mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.0.0-beta
This commit is contained in:
+6
-1
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user