This commit is contained in:
Rocky Kwok committed 2026-07-27 10:06:33 +08:00
commit fb73ae0471
220 files changed
+30857 -1735

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)→ 同浏览器另开的正常系统页不受影响。
@@ -89,7 +96,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
themes={["light", "dark", "lightblue"]}
themes={["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"]}
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
@@ -98,8 +105,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<DesensitizeWordsLoader />
{!positionStandaloneMode && <AuthSyncWatcher />}
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
@@ -117,17 +124,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 />} />
@@ -164,7 +192,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>
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<defs>
<style>
.hit { fill: rgba(255, 255, 255, 0.8); opacity: 0; }
.mark { fill: #fff; }
</style>
</defs>
<g transform="translate(-2434.135 -67.941)">
<rect class="hit" width="12" height="12" transform="translate(2434.135 67.941)"/>
<path class="mark" d="M.885,0,0,.885,3.692,4.577,4.135,5l.442-.423L8.269.885,7.385,0,4.135,3.25Z" transform="translate(2436 71.942)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 501 B

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<defs>
<style>
.background { fill: none; }
.mark { stroke: rgba(0, 0, 0, 0); stroke-miterlimit: 10; fill: url(#gradient); }
</style>
<linearGradient id="gradient" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#a2e1f9"/>
<stop offset="1" stop-color="#45c3f3"/>
</linearGradient>
</defs>
<rect class="background" width="16" height="16"/>
<path class="mark" d="M14554.5,10202.5a1,1,0,1,1,0-2h5a1,1,0,0,1,0,2Zm9.8-.482-2.95-2.157a.445.445,0,0,1-.187-.361.46.46,0,0,1,.187-.366l2.95-2.152a.45.45,0,0,1,.716.365v4.307a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,14564.3,10202.019Zm-9.8-3.521a1,1,0,1,1,0-2h5a1,1,0,1,1,0,2Zm0-4a1,1,0,1,1,0-2h10a1,1,0,1,1,0,2Z" transform="translate(-14551.5 -10189.5)"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

@@ -564,6 +564,37 @@
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;
}
/* Pale yellow has the same light-banner contrast requirement as ice blue. */
html.lightyellow .light-topnav-item {
color: rgba(48, 61, 35, 0.78);
}
html.lightyellow .light-topnav-item:hover,
html.lightyellow .light-topnav-item--active,
html.lightyellow .light-topnav-item--active:hover {
color: #303d23;
}
html.lightyellow .light-topnav-item--active::after {
background: #4c6330;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
"cursor-pointer bg-primary text-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90",
destructive:
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
+11 -9
View File
@@ -71,15 +71,17 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
+17 -13
View File
@@ -57,18 +57,21 @@ function useSidebarSafe() {
return React.useContext(SidebarContext);
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
disableToggle = false,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Keep a controlled deployment-level rail from being reopened by hotkeys. */
disableToggle?: boolean;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
@@ -92,10 +95,11 @@ function SidebarProvider({
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
if (disableToggle) return;
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [disableToggle, isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -24,12 +24,60 @@ const AGENT_COLORS_DARK = [
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
export function agentColor(id: string, isDark: boolean) {
/** 按 agent id 稳定散列出一对渐变色,并根据当前主题返回可读的图标前景色。 */
const AGENT_COLORS_ICEBLUE = [
{ from: "#eff8fd", to: "#b7dcf5" },
{ from: "#e3f2fb", to: "#9bcef1" },
{ from: "#ebf6fb", to: "#add8e9" },
{ from: "#edf2fc", to: "#b7cdef" },
{ from: "#e9f7f5", to: "#afe0dc" },
] as const;
const AGENT_COLORS_LIGHTYELLOW = [
{ from: "#fbfdef", to: "#e4efbf" },
{ from: "#f7fae9", to: "#F3FBD9" },
{ from: "#f9fbe8", to: "#dcebad" },
{ from: "#faf8e9", to: "#e7dfb9" },
{ from: "#f1f8e5", to: "#cce0af" },
] as const;
type AgentAvatarTheme =
| "dark"
| "iceblue"
| "lightyellow"
| "light"
| "lightblue"
| "lightgreen"
| `custom-theme-${string}`;
/** Returns a stable avatar gradient and readable icon color for each theme. */
export function agentColor(id: string, theme: AgentAvatarTheme) {
if (theme.startsWith("custom-theme-")) {
return {
from: "var(--accent)",
to: "var(--brand-200)",
foreground: "var(--accent-foreground)",
};
}
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
const palette =
theme === "dark"
? AGENT_COLORS_DARK
: theme === "iceblue"
? AGENT_COLORS_ICEBLUE
: theme === "lightyellow"
? AGENT_COLORS_LIGHTYELLOW
: AGENT_COLORS_LIGHT;
return {
...palette[hash % palette.length]!,
foreground:
theme === "iceblue"
? "#2d587a"
: theme === "lightyellow"
? "#4c6330"
: "#ffffff",
};
}
@@ -254,7 +254,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
{agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<Link
key={agent.id}
@@ -279,6 +279,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
)}
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
color: foreground,
}}
>
<Bot className="size-[19px]" />
@@ -206,7 +206,7 @@ export function AgentModuleSidebar({
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<Link
key={`${keyPrefix}:${agent.id}`}
@@ -236,7 +236,10 @@ export function AgentModuleSidebar({
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
color: foreground,
}}
>
<Bot className="size-[19px]" />
</span>
@@ -1,7 +1,7 @@
"use client";
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -15,6 +15,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -26,10 +28,13 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
*/
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(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: "lightyellow", label: "浅黄色", color: "#F3FBD9" },
{ id: "dark", label: "深蓝色", color: "#002466" },
];
@@ -79,8 +84,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme();
const { data: appearance } = useAppearanceSettings();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const swatches = useMemo(
() => [
...THEME_SWATCHES,
...(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => ({
id: preset.id as ThemeType,
label: preset.name,
color: preset.primary,
})),
],
[appearance?.custom_themes],
);
const cancelClose = () => {
if (closeTimer.current) {
@@ -95,6 +115,8 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
useEffect(() => () => cancelClose(), []);
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
return (
<div
className="relative"
@@ -117,9 +139,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
{THEME_SWATCHES.map((swatch) => {
<div className="relative flex max-w-64 flex-wrap items-center justify-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
{swatches.map((swatch) => {
const active = theme === swatch.id;
return (
<button
@@ -185,6 +207,8 @@ export function LayoutModeSwitcher({
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -11,6 +11,7 @@ import {
LoaderIcon,
PackageIcon,
SaveIcon,
SparklesIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
@@ -37,6 +38,7 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
@@ -121,13 +123,14 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: {
className?: string;
filepath: string;
threadId: string;
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
hideFileName = false,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
@@ -140,9 +143,10 @@ export function ArtifactFileDetail({
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) {
*/
disableDesensitize?: boolean;
hideFileName?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
@@ -214,12 +218,17 @@ export function ArtifactFileDetail({
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。
const canUseAiEditor = canEditArtifact;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isAutoPreviewLanguage = language === "html" || language === "markdown";
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isSpreadsheetFile) return "XLSX";
@@ -231,7 +240,7 @@ export function ArtifactFileDetail({
return "FILE";
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
@@ -258,28 +267,36 @@ export function ArtifactFileDetail({
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
return;
}
if (isSupportPreview) {
setViewMode("preview");
return;
}
setViewMode("code");
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
useEffect(() => {
if (!allowAutoViewSwitch || language !== "html") {
return;
}
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setShowPreviewSwitchPulse(false);
}, [filepathFromProps]);
useEffect(() => {
if (isPreviewStreaming) {
setViewMode("code");
return;
}
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
return;
}
if (isSupportPreview) {
setViewMode("preview");
return;
}
setViewMode("code");
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
useEffect(() => {
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
return;
}
if (thread.isLoading) {
setViewMode("code");
@@ -291,9 +308,9 @@ export function ArtifactFileDetail({
const timeoutId = window.setTimeout(() => {
setShowPreviewSwitchPulse(false);
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, language, thread.isLoading]);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
const scrollArtifactToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -576,8 +593,11 @@ export function ArtifactFileDetail({
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
</span>
<ArtifactTitle className="min-w-0">
{useWriteFileStreaming ? (
{!hideFileName && (
<ArtifactTitle className="min-w-0">
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
@@ -597,7 +617,8 @@ export function ArtifactFileDetail({
) : (
<div className="truncate px-3">{getFileName(filepath)}</div>
)}
</ArtifactTitle>
</ArtifactTitle>
)}
</div>
</div>
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
@@ -617,10 +638,19 @@ export function ArtifactFileDetail({
onValueChange={(value) => {
if (value) {
setAllowAutoViewSwitch(false);
setViewMode(value as "code" | "preview");
setViewMode(value as "code" | "preview" | "edit");
}
}}
>
{canUseAiEditor && (
<ToggleGroupItem
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
value="edit"
aria-label="AI 写作编辑"
>
<SparklesIcon />
</ToggleGroupItem>
)}
<ToggleGroupItem
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
value="code"
@@ -634,10 +664,10 @@ export function ArtifactFileDetail({
<EyeIcon />
</ToggleGroupItem>
</ToggleGroup>
{isHtmlStreaming && (
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
正在生成,完成后自动预览
{isPreviewStreaming && (
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
正在生成,完成后自动预览
</div>
)}
</div>
@@ -781,6 +811,17 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
markdown={displayContent ?? ""}
onChange={(value) => {
setDraftContent(value);
setHasLocalEdits(true);
}}
threadId={threadId}
/>
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" ||
@@ -23,28 +23,34 @@ import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { SkillInstallDialog } from "./skill-install-dialog";
export function ArtifactFileList({
className,
files,
threadId,
}: {
className?: string;
files: string[];
threadId: string;
}) {
export function ArtifactFileList({
className,
files,
threadId,
onOpenArtifact,
}: {
className?: string;
files: string[];
threadId: string;
onOpenArtifact?: (filepath: string) => void;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installFile, setInstallFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const handleClick = useCallback(
(filepath: string) => {
selectArtifact(filepath);
setOpen(true);
},
[selectArtifact, setOpen],
);
const handleClick = useCallback(
(filepath: string) => {
if (onOpenArtifact) {
onOpenArtifact(filepath);
return;
}
selectArtifact(filepath);
setOpen(true);
},
[onOpenArtifact, selectArtifact, setOpen],
);
const handleInstallSkill = useCallback(
(e: React.MouseEvent, filepath: string) => {
@@ -423,6 +423,7 @@ const ChatBox: React.FC<{
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
@@ -437,6 +438,7 @@ const ChatBox: React.FC<{
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
@@ -318,7 +318,7 @@ export function GeneralQaSidebar() {
onClick={handleNewChatClick}
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
className={cn(
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
"gq-new-chat-button flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
"border border-solid",
"bg-[rgb(var(--legacy-primary-rgb)/14%)] hover:bg-[rgb(var(--legacy-primary-rgb)/24%)]",
)}
@@ -342,7 +342,7 @@ export function GeneralQaSidebar() {
<div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const { from, to } = agentColor(agent.id, isDark);
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<button
key={agent.id}
@@ -366,7 +366,10 @@ export function GeneralQaSidebar() {
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
color: foreground,
}}
>
<Bot className="size-[19px]" />
</span>
@@ -464,9 +467,9 @@ export function GeneralQaSidebar() {
<Link
href={href}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
@@ -1172,7 +1172,7 @@ export function InputBox({
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted",
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
@@ -135,11 +135,12 @@ export function MessageListItem({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
extraRehypePlugins,
onEditResubmit,
onEditSave,
onMessageDeleted,
turnTime,
}: {
className?: string;
fitRowToContent?: boolean;
/**
@@ -151,10 +152,11 @@ export function MessageListItem({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
onMessageDeleted?: (deletedIds: string[]) => void;
/**
* Durable ask time of the turn this message belongs to (the preceding
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
@@ -162,14 +164,102 @@ export function MessageListItem({
* wall clock at render time and appear to "drift" to now on every reload.
*/
turnTime?: string;
}) {
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
'';
return (
<AIElementMessage
}) {
const { t } = useI18n();
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
'';
const [assistantEditing, setAssistantEditing] = useState(false);
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
const [assistantSaving, setAssistantSaving] = useState(false);
useEffect(() => {
if (!assistantEditing) {
setAssistantDraft(messageMarkdown);
}
}, [assistantEditing, messageMarkdown]);
const canSaveAssistantEdit =
!isLoading &&
!isHuman &&
!!onEditSave &&
!!message.id &&
!message.id.startsWith('opt-') &&
!!messageMarkdown;
const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) {
return;
}
setAssistantSaving(true);
try {
await onEditSave(message.id, value);
setAssistantEditing(false);
} catch {
// The page-level save callback owns user-facing error toasts. Keep the
// editor open so the user can adjust and retry.
} finally {
setAssistantSaving(false);
}
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
if (!isHuman && assistantEditing) {
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
anchorId && 'scroll-mt-20',
className,
)}
from="assistant"
>
<AIElementMessageContent className="w-full">
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
<Textarea
value={assistantDraft}
autoFocus
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void confirmAssistantEdit();
}
if (event.key === "Escape") {
event.preventDefault();
setAssistantEditing(false);
}
}}
className="min-h-32 resize-y text-[15px] leading-7"
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={assistantSaving}
onClick={() => setAssistantEditing(false)}
>
{t.common.cancel}
</Button>
<Button
size="sm"
disabled={!assistantDraft.trim() || assistantSaving}
onClick={() => void confirmAssistantEdit()}
>
{assistantSaving ? "保存中..." : t.common.save}
</Button>
</div>
</div>
</AIElementMessageContent>
</AIElementMessage>
);
}
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
@@ -187,11 +277,12 @@ export function MessageListItem({
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={isLoading ? undefined : onEditSave}
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
{/*
Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the
@@ -206,15 +297,28 @@ export function MessageListItem({
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
)}
</div>
</MessageToolbar>
)}
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setAssistantEditing(true)}
>
<PencilIcon size={11} />
</Button>
</Tooltip>
)}
</div>
</MessageToolbar>
)}
</AIElementMessage>
);
}
@@ -228,26 +332,31 @@ export function MessageListItem({
* the question; confirming sends the edited text as a brand-new turn (the
* original question stays in the thread).
*/
function HumanMessageBlock({
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
deleteButton,
}: {
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
deleteButton?: React.ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
function HumanMessageBlock({
messageId,
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
onEditSave,
deleteButton,
}: {
messageId?: string;
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: React.ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) {
@@ -255,14 +364,27 @@ function HumanMessageBlock({
}
}, [text, editing]);
const confirmEdit = useCallback(() => {
const value = draft.trim();
if (!value) {
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, onEditResubmit]);
const confirmEdit = useCallback(async () => {
const value = draft.trim();
if (!value || saving) {
return;
}
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
setSaving(true);
try {
await onEditSave(messageId, value);
setEditing(false);
} catch {
// The page-level save callback owns user-facing error toasts. Keep the
// editor open so the user can adjust and retry.
} finally {
setSaving(false);
}
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
if (editing) {
return (
@@ -275,10 +397,10 @@ function HumanMessageBlock({
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
confirmEdit();
}
if (event.key === "Escape") {
event.preventDefault();
void confirmEdit();
}
if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
@@ -286,17 +408,22 @@ function HumanMessageBlock({
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
{t.common.confirm}
</Button>
</div>
</div>
size="sm"
variant="ghost"
disabled={saving}
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button
size="sm"
onClick={() => void confirmEdit()}
disabled={!draft.trim() || saving}
>
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
</Button>
</div>
</div>
);
}
@@ -324,11 +451,11 @@ function HumanMessageBlock({
{(text || deleteButton) && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
{text && <CopyButton clipboardData={text} className="size-6" />}
{text && onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
{text && (onEditResubmit || onEditSave) && (
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setEditing(true)}
@@ -396,22 +523,24 @@ function MessageContent_({
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
extraRehypePlugins,
onEditResubmit,
onEditSave,
onMessageDeleted,
turnTime,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) {
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Streamdown splits content into per-block processors; calling the factory
@@ -555,14 +684,16 @@ function MessageContent_({
const canDelete =
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
return (
<HumanMessageBlock
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
deleteButton={
<HumanMessageBlock
messageId={message.id}
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
deleteButton={
canDelete ? (
<DeleteMessageButton
threadId={threadId}
@@ -97,14 +97,18 @@ export function MessageList({
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
}: {
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onOpenArtifact,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -115,15 +119,19 @@ export function MessageList({
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onOpenArtifact?: (filepath: string) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -155,17 +163,33 @@ export function MessageList({
});
}, []);
const allMessages = useMemo(
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
[thread.messages],
);
const messages = useMemo(
() =>
hiddenMessageIds.size === 0
? allMessages
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
[allMessages, hiddenMessageIds],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const askTimes = useChatAskTimes();
@@ -380,10 +404,11 @@ export function MessageList({
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
@@ -488,7 +513,11 @@ export function MessageList({
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -7,10 +7,13 @@ import {
ExternalLink,
LayoutGridIcon,
LayoutIcon,
LeafIcon,
ListIcon,
MoonIcon,
PanelLeftIcon,
PaletteIcon,
Plus,
SnowflakeIcon,
SunIcon,
Trash2,
} from "lucide-react";
@@ -18,7 +21,7 @@ import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
@@ -61,16 +64,17 @@ import {
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
{ value: "en-US", label: enUS.locale.localName },
{ value: "zh-CN", label: zhCN.locale.localName },
];
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
@@ -174,9 +178,21 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault,
);
if (customTheme) return customTheme.name;
if (globalDefault === "light") return "浅色";
if (globalDefault === "dark") return "深色";
if (globalDefault === "lightgreen") return "绿色";
if (globalDefault === "iceblue") return "冰川蓝";
if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝";
}, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
@@ -211,14 +227,24 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
}
};
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue",
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
) => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
const label =
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
const label =
value === "light"
? "浅色"
: value === "dark"
? "深色"
: value === "lightgreen"
? "绿色"
: value === "iceblue"
? "冰川蓝"
: value === "lightyellow"
? "浅黄色"
: "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
@@ -368,20 +394,44 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description: t.settings.appearance.darkDescription,
icon: MoonIcon,
},
{
id: "lightblue",
label: t.settings.appearance.lightblue,
description: t.settings.appearance.lightblueDescription,
icon: DropletIcon,
},
{
id: "lightblue",
label: t.settings.appearance.lightblue,
description: t.settings.appearance.lightblueDescription,
icon: DropletIcon,
},
{
id: "lightgreen",
label: t.settings.appearance.lightgreen,
description: t.settings.appearance.lightgreenDescription,
icon: LeafIcon,
},
{
id: "iceblue",
label: t.settings.appearance.iceblue,
description: t.settings.appearance.iceblueDescription,
icon: SnowflakeIcon,
},
{
id: "lightyellow",
label: t.settings.appearance.lightyellow,
description: t.settings.appearance.lightyellowDescription,
icon: SunIcon,
},
],
[
t.settings.appearance.dark,
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription,
t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription,
t.settings.appearance.lightgreen,
t.settings.appearance.lightgreenDescription,
t.settings.appearance.iceblue,
t.settings.appearance.iceblueDescription,
t.settings.appearance.lightyellow,
t.settings.appearance.lightyellowDescription,
],
);
@@ -392,18 +442,34 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
icon={option.icon}
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue"}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"}
onSelect={(value) => setTheme(value)}
/>
))}
{(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => (
<ThemePreviewCard
key={preset.id}
icon={PaletteIcon}
label={preset.name}
description={`自定义主题 · 主色 ${preset.primary}`}
active={currentTheme === preset.id}
mode={preset.id}
previewDark={preset.mode === "dark"}
accentColor={preset.primary}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
@@ -541,30 +607,80 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<MoonIcon className="mr-1.5 size-4" />
深色
</Button>
<Button
type="button"
variant={globalDefault === "lightblue" ? "default" : "outline"}
<Button
type="button"
variant={globalDefault === "lightblue" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightblue")}
disabled={updateAppearance.isPending}
>
<DropletIcon className="mr-1.5 size-4" />
浅蓝
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light"
? "浅色"
: globalDefault === "dark"
? "深色"
: "浅蓝"}
</p>
浅蓝
</Button>
<Button
type="button"
variant={globalDefault === "lightgreen" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightgreen")}
disabled={updateAppearance.isPending}
>
<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>
<Button
type="button"
variant={globalDefault === "lightyellow" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightyellow")}
disabled={updateAppearance.isPending}
>
<SunIcon className="mr-1.5 size-4" />
浅黄色
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document
.getElementById("custom-theme-library")
?.scrollIntoView({ behavior: "smooth", block: "start" })
}
>
<PaletteIcon className="mr-1.5 size-4" />
管理自定义主题
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{globalDefaultLabel}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
</>
)}
{isAdmin && (
<div id="custom-theme-library" className="scroll-mt-4">
<Separator />
<SettingsSection
title="自定义主题库(管理员)"
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
>
<CustomThemeLibrarySettings />
</SettingsSection>
</div>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
@@ -1065,19 +1181,24 @@ function CompactShortcutSettings({
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "light" | "dark" | "lightblue";
onSelect: (mode: "light" | "dark" | "lightblue") => void;
}) {
const previewMode = mode;
description,
active,
mode,
previewDark,
accentColor,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: ThemeType;
previewDark?: boolean;
accentColor?: string;
onSelect: (mode: ThemeType) => void;
}) {
const previewMode = mode;
const isDarkPreview = previewDark ?? previewMode === "dark";
return (
<button
type="button"
@@ -1103,23 +1224,38 @@ function ThemePreviewCard({
<div
className={cn(
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
: "border-slate-200 bg-white text-slate-900",
isDarkPreview
? "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 === "iceblue"
? "border-sky-200 bg-[#F7FBFE] text-[#18324A]"
: previewMode === "lightyellow"
? "border-lime-200 bg-[#FCFDF5] text-[#303D23]"
: "border-slate-200 bg-white text-slate-900",
)}
>
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
<div
className={cn(
"h-2 w-2 rounded-full",
previewMode === "dark"
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: "bg-emerald-500",
)}
accentColor
? ""
: isDarkPreview
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
style={accentColor ? { backgroundColor: accentColor } : undefined}
/>
<div className="h-2 w-10 rounded-full bg-current/20" />
<div className="h-2 w-6 rounded-full bg-current/15" />
@@ -0,0 +1,753 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
CheckIcon,
MoonIcon,
PaletteIcon,
PlusIcon,
RotateCcwIcon,
SaveIcon,
SlidersHorizontalIcon,
SunIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react";
import { ColorPickerPanel } from "tdesign-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
import {
useAppearanceSettings,
useUpdateAppearanceSettings,
} from "@/core/system-settings/hooks";
import {
CUSTOM_THEME_ADVANCED_KEYS,
getCustomThemeAutomaticTokens,
getCustomThemeTokens,
normalizeHexColor,
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
const SUGGESTED_COLORS = [
"#9BCEF1",
"#F3FBD9",
"#7AB8F5",
"#8ED1B2",
"#C6B4F6",
"#F3C5A6",
"#F3B1B7",
"#63C4C8",
];
const TEXT_COLORS = ["#202A3A", "#172033", "#2D587A", "#4C6330", "#F4F7FB", "#FFFFFF"];
const ADVANCED_COLOR_FIELDS: Array<{
key: CustomThemeAdvancedKey;
label: string;
description: string;
token: string;
}> = [
{
key: "button_hover",
label: "按钮悬停色",
description: "主按钮悬停时的背景色",
token: "--theme-button-hover",
},
{
key: "selected_background",
label: "选中背景",
description: "左侧菜单、卡片选中时的浅背景",
token: "--theme-selected-background",
},
{
key: "border",
label: "边框色",
description: "卡片、输入框和分隔线",
token: "--border",
},
{
key: "muted",
label: "弱化背景",
description: "次级区域与弱化容器",
token: "--muted",
},
{
key: "muted_text",
label: "辅助文字",
description: "说明、占位和次要信息文字",
token: "--muted-foreground",
},
{
key: "destructive",
label: "删除/危险色",
description: "删除按钮、错误与危险操作的颜色",
token: "--destructive",
},
{
key: "brand_title",
label: "栏目标题文字",
description: "业务链、分析记录等带图标栏目标题",
token: "--brand-title-foreground",
},
{
key: "primary_foreground",
label: "主色块文字(Logo/状态标签)",
description: "Logo 的 C、启用状态和默认标签;不影响主按钮文字",
token: "--primary-foreground",
},
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
return `custom-theme-${suffix}`;
}
function newCustomTheme(order: number): CustomThemePreset {
return {
id: makeThemeId(),
name: `自定义主题 ${order + 1}`,
primary: "#9BCEF1",
text_color: null,
mode: "light",
overrides: {},
enabled: true,
sort_order: order,
};
}
function normalizeColorOverrides(
overrides: CustomThemeColorOverrides | undefined,
): CustomThemeColorOverrides {
const normalized: CustomThemeColorOverrides = {};
for (const key of CUSTOM_THEME_ADVANCED_KEYS) {
const value = overrides?.[key];
if (typeof value === "string" && value.trim()) {
normalized[key] = normalizeHexColor(value);
}
}
return normalized;
}
function normalizeThemeDrafts(
themes: CustomThemePreset[] | undefined,
): CustomThemePreset[] {
return [...(themes ?? [])]
.sort((a, b) => a.sort_order - b.sort_order)
.map((theme, index) => ({
...theme,
id: theme.id || makeThemeId(),
name: theme.name || `自定义主题 ${index + 1}`,
primary: normalizeHexColor(theme.primary),
text_color:
typeof theme.text_color === "string" && theme.text_color.trim()
? normalizeHexColor(theme.text_color)
: null,
mode: theme.mode === "dark" ? "dark" : "light",
overrides: normalizeColorOverrides(theme.overrides),
enabled: theme.enabled !== false,
sort_order: index,
}));
}
/**
* Render the TDesign panel inside the settings dialog instead of its default
* body-level popup. The dialog otherwise treats clicks inside that portal as
* outside interactions and closes the picker while a colour is being chosen.
*/
function InlineThemeColorPicker({
value,
fallback,
swatchColors,
onCommit,
ariaLabel = "选择颜色",
}: {
value: string;
fallback?: string;
swatchColors: string[];
onCommit: (value: string) => void;
ariaLabel?: string;
}) {
const normalizedValue = normalizeHexColor(value, fallback);
const [pendingValue, setPendingValue] = useState(normalizedValue);
const [open, setOpen] = useState(false);
const panelId = useId();
useEffect(() => {
if (!open) setPendingValue(normalizedValue);
}, [normalizedValue, open]);
return (
<div className="w-fit">
<button
type="button"
className="flex h-10 min-w-32 items-center gap-2 rounded-md border bg-background px-2 font-mono text-sm shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((visible) => !visible)}
>
<span
className="size-7 rounded border"
style={{ backgroundColor: pendingValue }}
aria-hidden
/>
{pendingValue}
</button>
{open && (
<div
id={panelId}
className="mt-2 w-fit rounded-lg border bg-background p-2 shadow-lg"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ColorPickerPanel
value={pendingValue}
format="HEX"
colorModes={["monochrome"]}
enableAlpha={false}
swatchColors={swatchColors}
onChange={(color) => setPendingValue(normalizeHexColor(String(color), fallback))}
/>
<div className="mt-2 flex justify-end gap-2 border-t pt-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setPendingValue(normalizedValue);
setOpen(false);
}}
>
取消
</Button>
<Button
type="button"
size="sm"
onClick={() => {
setOpen(false);
onCommit(pendingValue);
}}
>
应用颜色
</Button>
</div>
</div>
)}
</div>
);
}
function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
const tokens = getCustomThemeTokens(theme) as CSSProperties;
const buttonContrast = customThemeTextContrast(theme);
const isButtonContrastLow = buttonContrast < 4.5;
return (
<div
className="overflow-hidden rounded-lg border"
style={tokens}
aria-label={`${theme.name} 的颜色预览`}
>
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--background)] px-3 py-2">
<span
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
aria-hidden
>
C
</span>
<span className="text-xs font-semibold text-[var(--foreground)]">
实时预览
</span>
<span
className={cn(
"ml-auto text-[11px]",
isButtonContrastLow
? "text-amber-700 dark:text-amber-300"
: "text-[var(--muted-foreground)]",
)}
>
主按钮对比度 {buttonContrast.toFixed(1)}:1
</span>
</div>
<div className="grid gap-2 bg-[var(--background)] p-3 sm:grid-cols-[1fr_auto]">
<div className="rounded-md border border-[var(--border)] bg-[var(--card)] p-2.5">
<div className="text-xs font-semibold text-[var(--brand-title-foreground)]">
{theme.name || "未命名主题"}
</div>
<div className="mt-1 text-[11px] text-[var(--muted-foreground)]">
页面正文自动生成;主按钮文字可单独指定。
</div>
</div>
<button
type="button"
className="rounded-md bg-[var(--primary)] px-3 py-2 text-xs font-semibold text-[var(--button-foreground,var(--primary-foreground))] shadow-sm"
tabIndex={-1}
>
主按钮
</button>
{isButtonContrastLow && (
<p className="sm:col-span-2 text-[11px] text-amber-700 dark:text-amber-300">
当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。
</p>
)}
</div>
</div>
);
}
function AdvancedThemeColorControls({
theme,
onPatch,
}: {
theme: CustomThemePreset;
onPatch: (patch: Partial<CustomThemePreset>) => void;
}) {
const automaticTokens = getCustomThemeAutomaticTokens({
primary: theme.primary,
mode: theme.mode,
});
const setOverride = (key: CustomThemeAdvancedKey, value: string) => {
const overrides = { ...(theme.overrides ?? {}) };
if (!value.trim()) {
delete overrides[key];
} else {
overrides[key] = value;
}
onPatch({ overrides });
};
return (
<div className="space-y-3 rounded-lg border border-dashed bg-muted/15 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-sm font-medium">更多配色</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。
</p>
</div>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
已覆盖 {Object.keys(theme.overrides ?? {}).length} 项
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{ADVANCED_COLOR_FIELDS.map((field) => {
const explicit = theme.overrides?.[field.key];
const automatic = automaticTokens[field.token] ?? "#0062D9";
const value = explicit ?? automatic;
return (
<div key={field.key} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold">{field.label}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{field.description}
</div>
</div>
{explicit ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setOverride(field.key, "")}
>
<RotateCcwIcon className="mr-1 size-3.5" />
恢复自动
</Button>
) : (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
自动
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-2">
<InlineThemeColorPicker
value={value}
swatchColors={SUGGESTED_COLORS}
onCommit={(color) => setOverride(field.key, color)}
ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`}
/>
<Input
value={value}
maxLength={7}
className="h-8 w-28 font-mono text-xs uppercase"
onChange={(event) => setOverride(field.key, event.target.value)}
onBlur={(event) =>
setOverride(
field.key,
event.target.value.trim()
? normalizeHexColor(event.target.value)
: "",
)
}
aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`}
/>
</div>
{!explicit && (
<p className="mt-2 text-[11px] text-muted-foreground">
当前自动值:{automatic}
</p>
)}
</div>
);
})}
</div>
</div>
);
}
export function CustomThemeLibrarySettings() {
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const [drafts, setDrafts] = useState<CustomThemePreset[]>([]);
const [advancedThemeIds, setAdvancedThemeIds] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setDrafts(normalizeThemeDrafts(appearance?.custom_themes));
}, [appearance?.custom_themes]);
const hasChanges = useMemo(
() =>
JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !==
JSON.stringify(normalizeThemeDrafts(drafts)),
[appearance?.custom_themes, drafts],
);
const patchTheme = (id: string, patch: Partial<CustomThemePreset>) => {
setDrafts((items) =>
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
};
const toggleAdvancedTheme = (id: string) => {
setAdvancedThemeIds((ids) => {
const next = new Set(ids);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({
...theme,
name: theme.name.trim(),
primary: normalizeHexColor(theme.primary),
}));
if (cleaned.some((theme) => !theme.name)) {
toast.error("请为每个主题填写名称");
return;
}
const names = cleaned.map((theme) => theme.name.toLocaleLowerCase());
if (new Set(names).size !== names.length) {
toast.error("主题名称不能重复");
return;
}
try {
await updateAppearance.mutateAsync({ custom_themes: cleaned });
toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存主题库失败");
}
};
const handleSetDefault = async (theme: CustomThemePreset) => {
if (!theme.enabled || appearance?.default_theme === theme.id) return;
if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) {
toast.info("请先保存主题库,再将新主题设为系统默认");
return;
}
try {
await updateAppearance.mutateAsync({ default_theme: theme.id });
toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "设置系统默认主题失败");
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。
</p>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
setDrafts((items) =>
items.length >= MAX_CUSTOM_THEMES
? items
: [...items, newCustomTheme(items.length)],
)
}
disabled={drafts.length >= MAX_CUSTOM_THEMES || updateAppearance.isPending}
>
<PlusIcon className="mr-1.5 size-4" />
添加主题
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSave()}
disabled={!hasChanges || updateAppearance.isPending}
>
<SaveIcon className="mr-1.5 size-4" />
保存主题库
</Button>
</div>
</div>
{drafts.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-5 py-8 text-center">
<PaletteIcon className="mx-auto mb-2 size-5 text-muted-foreground" />
<div className="text-sm font-medium">还没有自定义主题</div>
<p className="mt-1 text-xs text-muted-foreground">
添加后,用户可以从右上角主题切换器中自行选择。
</p>
</div>
) : (
<div className="space-y-4">
{drafts.map((theme, index) => {
const isDefault = appearance?.default_theme === theme.id;
const advancedOpen = advancedThemeIds.has(theme.id);
const automaticTokens = getCustomThemeAutomaticTokens({
primary: theme.primary,
mode: theme.mode,
});
const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A";
return (
<div key={theme.id} className="space-y-4 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<PaletteIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">主题 {index + 1}</span>
{isDefault && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<CheckIcon className="size-3" />
系统默认
</span>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={theme.enabled}
onCheckedChange={(enabled) => patchTheme(theme.id, { enabled })}
aria-label={`启用${theme.name || "自定义主题"}`}
/>
{theme.enabled ? "启用" : "停用"}
</label>
<Button
type="button"
size="sm"
variant={isDefault ? "default" : "outline"}
onClick={() => void handleSetDefault(theme)}
disabled={!theme.enabled || isDefault || hasChanges || updateAppearance.isPending}
>
设为默认
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => toggleAdvancedTheme(theme.id)}
aria-expanded={advancedOpen}
aria-controls={`${theme.id}-advanced-colors`}
>
<SlidersHorizontalIcon className="mr-1.5 size-4" />
更多
{advancedOpen ? (
<ChevronUpIcon className="ml-1 size-3.5" />
) : (
<ChevronDownIcon className="ml-1 size-3.5" />
)}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() =>
setDrafts((items) =>
items
.filter((item) => item.id !== theme.id)
.map((item, order) => ({ ...item, sort_order: order })),
)
}
title="删除主题"
aria-label={`删除${theme.name || "自定义主题"}`}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(180px,0.55fr)_150px_150px]">
<label className="space-y-1.5">
<span className="text-xs font-medium">主题名称</span>
<Input
value={theme.name}
maxLength={32}
onChange={(event) => patchTheme(theme.id, { name: event.target.value })}
placeholder="例如:品牌浅蓝"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium">基础模式</span>
<Select
value={theme.mode}
onValueChange={(value) =>
patchTheme(theme.id, {
mode: value === "dark" ? "dark" : "light",
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">
<span className="inline-flex items-center gap-1.5"><SunIcon className="size-3.5" />浅色</span>
</SelectItem>
<SelectItem value="dark">
<span className="inline-flex items-center gap-1.5"><MoonIcon className="size-3.5" />深色</span>
</SelectItem>
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium">主色值</span>
<Input
value={theme.primary}
maxLength={7}
className="font-mono uppercase"
onChange={(event) => patchTheme(theme.id, { primary: event.target.value })}
onBlur={() =>
patchTheme(theme.id, {
primary: normalizeHexColor(theme.primary),
})
}
aria-label={`${theme.name || "自定义主题"}主色十六进制值`}
/>
</label>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">主按钮文字</span>
{theme.text_color ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[11px]"
onClick={() => patchTheme(theme.id, { text_color: null })}
>
自动
</Button>
) : (
<span className="text-[11px] text-muted-foreground">自动</span>
)}
</div>
<Input
value={textColor}
maxLength={7}
className="font-mono uppercase"
onChange={(event) =>
patchTheme(theme.id, { text_color: event.target.value })
}
onBlur={(event) =>
patchTheme(theme.id, {
text_color: event.target.value.trim()
? normalizeHexColor(event.target.value)
: null,
})
}
aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`}
/>
</div>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/15 p-3">
<div className="space-y-1.5">
<span className="text-xs font-medium">选择主色</span>
<InlineThemeColorPicker
value={theme.primary}
swatchColors={SUGGESTED_COLORS}
onCommit={(primary) => patchTheme(theme.id, { primary })}
ariaLabel={`${theme.name || "自定义主题"}主色选色器`}
/>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium">选择主按钮文字</span>
<InlineThemeColorPicker
value={textColor}
fallback="#202A3A"
swatchColors={TEXT_COLORS}
onCommit={(text_color) => patchTheme(theme.id, { text_color })}
ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`}
/>
</div>
<div className="flex flex-wrap gap-1.5 pb-0.5">
{SUGGESTED_COLORS.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-7 rounded-md border shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
normalizeHexColor(theme.primary) === color
? "ring-2 ring-primary ring-offset-2"
: "border-border",
)}
style={{ backgroundColor: color }}
onClick={() => patchTheme(theme.id, { primary: color })}
title={`使用 ${color}`}
aria-label={`使用主色 ${color}`}
/>
))}
</div>
</div>
{advancedOpen && (
<div id={`${theme.id}-advanced-colors`}>
<AdvancedThemeColorControls
theme={theme}
onPatch={(patch) => patchTheme(theme.id, patch)}
/>
</div>
)}
<CustomThemePreview theme={{ ...theme, primary: normalizeHexColor(theme.primary) }} />
</div>
);
})}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -9,10 +9,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useEffect, useState } from "react";
export function SkillDisplaySources({
highlightedReferenceId,
@@ -51,6 +53,9 @@ export function SourceCard({
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const cleanupWords =
@@ -73,6 +78,44 @@ export function SourceCard({
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => {
if (!open || !previewTarget) {
return;
}
let cancelled = false;
setContext(null);
setContextError(null);
setContextLoading(true);
const params = new URLSearchParams({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
throw new Error(readPreviewError(payload, response.status));
}
if (!cancelled) {
setContext(payload.data);
}
})
.catch((error: unknown) => {
if (!cancelled) {
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
}
})
.finally(() => {
if (!cancelled) {
setContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return (
<>
@@ -114,7 +157,14 @@ export function SourceCard({
)}
</div>
{source.url && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
previewTarget ? (
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
查看原文
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
</button>
@@ -141,7 +191,7 @@ export function SourceCard({
</DialogDescription>
)}
</div>
{source.url && (
{source.url && !previewTarget && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
@@ -155,7 +205,13 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{paragraphs.length > 0 && (
{previewTarget ? (
<WeKnoraSourceContextPanel
context={context}
loading={contextLoading}
error={contextError}
/>
) : paragraphs.length > 0 ? (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
@@ -163,8 +219,8 @@ export function SourceCard({
</p>
))}
</div>
)}
{source.url && (
) : null}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
@@ -175,6 +231,118 @@ export function SourceCard({
);
}
type WeKnoraPreviewTarget = {
knowledgeBaseId: string;
chunkId: string;
};
type WeKnoraSourceContext = {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
focus_chunk_id: string;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
};
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
}
function isWeKnoraSourceContextResponse(
value: unknown,
): value is { success: true; data: WeKnoraSourceContext } {
if (!value || typeof value !== "object") {
return false;
}
const data = (value as { data?: unknown }).data;
return (
(value as { success?: unknown }).success === true &&
!!data &&
typeof data === "object" &&
Array.isArray((data as WeKnoraSourceContext).chunks)
);
}
function readPreviewError(payload: unknown, status: number): string {
if (payload && typeof payload === "object") {
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === "string" && detail) {
return detail;
}
}
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
}
function WeKnoraSourceContextPanel({
context,
loading,
error,
}: {
context: WeKnoraSourceContext | null;
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" />
正在加载原文上下文…
</div>
);
}
if (error) {
return (
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
{error}
</div>
);
}
if (!context) {
return null;
}
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
{context.document_title && <span> · {context.document_title}</span>}
</div>
{context.chunks.map((chunk) => (
<section
key={chunk.id}
className={cn(
"rounded-lg border p-3 text-sm leading-relaxed",
chunk.is_focus
? "border-primary/50 bg-primary/5 shadow-sm"
: "bg-muted/20",
)}
>
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
{chunk.is_focus && (
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
检索命中
</span>
)}
</div>
<p className="whitespace-pre-wrap">{chunk.content}</p>
</section>
))}
</div>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -23,19 +23,19 @@ import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
"workspace-nav-item text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
// 「新对话」专属:默认仅主色文字/图标(显眼但不是选中态),命中 /chats/new 时才上主色底
const NEW_CHAT_CLASS =
"rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
"workspace-nav-new-chat rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
const NEW_CHAT_COMPACT_CLASS =
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
"workspace-compact-new-chat h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
"border border-[rgb(var(--legacy-primary-rgb)/18%)] " +
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商1</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -1,223 +1,223 @@
"use client";
import {
BellIcon,
BotIcon,
BrainIcon,
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
"use client";
import {
BellIcon,
BotIcon,
BrainIcon,
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
t,
}: {
isSidebarOpen: boolean;
t: ReturnType<typeof useI18n>["t"];
}) {
return isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<SettingsIcon className="size-4" />
<span>{t.workspace.settingsAndMore}</span>
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
</div>
) : (
<div className="flex size-full items-center justify-center">
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
</div>
);
}
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
setSettingsOpen(true);
}}
>
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/scheduled-tasks")
}
>
<CalendarClockIcon />
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
Settings2Icon,
SettingsIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
t,
}: {
isSidebarOpen: boolean;
t: ReturnType<typeof useI18n>["t"];
}) {
return isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<SettingsIcon className="size-4" />
<span>{t.workspace.settingsAndMore}</span>
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
</div>
) : (
<div className="flex size-full items-center justify-center">
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
</div>
);
}
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
setSettingsOpen(true);
}}
>
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/scheduled-tasks")
}
>
<CalendarClockIcon />
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{/* {isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
@@ -226,51 +226,51 @@ export function WorkspaceNavMenu() {
插件管理
</DropdownMenuItem>
)} */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton size="lg" className="pointer-events-none">
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
</>
);
}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton size="lg" className="pointer-events-none">
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
</>
);
}
@@ -20,11 +20,15 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
@@ -32,8 +36,26 @@ export function WorkspaceSidebar({
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
+159 -23
View File
@@ -8,13 +8,20 @@ import React, {
import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
import {
BUILTIN_THEME_IDS,
type BuiltinThemeId,
type CustomThemePreset,
} from '@/core/system-settings/api';
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
// 主题类型
export type ThemeType = 'dark' | 'light' | 'lightblue';
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
// 主题配置
export interface ThemeConfig {
type: ThemeType;
type: BuiltinThemeId;
name: string;
colors: {
background: string;
@@ -37,7 +44,7 @@ export interface ThemeConfig {
}
// 主题配置
const themes: Record<ThemeType, ThemeConfig> = {
const themes: Record<BuiltinThemeId, ThemeConfig> = {
dark: {
type: 'dark',
name: '深色主题',
@@ -104,6 +111,74 @@ const themes: Record<ThemeType, ThemeConfig> = {
overlay: 'black/30',
},
},
lightgreen: {
type: 'lightgreen',
name: '绿色主题',
colors: {
background: 'white',
backgroundSecondary: 'emerald-50',
text: 'emerald-950',
textSecondary: 'emerald-800',
primary: 'emerald-600',
primaryHover: 'emerald-500',
border: 'emerald-200',
accent: 'emerald-700',
success: 'emerald-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'emerald-50',
input: 'white',
inputBorder: 'emerald-200',
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',
},
},
lightyellow: {
type: 'lightyellow',
name: '浅黄色主题',
colors: {
background: 'lime-50',
backgroundSecondary: 'lime-100',
text: 'lime-950',
textSecondary: 'lime-800',
primary: 'lime-100',
primaryHover: 'lime-200',
border: 'lime-200',
accent: 'lime-100',
success: 'emerald-600',
warning: 'amber-600',
error: 'red-600',
card: 'white',
cardHover: 'lime-50',
input: 'white',
inputBorder: 'lime-200',
overlay: 'slate-900/30',
},
},
};
const STORAGE_KEY = 'strategy-theme';
@@ -114,13 +189,30 @@ const STORAGE_KEY = 'strategy-theme';
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
const CUSTOM_THEME_TOKEN_NAMES = Object.keys(
getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }),
);
function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId {
return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
function getEnabledCustomTheme(
id: string | null | undefined,
presets: CustomThemePreset[] | undefined,
): CustomThemePreset | undefined {
return isCustomThemeId(id)
? presets?.find((preset) => preset.id === id && preset.enabled)
: undefined;
}
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
if (isBuiltinTheme(saved)) return saved;
if (isCustomThemeId(saved)) return saved;
return 'light';
}
@@ -161,7 +253,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);
@@ -175,12 +269,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (
current &&
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue'
) {
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
@@ -211,19 +300,47 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
const effectiveTheme = forcedTheme ?? resolvedTheme;
const theme: ThemeType = mounted
? effectiveTheme === 'dark'
? 'dark'
: effectiveTheme === 'lightblue'
? 'lightblue'
: 'light'
: readPersistedTheme();
const persistedTheme = readPersistedTheme();
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
const customTheme = getEnabledCustomTheme(
requestedTheme,
appearance?.custom_themes,
);
const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme
: customTheme?.id ?? 'light';
useEffect(() => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// next-themes adds a class for every value passed to setTheme(). Custom
// themes use data attributes and CSS variables instead, so remove its
// transient class before applying the generated token set.
for (const className of [...root.classList]) {
if (isCustomThemeId(className)) {
root.classList.remove(className);
}
}
for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) {
root.style.removeProperty(tokenName);
}
root.removeAttribute('data-custom-theme');
if (customTheme) {
for (const [tokenName, tokenValue] of Object.entries(
getCustomThemeTokens(customTheme),
)) {
root.style.setProperty(tokenName, tokenValue);
}
root.setAttribute('data-theme', customTheme.id);
root.setAttribute('data-custom-theme', 'true');
root.setAttribute('theme-mode', customTheme.mode);
root.classList.toggle('dark', customTheme.mode === 'dark');
return;
}
if (theme === 'dark') {
root.setAttribute('theme-mode', 'dark');
root.classList.add('dark');
@@ -234,12 +351,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
root.removeAttribute('theme-mode');
root.classList.remove('dark');
if (theme === 'lightblue') {
root.setAttribute('data-theme', 'lightblue');
if (
theme === 'lightblue' ||
theme === 'lightgreen' ||
theme === 'iceblue' ||
theme === 'lightyellow'
) {
root.setAttribute('data-theme', theme);
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
}, [customTheme, theme]);
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
@@ -251,7 +373,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
markExplicitChoice();
};
const themeConfig = themes[theme];
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
return (
<ThemeContext.Provider
@@ -277,6 +399,20 @@ export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string =
return themeConfig.colors[colorKey];
};
/** Resolve custom runtime themes to the two visual modes expected by older
* integrations (reports, canvases and exported artifacts). */
export function themeToBaseMode(theme: ThemeType): 'light' | 'dark' {
if (theme === 'dark') return 'dark';
if (
isCustomThemeId(theme) &&
typeof document !== 'undefined' &&
document.documentElement.getAttribute('theme-mode') === 'dark'
) {
return 'dark';
}
return 'light';
}
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
+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 };
}
+26 -6
View File
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
return { content: text, url };
}
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
if (!response.ok) {
let detail = text.trim();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const parsedDetail =
typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (parsedDetail) {
detail = parsedDetail;
}
} catch {
// Keep the raw response text as the diagnostic detail.
}
throw new Error(
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
url: urlString,
+22 -20
View File
@@ -1,12 +1,12 @@
import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads";
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
@@ -16,22 +16,24 @@ export function urlOfArtifact({
}) {
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
}
+38 -207
View File
@@ -1,101 +1,19 @@
/**
* 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。
* 大屏绘制智能体的任务输入适配层。
*
* 设计意图:深链 `?taskId=` 模式下,后端提供「研判情报报告」接口
* GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`
* 返回该任务的多类情报内容;本页把它**提取成干净的结构化 JSON** 交给智能体分析
* → 产出 report-json → 绘制大屏(react / html)。
*
* 接口返回结构:`{ state, msg, data: [{ id, taskId, ..., contentJson, categoryType }] }`,
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
* 本模块 `extractSqReport` 把每条的 `contentJson` 反序列化、按 `categoryType` 归类,
* 拼成 `{ taskId, categories: { 敌情关键事件: [...], 决心建议: {...}, ... } }` 喂给智能体。
*
* ⚠️ 真实接口现在还**请求不到**:通过 `VITE_MOCK_SQ_REPORT=true`(见 `utils/request.ts`)
* 走后端给的样例假数据(`sq-report-mock.json`),机制与 cop-task-detail 假数据一致。
* 联调通过后把该 env 置 false 即走真实接口;接口/数据若有变只需改 `extractSqReport`。
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
* 结构化输入。
*/
import { requestCrudService } from "@/service/request-axios-service";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { getTaskReport } from "@/strategy-components/api/task-reports";
import { readRuntimeFlag } from "@/env";
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
/**
* 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。
* 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。
* ⚠️ 接口就绪后删除本常量及其兜底引用。
*/
export const DEMO_DASHBOARD_INPUT = {
taskId: "DEMO-涉台舆情",
taskName: "2026 第一季度涉台舆情态势研判",
taskContent:
"汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。",
overview:
"本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。",
period: "2026-01-01 ~ 2026-03-31",
kpis: {
总信息量: 1284000,
日均增速: "12%",
正向占比: "58%",
负向占比: "19%",
识别风险点: 7,
覆盖平台: 12,
},
channels: [
{ platform: "微博", share: 34 },
{ platform: "境外社媒(X/FB)", share: 27 },
{ platform: "短视频", share: 18 },
{ platform: "新闻门户", share: 13 },
{ platform: "论坛/贴吧", share: 8 },
],
narratives: [
{ theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" },
{ theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" },
{ theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" },
{ theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" },
],
actors: [
{ name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" },
{ name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" },
{ name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" },
{ name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" },
],
risks: [
{ label: "军事议题被恶意剪辑放大", level: "high" },
{ label: "境外智库联动制造紧张框架", level: "high" },
{ label: "个别大V情绪化带节奏", level: "mid" },
{ label: "短视频谣言二次传播", level: "mid" },
{ label: "节假日期间信息量骤增", level: "low" },
],
timeline: [
{ phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" },
{ phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" },
{ phase: "行为体画像", desc: "关键账号立场与影响力建模" },
{ phase: "风险研判", desc: "识别高危议题与传播链路" },
{ phase: "处置建议", desc: "分级响应与正向引导预案" },
],
weeklyVolume: [
{ week: "W1", value: 86 },
{ week: "W2", value: 92 },
{ week: "W3", value: 110 },
{ week: "W4", value: 134 },
{ week: "W5", value: 121 },
{ week: "W6", value: 158 },
],
};
/** 「研判情报报告」接口完整地址:`{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport`。 */
function sqReportUrl(): string {
const value = readRuntimeEnv("VITE_AIPI_CONSUMER_URL");
const base = typeof value === "string" ? value.replace(/\/+$/, "") : "";
return base ? `${base}/cop/selectSqReport` : "";
}
/** 把单条 `contentJson` 反序列化为对象/数组/原值(已是对象则原样返回)。 */
function parseContentJson(raw: unknown): unknown {
if (typeof raw !== "string") return raw ?? null;
const text = raw.trim();
@@ -103,11 +21,11 @@ function parseContentJson(raw: unknown): unknown {
try {
return JSON.parse(text);
} catch {
return text; // 解析失败就把原始字符串透传给智能体,让它自行理解
// 非 JSON 字符串仍透传给智能体,避免因单条异常让整份报告不可用。
return text;
}
}
/** 去掉 categoryType 末尾的「 JSON」后缀,作为归类键(如「敌情关键事件 JSON」→「敌情关键事件」)。 */
function categoryKey(categoryType: unknown, index: number): string {
const raw = typeof categoryType === "string" ? categoryType.trim() : "";
const cleaned = raw.replace(/\s*JSON\s*$/i, "").trim();
@@ -115,157 +33,70 @@ function categoryKey(categoryType: unknown, index: number): string {
}
/**
* 把 selectSqReport 的整包响应**提取**成喂给智能体的干净结构化输入:
* `{ taskId, source, categories: { [类目]: 反序列化后的内容 } }`
* 返回 null 表示无可用内容(调用方据此回退/不开跑)。
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
* 返回 null 表示该任务尚未保存可用的报告内容。
*/
export function extractSqReport(body: unknown, taskId: string): unknown | null {
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
| Array<Record<string, unknown>>
| undefined;
if (!Array.isArray(data) || data.length === 0) return null;
const categories: Record<string, unknown> = {};
data.forEach((item, index) => {
const content = parseContentJson(item?.contentJson);
if (content == null) return;
let key = categoryKey(item?.categoryType, index);
if (key in categories) key = `${key}(${index + 1})`; // 同名类目去重保留
if (key in categories) key = `${key}(${index + 1})`;
categories[key] = content;
});
if (Object.keys(categories).length === 0) return null;
return { taskId, source: "cop/selectSqReport", categories };
return { taskId, source: "api/task-reports", categories };
}
/**
* 输入 JSON 缓存**仅供开发(mock)使用**:`VITE_MOCK_SQ_REPORT==='true'` 时才读/写缓存,
* 避免开发反复请求样例假数据。真实接口模式(false)下不读不写缓存,**始终拉取真实数据**。
*/
function inputCacheEnabled(): boolean {
return readRuntimeFlag("VITE_MOCK_SQ_REPORT");
}
/** 读取暂存的输入 JSON(仅 mock 开发模式生效,真实模式恒返回 null 以强制重新拉取)。 */
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
export function readDashboardInputJson(): unknown | null {
if (!inputCacheEnabled()) return null;
try {
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
return null;
}
/** 暂存输入 JSON(仅 mock 开发模式生效;隐私模式/quota 异常静默失败)。 */
export function persistDashboardInputJson(data: unknown): void {
if (!inputCacheEnabled()) return;
try {
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
} catch {
/* noop */
}
/** 兼容旧调用方:报告数据只由后端接口维护。 */
export function persistDashboardInputJson(_data: unknown): void {
// noop
}
/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */
function placeholderFromCopTask(taskId: string): unknown | null {
const detail = readCopTaskDetail();
if (!detail) return taskId ? { taskId } : null;
return {
taskId,
taskName: detail.taskName,
taskContent: detail.taskContent,
overview: detail.overview,
taskDirection: detail.taskDirection,
_note: "占位输入(真实业务 JSON 接口未就绪)",
};
}
/**
* 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。
*
* - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据——
* **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」,
* 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。
* - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求;
* 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。
*/
/** 查询并提取当前任务的报告详情;失败或空数据均返回 null。 */
export async function fetchDashboardInputJson(
taskId: string,
opts?: { forceRefresh?: boolean },
_opts?: { forceRefresh?: boolean },
): Promise<unknown | null> {
const mock = inputCacheEnabled(); // true=mock/开发;false=真实接口
// forceRefresh(重新分析/修改时):跳过缓存,强制重新请求接口拿**最新数据**(数据可能已变化)。
const cached = opts?.forceRefresh ? null : readDashboardInputJson(); // 真实模式恒 null(缓存仅开发用)
if (cached) return cached;
const url = sqReportUrl();
if (url && taskId) {
try {
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
// await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。
const body = await requestCrudService.get(url, { taskId });
const extracted = extractSqReport(body, taskId);
if (extracted) {
persistDashboardInputJson(extracted); // 真实模式 no-op
return extracted;
}
} catch {
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
}
if (!taskId) return null;
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
return extractSqReport(
{
...SQ_REPORT_MOCK_RESPONSE,
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId })),
},
taskId,
);
}
// 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。
if (!mock) return null;
// 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。
const placeholder = placeholderFromCopTask(taskId);
const detail = readCopTaskDetail();
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
if (resolved) persistDashboardInputJson(resolved);
return resolved;
const body = await getTaskReport(taskId);
return extractSqReport(body, taskId);
}
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
/**
* 「3q(研判情报报告)是否完成」状态:
* - ``complete`` = selectSqReport 数组里有有效内容;
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
*/
export type SqReportStatus = "complete" | "incomplete" | "unknown";
export interface SqReportCheckResult {
status: SqReportStatus;
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
report?: unknown;
}
/**
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
* - 有可用内容 → ``complete``(正常);
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
*
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
*/
/** rwfx 进入前检查:任务已有可解析报告则 complete,否则 incomplete。 */
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
if (!taskId) return { status: "unknown" };
const cfg = await fetchTaskDeeplinkConfig();
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
if (cfg.test) {
const report = extractSqReport(cfg.mock.sqReport, taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
}
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
const url = sqReportUrl();
if (!url) return { status: "unknown" };
try {
const body = await requestCrudService.get(url, { taskId });
const report = extractSqReport(body, taskId);
const report = await fetchDashboardInputJson(taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
} catch {
return { status: "unknown" };
+25 -7
View File
@@ -3,23 +3,33 @@ 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" | "dark";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
lightblue: fallbackLogoLightblue,
lightgreen: fallbackLogoLight,
iceblue: fallbackLogoLightblue,
lightyellow: fallbackLogoLight,
dark: fallbackLogoDark,
};
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",
lightyellow: "VITE_BRAND_LOGO_LIGHTYELLOW_URL",
dark: "VITE_BRAND_LOGO_DARK_URL",
};
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",
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
@@ -52,18 +62,26 @@ function readRuntimeBrandAsset(key: string): string | undefined {
}
}
export function getHeaderBrandAsset(theme: BrandTheme): string {
return readRuntimeBrandAsset(headerRuntimeKeys[theme]) ?? headerFallbackAssets[theme];
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
? "dark"
: "light";
}
export function getHeaderBrandFallback(theme: BrandTheme): string {
return headerFallbackAssets[theme];
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
const resolved = resolveBrandTheme(theme);
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
}
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[theme]);
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
@@ -145,6 +145,10 @@ export function isChromeHidden(): boolean {
return s?.embed === true || s?.hideChrome === true;
}
export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
+8 -2
View File
@@ -448,11 +448,17 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
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.",
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.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
+8 -2
View File
@@ -383,11 +383,17 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
languageTitle: string;
languageDescription: string;
};
+8 -2
View File
@@ -429,11 +429,17 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
@@ -80,6 +86,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{
id: "qa-research-chat",
label: "课题研究(对话版)",
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
],
},
{
+36 -1
View File
@@ -276,7 +276,40 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue";
export const BUILTIN_THEME_IDS = [
"light",
"dark",
"lightblue",
"lightgreen",
"iceblue",
"lightyellow",
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
muted_text?: string | null;
destructive?: string | null;
brand_title?: string | null;
primary_foreground?: string | null;
}
export interface CustomThemePreset {
id: `custom-theme-${string}`;
name: string;
primary: string;
text_color?: string | null;
mode: CustomThemeBaseMode;
overrides?: CustomThemeColorOverrides;
enabled: boolean;
sort_order: number;
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -304,6 +337,7 @@ export interface AppearanceSettings {
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
export interface AppearanceUpdate {
@@ -312,6 +346,7 @@ export interface AppearanceUpdate {
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -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)
+284
View File
@@ -0,0 +1,284 @@
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
export const CUSTOM_THEME_PREFIX = "custom-theme-";
type Rgb = { r: number; g: number; b: number };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
] as const;
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
export type CustomThemeTokenInput = Pick<
CustomThemePreset,
"primary" | "mode"
> &
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
}
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
const normalized = value.trim().toUpperCase();
return HEX_COLOR.test(normalized) ? normalized : fallback;
}
function optionalHexColor(value: string | null | undefined): string | undefined {
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
return value.trim().toUpperCase();
}
function toRgb(value: string): Rgb {
const hex = normalizeHexColor(value).slice(1);
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
function toHex({ r, g, b }: Rgb): string {
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const ratio = Math.min(1, Math.max(0, amount));
return {
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
};
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter! + 0.05) / (darker! + 0.05);
}
function readableForeground(background: Rgb): string {
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
}
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
let candidate = color;
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
candidate = mix(candidate, toward, 0.18);
}
return candidate;
}
function rgbChannels(color: Rgb): string {
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
}
/**
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
* Inputs are restricted to a safe six-digit primary colour, optional primary
* button text colour, a small set of interaction overrides and a light/dark surface
* family, so callers never interpolate arbitrary CSS values.
*/
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
const primary = toRgb(theme.primary);
const isDark = theme.mode === "dark";
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
// Normal page copy stays automatic so selecting white for a teal/blue
// action does not accidentally make a light-page's body text white.
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
const foregroundRgb = ensureContrast(
preferredForeground,
background,
toRgb(readableForeground(background)),
);
const foreground = toHex(foregroundRgb);
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
? ensureContrast(
toRgb(overrides.muted_text!),
background,
toRgb(readableForeground(background)),
3,
)
: defaultMutedForeground;
const mutedForeground = toHex(mutedForegroundRgb);
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
const accent = optionalHexColor(overrides.selected_background)
? toRgb(overrides.selected_background!)
: defaultAccent;
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
const action = ensureContrast(
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
isDark ? background : WHITE,
isDark ? WHITE : BLACK,
);
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
const ring = rgbChannels(primary);
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
const primaryHex = toHex(primary);
const actionHex = toHex(action);
const actionHoverHex = toHex(actionHover);
const actionActiveHex = toHex(actionActive);
const accentHex = toHex(accent);
const accentHoverHex = toHex(accentHover);
const backgroundHex = toHex(background);
const cardHex = toHex(card);
const borderHex = toHex(border);
const inputHex = toHex(input);
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const sidebarHex = toHex(sidebar);
return {
"--workspace-brand": primaryHex,
"--workspace-brand-foreground": primaryForeground,
"--brand-200": accentHex,
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
"--card": cardHex,
"--card-foreground": foreground,
"--popover": cardHex,
"--popover-foreground": foreground,
"--primary": primaryHex,
"--primary-foreground": primaryForeground,
"--button-foreground": buttonForeground,
"--brand-title-foreground": brandTitle,
"--secondary": secondaryHex,
"--secondary-foreground": actionHex,
"--muted": secondaryHex,
"--muted-foreground": mutedForeground,
"--accent": accentHex,
"--accent-foreground": actionHex,
"--destructive": destructive,
"--border": borderHex,
"--input": inputHex,
"--ring": `rgb(${ring} / 0.48)`,
"--chart-1": actionHex,
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
"--sidebar": sidebarHex,
"--sidebar-foreground": foreground,
"--sidebar-primary": primaryHex,
"--sidebar-primary-foreground": primaryForeground,
"--sidebar-accent": accentHex,
"--sidebar-accent-foreground": actionHex,
"--sidebar-border": borderHex,
"--sidebar-ring": `rgb(${ring} / 0.48)`,
"--legacy-accent-rgb": rgbChannels(accent),
"--legacy-background-rgb": rgbChannels(background),
"--legacy-border-rgb": rgbChannels(border),
"--legacy-brand-200-rgb": rgbChannels(accent),
"--legacy-card-rgb": rgbChannels(card),
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-input-rgb": rgbChannels(input),
"--legacy-muted-rgb": rgbChannels(secondary),
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
"--legacy-primary-rgb": rgbChannels(primary),
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
"--legacy-ring-rgb": ring,
"--legacy-secondary-rgb": rgbChannels(secondary),
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
"--legacy-sidebar-border-rgb": rgbChannels(border),
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-workspace-brand-rgb": rgbChannels(primary),
"--td-brand-color-1": backgroundHex,
"--td-brand-color-2": secondaryHex,
"--td-brand-color-3": accentHex,
"--td-brand-color-4": accentHoverHex,
"--td-brand-color-5": primaryHex,
"--td-brand-color-6": primaryHex,
"--td-brand-color-7": actionHex,
"--td-brand-color-8": actionHoverHex,
"--td-brand-color-9": actionActiveHex,
"--td-brand-color-10": actionActiveHex,
"--td-brand-color": primaryHex,
"--td-brand-color-hover": primaryHoverHex,
"--td-brand-color-focus": accentHex,
"--td-brand-color-active": primaryActiveHex,
"--td-brand-color-disabled": accentHoverHex,
"--td-brand-color-light": accentHex,
"--td-brand-color-light-hover": accentHoverHex,
"--td-text-color-primary": foreground,
"--td-text-color-secondary": mutedForeground,
"--td-text-color-brand": actionHex,
"--td-text-color-link": actionHex,
"--td-text-color-anti": buttonForeground,
"--td-bg-color-page": backgroundHex,
"--td-bg-color-container": cardHex,
"--td-bg-color-container-hover": secondaryHex,
"--td-bg-color-container-active": accentHex,
"--td-bg-color-secondarycontainer": secondaryHex,
"--td-bg-color-secondarycontainer-hover": accentHex,
"--td-bg-color-secondarycontainer-active": accentHoverHex,
"--td-border-level-1-color": borderHex,
"--td-component-stroke": borderHex,
"--td-border-level-2-color": inputHex,
"--td-component-border": inputHex,
};
}
/** Tokens produced from the automatic palette only, used by the editor to
* show which advanced values are currently inheriting from the system. */
export function getCustomThemeAutomaticTokens(
theme: Pick<CustomThemePreset, "primary" | "mode">,
): Record<string, string> {
return getCustomThemeTokens(theme);
}
export function customThemeContrast(primary: string): number {
const color = toRgb(primary);
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
}
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
const tokens = getCustomThemeTokens(theme);
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
}
+332 -72
View File
@@ -30,11 +30,20 @@ export type ToolEndEvent = {
data: unknown;
};
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
/**
* Per-page run behaviour. Most chats keep the SDK defaults; long-running
* workspace flows can opt into a resumable stream without changing global
* chat semantics.
*/
runOptions?: {
onDisconnect?: "cancel" | "continue";
multitaskStrategy?: "reject" | "rollback" | "interrupt" | "enqueue";
};
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>;
@@ -45,11 +54,45 @@ export type ThreadStreamOptions = {
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
/**
* Extra human-context messages submitted before the visible user message.
* They are persisted with `hide_from_ui` so the model receives rich business
* context without dumping long prompts into the conversation surface.
*/
hiddenContext?:
| string
| {
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}
| Array<{
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
const value = options?.hiddenContext;
if (!value) return [];
const items =
typeof value === "string"
? [{ text: value }]
: Array.isArray(value)
? value
: [value];
return items
.map((item) => ({
...item,
text: item.text.trim(),
}))
.filter((item) => item.text.length > 0);
}
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -182,6 +225,22 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed.";
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// A completed server run normally sends a terminal SSE event. In local
// development, however, a Vite hot update, a proxy disconnect, or a brief
// network interruption can make the browser miss that final event while the
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
@@ -384,9 +443,10 @@ function lastHumanFromChatModelInput(input: unknown): string {
export function useThreadStream({
threadId,
context,
isMock,
assistantId,
threadMetadata,
isMock,
assistantId,
runOptions,
threadMetadata,
onStart,
onFinish,
onToolEnd,
@@ -397,8 +457,14 @@ export function useThreadStream({
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
}, []);
const listeners = useRef({
onStart,
@@ -430,13 +496,14 @@ export function useThreadStream({
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
markStreamActivity();
_handleOnStart(_threadId);
},
[_handleOnStart, markStreamActivity],
);
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
@@ -460,8 +527,8 @@ export function useThreadStream({
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
@@ -475,9 +542,10 @@ export function useThreadStream({
.threads.update(meta.thread_id, { metadata })
.catch(() => ({}));
}
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
},
onLangChainEvent(event) {
markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
@@ -505,9 +573,10 @@ export function useThreadStream({
/* 上报绝不影响问答 */
}
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
},
onUpdateEvent(data) {
markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
data || {},
);
for (const update of updates) {
@@ -528,9 +597,10 @@ export function useThreadStream({
);
}
}
},
onCustomEvent(event: unknown) {
if (
},
onCustomEvent(event: unknown) {
markStreamActivity();
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
@@ -574,9 +644,10 @@ export function useThreadStream({
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
}
},
onError(error) {
setOptimisticMessages([]);
},
onError(error) {
markStreamActivity();
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
@@ -635,10 +706,11 @@ export function useThreadStream({
}
toast.error(message);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
onFinish(state) {
markStreamActivity();
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
// Optimistic messages shown before the server stream responds
@@ -661,8 +733,97 @@ export function useThreadStream({
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileCompletedRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
) {
return;
}
const storage = runMetadataStorageRef.current;
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
if (!runId) return;
streamReconciliationInFlightRef.current = true;
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
return;
}
// The server is already terminal, so the existing SSE listener cannot
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event.
const finalState = await client.threads.getState(activeThreadId);
if (disposed || !threadStreamRef.current.isLoading) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
}
// Changing the controlled thread id clears only the local SDK stream
// (it does not cancel the already-terminal backend run). Switching it
// back immediately makes useStream fetch the persisted messages, so
// the page exits the false loading state without a manual refresh.
setOnStreamThreadId(null);
refreshTimer = window.setTimeout(() => {
if (!disposed && threadIdRef.current === activeThreadId) {
setOnStreamThreadId(activeThreadId);
}
}, 0);
} catch {
// A transient status lookup failure must not disrupt a real stream;
// the next polling interval will try again if it is still quiet.
} finally {
if (!disposed) {
streamReconciliationInFlightRef.current = false;
}
}
};
const interval = window.setInterval(
() => void reconcileCompletedRun(),
STREAM_RECONCILIATION_POLL_MS,
);
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
@@ -975,32 +1136,51 @@ export function useThreadStream({
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
type: "human" as const,
...(item.name ? { name: item.name } : {}),
content: [
{
type: "text" as const,
text: item.text,
},
],
additional_kwargs: {
...item.additionalKwargs,
hide_from_ui: true,
},
})),
{
type: "human" as const,
content: [
{
type: "text" as const,
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
];
await thread.submit(
{
messages: submittedMessages,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
onDisconnect: runOptions?.onDisconnect,
multitaskStrategy: runOptions?.multitaskStrategy,
config: {
recursion_limit: 1000,
},
context: {
@@ -1055,7 +1235,7 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
);
// Merge thread with optimistic messages for display
@@ -1330,8 +1510,8 @@ export function useDeleteThread() {
* `MessageList`) so the removal shows immediately; a reload then reads the
* persisted, already-trimmed state.
*/
export function useDeleteMessage() {
return useMutation({
export function useDeleteMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
@@ -1361,12 +1541,92 @@ export function useDeleteMessage() {
// Fall back to the requested id so the caller can always hide something.
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
});
}
/**
* Clear all messages from a thread checkpoint. The caller should immediately
* hide the returned ids locally because the active stream cache may still hold
* the old message objects until a reload/refetch reads the trimmed checkpoint.
*/
export function useClearThreadMessages() {
return useMutation({
mutationFn: async ({
threadId,
}: {
threadId: string;
}): Promise<string[]> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "清空对话失败。" }));
throw new Error(error.detail ?? "清空对话失败。");
}
const body = (await response.json().catch(() => ({}))) as {
deleted_ids?: string[];
};
return body.deleted_ids ?? [];
},
});
}
/**
* Persist an in-place message edit in the latest thread checkpoint.
*
* This is opt-in UI behaviour: most chat pages keep their existing
* "edit question and resubmit" affordance, while position roundtable can pass
* this mutation down to save the original message content itself.
*/
export function useEditMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
content,
}: {
threadId: string;
messageId: string;
content: string;
}): Promise<Message> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "消息保存失败。" }));
throw new Error(error.detail ?? "消息保存失败。");
}
const body = (await response.json().catch(() => ({}))) as {
message?: Message;
};
return body.message ?? ({ id: messageId, type: "human", content } as Message);
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
+5
View File
@@ -29,5 +29,10 @@ export const env = {
NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
/**
* Deployment-level compact navigation mode. This is intentionally a runtime
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
+4 -3
View File
@@ -2,9 +2,10 @@ import "@fontsource/noto-sans-sc/chinese-simplified-400.css";
import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
import "@fontsource/noto-sans-sc/chinese-simplified-600.css";
import "@fontsource/noto-sans-sc/chinese-simplified-700.css";
import "./styles/globals.css";
import "tdesign-react/es/style/index.css";
import "katex/dist/katex.min.css";
import "./styles/globals.css";
import "tdesign-react/es/style/index.css";
import "./styles/tdesign-theme.css";
import "katex/dist/katex.min.css";
import React from "react";
import ReactDOM from "react-dom/client";
@@ -0,0 +1,445 @@
/**
* 对话驱动写作台的「意图解析」客户端。
*
* 两级解析:
* 1. `fastPathIntent`:高频确定性短句的前端规则快路径(确认/继续/重新搜索/重写大纲…),
* 零延迟、零误判;**高代价动作(定稿/强制定稿/覆盖式重搜的模糊说法)绝不走快路径**。
* 2. `streamWritingIntent`:POST /api/ai-writing/sessions/{id}/intent/stream —— 后端读该会话的
* graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 流式解析,实时返回 thinking/text,
* 最后一帧返回结构化意图契约。`parseWritingIntent` 保留为一次性兼容接口。
*
* 返回的 payload 字段与 `UserInterventionPayload` 对齐,可直接喂 `submitIntervention`。
*/
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { BlockedSection, UserInterventionPayload } from '../types/ai-writing';
export type WritingIntentSource = 'rule' | 'llm';
export interface WritingIntentResult {
/** 意图标识:confirm_materials / research_again / revise_draft / ask_content / clarify / run_background… */
intent: string;
/** 需 resume 的暂停点(后端以 checkpoint 里真实 interrupt 为准回传);无动作时为 null */
pausePoint: string | null;
/** 映射到现有协议的 action;null = 不执行图动作(问答/澄清/控制类) */
action: string | null;
/** action 非空时可直接喂 submitIntervention 的载荷 */
payload: UserInterventionPayload | null;
/** 用户的要求需要现有素材之外的信息(素材确认阶段可自动重搜续跑,其余阶段仅提示) */
needResearch: boolean;
researchQuery: string;
/** 问答类意图(ask_content / ask_meta)的直接回答 */
answer: string;
confidence: number;
/** 非空 = 需要向用户澄清,不执行任何动作 */
clarify: string;
source: WritingIntentSource;
}
/** 前端判定「执行动作」的置信度下限(后端 normalize 已兜底一层,这里再保守一档)。 */
export const INTENT_CONFIDENCE_THRESHOLD = 0.55;
// ── 规则快路径 ────────────────────────────────────────────────────────────────
const CONFIRM_RE = /^(继续|下一步|确认|好的|好|可以|行|嗯|没问题|OK|ok|Ok|开始吧|通过)$/;
const RESEARCH_AGAIN_RE = /^(重新搜索|重新检索|重搜|再搜一次|重新搜)$/;
const REOUTLINE_RE = /^(重新规划大纲|重写大纲|大纲重来|重新规划)$/;
const TO_EDITOR_RE = /^((交给|让)?编辑(继续)?审核|送审|提交(编辑)?审核)$/;
const ACCEPT_REVIEW_RE = /^(按编辑(的)?意见(修改|改)|采纳编辑意见)$/;
const SECTION_ALL_SUPPLEMENT_RE = /^(都|全部|全都)(补充|补)检索(素材)?$/;
const SECTION_ALL_LOOSE_RE = /^(都|全部|全都)(按通用知识|用通用知识)?(续写|写|继续写)$/;
const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/;
const WRITING_ACTION_TARGET_RE = /(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)/;
const INFO_QUESTION_RE = /(哪些|什么|为什么|是什么|有哪些|多少|是否)/;
function stripPunct(text: string): string {
return text.trim().replace(/[。..!!~~\s]+$/g, '');
}
function looksLikeWritingChangeRequest(text: string): boolean {
if (INFO_QUESTION_RE.test(text)) return false;
return WRITING_ACTION_VERB_RE.test(text) && WRITING_ACTION_TARGET_RE.test(text);
}
function ruleResult(
intent: string,
pausePoint: string | null,
action: string | null,
payload: UserInterventionPayload | null,
): WritingIntentResult {
return {
intent,
pausePoint,
action,
payload,
needResearch: false,
researchQuery: '',
answer: '',
confidence: 1,
clarify: '',
source: 'rule',
};
}
/**
* 高频短句规则快路径。命中返回结果,未命中返回 null(走后端 LLM)。
*
* 只做「当前暂停点语义完全无歧义」的映射;一切带自由文本的要求(补充检索内容、
* 修改意见等)与高代价动作(定稿/强制定稿)一律交给后端 LLM + 澄清机制。
*/
export function fastPathIntent(
rawText: string,
ctx: {
pausePoint: string | null;
isPaused: boolean;
blockedSections?: BlockedSection[];
},
): WritingIntentResult | null {
const text = stripPunct(rawText);
if (!text) return null;
// 「暂停中 + 继续」= 恢复运行(控制类,优先于阶段确认语义)
if (ctx.isPaused && CONFIRM_RE.test(text)) {
return ruleResult('resume_writing', null, null, null);
}
const pp = ctx.pausePoint;
if (!pp) return null;
if (pp === 'outline_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_outline', pp, 're_outline', {
action: 're_outline',
outlineFeedback: rawText.trim(),
});
}
if (pp === 'draft_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_draft', pp, 'user_revise', {
action: 'user_revise',
userRevisionNotes: rawText.trim(),
});
}
if (pp === 'review_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('accept_review_with_user_notes', pp, 'accept_review', {
action: 'accept_review',
userRevisionNotes: rawText.trim(),
});
}
if (text.length > 12) return null;
if (pp === 'material_confirm') {
if (CONFIRM_RE.test(text)) {
// 不带 approvedMaterialIds → 后端默认采用全部素材
return ruleResult('confirm_materials', pp, 'confirm', { action: 'confirm' });
}
if (RESEARCH_AGAIN_RE.test(text)) {
return ruleResult('research_again', pp, 're_search', { action: 're_search', approvedMaterialIds: [] });
}
return null;
}
if (pp === 'outline_confirm') {
if (CONFIRM_RE.test(text)) {
return ruleResult('confirm_outline', pp, 'confirm', { action: 'confirm' });
}
if (REOUTLINE_RE.test(text)) {
return ruleResult('reoutline', pp, 're_outline', { action: 're_outline' });
}
return null;
}
if (pp === 'draft_confirm') {
// 「继续/下一步」在草稿阶段的安全缺省是送审(绝不缺省成定稿)
if (CONFIRM_RE.test(text) || TO_EDITOR_RE.test(text)) {
return ruleResult('to_editor', pp, 'to_editor', { action: 'to_editor' });
}
return null;
}
if (pp === 'review_confirm') {
if (CONFIRM_RE.test(text) || ACCEPT_REVIEW_RE.test(text)) {
// 不带 selectedIssueIndices → 后端按全部意见修改(与后台挂起的默认放行一致)
return ruleResult('accept_review', pp, 'accept_review', { action: 'accept_review' });
}
return null;
}
if (pp === 'section_help') {
const blocked = ctx.blockedSections ?? [];
if (blocked.length === 0) return null;
const buildDecisions = (decision: 'supplement' | 'loose') => ({
action: 'section_help',
sectionDecisions: blocked.map((b) => ({ sectionTitle: b.sectionTitle, decision })),
});
if (SECTION_ALL_SUPPLEMENT_RE.test(text)) {
return ruleResult('section_supplement_all', pp, 'section_help', buildDecisions('supplement'));
}
if (SECTION_ALL_LOOSE_RE.test(text)) {
return ruleResult('section_loose_all', pp, 'section_help', buildDecisions('loose'));
}
// 「继续/确认」在求助点语义含糊(补检索 or 续写?),交给 LLM 澄清
return null;
}
return null;
}
// ── 后端 LLM 解析 ─────────────────────────────────────────────────────────────
interface RawIntentResponse {
intent?: string;
pause_point?: string | null;
action?: string | null;
payload?: Record<string, unknown> | null;
need_research?: boolean;
research_query?: string | null;
answer?: string | null;
confidence?: number;
clarify?: string | null;
}
export type WritingIntentStreamEvent =
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'result'; result: WritingIntentResult }
| { type: 'done' }
| { type: 'error'; message: string };
export type WritingAnswerStreamEvent =
| { type: 'start' }
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'done' }
| { type: 'error'; message: string };
export interface WritingSuggestionMessage {
role: 'user' | 'assistant';
content: string;
}
function normalizePayload(raw: Record<string, unknown> | null | undefined, action: string | null): UserInterventionPayload | null {
if (!action) return null;
const p = raw ?? {};
const payload: UserInterventionPayload = { action };
if (Array.isArray(p.approvedMaterialIds)) payload.approvedMaterialIds = p.approvedMaterialIds.map(String);
if (typeof p.userQuery === 'string' && p.userQuery.trim()) payload.userQuery = p.userQuery.trim();
if (typeof p.outlineFeedback === 'string' && p.outlineFeedback.trim()) payload.outlineFeedback = p.outlineFeedback.trim();
if (typeof p.userRevisionNotes === 'string' && p.userRevisionNotes.trim()) payload.userRevisionNotes = p.userRevisionNotes.trim();
if (typeof p.overrideVerdict === 'boolean') payload.overrideVerdict = p.overrideVerdict;
if (Array.isArray(p.selectedIssueIndices)) {
payload.selectedIssueIndices = p.selectedIssueIndices
.map((n) => Number(n))
.filter((n) => Number.isInteger(n) && n >= 0);
}
if (Array.isArray(p.sectionDecisions)) {
payload.sectionDecisions = (p.sectionDecisions as Array<Record<string, unknown>>)
.map((d) => ({
sectionTitle: String(d.sectionTitle ?? d.section_title ?? ''),
decision: (['supplement', 'loose', 'delete'].includes(String(d.decision))
? String(d.decision)
: 'loose') as 'supplement' | 'loose' | 'delete',
}))
.filter((d) => d.sectionTitle);
}
return payload;
}
function normalizeIntentResponse(raw: RawIntentResponse): WritingIntentResult {
const action = raw.action || null;
return {
intent: raw.intent || 'unknown',
pausePoint: raw.pause_point || null,
action,
payload: normalizePayload(raw.payload, action),
needResearch: Boolean(raw.need_research),
researchQuery: (raw.research_query || '').trim(),
answer: (raw.answer || '').trim(),
confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0,
clarify: (raw.clarify || '').trim(),
source: 'llm',
};
}
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trimEnd();
if (trimmed.startsWith('event: ')) {
currentEvent = trimmed.slice(7);
} else if (trimmed.startsWith('data: ')) {
yield { event: currentEvent, data: trimmed.slice(6) };
currentEvent = '';
} else if (trimmed === '') {
currentEvent = '';
}
}
}
} finally {
reader.releaseLock();
}
}
/** 后端 LLM 意图解析。会话必须已存在(sessionId = graph thread_id)。 */
export async function parseWritingIntent(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null },
): Promise<WritingIntentResult> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
}),
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const raw = (await resp.json()) as RawIntentResponse;
return normalizeIntentResponse(raw);
}
/** 后端 LLM 意图解析的 SSE 流式版本,包含 `<think>`/reasoning 思考增量。 */
export async function* streamWritingIntent(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingIntentStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('意图解析失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'result') {
yield { type: 'result', result: normalizeIntentResponse(parsed as RawIntentResponse) };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '意图解析流式接口异常') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
export async function fetchWritingAnswerSuggestions(
sessionId: string,
body: { messages: WritingSuggestionMessage[]; n?: number; modelName?: string | null },
): Promise<string[]> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/suggestions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: body.messages,
n: body.n ?? 3,
model_name: body.modelName || null,
}),
},
);
if (!resp.ok) return [];
const raw = (await resp.json().catch(() => ({}))) as { suggestions?: unknown[] };
return (raw.suggestions ?? [])
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, body.n ?? 3);
}
export async function* streamWritingAnswer(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null; modelName?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingAnswerStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/answer/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
model_name: body.modelName || null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`回答生成失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('回答生成失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'start') {
yield { type: 'start' };
} else if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '回答生成失败') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
@@ -0,0 +1,29 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface AIWritingSampleExtractResult {
filename: string;
text: string;
char_count: number;
truncated: boolean;
}
export async function extractAIWritingSample(file: File): Promise<AIWritingSampleExtractResult> {
const form = new FormData();
form.append('file', file);
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/sample/extract`, {
method: 'POST',
body: form,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
let detail = '';
try {
const parsed = text ? JSON.parse(text) as { detail?: unknown } : null;
detail = parsed?.detail ? String(parsed.detail) : '';
} catch {}
if (detail) throw new Error(detail);
throw new Error(text || `样文解析失败:${resp.status}`);
}
return resp.json();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,920 @@
/**
* 对话版干预卡(AI 写作·对话驱动写作台专用)。
*
* 与旧版 `../ai-writing/InterventionCard.tsx` 的关系:**复制后减法**——
* - 保留:阶段产物展示(素材列表勾选 / 大纲可点选编辑 / 审核意见勾选)+ 全部确认/动作按钮;
* - 移除:每张卡内嵌的自由文本输入框(补充检索输入 / 大纲修改意见 textarea /
* 草稿修改意见 textarea / 审核补充指令 textarea)——这些自由输入统一改由页面
* 底部的智能输入框(WritingChatComposer)承接,经意图解析层翻译成 action。
*
* 按钮点击路径与旧卡完全一致:直接调 `onSubmit(UserInterventionPayload)`,
* 不经意图解析层,零延迟零误判。旧页面的 InterventionCard 不受本文件影响。
*/
import { useState, type ReactNode } from 'react';
import {
ArrowRight, BookOpen, CheckCircle2, ClipboardEdit, FileCheck2, FileText,
ListTree, MessageSquareText, RotateCcw, Search, ShieldAlert, ShieldCheck, Trash2,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, BlockedSection, ProgressEvent, ReviewIssue,
ReviewResult, SectionDecisionKind, SkillSearchStep, UserInterventionPayload,
} from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { MaterialsCard } from '../ai-writing/MaterialsCard';
import { OutlineCard } from '../ai-writing/OutlineCard';
import { SkillStepsCard } from '../ai-writing/SkillStepsCard';
interface ChatInterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
draftWordCount?: number;
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function ChatInterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, draftWordCount, wordCountTarget, revisionCount,
skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: ChatInterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return (
<MaterialConfirmChatCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmChatCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmChatCard
draftTitle={draftTitle}
draftWordCount={draftWordCount}
wordCountTarget={wordCountTarget}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'section_help') {
return (
<SectionHelpChatCard
blockedSections={pauseEvent.blockedSections ?? []}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmChatCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<ShieldAlert className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具 ──────────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
type DecisionTone = 'blue' | 'amber' | 'emerald';
interface DecisionMetric {
label: string;
value: string;
tone?: DecisionTone | 'muted';
}
const PANEL_TONE: Record<DecisionTone, {
shell: string;
icon: string;
badge: string;
}> = {
blue: {
shell: 'border-blue-200/80 bg-blue-50/35 dark:border-blue-900/40 dark:bg-blue-950/15',
icon: 'border-blue-200 bg-background text-blue-600 dark:border-blue-900/50 dark:text-blue-300',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300',
},
amber: {
shell: 'border-amber-200/90 bg-amber-50/45 dark:border-amber-900/40 dark:bg-amber-950/20',
icon: 'border-amber-200 bg-background text-amber-600 dark:border-amber-900/50 dark:text-amber-300',
badge: 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
},
emerald: {
shell: 'border-emerald-200/80 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
icon: 'border-emerald-200 bg-background text-emerald-600 dark:border-emerald-900/50 dark:text-emerald-300',
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300',
},
};
function metricClass(tone: DecisionMetric['tone']) {
if (tone === 'blue') return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
if (tone === 'amber') return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (tone === 'emerald') return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300';
return 'border-border bg-background/80 text-muted-foreground';
}
function DecisionMetricPill({ metric }: { metric: DecisionMetric }) {
return (
<span className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px]', metricClass(metric.tone))}>
<span className="text-muted-foreground">{metric.label}</span>
<span className="font-medium text-foreground/85 dark:text-foreground/80">{metric.value}</span>
</span>
);
}
function DecisionPanel({
tone = 'blue',
icon: Icon,
badge,
title,
description,
metrics,
children,
actions,
hint,
}: {
tone?: DecisionTone;
icon: LucideIcon;
badge: string;
title: string;
description: ReactNode;
metrics?: DecisionMetric[];
children?: ReactNode;
actions?: ReactNode;
hint?: ReactNode;
}) {
const classes = PANEL_TONE[tone];
return (
<section className={cn('overflow-hidden rounded-lg border shadow-sm', classes.shell)}>
<div className="p-3">
<div className="flex items-start gap-2.5">
<div className={cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border', classes.icon)}>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-sm font-semibold leading-5">{title}</h3>
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', classes.badge)}>
{badge}
</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
{metrics && metrics.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{metrics.map((metric) => (
<DecisionMetricPill key={`${metric.label}-${metric.value}`} metric={metric} />
))}
</div>
)}
</div>
{children && (
<div className="border-y border-background/80 bg-background/72 p-3 dark:bg-background/35">
{children}
</div>
)}
{(actions || hint) && (
<div className="space-y-2 bg-background/55 p-3 dark:bg-background/25">
{actions}
{hint}
</div>
)}
</section>
);
}
/** 卡片底部的「用对话继续」提示行:告诉用户自由输入去底部输入框。 */
function ComposerHint({ examples }: { examples: string }) {
return (
<div className="flex items-start gap-1.5 rounded-md border bg-muted/35 px-2.5 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
<MessageSquareText className="mt-0.5 size-3 shrink-0 text-primary/70" />
<span>也可以直接在底部输入框用一句话操作,例如:{examples}</span>
</div>
);
}
function getOutlineStats(outline: AIOutline | null): DecisionMetric[] {
if (!outline) return [];
let keyPointCount = 0;
let subPointCount = 0;
for (const section of outline.sections) {
const keyPoints = section.keyPoints ?? [];
keyPointCount += keyPoints.length;
subPointCount += keyPoints.reduce((sum, keyPoint) => sum + normalizeKeyPoint(keyPoint).subPoints.length, 0);
}
return [
{ label: '章节', value: `${outline.sections.length}`, tone: 'blue' },
{ label: '小节', value: `${keyPointCount}`, tone: 'emerald' },
{ label: '子要点', value: `${subPointCount}`, tone: 'muted' },
];
}
function reviewMetrics(review: ReviewResult | null): DecisionMetric[] {
if (!review) return [];
return [
{ label: '综合', value: `${review.overallScore}`, tone: review.verdict === 'pass' ? 'emerald' : 'amber' },
{ label: '事实', value: `${review.factScore}` },
{ label: '逻辑', value: `${review.logicScore}` },
{ label: '语言', value: `${review.languageScore}` },
{ label: '意见', value: `${review.revisionNotes.length}` },
];
}
function issueCategoryLabel(category: ReviewIssue['category']): string {
if (category === 'fact') return '事实';
if (category === 'logic') return '逻辑';
return '语言';
}
function issueTone(issue: ReviewIssue) {
if (issue.severity === 'major') return 'border-red-200 bg-red-50/65 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300';
if (issue.category === 'logic') return 'border-amber-200 bg-amber-50/65 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (issue.category === 'fact') return 'border-blue-200 bg-blue-50/65 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
return 'border-violet-200 bg-violet-50/65 text-violet-700 dark:border-violet-900/40 dark:bg-violet-950/20 dark:text-violet-300';
}
function ChatReviewDecisionView({
review,
revisionCount,
selectedIndices,
onToggleIssue,
}: {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}) {
const selectable = selectedIndices !== undefined && !!onToggleIssue;
return (
<div className="overflow-hidden rounded-lg border bg-background text-xs">
<div className="border-b px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">编辑审核结果</p>
{revisionCount !== undefined && revisionCount > 0 && (
<p className="mt-0.5 text-[11px] text-muted-foreground">第 {revisionCount} 次修改稿</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-md px-2 py-1 text-[11px] font-medium',
review.verdict === 'pass'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
)}
>
{review.verdict === 'pass' ? '通过' : '打回'}
</span>
<span className="text-sm font-semibold tabular-nums">
{review.overallScore}
<span className="text-xs font-normal text-muted-foreground"> / 100</span>
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
{[
['事实', review.factScore],
['逻辑', review.logicScore],
['语言', review.languageScore],
].map(([label, score]) => (
<div key={label} className="rounded-md border bg-muted/30 px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{score}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full',
Number(score) >= review.passThreshold ? 'bg-emerald-500' : 'bg-amber-500',
)}
style={{ width: `${Math.max(0, Math.min(100, Number(score)))}%` }}
/>
</div>
</div>
))}
</div>
</div>
{review.revisionNotes.length > 0 ? (
<div className="space-y-2 px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-muted-foreground">修改意见</p>
{selectable && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
{review.revisionNotes.map((issue, index) => {
const selected = selectedIndices?.has(index) ?? false;
return (
<button
key={`${issue.category}-${issue.location}-${index}`}
type="button"
disabled={!selectable}
onClick={() => onToggleIssue?.(index)}
className={cn(
'w-full rounded-md border p-2 text-left transition-colors',
selectable ? 'hover:border-primary/40 hover:bg-primary/5' : 'cursor-default',
selected && 'border-primary/50 bg-primary/5',
)}
>
<div className="flex items-start gap-2">
{selectable && (
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-background',
)}
>
{selected && <CheckCircle2 className="size-3" />}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className={cn('rounded border px-1.5 py-0.5 text-[10px] font-medium', issueTone(issue))}>
{issue.severity === 'major' ? '严重' : '轻微'} · {issueCategoryLabel(issue.category)}
</span>
{issue.location && (
<span className="truncate text-[10px] text-muted-foreground">位置:{issue.location}</span>
)}
</div>
<p className="mt-1 leading-relaxed text-foreground/90">{issue.issue}</p>
{issue.suggestion && (
<p className="mt-1 rounded bg-muted/45 px-2 py-1 leading-relaxed text-muted-foreground">
建议:{issue.suggestion}
</p>
)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="px-3 py-2.5 text-xs text-muted-foreground">没有需要修改的审核意见。</div>
)}
</div>
);
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
function MaterialConfirmChatCard({
materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [submitting, setSubmitting] = useState(false);
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds] });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
// 无输入 = 重新生成检索词、覆盖式重检;带自然语言的补充检索走底部输入框。
await onSubmit({ action: 're_search', approvedMaterialIds: [] });
setSubmitting(false);
}
if (materials.length === 0) {
return (
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材确认"
title="未检索到可用素材"
description="素材收集专家本次没有检索到相关数据。可以重新搜索、用一句话补充检索方向,或在无素材情况下继续写作。"
metrics={[{ label: '素材', value: '0 条', tone: 'amber' }]}
actions={(
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })}
disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「换个方向重新搜」" />}
>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
</DecisionPanel>
);
}
return (
<DecisionPanel
tone="blue"
icon={FileText}
badge="素材确认"
title="确认素材范围"
description="已搜集到候选素材,默认全选。可取消不相关素材,确认后进入三层大纲规划。"
metrics={[
{ label: '已检索', value: `${materials.length} 条`, tone: 'blue' },
{ label: '已选择', value: `${selectedIds.size} 条`, tone: selectedIds.size > 0 ? 'emerald' : 'amber' },
]}
actions={(
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))}
className="rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/5"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「只用第 1、3 条」「素材可以,继续」" />}
>
<div className="space-y-3">
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard
materials={materials}
selectedIds={selectedIds}
onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
/>
</div>
</DecisionPanel>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
function OutlineConfirmChatCard({
outline: initialOutline, onSubmit, disabled,
}: {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
return (
<DecisionPanel
tone="blue"
icon={ListTree}
badge="三层大纲"
title="确认三层大纲结构"
description="大纲结构为「章节 → 小节 → 子要点」。可直接在大纲里微调节点,也可以通过底部输入框要求 AI 重新规划。"
metrics={getOutlineStats(editedOutline)}
actions={(
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
)}
hint={<ComposerHint examples="「第二章拆成两节」「重新规划大纲」「加一章讲发展趋势」" />}
>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
</DecisionPanel>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
function DraftConfirmChatCard({
draftTitle, draftWordCount, wordCountTarget, revisionCount, reviewResult, onSubmit, disabled,
}: {
draftTitle: string;
draftWordCount?: number;
wordCountTarget?: number;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string) {
setSubmitting(true);
await onSubmit({ action });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
const showWordCmp = wordCountTarget != null && wordCountTarget > 0 && draftWordCount != null && draftWordCount > 0;
const wordDiff = showWordCmp ? draftWordCount! - wordCountTarget! : 0;
const wordWithin = Math.abs(wordDiff) <= 100;
return (
<DecisionPanel
tone="blue"
icon={FileCheck2}
badge="草稿确认"
title="草稿已完成,请确认下一步"
description={(
<>
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容。
</>
)}
metrics={[
...(draftWordCount ? [{ label: '当前字数', value: `${draftWordCount}`, tone: wordWithin ? 'emerald' as const : 'amber' as const }] : []),
...(wordCountTarget ? [{ label: '目标字数', value: `${wordCountTarget}` }] : []),
...(reviewResult ? reviewMetrics(reviewResult).slice(0, 2) : []),
]}
actions={(
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>下一步处理</span>
{showWordCmp && wordDiff !== 0 && (
<span className={wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400'}>
{wordDiff > 0 ? '超出' : '不足'} {Math.abs(wordDiff)} 字{wordWithin ? ',在误差范围内' : ''}
</span>
)}
</div>
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「第三段太啰嗦,精简一下」「结尾补个案例」「更口语一点」" />}
>
{reviewResult ? (
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
) : (
<div className="rounded-md border bg-background/80 px-3 py-2 text-xs text-muted-foreground">
当前草稿暂无编辑审核结果,可直接送审或定稿。
</div>
)}
</DecisionPanel>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
function ReviewConfirmChatCard({
reviewResult, revisionCount, onSubmit, disabled,
}: {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
await onSubmit({
action: 'accept_review',
userRevisionNotes: issueNotes || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
return (
<DecisionPanel
tone="amber"
icon={ShieldCheck}
badge="审核确认"
title="编辑认为文章需要修改"
description="勾选希望采纳的意见,再让作家重写;也可以忽略编辑意见并强制定稿。"
metrics={reviewMetrics(reviewResult)}
actions={(
<div className="space-y-2">
<div className="text-[11px] text-muted-foreground">请选择审核意见处理方式</div>
<div className="flex gap-2">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0)}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「再补一条意见:核查数据来源」「只按第 1、2 条改」「不改了,定稿」" />}
>
{reviewResult ? (
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
</DecisionPanel>
);
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
{ value: 'supplement', label: '补充检索素材', icon: Search },
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
{ value: 'delete', label: '删除该章节', icon: Trash2 },
];
function SectionHelpChatCard({
blockedSections, onSubmit, disabled,
}: {
blockedSections: BlockedSection[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
);
const [submitting, setSubmitting] = useState(false);
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
async function handleSubmit() {
setSubmitting(true);
await onSubmit({
action: 'section_help',
sectionDecisions: blockedSections.map((b) => ({
sectionTitle: b.sectionTitle,
decision: decisions[b.sectionTitle] ?? 'supplement',
})),
});
setSubmitting(false);
}
return (
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材不足"
title="部分章节素材不足,需要你决定"
description={`严格依据素材模式下,以下 ${blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。`}
metrics={[
{ label: '受影响章节', value: `${blockedSections.length}`, tone: 'amber' },
{ label: '需补检索', value: `${Object.values(decisions).filter((d) => d === 'supplement').length}` },
]}
actions={(
<div className="space-y-2">
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
</div>
)}
hint={<ComposerHint examples="「都补充检索」「都按通用知识写」「第一个删掉,其他补检索」" />}
>
<div className="space-y-2">
{blockedSections.map((b) => (
<div key={b.sectionTitle} className="rounded-md border bg-background/85 p-2.5 space-y-2">
<div>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
</div>
<div className="flex gap-1">
{DECISION_OPTIONS.map((opt) => {
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={disabled || submitting}
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
className={cn(
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
active
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-input text-muted-foreground hover:bg-muted/50',
)}
>
<Icon className="size-3 shrink-0" />
{opt.label}
</button>
);
})}
</div>
</div>
))}
</div>
</DecisionPanel>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react';
import { PenLine, SlidersHorizontal } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { FileText, Loader2, PenLine, SlidersHorizontal, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -17,6 +17,7 @@ import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
import { extractAIWritingSample } from '../../api/ai-writing-sample';
import { ResearchTemplatesPanel } from './ResearchTemplatesPanel';
interface AIWritingFormProps {
@@ -42,14 +43,26 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
type WritingModeType = 'normal' | 'imitate';
const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口,不走技能编排' },
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
const SHOW_NOTEBOOK_MATERIAL_SOURCE = false;
const WRITING_MODE_OPTIONS: Array<{ value: WritingModeType; label: string }> = [
{ value: 'normal', label: '普通写作' },
{ value: 'imitate', label: '样文仿写' },
];
const IMITATE_STRENGTH_OPTIONS: Array<{ value: 'light' | 'medium' | 'strong'; label: string }> = [
{ value: 'light', label: '轻度' },
{ value: 'medium', label: '中度' },
{ value: 'strong', label: '高度' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
const FIELD_TRIGGER_CLASS =
@@ -63,6 +76,11 @@ export function AIWritingForm({
}: AIWritingFormProps) {
const {
intent, setIntent,
writingModeType, setWritingModeType,
sampleText, setSampleText,
sampleFilename, setSampleFilename,
imitateStrength, setImitateStrength,
preserveSampleStructure, setPreserveSampleStructure,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
@@ -79,6 +97,10 @@ export function AIWritingForm({
modelName, setModelName,
} = useWritingForm();
const [sampleUploading, setSampleUploading] = useState(false);
const [sampleMessage, setSampleMessage] = useState('');
const [sampleError, setSampleError] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
@@ -98,17 +120,6 @@ export function AIWritingForm({
() => models.find((m) => m.name === modelName) ?? models[0],
[models, modelName],
);
const materialSourceOptions = useMemo(
() =>
SHOW_NOTEBOOK_MATERIAL_SOURCE || studioToken
? MATERIAL_SOURCE_OPTIONS
: MATERIAL_SOURCE_OPTIONS.filter((opt) => opt.value !== 'notebook'),
[studioToken],
);
const effectiveMaterialSource =
!SHOW_NOTEBOOK_MATERIAL_SOURCE && !studioToken && materialSource === 'notebook'
? 'general'
: materialSource;
// 初始化时加载文章类型
@@ -152,7 +163,7 @@ export function AIWritingForm({
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空。
if (val !== 'notebook' && !studioToken) {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
@@ -167,13 +178,41 @@ export function AIWritingForm({
);
}
async function handleSampleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setSampleUploading(true);
setSampleError('');
setSampleMessage('');
try {
const result = await extractAIWritingSample(file);
setSampleFilename(result.filename);
setSampleText(result.text);
setSampleMessage(result.truncated ? '已提取样文文本,内容较长已截断。' : '已提取样文文本。');
} catch (err) {
setSampleError(err instanceof Error ? err.message : '样文解析失败');
} finally {
setSampleUploading(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
if (writingModeType === 'imitate' && !sampleText.trim()) {
setSampleError('请上传样文或粘贴样文文本');
return;
}
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
userOutline: outlineText.trim() || undefined,
writingModeType,
sampleText: writingModeType === 'imitate' ? sampleText.trim() : undefined,
sampleFilename: writingModeType === 'imitate' ? sampleFilename || '粘贴文本' : undefined,
imitateStrength,
preserveSampleStructure,
articleType,
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
@@ -184,12 +223,10 @@ export function AIWritingForm({
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
skipResearch,
// 跳过素材收集时检索类型无意义,归一为 general
materialSource: skipResearch ? 'general' : effectiveMaterialSource,
materialSource: skipResearch ? 'general' : materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
noteSearchSpaceId: !skipResearch && effectiveMaterialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && effectiveMaterialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
noteSearchSpaceId: !skipResearch ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -203,6 +240,106 @@ export function AIWritingForm({
</h3>
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模式</label>
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-1 dark:border-[#1B477E] dark:bg-[#022248]/40">
{WRITING_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => setWritingModeType(opt.value)}
className={cn(
'h-10 rounded-lg text-sm font-medium transition-colors',
writingModeType === opt.value
? 'bg-white text-primary shadow-sm dark:bg-[#0B376A] dark:text-white'
: 'text-muted-foreground hover:bg-white/70 dark:text-[#AACEFF]/70 dark:hover:bg-[#0B376A]/60',
)}
>
{opt.label}
</button>
))}
</div>
</div>
{writingModeType === 'imitate' && (
<div className="mt-4 space-y-3 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<label className={FIELD_LABEL_CLASS}>样文</label>
<label
className={cn(
'inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg border border-[#e8dedd] bg-white px-3 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-[#fff8f8]',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
disabled || sampleUploading ? 'pointer-events-none opacity-60' : '',
)}
>
{sampleUploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
上传文件
<input
type="file"
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
className="sr-only"
disabled={disabled || sampleUploading}
onChange={handleSampleFileChange}
/>
</label>
</div>
{sampleFilename && (
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-xs text-muted-foreground dark:bg-[#022248] dark:text-[#AACEFF]/80">
<FileText className="size-3.5" />
<span className="truncate">{sampleFilename}</span>
</div>
)}
<Textarea
placeholder="上传 Word/PDF/Markdown/TXT,或直接粘贴一篇样文。AI 会先分析文风,再按你的写作意图仿写一篇新文章。"
value={sampleText}
onChange={(e) => {
setSampleText(e.target.value.slice(0, MAX_SAMPLE_TEXT_LENGTH));
setSampleError('');
if (!sampleFilename) setSampleFilename('粘贴文本');
}}
disabled={disabled || sampleUploading}
rows={8}
maxLength={MAX_SAMPLE_TEXT_LENGTH}
className="max-h-[13.5rem] resize-none overflow-y-auto rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm [field-sizing:fixed]"
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground/80">
<span>{sampleText.length} / {MAX_SAMPLE_TEXT_LENGTH} 字</span>
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
{sampleError && <span className="text-destructive">{sampleError}</span>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">仿写强度</label>
<Select
value={imitateStrength}
onValueChange={(v) => setImitateStrength(v as 'light' | 'medium' | 'strong')}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 pt-7 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={preserveSampleStructure}
onChange={(e) => setPreserveSampleStructure(e.target.checked)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
沿用样文结构
</label>
</div>
</div>
)}
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>
@@ -316,82 +453,80 @@ export function AIWritingForm({
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={effectiveMaterialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{materialSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索方式</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索方式" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
{effectiveMaterialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{/* studioToken 模式:空间已锁定,不展示选择器 */}
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
@@ -441,7 +576,7 @@ export function AIWritingForm({
<Button
type="submit"
disabled={disabled || !intent.trim()}
disabled={disabled || !intent.trim() || (writingModeType === 'imitate' && !sampleText.trim())}
className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
>
<PenLine className="size-4" />
@@ -10,6 +10,7 @@ export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '未完成', color: 'text-slate-400' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
analyzing_sample: { label: '样文分析', color: 'text-cyan-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -39,11 +39,14 @@ import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { KeywordStreamingView } from './KeywordStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftGeneratingView } from './DraftGeneratingView';
import { DraftSync } from './DraftSync';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { SampleAnalysisStreamingView } from './SampleAnalysisStreamingView';
import { SampleStyleProfileCard } from './SampleStyleProfileCard';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
@@ -54,6 +57,7 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
analyzing_sample: '样文分析师分析文风中…',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
@@ -99,93 +103,206 @@ export function AIWritingPanel({
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const shouldAutoFollow = !isIdle && !isHistoryMode;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
const intentSlot = (state.intentStreamingText || state.intentThinkingText) ? (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
) : state.parsedIntent ? (
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
embedded
/>
) : null;
const sampleAnalysisSlot = (state.sampleAnalysisStreamingText || state.sampleAnalysisThinkingText) ? (
<SampleAnalysisStreamingView
text={state.sampleAnalysisStreamingText}
thinkingText={state.sampleAnalysisThinkingText}
/>
) : null;
const sampleProfileSlot = state.sampleStyleProfile ? (
<SampleStyleProfileCard profile={state.sampleStyleProfile} />
) : null;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const shouldAutoFollowRef = useRef(shouldAutoFollow);
shouldAutoFollowRef.current = shouldAutoFollow;
const scrollbarDragRef = useRef(false);
const touchYRef = useRef<number | null>(null);
const [autoScroll, setAutoScroll] = useState(true);
const enableAutoScroll = useCallback(() => {
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
const scrollToBottomNow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
const jump = () => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ block: 'end' });
} else {
el.scrollTop = el.scrollHeight;
}
};
jump();
requestAnimationFrame(() => {
jump();
requestAnimationFrame(() => {
isAutoScrollingRef.current = false;
});
});
}, []);
const maybeAutoScrollToBottom = useCallback(() => {
if (!autoScrollRef.current || !shouldAutoFollowRef.current) return;
scrollToBottomNow();
}, [scrollToBottomNow]);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
enableAutoScroll();
}
}, [isIdle]);
}, [enableAutoScroll, isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
if (shouldAutoFollow && !state.isPaused) {
enableAutoScroll();
requestAnimationFrame(scrollToBottomNow);
}
}, [isRunning, state.isPaused]);
}, [enableAutoScroll, scrollToBottomNow, shouldAutoFollow, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 24;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
const onTouchStart = (e: TouchEvent) => {
touchYRef.current = e.touches[0]?.clientY ?? null;
};
const onTouchMove = (e: TouchEvent) => {
const prevY = touchYRef.current;
const nextY = e.touches[0]?.clientY ?? null;
if (prevY !== null && nextY !== null && nextY > prevY + 4) {
disableAutoScroll();
}
touchYRef.current = nextY;
};
const onPointerDown = (e: PointerEvent) => {
if (e.target === el) {
scrollbarDragRef.current = true;
}
};
const onPointerDone = () => {
scrollbarDragRef.current = false;
};
// scroll 事件只处理「用户拖动滚动条」和「手动回到底部」。
// 普通布局变化也会触发 scroll,不能再把所有 scrollTop 下降都当成用户上划。
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
if (isAutoScrollingRef.current) {
lastTop = cur;
return;
}
if (nearBottom()) {
enableAutoScroll();
} else if (scrollbarDragRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
el.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerDone);
window.addEventListener('pointercancel', onPointerDone);
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerDone);
window.removeEventListener('pointercancel', onPointerDone);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
}, [disableAutoScroll, enableAutoScroll]);
// 内容高度变化兜底:同一 timeline 节点内卡片展开、文风画像替换、流式文本变长,
// 都可能不改变 progressEvents.length。监听 DOM 变化可以把这些情况统一托底。
useEffect(() => {
const el = scrollRef.current;
if (!el || typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(() => {
maybeAutoScrollToBottom();
});
observer.observe(el, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [maybeAutoScrollToBottom]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
useLayoutEffect(() => {
maybeAutoScrollToBottom();
}, [
maybeAutoScrollToBottom,
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.status,
state.progressEvents.length,
state.sampleAnalysisStreamingText,
state.sampleAnalysisThinkingText,
state.sampleStyleProfile,
state.intentStreamingText,
state.intentThinkingText,
state.parsedIntent,
state.keywordStreamingText,
state.keywordThinkingText,
state.skillSearchSteps.length,
state.skillSelectStreamingText,
state.skillSelectThinkingText,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.outlineThinkingText,
state.revisionSummary,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
enableAutoScroll();
scrollToBottomNow();
}, [enableAutoScroll, scrollToBottomNow]);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
void openHistory(session);
@@ -449,7 +566,13 @@ export function AIWritingPanel({
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
<div
ref={scrollRef}
className={cn(
'absolute inset-0 overflow-y-auto',
autoScroll && '[overflow-anchor:none]',
)}
>
{/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */}
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
@@ -466,27 +589,16 @@ export function AIWritingPanel({
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
{(state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && !isHistoryMode && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
sampleAnalysisSlot={sampleAnalysisSlot}
sampleProfileSlot={sampleProfileSlot}
intentSlot={intentSlot}
intentSlotDone={!!state.parsedIntent && !state.intentStreamingText && !state.intentThinkingText}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
@@ -499,6 +611,8 @@ export function AIWritingPanel({
wordCountTarget={state.request?.wordCountTarget}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
materialSource={state.request?.materialSource ?? 'general'}
canUseNotebookSearch={state.request?.noteSearchSpaceId != null}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={false}
@@ -534,6 +648,16 @@ export function AIWritingPanel({
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 检索词生成过程:先展示模型如何从写作意图提炼搜索方向,再进入技能匹配 / 检索。 */}
{state.status === 'researching' && (
state.keywordStreamingText || state.keywordThinkingText
) && (
<KeywordStreamingView
text={state.keywordStreamingText}
thinkingText={state.keywordThinkingText}
/>
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
@@ -567,12 +691,16 @@ export function AIWritingPanel({
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 技能检索占位卡 / 选择打字机 / 已流式的技能步骤 已经在展示,不再叠加 skeleton。
/* 检索词生成卡 / 技能检索占位卡 / 选择打字机 / 已流式的技能步骤
已经在展示,不再叠加 skeleton。
(研究阶段一旦有 skill_search_step,就改由上面的 SkillStepsCard 实时呈现进度,
研究词生成阶段 steps 还为空时仍走 skeleton 透传 researchProgress 文案。) */
关键词 chunk 还没到之前仍走 skeleton 透传 researchProgress 文案。) */
!state.skillSearchPending &&
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
!(state.status === 'researching' && (state.keywordStreamingText || state.keywordThinkingText)) &&
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 样文分析已有流式过程卡时,不再叠加 skeleton */
!state.sampleAnalysisStreamingText && !state.sampleAnalysisThinkingText &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
@@ -589,6 +717,7 @@ export function AIWritingPanel({
{state.error}
</div>
)}
<div ref={bottomRef} className="h-px" aria-hidden="true" />
</div>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
@@ -1,3 +1,5 @@
import type { ReactNode } from 'react';
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
@@ -7,12 +9,17 @@ interface AIWritingTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: React.ReactNode;
currentInterventionSlot?: ReactNode;
sampleAnalysisSlot?: ReactNode;
sampleProfileSlot?: ReactNode;
intentSlot?: ReactNode;
intentSlotDone?: boolean;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'sample_profile_ready',
'materials_ready',
'outline_ready',
'draft_ready',
@@ -30,44 +37,162 @@ export function AIWritingTimeline({
completedInterventions,
currentPause,
currentInterventionSlot,
sampleAnalysisSlot,
sampleProfileSlot,
intentSlot,
intentSlotDone = false,
}: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
if (visible.length === 0 && !intentSlot && !sampleAnalysisSlot && !sampleProfileSlot) return null;
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
let awaitUserIdx = 0;
let intentInserted = false;
const lastSampleEventIndex = visible.reduce(
(last, event, index) => (isSampleEvent(event) ? index : last),
-1,
);
const rows: Array<{
key: string;
event: ProgressEvent;
slot: ReactNode;
extendLineThroughSlot?: boolean;
}> = [];
const makeIntentEvent = (): ProgressEvent => ({
type: intentSlotDone ? 'step_done' : 'step_start',
sessionId: 'intent-slot',
agentName: '系统',
message: intentSlotDone ? '意图解析完成' : '正在解析写作意图',
});
const pushIntentRow = (key: string) => {
if (!intentSlot || intentInserted) return;
rows.push({
key,
event: makeIntentEvent(),
slot: intentSlot,
extendLineThroughSlot: false,
});
intentInserted = true;
};
const makeSampleAnalysisEvent = (): ProgressEvent => ({
type: 'step_start',
sessionId: 'sample-analysis-slot',
agentName: '样文分析师',
message: '正在分析样文风格',
});
if (sampleAnalysisSlot && lastSampleEventIndex < 0) {
rows.push({
key: 'sample-analysis-live',
event: makeSampleAnalysisEvent(),
slot: sampleAnalysisSlot,
extendLineThroughSlot: false,
});
}
visible.forEach((event, idx) => {
let slot: ReactNode = null;
let extendLineThroughSlot = true;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && idx === visible.length - 1) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
if (intentSlot && isIntentDoneEvent(event)) {
slot = slot ? (
<div className="space-y-2">
{slot}
{intentSlot}
</div>
) : intentSlot;
extendLineThroughSlot = false;
intentInserted = true;
}
if (sampleAnalysisSlot && idx === lastSampleEventIndex) {
slot = slot ? (
<div className="space-y-2">
{slot}
{sampleAnalysisSlot}
</div>
) : sampleAnalysisSlot;
extendLineThroughSlot = false;
} else if (sampleProfileSlot && event.type === 'sample_profile_ready') {
slot = slot ? (
<div className="space-y-2">
{slot}
{sampleProfileSlot}
</div>
) : sampleProfileSlot;
extendLineThroughSlot = false;
}
rows.push({
key: `event-${idx}`,
event,
slot,
extendLineThroughSlot,
});
if (
intentSlot &&
!intentSlotDone &&
!intentInserted &&
lastSampleEventIndex >= 0 &&
idx === lastSampleEventIndex
) {
pushIntentRow(`intent-after-sample-${idx}`);
}
});
if (!intentInserted) {
pushIntentRow('intent-fallback');
}
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
let slot: React.ReactNode = null;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
return (
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
<TimelineStep
key={row.key}
event={row.event}
isLast={isLast && (!row.slot || !row.extendLineThroughSlot)}
>
{row.slot}
</TimelineStep>
);
})}
</div>
);
}
function isSampleEvent(event: ProgressEvent): boolean {
return event.agentName === '样文分析师' || event.type === 'sample_profile_ready';
}
function isIntentDoneEvent(event: ProgressEvent): boolean {
return (
event.type === 'step_done' &&
event.agentName === '系统' &&
(event.message ?? '').includes('意图解析完成')
);
}
@@ -26,6 +26,7 @@ export function DraftSync({ status, markdown, title }: DraftSyncProps) {
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
// writing_draft / revising 才只读。
const generating =
status === 'analyzing_sample' ||
status === 'researching' ||
status === 'writing_outline' ||
streaming;
@@ -7,6 +7,7 @@ interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
hideTargetAudience?: boolean;
embedded?: boolean;
}
/**
@@ -17,13 +18,14 @@ export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
embedded = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
return (
<div className="px-4 pb-2">
<div className={embedded ? 'mt-2' : 'px-4 pb-2'}>
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/40 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
@@ -5,6 +5,13 @@ import {
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
@@ -30,6 +37,8 @@ interface InterventionCardProps {
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
materialSource?: MaterialSource;
canUseNotebookSearch?: boolean;
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
@@ -38,7 +47,8 @@ interface InterventionCardProps {
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps,
materialSource, canUseNotebookSearch, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
@@ -47,6 +57,8 @@ export function InterventionCard({
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
initialMaterialSource={materialSource ?? 'general'}
canUseNotebookSearch={canUseNotebookSearch}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
@@ -117,7 +129,11 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
@@ -126,17 +142,73 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
initialMaterialSource: MaterialSource;
canUseNotebookSearch?: boolean;
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
];
function MaterialSourceSwitcher({
value,
onChange,
options,
disabled,
}: {
value: MaterialSource;
onChange: (value: MaterialSource) => void;
options: Array<{ value: MaterialSource; label: string; desc: string }>;
disabled?: boolean;
}) {
const current = options.find((opt) => opt.value === value) ?? options[0];
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-input bg-muted/30 px-2.5 py-2">
<span className="text-xs font-medium text-muted-foreground">检索方式</span>
<Select value={current.value} onValueChange={(v) => onChange(v as MaterialSource)} disabled={disabled}>
<SelectTrigger className="h-7 w-[9rem] bg-background px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
{current.desc}
</span>
</div>
);
}
function MaterialConfirmCard({
materials,
skillSearchSteps,
initialMaterialSource,
canUseNotebookSearch,
skillSearchInProgress,
onSubmit,
disabled,
}: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [userQuery, setUserQuery] = useState('');
const [materialSource, setMaterialSource] = useState<MaterialSource>(
initialMaterialSource === 'notebook' && !canUseNotebookSearch ? 'general' : initialMaterialSource,
);
const [submitting, setSubmitting] = useState(false);
const sourceOptions = MATERIAL_SOURCE_OPTIONS.filter((opt) => opt.value !== 'notebook' || canUseNotebookSearch);
async function handleConfirm() {
setSubmitting(true);
@@ -148,7 +220,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
await onSubmit({
action: 're_search',
approvedMaterialIds: [],
userQuery: userQuery.trim() || undefined,
materialSource,
});
setSubmitting(false);
}
@@ -163,7 +240,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以切换检索方式并描述想检索的内容,或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -172,6 +249,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -211,7 +295,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
或在下方切换检索方式并描述想补充检索的内容,系统会把新素材追加进列表。
</p>
</div>
</div>
@@ -226,6 +310,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
});
}} />
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight, Hash, Search } from 'lucide-react';
interface KeywordStreamingViewProps {
text: string;
thinkingText?: string;
}
function extractStreamingKeywords(text: string): string[] {
if (!text) return [];
const keyMatch = text.match(/["']?keywords["']?\s*:\s*\[/);
if (!keyMatch) return [];
const start = keyMatch.index! + keyMatch[0].length;
const rest = text.slice(start);
const endIdx = rest.indexOf(']');
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
const keywords: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(arrBody)) !== null) {
const keyword = m[1]
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.trim();
if (keyword) keywords.push(keyword);
}
return Array.from(new Set(keywords));
}
export function KeywordStreamingView({ text, thinkingText }: KeywordStreamingViewProps) {
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
const keywords = useMemo(() => extractStreamingKeywords(text), [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
return (
<div className="px-4 pb-2 space-y-2">
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[keyword-blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
<div className="rounded-xl border border-sky-200 dark:border-sky-900 bg-sky-50/60 dark:bg-sky-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-200/70 dark:border-sky-900/60 bg-sky-100/45 dark:bg-sky-900/30">
<Search className="size-3.5 text-sky-500 shrink-0" />
<span className="text-xs font-medium text-sky-700 dark:text-sky-300">
生成检索词
{keywords.length > 0 && (
<span className="ml-1 text-sky-600/80 dark:text-sky-300/80 font-normal">
· 已识别 {keywords.length} 个方向
</span>
)}
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-sky-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<div className="px-3 py-2.5">
{keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{keywords.map((keyword, idx) => (
<span
key={`${keyword}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] bg-sky-100 dark:bg-sky-900/60 text-sky-700 dark:text-sky-200 animate-[keyword-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Hash className="size-2.5 shrink-0" />
{keyword}
</span>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
等待模型提炼检索方向
<span className="inline-block w-0.5 h-3 bg-sky-400 align-text-bottom animate-[keyword-blink_1s_step-end_infinite]" />
</div>
)}
</div>
</div>
<style>{`
@keyframes keyword-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes keyword-fade-in {
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
补充检索(可选)— 选择检索方式后描述想检索的内容,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -34,16 +34,23 @@ function ScoreBar({ score }: { score: number }) {
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5 space-y-0.5">
<div>
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
{issue.location && (
<span className="text-muted-foreground/70 mr-1">【{issue.location}】</span>
)}
<span className="text-muted-foreground">{issue.issue}</span>
</div>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
<div className="text-orange-600 dark:text-orange-400">
建议:{issue.suggestion}
</div>
)}
</div>
);
@@ -1,6 +1,6 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
import type { ReviewIssue, RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
@@ -12,6 +12,45 @@ const CATEGORY_LABEL: Record<string, string> = {
language: '语言',
};
function IssueSummaryRow({ issue, skipped = false }: { issue: ReviewIssue; skipped?: boolean }) {
return (
<div
className={cn(
'flex items-start gap-1.5 pl-5 leading-snug',
skipped ? 'text-muted-foreground/60' : 'text-muted-foreground',
)}
>
<span
className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
issue.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
issue.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
skipped && 'opacity-70',
)}
>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<div className={cn('min-w-0 flex-1 space-y-0.5', skipped && 'line-through decoration-muted-foreground/50')}>
{issue.location && (
<div className="text-[11px] text-muted-foreground/70">
位置:{issue.location}
</div>
)}
<div className={skipped ? 'text-muted-foreground/70' : 'text-foreground/85 dark:text-foreground/80'}>
{issue.issue}
</div>
{issue.suggestion && (
<div className={skipped ? 'text-muted-foreground/60' : 'text-muted-foreground'}>
<span className="text-primary mr-1">建议:</span>
{issue.suggestion}
</div>
)}
</div>
</div>
);
}
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
@@ -39,19 +78,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
{revisedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} />)}
</div>
)}
@@ -62,11 +89,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
{skippedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} skipped />)}
</div>
)}
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
interface SampleAnalysisStreamingViewProps {
text: string;
thinkingText?: string;
}
export function SampleAnalysisStreamingView({ text, thinkingText }: SampleAnalysisStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="mt-2 space-y-2">
{thinkingText && (
<div className="overflow-hidden rounded-xl border border-amber-200 bg-amber-50/50 shadow-sm dark:border-amber-900 dark:bg-amber-950/20">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="flex w-full items-center gap-2 border-b border-amber-200/60 bg-amber-100/40 px-3 py-2 transition-colors hover:bg-amber-100/70 dark:border-amber-900/60 dark:bg-amber-900/30 dark:hover:bg-amber-900/50"
>
<Brain className="size-3.5 shrink-0 text-amber-500" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">样文分析思考</span>
<span className="ml-auto text-amber-400">
{thinkOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="max-h-36 overflow-y-auto whitespace-pre-wrap p-3 font-mono text-[11px] italic leading-relaxed text-amber-700/70 dark:text-amber-300/60"
>
{thinkingText}
</pre>
)}
</div>
)}
{text && (
<div className="overflow-hidden rounded-xl border border-cyan-200 bg-cyan-50/50 shadow-sm dark:border-cyan-900 dark:bg-cyan-950/20">
<div className="flex items-center gap-2 border-b border-cyan-200/60 bg-cyan-100/40 px-3 py-2 dark:border-cyan-900/60 dark:bg-cyan-900/30">
<Sparkles className="size-3.5 shrink-0 text-cyan-500" />
<span className="text-xs font-medium text-cyan-700 dark:text-cyan-300">样文分析中…</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 animate-bounce rounded-full bg-cyan-400"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="max-h-52 overflow-y-auto whitespace-pre-wrap p-3 font-mono text-[11px] leading-relaxed text-muted-foreground"
>
{text}
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-[blink_1s_step-end_infinite] align-text-bottom bg-cyan-400" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { FileText, ListTree, MessageSquareText, ShieldCheck, WandSparkles } from 'lucide-react';
import type { SampleStyleProfile } from '../../types/ai-writing';
interface SampleStyleProfileCardProps {
profile: SampleStyleProfile;
}
export function SampleStyleProfileCard({ profile }: SampleStyleProfileCardProps) {
return (
<div className="mt-2 overflow-hidden rounded-xl border border-cyan-200 bg-cyan-50/40 shadow-sm dark:border-cyan-900 dark:bg-cyan-950/20">
<div className="flex items-center gap-2 border-b border-cyan-200/60 bg-cyan-100/40 px-3 py-2 dark:border-cyan-900/60 dark:bg-cyan-900/30">
<WandSparkles className="size-3.5 shrink-0 text-cyan-500" />
<span className="text-xs font-medium text-cyan-700 dark:text-cyan-300">文风画像</span>
</div>
<div className="space-y-3 px-3 py-2.5 text-xs">
{profile.summary && (
<p className="leading-relaxed text-foreground">{profile.summary}</p>
)}
<div className="grid gap-2 md:grid-cols-2">
<Info icon={<FileText className="size-3.5" />} label="文章类型" value={profile.articleTypeGuess} />
<Info icon={<MessageSquareText className="size-3.5" />} label="语气特征" value={profile.tone} />
<Info icon={<ListTree className="size-3.5" />} label="句式节奏" value={profile.sentenceStyle} />
<Info icon={<ShieldCheck className="size-3.5" />} label="避抄边界" value={profile.avoidCopying?.[0]} />
</div>
<ListBlock title="结构骨架" items={profile.structure} />
<ListBlock title="语言特征" items={profile.languageFeatures} />
<ListBlock title="仿写建议" items={profile.imitationGuidance} />
</div>
</div>
);
}
function Info({ icon, label, value }: { icon: ReactNode; label: string; value?: string }) {
if (!value) return null;
return (
<div className="rounded-lg bg-white/70 px-2.5 py-2 dark:bg-[#022248]/70">
<div className="flex items-center gap-1.5 text-cyan-700 dark:text-cyan-300">
{icon}
<span className="font-medium">{label}</span>
</div>
<p className="mt-1 leading-relaxed text-muted-foreground">{value}</p>
</div>
);
}
function ListBlock({ title, items }: { title: string; items?: string[] }) {
const visible = (items ?? []).filter(Boolean).slice(0, 4);
if (visible.length === 0) return null;
return (
<div>
<p className="mb-1 font-medium text-foreground">{title}</p>
<ul className="space-y-1 text-muted-foreground">
{visible.map((item, idx) => (
<li key={`${title}-${idx}`} className="flex gap-1.5 leading-relaxed">
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-cyan-400" />
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
@@ -21,6 +21,7 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
}
const AGENT_COLOR: Record<string, string> = {
样文分析师: 'text-cyan-500',
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import type { Message } from '@langchain/langgraph-sdk';
import { FileText, Loader2, Sparkles, Upload, Zap } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -16,7 +17,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Zap } from 'lucide-react';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
@@ -33,9 +33,44 @@ import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { extractAIWritingSample } from '../../api/ai-writing-sample';
import type { AIWritingRequest } from '../../types/ai-writing';
import { WritingWelcome } from './WritingWelcome';
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const SETUP_SAMPLE_CONTEXT_TEXT_LENGTH = 12000;
const IMITATE_STRENGTH_OPTIONS: Array<{ value: 'light' | 'medium' | 'strong'; label: string }> = [
{ value: 'light', label: '轻度' },
{ value: 'medium', label: '中度' },
{ value: 'strong', label: '高度' },
];
type WritingFormOptional = ReturnType<typeof useWritingFormOptional>;
function buildWritingSampleContext(formCtx: WritingFormOptional): Record<string, unknown> | undefined {
if (!formCtx || formCtx.writingModeType !== 'imitate') return undefined;
const text = formCtx.sampleText.trim();
if (!text) return undefined;
return {
writing_sample_context: {
filename: formCtx.sampleFilename || '粘贴文本',
text: text.slice(0, SETUP_SAMPLE_CONTEXT_TEXT_LENGTH),
original_length: text.length,
imitate_strength: formCtx.imitateStrength,
preserve_sample_structure: formCtx.preserveSampleStructure,
},
};
}
function isOptimisticMessage(message: Message): boolean {
return typeof message.id === 'string' && message.id.startsWith('opt-');
}
function buildSampleDisplayText(filename: string, text: string): string {
const label = filename.trim() || '粘贴文本';
return `已识别样文(${label}):\n\n${text.trim()}`;
}
/**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
*
@@ -63,6 +98,10 @@ export function WritingSetupChat({
const [chatModel, setChatModel] = useState('');
// 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。
const [quickMode, setQuickMode] = useState(false);
const [sampleUploading, setSampleUploading] = useState(false);
const [sampleMessage, setSampleMessage] = useState('');
const [sampleError, setSampleError] = useState('');
const [sampleDisplay, setSampleDisplay] = useState<{ message: Message; insertAfter: number } | null>(null);
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
@@ -71,6 +110,16 @@ export function WritingSetupChat({
// 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。
const aiWriting = useAIWritingOptional();
const canStartWriting = !onApproveProp && !!aiWriting;
const currentWritingModeType = formCtx?.writingModeType ?? 'normal';
const isImitateMode = currentWritingModeType === 'imitate';
const recognizedSampleText = formCtx?.sampleText.trim() ?? '';
const sampleReady = isImitateMode && recognizedSampleText.length > 0;
const awaitingSample = isImitateMode && !sampleReady;
const inputPlaceholder = isImitateMode
? sampleReady
? '样文已识别,请输入你的仿写要求,例如:按这个风格写一篇 1500 字周报'
: '上传样文,或直接粘贴样文正文后点击「识别样文」'
: '例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道';
// 首个模型可用时默认选中
useEffect(() => {
@@ -111,9 +160,77 @@ export function WritingSetupChat({
const trimmed = text.trim();
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
},
[thread, ensureThreadExists, sendMessage, threadId],
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const publishSampleMessage = useCallback(
(filename: string, text: string) => {
const normalized = text.trim();
if (!normalized) return;
const insertAfter = thread.messages.filter((message) => !isOptimisticMessage(message)).length;
setSampleDisplay({
insertAfter,
message: {
type: 'human',
id: `local-ai-writing-sample-${Date.now()}`,
content: buildSampleDisplayText(filename, normalized),
additional_kwargs: {
ai_writing_sample: true,
client_ts: new Date().toISOString(),
},
},
});
},
[thread.messages],
);
const handleSampleFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !formCtx) return;
setSampleUploading(true);
setSampleError('');
setSampleMessage('');
try {
const result = await extractAIWritingSample(file);
formCtx.setSampleFilename(result.filename);
formCtx.setSampleText(result.text);
publishSampleMessage(result.filename, result.text);
setSampleMessage(result.truncated ? '已识别样文,内容较长已截断' : '已识别样文');
} catch (err) {
setSampleError(err instanceof Error ? err.message : '样文解析失败');
} finally {
setSampleUploading(false);
}
},
[formCtx, publishSampleMessage],
);
const handleComposerSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
await handleChatSubmit(text);
return;
}
const trimmed = text.trim();
if (currentWritingModeType === 'imitate' && formCtx && !formCtx.sampleText.trim()) {
if (!trimmed) {
setSampleError('请先上传或粘贴样文');
return;
}
formCtx.setSampleText(trimmed.slice(0, MAX_SAMPLE_TEXT_LENGTH));
formCtx.setSampleFilename('粘贴文本');
publishSampleMessage('粘贴文本', trimmed.slice(0, MAX_SAMPLE_TEXT_LENGTH));
setSampleError('');
setSampleMessage('已识别样文,请继续输入仿写要求');
return;
}
await handleChatSubmit(text);
},
[currentWritingModeType, formCtx, handleChatSubmit, publishSampleMessage, thread.isLoading],
);
// 提交按钮状态:生成中显示「停止」方块,可点击暂停对话
@@ -140,11 +257,19 @@ export function WritingSetupChat({
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
const skipResearch = formCtx?.skipResearch ?? false;
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
const writingModeType = formCtx?.writingModeType === 'imitate' ? 'imitate' : 'normal';
const sampleText = formCtx?.sampleText?.trim() ?? '';
const request: AIWritingRequest = {
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
writingModeType,
sampleText: writingModeType === 'imitate' ? sampleText : undefined,
sampleFilename: writingModeType === 'imitate' ? formCtx?.sampleFilename || '粘贴文本' : undefined,
imitateStrength: writingModeType === 'imitate' ? formCtx?.imitateStrength ?? 'medium' : undefined,
preserveSampleStructure: writingModeType === 'imitate' ? formCtx?.preserveSampleStructure ?? true : undefined,
skipIntentRecognition: true,
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
@@ -159,9 +284,9 @@ export function WritingSetupChat({
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
!skipResearch ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
noteSourceIds:
materialSource === 'notebook' && formCtx?.selectedNotebookId != null
!skipResearch && formCtx?.selectedNotebookId != null
? formCtx.selectedDocumentIds
: undefined,
};
@@ -170,13 +295,33 @@ export function WritingSetupChat({
toast.error('写作意图为空,请先补充意图');
return;
}
if (writingModeType === 'imitate' && !sampleText) {
toast.error('仿写模式需要先上传或粘贴样文');
return;
}
await aiWriting.startWriting(request);
},
[aiWriting, applyProposal, formCtx, chatModel],
);
const displayThread = useMemo(() => {
if (!sampleDisplay || !isImitateMode) return thread;
const messages = thread.messages;
const optimisticIndex = messages.findIndex(isOptimisticMessage);
const insertLimit = optimisticIndex >= 0 ? optimisticIndex : messages.length;
const insertIndex = Math.min(sampleDisplay.insertAfter, insertLimit);
return {
...thread,
messages: [
...messages.slice(0, insertIndex),
sampleDisplay.message,
...messages.slice(insertIndex),
],
} as typeof thread;
}, [isImitateMode, sampleDisplay, thread]);
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const showWelcome = displayThread.messages.length === 0 && !thread.isLoading;
// 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何
// 可见输出(无答案文本、无 <think> 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白,
@@ -197,7 +342,7 @@ export function WritingSetupChat({
}, [handleChatSubmit]);
return (
<ThreadContext.Provider value={{ thread }}>
<ThreadContext.Provider value={{ thread: displayThread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
@@ -216,7 +361,7 @@ export function WritingSetupChat({
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
thread={displayThread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
@@ -238,7 +383,7 @@ export function WritingSetupChat({
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
@@ -266,6 +411,36 @@ export function WritingSetupChat({
</Select>
)}
</div>
{formCtx && (
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span className="shrink-0">写作模式</span>
<div className="grid h-8 w-[10.5rem] grid-cols-2 rounded-md border border-input bg-background p-0.5 dark:border-[#1B477E] dark:bg-[#022248]">
{([
['normal', '普通写作'],
['imitate', '样文仿写'],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
disabled={thread.isLoading}
onClick={() => {
formCtx.setWritingModeType(value);
setSampleError('');
setSampleMessage('');
}}
className={cn(
'rounded-[5px] px-2 text-xs font-medium transition-colors',
currentWritingModeType === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted',
)}
>
{label}
</button>
))}
</div>
</div>
)}
{/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */}
<label
className={cn(
@@ -286,12 +461,101 @@ export function WritingSetupChat({
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInput onSubmit={({ text }) => void handleComposerSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道"
placeholder={inputPlaceholder}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} />
<PromptInputFooter className="flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{formCtx && isImitateMode && (
<>
<label
className={cn(
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-input bg-background px-2 font-medium shadow-xs transition-colors hover:bg-muted',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
thread.isLoading || sampleUploading ? 'pointer-events-none opacity-60' : '',
)}
>
{sampleUploading ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
上传样文
<input
type="file"
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
className="sr-only"
disabled={thread.isLoading || sampleUploading}
onChange={handleSampleFileChange}
/>
</label>
{sampleReady ? (
<div className="flex min-w-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-foreground dark:bg-[#0B376A]/60 dark:text-[#AACEFF]">
<FileText className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{formCtx.sampleFilename || '样文'} · {formCtx.sampleText.length} 字
</span>
<button
type="button"
disabled={thread.isLoading}
onClick={() => {
formCtx.setSampleText('');
formCtx.setSampleFilename('');
setSampleMessage('');
setSampleError('');
setSampleDisplay(null);
}}
className="ml-1 rounded px-1 text-muted-foreground hover:bg-background hover:text-foreground disabled:opacity-50"
>
清除
</button>
</div>
) : (
<span>先上传样文,或把样文正文粘贴到输入框</span>
)}
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
{sampleError && <span className="text-destructive">{sampleError}</span>}
</>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{formCtx && isImitateMode && (
<>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>仿写强度</span>
<Select
value={formCtx.imitateStrength}
onValueChange={(v) => formCtx.setImitateStrength(v as 'light' | 'medium' | 'strong')}
disabled={thread.isLoading}
>
<SelectTrigger className="h-7 w-[5.5rem] rounded-md border-input bg-background px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex cursor-pointer select-none items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={formCtx.preserveSampleStructure}
onChange={(e) => formCtx.setPreserveSampleStructure(e.target.checked)}
disabled={thread.isLoading}
className="size-3.5 shrink-0 accent-primary"
/>
沿用结构
</label>
</>
)}
<PromptInputSubmit
status={submitStatus}
disabled={sampleUploading}
size={awaitingSample && submitStatus !== 'streaming' ? 'sm' : 'icon-sm'}
className={awaitingSample && submitStatus !== 'streaming' ? 'rounded-lg px-3' : undefined}
>
{awaitingSample && submitStatus !== 'streaming' ? '识别样文' : undefined}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
@@ -217,10 +217,10 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
if (status === 'analyzing_sample' || status === 'researching' || status === 'awaiting_material_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<MaterialsSkeleton researchProgress={researchProgress} />
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
</div>
);
}
@@ -0,0 +1,193 @@
import { useCallback, useRef, useState } from "react";
import { CanvasThreadProvider } from "../../contexts/CanvasThreadContext";
import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
interface MarkdownAiEditorProps {
/** 原始 markdown(编辑缓冲),由父级持有;编辑时通过 onChange 回写。 */
markdown: string;
/** 编辑回调:返回最新 markdown,父级据此 setDraftContent 并标记有改动。 */
onChange?: (markdown: string) => void;
/** 当前线程 id:透传给 AI 改写接口,便于后端结合上下文。 */
threadId?: string | null;
/** 只读:禁用编辑、AI 改写。 */
readOnly?: boolean;
className?: string;
}
interface PanelPos {
x: number;
y: number;
}
type FlowPhase = "idle" | "menu" | "preview";
interface LastAction {
action: RewriteAction;
tone?: RewriteTone;
customInstruction?: string;
referenceText?: string;
}
/**
* 沙箱 md 的「AI 写作」编辑器:复用 AI 写作 那套 TipTap 编辑器 + 划词「AI 改写」气泡/段落
* 菜单 + 改写流式预览(润色/扩写/缩写/改语气/仿写/自定义指令)。自带 CanvasThread/CanvasUI
* Provider,故可直接嵌入任意页面,无需在外层包裹整页 Provider。
*
* 与 AI 写作 整页编辑器(TextArtifactRenderer)的差异:去掉对 CanvasGraphContext 的耦合(不
* 走全局 artifact 状态),去掉自带的工具栏/目录栏(保存/导出由宿主 ArtifactFileDetail 头部按钮
* 负责),更贴合侧边栏内的紧凑展示。
*/
export function MarkdownAiEditor(props: MarkdownAiEditorProps) {
return (
<CanvasThreadProvider initialThreadId={props.threadId ?? null}>
<CanvasUIProvider>
<MarkdownAiEditorInner {...props} />
</CanvasUIProvider>
</CanvasThreadProvider>
);
}
function MarkdownAiEditorInner({
markdown,
onChange,
readOnly = false,
className,
}: MarkdownAiEditorProps) {
const { writingSelection } = useCanvasUIContext();
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const editor = useWritingEditor({
markdown,
onChange,
editable: !readOnly,
placeholder: "空白文档…",
});
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } =
useWritingRewrite(editor);
const computePos = useCallback((): PanelPos => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (rect.width || rect.height) {
return { x: rect.left, y: rect.bottom + 6 };
}
}
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
}, []);
const openAiMenu = useCallback(() => {
reset();
setPanelPos(computePos());
setPhase("menu");
}, [reset, computePos]);
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
setPhase("idle");
reset();
}, [reset, editor]);
const handleAction = useCallback(
(
action: RewriteAction,
tone?: RewriteTone,
customInstruction?: string,
referenceText?: string,
) => {
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp],
);
const handleReplace = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
const handleInsert = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
{editor && !readOnly && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
{!readOnly && phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
error={error}
onAction={handleAction}
onAbort={reset}
onRetry={handleRetry}
onClose={closeAll}
/>
)}
{!readOnly && phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
originalText={writingSelection?.text ?? ""}
result={result}
thinking={thinking}
error={error}
action={lastActionRef.current?.action ?? "polish"}
tone={lastActionRef.current?.tone}
onReplace={handleReplace}
onInsert={handleInsert}
onRetry={handleRetry}
onAbort={reset}
onClose={closeAll}
onFollowUp={handleFollowUp}
/>
)}
</div>
);
}
@@ -52,6 +52,7 @@ const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
'draft_chunk',
'outline_chunk',
'intent_chunk',
'keyword_chunk',
'skill_select_chunk',
'review_chunk',
]);
@@ -426,16 +427,19 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const stream = streamRef.current;
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
// 默认是 general(通用检索),但用户可以在开写前或素材确认后手动切换检索方式。
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
skip_intent_parser: request.skipIntentRecognition ?? false,
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
sample_text: request.sampleText?.trim() || '',
sample_filename: request.sampleFilename?.trim() || '',
sample_style_profile: null,
imitate_strength: request.imitateStrength ?? 'medium',
preserve_sample_structure: request.preserveSampleStructure ?? true,
article_type: request.articleType,
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
article_type_detail: request.articleTypeDetail?.trim() || '',
@@ -452,7 +456,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
max_revisions: 3,
status: 'researching',
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
drafts: [],
progress_events: [],
material_package: null,
@@ -509,10 +513,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
}
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
const isApplyingReview =
state.reviewResult != null &&
(
(pausePoint === 'review_confirm' && payload.action === 'accept_review') ||
payload.selectedIssueIndices !== undefined
);
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const selectedSet = payload.selectedIssueIndices !== undefined
? new Set(payload.selectedIssueIndices)
: new Set(allIssues.map((_, i) => i));
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
@@ -541,6 +552,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.materialSource !== undefined) resumePayload.materialSource = payload.materialSource;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -556,7 +568,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext, materialSource: payload.materialSource });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
@@ -26,6 +26,16 @@ export interface WritingFormValue {
setShowOutline: (v: boolean) => void;
outlineText: string;
setOutlineText: (v: string) => void;
writingModeType: 'normal' | 'imitate';
setWritingModeType: (v: 'normal' | 'imitate') => void;
sampleText: string;
setSampleText: (v: string) => void;
sampleFilename: string;
setSampleFilename: (v: string) => void;
imitateStrength: 'light' | 'medium' | 'strong';
setImitateStrength: (v: 'light' | 'medium' | 'strong') => void;
preserveSampleStructure: boolean;
setPreserveSampleStructure: (v: boolean) => void;
articleType: string;
setArticleType: (v: string) => void;
wordCountInput: string;
@@ -83,6 +93,11 @@ export function WritingFormProvider({
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true);
const [outlineText, setOutlineText] = useState('');
const [writingModeType, setWritingModeType] = useState<'normal' | 'imitate'>('normal');
const [sampleText, setSampleText] = useState('');
const [sampleFilename, setSampleFilename] = useState('');
const [imitateStrength, setImitateStrength] = useState<'light' | 'medium' | 'strong'>('medium');
const [preserveSampleStructure, setPreserveSampleStructure] = useState(true);
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
@@ -105,12 +120,6 @@ export function WritingFormProvider({
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
// knowledge_base=知识库检索(直连内网 ES)保留;旧值 skill 归一为通用检索 general
if (payload.material_source === 'knowledge_base') {
setMaterialSource('knowledge_base');
} else if (payload.material_source === 'skill' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
@@ -123,6 +132,11 @@ export function WritingFormProvider({
formExpanded, setFormExpanded,
showOutline, setShowOutline,
outlineText, setOutlineText,
writingModeType, setWritingModeType,
sampleText, setSampleText,
sampleFilename, setSampleFilename,
imitateStrength, setImitateStrength,
preserveSampleStructure, setPreserveSampleStructure,
articleType, setArticleType,
wordCountInput, setWordCountInput,
audience, setAudience,
@@ -135,7 +149,7 @@ export function WritingFormProvider({
modelName, setModelName,
applyProposal,
}),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, writingModeType, sampleText, sampleFilename, imitateStrength, preserveSampleStructure, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -51,6 +51,7 @@ const INITIAL_STATE: AIWritingState = {
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
sampleStyleProfile: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
@@ -59,10 +60,14 @@ const INITIAL_STATE: AIWritingState = {
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
sampleAnalysisStreamingText: '',
sampleAnalysisThinkingText: '',
intentStreamingText: '',
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
@@ -82,7 +87,7 @@ export type Action =
| { type: 'handle_batch'; events: ProgressEvent[] }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext']; materialSource?: AIWritingRequest['materialSource'] }
| { type: 'pause' }
| { type: 'resume_ui' }
// Restore history: rebuild state from the graph checkpoint values snapshot.
@@ -101,7 +106,11 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
return action.events.reduce(handleEvent, prev);
case 'start':
return { ...INITIAL_STATE, status: 'researching', request: action.request };
return {
...INITIAL_STATE,
status: action.request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
request: action.request,
};
case 'set_session':
return { ...prev, sessionId: action.sessionId };
@@ -119,14 +128,24 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
currentPause: null,
status: ns,
skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending,
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
...(ns === 'researching' ? {
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
} : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
// 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}),
...(action.materialSource && prev.request
? { request: { ...prev.request, materialSource: action.materialSource } }
: {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
pendingRevisionContext: action.pendingRevisionContext,
};
}
@@ -145,10 +164,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
return {
...prev,
isPaused: false,
sampleAnalysisStreamingText: '', sampleAnalysisThinkingText: '',
intentStreamingText: '', intentThinkingText: '',
outlineStreamingText: '', outlineThinkingText: '',
streamingDraftSections: [], streamingDraftTitle: '',
streamingReviewItems: [],
keywordStreamingText: '', keywordThinkingText: '',
skillSelectStreamingText: '', skillSelectThinkingText: '',
};
@@ -201,6 +222,16 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'step_done':
return base;
case 'sample_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
sampleAnalysisStreamingText: isThinking ? prev.sampleAnalysisStreamingText : prev.sampleAnalysisStreamingText + text,
sampleAnalysisThinkingText: isThinking ? prev.sampleAnalysisThinkingText + text : prev.sampleAnalysisThinkingText,
};
}
case 'intent_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
@@ -239,6 +270,27 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'research_progress':
return { ...base, researchProgress: event.message ?? '' };
case 'keyword_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const visibleText = text || (prev.keywordStreamingText ? '' : ' ');
return {
...prev,
status: 'researching',
keywordStreamingText: isThinking ? prev.keywordStreamingText : prev.keywordStreamingText + visibleText,
keywordThinkingText: isThinking ? prev.keywordThinkingText + text : prev.keywordThinkingText,
};
}
case 'sample_profile_ready':
return {
...base,
sampleStyleProfile: event.sampleStyleProfile ?? null,
sampleAnalysisStreamingText: '',
sampleAnalysisThinkingText: '',
status: 'researching',
};
case 'resumed':
return { ...base, currentPause: null };
@@ -269,6 +321,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
};
}
@@ -712,6 +766,23 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
if (out.sample_style_profile && !out.sampleStyleProfile) {
const p = out.sample_style_profile as Record<string, unknown>;
out.sampleStyleProfile = {
summary: p.summary as string | undefined,
articleTypeGuess: (p.article_type_guess ?? p.articleTypeGuess) as string | undefined,
tone: p.tone as string | undefined,
structure: (p.structure as string[] | undefined) ?? [],
languageFeatures: (p.language_features ?? p.languageFeatures ?? []) as string[],
sentenceStyle: (p.sentence_style ?? p.sentenceStyle) as string | undefined,
openingStyle: (p.opening_style ?? p.openingStyle) as string | undefined,
endingStyle: (p.ending_style ?? p.endingStyle) as string | undefined,
imitationGuidance: (p.imitation_guidance ?? p.imitationGuidance ?? []) as string[],
avoidCopying: (p.avoid_copying ?? p.avoidCopying ?? []) as string[],
sampleOutline: (p.sample_outline ?? p.sampleOutline ?? []) as string[],
};
}
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
@@ -753,6 +824,9 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : null;
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
const sampleProfileEvent = values.sample_style_profile
? normalizeProgressDict({ type: 'sample_profile_ready', sample_style_profile: values.sample_style_profile }, sessionId)
: null;
return {
sessionId,
@@ -764,6 +838,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
currentDraftTitle: String(draft?.title ?? ''),
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
reviewResult: normalizeReviewResult(values.review_result),
sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null,
progressEvents,
completedInterventions,
};
@@ -799,12 +874,15 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
const allowed: ProgressEventType[] = [
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'sample_chunk',
'sample_profile_ready',
'research_progress', 'keyword_chunk', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
'model_switch',
];
if (!allowed.includes(type as ProgressEventType)) return null;
if (type === 'sample_profile_ready') return normalizeProgressDict(o, sessionId);
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
@@ -933,6 +1011,12 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
export type AIWritingValues = {
user_intent?: string;
user_outline_raw?: string;
writing_mode_type?: string;
sample_text?: string;
sample_filename?: string;
sample_style_profile?: unknown;
imitate_strength?: string;
preserve_sample_structure?: boolean;
article_type?: string;
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
article_type_detail?: string;
@@ -0,0 +1,155 @@
/**
* AI 写作 · 对话版(对话驱动写作台)。
*
* 与经典版 `AIWritingPage` 的关系:**并存的新入口,旧页面零改动**。
*
* 布局分两态(与用户预期对齐:「初始和原来一样,进入写作才进入新页面」):
* - idle(未开始写作、非历史回看):沿用经典版的初始双栏——左「写作助手 Q&A」
* (WritingSetupChat)+ 右「写作配置选项」表单(AIWritingPanel,可整块收起),
* 由 WritingFormProvider 承载表单状态;点表单「开始写作」或助手「开始写作」
* 后 status 离开 idle;
* - 写作中 / 历史回看:切到对话驱动工作台——左 WritingChatPanel(统一信息流 +
* 按钮版干预卡 + 底部智能输入框),右 AIWritingDraftPanel(草稿实时预览)。
*
* 引擎层完全复用:AIWritingProvider(状态机 + LangGraph useStream)、
* AIWritingHistoryProvider(历史回看)。路由:/page/canvas/ai-writing-chat。
*/
import { useRef } from 'react';
import { PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { cn } from '@/lib/utils';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { WritingChatPanel } from '../components/ai-writing-chat/WritingChatPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
const CHAT_ID = 'awc-chat';
const DRAFT_ID = 'awc-draft';
// 对话区略宽于经典版左栏,以容纳信息流 + 干预卡
const DEFAULT_LAYOUT = { [CHAT_ID]: 40, [DRAFT_ID]: 60 };
/**
* idle 初始布局 —— 与经典版 AIWritingPage 的 idle 态一致:
* 左「写作助手 Q&A」恒为第一个子节点(位置稳定不重挂,避免 WritingSetupChat
* 内部线程丢失),右「写作配置表单」整块可收起(formExpanded 联动)。
*/
function SetupLayout() {
const { formExpanded, setFormExpanded } = useWritingForm();
return (
<div className="relative flex h-full">
<div className="min-h-0 flex-1 overflow-hidden">
<WritingSetupChat />
</div>
<div
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields />
</div>
</div>
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
);
}
/** 写作中 / 历史回看:对话驱动工作台(左信息流 + 右草稿预览)。 */
function ChatWorkbenchLayout() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
<ResizablePanel
id={CHAT_ID}
defaultSize={40}
minSize={25}
className="overflow-hidden flex flex-col"
>
<WritingChatPanel />
</ResizablePanel>
<ResizableHandle
withHandle
className="after:left-0 after:w-3 after:translate-x-0 [&>div]:translate-x-1.5"
/>
<ResizablePanel
id={DRAFT_ID}
defaultSize={60}
minSize={35}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
);
}
function AIWritingChatBody() {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// idle 且非历史回看 → 经典初始布局;开始写作 / 查看历史 → 对话驱动工作台。
// 两套布局都保持挂载态简单切换:SetupLayout 内的 WritingSetupChat 在开始写作后
// 卸载没有代价(它只负责启动前配置,线程与写作 graph 解耦)。
if (state.status === 'idle' && !isHistoryMode) {
return <SetupLayout />;
}
return <ChatWorkbenchLayout />;
}
export default function AIWritingChatPage() {
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingChatBody />
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -635,7 +635,6 @@ export default function AIWritingPage({
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
@@ -3,6 +3,7 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import AIWritingChatPage from "../pages/AIWritingChatPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
@@ -12,6 +13,7 @@ export default function OpenCanvasRoutes() {
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing/*" element={<AIWritingPage hideAdvancedFields />} />
<Route path="ai-writing-chat/*" element={<AIWritingChatPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
@@ -135,10 +135,27 @@ export interface ReviewResult {
revisionNotes: ReviewIssue[];
}
// ── 样文仿写 ─────────────────────────────────────────────────────────────────
export interface SampleStyleProfile {
summary?: string;
articleTypeGuess?: string;
tone?: string;
structure?: string[];
languageFeatures?: string[];
sentenceStyle?: string;
openingStyle?: string;
endingStyle?: string;
imitationGuidance?: string[];
avoidCopying?: string[];
sampleOutline?: string[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| "idle"
| "analyzing_sample"
| "researching"
| "awaiting_material_confirm"
| "writing_outline"
@@ -158,8 +175,11 @@ export type ProgressEventType =
| "connected"
| "step_start"
| "step_done"
| "sample_chunk"
| "sample_profile_ready"
| "intent_chunk"
| "intent_ready"
| "keyword_chunk"
| "research_progress"
| "materials_ready"
| "outline_chunk"
@@ -226,6 +246,8 @@ export interface ProgressEvent {
sectionIndex?: number;
sectionTitle?: string;
};
// sample_profile_ready:样文仿写的文风画像
sampleStyleProfile?: SampleStyleProfile;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -237,6 +259,8 @@ export interface UserInterventionPayload {
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook";
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
@@ -253,6 +277,15 @@ export interface AIWritingRequest {
userIntent: string;
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
userOutline?: string;
/** 写作入口模式:normal=普通写作 / imitate=样文仿写 */
writingModeType?: "normal" | "imitate";
/** 样文原文(上传提取或直接粘贴),仅样文仿写模式使用 */
sampleText?: string;
sampleFilename?: string;
imitateStrength?: "light" | "medium" | "strong";
preserveSampleStructure?: boolean;
/** 从初始化对话确认后进入正式写作时,意图字段已经结构化,可跳过后端二次识别。 */
skipIntentRecognition?: boolean;
articleType: string;
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
articleTypeDetail?: string;
@@ -356,6 +389,7 @@ export interface AIWritingState {
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
sampleStyleProfile: SampleStyleProfile | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
@@ -366,12 +400,18 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 样文分析(流式 + 结果)
sampleAnalysisStreamingText: string;
sampleAnalysisThinkingText: string;
// 意图解析(流式 + 结果)
intentStreamingText: string;
intentThinkingText: string;
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 检索词生成 LLM 流式打字内容(正文 / 思考分开)
keywordStreamingText: string;
keywordThinkingText: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
+10 -3
View File
@@ -37,6 +37,7 @@ import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react";
@@ -100,6 +101,11 @@ export default function ChatPage() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// `chatMode=full` is used by an embedded standard chat. It intentionally
// overrides the user's compact-layout preference for this route only, so
// the page keeps the full landing composition and its agent/history sidebar.
const fullChatMode =
searchParams.get("chatMode")?.trim().toLowerCase() === "full";
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar();
const { t } = useI18n();
@@ -451,7 +457,7 @@ export default function ChatPage() {
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
const chromeByPreference =
layoutMode === "full"
fullChatMode || layoutMode === "full"
? true
: layoutMode === "compact"
? false
@@ -459,6 +465,7 @@ export default function ChatPage() {
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
const compactActionsHidden = isHideChromeActive();
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@@ -483,7 +490,7 @@ export default function ChatPage() {
</div>
<div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && (
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
@@ -507,7 +514,7 @@ export default function ChatPage() {
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && (
{!compactChrome && !compactActionsHidden && (
<>
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
@@ -28,7 +28,7 @@ import {
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useTheme } from "@/contexts/ThemeContext";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import {
useGraphBlacklist,
useGraphBlacklistActions,
@@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() {
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
theme={theme}
theme={themeToBaseMode(theme)}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
+11 -1
View File
@@ -21,7 +21,17 @@ export default function LightAppIframeView() {
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
const themeColor: AppThemeColor =
theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red";
theme === "dark"
? "dark-blue"
: theme === "lightgreen"
? "green"
: theme === "lightblue"
? "blue"
: theme === "iceblue"
? "iceblue"
: theme === "lightyellow"
? "lightyellow"
: "red";
const src = useMemo(
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
[app, themeColor],
+80 -12
View File
@@ -34,7 +34,8 @@ import { toast } from "sonner";
import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -64,12 +65,79 @@ function hashIndex(str: string, mod: number): number {
return mod > 0 ? h % mod : 0;
}
function getLightAccent(theme: ThemeType) {
if (isCustomThemeId(theme)) {
return {
dot: "bg-[var(--primary)]",
badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]",
focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]",
text: "text-[var(--accent-foreground)]",
hoverText: "group-hover:text-[var(--accent-foreground)]",
link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]",
button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]",
};
}
if (theme === "lightgreen") {
return {
dot: "bg-[#07C05F]",
badge: "border-[#07C05F]/15 bg-[#E9F8EC] text-[#049B38]",
focus: "focus:border-[#07C05F]/60 focus:ring-[#07C05F]/20",
text: "text-[#049B38]",
hoverText: "group-hover:text-[#049B38]",
link: "text-[#049B38] hover:text-[#038626]",
button: "bg-[#07C05F] shadow-[0_1px_3px_rgba(7,192,95,0.2)] hover:bg-[#08DD6E]",
};
}
if (theme === "lightblue") {
return {
dot: "bg-[#0062D9]",
badge: "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]",
focus: "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20",
text: "text-[#0062D9]",
hoverText: "group-hover:text-[#0062D9]",
link: "text-[#0062D9] hover:text-[#0056C6]",
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]",
};
}
if (theme === "lightyellow") {
return {
dot: "bg-[#F3FBD9]",
badge: "border-[#4C6330]/15 bg-[#EEF6D4] text-[#4C6330]",
focus: "focus:border-[#4C6330]/60 focus:ring-[#F3FBD9]/40",
text: "text-[#4C6330]",
hoverText: "group-hover:text-[#4C6330]",
link: "text-[#4C6330] hover:text-[#34451F]",
button: "bg-[#F3FBD9] text-[#303D23] shadow-[0_1px_3px_rgba(243,251,217,0.72)] hover:bg-[#E4EFBF]",
};
}
return {
dot: "bg-[#CF1322]",
badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]",
focus: "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20",
text: "text-[#CF1322]",
hoverText: "group-hover:text-[#CF1322]",
link: "text-[#CF1322] hover:text-[#A8071A]",
button: "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]",
};
}
export default function LightAppListPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme();
const isLightBlue = theme === 'lightblue';
const lightAccent = getLightAccent(theme);
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
@@ -118,13 +186,13 @@ export default function LightAppListPage() {
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<span className="relative flex size-3">
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
<span className={cn("relative inline-flex size-3 rounded-full", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", lightAccent.dot, "dark:bg-[#1677FF]")} />
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", isLightBlue ? "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]" : "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]")}>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台
</span>
</div>
@@ -142,7 +210,7 @@ export default function LightAppListPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", isLightBlue ? "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20" : "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20")}
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", lightAccent.focus)}
/>
{search && (
<button
@@ -161,13 +229,13 @@ export default function LightAppListPage() {
<div className="-mt-2 flex items-center justify-between">
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
检索到最终结果:{" "}
<span className={cn("font-bold dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>{apps.length}</span> 个关联应用
<span className={cn("font-bold dark:text-[#4096FF]", lightAccent.text)}>{apps.length}</span> 个关联应用
</div>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", isLightBlue ? "text-[#0062D9] hover:text-[#0056C6]" : "text-[#CF1322] hover:text-[#A8071A]")}
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", lightAccent.link)}
>
重置过滤条件
</button>
@@ -239,7 +307,7 @@ function AppCard({
onOpen: () => void;
}) {
const { theme } = useTheme();
const isLightBlue = theme === 'lightblue';
const lightAccent = getLightAccent(theme);
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
@@ -256,7 +324,7 @@ function AppCard({
</div>
</div>
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", isLightBlue ? "group-hover:text-[#0062D9]" : "group-hover:text-[#CF1322]")}>
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", lightAccent.hoverText)}>
{app.name}
</h4>
@@ -270,7 +338,7 @@ function AppCard({
<div className="mb-4 rounded-[5px] bg-zinc-50/70 p-3 dark:bg-[#0C1836]">
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
<span>拼接后目标路由终端:</span>
<span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>
<span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", lightAccent.text)}>
自动拼接已激活参
</span>
</div>
@@ -312,7 +380,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]", isLightBlue ? "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]" : "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]")}
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" />
@@ -93,7 +93,7 @@ const newParam = (): LightAppRouteParam => ({
paramType: "login",
source: "access_token",
customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
themeValue: "red=red;green=green;blue=blue;dark-blue=dark-blue",
});
function formFromApp(app: LightApp): FormState {
+19 -5
View File
@@ -9,7 +9,9 @@ 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 isLightYellow = theme === "lightyellow";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
@@ -30,7 +32,9 @@ export default function LightAppNoteQAPage() {
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
: isLightYellow
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(243,251,217,0.7) 0%, rgba(220,235,173,0.26) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
@@ -42,7 +46,11 @@ export default function LightAppNoteQAPage() {
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
isDark
? "bg-violet-400 text-slate-950"
: isLightYellow
? "bg-[#4C6330] text-white"
: "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
@@ -65,7 +73,9 @@ export default function LightAppNoteQAPage() {
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
: isLightYellow
? "bg-[#EEF6D4] text-[#4C6330] hover:bg-[#E4EFBF]"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
@@ -112,7 +122,11 @@ export default function LightAppNoteQAPage() {
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: isLightBlue
: isLightGreen
? "bg-[#07C05F] text-white hover:bg-[#08DD6E]"
: isLightYellow
? "bg-[#F3FBD9] text-[#303D23] hover:bg-[#E4EFBF]"
: isLightBlue
? "bg-[#0062D9] text-white hover:bg-[#0079FE]"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
+42 -10
View File
@@ -71,6 +71,7 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
const DEFAULT_USERNAME = 'guest';
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
const FULL_CHAT_MODE = 'full';
type LoginStatus = 'loading' | 'success' | 'error';
@@ -91,10 +92,21 @@ export default function LoginPage() {
() => readEmbedParam(searchParams.toString()) === 'on',
[searchParams],
);
const isIframeLogin = useMemo(
() => isChatsIframeLogin(searchParams.toString()),
// Wujie is an application container, not the Brainy iframe protocol. It
// must therefore land on the regular full ChatPage even when an old portal
// URL still carries `from=iframe`. Plain embeds can request the same route
// explicitly with `chatMode=full`.
const fullChatRequested = useMemo(
() =>
isWujieEnv() ||
searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
[searchParams],
);
const isIframeLogin = useMemo(
() =>
!fullChatRequested && isChatsIframeLogin(searchParams.toString()),
[fullChatRequested, searchParams],
);
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
@@ -103,12 +115,20 @@ export default function LoginPage() {
// ticket/userId/env 仍按既有登录存储策略持久化。
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const redirectPath = useMemo(
() =>
resolvePostLoginRedirectPath(
() => {
if (yUserId) return GENERAL_QA_ROUTE;
// Keep the mode on the destination URL so PageRoutes can retain the
// full chat workspace sidebar while hideChrome hides only the global
// application chrome.
if (fullChatRequested) {
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
}
return resolvePostLoginRedirectPath(
searchParams.toString(),
yUserId ? GENERAL_QA_ROUTE : DEFAULT_REDIRECT_PATH,
),
[searchParams, yUserId],
DEFAULT_REDIRECT_PATH,
);
},
[fullChatRequested, searchParams, yUserId],
);
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
@@ -131,15 +151,27 @@ export default function LoginPage() {
// taskId,无需把它编进 redirect 值里。目标已自带的同名参数优先(不覆盖);登录鉴权类参数
// (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。
const redirectTo = useMemo(() => {
const raw = searchParams.get('redirect')?.trim() ?? '';
const raw =
searchParams.get('redirect')?.trim() ||
searchParams.get('route')?.trim() ||
'';
if (!raw) return '';
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//') || raw.includes('\\')) return '';
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
// Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar.
if (
fullChatRequested &&
pathOnly === DEFAULT_REDIRECT_PATH &&
!target.has('chatMode')
) {
target.set('chatMode', FULL_CHAT_MODE);
}
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
const LOGIN_OWNED = new Set([
'redirect', 'ticket', 'userId', 'env', 'theme', 'yUserId',
'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId',
'token', 'access_token', 'password', 'authToken', 'goPath', 'from',
]);
new URLSearchParams(searchParams.toString()).forEach((value, key) => {
@@ -149,7 +181,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [searchParams]);
}, [fullChatRequested, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -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'];
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen', 'iceblue', 'lightyellow'];
interface NotebookTokenData {
doc_ids: number[];
+38 -3
View File
@@ -9,6 +9,7 @@ import { isWujieEnv } from "@/core/embed/wujie";
import {
isEmbedActive,
isChromeHidden,
isHideChromeActive,
readEmbedParam,
readHideChromeParam,
} from "@/core/embed/embed-session";
@@ -16,6 +17,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { env } from "@/env";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -27,6 +29,7 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const location = useLocation();
const { theme } = useTheme();
@@ -42,7 +45,13 @@ function PageLayout() {
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode();
const chromeByPreference =
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
forceCollapsedSidebar
? false
: layoutMode === "full"
? true
: layoutMode === "compact"
? false
: sidebarEnabled;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo(
@@ -56,11 +65,29 @@ function PageLayout() {
() => isChromeHidden() || readHideChromeParam(location.search) === "on",
[location.pathname, location.search],
);
const hideChromeActive = useMemo(
() => isHideChromeActive() || readHideChromeParam(location.search) === "on",
[location.pathname, location.search],
);
// `chatMode=full` is the embed-friendly standard chat view: hide the global
// Header/PageSidebar when `hideChrome=1`, but keep WorkspaceSidebar so users
// can still pick an agent and access their common agents/history.
const fullChatMode = useMemo(() => {
const mode = new URLSearchParams(location.search)
.get("chatMode")
?.trim()
.toLowerCase();
return (
mode === "full" &&
location.pathname.startsWith("/page/workspace/chats/")
);
}, [location.pathname, location.search]);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent");
const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) &&
!isChatsIframeWorkspacePath(location.pathname) &&
@@ -111,9 +138,17 @@ function PageLayout() {
<WorkspaceRoutes
pageSidebarCollapsed={collapsed}
pageSidebarHidden={!pageSidebarAllowed}
workspaceSidebarHidden={hideChromeActive && !fullChatMode}
headerHidden={!pageSidebarAllowed}
compactWorkspaceSidebar={pageSidebarAllowed}
// Full-chat embedding has no global PageSidebar, but it
// still needs the full GeneralQaSidebar (common agents and
// conversation history) inside the workspace.
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
compactActionsHidden={hideChromeActive}
pageSidebarDark={isDark}
forceWorkspaceSidebarCollapsed={
forceCollapsedSidebar && !fullChatMode
}
/>
}
/>
@@ -793,7 +793,7 @@ export default function ScheduledTasksPage({
</Button>
<Button
size="icon"
variant="destructive"
variant={theme === "iceblue" || theme === "lightyellow" ? "default" : "destructive"}
title="删除"
onClick={(event) => {
event.stopPropagation();
@@ -1034,7 +1034,7 @@ export default function ScheduledTasksPage({
{selectedTaskCanEditRuns && run.can_edit !== false && (
<Button
size="sm"
variant="destructive"
variant={theme === "iceblue" || theme === "lightyellow" ? "default" : "destructive"}
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
@@ -21,6 +21,7 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
@@ -91,6 +92,14 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route
path="qa/position-roundtable"
element={
<ChatRuntime>
<PositionRoundtablePage />
</ChatRuntime>
}
/>
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
+114 -44
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isWujieEnv } from '@/core/embed/wujie';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -54,6 +55,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
@@ -71,14 +73,6 @@ function NewAgentPageWithKey() {
return <NewAgentPage key={thread_id} />;
}
function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch {
return true;
}
}
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
@@ -117,30 +111,35 @@ export function WorkspaceLayout({
pageSidebarHidden = false,
workspaceSidebarHidden = false,
headerHidden = false,
compactActionsHidden = false,
compactWorkspaceSidebar = false,
defaultHistorySidebarOpen,
wrapperOverflowHidden = false,
insetOverflowHidden = false,
pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
pageSidebarHidden?: boolean;
workspaceSidebarHidden?: boolean;
headerHidden?: boolean;
compactActionsHidden?: boolean;
compactWorkspaceSidebar?: boolean;
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
defaultHistorySidebarOpen?: boolean;
wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean;
pageSidebarDark?: boolean;
/** Deployment-level compact mode: keep only the icon menu rail. */
forceWorkspaceSidebarCollapsed?: boolean;
}) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const showCompactTopActions =
headerHidden && shouldShowCompactTopActions(pathname);
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
@@ -169,11 +168,19 @@ export function WorkspaceLayout({
key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
forceWorkspaceSidebarCollapsed
? false
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
}
open={forceWorkspaceSidebarCollapsed ? false : undefined}
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
disableToggle={forceWorkspaceSidebarCollapsed}
>
{!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
<WorkspaceSidebar
compactChrome={compactWorkspaceSidebar}
forceCollapsed={forceWorkspaceSidebarCollapsed}
/>
)}
<SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
@@ -224,15 +231,21 @@ function NewChatEntryRoute({
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
if (isInIframe()) {
return <Navigate to="/page/workspace/chats_iframe/new" replace />;
}
// Do not infer the chat experience from window.self !== window.top here.
// A generic `/chats/new` deep link must render the same standard chat both
// top-level and inside an iframe. The dedicated iframe protocol page remains
// available only through its explicit `/chats_iframe/...` route (LoginPage
// selects it for `from=iframe`). Otherwise a normal deep link was silently
// redirected to IframeChatPage, whose new-thread state intentionally has no
// manually selected agent and therefore stopped at "选择 Agent".
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
@@ -242,25 +255,94 @@ function NewChatEntryRoute({
);
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
* Wujie links still target it (or carry `from=iframe` during login), but Wujie
* needs the normal full chat workspace instead. Redirect it before mounting
* IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch
* for a Wujie integration that really implements that protocol.
*/
function IframeChatEntryRoute({
layoutProps,
hasThreadParam,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
hasThreadParam: boolean;
}) {
const location = useLocation();
const { thread_id } = useParams<{ thread_id: string }>();
const requestedMode = new URLSearchParams(location.search)
.get('chatMode')
?.trim()
.toLowerCase();
if (isWujieEnv() && requestedMode !== 'iframe') {
const targetParams = new URLSearchParams(location.search);
targetParams.set('chatMode', 'full');
const targetPath = hasThreadParam && thread_id
? `/page/workspace/chats/${thread_id}`
: '/page/workspace/chats/new';
const targetSearch = targetParams.toString();
return (
<Navigate
to={targetSearch ? `${targetPath}?${targetSearch}` : targetPath}
replace
/>
);
}
return (
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden={hasThreadParam}
insetOverflowHidden={hasThreadParam}
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
workspaceSidebarHidden = false,
headerHidden = false,
compactActionsHidden = false,
compactWorkspaceSidebar = false,
pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false,
}: {
pageSidebarCollapsed?: boolean;
pageSidebarHidden?: boolean;
workspaceSidebarHidden?: boolean;
headerHidden?: boolean;
compactActionsHidden?: boolean;
compactWorkspaceSidebar?: boolean;
pageSidebarDark?: boolean;
forceWorkspaceSidebarCollapsed?: boolean;
}) {
const layoutProps = {
pageSidebarCollapsed,
pageSidebarHidden,
workspaceSidebarHidden,
headerHidden,
compactActionsHidden,
compactWorkspaceSidebar,
pageSidebarDark,
forceWorkspaceSidebarCollapsed,
};
return (
@@ -271,7 +353,7 @@ export default function WorkspaceRoutes({
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={headerHidden} />
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} />
</WorkspaceLayout>
}
/>
@@ -291,33 +373,11 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden
insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
/>
<Route
path="agents"
@@ -357,7 +417,7 @@ export default function WorkspaceRoutes({
<AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden ? <CompactAppearanceActions /> : undefined
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
}
/>
</ChatRuntime>
@@ -394,7 +454,7 @@ export default function WorkspaceRoutes({
<WorkspaceLayout {...layoutProps}>
<AIWritingPage
hideAdvancedFields
compactChrome={headerHidden}
compactChrome={headerHidden && !compactActionsHidden}
/>
</WorkspaceLayout>
}
@@ -428,7 +488,17 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage compactChrome={headerHidden} />
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable/position"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
@@ -490,7 +560,7 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage compactChrome={headerHidden} />
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
</WorkspaceLayout>
}
/>
+171 -4
View File
@@ -651,15 +651,25 @@
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
}
/* ── TDesign Select focus / hover ────────────────────────────────────── */
/* ── TDesign Select interaction ──────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input,
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input__wrap {
border-color: rgba(0, 98, 217, 0.5);
border-color: #A6A6A6;
}
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: #0062D9;
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
/* Opening the options with a mouse is not a validation or selected state. */
border-color: #DBDBDB;
box-shadow: none;
}
/* Keep a visible focus ring for keyboard navigation only. */
@supports selector(.t-select:has(.t-input__inner:focus-visible)) {
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused:has(.t-input__inner:focus-visible) .t-input,
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused:has(.t-input__inner:focus-visible) .t-input__wrap {
border-color: #0062D9;
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
}
}
/* ── 启用开关 ───────────────────────────────────────────────────────── */
@@ -679,6 +689,163 @@
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;
}
/* Pale yellow: #F3FBD9 surfaces pair with dark olive interaction text. */
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save,
[data-theme="lightyellow"] .light-app-management-page .lam-switch-on {
background-color: #F3FBD9;
}
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save {
color: #303D23;
box-shadow: 0 2px 6px rgba(243, 251, 217, 0.72);
}
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn:hover,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save:hover {
background-color: #E4EFBF;
}
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn:active,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save:active {
background-color: #DCEBAD;
}
[data-theme="lightyellow"] .light-app-management-page .lam-search-input:focus {
border-color: rgba(76, 99, 48, 0.5);
box-shadow: 0 0 0 4px rgba(243, 251, 217, 0.72);
}
[data-theme="lightyellow"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="lightyellow"] .light-app-management-page .lam-cat-toggle-btn.is-active,
[data-theme="lightyellow"] .light-app-management-page .lam-count strong,
[data-theme="lightyellow"] .light-app-management-page .lam-table tr:hover .lam-row-name,
[data-theme="lightyellow"] .light-app-management-page .lam-param-popover-close,
[data-theme="lightyellow"] .light-app-management-page .lam-param-key,
[data-theme="lightyellow"] .light-app-management-page .lam-card:hover .lam-card-title,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-close:hover,
[data-theme="lightyellow"] .light-app-management-page .lam-add-param-btn:hover {
color: #4C6330;
}
[data-theme="lightyellow"] .light-app-management-page .lam-add-param-btn:hover,
[data-theme="lightyellow"] .light-app-management-page .lam-input:focus,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog input[type="text"]:focus {
border-color: #B4C985;
}
[data-theme="lightyellow"] .light-app-management-page .lam-input:focus,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog input[type="text"]:focus,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
[data-theme="lightyellow"] .light-app-management-page .lam-dialog textarea:focus-visible,
[data-theme="lightyellow"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="lightyellow"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
box-shadow: 0 0 0 2px rgba(243, 251, 217, 0.78);
}
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-icon {
background-color: #EEF6D4;
color: #4C6330;
}
/* WeKnora green theme. */
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save,
[data-theme="lightgreen"] .light-app-management-page .lam-switch-on {
background-color: #07C05F;
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save {
box-shadow: 0 2px 6px rgba(7, 192, 95, 0.2);
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:hover,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:hover {
background-color: #08DD6E;
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:active,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:active {
background-color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-search-input:focus {
border-color: rgba(7, 192, 95, 0.4);
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.1);
}
[data-theme="lightgreen"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="lightgreen"] .light-app-management-page .lam-cat-toggle-btn.is-active,
[data-theme="lightgreen"] .light-app-management-page .lam-count strong,
[data-theme="lightgreen"] .light-app-management-page .lam-table tr:hover .lam-row-name,
[data-theme="lightgreen"] .light-app-management-page .lam-param-popover-close,
[data-theme="lightgreen"] .light-app-management-page .lam-param-key,
[data-theme="lightgreen"] .light-app-management-page .lam-card:hover .lam-card-title,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-close:hover,
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
border-color: #07C05F;
}
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-icon {
background-color: #E9F8EC;
color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-input:focus,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog input[type="text"]:focus,
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: #07C05F;
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.16);
}
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input,
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input__wrap {
border-color: rgba(7, 192, 95, 0.5);
}
/* ── iframe 菜单排序:拖拽行 + 拖拽手柄 ───────────────────────────────── */
.light-app-management-page .lam-reorder-row {
cursor: grab;
@@ -7,6 +7,16 @@
background-color: #F9FCFF;
}
[data-theme="lightgreen"] .light-app-management-page,
[data-theme="lightgreen"] .light-app-list-page {
background-color: #F9FDF9;
}
[data-theme="lightyellow"] .light-app-management-page,
[data-theme="lightyellow"] .light-app-list-page {
background-color: #FCFDF5;
}
.dark .light-app-management-page,
.dark .light-app-list-page {
background-color: var(--color-bg-page);
@@ -0,0 +1,228 @@
# 岗位多智能体会商页面开发说明
## 1. 页面定位
岗位多智能体会商将一个业务链条拆成多个“岗位视角”的单智能体问答:每个席位仍使用既有的 LangGraph 对话线程完成流式生成,岗位会商服务只负责保存任务快照、冻结后的链条、节点状态与线程索引。
因此它不是新的多智能体调度图,也不创建后台会商任务。页面的核心约束是:
- 一条岗位会商会话对应一个任务和一份冻结的业务链配置;
- 一个业务链席位对应一个 `PositionRoundtableNode` 和一条直接对话线程;
- 节点写出 Markdown 交付物后才能被视为完成并解锁后序;
- 所有岗位交付完成后,测试分析岗可以依次进入“方案总结”和“行动规划”两个收口智能体;
- 页面中的文件预览沙箱只有一个实例,不会为多个节点重复打开或同时流式渲染。
前端入口位于 `src/pages/StrategyRoutes.tsx`:`qa/position-roundtable`。
## 2. URL 与会话恢复
推荐访问地址:
```text
/#/page/strategy/qa/position-roundtable?taskId=<任务ID>&sessionId=<会商会话ID>&positionId=<岗位ID>
```
| 参数 | 是否必填 | 作用 |
| --- | --- | --- |
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
当前岗位 ID 定义在 `lib/positions.ts`:
| ID | 页面岗位 | 职责 |
| --- | --- | --- |
| `intelligence` | 测试分析岗 | 完成任务意图识别、业务链确认,以及收口智能体入口。 |
| `planning` | 组织测试岗 | 承接上游信息并完成组织侧分析。 |
| `solution` | 方案设计岗 | 产出方案侧 Markdown 交付物。 |
| `evaluation` | 综合测试岗 | 汇总评估并给出综合建议。 |
切换岗位时,`PositionRoundtablePage` 会关闭当前沙箱、清空当前节点/收口智能体选择,并写回 `positionId`。不要在子组件中直接修改 URL,否则容易留下上一岗位的预览状态。
## 3. 页面结构与职责
```text
PositionRoundtablePage
├─ 左侧:任务分析
│ ├─ 任务卡片(名称、简介、来源、下发时间)
│ ├─ 意图分析
│ └─ 内嵌业务链流程图
├─ 中间:当前岗位的对话区
│ ├─ 测试分析岗:Step1Panel + PositionIntentInput
│ ├─ 普通席位:PositionNodeChat + PositionAgentInput
│ └─ 收口阶段:PositionBuiltinAgentChat + PositionAgentInput
├─ 右侧:智能体配置 / 交付产物抽屉
└─ 文件预览:唯一的 ArtifactFileDetail 沙箱实例
```
主要文件:
| 文件 | 责任 |
| --- | --- |
| `pages/PositionRoundtablePage.tsx` | 页面状态机、URL 同步、任务与会话恢复、岗位/节点切换、唯一沙箱预览。 |
| `api/sessions.ts` | 岗位会商 API 的前端类型、snake_case/camelCase 映射和请求函数。 |
| `components/PositionNodeChat.tsx` | 一个普通业务链节点的一对一对话、线程绑定、上游上下文注入与交付完成回写。 |
| `components/PositionBuiltinAgentChat.tsx` | 方案总结、行动规划的直接对话和“准备 → 线程就绪 → 发送 → 归档”流程。 |
| `components/PositionIntentInput.tsx` | 仅测试分析岗使用的意图输入框。样式、工具栏和按钮均与其他页面隔离。 |
| `components/PositionAgentInput.tsx` | 普通节点与收口智能体共用的岗位专用输入框边界。 |
| `components/PositionAgentSwitcher.tsx` | 测试分析岗中两个收口智能体的全名切换按钮。 |
| `lib/positions.ts` | 前端岗位目录和稳定岗位 ID。 |
## 4. 状态流转
```text
意图识别
└─ 确认意图并启动业务链
├─ 冻结 taskSnapshot / intentSnapshot / chainSnapshot
├─ 按业务链席位创建节点
└─ intelligence 岗节点自动标记完成
普通业务链节点
└─ 直接问答并生成 Markdown
├─ 写入 LangGraph 线程
├─ 回写 node.threadId
├─ 保存 latestAnswer 与 artifactManifest
└─ Markdown 交付成功后解锁后续阶段
全部普通节点已交付
└─ 方案总结(测试分析岗)
└─ 行动规划(测试分析岗)
```
### 4.1 意图识别与业务链启动
测试分析岗复用 `Step1Panel` 的消息和状态逻辑;页面通过 `inputArea` 插槽替换为 `PositionIntentInput`,因此不会改动普通会商 Step 1 的输入样式。
`handleActivateChain` 启动前必须同时满足:
1. 选择了业务链;
2. 业务链至少有一个席位;
3. 每个席位已配置智能体和岗位;
4. `intent.intentReady` 存在结构化意图结果。
启动成功后会调用:
1. `createPositionSession`(首次启动时);
2. `savePositionIntent`;
3. `activatePositionSession`。
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
### 4.2 普通节点问答
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
首次创建线程时,`onStart` 会调用 `bindPositionNodeThread`。组件还监听 SDK 实际线程 ID,处理“线程创建后、绑定写入前刷新页面”的竞态。
流结束后,组件会读取该线程的产物列表并调用 `completePositionNodeTurn`。普通节点只有交付 Markdown 文件才会变为 `done`;单纯继续追问只会保存回答,不会误解锁下游节点。
### 4.3 收口智能体
收口阶段只有测试分析岗可见:
- 方案总结:全部岗位交付完成后可开始;
- 行动规划:方案总结完成后可开始。
`PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。方案总结(以及行动规划的 Markdown 部分)若模型只返回了对话正文、遗漏 `write_file`,服务端会将该正文自动写入对应线程的 `outputs/` 目录并登记为 Markdown 产物,因此用户始终同时获得聊天正文与可预览、下载的报告文件;行动规划仍必须另外生成 `action-plan-subtasks.json`。
## 5. 文件预览沙箱
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
设计要点:
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
不要在节点组件中直接操作全局 `ArtifactsContext` 或创建第二个 `ArtifactFileDetail`,否则会出现预览重复、关闭失效和更新深度异常。
## 6. 输入框隔离约定
岗位会商有两类输入框,均应只在 `src/position-roundtable/components/` 内调整:
| 组件 | 使用范围 | 调整原则 |
| --- | --- | --- |
| `PositionIntentInput` | 测试分析岗意图识别 | 完整自有布局;发送和阶段按钮均在同一输入框内。 |
| `PositionAgentInput` | 普通岗位节点、方案总结、行动规划 | 目前包装全局 `InputBox`,保留全部对话能力,同时提供 `position-roundtable-agent-input` 样式边界。 |
禁止直接修改 `components/workspace/input-box` 或默认 `Step1Panel` 的输入样式来满足岗位会商需求。这会影响普通工作区、其他业务和原始多智能体会商。
## 7. 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、结构化意图和完整意图对话快照。 |
| `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. 演示数据与免登录独立模式
完整演示数据只有一份事实源:
```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 切换项。
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
5. 变更输入框视觉时:只改对应的岗位专用输入组件,不要向全局 `InputBox` 添加岗位样式。
6. 验证最少覆盖:新建会话、刷新恢复、切岗位、单节点多智能体切换、生成 Markdown、关闭后再次对话、历史会话、方案总结、行动规划。
## 10. 构建验证
```powershell
cd frontend-web
pnpm build
```
提交前还应执行:
```powershell
git diff --check
```
@@ -0,0 +1,602 @@
/** Client for the independent position-collaboration Session / Node API.
*
* Messages themselves stay in LangGraph threads. These endpoints only persist
* task/intent snapshots, frozen chain topology, node states and indexes that
* let the UI recover the correct thread after a refresh.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
* 不应在组件内自行把节点改成 done 或 ready。
*/
export type PositionNodeStatus =
| "locked"
| "ready"
| "running"
| "done"
| "stale"
| "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;
}
/**
* 冻结业务链中的一个席位。
*
* `threadId` 指向真实的 LangGraph 对话;节点表只承担“席位 ↔ 线程”索引、
* 上游版本与交付清单的职责,绝不保存完整聊天消息。
*/
export interface PositionRoundtableNode {
id: string;
sessionId: string;
nodeKey: string;
stageIndex: number;
seatIndex: number;
agentId: string;
positionId: string | null;
threadId: string | null;
status: PositionNodeStatus;
latestAnswer: string | null;
artifactManifest: Array<Record<string, unknown>>;
conversationSnapshot: PositionConversationSnapshot | null;
revision: number;
upstreamRevision: Record<string, unknown> | null;
rejectionCount: number;
lastRejection: Record<string, unknown> | null;
invalidatedBy: string | null;
createdAt?: string;
updatedAt?: string;
}
/**
* 岗位会商会话的可恢复快照。
*
* 任务、意图与链条在启动后被分别冻结,避免外部任务或链条配置发生变化时
* 改写已经开始的协同过程。`nodes` 只在详情接口中携带。
*/
export interface PositionRoundtableSession {
id: string;
externalTaskId: string | null;
taskSnapshot: Record<string, unknown>;
chainId: string | null;
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;
nodes?: PositionRoundtableNode[];
}
interface ServerNode {
id: string;
session_id: string;
node_key: string;
stage_index: number;
seat_index: number;
agent_id: string;
position_id: string | null;
thread_id: string | null;
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;
last_rejection?: Record<string, unknown> | null;
invalidated_by?: string | null;
created_at?: string;
updated_at?: string;
}
interface ServerSession {
id: string;
external_task_id: string | null;
task_snapshot?: Record<string, unknown>;
chain_id: string | null;
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;
nodes?: ServerNode[];
}
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`;
async function readJson<T>(response: Response, label: string): Promise<T> {
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`);
}
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 演进时遗漏状态字段。
*/
function toNode(node: ServerNode): PositionRoundtableNode {
return {
id: node.id,
sessionId: node.session_id,
nodeKey: node.node_key,
stageIndex: node.stage_index,
seatIndex: node.seat_index,
agentId: node.agent_id,
positionId: node.position_id,
threadId: node.thread_id,
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,
lastRejection: node.last_rejection ?? null,
invalidatedBy: node.invalidated_by ?? null,
createdAt: node.created_at,
updatedAt: node.updated_at,
};
}
/** 同上:会话快照在这一层完成默认值填充与字段转换。 */
function toSession(session: ServerSession): PositionRoundtableSession {
const result: PositionRoundtableSession = {
id: session.id,
externalTaskId: session.external_task_id,
taskSnapshot: session.task_snapshot ?? {},
chainId: session.chain_id,
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,
};
if (Array.isArray(session.nodes)) {
result.nodes = session.nodes.map(toNode);
}
return result;
}
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson<ServerSession[]>(
await apiFetch(`${BASE()}/sessions${params}`),
"list position sessions",
);
return result.map(toSession);
}
export async function createPositionSession(input: {
externalTaskId?: string;
taskSnapshot: Record<string, unknown>;
}): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
external_task_id: input.externalTaskId || null,
task_snapshot: input.taskSnapshot,
}),
}),
"create position session",
);
return toSession(result);
}
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
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);
}
export async function updatePositionTaskSnapshot(
sessionId: string,
taskSnapshot: Record<string, unknown>,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }),
}),
"update position task",
);
return toSession(result);
}
export async function savePositionIntent(
sessionId: string,
input: {
intentThreadId?: string | null;
intentSnapshot: object | null;
conversationSnapshot?: PositionConversationSnapshot | null;
},
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent_thread_id: input.intentThreadId || null,
intent_snapshot: input.intentSnapshot,
conversation_snapshot: input.conversationSnapshot ?? null,
}),
}),
"save position intent",
);
return toSession(result);
}
export async function activatePositionSession(
sessionId: string,
chainId: string,
): Promise<PositionRoundtableSession> {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
}),
"activate position session",
);
return toSession(result);
}
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST",
}),
"archive position session",
);
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,
threadId: string,
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId }),
},
),
"bind position node thread",
);
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);
}
/**
* 在普通节点发送前取得受长度限制的隐藏交接上下文。
*
* 该上下文会作为 `task_external_context` 发送给 LangGraph,而不是渲染在聊天
* 消息列表中;这样既能让节点承接上游,又不会把整段交接材料重复展示给用户。
*/
export async function getPositionNodeContext(
sessionId: string,
nodeKey: string,
): Promise<Record<string, unknown>> {
return readJson<Record<string, unknown>>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" },
),
"build position node context",
);
}
export async function completePositionNodeTurn(
sessionId: string,
nodeKey: string,
input: {
latestAnswer: string;
artifactManifest?: Array<Record<string, unknown>>;
upstreamRevision?: Record<string, unknown> | null;
},
): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latest_answer: input.latestAnswer,
artifact_manifest: input.artifactManifest ?? [],
upstream_revision: input.upstreamRevision ?? null,
}),
},
),
"complete position node turn",
);
return toNode(result);
}
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// ``title`` is retained for older callers. The current position
// workspace deliberately sends only an optional reason.
...(input.title ? { title: input.title } : {}),
reason: input.reason ?? "",
}),
},
),
"reject position node",
);
return toSession(result);
}
export interface PositionBuiltinRunPreparation {
agentId: string;
displayName: string;
threadId: string | null;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface ServerBuiltinRunPreparation {
agent_id: string;
display_name: string;
thread_id?: string | null;
prompt: string;
source_revisions?: Record<string, number>;
}
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
return {
agentId: value.agent_id,
displayName: value.display_name,
threadId: value.thread_id ?? null,
prompt: value.prompt,
sourceRevisions: value.source_revisions ?? {},
};
}
type BuiltinKind = "summary" | "action_plan";
async function prepareBuiltinRun(
sessionId: string,
kind: BuiltinKind,
message = "",
): Promise<PositionBuiltinRunPreparation> {
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
}),
`prepare position ${kind}`,
);
return toBuiltinRunPreparation(result);
}
async function completeBuiltinRun(
sessionId: string,
kind: BuiltinKind,
input: {
threadId: string;
content: string;
artifactManifest: Array<Record<string, unknown>>;
sourceRevisions: Record<string, number>;
model?: string;
},
): Promise<PositionRoundtableSession> {
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId,
content: input.content,
artifact_manifest: input.artifactManifest,
source_revisions: input.sourceRevisions,
model: input.model || null,
}),
}),
`complete position ${kind}`,
);
return toSession(result);
}
export function preparePositionSummary(
sessionId: string,
message = "",
): Promise<PositionBuiltinRunPreparation> {
return prepareBuiltinRun(sessionId, "summary", message);
}
export function completePositionSummary(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "summary", input);
}
export function preparePositionActionPlan(
sessionId: string,
message = "",
): Promise<PositionBuiltinRunPreparation> {
return prepareBuiltinRun(sessionId, "action_plan", message);
}
export function completePositionActionPlan(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "action_plan", input);
}
@@ -0,0 +1,63 @@
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;
};
/**
* 岗位会商的岗位智能体输入框。
*
* 目前保留 InputBox 的完整交互能力;后续本页面需要独立调整视觉或工具栏时,
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
*/
export function PositionAgentInput({
className,
draftKey = "",
draftValue = "",
onDraftChange,
...props
}: 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.
return (
<InputBox
{...props}
className={cn("position-roundtable-agent-input w-full", className)}
/>
);
}
@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
export interface PositionBuiltinAgentSwitchOption {
kind: PositionBuiltinAgentKind;
label: string;
disabled?: boolean;
disabledReason?: string;
}
interface PositionBuiltinAgentTabsProps {
activeKind: PositionBuiltinAgentKind | null;
options: PositionBuiltinAgentSwitchOption[];
isStreaming?: boolean;
onSelect: (kind: PositionBuiltinAgentKind) => void;
}
/**
* The two closing agents are intentionally exposed as full-name buttons
* instead of another dropdown: users can see the whole progression and jump
* between the two stages with one click.
*/
export function PositionBuiltinAgentTabs({
activeKind,
options,
isStreaming,
onSelect,
}: PositionBuiltinAgentTabsProps) {
if (options.length === 0) return null;
return (
<div
className="ml-auto mr-12 flex shrink-0 items-center gap-1.5"
aria-label="收口智能体切换"
title={isStreaming ? "当前智能体正在生成,完成后可切换" : "切换收口智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
收口智能体
</span>
{options.map((option) => {
const active = activeKind === option.kind;
const disabled = Boolean(option.disabled || (isStreaming && !active));
return (
<button
key={option.kind}
type="button"
disabled={disabled}
aria-pressed={active}
title={disabled ? option.disabledReason || "当前阶段暂不可进入" : option.label}
onClick={() => onSelect(option.kind)}
className={cn(
"inline-flex h-8 min-w-[126px] items-center justify-center rounded-lg border px-2.5 text-xs font-semibold transition",
active
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]",
disabled &&
"cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 opacity-75 hover:border-slate-200 hover:text-slate-400 dark:border-[#2b8ffe]/20 dark:bg-[#082d5f]/55 dark:text-[#7595be]",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,740 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
import {
completePositionActionPlan,
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,
listSessionArtifacts,
} from "@/roundtable-planning/api/multi-agent";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
interface BuiltinArtifact {
name: string;
path: string;
mimeType: string | null;
}
interface PendingBuiltinSubmission {
threadId: string;
agentId: string;
message: PromptInputMessage;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface PositionBuiltinAgentChatProps {
kind: PositionBuiltinAgentKind;
taskId: string;
session: PositionRoundtableSession | null;
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[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onSessionUpdate: (session: PositionRoundtableSession) => void;
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
threadId: string;
thread: BaseStream<AgentThreadState>;
}) => void;
}
/**
* 收口阶段不是业务链里的普通席位,但仍是可独立配置的智能体。
* 导出这两个固定 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;
}
function builtinAgentFallbackName(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结智能体" : "行动规划智能体";
}
function builtinTitle(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结" : "行动规划";
}
function builtinDescription(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "汇总全部岗位交付,形成最终方案建议、关键依据和风险提示。"
: "基于方案总结继续拆解行动步骤、责任建议、时间节奏和子任务清单。";
}
function defaultStartText(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "请基于当前所有岗位交付,开始生成方案总结。"
: "请基于当前方案总结,开始生成行动规划。";
}
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value : "";
}
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined) {
const status = recordString(snapshot, "status");
return status || "pending";
}
function snapshotArtifact(
snapshot: Record<string, unknown> | null | undefined,
key: string,
): Record<string, unknown> | null {
const value = snapshot?.[key];
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function artifactName(artifact: Record<string, unknown> | null | undefined): string {
const name = recordString(artifact, "name").trim();
const path = recordString(artifact, "path").trim();
return name || path.split("/").filter(Boolean).at(-1) || "未命名产物";
}
function artifactPath(artifact: Record<string, unknown> | null | undefined): string {
return recordString(artifact, "path").trim();
}
function artifactMimeType(artifact: Record<string, unknown> | null | undefined): string | null {
return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null;
}
function readBuiltinArtifacts(
snapshot: Record<string, unknown> | null | undefined,
): BuiltinArtifact[] {
const seen = new Set<string>();
return ["primary_artifact", "subtasks_artifact"].flatMap((key) => {
const artifact = snapshotArtifact(snapshot, key);
const path = artifactPath(artifact);
if (!path || seen.has(path)) return [];
seen.add(path);
return [
{
path,
name: artifactName(artifact),
mimeType: artifactMimeType(artifact),
},
];
});
}
function artifactDisplayName(pathOrUrl: string): string {
let path = pathOrUrl;
if (pathOrUrl.startsWith("write-file:")) {
try {
path = decodeURIComponent(new URL(pathOrUrl).pathname);
} catch {
path = pathOrUrl;
}
}
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function latestAssistantText(messages: AgentThreadState["messages"]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== "ai") continue;
const text = textOfMessage(message)?.trim();
if (text) return text;
}
return "";
}
function manifestFromArtifacts(
files: Awaited<ReturnType<typeof listSessionArtifacts>>["files"],
): Array<Record<string, unknown>> {
return files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
}
export function PositionBuiltinAgentChat({
kind,
taskId,
session,
allDelivered,
summaryDone,
selectedModel,
offlineDemo = false,
autoStartRequestKey,
builtinAgentOptions,
onSelectBuiltinAgent,
onSessionUpdate,
onConversationStart,
onPreviewArtifact,
onThreadSnapshot,
}: PositionBuiltinAgentChatProps) {
const [settings, setSettings] = useLocalSettings();
const fallbackAgentId = builtinAgentId(kind);
const snapshot =
kind === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot;
const persistedThreadId =
(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, { enabled: !offlineDemo });
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
const draftThreadIdRef = useRef(uuid());
const pendingRunRef = useRef<{
threadId: string;
sourceRevisions: Record<string, number>;
model?: string;
} | null>(null);
const [pendingSubmission, setPendingSubmission] =
useState<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
useEffect(() => {
setAssistantId(fallbackAgentId);
setBoundThreadId(persistedThreadId);
boundThreadIdRef.current = persistedThreadId;
setInputDraft(readConversationDraft(persistedConversation));
draftThreadIdRef.current = uuid();
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
setPendingSubmission(null);
handledAutoStartRequestRef.current = undefined;
// Do not depend on `persistedThreadId` here. Preparing the automatic
// first question can persist a thread id while the page's initial
// session-request is still resolving. Treating that update as a new
// session used to clear `pendingSubmission` before the normal send path
// ran; a manual click only worked because that request had already ended.
}, [fallbackAgentId, kind, session?.id]);
const canWrite =
Boolean(session) &&
session?.status !== "archived" &&
allDelivered &&
(kind === "summary" || summaryDone);
const readOnlyHint =
session?.status === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: !allDelivered
? "所有岗位交付完成后,才能进入收口智能体对话"
: kind === "action_plan" && !summaryDone
? "请先进入方案总结智能体并完成有效总结,再继续行动规划"
: "";
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId,
context: {
...settings.context,
agent_id: assistantId,
...(selectedModel ? { model: selectedModel } : {}),
},
threadMetadata: {
thread_type: "position_roundtable_builtin",
taskId,
position_roundtable_task_id: taskId,
position_session_id: session?.id,
position_builtin_kind: kind,
agent_id: assistantId,
},
onStart: (threadId) => {
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
},
onFinish: (state) => {
const pending = pendingRunRef.current;
if (!pending || !session) return;
const content = latestAssistantText(state.messages ?? []);
if (!content) return;
pendingRunRef.current = null;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(pending.threadId);
artifactManifest = manifestFromArtifacts(artifacts.files);
} catch {
// Completion still receives the visible answer; it will give a
// clearer error if a required deliverable is genuinely missing.
}
try {
const next =
kind === "summary"
? await completePositionSummary(session.id, {
threadId: pending.threadId,
content,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
})
: await completePositionActionPlan(session.id, {
threadId: pending.threadId,
content,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
});
onSessionUpdate(next);
} catch (error) {
toast.error(
`${builtinTitle(kind)}结果归档失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
})();
},
});
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 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;
boundThreadIdRef.current = actualThreadId;
setBoundThreadId(actualThreadId);
}, [actualThreadId]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
const ensureBuiltinThread = useCallback(
async (preparedThreadId: string | null, preparedAgentId: string) => {
if (preparedThreadId) {
return { agentName: preparedAgentId, threadId: preparedThreadId };
}
if (boundThreadIdRef.current) {
return { agentName: preparedAgentId, threadId: boundThreadIdRef.current };
}
return kind === "summary" ? initSummaryThread() : initPositionActionPlanThread();
},
[kind],
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!session) {
toast.warning("请先启动业务链");
return;
}
if (!canWrite) {
toast.info(readOnlyHint || "当前阶段暂不可对话");
return;
}
if (pendingSubmissionRef.current || thread.isLoading) {
return;
}
pendingSubmissionRef.current = true;
const visibleText = message.text.trim() || defaultStartText(kind);
const followUp = kind === "summary" && !snapshotDone ? "" : visibleText;
try {
onConversationStart?.();
const preparation =
kind === "summary"
? await preparePositionSummary(session.id, followUp)
: await preparePositionActionPlan(session.id, followUp);
setAssistantId(preparation.agentId);
const builtinThread = await ensureBuiltinThread(preparation.threadId, preparation.agentId);
boundThreadIdRef.current = builtinThread.threadId;
setBoundThreadId(builtinThread.threadId);
pendingRunRef.current = {
threadId: builtinThread.threadId,
sourceRevisions: preparation.sourceRevisions,
model: selectedModel || undefined,
};
// The standard chat stream must first bind to the prepared thread.
// The effect below then submits the same visible message a manual
// click would send, so this flow has no separate presentation layer.
setPendingSubmission({
threadId: builtinThread.threadId,
agentId: preparation.agentId,
message: { ...message, text: visibleText },
prompt: preparation.prompt,
sourceRevisions: preparation.sourceRevisions,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
toast.error(`发送失败:${message}`);
}
},
[
canWrite,
ensureBuiltinThread,
kind,
onConversationStart,
readOnlyHint,
selectedModel,
session,
snapshotDone,
thread.isLoading,
],
);
useEffect(() => {
if (
!pendingSubmission ||
thread.isLoading ||
actualThreadId !== pendingSubmission.threadId ||
assistantId !== pendingSubmission.agentId
) {
return;
}
const submission = pendingSubmission;
setPendingSubmission(null);
void (async () => {
try {
await sendMessage(
submission.threadId,
submission.message,
{
agent_id: submission.agentId,
...(selectedModel ? { model: selectedModel } : {}),
},
{
hiddenContext: {
text: submission.prompt,
name: "position_builtin_context",
additionalKwargs: {
position_builtin_kind: kind,
position_session_id: session?.id,
source_revisions: submission.sourceRevisions,
},
},
},
);
} catch (error) {
pendingRunRef.current = null;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
pendingSubmissionRef.current = false;
}
})();
}, [actualThreadId, assistantId, kind, pendingSubmission, selectedModel, sendMessage, session?.id, thread.isLoading]);
const handleStop = useCallback(() => {
pendingSubmissionRef.current = false;
pendingRunRef.current = null;
setPendingSubmission(null);
void thread.stop();
}, [thread]);
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: artifactDisplayName(filepath),
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
useEffect(() => {
if (
!autoStartRequestKey ||
handledAutoStartRequestRef.current === autoStartRequestKey ||
!canWrite ||
snapshotDone ||
thread.isLoading
) {
return;
}
// The request key changes only after an explicit page action. Keeping the
// handled key locally prevents route/session re-renders from auto-sending
// the first question again.
handledAutoStartRequestRef.current = autoStartRequestKey;
void handleSubmit({ text: defaultStartText(kind), files: [] });
}, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]);
const Icon = kind === "summary" ? Sparkles : Target;
const snapshotLabel = snapshotDone ? "已完成" : snapshotStale ? "需重做" : "待对话";
return (
<ThreadContext.Provider value={threadContextValue}>
<div className="position-roundtable-agent-analysis-page position-roundtable-builtin-chat flex min-h-0 flex-1 flex-col">
<header className="position-roundtable-agent-header flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
<span className="position-roundtable-agent-avatar flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{canWrite ? <Icon className="size-4" /> : <LockKeyhole className="size-3.5" />}
</span>
<div className="position-roundtable-agent-title min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || builtinDescription(kind)}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
snapshotDone
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: snapshotStale
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
)}
>
{snapshotLabel}
</span>
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={kind}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header>
{persistedArtifacts.length > 0 && (
<section className="position-roundtable-agent-artifacts shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
阶段产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{persistedArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{persistedArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadIdRef.current) {
onPreviewArtifact?.({
threadId: boundThreadIdRef.current,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId || recoveredMessages.length > 0 ? (
<MessageList
className="position-roundtable-agent-message-list min-h-0 flex-1"
threadId={boundThreadId ?? displayThreadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Use the built-in agent's regular submit path. This keeps a
// clarification answer on the same persisted thread and retains
// the summary/action-plan preparation and completion lifecycle.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? `进入${builtinTitle(kind)}智能体对话` : "当前阶段尚未解锁"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "你可以直接发送开始生成,也可以先补充口径、约束或输出格式要求。系统会自动带入全部有效岗位产物。"
: readOnlyHint}
</p>
{canWrite && (
<button
type="button"
disabled={isSubmitting}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSubmit({ text: defaultStartText(kind), files: [] })}
>
<CheckCircle2 className="size-3.5" />
{kind === "summary" ? "开始方案总结" : "开始行动规划"}
</button>
)}
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={assistantId}
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)}
onStop={handleStop}
/>
</div>
</div>
</ThreadContext.Provider>
);
}
@@ -0,0 +1,273 @@
import { useRef } from "react";
import {
FileText,
Loader2,
Paperclip,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
} from "lucide-react";
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
interface PositionIntentModelOption {
label: string;
value: string;
}
interface PositionIntentInputProps extends Step1InputAreaProps {
modelOptions?: PositionIntentModelOption[];
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
}
/** Keep attachment metadata presentation local to this position-only composer. */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* 岗位会商的意图理解输入框。
*
* 它只服务岗位会商页,通过 Step1Panel 的输入插槽接入;视觉调整不会回流到
* 原有多智能体会商 Step 1 页面。
*/
export function PositionIntentInput({
value,
onValueChange,
attachedFiles,
onAddFiles,
onRemoveFile,
charactersLeft,
isSending,
canSend,
pendingClarification,
onSend,
onStop,
onFollowUp,
onSkip,
onEnterRoundtable,
enterButtonLabel,
intentReady,
footerAction,
hideEnterRoundtable,
onManualSave,
onTriggerToast,
modelOptions = [],
selectedModel,
onSelectedModelChange,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state.
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<AutoGrowTextarea
id="input-text-area"
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder={
pendingClarification
? "也可在此输入自定义回答(Enter 发送 / Shift+Enter 换行)"
: "请输入补充信息 / 回答问题 / 继续澄清(Enter 发送 / Shift+Enter 换行)"
}
className="position-roundtable-intent-textarea min-h-[74px] w-full max-h-80 bg-transparent px-3 py-2.5 text-sm font-normal leading-relaxed text-slate-700 placeholder:text-slate-400 focus:outline-none dark:text-[#e7f0ff] dark:placeholder:text-[#7e9bc5]"
maxHeight={320}
maxLength={500}
/>
<div className="hidden">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onTriggerToast("表情包功能仍在微调状态(仅供演示)")}
aria-label="打开表情"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Smile className="size-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
</button>
</div>
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
</div>
{/*
All stage actions intentionally stay inside the same composer frame.
Parent pages can still hide optional actions by their stable ids
without changing this component's layout or global input styles.
*/}
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
<div className="flex shrink-0 items-center">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件"
aria-label="上传参考文件"
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
</button>
</div>
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{modelOptions.length > 0 && (
<div
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
title="切换意图理解模型"
>
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
<TSelect
size="small"
aria-label="切换意图理解模型"
value={selectedModel}
options={modelOptions}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
popupProps={{ overlayClassName: "z-[80]" }}
/>
</div>
)}
<button
id="send-message-btn"
type="button"
onClick={onSend}
disabled={!canSend}
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
{isSending ? "发送中" : "发送"}
</button>
{isSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStop}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
>
<Pause className="size-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
{footerAction ??
(!hideEnterRoundtable && (
<button
id="enter-roundtable-btn"
type="button"
onClick={onEnterRoundtable}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
className={`flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10 ${
intentReady
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white"
}`}
>
<Sparkles className="size-3.5 text-emerald-100" />
{enterButtonLabel}
</button>
))}
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,851 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, FileText, LockKeyhole, Settings } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Select as TSelect } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
savePositionNodeConversation,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentKind,
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;
agentName?: string | null;
}
interface PositionNodeChatProps {
sessionId: string;
/** Parent task id: all node threads are indexed under this task. */
taskId: string;
chainId: string | null;
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;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
/** Rendered in the header so the wide same-position selector never overlaps it. */
onOpenConfiguration?: () => void;
onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
/** Unique write_file invocation used to distinguish rewrites of one path. */
previewKey?: string;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
threadId: string;
thread: BaseStream<AgentThreadState>;
}) => void;
}
interface NodeArtifact {
name: string;
path: string;
mimeType: string | null;
}
function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifact[] {
return manifest.flatMap((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path : "";
if (!path) return [];
const name =
typeof artifact.name === "string" && artifact.name.trim()
? artifact.name
: path.split("/").filter(Boolean).at(-1) || "未命名产物";
const mimeType =
typeof artifact.mime_type === "string"
? artifact.mime_type
: typeof artifact.mimeType === "string"
? artifact.mimeType
: null;
return [{ name, path, mimeType }];
});
}
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
const mime =
typeof artifact.mime_type === "string"
? artifact.mime_type.toLowerCase()
: typeof artifact.mimeType === "string"
? artifact.mimeType.toLowerCase()
: "";
return (
mime.includes("markdown") ||
path.endsWith(".md") ||
path.endsWith(".markdown") ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
});
}
function hasAnyArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.trim() : "";
const name = typeof artifact.name === "string" ? artifact.name.trim() : "";
return Boolean(path || name);
});
}
function nodeSwitchStatusLabel(node: PositionRoundtableNode): string {
if (node.invalidatedBy || node.status === "stale") return "需重做";
switch (node.status) {
case "running":
return "进行中";
case "done":
return "已完成";
case "rejected":
return "已驳回";
case "error":
return "异常";
case "locked":
return "未开始";
case "ready":
default:
return "待开始";
}
}
function artifactDisplayName(pathOrUrl: string): string {
let path = pathOrUrl;
if (pathOrUrl.startsWith("write-file:")) {
try {
path = decodeURIComponent(new URL(pathOrUrl).pathname);
} catch {
path = pathOrUrl;
}
}
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
}
function latestStreamingWriteFile(
messages: AgentThreadState["messages"],
): { key: string; path: string; name: string; mimeType: string | null } | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string;
tool_calls?: Array<{ id?: string; name?: string; args?: Record<string, unknown> }>;
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
const rawPath = toolCall.args?.path;
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
const content = toolCall.args?.content;
if (typeof content !== "string" || content.length === 0) continue;
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
return {
key: `${message.id}:${toolCall.id}`,
path: virtualPath,
name: artifactDisplayName(rawPath),
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
};
}
}
return null;
}
/**
* A tool-call id is stable within a persisted thread. Capturing the existing
* ids right before a new submission lets the streaming preview distinguish a
* new write_file from an older deliverable kept in the message history.
*/
function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string> {
const keys = new Set<string>();
for (const rawMessage of messages) {
const message = rawMessage as (typeof messages)[number] & {
id?: string;
tool_calls?: Array<{ id?: string; name?: string }>;
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (const toolCall of message.tool_calls) {
if (toolCall?.name === "write_file" && toolCall.id) {
keys.add(`${message.id}:${toolCall.id}`);
}
}
}
return keys;
}
/**
* Direct single-agent chat for one frozen business-chain node.
*
* It deliberately uses the regular `useThreadStream` + `MessageList` +
* `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,
taskId,
chainId,
node,
currentPositionId,
sessionStatus,
offlineDemo = false,
agentName,
positionNodeOptions,
onSelectPositionNode,
builtinAgentOptions,
onSelectBuiltinAgent,
onOpenConfiguration,
onNodeUpdate,
onConversationStart,
onPreviewArtifact,
onThreadSnapshot,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false);
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 =
lastRejection && typeof lastRejection.reason === "string" ? lastRejection.reason.trim() : "";
const switchOptions = useMemo<PositionNodeSwitchOption[]>(
() =>
positionNodeOptions?.length
? positionNodeOptions
: [{ node, agentName }],
[agentName, node, positionNodeOptions],
);
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
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" &&
node.positionId === currentPositionId &&
node.status !== "locked" &&
node.status !== "error" &&
!node.invalidatedBy;
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(
(threadId: string) => {
// Persist as soon as the SDK exposes an id. A refresh after this point
// can restore the exact seat thread instead of starting a second chat.
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
.then(onNodeUpdate)
.catch((error) => {
toast.error(`节点对话绑定失败:${error instanceof Error ? error.message : String(error)}`);
});
},
[node.nodeKey, onNodeUpdate, sessionId],
);
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId: node.agentId,
context: { ...settings.context, agent_id: node.agentId },
threadMetadata: {
thread_type: "position_roundtable",
// ``taskId`` is the shared task-workspace convention. The two
// position-specific identifiers retain the exact child node for audits
// and restore, even when one position owns more than one chain seat.
taskId,
position_roundtable_task_id: taskId,
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
position_session_id: sessionId,
position_node_id: node.id,
position_node_key: node.nodeKey,
position_id: node.positionId,
chain_id: chainId,
agent_id: node.agentId,
},
onStart: bindCreatedThread,
onFinish: (state) => {
// A turn is not considered delivered by the UI alone. The persisted
// completion call records the answer, file manifest and upstream version
// so the service can decide whether this seat unlocks its downstream.
const threadId = boundThreadIdRef.current;
const latestAi = [...(state.messages ?? [])]
.reverse()
.find((message) => message.type === "ai");
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
if (!threadId || !latestAnswer.trim()) return;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(threadId);
artifactManifest = artifacts.files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
} catch {
// Artifact listing is best-effort: a valid text answer must still be
// persisted and unlock downstream work even if files cannot be read.
}
try {
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer,
artifactManifest,
upstreamRevision: upstreamRevisionRef.current,
});
onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) {
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
})();
},
});
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];
if (message?.type === "ai" && message.id && (textOfMessage(message) ?? "").trim()) {
return message.id;
}
}
return null;
}, [thread.messages]);
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: filepath.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
const handleSaveMessageEdit = useCallback(
async (messageId: string, text: string) => {
const threadId = boundThreadIdRef.current;
const content = text.trim();
if (!threadId || !content) return;
try {
const updatedMessage = await editMessage.mutateAsync({
threadId,
messageId,
content,
});
setMessageContentOverrides((prev) => ({
...prev,
[messageId]: textOfMessage(updatedMessage) ?? content,
}));
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
try {
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer: content,
artifactManifest: node.artifactManifest,
upstreamRevision: node.upstreamRevision,
});
onNodeUpdate(updatedNode);
} catch (syncError) {
toast.warning(
`消息已保存,但节点交接摘要同步失败:${
syncError instanceof Error ? syncError.message : String(syncError)
}`,
);
}
}
toast.success("消息已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "消息保存失败");
throw error;
}
},
[
editMessage,
latestAiMessageId,
node.artifactManifest,
node.nodeKey,
node.upstreamRevision,
onNodeUpdate,
sessionId,
],
);
const hasMultipleSwitchOptions = switchOptions.length > 1;
const switchSelectOptions = useMemo(
() =>
switchOptions.map((option, index) => {
const optionNode = option.node;
const statusLabel = nodeSwitchStatusLabel(optionNode);
const optionName = desensitize(
option.agentName || optionNode.agentId || `智能体 ${index + 1}`,
);
const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : "";
return { label: `${optionName} · ${statusLabel}${artifactText}`, value: optionNode.nodeKey };
}),
[switchOptions],
);
const switchSelectStyle = useMemo(() => ({ width: 380, maxWidth: "34vw" }), []);
const switchSelectPopupProps = useMemo(
() => ({ overlayInnerStyle: { minWidth: 380 } }),
[],
);
const handleSwitchNode = useCallback(
(targetNode: PositionRoundtableNode) => {
if (targetNode.nodeKey === node.nodeKey) return;
if (thread.isLoading) {
toast.info("当前智能体正在生成,完成后可切换");
return;
}
onSelectPositionNode?.(targetNode);
},
[node.nodeKey, onSelectPositionNode, thread.isLoading],
);
const handleSwitchSelectChange = useCallback(
(value: unknown) => {
const nextNode = switchOptions.find((option) => option.node.nodeKey === String(value))?.node;
if (nextNode) handleSwitchNode(nextNode);
},
[handleSwitchNode, switchOptions],
);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
const streamingArtifact = useMemo(
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages],
);
useEffect(() => {
if (
!thread.isLoading ||
!streamingArtifact ||
!autoPreviewRunArmedRef.current ||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
) {
return;
}
// One write_file call should open at most one preview. The parent further
// guards manual close state, because it owns the page-wide singleton sandbox.
// A report is often rewritten to the same output path. The tool-call key
// (not just the path) makes that a new previewable delivery.
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId: activeThreadId,
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
previewKey: streamingArtifact.key,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page
// refresh could happen between creation and the async node-index write.
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
bindCreatedThread(actualThreadId);
}, [actualThreadId, bindCreatedThread]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!canWrite) {
toast.info(
sessionStatus === "archived"
? "该会话已归档,仅可查看历史记录"
: node.invalidatedBy
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
: node.status === "locked"
? "该节点尚未解锁"
: "当前岗位只能查看该节点对话",
);
return;
}
try {
onConversationStart?.();
// Fetch handoff immediately before sending rather than when the page
// mounts. This guarantees the prompt sees the newest completed upstream
// delivery and captures the matching revision for completion checks.
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
const upstreamRevision = taskExternalContext.upstream_revision;
upstreamRevisionRef.current =
upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision)
? (upstreamRevision as Record<string, unknown>)
: null;
// A thread retains historical write_file tool calls. Only files whose
// call id appears after this exact submission may open the sandbox.
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true;
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
agent_id: node.agentId,
task_external_context: taskExternalContext,
});
} catch (error) {
autoPreviewRunArmedRef.current = false;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
[
canWrite,
node.agentId,
node.nodeKey,
node.status,
onConversationStart,
sendMessage,
sessionId,
sessionStatus,
thread.messages,
],
);
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
const readOnlyHint =
sessionStatus === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: node.invalidatedBy
? "上游产物已变更,需等待前序节点重新完成后再重做"
: node.status === "locked"
? "等待上游岗位完成后解锁"
: node.positionId !== currentPositionId
? "当前岗位仅可查看该节点的对话和产物"
: "";
return (
<ThreadContext.Provider value={threadContextValue}>
<div className="position-roundtable-agent-analysis-page position-roundtable-node-chat flex min-h-0 flex-1 flex-col">
<header className="position-roundtable-agent-header flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
<span className="position-roundtable-agent-avatar flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{node.status === "locked" ? <LockKeyhole className="size-3.5" /> : <Bot className="size-4" />}
</span>
<div className="position-roundtable-agent-title min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || `节点状态:${node.status}`}
</p>
</div>
{hasMultipleSwitchOptions && (
<div
className="ml-auto flex min-w-0 shrink-0 items-center gap-2"
title={thread.isLoading ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
同岗位智能体
</span>
{thread.isLoading ? (
<div
className="flex h-9 shrink-0 items-center rounded-[4px] border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]"
style={switchSelectStyle}
>
{agentDisplayName} · 进行中
</div>
) : (
<TSelect
aria-label="同岗位智能体切换"
className="shrink-0 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]"
style={switchSelectStyle}
value={node.nodeKey}
options={switchSelectOptions}
onChange={handleSwitchSelectChange}
popupProps={switchSelectPopupProps}
/>
)}
</div>
)}
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={null}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
{lastRejection && (
<TButton
size="small"
theme="warning"
variant="outline"
className="!shrink-0"
onClick={() => setRejectionReasonOpen(true)}
>
查看驳回理由
</TButton>
)}
{onOpenConfiguration && (
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Settings className="!size-4 !shrink-0" />}
onClick={onOpenConfiguration}
title="打开右侧配置"
/>
)}
</header>
{nodeArtifacts.length > 0 && (
<section className="position-roundtable-agent-artifacts shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
节点产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{nodeArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{nodeArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadId && onPreviewArtifact) {
onPreviewArtifact({
threadId: boundThreadId,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId || recoveredMessages.length > 0 ? (
<MessageList
className="position-roundtable-agent-message-list min-h-0 flex-1"
threadId={boundThreadId ?? displayThreadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Clarification cards are rendered by MessageList, but position
// nodes own the actual stream. Route their answer through the
// same submit path as the normal input so handoff context,
// permissions and node lifecycle updates are all preserved.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onEditSave={handleSaveMessageEdit}
messageContentOverrides={messageContentOverrides}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? "向该岗位智能体发起第一次问答" : "该节点尚无可查看的对话"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "系统会自动传入任务意图和已完成上游节点的问答结果。"
: readOnlyHint || "请选择已启动的岗位节点。"}
</p>
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={node.agentId}
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)}
onStop={() => void thread.stop()}
/>
</div>
<Dialog open={rejectionReasonOpen} onOpenChange={setRejectionReasonOpen}>
<DialogContent className="w-[480px] !max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle>驳回理由</DialogTitle>
</DialogHeader>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-sm leading-6 text-amber-900 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-100">
{rejectionReason || "本次驳回未填写具体理由。"}
</div>
<DialogFooter>
<TButton variant="outline" onClick={() => setRejectionReasonOpen(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ThreadContext.Provider>
);
}
@@ -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(),
};
}
@@ -0,0 +1,65 @@
/**
* Temporary task source for the standalone position-collaboration workspace.
*
* The real task-system adapter should replace only this module. The page and
* Session API consume the normalized `PositionTask` shape, so they do not need
* to know whether data came from an external task platform or this local demo.
*/
export interface PositionTask {
id: string;
title: string;
description: string;
direction: string;
source: string;
issuedAt: string;
}
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
{
id: "mock-position-task-001",
title: "暑期活动",
description:
"围绕暑期活动的游泳夏令营做宣传",
direction: "暑期活动",
source: "本系统创建",
issuedAt: "2026-07-22 17:42:00",
},
] as const;
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
export function getMockPositionTask(taskId?: string | null): PositionTask {
return (
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
MOCK_POSITION_TASKS[0]
);
}
/** Normalizes a persisted Session task snapshot without exposing source internals. */
export function taskFromPositionSnapshot(
snapshot: Record<string, unknown> | null | undefined,
fallback: PositionTask,
): PositionTask {
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
const value = (keys: string[], defaultValue: string) => {
const found = keys
.map((key) => snapshot[key])
.find((item) => typeof item === "string" && item.trim());
return typeof found === "string" ? found : defaultValue;
};
return {
id: value(["id", "taskId", "externalTaskId"], fallback.id),
title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction),
source: (() => {
const source = value(["source", "taskSource", "sourceName"], fallback.source);
return source === "mock" ? "本系统创建" : source;
})(),
issuedAt: value(
["issuedAt", "issued_at", "dispatchTime", "publishedAt", "createdAt", "created_at"],
fallback.issuedAt,
),
};
}
@@ -0,0 +1,39 @@
/**
* 岗位协同的第一版岗位目录。
*
* 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id,
* 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。
*/
export const POSITION_OPTIONS = [
{
id: "intelligence",
name: "测试分析岗",
description: "识别任务意图、补齐约束并确认业务链条",
isIntentPosition: true,
},
{
id: "planning",
name: "组织测试岗",
description: "承接上游信息并进行组织测试",
isIntentPosition: false,
},
{
id: "solution",
name: "方案设计岗",
description: "承接上游结果并形成方案产物",
isIntentPosition: false,
},
{
id: "evaluation",
name: "综合测试岗",
description: "测试链条产物并提出综合建议",
isIntentPosition: false,
},
] as const;
export type PositionId = (typeof POSITION_OPTIONS)[number]["id"];
export const DEFAULT_POSITION_ID: PositionId = "intelligence";
export const positionNameById = (positionId: string | null | undefined): string =>
POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
/*
* 岗位会商 - 其他岗位智能体分析页面专属样式
*
* 覆盖范围:
* - 普通业务链节点:PositionNodeChat
* - 内置收口节点:PositionBuiltinAgentChat(方案总结 / 行动规划)
*
* 后续如果要调岗位智能体聊天页、产物条、输入区,优先改这个文件。
*/
.position-roundtable-agent-analysis-page {
--prt-agent-panel-bg: #ffffff;
--prt-agent-header-bg: #ffffff;
--prt-agent-header-border: rgb(241 245 249);
--prt-agent-artifact-bg: rgba(248, 250, 252, 0.72);
--prt-agent-composer-bg: #ffffff;
--prt-agent-border: rgb(241 245 249);
--prt-agent-title-color: rgb(15 23 42);
--prt-agent-muted-color: rgb(100 116 139);
--prt-agent-avatar-bg: rgba(245, 34, 45, 0.1);
--prt-agent-avatar-color: var(--primary);
}
.dark .position-roundtable-agent-analysis-page {
--prt-agent-panel-bg: rgba(3, 38, 80, 0.62);
--prt-agent-header-bg: rgba(3, 38, 80, 0.74);
--prt-agent-header-border: rgba(43, 143, 254, 0.2);
--prt-agent-artifact-bg: rgba(3, 38, 80, 0.5);
--prt-agent-composer-bg: rgba(3, 38, 80, 0.64);
--prt-agent-border: rgba(43, 143, 254, 0.2);
--prt-agent-title-color: #e8edf5;
--prt-agent-muted-color: #8aa0c4;
--prt-agent-avatar-bg: rgba(43, 143, 254, 0.16);
--prt-agent-avatar-color: #73c8ff;
}
.position-roundtable-agent-analysis-page {
background: var(--prt-agent-panel-bg);
}
.position-roundtable-agent-header {
border-color: var(--prt-agent-header-border);
background: var(--prt-agent-header-bg);
}
.position-roundtable-agent-avatar {
background: var(--prt-agent-avatar-bg);
color: var(--prt-agent-avatar-color);
}
.position-roundtable-agent-title h2 {
color: var(--prt-agent-title-color);
}
.position-roundtable-agent-title p {
color: var(--prt-agent-muted-color);
}
.position-roundtable-agent-artifacts {
border-color: var(--prt-agent-border);
background: var(--prt-agent-artifact-bg);
}
.position-roundtable-agent-message-list {
background: var(--prt-agent-panel-bg);
}
.position-roundtable-agent-composer-bar {
border-color: var(--prt-agent-border);
background: var(--prt-agent-composer-bg);
}
.position-roundtable-agent-input {
--prt-agent-input-radius: 14px;
}
@@ -0,0 +1,71 @@
/*
* 岗位会商 - 意图理解页面专属样式
*
* 这个文件只服务 `PositionRoundtablePage` 里的意图理解区域。
* 后续手写样式优先改这里,尽量不要再往 globals.css 或组件 className 里堆。
*/
.position-roundtable-intent-page {
--prt-intent-panel-bg: #ffffff;
--prt-intent-panel-border: rgb(226 232 240);
--prt-intent-composer-bg: #f7faff;
--prt-intent-composer-frame-bg: #ffffff;
--prt-intent-composer-border: rgb(203 213 225);
--prt-intent-toolbar-bg: #ffffff;
--prt-intent-text-color: rgb(51 65 85);
--prt-intent-muted-color: rgb(100 116 139);
--prt-intent-radius: 4px;
}
.dark .position-roundtable-intent-page {
--prt-intent-panel-bg: rgba(3, 38, 80, 0.72);
--prt-intent-panel-border: rgba(43, 143, 254, 0.28);
--prt-intent-composer-bg: #071a38;
--prt-intent-composer-frame-bg: #0d2855;
--prt-intent-composer-border: rgba(43, 143, 254, 0.55);
--prt-intent-toolbar-bg: #0b234c;
--prt-intent-text-color: #e7f0ff;
--prt-intent-muted-color: #8aa0c4;
}
.position-roundtable-intent-panel {
background: var(--prt-intent-panel-bg);
}
.position-roundtable-intent-panel > div:first-child {
border-color: var(--prt-intent-panel-border);
}
.position-roundtable-intent-panel > div:nth-child(2) > div:first-child {
background: var(--prt-intent-panel-bg);
}
.position-roundtable-intent-composer {
background: var(--prt-intent-composer-bg);
}
.position-roundtable-intent-composer-frame {
border-color: var(--prt-intent-composer-border);
border-radius: var(--prt-intent-radius);
background: var(--prt-intent-composer-frame-bg);
}
.position-roundtable-intent-textarea {
color: var(--prt-intent-text-color);
}
.position-roundtable-intent-toolbar {
background: var(--prt-intent-toolbar-bg);
}
.position-roundtable-intent-attachments {
color: var(--prt-intent-muted-color);
}
.position-roundtable-intent-actions {
row-gap: 6px;
}
.position-roundtable-intent-model-switch {
border-radius: var(--prt-intent-radius);
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more