release: v1.0.0-beta

This commit is contained in:
coso
2026-03-31 02:18:13 +08:00
parent 5398aa0ca3
commit 732cf9b390
389 changed files with 7739 additions and 31331 deletions
+6 -1
View File
@@ -8,7 +8,12 @@
* _需求: 2.2, 3.2, 5.2_
*/
import React, { Suspense, lazy, useState, useCallback } from "react";
import React, {
Suspense,
lazy,
useState,
useCallback,
} from "react";
import styled from "styled-components";
import { withI18nPatch } from "./i18n/withI18nPatch";
import { AppPageContent } from "./components/AppPageContent";
+11
View File
@@ -23,6 +23,10 @@ vi.mock("./channels/ImConfigPage", () => ({
ImConfigPage: () => <div data-testid="im-config-page" />,
}));
vi.mock("./settings-v2", () => ({
SettingsPageV2: () => <div data-testid="settings-page" />,
}));
interface MountedContent {
container: HTMLDivElement;
root: Root;
@@ -134,4 +138,11 @@ describe("AppPageContent", () => {
expect(container.querySelector('[data-testid="im-config-page"]')).not.toBeNull();
});
it("settings 页面应渲染设置页入口", async () => {
const container = renderContent("settings");
await flushEffects();
expect(container.querySelector('[data-testid="settings-page"]')).not.toBeNull();
});
});
+1 -28
View File
@@ -15,13 +15,13 @@ import type {
Page,
PageParams,
SettingsPageParams,
StylePageParams,
} from "@/types/page";
import {
getThemeByWorkspacePage,
isThemeWorkspacePage,
type ThemeWorkspacePage,
} from "@/types/page";
import { SettingsPageV2 } from "./settings-v2";
const PageWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
@@ -46,11 +46,6 @@ const columnPageStyle = {
flexDirection: "column",
} as const;
const SettingsPageV2 = lazy(() =>
import("./settings-v2").then((module) => ({
default: module.SettingsPageV2,
})),
);
const ToolsPage = lazy(() =>
import("./tools/ToolsPage").then((module) => ({
default: module.ToolsPage,
@@ -66,11 +61,6 @@ const MemoryPage = lazy(() =>
default: module.MemoryPage,
})),
);
const StylePage = lazy(() =>
import("./style").then((module) => ({
default: module.StylePage,
})),
);
const PluginsPage = lazy(() =>
import("./plugins/PluginsPage").then((module) => ({
default: module.PluginsPage,
@@ -161,12 +151,6 @@ function renderThemeWorkspace(
theme={theme}
viewMode={agentPageParams.workspaceViewMode}
resetAt={agentPageParams.workspaceResetAt}
initialStyleGuideDialogOpen={
agentPageParams.workspaceOpenProjectStyleGuide
}
initialStyleGuideSourceEntryId={
agentPageParams.workspaceOpenProjectStyleGuideSourceEntryId
}
initialCreatePrompt={agentPageParams.workspaceCreatePrompt}
initialCreateSource={agentPageParams.workspaceCreateSource}
initialCreateFallbackTitle={
@@ -333,17 +317,6 @@ export function AppPageContent({
);
}
if (currentPage === "style") {
return (
<div style={columnPageStyle}>
<StylePage
onNavigate={onNavigate}
pageParams={pageParams as StylePageParams}
/>
</div>
);
}
if (currentPage === "memory") {
return (
<div style={columnPageStyle}>
+4 -11
View File
@@ -110,7 +110,7 @@ describe("AppSidebar", () => {
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
});
it("旧导航配置未包含能力分组时也应显示固定能力入口和二级项", async () => {
it("旧导航配置未包含能力入口时也应显示固定能力分组和核心能力项", async () => {
mockGetConfig.mockResolvedValue({
navigation: {
enabled_items: ["home-general", "claw"],
@@ -130,26 +130,19 @@ describe("AppSidebar", () => {
expect(container.textContent).toContain("IM 配置");
});
it("进入 IM 配置页时应高亮能力分组和对应二级导航", async () => {
it("进入 IM 配置页时应高亮对应能力入口并保留能力分组标题", async () => {
const container = mountSidebar({
currentPage: "channels",
});
await flushEffects();
expect(
container.querySelector('button[aria-label="能力"][aria-current="page"]'),
).not.toBeNull();
expect(container.textContent).toContain("能力");
expect(
container.querySelector('button[aria-label="IM 配置"][aria-current="page"]'),
).not.toBeNull();
expect(
container.querySelector('button[aria-label="能力"]')?.getAttribute(
"aria-expanded",
),
).toBe("true");
});
it("未启用创作主题时不应渲染主题分组标题", async () => {
it("侧边栏不应再渲染旧主题分组标题", async () => {
const container = mountSidebar({
currentPageParams: {
agentEntry: "new-task",
+33 -148
View File
@@ -1,7 +1,7 @@
/**
* 全局应用侧边栏
*
* 参考成熟产品的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口
* 参考任务中心信息架构:任务、工作台、能力、资料库、系统入口分层展示
*/
import { useState, useEffect, useMemo, type ReactElement } from "react";
@@ -9,19 +9,11 @@ import styled from "styled-components";
import {
ChevronDown,
ChevronRight,
Image,
Moon,
Sun,
Search,
PanelLeftClose,
PanelLeftOpen,
PenTool,
Video,
Music,
BookOpen,
Lightbulb,
CalendarRange,
FileType,
Activity,
LucideIcon,
} from "lucide-react";
@@ -29,11 +21,10 @@ import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
import {
AgentPageParams,
getThemeWorkspacePage,
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
Page,
PageParams,
ThemeWorkspacePage,
isThemeWorkspacePage,
} from "@/types/page";
import { getConfig } from "@/lib/api/appConfig";
import {
@@ -43,15 +34,13 @@ import {
} from "@/lib/workspace/navigation";
import {
DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS,
FOOTER_SIDEBAR_NAV_ITEMS,
FOOTER_SIDEBAR_NAV_SECTIONS,
MAIN_SIDEBAR_NAV_SECTIONS,
MAIN_SIDEBAR_NAV_ITEMS,
resolveEnabledSidebarNavItems,
type SidebarNavItemDefinition,
type SidebarNavSectionDefinition,
} from "@/lib/navigation/sidebarNav";
import {
DEFAULT_ENABLED_CONTENT_THEME_IDS,
resolveEnabledContentThemes,
} from "@/lib/contentCreator/themeDefaults";
import {
Tooltip,
TooltipContent,
@@ -66,6 +55,7 @@ interface AppSidebarProps {
}
type SidebarNavItem = SidebarNavItemDefinition;
type SidebarNavSection = SidebarNavSectionDefinition;
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200;
@@ -443,70 +433,6 @@ const IconActionButton = styled.button<{ $active?: boolean }>`
}
`;
const THEME_MENU_ITEMS: SidebarNavItem[] = [
{
id: "theme-social-media",
label: "社媒内容",
icon: PenTool,
page: getThemeWorkspacePage("social-media"),
isActive: (currentPage) =>
currentPage === getThemeWorkspacePage("social-media"),
},
{
id: "theme-poster",
label: "图文海报",
icon: Image,
page: getThemeWorkspacePage("poster"),
isActive: (currentPage) => currentPage === getThemeWorkspacePage("poster"),
},
{
id: "theme-video",
label: "短视频",
icon: Video,
page: getThemeWorkspacePage("video"),
params: { workspaceViewMode: "workspace" },
isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"),
},
{
id: "theme-music",
label: "歌词曲谱",
icon: Music,
page: getThemeWorkspacePage("music"),
isActive: (currentPage) => currentPage === getThemeWorkspacePage("music"),
},
{
id: "theme-novel",
label: "小说创作",
icon: BookOpen,
page: getThemeWorkspacePage("novel"),
isActive: (currentPage) => currentPage === getThemeWorkspacePage("novel"),
},
{
id: "theme-document",
label: "办公文档",
icon: FileType,
page: getThemeWorkspacePage("document"),
isActive: (currentPage) =>
currentPage === getThemeWorkspacePage("document"),
},
{
id: "theme-knowledge",
label: "知识探索",
icon: Lightbulb,
page: getThemeWorkspacePage("knowledge"),
isActive: (currentPage) =>
currentPage === getThemeWorkspacePage("knowledge"),
},
{
id: "theme-planning",
label: "计划规划",
icon: CalendarRange,
page: getThemeWorkspacePage("planning"),
isActive: (currentPage) =>
currentPage === getThemeWorkspacePage("planning"),
},
];
function getIconByName(iconName: string): LucideIcon {
const IconComponent = (
LucideIcons as unknown as Record<string, LucideIcon | undefined>
@@ -514,10 +440,6 @@ function getIconByName(iconName: string): LucideIcon {
return IconComponent || Activity;
}
function isThemeWorkspacePage(page: Page): page is ThemeWorkspacePage {
return typeof page === "string" && page.startsWith("workspace-");
}
export function AppSidebar({
currentPage,
currentPageParams,
@@ -547,14 +469,8 @@ export function AppSidebar({
const [enabledNavItems, setEnabledNavItems] = useState<string[]>(
DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS,
);
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_CONTENT_THEME_IDS,
);
const [sidebarPlugins, setSidebarPlugins] = useState<PluginUIInfo[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [_activeThemeKey, setActiveThemeKey] = useState<string>(
getThemeWorkspacePage("general"),
);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>(() =>
MAIN_SIDEBAR_NAV_ITEMS.filter(
(item) => item.defaultExpanded && item.children && item.children.length > 0,
@@ -567,9 +483,6 @@ export function AppSidebar({
const config = await getConfig();
const saved = config.navigation?.enabled_items;
setEnabledNavItems(resolveEnabledSidebarNavItems(saved));
const savedThemes = config.content_creator?.enabled_themes;
setEnabledThemes(resolveEnabledContentThemes(savedThemes));
} catch (error) {
console.error("加载配置失败:", error);
}
@@ -582,35 +495,32 @@ export function AppSidebar({
};
window.addEventListener("nav-config-changed", handleConfigChange);
window.addEventListener("theme-config-changed", handleConfigChange);
return () => {
window.removeEventListener("nav-config-changed", handleConfigChange);
window.removeEventListener("theme-config-changed", handleConfigChange);
};
}, []);
const filteredMainMenuItems = useMemo(() => {
return MAIN_SIDEBAR_NAV_ITEMS.filter((item) =>
item.configurable === false || enabledNavItems.includes(item.id),
);
const filteredMainSections = useMemo<SidebarNavSection[]>(() => {
return MAIN_SIDEBAR_NAV_SECTIONS.map((section) => ({
...section,
items: section.items.filter(
(item) =>
item.configurable === false || enabledNavItems.includes(item.id),
),
})).filter((section) => section.items.length > 0);
}, [enabledNavItems]);
const filteredFooterMenuItems = useMemo(() => {
return FOOTER_SIDEBAR_NAV_ITEMS.filter(
(item) =>
item.configurable === false || enabledNavItems.includes(item.id),
);
const filteredFooterSections = useMemo<SidebarNavSection[]>(() => {
return FOOTER_SIDEBAR_NAV_SECTIONS.map((section) => ({
...section,
items: section.items.filter(
(item) =>
item.configurable === false || enabledNavItems.includes(item.id),
),
})).filter((section) => section.items.length > 0);
}, [enabledNavItems]);
const filteredThemeMenuItems = useMemo(() => {
return THEME_MENU_ITEMS.filter((item) => {
// 从 theme-xxx 提取出 xxx
const themeId = item.id.replace("theme-", "");
return enabledThemes.includes(themeId);
});
}, [enabledThemes]);
useEffect(() => {
let cancelled = false;
@@ -697,21 +607,6 @@ export function AppSidebar({
setCollapsed(true);
}, [isClawTaskCenter, isNewTaskHome]);
useEffect(() => {
if (isThemeWorkspacePage(currentPage)) {
setActiveThemeKey(currentPage);
}
}, [currentPage]);
useEffect(() => {
const savedThemeKey = localStorage.getItem(
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
);
if (savedThemeKey) {
setActiveThemeKey(savedThemeKey);
}
}, []);
const assistantItems = useMemo<SidebarNavItem[]>(() => {
return sidebarPlugins.map((plugin) => {
const pluginPageId = `plugin:${plugin.pluginId}` as Page;
@@ -733,10 +628,6 @@ export function AppSidebar({
return false;
}
if (item.id.startsWith("theme-")) {
return currentPage === item.page;
}
if (item.isActive) {
return item.isActive(currentPage, currentPageParams);
}
@@ -754,10 +645,6 @@ export function AppSidebar({
return false;
}
if (item.id.startsWith("theme-")) {
return currentPage === item.page;
}
if (item.isActive) {
return item.isActive(currentPage, currentPageParams);
}
@@ -786,7 +673,6 @@ export function AppSidebar({
}
if (isThemeWorkspacePage(item.page)) {
setActiveThemeKey(item.page);
localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, item.page);
}
@@ -941,16 +827,12 @@ export function AppSidebar({
</HeaderArea>
<MenuScroll>
<Section $collapsed={collapsed}>
{filteredMainMenuItems.map((item) => renderNavItem(item))}
</Section>
{filteredThemeMenuItems.length > 0 && (
<Section $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>创作主题</SectionTitle>
{filteredThemeMenuItems.map((item) => renderNavItem(item))}
{filteredMainSections.map((section) => (
<Section key={section.id} $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>{section.title}</SectionTitle>
{section.items.map((item) => renderNavItem(item))}
</Section>
)}
))}
{assistantItems.length > 0 && (
<Section $collapsed={collapsed}>
@@ -961,9 +843,12 @@ export function AppSidebar({
</MenuScroll>
<FooterArea $collapsed={collapsed}>
<Section $collapsed={collapsed}>
{filteredFooterMenuItems.map((item) => renderNavItem(item))}
</Section>
{filteredFooterSections.map((section) => (
<Section key={section.id} $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>{section.title}</SectionTitle>
{section.items.map((item) => renderNavItem(item))}
</Section>
))}
<ActionRow $collapsed={collapsed}>
{!collapsed ? <div /> : null}
@@ -31,12 +31,12 @@ import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts";
import { type CanvasWorkbenchLayoutMode } from "./components/CanvasWorkbenchLayout";
import type { CreationMode } from "./components/types";
import { type TaskFile } from "./components/TaskFiles";
import { useWorkflow } from "@/components/content-creator/hooks/useWorkflow";
import { useWorkflow } from "@/lib/workspace/workbenchWorkflow";
import {
createInitialCanvasState,
type CanvasStateUnion,
} from "@/components/content-creator/canvas/canvasUtils";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
} from "@/lib/workspace/workbenchCanvas";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
import {
type CanvasState as GeneralCanvasState,
DEFAULT_CANVAS_STATE,
@@ -48,11 +48,8 @@ import {
} from "@/lib/artifact/store";
import type { Artifact } from "@/lib/artifact/types";
import { useAtomValue, useSetAtom } from "jotai";
import {
generateContentCreationPrompt,
isContentCreationTheme,
} from "@/components/content-creator/utils/systemPrompt";
import { generateProjectMemoryPrompt } from "@/components/content-creator/utils/projectPrompt";
import { generateContentCreationPrompt } from "@/lib/workspace/workbenchPrompt";
import { generateProjectMemoryPrompt } from "@/lib/workspace/workbenchPrompt";
import {
getProject,
getContent,
@@ -77,7 +74,11 @@ import type {
SiteSavedContentTarget,
WriteArtifactContext,
} from "./types";
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import {
isContentCreationTheme,
type LayoutMode,
type ThemeType,
} from "@/lib/workspace/workbenchContract";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata";
import { deriveHarnessSessionState } from "./utils/harnessState";
@@ -113,12 +114,6 @@ import { useThemeWorkbenchEntryPromptActions } from "./hooks/useThemeWorkbenchEn
import { useThemeWorkbenchSendBoundary } from "./hooks/useThemeWorkbenchSendBoundary";
import type { BrowserTaskPreflight } from "./hooks/handleSendTypes";
import { mergeThreadItems } from "./utils/threadTimelineView";
import {
DEFAULT_STYLE_PROFILE,
buildRuntimeStyleOverridePrompt,
getStyleProfileFromGuide,
type RuntimeStyleSelection,
} from "@/lib/style-guide";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
import {
asRecord,
@@ -461,15 +456,6 @@ export function AgentChatWorkspace({
),
[mediaDefaults.image, project?.settings?.imageGeneration],
);
const [runtimeStyleSelection, setRuntimeStyleSelection] =
useState<RuntimeStyleSelection>({
presetId: "project-default",
strength: DEFAULT_STYLE_PROFILE.simulationStrength,
customNotes: "",
source: "project-default",
sourceLabel: undefined,
sourceProfile: null,
});
const imageWorkbenchGenerationRuntime = useImageGen({
preferredProviderId: effectiveImageWorkbenchPreference.preferredProviderId,
@@ -537,28 +523,6 @@ export function AgentChatWorkspace({
taskFilesRef.current = taskFiles;
}, [taskFiles]);
useEffect(() => {
setRuntimeStyleSelection((previous) => {
if (
previous.presetId !== "project-default" ||
previous.customNotes.trim()
) {
return previous;
}
const nextStrength =
getStyleProfileFromGuide(projectMemory?.style_guide)
?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength;
return previous.strength === nextStrength
? previous
: {
...previous,
strength: nextStrength,
};
});
}, [projectMemory?.style_guide]);
// 主动 workspace 健康检查失败标记(区别于 workspacePathMissing 发送失败场景)
const [workspaceHealthError, setWorkspaceHealthError] = useState(false);
@@ -618,14 +582,6 @@ export function AgentChatWorkspace({
// 工作流状态(仅在内容创作模式下使用)
const mappedTheme = activeTheme as ThemeType;
useEffect(() => {
setRuntimeStyleSelection({
presetId: "project-default",
strength: DEFAULT_STYLE_PROFILE.simulationStrength,
customNotes: "",
});
}, [mappedTheme, projectId]);
const { steps, currentStepIndex, goToStep, completeStep } = useWorkflow(
mappedTheme,
creationMode,
@@ -793,7 +749,6 @@ export function AgentChatWorkspace({
charactersCount: memory?.characters?.length ?? 0,
durationMs: Date.now() - startedAt,
hasOutline: Boolean(memory?.outline?.length),
hasStyleGuide: Boolean(memory?.style_guide),
projectId,
});
@@ -1048,32 +1003,6 @@ export function AgentChatWorkspace({
});
}, [project, projectId, rememberProjectId]);
const runtimeStylePrompt = useMemo(
() =>
buildRuntimeStyleOverridePrompt({
projectStyleGuide: projectMemory?.style_guide,
selection: runtimeStyleSelection,
activeTheme: mappedTheme,
}),
[mappedTheme, projectMemory?.style_guide, runtimeStyleSelection],
);
const runtimeStyleMessagePrompt = useMemo(() => {
const projectDefaultStrength =
getStyleProfileFromGuide(projectMemory?.style_guide)
?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength;
const hasPresetOverride =
runtimeStyleSelection.presetId !== "project-default" ||
runtimeStyleSelection.source === "library";
const hasCustomNotes = runtimeStyleSelection.customNotes.trim().length > 0;
const hasStrengthOverride =
runtimeStyleSelection.strength !== projectDefaultStrength;
return hasPresetOverride || hasCustomNotes || hasStrengthOverride
? runtimeStylePrompt
: "";
}, [projectMemory?.style_guide, runtimeStylePrompt, runtimeStyleSelection]);
const chatMode = useMemo(
() => resolveAgentChatMode(mappedTheme, isContentCreationMode),
[isContentCreationMode, mappedTheme],
@@ -1765,7 +1694,6 @@ export function AgentChatWorkspace({
providerType,
model,
mappedTheme,
chatMode,
isSending,
projectMemory,
harnessState,
@@ -2180,7 +2108,6 @@ export function AgentChatWorkspace({
activeContextPrompt: contextWorkspace.activeContextPrompt,
prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt,
},
runtimeStyleMessagePrompt,
projectId,
executionStrategy,
accessMode,
@@ -2588,7 +2515,6 @@ export function AgentChatWorkspace({
canvasState,
isThemeWorkbench,
mappedTheme,
creationMode,
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
themeWorkbenchEntryCheckPending,
@@ -2839,11 +2765,7 @@ export function AgentChatWorkspace({
projectId,
projectMemory,
handleSend,
mappedTheme,
runtimeStyleSelection,
setRuntimeStyleSelection,
generalCanvasState,
runtimeStylePrompt,
showSidebar,
topics,
switchTopic,
+4 -5
View File
@@ -44,9 +44,9 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成
### 2. 内容创作模式
- 6 种创作主题(知识探索、计划规划、社媒内容、图文海报、办公文档、短视频)
- 6 种工作区主题(知识探索、计划规划、社媒内容、图文海报、办公文档、短视频)
- 4 种创作模式(引导/快速/混合/框架)
- 步骤进度条(仅内容创作主题)
- 步骤进度条(仅内容工作区主题)
- 布局过渡(对话 ↔ 对话+画布)
### 3. 画布内工作台
@@ -57,9 +57,8 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成
## 依赖模块
- `@/components/content-creator/core/LayoutTransition` - 布局过渡
- `@/components/content-creator/core/StepGuide` - 步骤引导
- `@/components/content-creator/hooks/useWorkflow` - 工作流状态
- `@/lib/workspace/workbenchUi` - 工作台共享 UI(布局过渡、步骤引导)
- `@/lib/workspace/workbenchWorkflow` - 工作流状态
## 使用示例
@@ -1,6 +1,6 @@
import type { CreationMode } from "./components/types";
import type { MessageImage } from "./types";
import type { StepStatus } from "@/components/content-creator/types";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type { Page, PageParams } from "@/types/page";
import type { AgentSiteSkillLaunchParams } from "@/types/page";
@@ -1,27 +1,12 @@
import type { ThemeType } from "@/components/content-creator/types";
import {
normalizeThemeType,
type ThemeType,
} from "@/lib/workspace/workbenchContract";
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
import type { ProjectType } from "@/lib/api/project";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
export function normalizeInitialTheme(value?: string): ThemeType {
if (!value) {
return "general";
}
if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) {
return value as ThemeType;
}
return "general";
return normalizeThemeType(value);
}
export function deriveCurrentSessionRuntimeStatus(params: {
@@ -72,8 +57,5 @@ export function deriveLatestTurnRuntimeStatus(
}
export function projectTypeToTheme(projectType: ProjectType): ThemeType {
if (projectType === "persistent" || projectType === "temporary") {
return "general";
}
return projectType as ThemeType;
return normalizeThemeType(projectType);
}
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import {
cleanupMountedRoots,
clickButtonByText,
@@ -1,20 +1,20 @@
import { A2UIRenderer } from "@/components/content-creator/a2ui";
import { A2UIRenderer } from "@/lib/workspace/a2ui";
import { cn } from "@/lib/utils";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import {
DEFAULT_A2UI_TASK_CARD_PRESET,
type A2UITaskCardPreset,
} from "@/components/content-creator/a2ui/taskCardPresets";
} from "@/lib/workspace/a2ui";
import {
A2UITaskCardBody,
A2UITaskCardHeader,
A2UITaskCardLoadingBody,
A2UITaskCardShell,
type A2UITaskCardSurface,
} from "@/components/content-creator/a2ui/taskCardPrimitives";
} from "@/lib/workspace/a2ui";
export interface A2UITaskCardProps {
response: A2UIResponse;
@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils";
import {
CHAT_A2UI_TASK_CARD_PRESET,
TIMELINE_A2UI_TASK_CARD_PRESET,
} from "@/components/content-creator/a2ui/taskCardPresets";
} from "@/lib/workspace/a2ui";
import type { ActionRequired } from "../types";
import {
buildActionRequestA2UI,
@@ -87,8 +87,10 @@ const mockToolCallItem = vi.fn(
),
);
vi.mock("@/components/content-creator/a2ui/parser", () => ({
vi.mock("@/lib/workspace/a2ui", () => ({
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
CHAT_A2UI_TASK_CARD_PRESET: {},
TIMELINE_A2UI_TASK_CARD_PRESET: {},
}));
vi.mock("./MarkdownRenderer", () => ({
@@ -670,7 +672,7 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).not.toContain("```a2ui");
});
it("turn_summary 完成态不应显示已完成思考这类思考标签", () => {
it("内部路由型 turn_summary 完成态应降级为中性进展提示", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
@@ -685,7 +687,8 @@ describe("AgentThreadTimeline", () => {
},
});
expect(container.textContent).toContain("直接回答优先");
expect(container.textContent).toContain("当前进展");
expect(container.textContent).not.toContain("直接回答优先");
expect(container.textContent).not.toContain("已完成思考");
});
@@ -27,9 +27,9 @@ import {
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import { parseAIResponse } from "@/lib/workspace/a2ui";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import { cn } from "@/lib/utils";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard";
@@ -38,6 +38,10 @@ import { ToolCallItem } from "./ToolCallDisplay";
import { DecisionPanel } from "./DecisionPanel";
import { AgentThreadTimelineArtifactCard } from "./AgentThreadTimelineArtifactCard";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import {
isInternalRoutingTurnSummaryText,
normalizeTurnSummaryDisplayText,
} from "../utils/turnSummaryPresentation";
interface AgentThreadTimelineProps {
turn: AgentThreadTurn;
@@ -123,13 +127,15 @@ function resolveReasoningDisplayText(
}
function resolveThinkingDisplayText(
item: Extract<AgentThreadItem, { type: "reasoning" | "turn_summary" }>,
item: Extract<AgentThreadItem, { type: "reasoning" }>,
): string {
if (item.type === "reasoning") {
return resolveReasoningDisplayText(item).combinedText;
}
return resolveReasoningDisplayText(item).combinedText;
}
return item.text.trim() ? item.text : "";
function resolveTurnSummaryDisplayText(
item: Extract<AgentThreadItem, { type: "turn_summary" }>,
): string {
return normalizeTurnSummaryDisplayText(item.text);
}
function formatTimestamp(value?: string): string | null {
@@ -387,85 +393,107 @@ function ThinkingItemCard({
}: {
item: Extract<AgentThreadItem, { type: "reasoning" | "turn_summary" }>;
}) {
const displayText = useMemo(() => resolveThinkingDisplayText(item), [item]);
const displayText = useMemo(
() =>
item.type === "reasoning"
? resolveThinkingDisplayText(item)
: resolveTurnSummaryDisplayText(item),
[item],
);
const parsedContent = useMemo(
() => parseAIResponse(displayText, false),
[displayText],
);
const hasStructuredPreview = parsedContent.hasA2UI || parsedContent.hasPending;
const shouldHideTurnSummaryContent =
item.type === "turn_summary" &&
!hasStructuredPreview &&
isInternalRoutingTurnSummaryText(displayText);
const content = hasStructuredPreview ? (
<div className="space-y-3">
{parsedContent.parts.map((part, index) => {
if (part.type === "a2ui" && typeof part.content !== "string") {
const readonlyResponse: A2UIResponse = {
...part.content,
submitAction: undefined,
};
const content =
hasStructuredPreview ? (
<div className="space-y-3">
{parsedContent.parts.map((part, index) => {
if (part.type === "a2ui" && typeof part.content !== "string") {
const readonlyResponse: A2UIResponse = {
...part.content,
submitAction: undefined,
};
return (
<A2UITaskCard
key={`timeline-a2ui-${index}`}
response={readonlyResponse}
compact={true}
preview={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
/>
);
}
if (part.type === "pending_a2ui") {
return (
<A2UITaskLoadingCard
key={`timeline-pending-a2ui-${index}`}
compact={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
subtitle="这一步还在整理,稍等一下。"
/>
);
}
const textContent =
typeof part.content === "string" ? part.content.trim() : "";
if (!textContent) {
return null;
}
return (
<A2UITaskCard
key={`timeline-a2ui-${index}`}
response={readonlyResponse}
compact={true}
preview={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
<MarkdownRenderer
key={`timeline-text-${index}`}
content={textContent}
/>
);
}
if (part.type === "pending_a2ui") {
return (
<A2UITaskLoadingCard
key={`timeline-pending-a2ui-${index}`}
compact={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
subtitle="这一步还在整理,稍等一下。"
/>
);
}
const textContent =
typeof part.content === "string" ? part.content.trim() : "";
if (!textContent) {
return null;
}
return (
<MarkdownRenderer
key={`timeline-text-${index}`}
content={textContent}
/>
);
})}
</div>
) : (
<MarkdownRenderer content={displayText} />
);
})}
</div>
) : shouldHideTurnSummaryContent ? null : (
<MarkdownRenderer content={displayText} />
);
const statusLabel =
item.type === "turn_summary"
? item.status === "in_progress"
? "处理中"
: null
: "当前进展"
: item.status === "in_progress"
? "思考中"
: null;
const ToneIcon =
item.type === "turn_summary"
? item.status === "in_progress"
? Loader2
: Clock3
: item.status === "in_progress"
? Loader2
: Sparkles;
return (
<div className="py-1.5">
<div className="flex items-start gap-2.5">
<div className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-400">
{item.status === "in_progress" ? (
<Loader2 className="h-4 w-4 animate-spin text-sky-600" />
) : (
<Sparkles className="h-4 w-4" />
)}
<ToneIcon
className={cn(
"h-4 w-4",
item.status === "in_progress" && "animate-spin text-sky-600",
)}
/>
</div>
<div className="min-w-0 flex-1">
{statusLabel ? (
<div className="mb-1 text-xs text-slate-500">{statusLabel}</div>
) : null}
<div className="text-sm leading-7 text-slate-800">{content}</div>
{content ? (
<div className="text-sm leading-7 text-slate-800">{content}</div>
) : null}
</div>
</div>
</div>
@@ -662,22 +690,15 @@ function extractCompactThinkingParts(item: Extract<
}
if (item.type === "turn_summary") {
const displayText = resolveThinkingDisplayText(item);
const displayText = resolveTurnSummaryDisplayText(item);
const parsed = parseAIResponse(displayText, false);
if (parsed.hasA2UI || parsed.hasPending) {
return null;
}
const lines = displayText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const [title = item.status === "in_progress" ? "处理中" : "当前进展", ...rest] =
lines;
return {
title,
detail: rest.join("\n").trim(),
title: item.status === "in_progress" ? "处理中" : "当前进展",
detail: isInternalRoutingTurnSummaryText(displayText) ? "" : displayText,
};
}
@@ -705,9 +726,13 @@ function GroupedThinkingRow({
? item.status === "in_progress"
? Loader2
: ListChecks
: item.status === "in_progress"
? Loader2
: Sparkles;
: item.type === "turn_summary"
? item.status === "in_progress"
? Loader2
: Clock3
: item.status === "in_progress"
? Loader2
: Sparkles;
return (
<div className="flex items-start gap-2 py-1.5">
@@ -1107,11 +1132,15 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] {
.map((line) => shortenInlineText(line, 92) || line);
if (isTurnSummaryOnlyBlock) {
const headline = block.status === "in_progress" ? "处理中" : "当前进展";
if (normalizedPreviewLines.length > 0) {
return normalizedPreviewLines;
return [
headline,
...normalizedPreviewLines.filter((line) => line !== headline),
];
}
return [block.status === "in_progress" ? "处理中" : "当前进展"];
return [headline];
}
if (isThinkingOnlyBlock) {
@@ -39,8 +39,8 @@ import {
} from "@/lib/compactRightPanelEvents";
import { listDirectory, type DirectoryListing } from "@/lib/api/fileBrowser";
import type { Artifact } from "@/lib/artifact/types";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { DocumentVersion } from "@/components/content-creator/canvas/document/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { DocumentVersion } from "@/lib/workspace/workbenchCanvas";
import type { TaskFile } from "./TaskFiles";
import type { HarnessFilePreviewResult } from "./HarnessStatusPanel";
import {
@@ -222,6 +222,20 @@ describe("ChatNavbar", () => {
expect(button?.textContent).toContain("等待登录");
});
it("浏览器已就绪时不应再占用顶栏状态入口", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistLabel: "浏览器已就绪",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button).toBeNull();
expect(container.textContent).not.toContain("浏览器已就绪");
});
it("浏览器待继续时顶栏按钮应显示恢复态语义", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
@@ -140,8 +140,15 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
toolbarGhostIconButtonClassName,
isWorkspaceCompact && "h-8 w-8 rounded-[18px]",
);
const shouldShowBrowserAssistEntry =
showBrowserAssistEntry &&
(browserAssistLoading ||
browserAssistAttentionLevel !== "idle" ||
browserAssistLabel?.trim() !== "浏览器已就绪");
const showStatusTools =
showBrowserAssistEntry || showHarnessToggle || showContextCompactionAction;
shouldShowBrowserAssistEntry ||
showHarnessToggle ||
showContextCompactionAction;
const showNavigationTools =
!isWorkspaceCompact &&
(Boolean(onBackHome) ||
@@ -345,11 +352,11 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
{showContextCompactionAction &&
(showBrowserAssistEntry || showHarnessToggle) ? (
(shouldShowBrowserAssistEntry || showHarnessToggle) ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
{showBrowserAssistEntry ? (
{shouldShowBrowserAssistEntry ? (
<Button
type="button"
variant="ghost"
@@ -388,7 +395,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
) : null}
{showBrowserAssistEntry && showHarnessToggle ? (
{shouldShowBrowserAssistEntry && showHarnessToggle ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
@@ -64,10 +64,6 @@ import {
getSiteSkillAutoLaunchExample,
hasAutoLaunchableSiteSkill,
} from "../service-skills/siteSkillExamplePrompts";
import {
DEFAULT_ENABLED_CONTENT_THEME_IDS,
resolveEnabledContentThemes,
} from "@/lib/contentCreator/themeDefaults";
// Import Assets
import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg";
@@ -389,10 +385,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const siteSkillAutoLaunchExample =
getSiteSkillAutoLaunchExample(serviceSkills);
// 从配置中读取启用的主题
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_CONTENT_THEME_IDS,
);
const [
appendSelectedTextToRecommendation,
setAppendSelectedTextToRecommendation,
@@ -403,17 +395,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const loadConfigPreferences = async () => {
try {
const loadedConfig = await getConfig();
setEnabledThemes(
resolveEnabledContentThemes(
loadedConfig.content_creator?.enabled_themes,
),
);
setAppendSelectedTextToRecommendation(
loadedConfig.chat_appearance
?.append_selected_text_to_recommendation ?? true,
);
} catch (e) {
console.error("加载主题配置失败:", e);
console.error("加载首页配置失败:", e);
}
};
let cancelPendingLoad: () => void = () => undefined;
@@ -438,14 +425,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
void loadConfigPreferences();
};
window.addEventListener("theme-config-changed", handleConfigChange);
window.addEventListener(
"chat-appearance-config-changed",
handleConfigChange,
);
return () => {
window.removeEventListener("theme-config-changed", handleConfigChange);
window.removeEventListener(
"chat-appearance-config-changed",
handleConfigChange,
@@ -455,9 +440,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
}, [configLoadStrategy]);
// 过滤后的主题列表
const categories = ALL_CATEGORIES.filter((cat) =>
enabledThemes.includes(cat.id),
);
const categories = ALL_CATEGORIES;
// 使用外部传入的 activeTheme,如果有 onThemeChange 则使用受控模式
const handleThemeChange = (theme: string) => {
@@ -2,8 +2,8 @@ import styled from "styled-components";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
import { CHAT_FLOATING_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
} from "@/lib/workspace/a2ui";
import { CHAT_FLOATING_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import { A2UITaskCard } from "../../A2UITaskCard";
interface A2UIFloatingFormProps {
@@ -3,7 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputbarOverlayShell } from "./InputbarOverlayShell";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
vi.mock("../../TaskFiles", () => ({
TaskFileList: () => <div data-testid="task-file-list" />,
@@ -4,7 +4,7 @@ import type { TaskFile } from "../../TaskFiles";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice";
import { HintRoutePopup } from "./HintRoutePopup";
import { TaskFilesPanel } from "./TaskFilesPanel";
@@ -19,7 +19,7 @@ import type {
ThemeWorkbenchWorkflowStep,
} from "./useThemeWorkbenchInputState";
import { TeamSuggestionBar } from "@/components/agent/chat/components/TeamSuggestionBar";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
import { getTeamSuggestion } from "@/components/agent/chat/utils/teamSuggestion";
import type { MessageImage } from "../../../types";
import type { BuiltinInputCommand } from "../components/builtinCommands";
@@ -2,7 +2,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
import { useStickyA2UIForm } from "./useStickyA2UIForm";
type HookProps = Parameters<typeof useStickyA2UIForm>[0];
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
interface UseStickyA2UIFormParams {
form?: A2UIResponse | null;
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { StepStatus } from "@/components/content-creator/types";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
export interface ThemeWorkbenchGateState {
key: string;
@@ -6,7 +6,7 @@ import { Inputbar } from "./index";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
const mockCharacterMention =
vi.fn<
@@ -14,7 +14,7 @@ import type { A2UISubmissionNoticeData } from "./components/A2UISubmissionNotice
import type {
A2UIResponse,
A2UIFormData,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchWorkflowStep,
@@ -7,11 +7,13 @@ import { MarkdownRenderer } from "./MarkdownRenderer";
vi.mock("react-syntax-highlighter", () => ({
Prism: ({
children,
language,
className,
customStyle,
codeTagProps,
}: {
children: React.ReactNode;
language?: string;
className?: string;
customStyle?: React.CSSProperties;
codeTagProps?: {
@@ -20,12 +22,24 @@ vi.mock("react-syntax-highlighter", () => ({
}) => (
<pre
data-testid="syntax-highlighter"
data-language={language}
className={className}
data-font-family={customStyle?.fontFamily}
data-text-shadow={customStyle?.textShadow}
data-font-ligatures={String(codeTagProps?.style?.fontVariantLigatures)}
>
{children}
<code
data-testid="syntax-highlighter-code"
data-inline-code={String((codeTagProps as any)?.["data-inline-code"])}
data-display={codeTagProps?.style?.display}
data-padding={String(codeTagProps?.style?.padding)}
data-border={String(codeTagProps?.style?.border)}
data-border-radius={String(codeTagProps?.style?.borderRadius)}
data-background={String(codeTagProps?.style?.background)}
data-color={String(codeTagProps?.style?.color)}
>
{children}
</code>
</pre>
),
}));
@@ -375,6 +389,43 @@ describe("MarkdownRenderer", () => {
);
});
it("inline code 应单独标记,块级代码不应再继承胶囊样式", () => {
const content = [
"行内 `npm run dev`",
"",
"```ts",
"const answer = 42;",
"```",
].join("\n");
const container = render(content);
const inlineCode = container.querySelector('code[data-inline-code="true"]');
const blockCode = container.querySelector(
'[data-testid="syntax-highlighter-code"]',
);
expect(inlineCode?.textContent).toContain("npm run dev");
expect(blockCode?.getAttribute("data-inline-code")).toBe("undefined");
expect(blockCode?.getAttribute("data-display")).toBe("block");
expect(blockCode?.getAttribute("data-padding")).toBe("0");
expect(blockCode?.getAttribute("data-border")).toBe("none");
expect(blockCode?.getAttribute("data-border-radius")).toBe("0");
expect(blockCode?.getAttribute("data-background")).toBe("transparent");
expect(blockCode?.getAttribute("data-color")).toBe("inherit");
});
it("代码块语言解析应兼容大小写与常见别名", () => {
const content = ["```SHELL", "echo hello", "```"].join("\n");
const container = render(content);
const syntaxHighlighter = container.querySelector(
'[data-testid="syntax-highlighter"]',
);
expect(syntaxHighlighter?.getAttribute("data-language")).toBe("bash");
expect(container.textContent).toContain("bash");
});
it("文本流程代码块应渲染为流程视图而不是语法高亮", () => {
const content = [
"```text",
@@ -8,9 +8,9 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import styled from "styled-components";
import { Copy, Check, Quote } from "lucide-react";
import { parseA2UIJson } from "@/components/content-creator/a2ui/parser";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import { parseA2UIJson } from "@/lib/workspace/a2ui";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
@@ -106,7 +106,7 @@ const MarkdownContainer = styled.div`
opacity: 0.9;
}
code {
code[data-inline-code="true"] {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
@@ -353,6 +353,28 @@ const PLAIN_TEXT_LANGUAGES = new Set(["text", "plaintext", "plain", "txt"]);
const FLOW_ARROW_ONLY_PATTERN = /^(↓|⬇|⇣|↧|->|=>|→|↘|v)$/u;
const CODE_SIGNAL_PATTERN =
/[{}[\];=]|\b(const|let|var|function|class|return|import|export|interface|type|async|await)\b/;
const LANGUAGE_CLASS_PATTERN = /\blanguage-([^\s]+)/i;
const CODE_LANGUAGE_ALIASES: Record<string, string> = {
"c#": "csharp",
"c++": "cpp",
js: "javascript",
md: "markdown",
objc: "objectivec",
"objective-c": "objectivec",
plain: "text",
plaintext: "text",
ps1: "powershell",
py: "python",
rb: "ruby",
rs: "rust",
sh: "bash",
shell: "bash",
text: "text",
ts: "typescript",
txt: "text",
yml: "yaml",
zsh: "bash",
};
interface MarkdownRendererProps {
content: string;
@@ -375,7 +397,17 @@ interface MarkdownRendererProps {
}
function normalizeCodeLanguage(language: string): string {
return language.trim().toLowerCase();
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "text";
}
return CODE_LANGUAGE_ALIASES[normalized] ?? normalized;
}
function extractCodeLanguageToken(className: string): string {
const match = LANGUAGE_CLASS_PATTERN.exec(className);
return (match?.[1] ?? "text").trim().toLowerCase() || "text";
}
function resolveCodePresentationMode(
@@ -789,8 +821,8 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const childProps = child.props as any;
const className = childProps?.className || "";
const match = /language-(\w+)/.exec(className);
const language = match ? match[1] : "text";
const rawLanguage = extractCodeLanguageToken(className);
const language = normalizeCodeLanguage(rawLanguage);
const codeChildren = childProps?.children;
const codeContent = String(
Array.isArray(codeChildren)
@@ -830,17 +862,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const shouldRenderArtifactPlaceholder =
collapseCodeBlocks &&
(shouldCollapseCodeBlock
? shouldCollapseCodeBlock(language, codeContent)
? shouldCollapseCodeBlock(rawLanguage, codeContent)
: true);
if (shouldRenderArtifactPlaceholder) {
const lineCount = codeContent.split("\n").length;
return (
<ArtifactPlaceholder
language={language}
language={rawLanguage}
lineCount={isStreaming ? undefined : lineCount}
isStreaming={isStreaming}
onClick={() => onCodeBlockClick?.(language, codeContent)}
onClick={() =>
onCodeBlockClick?.(rawLanguage, codeContent)
}
/>
);
}
@@ -888,9 +922,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
PreTag="div"
codeTagProps={{
style: {
display: "block",
fontFamily: CODE_FONT_FAMILY,
textShadow: "none",
fontVariantLigatures: "none",
padding: 0,
border: "none",
borderRadius: 0,
background: "transparent",
color: "inherit",
textShadow: "none",
},
}}
customStyle={{
@@ -910,10 +950,21 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
);
},
code({ inline, className, children, ...props }: any) {
// Inline code
if (inline) {
const content = String(
Array.isArray(children) ? children.join("") : children || "",
);
const isInlineCode =
typeof inline === "boolean"
? inline
: !className && !content.includes("\n");
if (isInlineCode) {
return (
<code className={className} {...props}>
<code
className={className}
data-inline-code="true"
{...props}
>
{children}
</code>
);
@@ -16,6 +16,8 @@ const mockStreamingRenderer = vi.fn(
content,
onOpenSavedSiteContent,
suppressProcessFlow,
showRuntimeStatusInline,
renderProposedPlanBlocks,
showContentBlockActions,
onQuoteContent,
}: {
@@ -23,6 +25,8 @@ const mockStreamingRenderer = vi.fn(
renderA2UIInline?: boolean;
suppressedActionRequestId?: string | null;
suppressProcessFlow?: boolean;
showRuntimeStatusInline?: boolean;
renderProposedPlanBlocks?: boolean;
showContentBlockActions?: boolean;
onQuoteContent?: (content: string) => void;
onOpenSavedSiteContent?: (target: {
@@ -35,6 +39,12 @@ const mockStreamingRenderer = vi.fn(
data-testid="streaming-renderer"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
data-suppress-process-flow={suppressProcessFlow ? "yes" : "no"}
data-show-runtime-status-inline={
showRuntimeStatusInline ? "yes" : "no"
}
data-render-proposed-plan-blocks={
renderProposedPlanBlocks ? "yes" : "no"
}
data-show-content-block-actions={showContentBlockActions ? "yes" : "no"}
data-has-on-quote-content={onQuoteContent ? "yes" : "no"}
>
@@ -263,7 +273,7 @@ describe("MessageList", () => {
);
});
it("存在主执行轨迹时应抑制正文内重复过程流", () => {
it("存在主执行轨迹时不应再默认压掉正文过程流", () => {
const now = new Date();
const messages: Message[] = [
{
@@ -304,11 +314,378 @@ describe("MessageList", () => {
],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect(mockStreamingRenderer).not.toHaveBeenCalledWith(
expect.objectContaining({ suppressProcessFlow: true }),
);
});
it("正文已承载工具调用时,不应再把执行轨迹提前到消息顶部", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-inline-tool",
role: "assistant",
content: "已经定位到问题根因。",
timestamp: now,
contentParts: [
{
type: "tool_use",
toolCall: {
id: "tool-inline-1",
name: "functions.exec_command",
arguments: JSON.stringify({ cmd: "rg -n issue src" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: now,
endTime: now,
},
},
{
type: "text",
text: "已经定位到问题根因。",
},
],
},
];
const container = render(messages, {
currentTurnId: "turn-inline-tool",
turns: [
{
id: "turn-inline-tool",
thread_id: "thread-1",
prompt_text: "继续排查",
status: "completed",
started_at: "2026-03-28T12:00:00Z",
completed_at: "2026-03-28T12:00:03Z",
created_at: "2026-03-28T12:00:00Z",
updated_at: "2026-03-28T12:00:03Z",
},
],
threadItems: [
{
id: "item-inline-tool",
thread_id: "thread-1",
turn_id: "turn-inline-tool",
sequence: 1,
status: "completed",
started_at: "2026-03-28T12:00:01Z",
completed_at: "2026-03-28T12:00:02Z",
updated_at: "2026-03-28T12:00:02Z",
type: "tool_call",
tool_name: "functions.exec_command",
arguments: { cmd: "rg -n issue src" },
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
renderProposedPlanBlocks: true,
}),
);
});
it("正文已承载过程流时,未被正文承载的计划信息仍应保留在消息前序", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-inline-process",
role: "assistant",
content: "已经整理完执行思路。",
timestamp: now,
contentParts: [
{
type: "thinking",
text: "先对照用户截图,再确认 thread item 是否有重复来源。",
},
{
type: "text",
text: "已经整理完执行思路。",
},
],
},
];
const container = render(messages, {
currentTurnId: "turn-inline-process",
turns: [
{
id: "turn-inline-process",
thread_id: "thread-1",
prompt_text: "继续收口消息流",
status: "completed",
started_at: "2026-03-29T12:00:00Z",
completed_at: "2026-03-29T12:00:03Z",
created_at: "2026-03-29T12:00:00Z",
updated_at: "2026-03-29T12:00:03Z",
},
],
threadItems: [
{
id: "item-inline-process-plan",
thread_id: "thread-1",
turn_id: "turn-inline-process",
sequence: 1,
status: "completed",
started_at: "2026-03-29T12:00:01Z",
completed_at: "2026-03-29T12:00:02Z",
updated_at: "2026-03-29T12:00:02Z",
type: "plan",
text: "1. 合并 assistant turn\n2. 收拢补充 timeline",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
renderProposedPlanBlocks: true,
}),
);
});
it("正文已承载过程流时,file_artifact 仍应作为尾部补充信息展示", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-inline-artifact",
role: "assistant",
content: "结果已经整理好了。",
timestamp: now,
contentParts: [
{
type: "thinking",
text: "先整理结果,再把产物路径落盘。",
},
{
type: "text",
text: "结果已经整理好了。",
},
],
},
];
const container = render(messages, {
currentTurnId: "turn-inline-artifact",
turns: [
{
id: "turn-inline-artifact",
thread_id: "thread-1",
prompt_text: "继续整理产物",
status: "completed",
started_at: "2026-03-29T13:00:00Z",
completed_at: "2026-03-29T13:00:03Z",
created_at: "2026-03-29T13:00:00Z",
updated_at: "2026-03-29T13:00:03Z",
},
],
threadItems: [
{
id: "item-inline-artifact",
thread_id: "thread-1",
turn_id: "turn-inline-artifact",
sequence: 1,
status: "completed",
started_at: "2026-03-29T13:00:01Z",
completed_at: "2026-03-29T13:00:02Z",
updated_at: "2026-03-29T13:00:02Z",
type: "file_artifact",
path: "notes/agent-summary.md",
source: "artifact_snapshot",
content: "# Summary",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).not.toBeNull();
});
it("运行中的 turn_summary 应作为尾部过程状态展示,而不是顶到消息头部", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-running-summary",
role: "assistant",
content: "",
timestamp: now,
},
];
const container = render(messages, {
currentTurnId: "turn-running-summary",
turns: [
{
id: "turn-running-summary",
thread_id: "thread-1",
prompt_text: "继续搜索 GitHub",
status: "running",
started_at: "2026-03-30T10:00:00Z",
created_at: "2026-03-30T10:00:00Z",
updated_at: "2026-03-30T10:00:05Z",
},
],
threadItems: [
{
id: "summary-running-1",
thread_id: "thread-1",
turn_id: "turn-running-summary",
sequence: 1,
status: "in_progress",
started_at: "2026-03-30T10:00:00Z",
updated_at: "2026-03-30T10:00:05Z",
type: "turn_summary",
text: "正在打开 GitHub 搜索页",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).not.toBeNull();
});
it("正文已有 runtime status 时,运行中的 turn_summary 不应再重复进入时间线", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-runtime-status",
role: "assistant",
content: "",
timestamp: now,
isThinking: true,
runtimeStatus: {
phase: "routing",
title: "正在打开 GitHub",
detail: "已连上浏览器,准备进入搜索页。",
checkpoints: ["浏览器已就绪"],
},
},
];
const container = render(messages, {
currentTurnId: "turn-runtime-status",
turns: [
{
id: "turn-runtime-status",
thread_id: "thread-1",
prompt_text: "继续搜索 GitHub",
status: "running",
started_at: "2026-03-30T10:10:00Z",
created_at: "2026-03-30T10:10:00Z",
updated_at: "2026-03-30T10:10:05Z",
},
],
threadItems: [
{
id: "summary-runtime-status-1",
thread_id: "thread-1",
turn_id: "turn-runtime-status",
sequence: 1,
status: "in_progress",
started_at: "2026-03-30T10:10:00Z",
updated_at: "2026-03-30T10:10:05Z",
type: "turn_summary",
text: "正在打开 GitHub 搜索页",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
showRuntimeStatusInline: true,
}),
);
});
it("已完成且已有真实过程项的 turn_summary 不应再单独占用消息头部或尾部", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-finished-summary",
role: "assistant",
content: "已经打开 GitHub 并完成搜索。",
timestamp: now,
},
];
const container = render(messages, {
currentTurnId: "turn-finished-summary",
turns: [
{
id: "turn-finished-summary",
thread_id: "thread-1",
prompt_text: "帮我找 AI Agent 项目",
status: "completed",
started_at: "2026-03-30T11:00:00Z",
completed_at: "2026-03-30T11:00:05Z",
created_at: "2026-03-30T11:00:00Z",
updated_at: "2026-03-30T11:00:05Z",
},
],
threadItems: [
{
id: "summary-finished-1",
thread_id: "thread-1",
turn_id: "turn-finished-summary",
sequence: 1,
status: "completed",
started_at: "2026-03-30T11:00:00Z",
completed_at: "2026-03-30T11:00:01Z",
updated_at: "2026-03-30T11:00:01Z",
type: "turn_summary",
text: "已打开 GitHub 搜索页面",
},
{
id: "tool-finished-1",
thread_id: "thread-1",
turn_id: "turn-finished-summary",
sequence: 2,
status: "completed",
started_at: "2026-03-30T11:00:02Z",
completed_at: "2026-03-30T11:00:04Z",
updated_at: "2026-03-30T11:00:04Z",
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://github.com/search?q=ai+agent" },
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
expect(mockAgentThreadTimeline).toHaveBeenCalledTimes(1);
});
it("应按回合分组展示同一轮用户与后续助手回复", () => {
const now = new Date();
const messages: Message[] = [
@@ -650,7 +1027,7 @@ describe("MessageList", () => {
).toBeTruthy();
});
it("应向执行轨迹透传助手消息上的 actionRequests", () => {
it("助手消息上的 actionRequests 应继续留在正文链路,不再重复透传给 timeline", () => {
const now = new Date();
const messages: Message[] = [
{
@@ -708,13 +1085,12 @@ describe("MessageList", () => {
| undefined;
expect(timelineProps?.placement).toBe("leading");
expect(timelineProps?.actionRequests).toEqual([
expect(timelineProps?.actionRequests).toBeUndefined();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
requestId: "req-browser",
uiKind: "browser_preflight",
browserPrepState: "awaiting_user",
renderProposedPlanBlocks: true,
}),
]);
);
});
it("应向执行轨迹透传已保存站点内容打开回调", () => {
@@ -31,6 +31,7 @@ import {
sanitizeContentPartsForDisplay,
sanitizeMessageTextForDisplay,
} from "../utils/internalImagePlaceholder";
import { isInternalRoutingTurnSummaryText } from "../utils/turnSummaryPresentation";
import {
Message,
type ActionRequired,
@@ -40,7 +41,7 @@ import {
type WriteArtifactContext,
type PendingA2UISource,
} from "../types";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import type { ConfirmResponse } from "../types";
import type {
AgentRuntimeThreadReadModel,
@@ -121,7 +122,96 @@ interface MessageListProps {
}
function isDeferredTimelineItem(item: AgentThreadItem): boolean {
return item.type === "file_artifact";
return item.type === "file_artifact" || item.type === "turn_summary";
}
function shouldRenderConversationTimelineItem(
item: AgentThreadItem,
timelineItems: AgentThreadItem[],
options?: {
hasInlineRuntimeStatus?: boolean;
},
): boolean {
if (item.type !== "turn_summary") {
return true;
}
if (item.status === "in_progress" && options?.hasInlineRuntimeStatus) {
return false;
}
if (item.status !== "completed") {
return true;
}
if (isInternalRoutingTurnSummaryText(item.text)) {
return false;
}
return !timelineItems.some(
(entry) => entry.id !== item.id && entry.type !== "turn_summary",
);
}
interface InlineProcessCoverage {
hasInlineProcessEntries: boolean;
thinking: boolean;
tools: boolean;
actions: boolean;
}
function resolveInlineProcessCoverage(params: {
contentParts?: Message["contentParts"];
thinkingContent?: string;
toolCalls?: Message["toolCalls"];
actionRequests?: Message["actionRequests"];
}): InlineProcessCoverage {
const contentParts = params.contentParts || [];
if (contentParts.length > 0) {
const thinking = contentParts.some(
(part) => part.type === "thinking" && part.text.trim().length > 0,
);
const tools = contentParts.some((part) => part.type === "tool_use");
const actions = contentParts.some(
(part) => part.type === "action_required",
);
return {
hasInlineProcessEntries: thinking || tools || actions,
thinking,
tools,
actions,
};
}
const thinking = Boolean(params.thinkingContent?.trim());
const tools = Boolean(params.toolCalls?.length);
const actions = Boolean(params.actionRequests?.length);
return {
hasInlineProcessEntries: thinking || tools || actions,
thinking,
tools,
actions,
};
}
function isInlineCoveredTimelineItem(
item: AgentThreadItem,
coverage: InlineProcessCoverage,
): boolean {
switch (item.type) {
case "reasoning":
return coverage.thinking;
case "tool_call":
case "command_execution":
case "web_search":
return coverage.tools;
case "approval_request":
case "request_user_input":
return coverage.actions;
default:
return false;
}
}
const MessageListInner: React.FC<MessageListProps> = ({
@@ -261,6 +351,12 @@ const MessageListInner: React.FC<MessageListProps> = ({
hasImages,
},
);
const inlineProcessCoverage = resolveInlineProcessCoverage({
contentParts: displayContentParts,
thinkingContent: msg.thinkingContent,
toolCalls: msg.toolCalls,
actionRequests: msg.actionRequests,
});
const mappedTimeline = timelineByMessageId.get(msg.id);
const timeline =
msg.role !== "assistant"
@@ -270,11 +366,40 @@ const MessageListInner: React.FC<MessageListProps> = ({
: mappedTimeline?.turn.id === currentTurnTimeline?.turn.id
? null
: mappedTimeline || null;
const timelineConversationItems = timeline
? timeline.items.filter((item) =>
shouldRenderConversationTimelineItem(item, timeline.items, {
hasInlineRuntimeStatus: Boolean(msg.runtimeStatus),
}),
)
: [];
const primaryTimelineItems = timeline
? timeline.items.filter((item) => !isDeferredTimelineItem(item))
? timeline.items.filter((item) => {
if (
!timelineConversationItems.some(
(timelineItem) => timelineItem.id === item.id,
)
) {
return false;
}
if (isDeferredTimelineItem(item)) {
return false;
}
if (!inlineProcessCoverage.hasInlineProcessEntries) {
return true;
}
if (isInlineCoveredTimelineItem(item, inlineProcessCoverage)) {
return false;
}
return true;
})
: [];
const trailingTimelineItems = timeline
? timeline.items.filter(isDeferredTimelineItem)
? timelineConversationItems.filter((item) => isDeferredTimelineItem(item))
: [];
const primaryTimeline =
timeline && primaryTimelineItems.length > 0
@@ -284,10 +409,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
timeline && trailingTimelineItems.length > 0
? { ...timeline, items: trailingTimelineItems }
: null;
const timelineActionRequests = inlineProcessCoverage.actions
? undefined
: msg.actionRequests;
const primaryActionRequests =
primaryTimelineItems.length > 0 ? msg.actionRequests : undefined;
primaryTimelineItems.length > 0 ? timelineActionRequests : undefined;
const trailingActionRequests =
primaryTimelineItems.length === 0 ? msg.actionRequests : undefined;
primaryTimelineItems.length === 0 ? timelineActionRequests : undefined;
const shouldSuppressInlineA2UI =
activePendingA2UISource?.kind !== "action_request" &&
activePendingA2UISource?.messageId === msg.id;
@@ -373,8 +501,11 @@ const MessageListInner: React.FC<MessageListProps> = ({
onCodeBlockClick={onCodeBlockClick}
promoteActionRequestsToA2UI={promoteActionRequestsToA2UI}
suppressedActionRequestId={suppressedActionRequestId}
suppressProcessFlow={Boolean(primaryTimeline)}
renderProposedPlanBlocks={!timeline}
showRuntimeStatusInline={true}
renderProposedPlanBlocks={
!primaryTimeline ||
inlineProcessCoverage.hasInlineProcessEntries
}
showContentBlockActions={Boolean(actionContent)}
onQuoteContent={
onQuoteMessage
@@ -1,264 +0,0 @@
import { useMemo, useState } from "react";
import { CheckCheck, LibraryBig, Palette, RefreshCcw, Settings2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { StyleLibraryPickerDialog } from "@/components/style-library/StyleLibraryPickerDialog";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { StyleGuidePanel } from "@/components/projects/memory/StyleGuidePanel";
import type { ThemeType } from "@/components/content-creator/types";
import type { StyleGuide } from "@/lib/api/memory";
import {
DEFAULT_STYLE_PROFILE,
buildRuntimeStyleOverridePrompt,
describeRuntimeStyleSelection,
getAvailableStylePresets,
getStylePresetById,
getStyleProfileFromGuide,
type RuntimeStyleSelection,
} from "@/lib/style-guide";
interface RuntimeStyleControlBarProps {
projectId: string;
activeTheme: ThemeType;
projectStyleGuide?: StyleGuide | null;
selection: RuntimeStyleSelection;
onSelectionChange: (selection: RuntimeStyleSelection) => void;
onRewrite: () => void;
onAudit: () => void;
actionsDisabled?: boolean;
}
export function RuntimeStyleControlBar({
projectId,
activeTheme,
projectStyleGuide,
selection,
onSelectionChange,
onRewrite,
onAudit,
actionsDisabled = false,
}: RuntimeStyleControlBarProps) {
const [styleGuideDialogOpen, setStyleGuideDialogOpen] = useState(false);
const [styleLibraryDialogOpen, setStyleLibraryDialogOpen] = useState(false);
const presets = useMemo(() => getAvailableStylePresets(activeTheme), [activeTheme]);
const summary = useMemo(
() =>
describeRuntimeStyleSelection({
projectStyleGuide,
selection,
}),
[projectStyleGuide, selection],
);
const previewPrompt = useMemo(
() =>
buildRuntimeStyleOverridePrompt({
projectStyleGuide,
selection,
activeTheme,
}),
[activeTheme, projectStyleGuide, selection],
);
const hasProjectDefaultStyle = Boolean(getStyleProfileFromGuide(projectStyleGuide));
const handlePresetChange = (value: string) => {
const nextStrength =
value === "project-default"
? getStyleProfileFromGuide(projectStyleGuide)?.simulationStrength ||
DEFAULT_STYLE_PROFILE.simulationStrength
: getStylePresetById(value)?.profile.simulationStrength ||
selection.strength;
onSelectionChange({
...selection,
presetId: value,
strength: nextStrength,
source: value === "project-default" ? "project-default" : "preset",
sourceLabel: undefined,
sourceProfile: null,
});
};
const clearLibrarySelection = () => {
const fallbackStrength =
getStyleProfileFromGuide(projectStyleGuide)?.simulationStrength ||
DEFAULT_STYLE_PROFILE.simulationStrength;
onSelectionChange({
...selection,
presetId: "project-default",
strength: fallbackStrength,
source: "project-default",
sourceLabel: undefined,
sourceProfile: null,
});
};
return (
<Card className="mx-4 mt-3 overflow-hidden rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.92)_0%,rgba(255,255,255,1)_42%)] shadow-sm shadow-slate-950/5">
<CardContent className="space-y-3 p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="mr-1 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-sm font-medium text-slate-600 shadow-sm">
<Palette className="h-4 w-4 text-slate-700" />
任务风格
</div>
<Select value={selection.presetId} onValueChange={handlePresetChange}>
<SelectTrigger className="h-9 w-[210px] border-slate-200 bg-white">
<SelectValue placeholder="选择本次任务风格" />
</SelectTrigger>
<SelectContent>
<SelectItem value="project-default">使用项目默认风格</SelectItem>
{presets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Settings2 className="h-4 w-4" />
本次模拟说明
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 space-y-3 rounded-[20px] border border-slate-200/80 bg-white shadow-lg shadow-slate-950/10" align="start">
<div>
<div className="text-sm font-medium">临时风格备注</div>
<p className="mt-1 text-xs text-muted-foreground">
用来描述这次想模拟的额外风格,例如“更像知识型创作者,但保持克制,不要营销感”。
</p>
</div>
<Textarea
value={selection.customNotes}
onChange={(event) =>
onSelectionChange({
...selection,
customNotes: event.target.value,
})
}
rows={6}
placeholder="写下本次风格模拟要求..."
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setStyleLibraryDialogOpen(true)}
>
<LibraryBig className="h-4 w-4" />
从我的风格库选择
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setStyleGuideDialogOpen(true)}
>
<Palette className="h-4 w-4" />
编辑项目风格
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={onAudit} disabled={actionsDisabled}>
<CheckCheck className="h-4 w-4" />
检查风格
</Button>
<Button size="sm" className="gap-2" onClick={onRewrite} disabled={actionsDisabled}>
<RefreshCcw className="h-4 w-4" />
按当前风格重写
</Button>
</div>
<div className="grid gap-3 rounded-[20px] border border-slate-200/80 bg-white/92 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_220px] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-700">
<span>{summary}</span>
{selection.source === "library" && selection.sourceLabel ? (
<button
type="button"
onClick={clearLibrarySelection}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 px-2 py-0.5 text-xs text-slate-500 hover:bg-slate-50"
>
来自我的风格:{selection.sourceLabel}
<X className="h-3 w-3" />
</button>
) : null}
</div>
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
{previewPrompt || "未设置临时风格覆盖,将沿用项目默认风格与当前创作上下文。"}
</p>
{!hasProjectDefaultStyle && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
当前项目还没有默认风格,建议先点“编辑项目风格”配置基线风格。
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>风格强度</span>
<span>{selection.strength}</span>
</div>
<Slider
value={[selection.strength]}
onValueChange={([value]) =>
onSelectionChange({
...selection,
strength: value,
})
}
min={0}
max={100}
step={1}
/>
</div>
</div>
</CardContent>
<Dialog open={styleGuideDialogOpen} onOpenChange={setStyleGuideDialogOpen}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="sr-only">
<DialogTitle>项目默认风格</DialogTitle>
</DialogHeader>
<div className="p-6">
<StyleGuidePanel projectId={projectId} />
</div>
</DialogContent>
</Dialog>
<StyleLibraryPickerDialog
open={styleLibraryDialogOpen}
onOpenChange={setStyleLibraryDialogOpen}
onSelect={(entry) =>
onSelectionChange({
...selection,
presetId: "project-default",
strength: entry.profile.simulationStrength,
source: "library",
sourceLabel: entry.profile.name,
sourceProfile: entry.profile,
})
}
theme={activeTheme}
onlyEnabled
title="选择本次任务要使用的我的风格"
description="从你上传或保存的风格中选择一条,作为当前任务的临时风格覆盖。"
/>
</Card>
);
}
@@ -67,8 +67,10 @@ const mockToolCallItem = vi.fn(
),
);
vi.mock("@/components/content-creator/a2ui/parser", () => ({
vi.mock("@/lib/workspace/a2ui", () => ({
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
CHAT_A2UI_TASK_CARD_PRESET: {},
TIMELINE_A2UI_TASK_CARD_PRESET: {},
}));
vi.mock("@/lib/artifact/hooks/useDebouncedValue", () => ({
@@ -595,6 +597,64 @@ describe("StreamingRenderer", () => {
expect(container.textContent).toContain("稳妥处理");
});
it("正文已经开始输出后,仍应继续显示轻量运行状态", () => {
const { container } = renderHarness({
content: "我来帮你先打开 GitHub 搜索页。",
isStreaming: true,
runtimeStatus: {
phase: "routing",
title: "正在搜索 GitHub",
detail: "已经打开搜索页,准备补充筛选条件。",
checkpoints: ["浏览器已就绪", "准备应用最近更新时间筛选"],
},
showRuntimeStatusInline: true,
});
expect(
container.querySelector('[data-testid="agent-runtime-status"]'),
).toBeTruthy();
expect(container.textContent).toContain("正在搜索 GitHub");
expect(container.textContent).toContain("已经打开搜索页,准备补充筛选条件。");
expect(container.textContent).toContain("浏览器已就绪");
});
it("交错内容模式下也应继续显示轻量运行状态", () => {
const { container } = renderHarness({
content: "",
isStreaming: true,
runtimeStatus: {
phase: "context",
title: "正在整理搜索结果",
detail: "已拿到页面内容,正在提取最近一个月更新的仓库。",
checkpoints: ["页面内容已获取"],
},
showRuntimeStatusInline: true,
contentParts: [
{
type: "text",
text: "我已经打开 GitHub 搜索页,接下来开始筛选结果。",
},
{
type: "tool_use",
toolCall: {
id: "tool-runtime-status-inline",
name: "browser_snapshot",
arguments: JSON.stringify({ page: "github-search" }),
status: "running",
result: undefined,
startTime: new Date("2026-03-30T12:00:00.000Z"),
},
},
],
});
expect(
container.querySelector('[data-testid="agent-runtime-status"]'),
).toBeTruthy();
expect(container.textContent).toContain("正在整理搜索结果");
expect(container.textContent).toContain("页面内容已获取");
});
it("思考内容进入流式阶段后应自动展开", () => {
const { container, rerender } = renderHarness({
content: "",
@@ -13,7 +13,6 @@ import {
ExternalLink,
FileText,
Loader2,
Sparkles,
} from "lucide-react";
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
import { MarkdownRenderer } from "./MarkdownRenderer";
@@ -22,13 +21,13 @@ import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard";
import { ToolCallItem } from "./ToolCallDisplay";
import { DecisionPanel } from "./DecisionPanel";
import { AgentPlanBlock } from "./AgentPlanBlock";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import { parseAIResponse } from "@/lib/workspace/a2ui";
import type {
A2UIFormData,
ParseResult,
ParsedMessageContent,
} from "@/components/content-creator/a2ui/types";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
} from "@/lib/workspace/a2ui";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import type {
AgentRuntimeStatus,
@@ -826,6 +825,34 @@ const RUNTIME_PHASE_LABELS: Record<AgentRuntimeStatus["phase"], string> = {
failed: "需要处理",
};
function normalizeRuntimeStatusLine(value?: string | null): string {
return (value || "").trim().replace(/\s+/g, " ");
}
function buildRuntimeStatusSupportingLines(status: AgentRuntimeStatus): string[] {
const lines: string[] = [];
const normalizedTitle = normalizeRuntimeStatusLine(status.title);
const normalizedDetail = normalizeRuntimeStatusLine(status.detail);
if (normalizedDetail && normalizedDetail !== normalizedTitle) {
lines.push(normalizedDetail);
}
const checkpointText = (status.checkpoints || [])
.map((item) => normalizeRuntimeStatusLine(item))
.filter(Boolean)
.filter((item, index, array) => array.indexOf(item) === index)
.filter((item) => item !== normalizedTitle && item !== normalizedDetail)
.slice(0, 2)
.join(" · ");
if (checkpointText) {
lines.push(checkpointText);
}
return lines;
}
const AgentRuntimeStatusBlock: React.FC<{ status: AgentRuntimeStatus }> = ({
status,
}) => {
@@ -834,83 +861,71 @@ const AgentRuntimeStatusBlock: React.FC<{ status: AgentRuntimeStatus }> = ({
!failed &&
status.metadata?.concurrency_scope === "provider_global" &&
status.metadata?.retryable_overload;
const supportingLines = buildRuntimeStatusSupportingLines(status);
const ToneIcon = failed ? AlertTriangle : Loader2;
return (
<div
data-testid="agent-runtime-status"
className={cn(
"rounded-2xl border px-4 py-3",
"rounded-xl border px-3.5 py-2.5",
failed
? "border-rose-200 bg-rose-50/90"
: "border-border/70 bg-muted/30",
? "border-rose-200 bg-rose-50/80"
: "border-border/60 bg-slate-50",
)}
>
<div className="mb-2 flex items-center gap-2">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full",
failed ? "bg-rose-100 text-rose-600" : "bg-primary/10 text-primary",
)}
>
{failed ? (
<AlertTriangle className="h-4 w-4" />
) : (
<Sparkles className="h-4 w-4" />
)}
<div className="flex items-start gap-2.5">
<div className="flex h-5 w-5 shrink-0 items-center justify-center">
<ToneIcon
className={cn(
"h-4 w-4",
failed ? "text-rose-600" : "animate-spin text-sky-600",
)}
/>
</div>
<div
className={cn(
"text-sm font-medium",
failed ? "text-rose-900" : "text-foreground",
)}
>
{status.title}
</div>
<div
className={cn(
"ml-auto inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs",
failed
? "border-rose-200 bg-white text-rose-700"
: "border-border/70 bg-background/80 text-muted-foreground",
)}
>
{failed ? (
<AlertTriangle className="h-3 w-3" />
) : (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{RUNTIME_PHASE_LABELS[status.phase]}
</div>
{sequentialProtection ? (
<div className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs text-amber-700">
稳妥处理
</div>
) : null}
</div>
<div
className={cn(
"text-sm",
failed ? "text-rose-700" : "text-muted-foreground",
)}
>
{status.detail}
</div>
{status.checkpoints && status.checkpoints.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{status.checkpoints.map((item) => (
<span
key={item}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div
className={cn(
"rounded-full border px-2.5 py-1 text-xs",
failed
? "border-rose-200 bg-white text-rose-700"
: "border-border/60 bg-background/80 text-muted-foreground",
"min-w-0 text-sm font-medium leading-6",
failed ? "text-rose-900" : "text-slate-800",
)}
>
{item}
</span>
))}
{status.title}
</div>
<div
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] leading-none",
failed
? "border-rose-200 bg-white text-rose-700"
: "border-slate-200 bg-white text-slate-500",
)}
>
{RUNTIME_PHASE_LABELS[status.phase]}
</div>
{sequentialProtection ? (
<div className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] leading-none text-amber-700">
稳妥处理
</div>
) : null}
</div>
{supportingLines.length > 0 ? (
<div className="mt-0.5 space-y-0.5">
{supportingLines.map((line) => (
<div
key={line}
className={cn(
"text-xs leading-5",
failed ? "text-rose-700" : "text-slate-500",
)}
>
{line}
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
);
};
@@ -1456,6 +1471,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
if (useInterleavedMode) {
const nodes: React.ReactNode[] = [];
let processBuffer: StreamingProcessEntry[] = [];
const shouldShowRuntimeStatus =
showRuntimeStatusInline && Boolean(runtimeStatus) && isStreaming;
const flushProcessBuffer = (keySuffix: string) => {
if (processBuffer.length === 0) {
@@ -1523,6 +1540,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
return (
<div className="flex flex-col gap-2">
{shouldShowRuntimeStatus && runtimeStatus ? (
<AgentRuntimeStatusBlock status={runtimeStatus} />
) : null}
{nodes}
{/* 如果没有内容但正在流式输出,显示光标 */}
@@ -1539,8 +1559,6 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
}
// 回退模式:将思考、工具和确认收敛成同一条执行过程
const hasToolCalls = toolCalls && toolCalls.length > 0;
const hasActionRequests = visibleActionRequests.length > 0;
const legacyProcessEntries: StreamingProcessEntry[] = [];
if (finalThinking) {
legacyProcessEntries.push({
@@ -1567,11 +1585,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
const shouldShowRuntimeStatus =
showRuntimeStatusInline &&
Boolean(runtimeStatus) &&
isStreaming &&
!hasVisibleContent &&
!hasToolCalls &&
!hasActionRequests &&
!hasRunningTools;
isStreaming;
return (
<div className="flex flex-col gap-2">
@@ -11,7 +11,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import type { StepStatus } from "@/components/content-creator/types";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type { AgentRun } from "@/lib/api/executionRun";
import {
DropdownMenu,
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { StepStatus } from "@/components/content-creator/types";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import type { AgentRun } from "@/lib/api/executionRun";
import type {
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import type { StepStatus } from "@/components/content-creator/types";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import {
buildThemeWorkbenchActivityLogGroups,
@@ -40,6 +40,28 @@ function defaultCreateAssistantMessageId() {
return `context_compaction:${crypto.randomUUID()}`;
}
export class AgentStreamCompactionError extends Error {
readonly alreadyNotified: boolean;
constructor(message: string, alreadyNotified = false) {
super(message);
this.name = "AgentStreamCompactionError";
this.alreadyNotified = alreadyNotified;
}
}
export function normalizeAgentStreamCompactionError(error: unknown) {
if (error instanceof AgentStreamCompactionError) {
return error;
}
if (error instanceof Error && error.message.trim()) {
return new AgentStreamCompactionError(error.message);
}
const message = String(error ?? "").trim() || "压缩上下文失败,请稍后重试";
return new AgentStreamCompactionError(message);
}
export async function runAgentStreamCompaction(
options: RunAgentStreamCompactionOptions,
) {
@@ -60,6 +82,7 @@ export async function runAgentStreamCompaction(
} = options;
const eventName = createEventName();
let disposed = false;
let notifiedErrorMessage: string | null = null;
const disposeListener = () => {
if (disposed) {
@@ -125,7 +148,8 @@ export async function runAgentStreamCompaction(
break;
}
case "error":
notify.error(`压缩上下文失败: ${data.message}`);
notifiedErrorMessage = String(data.message ?? "").trim() || "未知错误";
notify.error(`压缩上下文失败: ${notifiedErrorMessage}`);
clearActiveStreamIfMatch(eventName);
disposeListener();
break;
@@ -143,6 +167,9 @@ export async function runAgentStreamCompaction(
} catch (error) {
clearActiveStreamIfMatch(eventName);
disposeListener();
throw error;
if (notifiedErrorMessage) {
throw new AgentStreamCompactionError(notifiedErrorMessage, true);
}
throw normalizeAgentStreamCompactionError(error);
}
}
@@ -17,7 +17,7 @@ import {
resolveArtifactProtocolPreviewText,
} from "@/lib/artifact-protocol";
import type { ActionRequired, Message, WriteArtifactContext } from "../types";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import {
isAskToolName,
normalizeAskOptions,
@@ -64,6 +64,7 @@ describe("agentStreamPreparedSendDispatch", () => {
modelRef: { current: "gpt-5.4" } as MutableRefObject<string>,
sessionIdRef: { current: null } as MutableRefObject<string | null>,
getQueuedTurnsCount: () => 0,
isThreadBusy: () => false,
getRequiredWorkspaceId: () => "workspace-1",
getSyncedSessionModelPreference: () => null,
getSyncedSessionExecutionStrategy: (_sessionId) => "react",
@@ -18,6 +18,7 @@ describe("createAgentStreamPreparedSendEnv", () => {
it("应把 queuedTurnsCount 封装成稳定 getter", () => {
const env = createAgentStreamPreparedSendEnv({
queuedTurnsCount: 3,
threadBusy: false,
runtime: {} as never,
ensureSession: async () => "session-1",
executionStrategy: "react",
@@ -29,6 +29,7 @@ export interface AgentStreamPreparedSendEnv {
modelRef: MutableRefObject<string>;
sessionIdRef: MutableRefObject<string | null>;
getQueuedTurnsCount: () => number;
isThreadBusy: () => boolean;
getRequiredWorkspaceId: () => string;
getSyncedSessionModelPreference: (
sessionId: string,
@@ -69,17 +70,19 @@ export interface AgentStreamPreparedSendEnv {
}
export interface CreateAgentStreamPreparedSendEnvOptions
extends Omit<AgentStreamPreparedSendEnv, "getQueuedTurnsCount"> {
extends Omit<AgentStreamPreparedSendEnv, "getQueuedTurnsCount" | "isThreadBusy"> {
queuedTurnsCount: number;
threadBusy: boolean;
}
export function createAgentStreamPreparedSendEnv(
options: CreateAgentStreamPreparedSendEnvOptions,
): AgentStreamPreparedSendEnv {
const { queuedTurnsCount, ...rest } = options;
const { queuedTurnsCount, threadBusy, ...rest } = options;
return {
...rest,
getQueuedTurnsCount: () => queuedTurnsCount,
isThreadBusy: () => threadBusy,
};
}
@@ -10,7 +10,7 @@ import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import type { ActionRequired, Message } from "../types";
import { appendTextToParts } from "./agentChatHistory";
import { updateMessageArtifactsStatus } from "../utils/messageArtifacts";
@@ -36,6 +36,7 @@ import {
} from "../utils/agentRuntimeStatus";
import { normalizeLegacyRuntimeStatusTitle } from "@/lib/api/agentTextNormalization";
import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation";
import { buildQueuedRuntimeStatus } from "./agentStreamSubmitDraft";
import {
applyModelChangeExecutionRuntime,
applyTurnContextExecutionRuntime,
@@ -87,7 +88,9 @@ interface HandleTurnStreamEventOptions {
activeSessionId: string;
resolvedWorkspaceId: string;
effectiveExecutionStrategy: AsterExecutionStrategy;
content: string;
runtime: AgentRuntimeAdapter;
webSearch?: boolean;
warnedKeysRef: MutableRefObject<Set<string>>;
actionLoggedKeys: Set<string>;
toolLogIdByToolId: Map<string, string>;
@@ -106,6 +109,7 @@ interface HandleTurnStreamEventOptions {
setExecutionRuntime: Dispatch<
SetStateAction<AsterSessionExecutionRuntime | null>
>;
setIsSending: Dispatch<SetStateAction<boolean>>;
}
function finishRequestLog(
@@ -143,7 +147,9 @@ export function handleTurnStreamEvent({
activeSessionId,
resolvedWorkspaceId,
effectiveExecutionStrategy,
content,
runtime,
webSearch,
warnedKeysRef,
actionLoggedKeys,
toolLogIdByToolId,
@@ -156,6 +162,7 @@ export function handleTurnStreamEvent({
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
setIsSending,
}: HandleTurnStreamEventOptions): void {
const {
activateStream,
@@ -215,6 +222,31 @@ export function handleTurnStreamEvent({
});
};
const markQueuedDraftState = (queuedMessageText?: string | null) => {
clearActiveStreamIfMatch(eventName);
clearOptimisticItem();
clearOptimisticTurn();
setIsSending(false);
const queuedRuntimeStatus = buildQueuedRuntimeStatus(
effectiveExecutionStrategy,
queuedMessageText?.trim() || content,
webSearch,
);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
runtimeStatus: queuedRuntimeStatus,
}
: msg,
),
);
};
switch (data.type) {
case "thread_started":
break;
@@ -222,6 +254,7 @@ export function handleTurnStreamEvent({
case "queue_added":
requestState.queuedTurnId = data.queued_turn.queued_turn_id;
upsertQueuedTurn(data.queued_turn);
markQueuedDraftState(data.queued_turn.message_text);
break;
case "queue_removed":
@@ -4,7 +4,7 @@ import type { Message, MessageImage } from "../types";
import type { AssistantDraftState } from "./agentChatShared";
import { buildInitialAgentRuntimeStatus } from "../utils/agentRuntimeStatus";
function buildQueuedMessagePreview(content: string): string {
export function buildQueuedMessagePreview(content: string): string {
const compact = content.split(/\s+/).filter(Boolean).join(" ");
if (!compact) {
return "空白输入";
@@ -14,7 +14,7 @@ function buildQueuedMessagePreview(content: string): string {
return compact.length > preview.length ? `${preview}...` : preview;
}
function buildQueuedRuntimeStatus(
export function buildQueuedRuntimeStatus(
executionStrategy: AsterExecutionStrategy,
content: string,
webSearch?: boolean,
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol";
import type {
AsterSessionExecutionRuntime,
@@ -88,6 +88,7 @@ describe("agentStreamSubmitExecution", () => {
},
appendThinkingToParts: (parts: NonNullable<Message["contentParts"]>) => parts,
setMessages: noopDispatch<Message[]>(),
setIsSending: noopDispatch<boolean>(),
setPendingActions: noopDispatch<ActionRequired[]>(),
setThreadItems: noopDispatch<AgentThreadItem[]>(),
setThreadTurns: noopDispatch<AgentThreadTurn[]>(),
@@ -91,6 +91,7 @@ interface ExecuteAgentStreamSubmitOptions {
textDelta: string,
) => MessageParts;
setMessages: Dispatch<SetStateAction<Message[]>>;
setIsSending: Dispatch<SetStateAction<boolean>>;
setPendingActions: Dispatch<SetStateAction<ActionRequired[]>>;
setThreadItems: Dispatch<SetStateAction<AgentThreadItem[]>>;
setThreadTurns: Dispatch<SetStateAction<AgentThreadTurn[]>>;
@@ -144,6 +145,7 @@ export async function executeAgentStreamSubmit(
sounds,
appendThinkingToParts,
setMessages,
setIsSending,
setPendingActions,
setThreadItems,
setThreadTurns,
@@ -181,6 +183,7 @@ export async function executeAgentStreamSubmit(
effectiveModel,
effectiveExecutionStrategy,
content,
webSearch,
autoContinue,
expectingQueue,
activeSessionId,
@@ -215,6 +218,7 @@ export async function executeAgentStreamSubmit(
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
setIsSending,
});
callbacks.registerListener(unlisten);
@@ -1,6 +1,6 @@
import { toast } from "sonner";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import { updateMessageArtifactsStatus } from "../utils/messageArtifacts";
import type { Message, MessageImage } from "../types";
import {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Dispatch, SetStateAction } from "react";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol";
import type {
AsterSessionExecutionRuntime,
@@ -80,6 +80,7 @@ describe("agentStreamTurnEventBinding", () => {
setThreadTurns: noopDispatch<AgentThreadTurn[]>(),
setCurrentTurnId: noopDispatch<string | null>(),
setExecutionRuntime: noopDispatch<AsterSessionExecutionRuntime | null>(),
setIsSending: noopDispatch<boolean>(),
});
expect(result).toBe(unlisten);
@@ -6,7 +6,7 @@ import type {
AutoContinueRequestPayload,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import type { ActionRequired, Message } from "../types";
import { mapProviderName } from "./agentChatCoreUtils";
import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler";
@@ -30,6 +30,7 @@ interface RegisterAgentStreamTurnEventBindingOptions {
effectiveModel: string;
effectiveExecutionStrategy: AsterExecutionStrategy;
content: string;
webSearch?: boolean;
autoContinue?: AutoContinueRequestPayload;
expectingQueue: boolean;
activeSessionId: string;
@@ -79,6 +80,7 @@ interface RegisterAgentStreamTurnEventBindingOptions {
setExecutionRuntime: Dispatch<
SetStateAction<AsterSessionExecutionRuntime | null>
>;
setIsSending: Dispatch<SetStateAction<boolean>>;
}
export async function registerAgentStreamTurnEventBinding(
@@ -93,6 +95,7 @@ export async function registerAgentStreamTurnEventBinding(
effectiveModel,
effectiveExecutionStrategy,
content,
webSearch,
autoContinue,
expectingQueue,
activeSessionId,
@@ -117,6 +120,7 @@ export async function registerAgentStreamTurnEventBinding(
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
setIsSending,
} = options;
requestState.requestStartedAt = Date.now();
@@ -175,7 +179,9 @@ export async function registerAgentStreamTurnEventBinding(
activeSessionId,
resolvedWorkspaceId,
effectiveExecutionStrategy,
content,
runtime,
webSearch,
warnedKeysRef,
actionLoggedKeys,
toolLogIdByToolId,
@@ -188,6 +194,7 @@ export async function registerAgentStreamTurnEventBinding(
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
setIsSending,
});
});
}
@@ -22,6 +22,7 @@ describe("agentStreamUserInputSendPreparation", () => {
sessionId?: string | null;
activeStream?: ActiveStreamState | null;
queuedTurnsCount?: number;
threadBusy?: boolean;
providerType?: string;
model?: string;
}): AgentStreamUserInputSendPreparationEnv {
@@ -43,6 +44,7 @@ describe("agentStreamUserInputSendPreparation", () => {
current: options?.activeStream ?? null,
} as MutableRefObject<ActiveStreamState | null>,
getQueuedTurnsCount: () => options?.queuedTurnsCount ?? 0,
isThreadBusy: () => options?.threadBusy ?? false,
getSyncedSessionModelPreference: () => ({
providerType: "openai",
model: "gpt-5.4",
@@ -149,4 +151,36 @@ describe("agentStreamUserInputSendPreparation", () => {
expect(messages[0]?.content).toBe("队列中");
expect(isSending).toBe(false);
});
it("恢复态 thread 仍忙时也应直接进入 queue 模式", () => {
vi.spyOn(crypto, "randomUUID")
.mockReturnValueOnce("00000000-0000-0000-0000-000000000004")
.mockReturnValueOnce("00000000-0000-0000-0000-000000000005");
let messages: Message[] = [];
let isSending = false;
const env = {
...createEnv({
threadBusy: true,
}),
setMessages: createStateSetter(() => messages, (value) => {
messages = value;
}),
setIsSending: createStateSetter(() => isSending, (value) => {
isSending = value;
}),
};
const result = prepareAgentStreamUserInputSend({
content: "继续分析这个项目",
images: [],
skipUserMessage: false,
env,
});
expect(result.expectingQueue).toBe(true);
expect(messages).toHaveLength(2);
expect(messages[1]?.runtimeStatus?.title).toBe("已加入排队列表");
expect(isSending).toBe(false);
});
});
@@ -16,6 +16,7 @@ export type AgentStreamUserInputSendPreparationEnv = Pick<
| "sessionIdRef"
| "activeStreamRef"
| "getQueuedTurnsCount"
| "isThreadBusy"
| "getSyncedSessionModelPreference"
| "setMessages"
| "setIsSending"
@@ -92,7 +93,9 @@ export function prepareAgentStreamUserInputSend(
const messagePurpose = sendOptions?.purpose;
const assistantDraft = sendOptions?.assistantDraft;
const expectingQueue =
Boolean(env.activeStreamRef.current) || env.getQueuedTurnsCount() > 0;
Boolean(env.activeStreamRef.current) ||
env.getQueuedTurnsCount() > 0 ||
env.isThreadBusy();
const assistantMsgId = crypto.randomUUID();
const userMsgId = skipUserMessage ? null : crypto.randomUUID();
const { assistantMsg } = prepareAgentStreamSubmitDraft({
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import { activityLogger } from "@/lib/workspace/workbenchRuntime";
import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol";
import type {
AsterSessionExecutionRuntime,
@@ -63,6 +63,7 @@ describe("agentStreamUserInputSubmission", () => {
modelRef: { current: "gpt-5.4" } as MutableRefObject<string>,
sessionIdRef: { current: null } as MutableRefObject<string | null>,
getQueuedTurnsCount: () => 0,
isThreadBusy: () => false,
getRequiredWorkspaceId: () => "workspace-1",
getSyncedSessionModelPreference: () => null,
getSyncedSessionExecutionStrategy: () => "react",
@@ -129,6 +129,7 @@ export async function submitAgentStreamUserInput(
},
appendThinkingToParts: env.appendThinkingToParts,
setMessages: env.setMessages,
setIsSending: env.setIsSending,
setPendingActions: env.setPendingActions,
setThreadItems: env.setThreadItems,
setThreadTurns: env.setThreadTurns,
@@ -57,6 +57,7 @@ async function mountHook(props?: Partial<HookProps>): Promise<HookHarness> {
sessionId: "session-1",
parentSessionId: null,
isSending: false,
threadReadStatus: null,
queuedTurnCount: 0,
threadTurns: [],
refreshSessionDetail: vi.fn(async () => true),
@@ -178,6 +179,28 @@ describe("useAgentRuntimeSyncEffects", () => {
}
});
it("仅 thread_read 标记为 running 时也应继续轮询刷新", async () => {
const refreshSessionDetail = vi.fn(async () => true);
const harness = await mountHook({
threadReadStatus: "running",
refreshSessionDetail,
});
try {
expect(refreshSessionDetail).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1500);
await Promise.resolve();
});
expect(refreshSessionDetail).toHaveBeenCalledTimes(1);
expect(refreshSessionDetail).toHaveBeenCalledWith("session-1");
} finally {
harness.unmount();
}
});
it("收到 subagent 状态事件后应刷新当前会话详情", async () => {
const refreshSessionDetail = vi.fn(async () => true);
const listeners = new Map<
@@ -24,6 +24,7 @@ interface UseAgentRuntimeSyncEffectsOptions {
sessionId: string | null;
parentSessionId?: string | null;
isSending: boolean;
threadReadStatus?: string | null;
queuedTurnCount: number;
threadTurns: AgentThreadTurn[];
refreshSessionDetail: (targetSessionId?: string) => Promise<unknown>;
@@ -38,6 +39,7 @@ export function useAgentRuntimeSyncEffects(
sessionId,
parentSessionId,
isSending,
threadReadStatus,
queuedTurnCount,
threadTurns,
refreshSessionDetail,
@@ -68,8 +70,12 @@ export function useAgentRuntimeSyncEffects(
return;
}
const normalizedThreadReadStatus = (threadReadStatus || "").toLowerCase();
const hasRecoveredQueueWork =
queuedTurnCount > 0 || threadTurns.some((turn) => turn.status === "running");
normalizedThreadReadStatus === "running" ||
normalizedThreadReadStatus === "queued" ||
queuedTurnCount > 0 ||
threadTurns.some((turn) => turn.status === "running");
if (!hasRecoveredQueueWork) {
return;
}
@@ -83,6 +89,7 @@ export function useAgentRuntimeSyncEffects(
};
}, [
isSending,
threadReadStatus,
queuedTurnCount,
refreshSessionDetail,
sessionId,
@@ -30,7 +30,10 @@ import {
createAgentStreamPreparedSendEnv,
type AgentStreamPreparedSendEnv,
} from "./agentStreamPreparedSendEnv";
import { runAgentStreamCompaction } from "./agentStreamCompaction";
import {
normalizeAgentStreamCompactionError,
runAgentStreamCompaction,
} from "./agentStreamCompaction";
import {
promoteQueuedAgentTurn,
removeQueuedAgentTurn,
@@ -100,6 +103,7 @@ interface UseAgentStreamOptions {
setExecutionRuntime: Dispatch<
SetStateAction<AsterSessionExecutionRuntime | null>
>;
threadBusy: boolean;
queuedTurns: QueuedTurnSnapshot[];
setQueuedTurns: Dispatch<SetStateAction<QueuedTurnSnapshot[]>>;
setPendingActions: Dispatch<SetStateAction<ActionRequired[]>>;
@@ -132,6 +136,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
threadBusy,
queuedTurns,
setQueuedTurns,
setPendingActions,
@@ -158,6 +163,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
() =>
createAgentStreamPreparedSendEnv({
queuedTurnsCount: queuedTurns.length,
threadBusy,
runtime,
ensureSession,
executionStrategy,
@@ -207,6 +213,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
queuedTurns.length,
runtime,
sessionIdRef,
threadBusy,
setActiveStream,
setCurrentTurnId,
setExecutionRuntime,
@@ -336,10 +343,11 @@ export function useAgentStream(options: UseAgentStreamOptions) {
},
});
} catch (error) {
console.error("[AsterChat] 压缩上下文失败:", error);
toast.error(
error instanceof Error ? error.message : "压缩上下文失败,请稍后重试",
);
const compactionError = normalizeAgentStreamCompactionError(error);
console.error("[AsterChat] 压缩上下文失败:", compactionError);
if (!compactionError.alreadyNotified) {
toast.error(compactionError.message);
}
}
}, [
clearActiveStreamIfMatch,
@@ -1248,6 +1248,61 @@ describe("useAsterAgentChat.confirmAction", () => {
});
describe("useAsterAgentChat queue hydration", () => {
it("恢复态 thread 仍在运行时,发送继续应直接展示排队态而不是伪装成处理中", async () => {
const workspaceId = "ws-queue-on-restored-running";
const sessionId = "session-queue-on-restored-running";
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: sessionId,
name: "恢复中的运行会话",
created_at: 1,
updated_at: 2,
messages_count: 0,
},
]);
mockGetAgentRuntimeSession.mockResolvedValue({
id: sessionId,
messages: [],
turns: [],
items: [],
queued_turns: [],
thread_read: {
thread_id: "thread-queue-on-restored-running",
status: "running",
active_turn_id: "turn-running-1",
pending_requests: [],
incidents: [],
queued_turns: [],
},
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().switchTopic(sessionId);
});
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("请继续分析这个项目", [], false, false, false, "react");
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(assistantMessage?.runtimeStatus?.title).toBe("已加入排队列表");
expect(harness.getValue().isSending).toBe(false);
expect(harness.getValue().currentTurnId).toBeNull();
} finally {
harness.unmount();
}
});
it("切换话题时应恢复后端返回的排队项", async () => {
mockListAgentRuntimeSessions.mockResolvedValue([
{
@@ -1707,6 +1762,79 @@ describe("useAsterAgentChat thread timeline", () => {
}
});
it("运行时意外返回 queue_added 时,应降级为排队态并清掉假 running 占位", async () => {
const workspaceId = "ws-thread-queue-added-fallback";
const sessionId = "session-thread-queue-added-fallback";
seedSession(workspaceId, sessionId);
let queuedAdded = false;
mockGetAgentRuntimeSession.mockImplementation(async () => ({
id: sessionId,
messages: [],
turns: [],
items: [],
queued_turns: queuedAdded
? [
{
queuedTurnId: "queued-fallback-1",
messagePreview: "请继续往下分析",
messageText: "请继续往下分析",
createdAt: 1700000000000,
imageCount: 0,
position: 1,
},
]
: [],
}));
const harness = mountHook(workspaceId);
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("请继续往下分析", [], false, false, false, "react");
});
expect(harness.getValue().isSending).toBe(true);
expect(harness.getValue().turns).toHaveLength(1);
act(() => {
queuedAdded = true;
stream.emit({
type: "queue_added",
session_id: sessionId,
queued_turn: {
queued_turn_id: "queued-fallback-1",
message_preview: "请继续往下分析",
message_text: "请继续往下分析",
created_at: 1700000000000,
image_count: 0,
position: 1,
},
});
});
await flushEffects();
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(harness.getValue().isSending).toBe(false);
expect(harness.getValue().currentTurnId).toBeNull();
expect(harness.getValue().turns).toEqual([]);
expect(harness.getValue().queuedTurns).toEqual([
expect.objectContaining({
queued_turn_id: "queued-fallback-1",
}),
]);
expect(assistantMessage?.runtimeStatus?.title).toBe("已加入排队列表");
} finally {
harness.unmount();
}
});
it("submitTurn 失败时应保留失败回合与失败消息,而不是清空当前过程", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
@@ -2024,6 +2152,30 @@ describe("useAsterAgentChat thread timeline", () => {
}
});
it("手动压缩上下文返回字符串错误时应透出真实原因", async () => {
const workspaceId = "ws-context-compaction-error";
seedSession(workspaceId, "session-context-compaction-error");
const harness = mountHook(workspaceId);
mockCompactAgentRuntimeSession.mockRejectedValueOnce(
"当前会话上下文尚未准备完成,请稍后再试",
);
try {
await flushEffects();
await act(async () => {
await harness.getValue().compactSession();
});
expect(mockToast.error).toHaveBeenCalledWith(
"当前会话上下文尚未准备完成,请稍后再试",
);
} finally {
harness.unmount();
}
});
it("Artifact 恢复提示不应打断为全局 toast", async () => {
const workspaceId = "ws-artifact-warning-tone";
seedSession(workspaceId, "session-artifact-warning-tone");
@@ -134,6 +134,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
setThreadTurns: session.setThreadTurns,
setCurrentTurnId: session.setCurrentTurnId,
setExecutionRuntime: session.setExecutionRuntime,
threadBusy:
session.threadRead?.status === "running" ||
session.threadRead?.status === "queued" ||
session.threadTurns.some((turn) => turn.status === "running"),
queuedTurns: session.queuedTurns,
setQueuedTurns: session.setQueuedTurns,
setPendingActions: tools.setPendingActions,
@@ -223,6 +227,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
sessionId: session.sessionId,
parentSessionId: session.subagentParentContext?.parent_session_id,
isSending: stream.isSending,
threadReadStatus: session.threadRead?.status,
queuedTurnCount: session.queuedTurns.length,
threadTurns: session.threadTurns,
refreshSessionDetail: session.refreshSessionDetail,
@@ -23,9 +23,14 @@ vi.mock("@/hooks/useMaterials", () => ({
useMaterials: mockUseMaterials,
}));
vi.mock("@/components/content-creator/utils/systemPrompt", () => ({
isContentCreationTheme: mockIsContentCreationTheme,
}));
vi.mock("@/lib/workspace/workbenchContract", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/workspace/workbenchContract")>();
return {
...actual,
isContentCreationTheme: mockIsContentCreationTheme,
};
});
vi.mock("../utils/contextSearch", () => ({
searchThemeContextWithWebSearch: mockSearchThemeContextWithWebSearch,
@@ -3,7 +3,7 @@ import { getContent, listContents, type ContentListItem } from "@/lib/api/projec
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import { normalizeProjectId } from "../utils/topicProjectResolution";
import { useMaterials } from "@/hooks/useMaterials";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
import { isContentCreationTheme } from "@/lib/workspace/workbenchContract";
import type { Message } from "../types";
import {
searchThemeContextWithWebSearch,
@@ -1,9 +1,11 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowState as ContentWorkflowState } from "@/lib/api/content-workflow";
import type { ThemeWorkbenchRunState as BackendThemeWorkbenchRunState } from "@/lib/api/executionRun";
import { useThemeWorkbenchEntryPrompt } from "./useThemeWorkbenchEntryPrompt";
import {
useThemeWorkbenchEntryPrompt,
type ThemeWorkbenchResumeWorkflowState,
} from "./useThemeWorkbenchEntryPrompt";
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPrompt>;
@@ -33,7 +35,9 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const onHydrateInitialPrompt = vi.fn();
const loadWorkflow = vi.fn(
async (_contentId: string): Promise<ContentWorkflowState | null> => null,
async (
_contentId: string,
): Promise<ThemeWorkbenchResumeWorkflowState | null> => null,
);
const loadRunState = vi.fn(
async (_sessionId: string): Promise<BackendThemeWorkbenchRunState | null> =>
@@ -155,50 +159,31 @@ describe("useThemeWorkbenchEntryPrompt", () => {
}
});
it("无初始意图时应查询 resume prompt", async () => {
it("无初始意图时应基于 run-state 生成 resume prompt", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onHydrateInitialPrompt = vi.fn();
const loadWorkflow = vi.fn(
async (_contentId: string): Promise<ContentWorkflowState | null> => ({
id: "wf-1",
content_id: "content-1",
theme: "social-media",
mode: "guided",
current_step_index: 1,
created_at: Date.now(),
updated_at: Date.now(),
steps: [
const loadRunState = vi.fn(
async (_sessionId: string): Promise<BackendThemeWorkbenchRunState | null> => ({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
{
id: "step-1",
type: "write",
run_id: "run-1",
title: "撰写主稿",
behavior: {
skippable: false,
redoable: true,
auto_advance: false,
},
status: "completed",
},
{
id: "step-2",
type: "polish",
title: "润色结尾",
behavior: {
skippable: false,
redoable: true,
auto_advance: false,
},
status: "pending",
gate_key: "write_mode",
status: "running",
source: "skill",
source_ref: null,
started_at: new Date().toISOString(),
},
],
latest_terminal: null,
recent_terminals: [],
updated_at: new Date().toISOString(),
}),
);
const loadRunState = vi.fn(
async (_sessionId: string): Promise<BackendThemeWorkbenchRunState | null> =>
null,
);
const hookValueRef: {
current: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null;
} = { current: null };
@@ -217,7 +202,6 @@ describe("useThemeWorkbenchEntryPrompt", () => {
initialUserImages: [],
consumedInitialPromptKey: null,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState,
});
return null;
@@ -229,10 +213,11 @@ describe("useThemeWorkbenchEntryPrompt", () => {
try {
await flushEffects();
expect(loadWorkflow).toHaveBeenCalledWith("content-1");
expect(loadRunState).toHaveBeenCalledWith("session-1");
expect(hookValueRef.current?.themeWorkbenchEntryPrompt).toMatchObject({
kind: "resume",
title: "发现上次未完成任务",
description: expect.stringContaining("撰写主稿"),
});
expect(hookValueRef.current?.themeWorkbenchEntryCheckPending).toBe(false);
} finally {
@@ -1,8 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
contentWorkflowApi,
type WorkflowState as ContentWorkflowState,
} from "@/lib/api/content-workflow";
import {
executionRunGetThemeWorkbenchState,
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
@@ -20,6 +16,20 @@ export interface ThemeWorkbenchEntryPromptState {
prompt: string;
}
export interface ThemeWorkbenchResumeWorkflowStep {
id: string;
title: string;
status: "pending" | "active" | "completed" | "skipped" | "error";
result?: unknown;
}
export interface ThemeWorkbenchResumeWorkflowState {
id: string;
current_step_index: number;
updated_at: number;
steps: ThemeWorkbenchResumeWorkflowStep[];
}
interface UseThemeWorkbenchEntryPromptOptions {
activeTheme: string;
contentId?: string;
@@ -33,15 +43,14 @@ interface UseThemeWorkbenchEntryPromptOptions {
initialUserImages?: MessageImage[];
consumedInitialPromptKey?: string | null;
onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void;
loadWorkflow?: (contentId: string) => Promise<ContentWorkflowState | null>;
loadWorkflow?: (
contentId: string,
) => Promise<ThemeWorkbenchResumeWorkflowState | null>;
loadRunState?: (
sessionId: string,
) => Promise<BackendThemeWorkbenchRunState | null>;
}
const defaultLoadThemeWorkbenchWorkflow = (contentId: string) =>
contentWorkflowApi.getByContent(contentId);
const defaultLoadThemeWorkbenchRunState = (sessionId: string) =>
executionRunGetThemeWorkbenchState(sessionId, 3);
@@ -63,7 +72,7 @@ function resolveThemeWorkbenchGateLabel(
}
function hasWorkflowMeaningfulProgress(
workflow: ContentWorkflowState | null,
workflow: ThemeWorkbenchResumeWorkflowState | null,
): boolean {
if (!workflow) {
return false;
@@ -83,7 +92,7 @@ function hasWorkflowMeaningfulProgress(
}
export function buildThemeWorkbenchResumePromptFromWorkflow(
workflow: ContentWorkflowState | null,
workflow: ThemeWorkbenchResumeWorkflowState | null,
): ThemeWorkbenchEntryPromptState | null {
if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) {
return null;
@@ -176,7 +185,7 @@ export function useThemeWorkbenchEntryPrompt({
initialUserImages,
consumedInitialPromptKey,
onHydrateInitialPrompt,
loadWorkflow = defaultLoadThemeWorkbenchWorkflow,
loadWorkflow,
loadRunState = defaultLoadThemeWorkbenchRunState,
}: UseThemeWorkbenchEntryPromptOptions) {
const [themeWorkbenchEntryPrompt, setThemeWorkbenchEntryPrompt] =
@@ -265,7 +274,7 @@ export function useThemeWorkbenchEntryPrompt({
void (async () => {
try {
const [workflow, backendState] = await Promise.all([
loadWorkflow(contentId).catch(() => null),
loadWorkflow ? loadWorkflow(contentId).catch(() => null) : null,
loadRunState(sessionId).catch(() => null),
]);
+81 -83
View File
@@ -45,8 +45,6 @@ const {
mockExecutionRunGetThemeWorkbenchState,
mockExecutionRunListThemeWorkbenchHistory,
mockExecutionRunGet,
mockContentWorkflowCreate,
mockContentWorkflowGetByContent,
mockSkillExecutionGetDetail,
mockSkillsGetAll,
mockSkillsGetLocal,
@@ -109,8 +107,6 @@ const {
mockExecutionRunGetThemeWorkbenchState: vi.fn(),
mockExecutionRunListThemeWorkbenchHistory: vi.fn(),
mockExecutionRunGet: vi.fn(),
mockContentWorkflowCreate: vi.fn(),
mockContentWorkflowGetByContent: vi.fn(),
mockSkillExecutionGetDetail: vi.fn(),
mockSkillsGetAll: vi.fn(),
mockSkillsGetLocal: vi.fn(),
@@ -204,7 +200,7 @@ vi.mock("./hooks/useContentSync", () => ({
}),
}));
vi.mock("@/components/content-creator/hooks/useWorkflow", () => ({
vi.mock("@/lib/workspace/workbenchWorkflow", () => ({
useWorkflow: () => ({
steps: [],
currentStepIndex: 0,
@@ -214,7 +210,7 @@ vi.mock("@/components/content-creator/hooks/useWorkflow", () => ({
}));
vi.mock(
"@/components/content-creator/core/LayoutTransition/LayoutTransition",
"@/lib/workspace/workbenchUi",
() => ({
LayoutTransition: ({
mode,
@@ -234,6 +230,7 @@ vi.mock(
</div>
</div>
),
StepProgress: () => <div data-testid="step-progress" />,
}),
);
@@ -492,10 +489,38 @@ vi.mock("./components/CanvasWorkbenchLayout", () => ({
mockCanvasWorkbenchLayout(props),
}));
vi.mock("@/components/content-creator/core/StepGuide/StepProgress", () => ({
StepProgress: () => <div data-testid="step-progress" />,
vi.mock("@/lib/workspace/workbenchCanvas", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/workspace/workbenchCanvas")>();
return {
...actual,
CanvasFactory: () => <div data-testid="canvas-factory" />,
createInitialCanvasState: vi.fn(() => null),
createInitialDocumentState: vi.fn((content = "") => ({
type: "document",
content,
platform: "markdown",
versions: [],
currentVersionId: "",
isEditing: true,
})),
};
});
vi.mock("@/lib/workspace/workbenchPrompt", () => ({
generateContentCreationPrompt: mockGenerateContentCreationPrompt,
generateProjectMemoryPrompt: vi.fn(() => ""),
}));
vi.mock("@/lib/workspace/workbenchContract", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/workspace/workbenchContract")>();
return {
...actual,
isContentCreationTheme: mockIsContentCreationTheme,
};
});
vi.mock("@/components/content-creator/canvas/CanvasFactory", () => ({
CanvasFactory: () => <div data-testid="canvas-factory" />,
}));
@@ -562,30 +587,6 @@ vi.mock("jotai", () => ({
},
}));
vi.mock("@/components/content-creator/utils/systemPrompt", () => ({
generateContentCreationPrompt: mockGenerateContentCreationPrompt,
isContentCreationTheme: mockIsContentCreationTheme,
}));
vi.mock("@/components/content-creator/utils/projectPrompt", () => ({
generateProjectMemoryPrompt: vi.fn(() => ""),
}));
vi.mock("@/components/content-creator/canvas/canvasUtils", () => ({
createInitialCanvasState: vi.fn(() => null),
}));
vi.mock("@/components/content-creator/canvas/document", () => ({
createInitialDocumentState: vi.fn((content = "") => ({
type: "document",
content,
platform: "markdown",
versions: [],
currentVersionId: "",
isEditing: true,
})),
}));
vi.mock("./utils/workflowMapping", async (importOriginal) => {
const actual =
await importOriginal<typeof import("./utils/workflowMapping")>();
@@ -631,13 +632,6 @@ vi.mock("@/lib/api/executionRun", () => ({
mockExecutionRunListThemeWorkbenchHistory,
}));
vi.mock("@/lib/api/content-workflow", () => ({
contentWorkflowApi: {
create: mockContentWorkflowCreate,
getByContent: mockContentWorkflowGetByContent,
},
}));
vi.mock("@/lib/api/skill-execution", () => ({
skillExecutionApi: {
getSkillDetail: mockSkillExecutionGetDetail,
@@ -1060,17 +1054,6 @@ beforeEach(() => {
next_offset: null,
});
mockExecutionRunGet.mockResolvedValue(null);
mockContentWorkflowCreate.mockResolvedValue({
id: "wf-default",
content_id: "content-default",
theme: "social-media",
mode: "guided",
steps: [],
current_step_index: 0,
created_at: Date.now(),
updated_at: Date.now(),
});
mockContentWorkflowGetByContent.mockResolvedValue(null);
mockSkillExecutionGetDetail.mockResolvedValue({
name: "social_post_with_cover",
display_name: "社媒主稿与封面",
@@ -1729,7 +1712,31 @@ describe("AgentChatPage 通用工作台", () => {
).not.toBeNull();
});
it("通用模式应通过顶部按钮打开工作台弹窗,而不是常驻右侧占位", async () => {
it("通用模式空闲时不应通过顶部按钮常驻显示工作台入口", async () => {
const container = renderPage({
theme: "general",
lockTheme: true,
});
await flushEffects();
const navbar = container.querySelector(
'[data-testid="chat-navbar"]',
) as HTMLDivElement | null;
expect(navbar?.dataset.showHarnessToggle).toBe("false");
expect(
container.querySelector('[data-testid="toggle-harness"]'),
).toBeNull();
expect(document.body.textContent).not.toContain("处理工作台");
expect(document.body.textContent).not.toContain("通用助手");
});
it("通用模式有处理活动时应通过顶部按钮打开工作台弹窗,而不是常驻右侧占位", async () => {
installMockAgentChatUnifiedState(
createMockAgentChatUnifiedState({
isSending: true,
}),
);
const container = renderPage({
theme: "general",
lockTheme: true,
@@ -2344,6 +2351,11 @@ describe("AgentChatPage 通用工作台", () => {
sourceKind: "builtin",
},
]);
installMockAgentChatUnifiedState(
createMockAgentChatUnifiedState({
isSending: true,
}),
);
const container = renderPage({
theme: "general",
@@ -2391,7 +2403,7 @@ describe("AgentChatPage 通用工作台", () => {
content: "/research 帮我整理当前主题",
},
],
isSending: false,
isSending: true,
sendMessage: sharedSendMessageMock,
stopSending: vi.fn(async () => undefined),
clearMessages: vi.fn(),
@@ -3531,40 +3543,26 @@ describe("AgentChatPage 自动引导", () => {
enabled: true,
}),
);
mockContentWorkflowGetByContent.mockResolvedValue({
id: "wf-resume-1",
content_id: "content-social-resume",
theme: "social-media",
mode: "guided",
current_step_index: 1,
created_at: Date.now() - 10_000,
updated_at: Date.now(),
steps: [
mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
{
id: "clarify",
type: "clarify",
title: "明确选题",
description: "",
behavior: {
skippable: true,
redoable: true,
auto_advance: false,
},
status: "completed",
},
{
id: "write",
type: "write",
run_id: "run-resume-1",
execution_id: "exec-resume-1",
session_id: "session-default",
artifact_paths: [],
title: "撰写主稿",
description: "",
behavior: {
skippable: false,
redoable: true,
auto_advance: false,
},
status: "pending",
gate_key: "write_mode",
status: "running",
source: "chat",
source_ref: "content-social-resume",
started_at: new Date(Date.now() - 10_000).toISOString(),
},
],
latest_terminal: null,
recent_terminals: [],
updated_at: new Date().toISOString(),
});
const container = renderPage({
@@ -3585,7 +3583,7 @@ describe("AgentChatPage 自动引导", () => {
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(
"/social_post_with_cover 请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“撰写主稿”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。",
"/social_post_with_cover 请基于当前文稿与最近一次未完成的运行继续推进。任务标题:撰写主稿。优先衔接“写作推进”阶段。不要从头开始,先概括已有进度,再继续执行。",
);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
@@ -54,7 +54,7 @@ describe("service skill workspace launch", () => {
});
});
it("非内容创作主题不应强制生成内容种子", () => {
it("非内容工作区主题不应强制生成内容种子", () => {
expect(
buildServiceSkillWorkspaceSeed(
createSkill({
@@ -4,7 +4,7 @@ import type {
ServiceSkillArtifactKind,
ServiceSkillItem,
} from "@/lib/api/serviceSkills";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
import { isContentCreationTheme } from "@/lib/workspace/workbenchContract";
export interface ServiceSkillWorkspaceSeed {
title: string;
@@ -2,7 +2,7 @@ import type {
A2UIComponent,
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import type {
ActionRequired,
ActionRequestGovernanceMeta,
@@ -218,4 +218,19 @@ describe("agentThreadGrouping", () => {
expect(model.summaryText).toBe("请先确认以下选项:");
expect(model.orderedBlocks[0]?.previewLines).toEqual(["请先确认以下选项:"]);
});
it("内部路由型 turn_summary 不应抢占整轮摘要", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "直接回答优先\n当前请求无需默认升级为搜索或任务。",
},
];
const model = buildAgentThreadDisplayModel(items);
expect(model.summaryText).toBeNull();
expect(model.orderedBlocks[0]?.previewLines).toEqual([]);
});
});
@@ -1,4 +1,4 @@
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import { parseAIResponse } from "@/lib/workspace/a2ui";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import {
extractFilesystemEventLocationHintsFromValue,
@@ -7,6 +7,7 @@ import {
import type { AgentThreadItem, AgentThreadItemStatus } from "../types";
import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation";
export type AgentThreadGroupKind =
| "process"
@@ -579,9 +580,13 @@ function summarizeOtherItem(item: AgentThreadItem): string | null {
function summarizeThinkingItem(item: AgentThreadItem): string | null {
if (item.type === "turn_summary") {
if (isInternalRoutingTurnSummaryText(item.text)) {
return null;
}
const preview = extractThinkingPreviewLine(item.text);
if (!preview) {
return item.status === "in_progress" ? "思考中" : "已完成思考";
return item.status === "in_progress" ? "处理中" : "当前进展";
}
return shortenText(preview);
@@ -1,7 +1,7 @@
const SOCIAL_MEDIA_DEFAULT_GUIDE_PROMPT = `你现在是社媒内容创作教练,请先进入“提问引导”阶段,不要直接成文。
请先用简洁问题逐项确认以下信息:
1. 创作主题(想解决的问题或核心观点)
1. 核心主题(想解决的问题或核心观点)
2. 发布平台(如小红书/公众号/知乎)
3. 目标受众(人群画像)
4. 目标结果(涨粉/互动/转化/品牌认知)
@@ -1,4 +1,4 @@
import type { ThemeType } from "@/components/content-creator/types";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { ChatToolPreferences } from "./chatToolPreferences";
const GENERAL_AGENT_THEMES = new Set<string>([
@@ -196,7 +196,7 @@ describe("deriveHarnessSessionState", () => {
});
});
it("仅有 turn_summary 时也应为 harness 提供计划摘要兜底", () => {
it("内部路由型 turn_summary 不应伪装成已就绪计划", () => {
const messages = [createMessage()];
const items: AgentThreadItem[] = [
{
@@ -215,10 +215,34 @@ describe("deriveHarnessSessionState", () => {
const state = deriveHarnessSessionState(messages, [], items);
expect(state.plan.phase).toBe("idle");
expect(state.plan.items).toHaveLength(0);
expect(state.plan.summaryText).toBeUndefined();
expect(state.outputSignals[0]?.toolName).toBe("turn_summary");
});
it("有真实进展的 turn_summary 仍应作为计划摘要兜底", () => {
const messages = [createMessage()];
const items: AgentThreadItem[] = [
{
id: "summary-2",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 1,
status: "completed",
started_at: "2026-03-13T12:00:00.000Z",
completed_at: "2026-03-13T12:00:01.000Z",
updated_at: "2026-03-13T12:00:01.000Z",
type: "turn_summary",
text: "已打开公众号后台\n后续可以继续执行发布。",
},
];
const state = deriveHarnessSessionState(messages, [], items);
expect(state.plan.phase).toBe("ready");
expect(state.plan.items).toHaveLength(0);
expect(state.plan.summaryText).toContain("直接回答优先");
expect(state.outputSignals[0]?.toolName).toBe("turn_summary");
expect(state.plan.summaryText).toContain("已打开公众号后台");
});
it("应保留最近 8 条输出信号以承载多组 WebSearch 扩搜", () => {
@@ -11,6 +11,7 @@ import {
import { extractFilesystemEventPathsFromValue } from "@/lib/filesystem-event-protocol";
import type { ActionRequired, AgentRuntimeStatus, Message } from "../types";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation";
import {
resolveArtifactPreviewText,
resolveArtifactWritePhase,
@@ -1141,6 +1142,10 @@ function toActionRequired(item: AgentThreadItem): ActionRequired | null {
}
function summarizePlanDecisionText(text?: string): string | undefined {
if (isInternalRoutingTurnSummaryText(text)) {
return undefined;
}
return buildTextPreview(text, {
maxLines: 4,
maxChars: 240,
@@ -4,7 +4,7 @@ import type {
A2UIComponent,
ChoicePickerComponent,
TextFieldComponent,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import {
buildLegacyQuestionnaireSubmissionPayload,
@@ -2,7 +2,7 @@ import type {
A2UIComponent,
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import type { ActionRequestGovernanceMeta } from "../types";
export interface LegacyQuestionnaireSubmissionEntry {
@@ -4,7 +4,7 @@ import type {
A2UIResponse,
ChildList,
TextComponent,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
type PromptFieldComponent = Extract<
A2UIComponent,
@@ -1,6 +1,6 @@
import type { ThemeType } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import { scriptStateToText } from "@/components/content-creator/canvas/script";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { scriptStateToText } from "@/lib/workspace/workbenchCanvas";
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
import type { TaskFile } from "../components/TaskFiles";
import { getSupportedFilenames } from "./workflowMapping";
@@ -0,0 +1,59 @@
const LEGACY_DECISION_PREFIX_RE = /^已决定[::]\s*/;
const INTERNAL_ROUTING_SUMMARY_PATTERNS = [
/^直接回答优先$/,
/^优先本地直接回答$/,
/^联网搜索能力待命$/,
/^联网搜索仅作为候选能力待命$/,
/^先理解意图再决定是否联网$/,
/^当前请求无需默认升级为搜索或任务$/,
/^当前请求无需工具介入$/,
/^等待首个模型事件$/,
/^推理增强已待命$/,
/^必要时启用深度思考$/,
/^先走轻量推理$/,
/^自动选择执行方式$/,
/^对话优先执行$/,
/^代码编排执行$/,
/^用户请求已入队$/,
/^系统引导请求$/,
/^正在启动处理流程$/,
/^正在准备处理$/,
/^已开始处理正在准备环境并等待第一条进展$/,
/^正在理解你的需求并准备当前阶段$/,
];
function normalizeLineForMatch(value: string): string {
return value
.replace(LEGACY_DECISION_PREFIX_RE, "")
.replace(/[。;,、,.!?!?::]/g, "")
.replace(/\s+/g, "")
.trim();
}
export function normalizeTurnSummaryDisplayText(text?: string | null): string {
return (text || "").trim();
}
export function extractTurnSummaryLines(text?: string | null): string[] {
return normalizeTurnSummaryDisplayText(text)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function isInternalRoutingTurnSummaryText(
text?: string | null,
): boolean {
const lines = extractTurnSummaryLines(text);
if (lines.length === 0) {
return false;
}
return lines.every((line) => {
const normalized = normalizeLineForMatch(line);
return INTERNAL_ROUTING_SUMMARY_PATTERNS.some((pattern) =>
pattern.test(normalized),
);
});
}
@@ -4,7 +4,7 @@
* @module components/agent/chat/utils/workflowMapping
*/
import type { ThemeType } from "@/components/content-creator/types";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
/**
* 根据主题类型获取文件名到步骤索引的映射
@@ -14,7 +14,9 @@ import {
import type { Artifact } from "@/lib/artifact/types";
import type { AgentThreadItem } from "../types";
vi.mock("@/components/content-creator/canvas/document/editor", async () => {
vi.mock("@/lib/workspace/workbenchCanvas", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/workspace/workbenchCanvas")>();
const ReactModule = await import("react");
const MockNotionEditor = ReactModule.forwardRef<
@@ -63,6 +65,7 @@ vi.mock("@/components/content-creator/canvas/document/editor", async () => {
MockNotionEditor.displayName = "MockNotionEditor";
return {
...actual,
NotionEditor: MockNotionEditor,
};
});
@@ -1,9 +1,8 @@
import type { ComponentProps, ReactNode } from "react";
import { Info } from "lucide-react";
import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress";
import { StepProgress } from "@/lib/workspace/workbenchUi";
import { EmptyState } from "../components/EmptyState";
import { MessageList } from "../components/MessageList";
import { RuntimeStyleControlBar } from "../components/RuntimeStyleControlBar";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
import { WorkspacePendingA2UIDialog } from "./WorkspacePendingA2UIDialog";
import {
@@ -18,7 +17,7 @@ import {
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import type { A2UISubmissionNoticeData } from "../components/Inputbar/components/A2UISubmissionNotice";
interface WorkspaceChatContentProps {
@@ -27,9 +26,6 @@ interface WorkspaceChatContentProps {
onDismissEntryBanner: () => void;
serviceSkillExecutionCard?: ReactNode;
stepProgressProps?: ComponentProps<typeof StepProgress> | null;
runtimeStyleControlBarProps?: ComponentProps<
typeof RuntimeStyleControlBar
> | null;
showChatLayout: boolean;
compactChrome: boolean;
contextWorkspaceEnabled: boolean;
@@ -53,7 +49,6 @@ export function WorkspaceChatContent({
onDismissEntryBanner,
serviceSkillExecutionCard,
stepProgressProps,
runtimeStyleControlBarProps,
showChatLayout,
compactChrome,
contextWorkspaceEnabled,
@@ -95,9 +90,6 @@ export function WorkspaceChatContent({
) : null}
{stepProgressProps ? <StepProgress {...stepProgressProps} /> : null}
{runtimeStyleControlBarProps ? (
<RuntimeStyleControlBar {...runtimeStyleControlBarProps} />
) : null}
{serviceSkillExecutionCard}
{showChatLayout ? (
@@ -1,5 +1,5 @@
import type { ComponentProps } from "react";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { ChatNavbar } from "../components/ChatNavbar";
import { EmptyState } from "../components/EmptyState";
import { WorkspaceChatContent } from "./WorkspaceChatContent";
@@ -30,9 +30,6 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps {
typeof WorkspaceChatContent
>["serviceSkillExecutionCard"];
stepProgressProps?: ComponentProps<typeof WorkspaceChatContent>["stepProgressProps"];
runtimeStyleControlBarProps?: ComponentProps<
typeof WorkspaceChatContent
>["runtimeStyleControlBarProps"];
showChatLayout: boolean;
contextWorkspaceEnabled: boolean;
themeWorkbenchMessageViewportBottomPadding?: string;
@@ -145,7 +142,6 @@ export function WorkspaceConversationScene({
onDismissEntryBanner,
serviceSkillExecutionCard,
stepProgressProps,
runtimeStyleControlBarProps,
showChatLayout,
compactChrome,
contextWorkspaceEnabled,
@@ -281,14 +277,13 @@ export function WorkspaceConversationScene({
});
const chatContent = (
<WorkspaceChatContent
entryBannerVisible={entryBannerVisible}
entryBannerMessage={entryBannerMessage}
onDismissEntryBanner={onDismissEntryBanner}
serviceSkillExecutionCard={serviceSkillExecutionCard}
stepProgressProps={stepProgressProps}
runtimeStyleControlBarProps={runtimeStyleControlBarProps}
showChatLayout={showChatLayout}
<WorkspaceChatContent
entryBannerVisible={entryBannerVisible}
entryBannerMessage={entryBannerMessage}
onDismissEntryBanner={onDismissEntryBanner}
serviceSkillExecutionCard={serviceSkillExecutionCard}
stepProgressProps={stepProgressProps}
showChatLayout={showChatLayout}
compactChrome={compactChrome}
contextWorkspaceEnabled={contextWorkspaceEnabled}
themeWorkbenchMessageViewportBottomPadding={
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { LayoutMode } from "@/components/content-creator/types";
import type { LayoutMode } from "@/lib/workspace/workbenchContract";
import {
LayoutTransitionRenderGate,
MainArea,
@@ -1,8 +1,8 @@
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
} from "@/lib/workspace/a2ui";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import { A2UITaskCard } from "../components/A2UITaskCard";
import {
A2UISubmissionNotice,
@@ -1,7 +1,7 @@
import { memo, type ReactNode } from "react";
import styled from "styled-components";
import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition";
import type { LayoutMode } from "@/components/content-creator/types";
import { LayoutTransition } from "@/lib/workspace/workbenchUi";
import type { LayoutMode } from "@/lib/workspace/workbenchContract";
import type { SyncStatus } from "../hooks/useContentSync";
export const PageContainer = styled.div<{ $compact?: boolean }>`
@@ -46,7 +46,7 @@ import { cn } from "@/lib/utils";
import {
NotionEditor,
type NotionEditorHandle,
} from "@/components/content-creator/canvas/document/editor";
} from "@/lib/workspace/workbenchCanvas";
export type ArtifactWorkbenchInspectorTab =
| "overview"
@@ -1,5 +1,5 @@
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { ThemeType } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
import type { TaskFile } from "../components/TaskFiles";
import type { CanvasWorkbenchDefaultPreview } from "../components/CanvasWorkbenchLayout";
@@ -1,10 +1,9 @@
import type { ComponentProps } from "react";
import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress";
import { StepProgress } from "@/lib/workspace/workbenchUi";
import { ChatNavbar } from "../components/ChatNavbar";
import { ChatSidebar } from "../components/ChatSidebar";
import { EmptyState } from "../components/EmptyState";
import { MessageList } from "../components/MessageList";
import { RuntimeStyleControlBar } from "../components/RuntimeStyleControlBar";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation";
@@ -45,51 +44,6 @@ export function buildStepProgressProps({
};
}
interface BuildRuntimeStyleControlBarPropsParams {
enabled: boolean;
projectId: string | null | undefined;
activeTheme: ComponentProps<typeof RuntimeStyleControlBar>["activeTheme"];
projectStyleGuide: ComponentProps<
typeof RuntimeStyleControlBar
>["projectStyleGuide"];
selection: ComponentProps<typeof RuntimeStyleControlBar>["selection"];
onSelectionChange: ComponentProps<
typeof RuntimeStyleControlBar
>["onSelectionChange"];
onRewrite: NonNullable<ComponentProps<typeof RuntimeStyleControlBar>["onRewrite"]>;
onAudit: NonNullable<ComponentProps<typeof RuntimeStyleControlBar>["onAudit"]>;
actionsDisabled: boolean;
}
export function buildRuntimeStyleControlBarProps({
enabled,
projectId,
activeTheme,
projectStyleGuide,
selection,
onSelectionChange,
onRewrite,
onAudit,
actionsDisabled,
}: BuildRuntimeStyleControlBarPropsParams):
| ComponentProps<typeof RuntimeStyleControlBar>
| null {
if (!enabled || !projectId) {
return null;
}
return {
projectId,
activeTheme,
projectStyleGuide,
selection,
onSelectionChange,
onRewrite,
onAudit,
actionsDisabled,
};
}
export function buildWorkspaceMessageListProps(
params: ComponentProps<typeof MessageList>,
): ComponentProps<typeof MessageList> {
@@ -1,5 +1,5 @@
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { PlatformType } from "@/components/content-creator/canvas/document/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { PlatformType } from "@/lib/workspace/workbenchCanvas";
import type {
CanvasImageInsertAnchorHint,
CanvasImageTargetType,
@@ -1,6 +1,6 @@
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { DocumentVersion } from "@/components/content-creator/canvas/document/types";
import type { StepStatus } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { DocumentVersion } from "@/lib/workspace/workbenchCanvas";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type {
ThemeWorkbenchRunTodoItem,
ThemeWorkbenchRunTerminalItem,
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import { parseAIResponse } from "@/lib/workspace/a2ui";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
} from "@/lib/workspace/a2ui";
import {
buildActionRequestA2UI,
isActionRequestA2UICompatible,
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { toast } from "sonner";
import type { A2UIFormData, A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui";
import type { ConfirmResponse } from "../types";
import type { SendMessageFn } from "../hooks/agentChatShared";
import type { ActionRequired } from "../types";
@@ -5,11 +5,11 @@ import {
type SetStateAction,
} from "react";
import { toast } from "sonner";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import { createInitialMusicState } from "@/components/content-creator/canvas/music/types";
import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser";
import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { createInitialMusicState } from "@/lib/workspace/workbenchCanvas";
import { parseLyrics } from "@/lib/workspace/workbenchCanvas";
import type { LayoutMode, ThemeType } from "@/lib/workspace/workbenchContract";
import { readFilePreview } from "@/lib/api/fileBrowser";
import type { SessionFile } from "@/lib/api/session-files";
import type { Artifact } from "@/lib/artifact/types";
@@ -1,11 +1,10 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { contentWorkflowApi } from "@/lib/api/content-workflow";
import { getDefaultGuidePromptByTheme } from "../utils/defaultGuidePrompt";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { MessageImage } from "../types";
import type { CreationMode, ThemeType } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { isCanvasStateEmpty } from "./themeWorkbenchHelpers";
import type { WorkspaceHandleSend } from "./useWorkspaceSendActions";
@@ -33,7 +32,6 @@ interface UseWorkspaceAutoGuideRuntimeParams {
canvasState: CanvasStateUnion | null;
isThemeWorkbench: boolean;
mappedTheme: ThemeType;
creationMode: CreationMode;
shouldUseCompactThemeWorkbench: boolean;
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt: boolean;
themeWorkbenchEntryCheckPending: boolean;
@@ -62,7 +60,6 @@ export function useWorkspaceAutoGuideRuntime({
canvasState,
isThemeWorkbench,
mappedTheme,
creationMode,
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
themeWorkbenchEntryCheckPending,
@@ -172,19 +169,7 @@ export function useWorkspaceAutoGuideRuntime({
}
hasTriggeredGuideRef.current = true;
logWorkspaceInfo("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
void (async () => {
try {
await contentWorkflowApi.create(contentId, mappedTheme, creationMode);
logWorkspaceInfo("[AgentChatPage] 后端工作流创建成功");
} catch (error) {
console.warn(
"[AgentChatPage] 后端工作流创建失败(不影响主流程):",
error,
);
}
})();
logWorkspaceInfo("[AgentChatPage] 主题工作台:触发 AI 引导");
triggerAIGuideRef.current();
return;
@@ -198,7 +183,6 @@ export function useWorkspaceAutoGuideRuntime({
chatToolPreferences.thinking,
chatToolPreferences.webSearch,
contentId,
creationMode,
handleSend,
initialDispatchKey,
initialAutoSendRequestMetadata,
@@ -291,34 +275,4 @@ export function useWorkspaceAutoGuideRuntime({
hasTriggeredGuideRef.current = false;
consumedInitialPromptRef.current = null;
}, [contentId, consumedInitialPromptRef, hasTriggeredGuideRef]);
useEffect(() => {
if (!contentId || !isThemeWorkbench) {
return;
}
let disposed = false;
void (async () => {
try {
const workflow = await contentWorkflowApi.getByContent(contentId);
if (!workflow || disposed) {
return;
}
const completedCount = workflow.steps.filter(
(step) => step.status === "completed" || step.status === "skipped",
).length;
logWorkspaceInfo(
`[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`,
);
} catch (error) {
console.debug("[AgentChatPage] 查询后端工作流失败:", error);
}
})();
return () => {
disposed = true;
};
}, [contentId, isThemeWorkbench]);
}
@@ -3,7 +3,10 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Artifact } from "@/lib/artifact/types";
import { resolveBrowserAssistSessionScopeKey } from "../utils/browserAssistSession";
import {
resolveBrowserAssistSessionScopeKey,
resolveBrowserAssistSessionStorageKey,
} from "../utils/browserAssistSession";
import { buildBrowserAssistArtifact } from "./browserAssistArtifact";
import { useWorkspaceBrowserAssistRuntime } from "./useWorkspaceBrowserAssistRuntime";
@@ -388,4 +391,36 @@ describe("useWorkspaceBrowserAssistRuntime", () => {
}),
);
});
it("只有 transient 恢复态时不应自动补拉旧浏览器会话", async () => {
const storageKey = resolveBrowserAssistSessionStorageKey(
"workspace-1",
"session-1",
);
window.sessionStorage.setItem(
storageKey,
JSON.stringify({
profileKey: "general-browser",
url: "https://www.google.com/",
title: "浏览器协助",
updatedAt: Date.now(),
}),
);
const { render, getValue } = renderHook();
await render();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockLaunchBrowserSession).not.toHaveBeenCalled();
expect(mockBrowserExecuteAction).not.toHaveBeenCalled();
expect(getValue().browserAssistSessionState).toEqual(
expect.objectContaining({
profileKey: "general-browser",
url: "https://www.google.com/",
}),
);
});
});
@@ -8,7 +8,7 @@ import {
type SetStateAction,
} from "react";
import { toast } from "sonner";
import type { LayoutMode } from "@/components/content-creator/types";
import type { LayoutMode } from "@/lib/workspace/workbenchContract";
import {
browserExecuteAction,
launchBrowserSession,
@@ -351,6 +351,25 @@ export function useWorkspaceBrowserAssistRuntime({
return `${latestArtifact.id}:${latestArtifact.updatedAt}:${latestArtifact.status}`;
}, [activeTheme, artifacts]);
const canAutoRestoreDetachedBrowserAssistSession = useMemo(() => {
if (activeTheme !== "general") {
return false;
}
return Boolean(
openBrowserAssistOnMount ||
initialSiteSkillLaunch ||
browserAssistArtifact ||
latestBrowserAssistSessionFromMessages,
);
}, [
activeTheme,
browserAssistArtifact,
initialSiteSkillLaunch,
latestBrowserAssistSessionFromMessages,
openBrowserAssistOnMount,
]);
const isBrowserAssistReady = useMemo(
() => hasActiveBrowserAssistSession(browserAssistSessionState),
[browserAssistSessionState],
@@ -1307,6 +1326,10 @@ export function useWorkspaceBrowserAssistRuntime({
return;
}
if (!canAutoRestoreDetachedBrowserAssistSession) {
return;
}
const isSameFailedLaunchArtifact =
isFailedBrowserAssistLaunchState(currentArtifactLaunchState) &&
currentArtifactProfileKey === nextProfileKey &&
@@ -1432,6 +1455,7 @@ export function useWorkspaceBrowserAssistRuntime({
autoOpenBrowserAssistCanvas,
browserAssistArtifact,
browserAssistSessionState,
canAutoRestoreDetachedBrowserAssistSession,
commitBrowserAssistSessionState,
currentBrowserAssistScopeKey,
generalBrowserAssistProfileKey,
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { createInitialCanvasState, type CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
import { createInitialCanvasState, type CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
import type { LayoutMode, ThemeType } from "@/lib/workspace/workbenchContract";
import { isContentCreationTheme } from "@/lib/workspace/workbenchContract";
import type { CanvasWorkbenchLayoutMode } from "../components/CanvasWorkbenchLayout";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
@@ -5,13 +5,13 @@ import {
type MutableRefObject,
type SetStateAction,
} from "react";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import {
createInitialNovelState,
countWords as countNovelWords,
} from "@/components/content-creator/canvas/novel/types";
import type { ThemeType } from "@/components/content-creator/types";
} from "@/lib/workspace/workbenchCanvas";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { Message } from "../types";
import { isCanvasStateEmpty } from "./themeWorkbenchHelpers";
@@ -6,9 +6,9 @@ import {
} from "react";
import { toast } from "sonner";
import { ArtifactCanvasOverlay, ArtifactToolbar } from "@/components/artifact";
import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { ThemeType } from "@/components/content-creator/types";
import { CanvasFactory } from "@/lib/workspace/workbenchCanvas";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import {
CanvasPanel as GeneralCanvasPanel,
type CanvasState as GeneralCanvasState,
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import type { ThemeType } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { TaskFile } from "../components/TaskFiles";
import { useWorkspaceCanvasPreviewPresentation } from "./useWorkspaceCanvasPreviewPresentation";
@@ -4,11 +4,11 @@ import {
type MutableRefObject,
type SetStateAction,
} from "react";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
import { createInitialMusicState } from "@/components/content-creator/canvas/music/types";
import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser";
import type { ThemeType } from "@/components/content-creator/types";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
import { createInitialMusicState } from "@/lib/workspace/workbenchCanvas";
import { parseLyrics } from "@/lib/workspace/workbenchCanvas";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { TaskFile } from "../components/TaskFiles";
import {
resolveCanvasTaskFileTarget,

Some files were not shown because too many files have changed in this diff Show More