release: v1.10.0

This commit is contained in:
coso
2026-04-13 13:07:08 +08:00
parent 0f15b8a71e
commit bf03e44ac7
273 changed files with 12344 additions and 26363 deletions
-13
View File
@@ -29,10 +29,6 @@ vi.mock("./channels/ImConfigPage", () => ({
ImConfigPage: () => <div data-testid="im-config-page" />,
}));
vi.mock("./workspace/video/VideoPage", () => ({
VideoPage: () => <div data-testid="video-page" />,
}));
vi.mock("./settings-v2", () => ({
SettingsPageV2: () => <div data-testid="settings-page" />,
}));
@@ -218,15 +214,6 @@ describe("AppPageContent", () => {
).not.toBeNull();
});
it("video 页面应渲染现役视频工作台入口", async () => {
const container = renderContent("video");
await flushEffects();
expect(
container.querySelector('[data-testid="video-page"]'),
).not.toBeNull();
});
it("settings 页面应渲染设置页入口", async () => {
const container = renderContent("settings");
await flushEffects();
-100
View File
@@ -26,15 +26,6 @@ const PageWrapper = styled.div<{ $isActive: boolean }>`
display: ${(props) => (props.$isActive ? "block" : "none")};
`;
const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
min-height: 0;
overflow: hidden;
display: ${(props) => (props.$isActive ? "flex" : "none")};
flex-direction: column;
position: relative;
`;
const columnPageStyle = {
flex: 1,
minHeight: 0,
@@ -42,11 +33,6 @@ const columnPageStyle = {
flexDirection: "column",
} as const;
const ToolsPage = lazy(() =>
import("./tools/ToolsPage").then((module) => ({
default: module.ToolsPage,
})),
);
const ResourcesPage = lazy(() =>
import("./resources").then((module) => ({
default: module.ResourcesPage,
@@ -62,16 +48,6 @@ const PluginsPage = lazy(() =>
default: module.PluginsPage,
})),
);
const ImageGenPage = lazy(() =>
import("./image-gen").then((module) => ({
default: module.ImageGenPage,
})),
);
const VideoPage = lazy(() =>
import("./workspace/video/VideoPage").then((module) => ({
default: module.VideoPage,
})),
);
const AutomationPage = lazy(() =>
import("./automation").then((module) => ({
default: module.AutomationPage,
@@ -97,26 +73,6 @@ const BrowserRuntimeWorkspace = lazy(() =>
default: module.BrowserRuntimeWorkspace,
})),
);
const TerminalWorkspace = lazy(() =>
import("./terminal").then((module) => ({
default: module.TerminalWorkspace,
})),
);
const SysinfoView = lazy(() =>
import("./terminal").then((module) => ({
default: module.SysinfoView,
})),
);
const FileBrowserView = lazy(() =>
import("./terminal").then((module) => ({
default: module.FileBrowserView,
})),
);
const WebView = lazy(() =>
import("./terminal").then((module) => ({
default: module.WebView,
})),
);
const AgentChatPage = lazy(() =>
import("./agent/chat").then((module) => ({
default: module.AgentChatPage,
@@ -136,22 +92,6 @@ export function AppPageContent({
onNavigate,
onAgentHasMessagesChange,
}: AppPageContentProps) {
if (currentPage === "image-gen") {
return (
<div style={columnPageStyle}>
<ImageGenPage onNavigate={onNavigate} />
</div>
);
}
if (currentPage === "video") {
return (
<div style={columnPageStyle}>
<VideoPage />
</div>
);
}
if (currentPage === "automation") {
return (
<div style={columnPageStyle}>
@@ -215,38 +155,6 @@ export function AppPageContent({
);
}
if (currentPage === "terminal") {
return (
<div style={columnPageStyle}>
<TerminalWorkspace onNavigate={onNavigate} isActive />
</div>
);
}
if (currentPage === "sysinfo") {
return (
<FullscreenWrapper $isActive={true}>
<SysinfoView />
</FullscreenWrapper>
);
}
if (currentPage === "files") {
return (
<FullscreenWrapper $isActive={true}>
<FileBrowserView />
</FullscreenWrapper>
);
}
if (currentPage === "web") {
return (
<FullscreenWrapper $isActive={true}>
<WebView />
</FullscreenWrapper>
);
}
if (currentPage === "resources") {
return (
<div style={columnPageStyle}>
@@ -255,14 +163,6 @@ export function AppPageContent({
);
}
if (currentPage === "tools") {
return (
<PageWrapper $isActive={true}>
<ToolsPage onNavigate={onNavigate} />
</PageWrapper>
);
}
if (currentPage === "browser-runtime") {
const browserRuntimeParams = pageParams as BrowserRuntimePageParams;
-2
View File
@@ -19,8 +19,6 @@ React 组件层,包含 UI 组件和业务组件。
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
- `tools/` - 工具页面组件
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
- `ui/` - 通用 UI 组件(按钮、输入框等)
- `websocket/` - WebSocket 管理组件
+519 -98
View File
@@ -62,6 +62,7 @@ import {
getOrCreateDefaultProject,
type Project,
} from "@/lib/api/project";
import { executionRunGetGeneralWorkbenchState } from "@/lib/api/executionRun";
import {
cancelMediaTaskArtifact,
createImageGenerationTaskArtifact,
@@ -127,11 +128,8 @@ import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToo
import { useLimeSkills } from "./hooks/useLimeSkills";
import { useServiceSkills } from "./service-skills/useServiceSkills";
import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection";
import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview";
import type { HandleSendOptions } from "./hooks/handleSendTypes";
import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation";
import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntryPrompt";
import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions";
import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary";
import { mergeThreadItems } from "./utils/threadTimelineView";
import { openCanvasForReason } from "./workspace/canvasOpenPolicy";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
@@ -170,12 +168,19 @@ import { useWorkspaceVideoTaskActionRuntime } from "./workspace/useWorkspaceVide
import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore";
import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime";
import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions";
import {
buildGeneralWorkbenchSendBoundaryState,
buildGeneralWorkbenchResumePromptFromRunState,
buildInitialDispatchKey,
type GeneralWorkbenchEntryPromptState,
type GeneralWorkbenchSendBoundaryState,
type InitialDispatchPreviewSnapshot,
} from "./workspace/workspaceSendHelpers";
import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime";
import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime";
import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch";
import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime";
import { useWorkspaceSceneGateRuntime } from "./workspace/useWorkspaceSceneGateRuntime";
import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime";
import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime";
import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
@@ -204,6 +209,7 @@ import { resolveSiteSavedContentTargetFromRunResult } from "./utils/siteToolResu
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
import { createUnifiedMemory } from "@/lib/api/unifiedMemory";
import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt";
import {
createInitialSessionImageWorkbenchState,
type SessionImageWorkbenchState,
@@ -212,6 +218,7 @@ import {
SOCIAL_ARTICLE_SKILL_KEY,
GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendGeneralWorkbenchDocumentState,
isCanvasStateEmpty,
isCorruptedGeneralWorkbenchDocumentContent,
isSyncContentEmpty,
readPersistedGeneralWorkbenchDocument,
@@ -2483,41 +2490,224 @@ export function AgentChatWorkspace({
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
const consumedInitialPromptRef = useRef<string | null>(null);
const {
const consumedInitialPromptKey = consumedInitialPromptRef.current;
const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] =
useState<InitialDispatchPreviewSnapshot | null>(null);
const [generalWorkbenchEntryPrompt, setGeneralWorkbenchEntryPrompt] =
useState<GeneralWorkbenchEntryPromptState | null>(null);
const [generalWorkbenchEntryCheckPending, setGeneralWorkbenchEntryCheckPending] =
useState(false);
const hydratedPromptSignatureRef = useRef<string | null>(null);
const dismissedPromptSignatureRef = useRef<string | null>(null);
const initialDispatchKey = useMemo(
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
[initialUserImages, initialUserPrompt],
);
useEffect(() => {
if (!initialDispatchKey) {
return;
}
setBootstrapDispatchSnapshot({
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
});
}, [initialDispatchKey, initialUserImages, initialUserPrompt]);
useEffect(() => {
if (messages.length > 0) {
setBootstrapDispatchSnapshot(null);
return;
}
if (!initialDispatchKey && !isSending && queuedTurns.length === 0) {
setBootstrapDispatchSnapshot(null);
}
}, [initialDispatchKey, isSending, messages.length, queuedTurns.length]);
const activeBootstrapDispatch = useMemo(() => {
if (
initialDispatchKey &&
((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0)
) {
return {
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
};
}
return bootstrapDispatchSnapshot;
}, [
bootstrapDispatchSnapshot,
initialDispatchKey,
isBootstrapDispatchPending,
bootstrapDispatchPreviewMessages,
} = useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
messagesCount: messages.length,
isSending,
queuedTurnCount: queuedTurns.length,
consumedInitialPromptKey: consumedInitialPromptRef.current,
shouldUseCompactGeneralWorkbench,
});
const {
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId: contentId ?? undefined,
sessionId: sessionId ?? undefined,
isThemeWorkbench,
initialUserPrompt,
]);
const isBootstrapDispatchPending =
activeBootstrapDispatch !== null &&
consumedInitialPromptKey !== activeBootstrapDispatch.key;
const bootstrapDispatchPreview =
!shouldUseCompactGeneralWorkbench &&
activeBootstrapDispatch &&
messages.length === 0 &&
(isSending || queuedTurns.length > 0)
? activeBootstrapDispatch
: null;
useEffect(() => {
hydratedPromptSignatureRef.current = null;
dismissedPromptSignatureRef.current = null;
setGeneralWorkbenchEntryPrompt(null);
setGeneralWorkbenchEntryCheckPending(false);
}, [activeTheme, contentId, initialDispatchKey]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
!isThemeWorkbench ||
autoRunInitialPromptOnMount ||
!contentId ||
!initialDispatchKey ||
!pendingInitialPrompt ||
pendingInitialImages.length > 0 ||
messages.length > 0
) {
return;
}
if (
consumedInitialPromptKey === initialDispatchKey ||
hydratedPromptSignatureRef.current === initialDispatchKey
) {
return;
}
hydratedPromptSignatureRef.current = initialDispatchKey;
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || pendingInitialPrompt);
setGeneralWorkbenchEntryPrompt({
kind: "initial_prompt",
signature: initialDispatchKey,
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: pendingInitialPrompt,
});
}, [
autoRunInitialPromptOnMount,
shouldUseCompactGeneralWorkbench,
messagesCount: messages.length,
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserPrompt,
initialUserImages,
consumedInitialPromptKey: consumedInitialPromptRef.current,
onHydrateInitialPrompt: useCallback((prompt: string) => {
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || prompt);
}, []),
});
initialUserPrompt,
isThemeWorkbench,
messages.length,
setInput,
shouldUseCompactGeneralWorkbench,
]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
if (
!isThemeWorkbench ||
!contentId ||
!sessionId ||
messages.length > 0 ||
Boolean(initialDispatchKey)
) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
let disposed = false;
setGeneralWorkbenchEntryCheckPending(true);
void (async () => {
try {
const backendState = await executionRunGetGeneralWorkbenchState(
sessionId,
3,
).catch(() => null);
if (disposed) {
return;
}
const nextPrompt =
buildGeneralWorkbenchResumePromptFromRunState(backendState);
if (!nextPrompt) {
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "resume" ? null : current,
);
return;
}
if (dismissedPromptSignatureRef.current === nextPrompt.signature) {
return;
}
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "initial_prompt" ? current : nextPrompt,
);
} finally {
if (!disposed) {
setGeneralWorkbenchEntryCheckPending(false);
}
}
})();
return () => {
disposed = true;
};
}, [
contentId,
initialDispatchKey,
isThemeWorkbench,
messages.length,
sessionId,
shouldUseCompactGeneralWorkbench,
]);
const clearGeneralWorkbenchEntryPrompt = useCallback(() => {
setGeneralWorkbenchEntryPrompt(null);
}, []);
const dismissGeneralWorkbenchEntryPrompt = useCallback(
(options?: {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}) => {
setGeneralWorkbenchEntryPrompt((current) => {
if (!current) {
return current;
}
if (
current.kind === "initial_prompt" &&
options?.consumeInitialPrompt &&
initialDispatchKey
) {
options.onConsumeInitialPrompt?.();
} else {
dismissedPromptSignatureRef.current = current.signature;
}
return null;
});
},
[initialDispatchKey],
);
const consumeInitialPrompt = useCallback(
(dispatchKey: string | null) => {
consumedInitialPromptRef.current = dispatchKey;
@@ -2532,22 +2722,57 @@ export function AgentChatWorkspace({
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, []);
const {
resolveSendBoundary,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
} = useGeneralWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey: consumedInitialPromptRef.current,
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
onConsumeInitialPrompt: consumeInitialPrompt,
onResetConsumedInitialPrompt: resetConsumedInitialPrompt,
onClearEntryPrompt: clearGeneralWorkbenchEntryPrompt,
});
const resolveSendBoundary = useCallback(
({
sourceText,
sendOptions,
}: {
sourceText: string;
sendOptions?: HandleSendOptions;
}): GeneralWorkbenchSendBoundaryState =>
buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
sourceText,
sendOptions,
}),
[
contentId,
consumedInitialPromptKey,
initialDispatchKey,
initialUserImages,
isThemeWorkbench,
mappedTheme,
],
);
const finalizeAfterSendSuccess = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (
boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt &&
initialDispatchKey
) {
consumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissGeneralWorkbenchEntryPrompt) {
clearGeneralWorkbenchEntryPrompt();
}
},
[clearGeneralWorkbenchEntryPrompt, consumeInitialPrompt, initialDispatchKey],
);
const rollbackAfterSendFailure = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt) {
resetConsumedInitialPrompt();
}
},
[resetConsumedInitialPrompt],
);
const { resetRestoredSessionState } = useWorkspaceSessionRestore({
sessionId,
sessionMeta,
@@ -2698,7 +2923,7 @@ export function AgentChatWorkspace({
browserAssistAutoLaunch: browserAssistRequestAutoLaunch,
workspaceRequestMetadataBase: initialRequestMetadata,
messages,
bootstrapDispatchPreviewMessages,
bootstrapDispatchPreview,
sendMessage,
resolveSendBoundary,
finalizeAfterSendSuccess,
@@ -2752,31 +2977,51 @@ export function AgentChatWorkspace({
submitImageWorkbenchAgentCommandRef.current =
submitImageWorkbenchAgentCommand;
const {
handleContinueGeneralWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPromptActions({
generalWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt: async (promptToSend) => {
await handleSendRef.current(
[],
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
promptToSend,
);
},
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt: (dispatchKey) => {
consumedInitialPromptRef.current = dispatchKey;
onInitialUserPromptConsumed?.();
},
onInputChange: setInput,
onRequirePrompt: () => {
const handleContinueGeneralWorkbenchEntryPrompt = useCallback(async () => {
if (!generalWorkbenchEntryPrompt) {
return;
}
const promptToSend =
input.trim() || generalWorkbenchEntryPrompt.prompt.trim();
if (!promptToSend) {
toast.info("请先补充要继续执行的内容");
},
});
return;
}
await handleSendRef.current(
[],
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
promptToSend,
);
}, [
effectiveChatToolPreferences.thinking,
generalWorkbenchEntryPrompt,
handleSendRef,
input,
webSearchPreferenceRef,
]);
const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => {
if (!generalWorkbenchEntryPrompt) {
return;
}
dismissGeneralWorkbenchEntryPrompt({
consumeInitialPrompt:
generalWorkbenchEntryPrompt.kind === "initial_prompt",
onConsumeInitialPrompt: () => {
consumeInitialPrompt(initialDispatchKey);
},
});
setInput("");
}, [
consumeInitialPrompt,
dismissGeneralWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
initialDispatchKey,
setInput,
]);
const {
handleDocumentThinkingEnabledChange,
handleDocumentAutoContinueRun,
@@ -3446,35 +3691,211 @@ export function AgentChatWorkspace({
setFocusedTimelineItemId(normalizedItemId);
setTimelineFocusRequestKey((current) => current + 1);
}, []);
const triggerAIGuideRef = useRef(triggerAIGuide);
triggerAIGuideRef.current = triggerAIGuide;
useWorkspaceAutoGuideRuntime({
contentId,
sessionId,
initialUserPrompt,
initialUserImages,
initialAutoSendRequestMetadata,
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const canvasEmpty = isCanvasStateEmpty(canvasState);
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
const defaultGuidePrompt =
contentId && canvasEmpty && !isThemeWorkbench
? getDefaultGuidePromptByTheme(mappedTheme)
: undefined;
if (
!contentId ||
messages.length > 0 ||
!project ||
!systemPrompt ||
isSending ||
!canvasEmpty
) {
return;
}
if (!initialDispatchKey && generalWorkbenchEntryCheckPending) {
return;
}
if (initialDispatchKey) {
if (
isThemeWorkbench &&
pendingInitialImages.length === 0 &&
!autoRunInitialPromptOnMount
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 自动发送首条创作意图消息");
}
void (async () => {
const started = await handleSend(
pendingInitialImages,
effectiveChatToolPreferences.webSearch,
effectiveChatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}
if (hasTriggeredGuide.current) {
return;
}
if (generalWorkbenchEntryPrompt?.kind === "resume") {
return;
}
if (defaultGuidePrompt) {
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || defaultGuidePrompt);
return;
}
if (isThemeWorkbench) {
if (shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt) {
return;
}
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 工作区上下文:触发 AI 引导");
}
triggerAIGuideRef.current();
return;
}
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 自动触发 AI 创作引导");
}
triggerAIGuideRef.current();
}, [
autoRunInitialPromptOnMount,
initialDispatchKey,
messagesCount: messages.length,
projectReady: Boolean(project),
systemPromptReady: Boolean(systemPrompt),
isSending,
canvasState,
isThemeWorkbench,
mappedTheme,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt:
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
contentId,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
chatToolPreferences: effectiveChatToolPreferences,
setInput,
handleSend,
triggerAIGuide,
initialAutoSendRequestMetadata,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isSending,
isThemeWorkbench,
mappedTheme,
messages.length,
onInitialUserPromptConsumed,
hasTriggeredGuideRef: hasTriggeredGuide,
consumedInitialPromptRef,
});
project,
setInput,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
shouldUseCompactGeneralWorkbench,
systemPrompt,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
]);
useEffect(() => {
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
shouldUseCompactGeneralWorkbench ||
!initialDispatchKey ||
contentId ||
!sessionId ||
messages.length > 0 ||
isSending
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
void (async () => {
const started = await handleSend(
pendingInitialImages,
effectiveChatToolPreferences.webSearch,
effectiveChatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}, [
contentId,
handleSend,
initialAutoSendRequestMetadata,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isSending,
messages.length,
onInitialUserPromptConsumed,
sessionId,
shouldUseCompactGeneralWorkbench,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
]);
useEffect(() => {
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, [contentId]);
useWorkspaceImageWorkbenchEventRuntime({
canvasState,
@@ -1,119 +0,0 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GeneralWorkbenchEntryPromptAccessory } from "./GeneralWorkbenchEntryPromptAccessory";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface MountedHarness {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderAccessory(
props?: Partial<ComponentProps<typeof GeneralWorkbenchEntryPromptAccessory>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultPrompt: GeneralWorkbenchEntryPromptState = {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: "请先生成主稿",
};
const defaultProps: ComponentProps<
typeof GeneralWorkbenchEntryPromptAccessory
> = {
prompt: defaultPrompt,
onRestart: vi.fn(),
onContinue: vi.fn(async () => undefined),
};
act(() => {
root.render(
<GeneralWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
);
});
mountedRoots.push({ container, root });
return {
container,
props: {
...defaultProps,
...props,
},
};
}
describe("GeneralWorkbenchEntryPromptAccessory", () => {
it("应渲染提示文案与操作按钮", () => {
const { container } = renderAccessory();
expect(
container.querySelector('[data-testid="theme-workbench-entry-prompt"]')
?.textContent,
).toContain("已恢复待执行创作意图");
expect(container.textContent).toContain("进入页面后不会自动开始生成");
expect(container.textContent).toContain("继续生成");
expect(container.textContent).toContain("重新开始");
});
it("应分发继续与重启动作", async () => {
const onRestart = vi.fn();
const onContinue = vi.fn(async () => undefined);
const { container } = renderAccessory({
onRestart,
onContinue,
});
const restartButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-restart"]',
);
const continueButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-continue"]',
);
if (!restartButton || !continueButton) {
throw new Error("未找到通用工作台入口提示操作按钮");
}
act(() => {
restartButton.click();
});
expect(onRestart).toHaveBeenCalledTimes(1);
await act(async () => {
continueButton.click();
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
});
@@ -1,140 +0,0 @@
import { memo } from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const GeneralWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 48px));
max-width: min(420px, calc(100vw - 48px));
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(191, 219, 254, 0.92);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(239, 246, 255, 0.96) 100%
);
color: #0f172a;
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const GeneralWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const GeneralWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const GeneralWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const GeneralWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid
${({ $variant }) =>
$variant === "ghost"
? "rgba(191, 219, 254, 0.92)"
: "rgba(59, 130, 246, 0.94)"};
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(255, 255, 255, 0.92)"
: "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"};
color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.16s ease,
box-shadow 0.2s ease,
background 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46);
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(239, 246, 255, 0.98)"
: "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"};
}
`;
export const GeneralWorkbenchEntryPromptAccessory = memo(
function GeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: GeneralWorkbenchEntryPromptAccessoryProps) {
return (
<GeneralWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<GeneralWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<GeneralWorkbenchEntryPromptTitleWrap>
<GeneralWorkbenchEntryPromptTitle>
{prompt.title}
</GeneralWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptDescription>
{prompt.description}
</GeneralWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitleWrap>
</GeneralWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptActions>
<GeneralWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</GeneralWorkbenchEntryPromptButton>
<GeneralWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
void onContinue();
}}
>
{prompt.actionLabel}
</GeneralWorkbenchEntryPromptButton>
</GeneralWorkbenchEntryPromptActions>
</GeneralWorkbenchEntryPromptCard>
);
},
);
@@ -201,9 +201,11 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("任务工作台");
expect(container.textContent).toContain("聚焦当前任务、后续节点与相关版本。");
expect(container.textContent).toContain("任务视图");
expect(container.textContent).toContain("当前任务");
expect(container.textContent).toContain("当前焦点");
expect(container.textContent).toContain("撰写主稿");
expect(container.textContent).toContain("后续任务");
expect(container.textContent).toContain("已完成 1/4");
expect(container.textContent).toContain("已完成 1 项");
expect(container.textContent).toMatch(/相关分支|相关版本/);
const stepNodes = Array.from(
@@ -216,15 +218,15 @@ describe("GeneralWorkbenchSidebar", () => {
'[data-testid="workflow-sidebar-branch-section"]',
) as HTMLElement | null;
expect(stepNodes).toHaveLength(4);
expect(stepNodes).toHaveLength(2);
expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([
"active",
"error",
"pending",
"completed",
]);
expect(taskSection).toBeTruthy();
expect(branchSection).toBeTruthy();
expect(taskSection?.textContent).toContain("当前焦点");
expect(taskSection?.textContent).toContain("后续任务");
const taskSectionOrder =
taskSection && branchSection
? taskSection.compareDocumentPosition(branchSection) &
@@ -449,7 +451,7 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("1. 提炼内容主线");
expect(container.textContent).toContain("2. 生成封面提示词");
expect(container.textContent).toContain("允许工具");
expect(container.textContent).toContain("文件读取");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("图片生成");
expect(container.textContent).toContain("适用场景");
expect(container.textContent).toContain(
@@ -583,7 +585,7 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("文件读取");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("文件不存在");
expect(container.textContent).not.toContain("执行技能 社媒主稿与封面");
});
@@ -644,7 +646,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain("页面打开");
expect(container.textContent).toContain("任务输出");
expect(container.textContent).toContain("查看任务结果");
expect(container.textContent).toContain("用户确认");
expect(container.textContent).not.toContain("网络检索");
expect(container.textContent).not.toContain("执行命令");
@@ -766,6 +768,19 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.querySelector("button[aria-label='切换相关记录']")).toBeTruthy();
expect(container.querySelector("button[aria-label='删除分支']")).toBeNull();
expect(container.textContent).toContain("当前焦点落在");
const branchToggle = container.querySelector(
"button[aria-label='切换相关记录']",
) as HTMLButtonElement | null;
if (branchToggle) {
act(() => {
branchToggle.click();
});
}
const mergeButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "采纳",
);
@@ -796,6 +811,16 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("相关版本");
expect(container.textContent).toContain("新增版本");
expect(container.textContent).toContain("当前焦点落在");
const branchToggle = container.querySelector(
"button[aria-label='切换相关记录']",
) as HTMLButtonElement | null;
if (branchToggle) {
act(() => {
branchToggle.click();
});
}
const setMainButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "设为主稿",
@@ -820,6 +845,9 @@ describe("GeneralWorkbenchSidebar", () => {
workflowTab.click();
});
}
expect(container.textContent).toContain("最近一组:content_post_with_cover");
const activityToggle = container.querySelector(
"button[aria-label='切换活动日志']",
) as HTMLButtonElement | null;
@@ -829,9 +857,10 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("闸门:写作闸门");
expect(container.textContent).toContain("来源:skill");
expect(container.textContent).toContain("运行:run-abcd…");
expect(container.textContent).toContain("过程记录");
expect(container.textContent).toContain("写作闸门");
expect(container.textContent).toContain("技能");
expect(container.textContent).toContain("查看运行 run-abcd…");
});
it("活动日志应按运行维度分组展示步骤", () => {
@@ -873,6 +902,7 @@ describe("GeneralWorkbenchSidebar", () => {
workflowTab.click();
});
}
expect(container.textContent).toContain("最近一组:research_topic");
const activityToggle = container.querySelector(
"button[aria-label='切换活动日志']",
@@ -885,12 +915,17 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("research_topic");
expect(container.textContent).toContain("write_file");
expect(container.textContent).toContain("技能:research_topic");
expect(container.textContent).toContain("修改:content-posts/research.md");
expect(container.textContent).toContain('输入:{"topic":"AI"}');
expect(container.textContent).toContain("输出:已完成选题调研");
expect(container.textContent).toContain("技能");
expect(container.textContent).toContain("content-posts/research.md");
expect(container.textContent).toContain('{"topic":"AI"}');
expect(container.textContent).toContain("已完成选题调研");
expect(
container.querySelector(
'button[aria-label="定位活动产物路径-content-posts/research.md"]',
),
).toBeNull();
const runButtons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent === "运行:rungrp01",
(button) => button.textContent === "查看运行 rungrp01",
);
expect(runButtons.length).toBe(1);
});
@@ -917,7 +952,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
const runButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("运行:run-abcd…"),
(button) => button.textContent?.includes("查看运行 run-abcd…"),
);
expect(runButton).toBeTruthy();
if (runButton) {
@@ -966,12 +1001,12 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("运行详情");
expect(container.textContent).toContain("ID:run-detail-1");
expect(container.textContent).toContain("状态:处理中");
expect(container.textContent).toContain("当前查看运行");
expect(container.textContent).toContain("运行ID:run-detail-1");
expect(container.textContent).toContain("处理中");
});
it("运行详情应支持复制运行ID与元数据", async () => {
it("运行详情应支持复制运行ID与原始记录", async () => {
const { container } = renderSidebar({
activeRunDetail: {
id: "run-copy-1",
@@ -1012,7 +1047,7 @@ describe("GeneralWorkbenchSidebar", () => {
"button[aria-label='复制运行ID']",
) as HTMLButtonElement | null;
const copyMetadataButton = container.querySelector(
"button[aria-label='复制运行元数据']",
"button[aria-label='复制原始记录']",
) as HTMLButtonElement | null;
expect(copyIdButton).toBeTruthy();
@@ -1084,12 +1119,10 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain(
"工作流:social_content_pipeline_v1",
"工作流 social_content_pipeline_v1",
);
expect(container.textContent).toContain("执行ID:exec-artifact-1");
expect(container.textContent).toContain("版本ID:ver-artifact-1");
expect(container.textContent).toContain(
"阶段:选题闸门 → 写作闸门 → 发布闸门",
"选题闸门 → 写作闸门 → 发布闸门",
);
expect(container.textContent).toContain("content-posts/demo.md");
expect(container.textContent).toContain(
@@ -1161,8 +1194,6 @@ describe("GeneralWorkbenchSidebar", () => {
timeLabel: "11:20",
applyTarget: "主稿内容",
contextIds: ["material:1"],
runId: "run-artifact-group-1",
executionId: "exec-artifact-group-1",
sessionId: "session-group",
artifactPaths: ["content-posts/group.md"],
gateKey: "write_mode",
@@ -1263,7 +1294,9 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("任务提交");
expect(container.textContent).toContain("任务记录");
expect(container.textContent).toContain("最近一次:排版优化");
expect(container.textContent).toContain("共 3 条任务记录,按 2 类归档。");
const toggleCreationTasksButton = container.querySelector(
"button[aria-label='切换任务提交记录']",
) as HTMLButtonElement | null;
@@ -1275,7 +1308,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain("配图生成");
expect(container.textContent).toContain("排版优化");
expect(container.textContent).toContain("本组 2 条");
expect(container.textContent).toContain("2 条记录");
const copyAbsolutePathButton = container.querySelector(
'button[aria-label="复制任务文件绝对路径-task-image-1"]',
File diff suppressed because it is too large Load Diff
@@ -582,6 +582,50 @@ describe("HarnessStatusPanel", () => {
known_gaps: [
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。",
],
observability_summary: {
schema_version: "v1",
known_gaps: [
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。",
],
signal_coverage: [
{
signal: "correlation",
status: "exported",
source: "runtime thread identity",
detail: "已导出关联键。",
},
{
signal: "artifactValidator",
status: "exported",
source: "artifact_document_validator",
detail: "已导出 Artifact 校验结果。",
},
],
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 2,
repaired_count: 1,
fallback_used_count: 0,
outcome: "blocking_failure",
},
browser_verification: {
record_count: 2,
success_count: 1,
failure_count: 1,
unknown_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 2 条未恢复 issues。",
"浏览器验证存在 1 条失败线索。",
],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
},
artifacts: [
{
kind: "summary",
@@ -620,6 +664,10 @@ describe("HarnessStatusPanel", () => {
"session-evidence-1",
);
expect(document.body.textContent).toContain("问题证据包");
expect(document.body.textContent).toContain("验证结果");
expect(document.body.textContent).toContain("阻塞失败");
expect(document.body.textContent).toContain("验证失败焦点");
expect(document.body.textContent).toContain("已恢复结果");
expect(document.body.textContent).toContain("当前已知缺口");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-evidence-1/evidence/summary.md",
@@ -875,6 +923,22 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 2,
repaired_count: 1,
fallback_used_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 2 条未恢复 issues。",
],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
decision: {
decision_status: "pending_review",
decision_summary: "",
@@ -883,8 +947,14 @@ describe("HarnessStatusPanel", () => {
risk_tags: [],
human_reviewer: "",
reviewed_at: undefined,
followup_actions: [],
regression_requirements: [],
followup_actions: [
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。",
"复查 Artifact 校验相关产物,确认 issues / repaired / fallback 状态与最终结论一致。",
],
regression_requirements: [
"按 replay case 复现问题并确认修复后行为与预期一致。",
"重新导出 evidence pack,确认 Artifact 校验摘要已更新。",
],
notes: "",
},
decision_status_options: [
@@ -967,6 +1037,17 @@ describe("HarnessStatusPanel", () => {
expect(document.body.textContent).toContain(
"确认最终决策由人工审核者填写。",
);
expect(document.body.textContent).toContain("验证结果");
expect(document.body.textContent).toContain("阻塞失败");
expect(document.body.textContent).toContain(
"Artifact 校验存在 2 条未恢复 issues。",
);
expect(document.body.textContent).toContain(
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点",
);
expect(document.body.textContent).toContain(
"重新导出 evidence pack,确认 Artifact 校验摘要已更新。",
);
expect(document.body.textContent).toContain("aster-rust");
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个人工审核文件");
});
@@ -996,6 +1077,20 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 1,
repaired_count: 0,
fallback_used_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 1 条未恢复 issue。",
],
focus_verification_recovered_outcomes: [],
},
decision: {
decision_status: "pending_review",
decision_summary: "",
@@ -1064,6 +1159,20 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 0,
repaired_count: 1,
fallback_used_count: 0,
outcome: "recovered",
},
focus_verification_failure_outcomes: [],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
decision: {
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
@@ -1131,6 +1240,16 @@ describe("HarnessStatusPanel", () => {
await Promise.resolve();
});
const reviewDialog = document.body.querySelector(
'[role="dialog"]',
) as HTMLDivElement | null;
expect(reviewDialog?.textContent).toContain("验证结果");
expect(reviewDialog?.textContent).toContain("阻塞失败");
expect(reviewDialog?.textContent).toContain(
"Artifact 校验存在 1 条未恢复 issue。",
);
const statusSelect = document.body.querySelector(
'select[aria-label="决策状态"]',
) as HTMLSelectElement | null;
@@ -112,6 +112,7 @@ import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspac
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection";
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
interface HarnessEnvironmentSummary {
@@ -2149,10 +2150,7 @@ export function HarnessStatusPanel({
) {
sections.push({ key: "plan", label: "规划状态" });
}
if (
realTeamSummary.total > 0 ||
harnessState.delegatedTasks.length > 0
) {
if (realTeamSummary.total > 0 || harnessState.delegatedTasks.length > 0) {
sections.push({ key: "delegation", label: "子任务" });
}
if (harnessState.latestContextTrace.length > 0) {
@@ -2787,7 +2785,8 @@ export function HarnessStatusPanel({
>
{runtimeTaskPresentation.stepStatus === "error" ? (
<AlertCircle className="h-4 w-4" />
) : runtimeTaskPresentation.stepStatus === "skipped" ? (
) : runtimeTaskPresentation.stepStatus ===
"skipped" ? (
<Clock3 className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -2905,7 +2904,9 @@ export function HarnessStatusPanel({
</div>
<Badge
variant={
isCurrentCheckpoint ? "secondary" : "outline"
isCurrentCheckpoint
? "secondary"
: "outline"
}
>
{isCurrentCheckpoint ? "当前" : "已记录"}
@@ -3198,30 +3199,49 @@ export function HarnessStatusPanel({
{evidencePack ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<InventoryStatCard
title="线程状态"
value={formatHandoffStatusLabel(
evidencePack.thread_status,
)}
hint={`最近导出 ${formatIsoDateTime(evidencePack.exported_at)}`}
/>
<InventoryStatCard
title="时间线"
value={`${evidencePack.turn_count} / ${evidencePack.item_count}`}
hint="turns / items"
/>
<InventoryStatCard
title="阻塞线索"
value={`${evidencePack.pending_request_count} / ${evidencePack.queued_turn_count}`}
hint="pending request / queued turn"
/>
<InventoryStatCard
title="已知缺口"
value={`${evidencePack.known_gaps.length}`}
hint={`最近产物 ${evidencePack.recent_artifact_count} 个`}
/>
</div>
{(() => {
const verificationSummary =
evidencePack.observability_summary
?.verification_summary;
const failureFocus =
verificationSummary?.focus_verification_failure_outcomes ??
[];
const exportedSignals =
evidencePack.observability_summary?.signal_coverage.filter(
(entry) => entry.status === "exported",
).length ?? 0;
return (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<InventoryStatCard
title="线程状态"
value={formatHandoffStatusLabel(
evidencePack.thread_status,
)}
hint={`最近导出 ${formatIsoDateTime(evidencePack.exported_at)}`}
/>
<InventoryStatCard
title="时间线"
value={`${evidencePack.turn_count} / ${evidencePack.item_count}`}
hint="turns / items"
/>
<InventoryStatCard
title="阻塞线索"
value={`${evidencePack.pending_request_count} / ${evidencePack.queued_turn_count}`}
hint="pending request / queued turn"
/>
<InventoryStatCard
title="已知缺口"
value={`${evidencePack.known_gaps.length}`}
hint={
verificationSummary
? `验证焦点 ${failureFocus.length} · 已导出信号 ${exportedSignals}`
: `最近产物 ${evidencePack.recent_artifact_count} 个`
}
/>
</div>
);
})()}
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -3246,6 +3266,16 @@ export function HarnessStatusPanel({
</div>
</div>
{evidencePack.observability_summary
?.verification_summary ? (
<HarnessVerificationSummarySection
summary={
evidencePack.observability_summary
.verification_summary
}
/>
) : null}
{evidencePack.known_gaps.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50/80 p-3">
<div className="text-sm font-medium text-amber-900">
@@ -3644,8 +3674,8 @@ export function HarnessStatusPanel({
<span>外部分析交接</span>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
把 handoff / evidence / replay 主链重新包装成外部
AI 可直接消费的分析交接;复制后可直接粘贴给 AI,
把 handoff / evidence / replay 主链重新包装成外部 AI
可直接消费的分析交接;复制后可直接粘贴给 AI,
不需要你再手写补充 prompt。
</div>
</div>
@@ -3907,8 +3937,9 @@ export function HarnessStatusPanel({
<span>人工审核记录</span>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
把外部 AI 的分析结论回挂为
`review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。
把外部 AI 的分析结论回挂为 `review-decision.md/json`
模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是
Lime 自动闭环。
</div>
</div>
<div className="flex flex-wrap gap-2">
@@ -4034,6 +4065,14 @@ export function HarnessStatusPanel({
</div>
</div>
{reviewDecisionTemplate.verification_summary ? (
<HarnessVerificationSummarySection
summary={
reviewDecisionTemplate.verification_summary
}
/>
) : null}
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">
@@ -0,0 +1,63 @@
import type { AgentRuntimeEvidenceVerificationSummary } from "@/lib/api/agentRuntime";
import { buildHarnessEvidenceVerificationCardPresentations } from "@/lib/agentRuntime/harnessVerificationPresentation";
import { Badge } from "@/components/ui/badge";
import { ShieldAlert } from "lucide-react";
export function HarnessVerificationSummarySection({
summary,
}: {
summary: AgentRuntimeEvidenceVerificationSummary;
}) {
return (
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldAlert className="h-4 w-4 text-emerald-600" />
<span>验证结果</span>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-3">
{buildHarnessEvidenceVerificationCardPresentations(summary).map(
(card) => (
<div
key={card.key}
className="rounded-lg border border-border/70 bg-muted/20 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-foreground">
{card.title}
</span>
<Badge variant={card.badge.variant}>{card.badge.label}</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{card.description}
</div>
</div>
),
)}
</div>
{summary.focus_verification_failure_outcomes.length > 0 ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50/80 p-3">
<div className="text-sm font-medium text-amber-900">验证失败焦点</div>
<div className="mt-2 space-y-1 text-xs text-amber-800">
{summary.focus_verification_failure_outcomes.map((outcome, index) => (
<div key={`${outcome}-${index}`}>{outcome}</div>
))}
</div>
</div>
) : null}
{summary.focus_verification_recovered_outcomes.length > 0 ? (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50/80 p-3">
<div className="text-sm font-medium text-emerald-900">已恢复结果</div>
<div className="mt-2 space-y-1 text-xs text-emerald-800">
{summary.focus_verification_recovered_outcomes.map(
(outcome, index) => (
<div key={`${outcome}-${index}`}>{outcome}</div>
),
)}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,278 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InlineToolProcessStep } from "./InlineToolProcessStep";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
vi.mock("@tauri-apps/plugin-shell", () => ({
open: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./MarkdownRenderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}));
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
interface RenderOptions {
isMessageStreaming?: boolean;
onOpenSavedSiteContent?: (target: unknown) => void;
}
const mountedRoots: RenderResult[] = [];
function renderTool(
toolCall: ToolCallState,
options?: RenderOptions,
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<InlineToolProcessStep
toolCall={toolCall}
isMessageStreaming={options?.isMessageStreaming}
onOpenSavedSiteContent={options?.onOpenSavedSiteContent}
/>,
);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
describe("InlineToolProcessStep", () => {
it("ToolSearch 在流式阶段应保持结构化预览,不自动展开原始 JSON", () => {
const { container } = renderTool(
{
id: "tool-search-streaming-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [{ name: "Read" }, { name: "Write" }],
}),
},
startTime: new Date("2026-04-13T10:00:00.000Z"),
endTime: new Date("2026-04-13T10:00:01.000Z"),
},
{ isMessageStreaming: true },
);
expect(container.textContent).toContain("找到工具 2 个");
expect(container.textContent).not.toContain("查询:");
expect(container.textContent).not.toContain("select:Read,Write");
expect(
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
).toBeNull();
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
expect(container.textContent).not.toContain('"tools"');
});
it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => {
const { container } = renderTool({
id: "tool-search-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [
{
name: "Read",
source: "native_registry",
description: "Read a file from disk",
always_visible: true,
},
{
name: "Write",
source: "native_registry",
description: "Write content to a file",
always_visible: true,
},
],
}),
},
startTime: new Date("2026-04-13T10:10:00.000Z"),
endTime: new Date("2026-04-13T10:10:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="展开过程详情"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("保存文件");
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
expect(container.textContent).not.toContain('"always_visible":true');
expect(container.textContent).not.toContain("Read a file from disk");
expect(container.textContent).not.toContain("查询:select:Read,Write");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("默认可见");
});
it("WebSearch 展开后应优先展示搜索结果列表", () => {
const { container } = renderTool({
id: "tool-search-web-1",
name: "WebSearch",
arguments: JSON.stringify({ query: "AI Agent 最新热点" }),
status: "completed",
result: {
success: true,
output: [
"Xinhua world news summary at 0030 GMT, March 13",
"https://example.com/xinhua",
"全球要闻摘要,覆盖国际局势与市场动态。",
"",
"Friday morning news: March 13, 2026 | WORLD - wng.org",
"https://example.com/wng",
"补充国际动态与区域冲突更新。",
].join("\n"),
},
startTime: new Date("2026-04-13T10:20:00.000Z"),
endTime: new Date("2026-04-13T10:20:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="展开过程详情"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(
document.body.querySelector(
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
),
).not.toBeNull();
expect(container.textContent).toContain(
"Friday morning news: March 13, 2026 | WORLD - wng.org",
);
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
});
it("完成态过程卡不应重复展示执行完成与原始工具名", () => {
const { container } = renderTool({
id: "tool-inline-ask-user-1",
name: "AskUserQuestion",
arguments: JSON.stringify({ question: "需要继续吗?" }),
status: "completed",
result: {
success: true,
output: "用户已确认继续。",
},
startTime: new Date("2026-04-13T10:30:00.000Z"),
endTime: new Date("2026-04-13T10:30:01.000Z"),
});
expect(container.textContent).toContain("已收集 需要继续吗?");
expect(container.textContent).not.toContain("执行完成");
expect(container.textContent).not.toContain("Ask User Question");
});
it("站点导出按钮副文案应优先展示短文件名", () => {
const onOpenSavedSiteContent = vi.fn();
const { container } = renderTool(
{
id: "tool-inline-site-run-1",
name: "lime_site_run",
arguments: JSON.stringify({
adapter_name: "x/article",
args: { url: "https://x.com/google/article/1" },
}),
status: "completed",
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-inline-site-1",
project_id: "project-inline-site-1",
title: "Google Cloud 周报",
markdown_relative_path:
"exports/social-article/google-cloud/index.md",
image_count: 3,
},
saved_by: "context_project",
},
},
startTime: new Date("2026-04-13T10:40:00.000Z"),
endTime: new Date("2026-04-13T10:40:01.000Z"),
},
{ onOpenSavedSiteContent },
);
expect(container.textContent).toContain("已保存到当前项目:Google Cloud 周报");
expect(container.textContent).toContain("已导出 Markdown 文稿");
expect(container.textContent).toContain("附带图片 3 张");
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("在下方预览导出 Markdown"),
) as HTMLButtonElement | undefined;
expect(openButton).toBeDefined();
expect(openButton?.textContent).toContain("index.md");
expect(openButton?.textContent).not.toContain(
"exports/social-article/google-cloud/index.md",
);
act(() => {
openButton?.click();
});
expect(onOpenSavedSiteContent).toHaveBeenCalledWith({
projectId: "project-inline-site-1",
contentId: "content-inline-site-1",
title: "Google Cloud 周报",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/social-article/google-cloud/index.md",
},
});
});
});
@@ -1,7 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
import {
extractLimeToolMetadataBlock,
normalizeToolResultImages,
@@ -11,18 +14,26 @@ import type { SiteSavedContentTarget } from "../types";
import {
buildToolHeadline,
getToolDisplayInfo,
humanizeToolName,
normalizeToolNameKey,
parseToolCallArguments,
resolveToolFilePath,
resolveToolPrimarySubject,
} from "../utils/toolDisplayInfo";
import {
isUnifiedWebSearchToolName,
resolveSearchResultPreviewItemsFromText,
} from "../utils/searchResultPreview";
import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteProjectTargetLabel,
resolveSiteSavedContentTargetDisplayName,
resolveSiteSavedContentTargetRelativePath,
resolveSiteProjectSourceLabel,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
import {
normalizeToolSearchResultSummary,
resolveUserFacingToolSearchItemLabel,
} from "../utils/toolSearchResultSummary";
interface InlineToolProcessStepProps {
toolCall: ToolCallState;
@@ -68,6 +79,34 @@ function summarizeResultText(value: string): string | null {
return `${singleLine.slice(0, 180).trim()}...`;
}
function summarizeToolSearchPreview(value: ReturnType<
typeof normalizeToolSearchResultSummary
>): string | null {
if (!value) {
return null;
}
const toolNames = value.tools
.slice(0, 2)
.map((item) => resolveUserFacingToolSearchItemLabel(item.name))
.filter(Boolean);
const prefix = `找到工具 ${value.count} 个`;
if (toolNames.length === 0) {
return prefix;
}
return `${prefix} · ${toolNames.join(" · ")}`;
}
function summarizeSearchResultPreview(resultCount: number): string | null {
if (resultCount <= 0) {
return null;
}
return `找到 ${resultCount} 条搜索结果`;
}
function buildSiteNoticeLines(toolCall: ToolCallState): string[] {
const summary = normalizeSiteToolResultSummary(toolCall.result?.metadata);
if (!summary) {
@@ -77,52 +116,35 @@ function buildSiteNoticeLines(toolCall: ToolCallState): string[] {
const lines: string[] = [];
const savedProjectId =
summary.savedProjectId || summary.savedContent?.projectId || "";
const savedSourceLabel = resolveSiteProjectSourceLabel(summary.savedBy || "");
const savedProjectTarget = resolveSiteProjectTargetLabel({
source: summary.savedBy,
projectId: savedProjectId || undefined,
});
if (summary.savedContent?.title) {
let line = `已保存:${summary.savedContent.title}`;
if (savedProjectId) {
line += ` · 项目 ${savedProjectId}`;
}
if (savedSourceLabel) {
line += ` · ${savedSourceLabel}`;
}
lines.push(line);
lines.push(`已保存到${savedProjectTarget}:${summary.savedContent.title}`);
}
if (summary.savedContent?.markdownRelativePath) {
lines.push(`Markdown:${summary.savedContent.markdownRelativePath}`);
lines.push("已导出 Markdown 文稿");
}
if (typeof summary.savedContent?.imageCount === "number") {
const imageDir = summary.savedContent.imagesRelativeDir;
lines.push(
`图片:${summary.savedContent.imageCount} 张${
imageDir ? ` · ${imageDir}` : ""
}`,
);
lines.push(`附带图片 ${summary.savedContent.imageCount} 张`);
}
if (summary.saveSkippedProjectId) {
const skippedSourceLabel = resolveSiteProjectSourceLabel(
summary.saveSkippedBy || "",
);
let line = `未写入项目 ${summary.saveSkippedProjectId}`;
if (skippedSourceLabel) {
line += ` · ${skippedSourceLabel}`;
}
lines.push(line);
const skippedProjectTarget = resolveSiteProjectTargetLabel({
source: summary.saveSkippedBy,
projectId: summary.saveSkippedProjectId,
});
lines.push(`未保存到${skippedProjectTarget}`);
}
if (summary.saveErrorMessage) {
lines.push(`自动保存失败:${summary.saveErrorMessage}`);
}
const adapterSourceLabel = resolveSiteAdapterSourceLabel(summary);
if (adapterSourceLabel) {
lines.push(`脚本来源:${adapterSourceLabel}`);
}
return lines;
}
@@ -167,15 +189,6 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
}),
[subject, toolCall.name, toolDisplay],
);
const rawToolNameLabel = useMemo(() => {
if (
toolDisplay.family === "generic" &&
toolDisplay.label !== humanizeToolName(toolCall.name)
) {
return humanizeToolName(toolCall.name);
}
return null;
}, [toolCall.name, toolDisplay.family, toolDisplay.label]);
const resultText = useMemo(() => {
const rawText = toolCall.result?.error || toolCall.result?.output || "";
return extractLimeToolMetadataBlock(rawText).text.trim();
@@ -188,12 +201,38 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
() => normalizeToolResultImages(toolCall.result?.images, resultText) || [],
[resultText, toolCall.result?.images],
);
const isToolSearch = useMemo(
() => normalizeToolNameKey(toolCall.name) === "toolsearch",
[toolCall.name],
);
const toolSearchSummary = useMemo(
() => (isToolSearch ? normalizeToolSearchResultSummary(resultText) : null),
[isToolSearch, resultText],
);
const searchResultItems = useMemo(() => {
if (!isUnifiedWebSearchToolName(toolCall.name)) {
return [];
}
return resolveSearchResultPreviewItemsFromText(resultText);
}, [resultText, toolCall.name]);
const structuredResultPreview = useMemo(() => {
if (toolSearchSummary) {
return summarizeToolSearchPreview(toolSearchSummary);
}
if (searchResultItems.length > 0) {
return summarizeSearchResultPreview(searchResultItems.length);
}
return resultPreview;
}, [resultPreview, searchResultItems.length, toolSearchSummary]);
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const savedSiteContentRelativePath = useMemo(
() => resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget),
const savedSiteContentDisplayName = useMemo(
() =>
resolveSiteSavedContentTargetDisplayName(savedSiteContentTarget) ||
resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget),
[savedSiteContentTarget],
);
const siteNoticeLines = useMemo(
@@ -209,31 +248,46 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
const hasDetails =
Boolean(resultText) ||
resultImages.length > 0 ||
searchResultItems.length > 0 ||
Boolean(toolSearchSummary) ||
siteNoticeLines.length > 0 ||
Boolean(savedSiteContentTarget) ||
Boolean(skillTitle && skillTitle !== subject);
const handleOpenExternalUrl = useCallback(async (url: string) => {
try {
await openExternal(url);
} catch {
if (typeof window !== "undefined" && typeof window.open === "function") {
window.open(url, "_blank");
}
}
}, []);
useEffect(() => {
if (
toolCall.status === "running" ||
isMessageStreaming ||
siteNoticeLines.length > 0
) {
if (toolCall.status === "running" || siteNoticeLines.length > 0) {
setExpanded(true);
return;
}
if (isMessageStreaming && !toolSearchSummary) {
setExpanded(true);
}
}, [isMessageStreaming, siteNoticeLines.length, toolCall.status]);
const statusLabel =
toolCall.status === "running"
? "执行中"
: toolCall.status === "failed"
? "执行失败"
: "执行完成";
}, [
isMessageStreaming,
siteNoticeLines.length,
toolCall.status,
toolSearchSummary,
]);
const detailBadges = [
isPreload ? "系统预执行" : null,
skillTitle && skillTitle !== subject ? `技能:${skillTitle}` : null,
statusLabel,
toolCall.status === "running"
? "执行中"
: toolCall.status === "failed"
? "执行失败"
: null,
].filter((value): value is string => Boolean(value));
return (
@@ -287,14 +341,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
<span key={badge}>{badge}</span>
))}
</div>
{rawToolNameLabel ? (
<div className="mt-0.5 truncate text-[11px] leading-5 text-slate-400">
{rawToolNameLabel}
</div>
) : null}
{!expanded && resultPreview ? (
{!expanded && structuredResultPreview ? (
<div className="mt-1 text-xs leading-5 text-slate-600">
{resultPreview}
{structuredResultPreview}
</div>
) : null}
</button>
@@ -361,9 +410,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
? "在下方预览导出 Markdown"
: "打开已保存内容"}
</span>
{savedSiteContentRelativePath ? (
{savedSiteContentDisplayName ? (
<span className="block truncate text-[11px] leading-5 text-emerald-700/80">
{savedSiteContentRelativePath}
{savedSiteContentDisplayName}
</span>
) : null}
</span>
@@ -372,7 +421,24 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
</div>
) : null}
{resultText ? (
{toolSearchSummary ? (
<ToolSearchSummaryPanel
summary={toolSearchSummary}
testId="inline-tool-process-tool-search-result"
/>
) : null}
{!toolSearchSummary && searchResultItems.length > 0 ? (
<SearchResultPreviewList
items={searchResultItems}
onOpenUrl={handleOpenExternalUrl}
popoverSide="bottom"
popoverAlign="start"
className="max-w-2xl"
/>
) : null}
{!toolSearchSummary && searchResultItems.length === 0 && resultText ? (
<div className="text-sm leading-6 text-slate-700">
<MarkdownRenderer content={resultText} />
</div>
@@ -14,7 +14,6 @@ import type { BuiltinInputCommand } from "../../../skill-selection/builtinComman
import { TeamSelector } from "./TeamSelector";
import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarPromptCacheNotice } from "./InputbarPromptCacheNotice";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect";
@@ -159,14 +158,10 @@ export const InputbarComposerSection: React.FC<
currentPendingImages.length > 0 &&
Boolean(resolvedProviderType?.trim()) &&
Boolean(resolvedModel?.trim());
const shouldShowPromptCacheNotice = Boolean(resolvedProviderType?.trim());
const resolvedTopExtra =
topExtra || shouldShowPromptCacheNotice || shouldShowVisionNotice ? (
topExtra || shouldShowVisionNotice ? (
<>
{topExtra}
{shouldShowPromptCacheNotice && resolvedProviderType ? (
<InputbarPromptCacheNotice providerType={resolvedProviderType} />
) : null}
{shouldShowVisionNotice && resolvedProviderType && resolvedModel ? (
<InputbarVisionCapabilityNotice
providerType={resolvedProviderType}
@@ -139,12 +139,6 @@ vi.mock("../ChatModelSelector", () => ({
ChatModelSelector: () => <div data-testid="model-selector" />,
}));
vi.mock("./components/InputbarPromptCacheNotice", () => ({
InputbarPromptCacheNotice: (props: { providerType: string }) => (
<div data-testid="inputbar-prompt-cache-warning">{props.providerType}</div>
),
}));
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(async () => []),
}));
@@ -828,7 +822,7 @@ describe("Inputbar", () => {
expect(latestCall.leftExtra).toBeDefined();
});
it("已选择 Provider 时应将 prompt cache 提示组件挂到输入区顶部", async () => {
it("已选择 Provider 时不应再将 prompt cache 提示组件常驻挂到输入区顶部", async () => {
const { container } = renderInputbar({
providerType: "custom-provider-id",
setProviderType: vi.fn(),
@@ -843,8 +837,7 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'),
).toBeTruthy();
expect(container.textContent).toContain("custom-provider-id");
).toBeNull();
});
it("任务中心工作区应使用继续推进型输入提示", async () => {
@@ -367,6 +367,57 @@ describe("MessageList", () => {
);
});
it("anthropic-compatible 自定义 Provider 存在缓存写入时不应再透传自动缓存提示", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-cache-write",
role: "assistant",
content: "本轮已完成。",
timestamp: now,
usage: {
input_tokens: 1_500,
output_tokens: 500,
cached_input_tokens: 0,
cache_creation_input_tokens: 256,
},
},
];
mockUseConfiguredProviders.mockImplementation(() => ({
providers: [
{
key: "custom-provider-id",
label: "Kimi Anthropic",
registryId: "custom-provider-id",
type: "anthropic-compatible",
providerId: "custom-provider-id",
},
],
loading: false,
}));
mockFindConfiguredProviderBySelection.mockImplementation(
(
providers: MockConfiguredProvider[],
selection?: string | null,
): MockConfiguredProvider | null =>
Array.isArray(providers)
? (providers.find((provider) => provider.key === selection) ?? null)
: null,
);
const container = render(messages, {
providerType: "custom-provider-id",
});
expect(container.textContent).not.toContain("未声明自动缓存");
expect(mockTokenUsageDisplay).toHaveBeenCalledWith(
expect.objectContaining({
promptCacheNotice: undefined,
}),
);
});
it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => {
const now = new Date();
const messages: Message[] = [
@@ -163,6 +163,16 @@ interface MessageListProps {
providerType?: string;
}
function resolvePromptCacheActivity(usage?: {
cached_input_tokens?: number;
cache_creation_input_tokens?: number;
}): number {
return (
Math.max(0, usage?.cached_input_tokens ?? 0) +
Math.max(0, usage?.cache_creation_input_tokens ?? 0)
);
}
function isDeferredTimelineItem(item: AgentThreadItem): boolean {
return item.type === "file_artifact" || item.type === "turn_summary";
}
@@ -426,7 +436,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
msg.role === "assistant" &&
!msg.isThinking &&
msg.usage &&
(msg.usage.cached_input_tokens ?? 0) <= 0,
resolvePromptCacheActivity(msg.usage) <= 0,
),
),
[messages, providerType],
@@ -886,7 +896,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
<TokenUsageDisplay
usage={msg.usage}
promptCacheNotice={
(msg.usage.cached_input_tokens ?? 0) <= 0
resolvePromptCacheActivity(msg.usage) <= 0
? promptCacheNotice
: undefined
}
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection";
interface RuntimeReviewDecisionDialogProps {
open: boolean;
@@ -194,6 +195,12 @@ export function RuntimeReviewDecisionDialog({
{template && formState ? (
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-5">
{template.verification_summary ? (
<HarnessVerificationSummarySection
summary={template.verification_summary}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
@@ -168,6 +168,40 @@ function createSavedSiteMetadata(): AgentToolResultMetadata {
}
describe("StreamingRenderer", () => {
it("交错内容应隐藏紧邻工具调用的调度自述", () => {
const { container } = renderHarness({
content: "",
contentParts: [
{
type: "text",
text: "ToolSearch 只返回了元数据,让我直接调用 WebSearch 进行多组检索。",
},
{
type: "tool_use",
toolCall: {
id: "tool-narration-hidden",
name: "WebSearch",
arguments: JSON.stringify({ query: "latest openai api" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-01T10:00:00.000Z"),
endTime: new Date("2026-04-01T10:00:01.000Z"),
},
},
{
type: "text",
text: "已经整理出 3 个可信来源。",
},
],
});
expect(container.textContent).not.toContain("只返回了元数据");
expect(
container.querySelector('[data-testid="inline-tool-process-step"]'),
).toBeTruthy();
expect(container.textContent).toContain("已经整理出 3 个可信来源。");
});
it("应过滤 assistant 正文中的工具协议残留", () => {
const { container } = renderHarness({
content:
@@ -76,7 +76,7 @@ describe("TokenUsageDisplay", () => {
expect(container.textContent).toContain("1.2M tokens");
});
it("存在缓存命中时应附带展示 cached token", () => {
it("存在缓存命中时应在总 token 旁边展示缓存 token", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -95,7 +95,82 @@ describe("TokenUsageDisplay", () => {
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("命中缓存 8.0K");
expect(container.textContent).toContain("缓存 8.0K");
});
it("存在缓存写入时应展示缓存总量与读写拆分", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 8_000,
cache_creation_input_tokens: 1_200,
}}
/>,
);
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("缓存 9.2K");
expect(container.textContent).toContain("读 8.0K / 写 1.2K");
});
it("存在缓存字段但未命中时也应在总 token 旁边展示缓存 token", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 0,
}}
/>,
);
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("缓存 0");
});
it("仅存在缓存写入时应抑制黄色提示并展示缓存写入", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 1_200,
}}
promptCacheNotice={{
label: "未声明自动缓存",
detail:
"当前 Provider 未声明支持自动 Prompt Cache;如需复用前缀,请使用显式 cache_control 标记。",
}}
/>,
);
});
expect(container.textContent).toContain("缓存 1.2K");
expect(container.textContent).toContain("读 0 / 写 1.2K");
expect(container.textContent).not.toContain("未声明自动缓存");
});
it("未启用自动缓存时应展示轻量诊断提示", () => {
@@ -73,6 +73,34 @@ function formatCompactTokenCount(value: number): string {
return normalized.toLocaleString();
}
function resolvePromptCacheMetaText(usage: TokenUsage): string | null {
const hasCachedRead = Number.isFinite(usage.cached_input_tokens);
const hasCacheCreation = Number.isFinite(usage.cache_creation_input_tokens);
if (!hasCachedRead && !hasCacheCreation) {
return null;
}
const cachedRead = Math.max(0, usage.cached_input_tokens ?? 0);
const cacheCreation = Math.max(0, usage.cache_creation_input_tokens ?? 0);
const totalCached = cachedRead + cacheCreation;
if (totalCached <= 0) {
return "· 缓存 0";
}
if (hasCacheCreation) {
if (hasCachedRead) {
return `· 缓存 ${formatCompactTokenCount(totalCached)}(读 ${formatCompactTokenCount(
cachedRead,
)} / 写 ${formatCompactTokenCount(cacheCreation)})`;
}
return `· 缓存写 ${formatCompactTokenCount(cacheCreation)}`;
}
return `· 缓存 ${formatCompactTokenCount(cachedRead)}`;
}
/**
* Token 使用量显示组件
*
@@ -84,9 +112,12 @@ export const TokenUsageDisplay: React.FC<TokenUsageDisplayProps> = ({
promptCacheNotice,
}) => {
const total = usage.input_tokens + usage.output_tokens;
const cachedInput = Math.max(0, usage.cached_input_tokens ?? 0);
const totalPromptCacheTokens =
Math.max(0, usage.cached_input_tokens ?? 0) +
Math.max(0, usage.cache_creation_input_tokens ?? 0);
const promptCacheMetaText = resolvePromptCacheMetaText(usage);
const missingPromptCacheNotice =
cachedInput > 0 ? null : (promptCacheNotice ?? null);
totalPromptCacheTokens > 0 ? null : (promptCacheNotice ?? null);
return (
<UsageContainer
@@ -95,9 +126,7 @@ export const TokenUsageDisplay: React.FC<TokenUsageDisplayProps> = ({
>
<UsageIcon />
<UsageText>{formatCompactTokenCount(total)} tokens</UsageText>
{cachedInput > 0 ? (
<UsageMeta>{`· 命中缓存 ${formatCompactTokenCount(cachedInput)}`}</UsageMeta>
) : null}
{promptCacheMetaText ? <UsageMeta>{promptCacheMetaText}</UsageMeta> : null}
{missingPromptCacheNotice ? (
<UsageMeta data-testid="token-usage-prompt-cache-notice">
{`· ${missingPromptCacheNotice.label}`}
@@ -82,7 +82,7 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeNull();
expect(document.body.textContent).toContain("查看原始输出");
expect(document.body.textContent).toContain("查看文本详情");
const firstSearchResult = document.body.querySelector(
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
@@ -104,12 +104,12 @@ describe("ToolCallDisplay", () => {
act(() => {
const rawToggle = document.body.querySelector(
'button[aria-label="查看搜索原始输出"]',
'button[aria-label="查看搜索文本详情"]',
) as HTMLButtonElement | null;
rawToggle?.click();
});
expect(document.body.textContent).toContain("收起原始输出");
expect(document.body.textContent).toContain("收起文本详情");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).not.toBeNull();
@@ -167,7 +167,7 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain(
"本次检索未返回可解析链接,请稍后重试。",
);
expect(container.textContent).not.toContain("查看原始输出");
expect(container.textContent).not.toContain("查看文本详情");
});
it("连续多次 WebSearch 应在对话区按搜索批次分组展示", () => {
@@ -214,8 +214,8 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain(
"搜索 March 13 2026 world headlines",
);
expect(container.textContent).toContain("中文日期检索");
expect(container.textContent).toContain("头条检索");
expect(container.textContent).not.toContain("中文日期检索");
expect(container.textContent).not.toContain("头条检索");
});
it("连续完成的命令工具应聚合成一个 work group", () => {
@@ -256,7 +256,7 @@ describe("ToolCallDisplay", () => {
'[data-testid="tool-call-work-group"]',
);
expect(groups).toHaveLength(1);
expect(container.textContent).toContain("已执行 2 条命令");
expect(container.textContent).toContain("已运行 2 条命令");
expect(container.textContent).toContain("2");
expect(container.textContent).toContain("pwd");
expect(container.textContent).toContain("ls -la");
@@ -268,8 +268,8 @@ describe("ToolCallDisplay", () => {
groupToggle?.click();
});
expect(container.textContent).toContain("执行 pwd");
expect(container.textContent).toContain("执行 ls -la");
expect(container.textContent).toContain("运行 pwd");
expect(container.textContent).toContain("运行 ls -la");
expect(container.textContent).not.toContain("pwd · ls -la");
});
@@ -299,8 +299,11 @@ describe("ToolCallDisplay", () => {
toggle?.click();
});
expect(container.textContent).toContain("已执行 ls -la");
expect(container.textContent).not.toContain("已执行已执行");
expect(container.textContent).toContain("已运行 ls -la");
expect(container.textContent).not.toContain("已运行已运行");
expect(container.textContent).not.toContain("退出码 0");
expect(container.textContent).not.toContain("stdout 24");
expect(container.textContent).not.toContain("stderr 0");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeTruthy();
@@ -308,6 +311,61 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("复制");
});
it("结果区应压缩内部元信息与长路径提示", () => {
const { container } = renderTool({
id: "tool-exec-render-2",
name: "bash",
arguments: JSON.stringify({ command: "generate-report" }),
status: "failed",
result: {
success: false,
output: "报告生成失败,请检查参数后重试。",
metadata: {
exit_code: 2,
lime_offloaded: true,
output_truncated: true,
output_file: "exports/reports/final-result.md",
},
},
startTime: new Date("2026-03-20T12:12:00.000Z"),
endTime: new Date("2026-03-20T12:12:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(container.textContent).toContain("内容较长,已省略部分文本");
expect(container.textContent).toContain("命令返回错误");
expect(container.textContent).toContain("结果文件: final-result.md");
expect(container.textContent).not.toContain("完整输出已转存");
expect(container.textContent).not.toContain("输出已截断");
expect(container.textContent).not.toContain("输出文件:");
expect(container.textContent).not.toContain("退出码 2");
expect(container.textContent).not.toContain("exports/reports/final-result.md");
});
it("正式工具卡不应额外展示原始工具名", () => {
const { container } = renderTool({
id: "tool-ask-user-1",
name: "AskUserQuestion",
arguments: JSON.stringify({ question: "需要继续吗?" }),
status: "completed",
result: {
success: true,
output: "用户已确认继续。",
},
startTime: new Date("2026-04-13T10:31:00.000Z"),
endTime: new Date("2026-04-13T10:31:01.000Z"),
});
expect(container.textContent).toContain("已收集 需要继续吗?");
expect(container.textContent).not.toContain("Ask User Question");
});
it("站点能力工具结果应展示自动保存结果与脚本来源", () => {
const { container } = renderTool({
id: "tool-site-run-1",
@@ -357,20 +415,18 @@ describe("ToolCallDisplay", () => {
});
expect(container.textContent).toContain(
"结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文",
"结果已自动保存到当前项目:GitHub MCP 搜索结果",
);
expect(container.textContent).toContain(
"项目目录:/Users/coso/Library/Application Support/lime/projects/project-1",
"已导出 Markdown 文稿",
);
expect(container.textContent).toContain(
"Markdown 文件:exports/x-article-export/github-mcp/index.md",
);
expect(container.textContent).toContain(
"图片资源:7 张 · exports/x-article-export/github-mcp/images",
);
expect(container.textContent).toContain(
"脚本来源:服务端脚本 · 2026-03-25",
"附带图片 7 张",
);
expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/index.md");
expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/images");
expect(container.textContent).not.toContain("项目目录:");
expect(container.textContent).not.toContain("脚本来源:");
});
it("站点能力工具结果应支持直接打开已保存内容", () => {
@@ -544,11 +600,14 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("匹配工具:2 个");
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("Write");
expect(container.textContent).toContain("原生工具");
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("保存文件");
expect(container.textContent).not.toContain("Read a file from disk");
expect(container.textContent).not.toContain('"always_visible":true');
expect(container.textContent).not.toContain("查询:select:Read,Write");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("默认可见");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeNull();
@@ -589,7 +648,7 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).toBeNull();
expect(container.textContent).not.toContain("匹配工具:2 个");
expect(container.textContent).not.toContain("找到工具:2 个");
expect(container.textContent).not.toContain("Read a file from disk");
});
@@ -626,7 +685,7 @@ describe("ToolCallDisplay", () => {
});
expect(container.textContent).toContain(
"执行失败,未保存到项目 project-2 · 来自当前项目上下文",
"执行失败,未保存到当前项目",
);
expect(container.textContent).toContain("自动保存失败:数据库写入失败");
});
@@ -756,13 +815,13 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("已打开 https://example.com/docs");
expect(container.textContent).toContain("子任务处理中 修复登录页");
expect(container.textContent).toContain("已读取输出 video-task-1");
expect(container.textContent).toContain("已查看结果 video-task-1");
expect(container.textContent).toContain("已加载技能 lime-governance");
expect(container.textContent).toContain("已列出 src/**/*.tsx");
expect(container.textContent).toContain("已找到 src/**/*.tsx");
expect(container.textContent).toContain("等待输入 需要继续吗?");
expect(container.textContent).toContain("已发送");
expect(container.textContent).toContain("修复已完成");
expect(container.textContent).toContain("已列出 当前团队");
expect(container.textContent).toContain("已查看 当前团队");
expect(container.textContent).toContain("已创建 当前团队");
expect(container.textContent).toContain("已删除 当前团队");
expect(container.textContent).toContain("已处理 remote-1");
@@ -787,6 +846,6 @@ describe("ToolCallDisplay", () => {
endTime: new Date("2026-03-25T09:00:01.000Z"),
});
expect(container.textContent).toContain("已写入 final.md");
expect(container.textContent).toContain("已保存 final.md");
});
});
@@ -22,6 +22,7 @@ import type {
import type { SiteSavedContentTarget } from "../types";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
import {
isUnifiedWebSearchToolName,
resolveSearchResultPreviewItemsFromText,
@@ -29,18 +30,11 @@ import {
import { extractLimeToolMetadataBlock } from "../hooks/agentChatToolResult";
import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteProjectSourceLabel,
resolveSiteProjectTargetLabel,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
import {
classifySearchQuerySemantic,
summarizeSearchQuerySemantics,
} from "../utils/searchQueryGrouping";
import {
normalizeToolSearchResultSummary,
resolveToolSearchItemSourceLabel,
resolveToolSearchItemStatusLabel,
} from "../utils/toolSearchResultSummary";
import type { ToolCallArgumentValue } from "../utils/toolDisplayInfo";
import {
@@ -49,7 +43,6 @@ import {
buildToolHeadline as buildToolHeadlineFromInfo,
extractSearchQueryLabel as extractSearchQueryLabelFromInfo,
getToolDisplayInfo as getToolDisplayInfoFromInfo,
humanizeToolName as humanizeToolNameFromInfo,
normalizeToolNameKey as normalizeToolNameKeyFromInfo,
parseToolCallArguments as parseToolCallArgumentsFromInfo,
resolveToolFilePath as resolveToolFilePathFromInfo,
@@ -171,6 +164,17 @@ const buildRenderedToolResultContent = (params: {
return content;
};
function resolveUserFacingPathName(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments.at(-1) || trimmed;
}
const isGroupableToolCall = (toolCall: ToolCallState): boolean => {
if (isUnifiedWebSearchToolName(toolCall.name)) {
return true;
@@ -567,46 +571,21 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
if (!resultMetadata) return [];
const items: string[] = [];
if (resultMetadata.lime_offloaded === true) {
items.push("完整输出已转存");
if (
resultMetadata.lime_offloaded === true ||
resultMetadata.output_truncated === true
) {
items.push("内容较长,已省略部分文本");
}
if (typeof resultMetadata.exit_code === "number") {
items.push(`退出码 ${resultMetadata.exit_code}`);
}
if (typeof resultMetadata.stdout_length === "number") {
items.push(`stdout ${resultMetadata.stdout_length}`);
}
if (typeof resultMetadata.stderr_length === "number") {
items.push(`stderr ${resultMetadata.stderr_length}`);
}
if (typeof resultMetadata.sandboxed === "boolean") {
items.push(resultMetadata.sandboxed ? "已隔离执行" : "普通执行");
}
if (resultMetadata.output_truncated === true) {
items.push("输出已截断");
}
if (typeof resultMetadata.offload_original_chars === "number") {
items.push(`原始 ${resultMetadata.offload_original_chars} 字符`);
}
if (typeof resultMetadata.offload_original_tokens === "number") {
items.push(`约 ${resultMetadata.offload_original_tokens} tokens`);
}
if (typeof resultMetadata.offload_trigger === "string") {
const triggerLabel =
resultMetadata.offload_trigger === "history_context_pressure"
? "上下文压力触发"
: resultMetadata.offload_trigger === "token_limit_before_evict"
? "token 阈值触发"
: resultMetadata.offload_trigger === "payload_bytes"
? "字节阈值触发"
: resultMetadata.offload_trigger === "payload_chars"
? "字符阈值触发"
: resultMetadata.offload_trigger;
items.push(triggerLabel);
if (
typeof resultMetadata.exit_code === "number" &&
(isFailed || resultMetadata.exit_code !== 0)
) {
items.push("命令返回错误");
}
return items;
}, [resultMetadata]);
}, [isFailed, resultMetadata]);
const siteResultNotices = useMemo(() => {
if (!siteResultSummary) return [] as ToolResultNotice[];
@@ -614,17 +593,13 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
const savedProjectId =
siteResultSummary.savedProjectId ||
siteResultSummary.savedContent?.projectId;
const savedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.savedBy,
);
const savedProjectTarget = resolveSiteProjectTargetLabel({
source: siteResultSummary.savedBy,
projectId: savedProjectId,
});
if (siteResultSummary.savedContent?.title) {
let text = `结果已自动保存${
savedProjectId ? `到项目 ${savedProjectId}` : ""
}:${siteResultSummary.savedContent.title}`;
if (savedSourceLabel) {
text = `${text} · ${savedSourceLabel}`;
}
const text = `结果已自动保存到${savedProjectTarget}:${siteResultSummary.savedContent.title}`;
notices.push({
key: "site-save-success",
text,
@@ -632,44 +607,31 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
});
}
if (siteResultSummary.savedContent?.projectRootPath) {
notices.push({
key: "site-save-project-root",
text: `项目目录:${siteResultSummary.savedContent.projectRootPath}`,
tone: "neutral",
});
}
if (siteResultSummary.savedContent?.markdownRelativePath) {
notices.push({
key: "site-save-markdown-path",
text: `Markdown 文件:${siteResultSummary.savedContent.markdownRelativePath}`,
text: "已导出 Markdown 文稿",
tone: "neutral",
});
}
if (typeof siteResultSummary.savedContent?.imageCount === "number") {
const imageDir = siteResultSummary.savedContent.imagesRelativeDir;
notices.push({
key: "site-save-images",
text: `图片资源:${siteResultSummary.savedContent.imageCount} 张${
imageDir ? ` · ${imageDir}` : ""
}`,
text: `附带图片 ${siteResultSummary.savedContent.imageCount} 张`,
tone: "neutral",
});
}
if (siteResultSummary.saveSkippedProjectId) {
let text =
const skippedProjectTarget = resolveSiteProjectTargetLabel({
source: siteResultSummary.saveSkippedBy,
projectId: siteResultSummary.saveSkippedProjectId,
});
const text =
toolCall.status === "failed"
? `执行失败,未保存到项目 ${siteResultSummary.saveSkippedProjectId}`
: `本次结果未保存到项目 ${siteResultSummary.saveSkippedProjectId}`;
const skippedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.saveSkippedBy,
);
if (skippedSourceLabel) {
text = `${text} · ${skippedSourceLabel}`;
}
? `执行失败,未保存到${skippedProjectTarget}`
: `本次结果未保存到${skippedProjectTarget}`;
notices.push({
key: "site-save-skipped",
text,
@@ -684,16 +646,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
tone: "error",
});
}
const adapterSourceLabel = resolveSiteAdapterSourceLabel(siteResultSummary);
if (adapterSourceLabel) {
notices.push({
key: "site-adapter-source",
text: `脚本来源:${adapterSourceLabel}`,
tone: "neutral",
});
}
return notices;
}, [siteResultSummary, toolCall.status]);
const resultPath = useMemo(() => {
@@ -702,24 +654,30 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
typeof resultMetadata.offload_file === "string" &&
resultMetadata.offload_file.trim()
) {
const fullPath = resultMetadata.offload_file.trim();
return {
label: "转存文件",
value: resultMetadata.offload_file.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
if (
typeof resultMetadata.output_file === "string" &&
resultMetadata.output_file.trim()
) {
const fullPath = resultMetadata.output_file.trim();
return {
label: "输出文件",
value: resultMetadata.output_file.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
if (typeof resultMetadata.path === "string" && resultMetadata.path.trim()) {
const fullPath = resultMetadata.path.trim();
return {
label: "产物路径",
value: resultMetadata.path.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
return undefined;
@@ -751,12 +709,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
() => buildGroupedChildLineFromInfo(toolCall),
[toolCall],
);
const shouldShowRawToolName = useMemo(
() =>
toolDisplay.family === "generic" &&
toolDisplay.label !== humanizeToolNameFromInfo(toolCall.name),
[toolCall.name, toolDisplay.family, toolDisplay.label],
);
const searchResultItems = useMemo(() => {
if (!isUnifiedWebSearchToolName(toolCall.name)) {
return [];
@@ -764,11 +716,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
return resolveSearchResultPreviewItemsFromText(toolCall.result?.output);
}, [toolCall.name, toolCall.result?.output]);
const searchSemantic = useMemo(
() =>
classifySearchQuerySemantic(extractSearchQueryLabelFromInfo(toolCall)),
[toolCall],
);
const hasResultImages = resultImages.length > 0;
const hasSearchResults = searchResultItems.length > 0;
const isToolSearch = useMemo(
@@ -865,11 +812,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
<div className="truncate text-sm text-slate-700">
{groupedChildLine}
</div>
{shouldShowRawToolName ? (
<div className="mt-0.5 truncate text-xs text-slate-500">
{humanizeToolNameFromInfo(toolCall.name)}
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-1 pt-0.5">
{openableFilePath && onFileClick && (
@@ -920,11 +862,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
<div className="truncate text-sm text-slate-900">
{toolHeadline}
</div>
{shouldShowRawToolName ? (
<div className="mt-0.5 truncate text-xs text-slate-500">
{humanizeToolNameFromInfo(toolCall.name)}
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-1 pt-0.5">
@@ -979,9 +916,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
{hasSearchResults && isExpanded && (
<div className="mb-2 ml-6 mt-1.5">
<div className="mb-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>{searchSemantic.label}</span>
</div>
<SearchResultPreviewList
items={searchResultItems}
onOpenUrl={handleOpenExternalUrl}
@@ -995,14 +929,14 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
className="rounded-md px-2 py-1 text-[11px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
aria-label={
showRawSearchResultOutput
? "收起搜索原始输出"
: "查看搜索原始输出"
? "收起搜索文本详情"
: "查看搜索文本详情"
}
onClick={() =>
setShowRawSearchResultOutput((current) => !current)
}
>
{showRawSearchResultOutput ? "收起原始输出" : "查看原始输出"}
{showRawSearchResultOutput ? "收起文本详情" : "查看文本详情"}
</button>
</div>
) : null}
@@ -1011,60 +945,10 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
{toolSearchSummary && isExpanded ? (
<div
className="mb-2 ml-6 mt-1.5 space-y-2"
className="mb-2 ml-6 mt-1.5"
data-testid="tool-call-tool-search-result"
>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span>匹配工具:{toolSearchSummary.count} 个</span>
{toolSearchSummary.query ? (
<span className="break-all">查询:{toolSearchSummary.query}</span>
) : null}
{typeof toolSearchSummary.totalDeferredTools === "number" ? (
<span>Deferred 总数:{toolSearchSummary.totalDeferredTools}</span>
) : null}
</div>
{toolSearchSummary.notes.length > 0 ? (
<div className="space-y-1 text-[11px] text-amber-700">
{toolSearchSummary.notes.map((note, index) => (
<div key={`${note}-${index}`}>{note}</div>
))}
</div>
) : null}
{toolSearchSummary.tools.length > 0 ? (
<div className="space-y-2">
{toolSearchSummary.tools.map((item) => {
const sourceLabel = resolveToolSearchItemSourceLabel(item);
const statusLabel = resolveToolSearchItemStatusLabel(item);
return (
<div
key={item.name}
className="rounded-[14px] border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-slate-900">
{item.name}
</span>
{sourceLabel ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{sourceLabel}
</span>
) : null}
{statusLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">
{statusLabel}
</span>
) : null}
</div>
{item.description ? (
<div className="mt-1 text-[11px] leading-5 text-slate-500">
{item.description}
</div>
) : null}
</div>
);
})}
</div>
) : null}
<ToolSearchSummaryPanel summary={toolSearchSummary} />
</div>
) : null}
@@ -1109,8 +993,11 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
</div>
) : null}
{resultPath ? (
<div className="break-all text-[11px] text-slate-500">
{resultPath.label}: {resultPath.value}
<div
className="break-all text-[11px] text-slate-500"
title={resultPath.value}
>
{resultPath.label}: {resultPath.displayValue}
</div>
) : null}
<div
@@ -1355,9 +1242,6 @@ function SearchToolCallGroup({
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
}) {
const [expanded, setExpanded] = useState(true);
const semanticSummaries = summarizeSearchQuerySemantics(
toolCalls.map(extractSearchQueryLabelFromInfo),
);
const headline = buildToolGroupHeadlineFromInfo(toolCalls);
const queryPreview = toolCalls
.slice(0, 2)
@@ -1392,15 +1276,6 @@ function SearchToolCallGroup({
)}
/>
</button>
{semanticSummaries.length > 0 ? (
<div className="ml-6 flex flex-wrap gap-x-3 gap-y-1 pb-1 text-[11px] text-slate-500">
{semanticSummaries.map((item) => (
<span key={item.key}>
{item.label} {item.count}
</span>
))}
</div>
) : null}
{expanded ? (
<div className="ml-6 space-y-1">
{toolCalls.map((toolCall, index) => (
@@ -0,0 +1,105 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: RenderResult[] = [];
function renderPanel(
summary: ComponentProps<typeof ToolSearchSummaryPanel>["summary"],
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ToolSearchSummaryPanel summary={summary} />);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ToolSearchSummaryPanel", () => {
it("应优先展示用户能看懂的工具标签,并隐藏内部状态标签", () => {
const { container } = renderPanel({
query: "browser click",
count: 3,
notes: ["未命中任何 deferred 工具"],
tools: [
{
name: "Read",
source: "native_registry",
alwaysVisible: true,
},
{
name: "mcp__playwright__browser_click",
source: "extension",
extensionName: "mcp__playwright",
status: "deferred",
deferredLoading: true,
},
{
name: "WebSearch",
source: "native_registry",
status: "loaded",
},
],
});
expect(container.textContent).toContain("找到工具:3 个");
expect(container.textContent).toContain("查询:browser click");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("页面点击");
expect(container.textContent).toContain("搜索网页");
expect(container.textContent).toContain("没有找到更多匹配工具");
expect(container.textContent).not.toContain("Read");
expect(container.textContent).not.toContain("mcp__playwright__browser_click");
expect(container.textContent).not.toContain("WebSearch");
expect(container.textContent).not.toContain("来源:");
expect(container.textContent).not.toContain("状态:");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("扩展工具");
expect(container.textContent).not.toContain("已加载");
expect(container.textContent).not.toContain("默认可见");
expect(container.textContent).not.toContain("待加载");
});
it("内部筛选语法查询不应直接展示给用户", () => {
const { container } = renderPanel({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [{ name: "Read" }, { name: "Write" }],
});
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).not.toContain("查询:");
expect(container.textContent).not.toContain("select:Read,Write");
});
});
@@ -0,0 +1,88 @@
import {
type ToolSearchResultSummary,
resolveUserFacingToolSearchItemLabel,
} from "../utils/toolSearchResultSummary";
interface ToolSearchSummaryPanelProps {
summary: ToolSearchResultSummary;
testId?: string;
}
function shouldShowUserFacingQuery(query: string | undefined): boolean {
const normalized = query?.trim();
if (!normalized) {
return false;
}
return !/^(?:select|tool|tools|name|tag):/i.test(normalized);
}
function resolveUserFacingToolSearchNote(note: string): string | null {
const trimmed = note.trim();
if (!trimmed) {
return null;
}
if (/未命中.*deferred/i.test(trimmed)) {
return "没有找到更多匹配工具";
}
if (
/(?:always[_\s-]?visible|native[_\s-]?registry|extension[_\s-]?name|total[_\s-]?deferred|caller)/i.test(
trimmed,
)
) {
return null;
}
return trimmed.replace(/\bdeferred\b/gi, "更多").trim();
}
export function ToolSearchSummaryPanel({
summary,
testId,
}: ToolSearchSummaryPanelProps) {
const userFacingNotes = summary.notes
.map((note) => resolveUserFacingToolSearchNote(note))
.filter((note): note is string => Boolean(note));
return (
<div className="space-y-2" data-testid={testId}>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span>找到工具:{summary.count} 个</span>
{shouldShowUserFacingQuery(summary.query) ? (
<span className="break-all">查询:{summary.query}</span>
) : null}
</div>
{userFacingNotes.length > 0 ? (
<div className="space-y-1 text-[11px] text-amber-700">
{userFacingNotes.map((note, index) => (
<div key={`${note}-${index}`}>{note}</div>
))}
</div>
) : null}
{summary.tools.length > 0 ? (
<div className="flex flex-wrap gap-2">
{summary.tools.map((item) => {
const label = resolveUserFacingToolSearchItemLabel(item.name);
const rawName = item.name.trim();
return (
<div
key={item.name}
title={label !== rawName ? rawName : undefined}
className="inline-flex min-h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700"
>
<span className="font-medium text-slate-900">{label}</span>
</div>
);
})}
</div>
) : null}
</div>
);
}
export default ToolSearchSummaryPanel;
@@ -58,6 +58,8 @@ export function buildGeneralWorkbenchWorkflowPanelProps({
onAddImage,
onImportDocument,
creationTaskEventsCount,
showBranchRecords: workflowPanelState.showBranchRecords,
onToggleBranchRecords: workflowPanelState.toggleBranchRecords,
showCreationTasks: workflowPanelState.showCreationTasks,
onToggleCreationTasks: workflowPanelState.toggleCreationTasks,
groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents,
@@ -4,7 +4,7 @@ import type {
ExecLogEntry,
ExecLogEntryDetail,
} from "./GeneralWorkbenchExecLog";
import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo";
import { resolveUserFacingToolDisplayLabel } from "../utils/toolDisplayInfo";
import type {
GeneralWorkbenchActivityLogGroup,
GeneralWorkbenchCreationTaskGroup,
@@ -115,7 +115,7 @@ function buildToolCallEntry(
return {
id: `${messageId}-tc-${toolCall.id}-${index}`,
type: "tool",
typeLabel: resolveToolDisplayLabel(toolCall.name),
typeLabel: resolveUserFacingToolDisplayLabel(toolCall.name),
content: argsPreview || toolCall.name,
meta: resultMeta,
timestamp: toolCall.startTime || fallbackTimestamp,
@@ -141,7 +141,7 @@ function buildRunEntry(
.map((step) => step.name?.trim() || step.id?.trim() || "")
.filter((step): step is string => Boolean(step));
const allowedTools = (skillDetail?.allowed_tools || [])
.map((toolName) => resolveToolDisplayLabel(toolName))
.map((toolName) => resolveUserFacingToolDisplayLabel(toolName))
.filter((toolName): toolName is string => Boolean(toolName));
const detail: ExecLogEntryDetail | undefined =
@@ -31,8 +31,10 @@ export interface GeneralWorkbenchWorkflowPanelState {
runMetadataText: string;
runMetadataSummary: GeneralWorkbenchRunMetadataSummary;
showActivityLogs: boolean;
showBranchRecords: boolean;
showCreationTasks: boolean;
toggleActivityLogs: () => void;
toggleBranchRecords: () => void;
toggleCreationTasks: () => void;
}
@@ -43,6 +45,7 @@ export function useGeneralWorkbenchWorkflowPanelState({
activeRunMetadata,
}: UseGeneralWorkbenchWorkflowPanelStateParams): GeneralWorkbenchWorkflowPanelState {
const [showActivityLogs, setShowActivityLogs] = useState(false);
const [showBranchRecords, setShowBranchRecords] = useState(false);
const [showCreationTasks, setShowCreationTasks] = useState(false);
const completedSteps = useMemo(
@@ -84,6 +87,10 @@ export function useGeneralWorkbenchWorkflowPanelState({
setShowActivityLogs((previous) => !previous);
}, []);
const toggleBranchRecords = useCallback(() => {
setShowBranchRecords((previous) => !previous);
}, []);
const toggleCreationTasks = useCallback(() => {
setShowCreationTasks((previous) => !previous);
}, []);
@@ -97,8 +104,10 @@ export function useGeneralWorkbenchWorkflowPanelState({
runMetadataText,
runMetadataSummary,
showActivityLogs,
showBranchRecords,
showCreationTasks,
toggleActivityLogs,
toggleBranchRecords,
toggleCreationTasks,
};
}
@@ -173,6 +173,7 @@ describe("agentChatHistory", () => {
input_tokens: 12000,
output_tokens: 19000,
cached_input_tokens: 4000,
cache_creation_input_tokens: 1200,
},
},
],
@@ -184,6 +185,7 @@ describe("agentChatHistory", () => {
input_tokens: 12000,
output_tokens: 19000,
cached_input_tokens: 4000,
cache_creation_input_tokens: 1200,
});
});
@@ -436,6 +438,7 @@ describe("agentChatHistory", () => {
input_tokens: 20480,
output_tokens: 10240,
cached_input_tokens: 8192,
cache_creation_input_tokens: 2048,
},
},
];
@@ -463,6 +466,7 @@ describe("agentChatHistory", () => {
input_tokens: 20480,
output_tokens: 10240,
cached_input_tokens: 8192,
cache_creation_input_tokens: 2048,
});
});
@@ -76,6 +76,9 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => {
const outputTokens = (usage as { output_tokens?: unknown }).output_tokens;
const cachedInputTokens = (usage as { cached_input_tokens?: unknown })
.cached_input_tokens;
const cacheCreationInputTokens = (
usage as { cache_creation_input_tokens?: unknown }
).cache_creation_input_tokens;
if (
typeof inputTokens !== "number" ||
typeof outputTokens !== "number" ||
@@ -96,6 +99,12 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => {
cachedInputTokens >= 0
? cachedInputTokens
: undefined,
cache_creation_input_tokens:
typeof cacheCreationInputTokens === "number" &&
Number.isFinite(cacheCreationInputTokens) &&
cacheCreationInputTokens >= 0
? cacheCreationInputTokens
: undefined,
};
};
@@ -904,7 +913,7 @@ const buildAssistantHydrationSignature = (message: Message): string => {
const buildHistoryMessageSignature = (message: Message): string => {
const usageSignature = message.usage
? `${message.usage.input_tokens}:${message.usage.output_tokens}:${message.usage.cached_input_tokens ?? ""}`
? `${message.usage.input_tokens}:${message.usage.output_tokens}:${message.usage.cached_input_tokens ?? ""}:${message.usage.cache_creation_input_tokens ?? ""}`
: "";
return [
message.role,
@@ -49,6 +49,7 @@ describe("agentStreamRuntimeHandler", () => {
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 8_000,
cache_creation_input_tokens: 1_200,
},
} as AgentEvent,
requestState: {
@@ -102,6 +103,7 @@ describe("agentStreamRuntimeHandler", () => {
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 8_000,
cache_creation_input_tokens: 1_200,
},
});
});
@@ -1,182 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildInitialDispatchKey,
useBootstrapDispatchPreview,
} from "./useBootstrapDispatchPreview";
interface HookHarness {
getValue: () => ReturnType<typeof useBootstrapDispatchPreview>;
rerender: (
props?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
) => void;
unmount: () => void;
}
function mountHook(
initialProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useBootstrapDispatchPreview> | null = null;
let currentProps = {
initialUserPrompt: "",
initialUserImages: [],
messagesCount: 0,
isSending: false,
queuedTurnCount: 0,
consumedInitialPromptKey: null,
shouldUseCompactGeneralWorkbench: false,
...initialProps,
};
function TestComponent() {
hookValue = useBootstrapDispatchPreview(currentProps);
return null;
}
const render = (
nextProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("useBootstrapDispatchPreview", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
// noop
});
it("应生成稳定的 initialDispatchKey", () => {
expect(
buildInitialDispatchKey("写一篇文章", [
{ data: "abcdef1234567890", mediaType: "image/png" },
]),
).toContain("写一篇文章");
});
it("发送中且无消息时应展示 bootstrap 预览消息", () => {
const harness = mountHook({
initialUserPrompt: "请开始处理这个任务",
isSending: true,
});
try {
const value = harness.getValue();
expect(value.initialDispatchKey).toBeTruthy();
expect(value.shouldShowBootstrapDispatchPreview).toBe(true);
expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2);
expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(
"请开始处理这个任务",
);
} finally {
harness.unmount();
}
});
it("有真实消息后应清空 bootstrap 预览", () => {
const harness = mountHook({
initialUserPrompt: "请开始处理这个任务",
isSending: true,
});
try {
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(
2,
);
harness.rerender({
messagesCount: 1,
isSending: false,
});
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(
0,
);
expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false);
} finally {
harness.unmount();
}
});
it("初始意图已标记消费后,只要仍在排队中就继续展示 bootstrap 预览", () => {
const prompt = "帮我把这篇文章发布到微信公众号后台";
const dispatchKey = buildInitialDispatchKey(prompt, [])!;
const harness = mountHook({
initialUserPrompt: prompt,
consumedInitialPromptKey: dispatchKey,
queuedTurnCount: 1,
});
try {
const value = harness.getValue();
expect(value.shouldShowBootstrapDispatchPreview).toBe(true);
expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2);
expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(prompt);
expect(value.bootstrapDispatchPreviewMessages[1]?.content).toBe(
"正在开始处理任务…",
);
} finally {
harness.unmount();
}
});
});
@@ -1,164 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type { Message, MessageImage } from "../types";
export interface InitialDispatchPreviewSnapshot {
key: string;
prompt?: string;
images: MessageImage[];
}
interface UseBootstrapDispatchPreviewOptions {
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactGeneralWorkbench?: boolean;
}
export function buildInitialDispatchKey(
prompt?: string,
images?: MessageImage[],
): string | null {
const normalizedPrompt = (prompt || "").trim();
const normalizedImages = images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return null;
}
const imageSignature = normalizedImages
.map(
(image, index) =>
`${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`,
)
.join("|");
return `${normalizedPrompt}::${imageSignature}`;
}
export function buildInitialDispatchPreviewMessages(
dispatchKey: string,
prompt?: string,
images?: MessageImage[],
assistantPreviewText?: string,
): Message[] {
const normalizedPrompt = (prompt || "").trim();
const normalizedImages = images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return [];
}
const timestamp = new Date();
const normalizedAssistantPreviewText =
assistantPreviewText?.trim() || "正在开始处理任务…";
const isAssistantThinking =
normalizedAssistantPreviewText === "正在开始处理任务…";
return [
{
id: `initial-dispatch:${dispatchKey}:user`,
role: "user",
content: normalizedPrompt,
images: normalizedImages.length > 0 ? normalizedImages : undefined,
timestamp,
},
{
id: `initial-dispatch:${dispatchKey}:assistant`,
role: "assistant",
content: normalizedAssistantPreviewText,
timestamp: new Date(timestamp.getTime() + 1),
isThinking: isAssistantThinking,
},
];
}
export function useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
messagesCount,
isSending,
queuedTurnCount,
consumedInitialPromptKey,
shouldUseCompactGeneralWorkbench = false,
}: UseBootstrapDispatchPreviewOptions) {
const initialDispatchKey = useMemo(
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
[initialUserImages, initialUserPrompt],
);
const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] =
useState<InitialDispatchPreviewSnapshot | null>(null);
useEffect(() => {
if (!initialDispatchKey) {
return;
}
setBootstrapDispatchSnapshot({
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
});
}, [initialDispatchKey, initialUserImages, initialUserPrompt]);
useEffect(() => {
if (messagesCount > 0) {
setBootstrapDispatchSnapshot(null);
return;
}
if (!initialDispatchKey && !isSending && queuedTurnCount === 0) {
setBootstrapDispatchSnapshot(null);
}
}, [initialDispatchKey, isSending, messagesCount, queuedTurnCount]);
const activeBootstrapDispatch = useMemo(() => {
if (
initialDispatchKey &&
((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0)
) {
return {
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
};
}
return bootstrapDispatchSnapshot;
}, [
bootstrapDispatchSnapshot,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
]);
const isBootstrapDispatchPending =
activeBootstrapDispatch !== null &&
consumedInitialPromptKey !== activeBootstrapDispatch.key;
const shouldShowBootstrapDispatchPreview =
!shouldUseCompactGeneralWorkbench &&
Boolean(activeBootstrapDispatch) &&
messagesCount === 0 &&
(isSending || queuedTurnCount > 0);
const bootstrapDispatchPreviewMessages = useMemo(() => {
if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) {
return [] as Message[];
}
return buildInitialDispatchPreviewMessages(
activeBootstrapDispatch.key,
activeBootstrapDispatch.prompt,
activeBootstrapDispatch.images,
);
}, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]);
return {
initialDispatchKey,
activeBootstrapDispatch,
isBootstrapDispatchPending,
shouldShowBootstrapDispatchPreview,
bootstrapDispatchPreviewMessages,
};
}
@@ -1,236 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GeneralWorkbenchRunState as BackendGeneralWorkbenchRunState } from "@/lib/api/executionRun";
import {
useGeneralWorkbenchEntryPrompt,
type GeneralWorkbenchResumeWorkflowState,
} from "./useGeneralWorkbenchEntryPrompt";
interface HookHarness {
getValue: () => ReturnType<typeof useGeneralWorkbenchEntryPrompt>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onHydrateInitialPrompt: ReturnType<typeof vi.fn>;
}
interface HookProps {
activeTheme: string;
contentId?: string;
sessionId?: string;
isThemeWorkbench: boolean;
autoRunInitialPromptOnMount: boolean;
shouldUseCompactGeneralWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
consumedInitialPromptKey?: string | null;
}
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
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<GeneralWorkbenchResumeWorkflowState | null> => null,
);
const loadRunState = vi.fn(
async (
_sessionId: string,
): Promise<BackendGeneralWorkbenchRunState | null> => null,
);
let hookValue: ReturnType<typeof useGeneralWorkbenchEntryPrompt> | null =
null;
let currentProps: HookProps = {
activeTheme: "general",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
autoRunInitialPromptOnMount: false,
shouldUseCompactGeneralWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
initialUserImages: [],
consumedInitialPromptKey: null,
...initialProps,
};
function TestComponent() {
hookValue = useGeneralWorkbenchEntryPrompt({
...currentProps,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState,
});
return null;
}
const render = (nextProps?: Partial<HookProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onHydrateInitialPrompt,
};
}
async function flushEffects(times = 4) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
describe("useGeneralWorkbenchEntryPrompt", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("主题工作台初始意图应先进入预填提示态", async () => {
const harness = mountHook({
initialDispatchKey: "initial-dispatch",
initialUserPrompt: "请先生成内容主稿",
});
try {
await flushEffects();
expect(harness.onHydrateInitialPrompt).toHaveBeenCalledWith(
"请先生成内容主稿",
"initial-dispatch",
);
expect(harness.getValue().generalWorkbenchEntryPrompt).toMatchObject({
kind: "initial_prompt",
prompt: "请先生成内容主稿",
});
} finally {
harness.unmount();
}
});
it("启用自动执行时不应进入预填提示态", async () => {
const harness = mountHook({
initialDispatchKey: "initial-dispatch",
initialUserPrompt: "请先生成内容主稿",
autoRunInitialPromptOnMount: true,
});
try {
await flushEffects();
expect(harness.onHydrateInitialPrompt).not.toHaveBeenCalled();
expect(harness.getValue().generalWorkbenchEntryPrompt).toBeNull();
} finally {
harness.unmount();
}
});
it("无初始意图时应基于 run-state 生成 resume prompt", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onHydrateInitialPrompt = vi.fn();
const loadRunState = vi.fn(
async (
_sessionId: string,
): Promise<BackendGeneralWorkbenchRunState | null> => ({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
{
run_id: "run-1",
title: "撰写主稿",
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 hookValueRef: {
current: ReturnType<typeof useGeneralWorkbenchEntryPrompt> | null;
} = { current: null };
function TestComponent() {
hookValueRef.current = useGeneralWorkbenchEntryPrompt({
activeTheme: "general",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
autoRunInitialPromptOnMount: false,
shouldUseCompactGeneralWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
initialUserImages: [],
consumedInitialPromptKey: null,
onHydrateInitialPrompt,
loadRunState,
});
return null;
}
act(() => {
root.render(<TestComponent />);
});
try {
await flushEffects();
expect(loadRunState).toHaveBeenCalledWith("session-1");
expect(hookValueRef.current?.generalWorkbenchEntryPrompt).toMatchObject({
kind: "resume",
title: "发现上次未完成任务",
description: expect.stringContaining("撰写主稿"),
});
expect(hookValueRef.current?.generalWorkbenchEntryCheckPending).toBe(
false,
);
} finally {
act(() => {
root.unmount();
});
container.remove();
}
});
});
@@ -1,362 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
executionRunGetGeneralWorkbenchState,
type GeneralWorkbenchRunState,
type GeneralWorkbenchRunTerminalItem,
type GeneralWorkbenchRunTodoItem,
} from "@/lib/api/executionRun";
import type { MessageImage } from "../types";
export interface GeneralWorkbenchEntryPromptState {
kind: "initial_prompt" | "resume";
signature: string;
title: string;
description: string;
actionLabel: string;
prompt: string;
}
export interface GeneralWorkbenchResumeWorkflowStep {
id: string;
title: string;
status: "pending" | "active" | "completed" | "skipped" | "error";
result?: unknown;
}
export interface GeneralWorkbenchResumeWorkflowState {
id: string;
current_step_index: number;
updated_at: number;
steps: GeneralWorkbenchResumeWorkflowStep[];
}
interface UseGeneralWorkbenchEntryPromptOptions {
activeTheme: string;
contentId?: string;
sessionId?: string;
isThemeWorkbench: boolean;
autoRunInitialPromptOnMount: boolean;
shouldUseCompactGeneralWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
consumedInitialPromptKey?: string | null;
onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void;
loadWorkflow?: (
contentId: string,
) => Promise<GeneralWorkbenchResumeWorkflowState | null>;
loadRunState?: (
sessionId: string,
) => Promise<GeneralWorkbenchRunState | null>;
}
const defaultLoadGeneralWorkbenchRunState = (sessionId: string) =>
executionRunGetGeneralWorkbenchState(sessionId, 3);
function resolveGeneralWorkbenchGateLabel(
gateKey?: GeneralWorkbenchRunTodoItem["gate_key"],
): string | null {
switch (gateKey) {
case "topic_select":
return "选题确认";
case "write_mode":
return "写作推进";
case "publish_confirm":
return "发布确认";
case null:
case undefined:
default:
return null;
}
}
function hasWorkflowMeaningfulProgress(
workflow: GeneralWorkbenchResumeWorkflowState | null,
): boolean {
if (!workflow) {
return false;
}
if (workflow.current_step_index > 0) {
return true;
}
return workflow.steps.some(
(step) =>
step.status === "completed" ||
step.status === "skipped" ||
step.status === "error" ||
Boolean(step.result),
);
}
export function buildGeneralWorkbenchResumePromptFromWorkflow(
workflow: GeneralWorkbenchResumeWorkflowState | null,
): GeneralWorkbenchEntryPromptState | null {
if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) {
return null;
}
const hasPendingStep = workflow.steps.some(
(step) => step.status !== "completed" && step.status !== "skipped",
);
if (!hasPendingStep) {
return null;
}
const activeStep =
workflow.steps.find(
(step) =>
step.status === "active" ||
step.status === "pending" ||
step.status === "error",
) || workflow.steps[workflow.current_step_index];
const stepTitle = activeStep?.title?.trim() || "当前创作阶段";
return {
kind: "resume",
signature: `workflow:${workflow.id}:${workflow.updated_at}:${activeStep?.id || ""}`,
title: "发现上次未完成任务",
description: `检测到当前文稿上次停留在“${stepTitle}”,可以直接衔接已有进度继续。`,
actionLabel: "继续上次任务",
prompt: `请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“${stepTitle}”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。`,
};
}
function resolveGeneralWorkbenchPendingRunCandidate(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchRunTodoItem | GeneralWorkbenchRunTerminalItem | null {
if (!state) {
return null;
}
const activeQueueItem = (state.queue_items || []).find((item) =>
["queued", "running", "error", "timeout"].includes(item.status),
);
if (activeQueueItem) {
return activeQueueItem;
}
if (
state.latest_terminal &&
["queued", "running", "error", "timeout"].includes(
state.latest_terminal.status,
)
) {
return state.latest_terminal;
}
return null;
}
export function buildGeneralWorkbenchResumePromptFromRunState(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchEntryPromptState | null {
const pendingRun = resolveGeneralWorkbenchPendingRunCandidate(state);
if (!pendingRun) {
return null;
}
const runTitle = pendingRun.title?.trim() || "最近一次创作任务";
const gateLabel = resolveGeneralWorkbenchGateLabel(pendingRun.gate_key);
const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : "";
return {
kind: "resume",
signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`,
title: "发现上次未完成任务",
description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`,
actionLabel: "继续上次任务",
prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`,
};
}
export function useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId,
sessionId,
isThemeWorkbench,
autoRunInitialPromptOnMount,
shouldUseCompactGeneralWorkbench,
messagesCount,
initialDispatchKey,
initialUserPrompt,
initialUserImages,
consumedInitialPromptKey,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState = defaultLoadGeneralWorkbenchRunState,
}: UseGeneralWorkbenchEntryPromptOptions) {
const [generalWorkbenchEntryPrompt, setGeneralWorkbenchEntryPrompt] =
useState<GeneralWorkbenchEntryPromptState | null>(null);
const [
generalWorkbenchEntryCheckPending,
setGeneralWorkbenchEntryCheckPending,
] = useState(false);
const hydratedPromptSignatureRef = useRef<string | null>(null);
const dismissedPromptSignatureRef = useRef<string | null>(null);
useEffect(() => {
hydratedPromptSignatureRef.current = null;
dismissedPromptSignatureRef.current = null;
setGeneralWorkbenchEntryPrompt(null);
setGeneralWorkbenchEntryCheckPending(false);
}, [activeTheme, contentId, initialDispatchKey]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
!isThemeWorkbench ||
autoRunInitialPromptOnMount ||
!contentId ||
!initialDispatchKey ||
!pendingInitialPrompt ||
pendingInitialImages.length > 0 ||
messagesCount > 0
) {
return;
}
if (
consumedInitialPromptKey === initialDispatchKey ||
hydratedPromptSignatureRef.current === initialDispatchKey
) {
return;
}
hydratedPromptSignatureRef.current = initialDispatchKey;
onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey);
setGeneralWorkbenchEntryPrompt({
kind: "initial_prompt",
signature: initialDispatchKey,
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: pendingInitialPrompt,
});
}, [
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isThemeWorkbench,
messagesCount,
onHydrateInitialPrompt,
autoRunInitialPromptOnMount,
shouldUseCompactGeneralWorkbench,
]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
if (
!isThemeWorkbench ||
!contentId ||
!sessionId ||
messagesCount > 0 ||
Boolean(initialDispatchKey)
) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
let disposed = false;
setGeneralWorkbenchEntryCheckPending(true);
void (async () => {
try {
const [workflow, backendState] = await Promise.all([
loadWorkflow ? loadWorkflow(contentId).catch(() => null) : null,
loadRunState(sessionId).catch(() => null),
]);
if (disposed) {
return;
}
const nextPrompt =
buildGeneralWorkbenchResumePromptFromWorkflow(workflow) ??
buildGeneralWorkbenchResumePromptFromRunState(backendState);
if (!nextPrompt) {
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "resume" ? null : current,
);
return;
}
if (dismissedPromptSignatureRef.current === nextPrompt.signature) {
return;
}
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "initial_prompt" ? current : nextPrompt,
);
} finally {
if (!disposed) {
setGeneralWorkbenchEntryCheckPending(false);
}
}
})();
return () => {
disposed = true;
};
}, [
contentId,
initialDispatchKey,
isThemeWorkbench,
loadRunState,
loadWorkflow,
messagesCount,
sessionId,
shouldUseCompactGeneralWorkbench,
]);
const clearGeneralWorkbenchEntryPrompt = useCallback(() => {
setGeneralWorkbenchEntryPrompt(null);
}, []);
const dismissGeneralWorkbenchEntryPrompt = useCallback(
(options?: {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}) => {
setGeneralWorkbenchEntryPrompt((current) => {
if (!current) {
return current;
}
if (
current.kind === "initial_prompt" &&
options?.consumeInitialPrompt &&
initialDispatchKey
) {
options.onConsumeInitialPrompt?.();
} else {
dismissedPromptSignatureRef.current = current.signature;
}
return null;
});
},
[initialDispatchKey],
);
return {
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
};
}
@@ -1,196 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGeneralWorkbenchEntryPromptActions } from "./useGeneralWorkbenchEntryPromptActions";
import type { GeneralWorkbenchEntryPromptState } from "./useGeneralWorkbenchEntryPrompt";
interface HookProps {
generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
}
interface HookHarness {
getValue: () => ReturnType<typeof useGeneralWorkbenchEntryPromptActions>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onContinuePrompt: ReturnType<typeof vi.fn>;
dismissGeneralWorkbenchEntryPrompt: ReturnType<typeof vi.fn>;
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
onInputChange: ReturnType<typeof vi.fn>;
onRequirePrompt: ReturnType<typeof vi.fn>;
}
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onContinuePrompt = vi.fn(async () => undefined);
const dismissGeneralWorkbenchEntryPrompt = vi.fn();
const onConsumeInitialPrompt = vi.fn();
const onInputChange = vi.fn();
const onRequirePrompt = vi.fn();
let hookValue: ReturnType<
typeof useGeneralWorkbenchEntryPromptActions
> | null = null;
let currentProps: HookProps = {
generalWorkbenchEntryPrompt: null,
input: "",
initialDispatchKey: null,
...initialProps,
};
function TestComponent() {
hookValue = useGeneralWorkbenchEntryPromptActions({
...currentProps,
onContinuePrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
});
return null;
}
const render = (nextProps?: Partial<HookProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onContinuePrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
};
}
describe("useGeneralWorkbenchEntryPromptActions", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => {
const harness = mountHook({
generalWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "desc",
actionLabel: "继续生成",
prompt: "请先生成主稿",
},
input: "",
initialDispatchKey: "dispatch-1",
});
try {
await act(async () => {
await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿");
harness.rerender({
input: "我已经补充了额外要求",
});
await act(async () => {
await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenLastCalledWith(
"我已经补充了额外要求",
);
} finally {
harness.unmount();
}
});
it("继续时没有任何可发送内容应提示补充", async () => {
const harness = mountHook({
generalWorkbenchEntryPrompt: {
kind: "resume",
signature: "resume-1",
title: "发现上次未完成任务",
description: "desc",
actionLabel: "继续任务",
prompt: " ",
},
input: " ",
});
try {
await act(async () => {
await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1);
expect(harness.onContinuePrompt).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("重新开始初始提示时应消费意图并清空输入", () => {
const harness = mountHook({
generalWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "desc",
actionLabel: "继续生成",
prompt: "请先生成主稿",
},
initialDispatchKey: "dispatch-1",
input: "已有内容",
});
try {
act(() => {
harness.getValue().handleRestartGeneralWorkbenchEntryPrompt();
});
expect(harness.dismissGeneralWorkbenchEntryPrompt).toHaveBeenCalledTimes(
1,
);
const options =
harness.dismissGeneralWorkbenchEntryPrompt.mock.calls[0]?.[0];
expect(options?.consumeInitialPrompt).toBe(true);
options?.onConsumeInitialPrompt?.();
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
expect(harness.onInputChange).toHaveBeenCalledWith("");
} finally {
harness.unmount();
}
});
});
@@ -1,72 +0,0 @@
import { useCallback } from "react";
import type { GeneralWorkbenchEntryPromptState } from "./useGeneralWorkbenchEntryPrompt";
interface DismissGeneralWorkbenchEntryPromptOptions {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}
interface UseGeneralWorkbenchEntryPromptActionsOptions {
generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
onContinuePrompt: (prompt: string) => Promise<void> | void;
dismissGeneralWorkbenchEntryPrompt: (
options?: DismissGeneralWorkbenchEntryPromptOptions,
) => void;
onConsumeInitialPrompt?: (dispatchKey: string | null) => void;
onInputChange: (value: string) => void;
onRequirePrompt?: () => void;
}
export function useGeneralWorkbenchEntryPromptActions({
generalWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
}: UseGeneralWorkbenchEntryPromptActionsOptions) {
const handleContinueGeneralWorkbenchEntryPrompt = useCallback(async () => {
if (!generalWorkbenchEntryPrompt) {
return;
}
const promptToSend =
input.trim() || generalWorkbenchEntryPrompt.prompt.trim();
if (!promptToSend) {
onRequirePrompt?.();
return;
}
await onContinuePrompt(promptToSend);
}, [generalWorkbenchEntryPrompt, input, onContinuePrompt, onRequirePrompt]);
const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => {
if (!generalWorkbenchEntryPrompt) {
return;
}
dismissGeneralWorkbenchEntryPrompt({
consumeInitialPrompt:
generalWorkbenchEntryPrompt.kind === "initial_prompt",
onConsumeInitialPrompt: () => {
onConsumeInitialPrompt?.(initialDispatchKey);
},
});
onInputChange("");
}, [
dismissGeneralWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
initialDispatchKey,
onConsumeInitialPrompt,
onInputChange,
]);
return {
handleContinueGeneralWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
};
}
@@ -1,165 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGeneralWorkbenchSendBoundary } from "./useGeneralWorkbenchSendBoundary";
interface HookHarnessProps {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
mappedTheme: string;
}
interface HookHarness {
getValue: () => ReturnType<typeof useGeneralWorkbenchSendBoundary>;
rerender: (props?: Partial<HookHarnessProps>) => void;
unmount: () => void;
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
onResetConsumedInitialPrompt: ReturnType<typeof vi.fn>;
onClearEntryPrompt: ReturnType<typeof vi.fn>;
}
function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onConsumeInitialPrompt = vi.fn();
const onResetConsumedInitialPrompt = vi.fn();
const onClearEntryPrompt = vi.fn();
let hookValue: ReturnType<typeof useGeneralWorkbenchSendBoundary> | null =
null;
let currentProps: HookHarnessProps = {
isThemeWorkbench: true,
contentId: "content-1",
initialDispatchKey: "dispatch-1",
consumedInitialPromptKey: null,
mappedTheme: "general",
...initialProps,
};
function TestComponent() {
hookValue = useGeneralWorkbenchSendBoundary({
...currentProps,
initialUserImages: [],
socialArticleSkillKey: "content_post_with_cover",
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
});
return null;
}
const render = (nextProps?: Partial<HookHarnessProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
};
}
describe("useGeneralWorkbenchSendBoundary", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("应识别工作区内容编排的首条意图消费", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "请生成今天的社媒主稿",
});
expect(boundary.sourceText).toBe(
"/content_post_with_cover 请生成今天的社媒主稿",
);
expect(boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt).toBe(
true,
);
expect(boundary.shouldDismissGeneralWorkbenchEntryPrompt).toBe(true);
expect(boundary.browserRequirementMatch).toBeNull();
} finally {
harness.unmount();
}
});
it("需要真实浏览器时应仅保留 requirement 检测,不再恢复旧状态机", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "帮我把这篇文章发布到微信公众号后台",
});
expect(boundary.sourceText).toBe(
"/content_post_with_cover 帮我把这篇文章发布到微信公众号后台",
);
expect(boundary.browserRequirementMatch).toEqual(
expect.objectContaining({
requirement: "required_with_user_step",
launchUrl: "https://mp.weixin.qq.com/",
platformLabel: "微信公众号后台",
}),
);
expect(harness.onConsumeInitialPrompt).not.toHaveBeenCalled();
expect(harness.onClearEntryPrompt).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("发送失败时应回滚已消费的首条意图", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "请生成今天的社媒主稿",
});
act(() => {
harness.getValue().rollbackAfterSendFailure(boundary);
});
expect(harness.onResetConsumedInitialPrompt).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
});
});
@@ -1,160 +0,0 @@
import { useCallback } from "react";
import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement";
import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement";
import type { MessageImage } from "../types";
import type { HandleSendOptions } from "./handleSendTypes";
interface BuildGeneralWorkbenchSendBoundaryStateOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
sourceText: string;
sendOptions?: HandleSendOptions;
}
export interface GeneralWorkbenchSendBoundaryState {
sourceText: string;
browserRequirementMatch: BrowserTaskRequirementMatch | null;
shouldConsumePendingGeneralWorkbenchInitialPrompt: boolean;
shouldDismissGeneralWorkbenchEntryPrompt: boolean;
}
interface UseGeneralWorkbenchSendBoundaryOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
onConsumeInitialPrompt: (dispatchKey: string) => void;
onResetConsumedInitialPrompt: () => void;
onClearEntryPrompt: () => void;
}
export function buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}: BuildGeneralWorkbenchSendBoundaryStateOptions): GeneralWorkbenchSendBoundaryState {
const shouldConsumePendingGeneralWorkbenchInitialPrompt =
isThemeWorkbench &&
Boolean(contentId) &&
Boolean(initialDispatchKey) &&
consumedInitialPromptKey !== initialDispatchKey &&
(initialUserImages || []).length === 0 &&
!sendOptions?.purpose;
const shouldDismissGeneralWorkbenchEntryPrompt =
isThemeWorkbench && !sendOptions?.purpose;
const trimmedSourceText = sourceText.trim();
const shouldWrapWithGeneralWorkbenchSkill =
isThemeWorkbench &&
mappedTheme === "general" &&
!sendOptions?.purpose &&
trimmedSourceText.length > 0 &&
!trimmedSourceText.startsWith("/") &&
!trimmedSourceText.startsWith("@");
const nextSourceText = shouldWrapWithGeneralWorkbenchSkill
? `/${socialArticleSkillKey} ${trimmedSourceText}`
: sourceText;
const browserRequirementSourceText = shouldWrapWithGeneralWorkbenchSkill
? trimmedSourceText
: nextSourceText;
const browserRequirementMatch =
mappedTheme === "general" && !sendOptions?.purpose
? detectBrowserTaskRequirement(browserRequirementSourceText)
: null;
return {
sourceText: nextSourceText,
browserRequirementMatch,
shouldConsumePendingGeneralWorkbenchInitialPrompt,
shouldDismissGeneralWorkbenchEntryPrompt,
};
}
export function useGeneralWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
}: UseGeneralWorkbenchSendBoundaryOptions) {
const resolveSendBoundary = useCallback(
({
sourceText,
sendOptions,
}: {
sourceText: string;
sendOptions?: HandleSendOptions;
}) =>
buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}),
[
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserImages,
isThemeWorkbench,
mappedTheme,
socialArticleSkillKey,
],
);
const finalizeAfterSendSuccess = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (
boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt &&
initialDispatchKey
) {
onConsumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissGeneralWorkbenchEntryPrompt) {
onClearEntryPrompt();
}
},
[initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt],
);
const rollbackAfterSendFailure = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt) {
onResetConsumedInitialPrompt();
}
},
[onResetConsumedInitialPrompt],
);
return {
resolveSendBoundary,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
};
}
+34
View File
@@ -3859,6 +3859,40 @@ describe("AgentChatPage 自动引导", { timeout: 20_000 }, () => {
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
});
it("初始创作意图点击重新开始后应清空输入并消费待执行意图", async () => {
mockIsSpecializedWorkbenchTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
const onInitialUserPromptConsumed = vi.fn();
const initialUserPrompt = "请先帮我写一篇社媒文案提纲。";
const container = renderPage({
projectId: "project-social-intent-restart",
contentId: "content-social-intent-restart",
theme: "general",
lockTheme: true,
initialUserPrompt,
onInitialUserPromptConsumed,
});
await flushEffects(12);
clickButton(container, "theme-workbench-entry-restart");
await flushEffects(12);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| { input?: string }
| undefined;
expect(latestInputbarProps?.input || "").toBe("");
expect(
container.querySelector('[data-testid="theme-workbench-entry-prompt"]'),
).toBeNull();
expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1);
});
it("存在 initialRequestMetadata 时应把结构化回放透传到首发 requestMetadata", async () => {
mockIsSpecializedWorkbenchTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
@@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest";
const PROTOCOL_GUARD_DIRS = [
join(process.cwd(), "src/components/agent/chat"),
join(process.cwd(), "src/components/terminal/ai"),
join(process.cwd(), "src/components/smart-input"),
] as const;
const API_PROTOCOL_GUARD_DIR = join(process.cwd(), "src/lib/api");
@@ -13,7 +13,7 @@ import {
type TeamWorkspaceRuntimeSessionSnapshot,
type TeamWorkspaceRuntimeStatus,
} from "../teamWorkspaceRuntime";
import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo";
import { resolveUserFacingToolDisplayLabel } from "../utils/toolDisplayInfo";
import { resolveTeamWorkspaceDisplayRuntimeStatusLabel } from "../utils/teamWorkspaceCopy";
const LIVE_ACTIVITY_ENTRY_LIMIT = 3;
@@ -210,7 +210,7 @@ function buildToolActivityEntry(params: {
}) {
const { sessionId, toolId, toolName, result } = params;
const displayToolName = toolName?.trim()
? resolveToolDisplayLabel(toolName)
? resolveUserFacingToolDisplayLabel(toolName)
: null;
const title = displayToolName ? `处理中 · ${displayToolName}` : "处理中";
const detail = result
@@ -13,7 +13,7 @@ import {
resolveTeamWorkspaceDisplayMemberStatusLabel,
resolveTeamWorkspaceDisplayRuntimeStatusLabel,
} from "./utils/teamWorkspaceCopy";
import { resolveToolDisplayLabel } from "./utils/toolDisplayInfo";
import { resolveUserFacingToolDisplayLabel } from "./utils/toolDisplayInfo";
export type TeamWorkspaceRuntimeStatus =
AsterSubagentSessionInfo["runtime_status"];
@@ -332,7 +332,7 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): {
};
case "tool_call": {
const displayToolName = item.tool_name
? resolveToolDisplayLabel(item.tool_name)
? resolveUserFacingToolDisplayLabel(item.tool_name)
: null;
return {
title: displayToolName ? `工具 ${displayToolName}` : "工具输出",
@@ -112,9 +112,9 @@ describe("agentThreadGrouping", () => {
"artifact",
"process",
]);
expect(model.groups[1]?.previewLines).toEqual(["产出了 wechat-draft.md"]);
expect(model.groups[1]?.previewLines).toEqual(["生成了 wechat-draft.md"]);
expect(model.groups[2]?.previewLines).toContain(
"执行了 npm test -- AgentThreadTimeline",
"运行了 npm test -- AgentThreadTimeline",
);
expect(model.summaryChips).toEqual([
{ kind: "process", label: "执行过程", count: 3 },
@@ -139,7 +139,7 @@ describe("agentThreadGrouping", () => {
const model = buildAgentThreadDisplayModel(items);
expect(model.groups.map((group) => group.kind)).toEqual(["process"]);
expect(model.groups[0]?.previewLines).toEqual(["写了 nested-draft.md"]);
expect(model.groups[0]?.previewLines).toEqual(["保存了 nested-draft.md"]);
});
it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => {
@@ -166,8 +166,8 @@ describe("agentThreadGrouping", () => {
expect(model.groups.map((group) => group.kind)).toEqual(["process"]);
expect(model.groups[0]?.previewLines).toEqual([
"看了 reports",
"动了 run.log",
"查看了 reports",
"处理了 run.log",
]);
});
@@ -226,6 +226,68 @@ describe("agentThreadGrouping", () => {
]);
});
it("交互与任务结果预览应使用更直白的用户文案", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("question-1", 1),
type: "tool_call",
tool_name: "AskUserQuestion",
arguments: { question: "需要继续吗?" },
},
{
...createBaseItem("task-output-1", 2),
type: "tool_call",
tool_name: "TaskOutput",
arguments: { task_id: "video-task-1" },
},
{
...createBaseItem("list-peers-1", 3),
type: "tool_call",
tool_name: "ListPeers",
arguments: { team_name: "当前团队" },
},
];
const model = buildAgentThreadDisplayModel(items);
expect(model.groups[0]?.previewLines).toEqual([
"等你确认:需要继续吗?",
"已查看结果 video-task-1",
"已查看 当前团队",
]);
});
it("协作任务控制预览应直接表达查看、继续与暂停动作", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("wait-agent-1", 1),
type: "tool_call",
tool_name: "WaitAgent",
arguments: { id: "agent-1" },
},
{
...createBaseItem("resume-agent-1", 2),
type: "tool_call",
tool_name: "ResumeAgent",
arguments: { id: "agent-1" },
},
{
...createBaseItem("close-agent-1", 3),
type: "tool_call",
tool_name: "CloseAgent",
arguments: { id: "agent-1" },
},
];
const model = buildAgentThreadDisplayModel(items);
expect(model.groups[0]?.previewLines).toEqual([
"已查看 agent-1",
"已继续 agent-1",
"已暂停 agent-1",
]);
});
it("内部路由型 turn_summary 不应抢占整轮摘要", () => {
const items: AgentThreadItem[] = [
{
@@ -6,7 +6,9 @@ import {
} from "@/lib/filesystem-event-protocol";
import type { AgentThreadItem, AgentThreadItemStatus } from "../types";
import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
import {
resolveUserFacingToolDisplayLabel,
} from "./toolDisplayInfo";
import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation";
export type AgentThreadGroupKind =
@@ -456,7 +458,7 @@ function summarizeSearchItem(item: AgentThreadItem): string | null {
const args = asRecord(item.arguments);
return prefixAction(
readString(args, ["query", "q", "pattern", "search", "url"]) ||
resolveToolDisplayLabel(item.tool_name),
resolveUserFacingToolDisplayLabel(item.tool_name),
"搜了 ",
["搜了 ", "查了 ", "搜索了 ", "检索了 "],
);
@@ -467,12 +469,13 @@ function summarizeFileItem(item: AgentThreadItem): string | null {
const fileLabel = path ? fileNameFromPath(path) : null;
if (item.type === "file_artifact") {
return prefixAction(fileLabel || item.path, "产出了 ", [
return prefixAction(fileLabel || item.path, "生成了 ", [
"生成了 ",
"产出了 ",
"写了 ",
"改了 ",
"看了 ",
"动了 ",
"保存了 ",
"修改了 ",
"查看了 ",
"处理了 ",
]);
}
@@ -487,9 +490,9 @@ function summarizeFileItem(item: AgentThreadItem): string | null {
normalized.includes("list")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"看了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name),
"查看了 ",
["查看了 ", "看了 ", "读了 ", "保存了 ", "修改了 ", "处理了 "],
);
}
@@ -500,9 +503,9 @@ function summarizeFileItem(item: AgentThreadItem): string | null {
normalized.includes("save")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"写了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name),
"保存了 ",
["保存了 ", "写了 ", "查看了 ", "修改了 ", "处理了 "],
);
}
@@ -513,16 +516,16 @@ function summarizeFileItem(item: AgentThreadItem): string | null {
normalized.includes("update")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"改了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name),
"修改了 ",
["修改了 ", "改了 ", "查看了 ", "保存了 ", "处理了 "],
);
}
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"动了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name),
"处理了 ",
["处理了 ", "查看了 ", "保存了 ", "修改了 ", "生成了 "],
);
}
@@ -533,7 +536,7 @@ function summarizeCommandItem(item: AgentThreadItem): string | null {
if (item.type === "command_execution") {
return prefixAction(
item.command,
"执行了 ",
"运行了 ",
["执行了 ", "跑了 ", "运行了 "],
64,
);
@@ -543,8 +546,8 @@ function summarizeCommandItem(item: AgentThreadItem): string | null {
const args = asRecord(item.arguments);
return prefixAction(
readString(args, ["command", "cmd", "script"]) ||
resolveToolDisplayLabel(item.tool_name),
"执行了 ",
resolveUserFacingToolDisplayLabel(item.tool_name),
"运行了 ",
["执行了 ", "跑了 ", "运行了 "],
64,
);
@@ -609,21 +612,32 @@ function summarizeCollaborationItem(item: AgentThreadItem): string | null {
if (normalized === "listpeers") {
return prefixAction(
readString(args, ["team_name", "teamName"]) || "当前团队",
"已列出 ",
["已列出 ", "列出了 ", "查看了 "],
"已查看 ",
["已查看 ", "查看了 ", "已列出 ", "列出了 "],
);
}
if (
normalized === "waitagent" ||
normalized === "resumeagent" ||
normalized === "closeagent"
) {
if (normalized === "waitagent") {
return prefixAction(
readString(args, ["id", "ids", "session_id"]) ||
resolveToolDisplayLabel(item.tool_name),
"处理了 ",
["处理了 ", "继续了 ", "暂停了 ", "查看了 "],
readString(args, ["id", "ids", "session_id"]) || "任务进展",
"已查看 ",
["已查看 ", "查看了 "],
);
}
if (normalized === "resumeagent") {
return prefixAction(
readString(args, ["id", "ids", "session_id"]) || "当前任务",
"已继续 ",
["已继续 ", "继续了 "],
);
}
if (normalized === "closeagent") {
return prefixAction(
readString(args, ["id", "ids", "session_id"]) || "当前任务",
"已暂停 ",
["已暂停 ", "暂停了 "],
);
}
@@ -665,20 +679,56 @@ function summarizeOtherItem(item: AgentThreadItem): string | null {
const normalized = normalizeToolName(item.tool_name);
const args = asRecord(item.arguments);
if (normalized === "askuserquestion") {
return prefixAction(
readString(args, ["question", "prompt", "header"]) || "等你确认这一步",
"等你确认:",
["等你确认:", "等你补充:"],
);
}
if (normalized === "taskoutput") {
return prefixAction(
readString(args, ["task_id", "taskId", "subject"]) || "任务结果",
"已查看结果 ",
["已查看结果 ", "查看了 "],
);
}
if (normalized === "listskills") {
return "已查看技能列表";
}
if (normalized === "loadskill") {
return prefixAction(
readString(args, ["name", "skill", "path"]) || "技能",
"已加载 ",
["已加载 ", "加载了 "],
);
}
if (normalized === "skill") {
return prefixAction(
readString(args, ["name", "skill", "path", "command"]) || "技能",
"已使用 ",
["已使用 ", "用了 "],
);
}
if (normalized === "sendusermessage" || normalized === "brief") {
return prefixAction(
readString(args, ["message"]) ||
resolveToolDisplayLabel(item.tool_name),
resolveUserFacingToolDisplayLabel(item.tool_name),
"已发送 ",
["已发送 ", "发送了 "],
);
}
return prefixAction(resolveToolDisplayLabel(item.tool_name), "执行了 ", [
"执行了 ",
"跑了 ",
"运行了 ",
]);
return prefixAction(
resolveUserFacingToolDisplayLabel(item.tool_name),
"处理了 ",
["处理了 ", "执行了 ", "跑了 ", "运行了 "],
);
}
return null;
}
@@ -10,7 +10,7 @@ import {
} from "@/lib/artifact-protocol";
import { extractFilesystemEventPathsFromValue } from "@/lib/filesystem-event-protocol";
import type { ActionRequired, AgentRuntimeStatus, Message } from "../types";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
import { resolveUserFacingToolDisplayLabel } from "./toolDisplayInfo";
import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation";
import {
resolveArtifactPreviewText,
@@ -1305,7 +1305,7 @@ function deriveHarnessSessionStateFromItems(
summary:
artifactPath ||
queryLabel ||
resolveToolDisplayLabel(item.tool_name),
resolveUserFacingToolDisplayLabel(item.tool_name),
preview: buildTextPreview(item.output),
content: maybeKeepTextContent(item.output),
artifactPath,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
sanitizeContentPartsForDisplay,
sanitizeMessageTextForDisplay,
} from "./internalImagePlaceholder";
import type { ContentPart } from "../types";
describe("internalImagePlaceholder", () => {
it("应清理紧邻工具调用的调度自述文本", () => {
const contentParts: ContentPart[] = [
{
type: "text",
text: "ToolSearch 只返回了元数据,让我直接调用 WebSearch 进行多组检索。",
},
{
type: "tool_use",
toolCall: {
id: "tool-narration-strip",
name: "WebSearch",
arguments: "{}",
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-13T09:00:00.000Z"),
},
},
{
type: "text",
text: "已经整理出 3 个可信来源。",
},
];
expect(
sanitizeContentPartsForDisplay(contentParts, {
role: "assistant",
}),
).toEqual([
contentParts[1],
contentParts[2],
]);
});
it("应清理紧邻工具调用的页面操作自述", () => {
const contentParts: ContentPart[] = [
{
type: "tool_use",
toolCall: {
id: "tool-narration-page",
name: "webReader",
arguments: "{}",
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-13T09:01:00.000Z"),
},
},
{
type: "text",
text: "我已经打开 GitHub 搜索页,接下来开始筛选结果。",
},
{
type: "text",
text: "筛到两个官方仓库入口。",
},
];
expect(
sanitizeContentPartsForDisplay(contentParts, {
role: "assistant",
}),
).toEqual([
contentParts[0],
contentParts[2],
]);
});
it("带结论的正常说明不应被误删", () => {
const contentParts: ContentPart[] = [
{
type: "text",
text: "我用 WebSearch 查到 3 个官方来源,结论是目前只支持桌面端。",
},
{
type: "tool_use",
toolCall: {
id: "tool-narration-keep",
name: "WebSearch",
arguments: "{}",
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-13T09:02:00.000Z"),
},
},
];
expect(
sanitizeContentPartsForDisplay(contentParts, {
role: "assistant",
}),
).toEqual(contentParts);
});
it("不挨着工具调用的普通说明不应被清理", () => {
const contentParts: ContentPart[] = [
{
type: "text",
text: "ToolSearch 用于查询当前可用工具,这里是在解释概念。",
},
{
type: "text",
text: "下面再继续说明使用方式。",
},
];
expect(
sanitizeContentPartsForDisplay(contentParts, {
role: "assistant",
}),
).toEqual(contentParts);
});
it("普通消息文本清洗仍不应误删工具说明", () => {
const text =
"ToolSearch 用于查询当前可用工具,这里是在给用户解释概念。";
expect(
sanitizeMessageTextForDisplay(text, {
role: "assistant",
}),
).toBe(text);
});
});
@@ -10,6 +10,21 @@ const BRACKET_IMAGE_PLACEHOLDER_TEST_RE = /\[\s*Image\s*#\d+\s*\]/i;
const BARE_IMAGE_PLACEHOLDER_TEST_RE =
/(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/i;
const EXACT_IMAGE_TASK_LABEL_RE = /^\[?\s*Image\s*#(\d+)\s*\]?$/i;
const TOOL_NARRATION_TOOL_NAME_RE =
/\b(?:ToolSearch|WebSearch|WebFetch|Read|Write|Edit|Glob|Grep|Bash|StructuredOutput|webReader)\b|(?:mcp__[\w-]+(?:__[\w-]+)?|lime_[\w-]+)/i;
const TOOL_NARRATION_ACTION_RE =
/调用|使用|执行|检索|搜索|读取|抓取|访问|打开|分析|查找|扩搜|筛选|切换|转去|改为|尝试/i;
const TOOL_NARRATION_SELF_PROCESS_RE =
/让我|我将|我会|接下来|现在|继续|直接|先|然后|随后|改为|转去|尝试|开始/i;
const TOOL_NARRATION_SCHEDULING_RE =
/只返回了元数据|未命中|没有返回|改为|转去|切换到|直接调用/i;
const TOOL_NARRATION_NAVIGATION_TARGET_RE =
/搜索页|结果页|网页|页面|链接|文件|目录|仓库|日志|结果/i;
const TOOL_NARRATION_NAVIGATION_RE =
/已经打开|已打开|打开了|开始筛选|继续筛选|开始查看|继续查看|开始检索|继续检索|开始分析|继续分析|开始整理|继续整理/i;
const TOOL_NARRATION_RESULT_RE =
/结果如下|结论|我发现|发现了|查到|查到了|显示|表明|说明|意味着|共有|共计|\d+\s*(?:个|条|项|篇|页|处)/i;
const TOOL_NARRATION_MAX_LENGTH = 120;
function collapseDisplayWhitespace(value: string): string {
return value
@@ -32,6 +47,41 @@ function replaceImagePlaceholders(text: string, replacement: string): string {
);
}
function hasAdjacentToolUse(
parts: ContentPart[],
index: number,
): boolean {
return (
parts[index - 1]?.type === "tool_use" || parts[index + 1]?.type === "tool_use"
);
}
function shouldStripAssistantToolNarration(text: string): boolean {
const normalized = collapseDisplayWhitespace(text);
if (!normalized || normalized.length > TOOL_NARRATION_MAX_LENGTH) {
return false;
}
if (TOOL_NARRATION_RESULT_RE.test(normalized)) {
return false;
}
const hasToolName = TOOL_NARRATION_TOOL_NAME_RE.test(normalized);
const hasAction = TOOL_NARRATION_ACTION_RE.test(normalized);
const hasSelfProcess = TOOL_NARRATION_SELF_PROCESS_RE.test(normalized);
const hasSchedulingCue = TOOL_NARRATION_SCHEDULING_RE.test(normalized);
if (hasToolName && hasAction && (hasSelfProcess || hasSchedulingCue)) {
return true;
}
return (
hasSelfProcess &&
TOOL_NARRATION_NAVIGATION_RE.test(normalized) &&
TOOL_NARRATION_NAVIGATION_TARGET_RE.test(normalized)
);
}
export function containsInternalImagePlaceholder(text: string): boolean {
return (
BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) ||
@@ -125,7 +175,7 @@ export function sanitizeContentPartsForDisplay(
return parts;
}
const sanitizedParts = parts.flatMap<ContentPart>((part) => {
const sanitizedParts = parts.flatMap<ContentPart>((part, index) => {
if (part.type !== "text") {
return [part];
}
@@ -135,6 +185,14 @@ export function sanitizeContentPartsForDisplay(
return [];
}
if (
options.role === "assistant" &&
hasAdjacentToolUse(parts, index) &&
shouldStripAssistantToolNarration(sanitizedText)
) {
return [];
}
return [
{
...part,
@@ -46,4 +46,31 @@ describe("protocolResidue", () => {
expect(containsAssistantProtocolResidue(normal)).toBe(false);
expect(stripAssistantProtocolResidue(normal)).toBe(normal);
});
it("应清理 provider 泄露的 Built-in Tool 执行痕迹,但保留正常说明", () => {
const leaked = [
"让我们进行多组 WebSearch 检索,获取最新热点。 Z.ai Built-in Tool: webReader",
"",
"Input:",
"JSON",
'{"url":"https://example.com/search?q=ai","return_format":"text"}',
"",
'Executing on server... Output: webReader_result_summary: [{"text":"ok","type":"text"}]',
"",
"我会继续整理结果。",
].join("\n");
expect(containsAssistantProtocolResidue(leaked)).toBe(true);
expect(stripAssistantProtocolResidue(leaked)).toBe(
"让我们进行多组 WebSearch 检索,获取最新热点。\n\n我会继续整理结果。",
);
});
it("正常解释 Input 与 Output 概念时不应误删", () => {
const normal =
"Input 是工具入参,Output 是执行结果,这里只是解释概念,不是运行时协议残留。";
expect(containsAssistantProtocolResidue(normal)).toBe(false);
expect(stripAssistantProtocolResidue(normal)).toBe(normal);
});
});
@@ -2,6 +2,10 @@ const TOOL_PROTOCOL_BLOCK_RE =
/<tool_(call|result)\b[^>]*>[\s\S]*?<\/tool_\1>/gi;
const TOOL_PROTOCOL_TAG_RE = /<\/?tool_(?:call|result)\b[^>]*\/?>/gi;
const TOOL_PROTOCOL_DETECT_RE = /<\/?tool_(?:call|result)\b[^>]*\/?>/i;
const PROVIDER_TRACE_TOOL_MARKER_RE =
/(?:^|\s)(?:[A-Za-z0-9.-]+\s+)?Built-in Tool:\s*[A-Za-z0-9_.-]+/i;
const PROVIDER_TRACE_EXECUTING_RE = /^executing on server(?:\.\.\.)?/i;
const PROVIDER_TRACE_RESULT_SUMMARY_RE = /\b[A-Za-z0-9_]+_result_summary\b/i;
const INTERNAL_PROTOCOL_PARAGRAPH_PATTERNS = [
/you must call the [`"]?structuredoutput[`"]? tool now/i,
/you must use the [`"]?structuredoutput[`"]? tool/i,
@@ -19,6 +23,55 @@ const INTERNAL_PROTOCOL_LINE_PATTERNS = [
/^output final deliver artifact document$/i,
] as const;
function isLikelyProviderTraceJsonLine(line: string): boolean {
const normalized = line.trim();
if (!normalized) {
return true;
}
return (
normalized === "JSON" ||
normalized === "```json" ||
normalized === "```" ||
/^(?:\[|{)/.test(normalized) ||
/^(?:\]|})/.test(normalized) ||
/^"(?:[^"]+)"\s*:/.test(normalized) ||
/^[:,}\]]+$/.test(normalized)
);
}
function isProviderTraceContinuationLine(line: string): boolean {
const normalized = line.trim();
if (!normalized) {
return true;
}
return (
/^input:$/i.test(normalized) ||
/^output:/i.test(normalized) ||
PROVIDER_TRACE_EXECUTING_RE.test(normalized) ||
PROVIDER_TRACE_RESULT_SUMMARY_RE.test(normalized) ||
isLikelyProviderTraceJsonLine(normalized)
);
}
function stripInlineProviderTraceStart(line: string): string | null {
const match = line.match(PROVIDER_TRACE_TOOL_MARKER_RE);
if (!match || typeof match.index !== "number") {
return null;
}
return line.slice(0, match.index).trimEnd();
}
function containsProviderTraceResidue(text: string): boolean {
return (
PROVIDER_TRACE_TOOL_MARKER_RE.test(text) ||
PROVIDER_TRACE_EXECUTING_RE.test(text) ||
PROVIDER_TRACE_RESULT_SUMMARY_RE.test(text)
);
}
function isAssistantProtocolResidueLine(line: string): boolean {
const normalized = line.trim();
if (!normalized) {
@@ -66,8 +119,88 @@ function normalizeProtocolStripWhitespace(text: string): string {
.trim();
}
function stripProviderTraceResidue(text: string): string {
if (!text) {
return "";
}
const lines = text.split(/\r?\n/);
const keptLines: string[] = [];
let skippingTrace = false;
let sawBlankWithinTrace = false;
const appendLine = (line: string) => {
keptLines.push(line);
};
for (const line of lines) {
if (!skippingTrace) {
const inlinePrefix = stripInlineProviderTraceStart(line);
if (inlinePrefix !== null) {
if (inlinePrefix) {
appendLine(inlinePrefix);
}
skippingTrace = true;
sawBlankWithinTrace = false;
continue;
}
if (
PROVIDER_TRACE_EXECUTING_RE.test(line.trim()) ||
PROVIDER_TRACE_RESULT_SUMMARY_RE.test(line)
) {
skippingTrace = true;
sawBlankWithinTrace = false;
continue;
}
appendLine(line);
continue;
}
if (isProviderTraceContinuationLine(line)) {
if (!line.trim()) {
sawBlankWithinTrace = true;
}
continue;
}
if (
sawBlankWithinTrace &&
keptLines.length > 0 &&
keptLines[keptLines.length - 1]?.trim() !== ""
) {
appendLine("");
}
skippingTrace = false;
sawBlankWithinTrace = false;
const inlinePrefix = stripInlineProviderTraceStart(line);
if (inlinePrefix !== null) {
if (inlinePrefix) {
appendLine(inlinePrefix);
}
skippingTrace = true;
continue;
}
if (
PROVIDER_TRACE_EXECUTING_RE.test(line.trim()) ||
PROVIDER_TRACE_RESULT_SUMMARY_RE.test(line)
) {
skippingTrace = true;
continue;
}
appendLine(line);
}
return normalizeProtocolStripWhitespace(keptLines.join("\n"));
}
export function containsAssistantProtocolResidue(text: string): boolean {
if (TOOL_PROTOCOL_DETECT_RE.test(text)) {
if (TOOL_PROTOCOL_DETECT_RE.test(text) || containsProviderTraceResidue(text)) {
return true;
}
@@ -85,7 +218,8 @@ export function stripAssistantProtocolResidue(text: string): string {
return "";
}
const withoutBlocks = text.replace(TOOL_PROTOCOL_BLOCK_RE, "\n");
const withoutProviderTrace = stripProviderTraceResidue(text);
const withoutBlocks = withoutProviderTrace.replace(TOOL_PROTOCOL_BLOCK_RE, "\n");
const withoutTags = withoutBlocks.replace(TOOL_PROTOCOL_TAG_RE, "\n");
const sanitizedParagraphs = withoutTags
.split(/\n{2,}/)
@@ -342,6 +342,22 @@ export function resolveSiteProjectSourceLabel(source?: string): string | null {
return null;
}
export function resolveSiteProjectTargetLabel(params: {
source?: string;
projectId?: string;
}): string {
if (params.source === "context_project") {
return "当前项目";
}
if (params.source === "explicit_project") {
return "所选项目";
}
if (params.projectId?.trim()) {
return `项目 ${params.projectId.trim()}`;
}
return "项目";
}
export function resolveSiteAdapterSourceLabel(
summary: SiteToolResultSummary,
): string | null {
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import {
buildToolHeadline,
buildToolGroupHeadline,
extractSearchQueryLabel,
getToolDisplayInfo,
normalizeToolNameKey,
resolveUserFacingToolDisplayLabel,
resolveToolDisplayLabel,
} from "./toolDisplayInfo";
@@ -73,6 +77,17 @@ describe("toolDisplayInfo", () => {
expect(resolveToolDisplayLabel("BashOutputTool")).toBe("任务输出");
});
it("应为用户可见场景提供更自然的工具标签", () => {
expect(resolveUserFacingToolDisplayLabel("FileReadTool")).toBe("查看文件");
expect(resolveUserFacingToolDisplayLabel("write_file")).toBe("保存文件");
expect(resolveUserFacingToolDisplayLabel("TaskOutput")).toBe(
"查看任务结果",
);
expect(resolveUserFacingToolDisplayLabel("mcp__playwright__browser_click")).toBe(
"页面点击",
);
});
it("应隐藏 ToolSearch 中的内部协议查询词", () => {
expect(
extractSearchQueryLabel({
@@ -84,4 +99,68 @@ describe("toolDisplayInfo", () => {
}),
).toBe("内部流程");
});
it("无主体对象时应直接展示动作句,避免重复拼接工具类别", () => {
expect(
buildToolHeadline({
toolDisplay: getToolDisplayInfo("TaskList", "completed"),
toolName: "TaskList",
}),
).toBe("已获取任务列表");
expect(
buildToolHeadline({
toolDisplay: getToolDisplayInfo("ListSkills", "completed"),
toolName: "ListSkills",
}),
).toBe("已获取技能列表");
});
it("应为查看类与计划类批次生成更自然的标题", () => {
expect(
buildToolGroupHeadline([
{
id: "tool-read-1",
name: "Read",
arguments: JSON.stringify({ file_path: "docs/guide.md" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-13T00:00:00.000Z"),
endTime: new Date("2026-04-13T00:00:01.000Z"),
},
{
id: "tool-glob-1",
name: "glob",
arguments: JSON.stringify({ pattern: "src/**/*.tsx" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-13T00:00:02.000Z"),
endTime: new Date("2026-04-13T00:00:03.000Z"),
},
]),
).toBe("已查看");
expect(
buildToolGroupHeadline([
{
id: "tool-task-list-1",
name: "TaskList",
arguments: JSON.stringify({}),
status: "completed",
result: { success: true, output: "[]" },
startTime: new Date("2026-04-13T00:00:04.000Z"),
endTime: new Date("2026-04-13T00:00:05.000Z"),
},
{
id: "tool-task-update-1",
name: "TaskUpdate",
arguments: JSON.stringify({ task_id: "task-1" }),
status: "completed",
result: { success: true, output: "{}" },
startTime: new Date("2026-04-13T00:00:06.000Z"),
endTime: new Date("2026-04-13T00:00:07.000Z"),
},
]),
).toBe("已处理 2 项安排");
});
});
@@ -89,19 +89,19 @@ const TOOL_STATUS_ACTIONS = {
running: "搜索中",
},
read: {
failed: "读取失败",
completed: "已读取",
running: "读取中",
failed: "查看失败",
completed: "已查看",
running: "查看中",
},
list: {
failed: "列出失败",
completed: "已列出",
running: "浏览中",
failed: "查看失败",
completed: "已查看",
running: "查看中",
},
write: {
failed: "写入失败",
completed: "已写入",
running: "写入中",
failed: "保存失败",
completed: "已保存",
running: "保存中",
},
edit: {
failed: "编辑失败",
@@ -109,9 +109,9 @@ const TOOL_STATUS_ACTIONS = {
running: "编辑中",
},
command: {
failed: "执行失败",
completed: "已执行",
running: "执行中",
failed: "运行失败",
completed: "已运行",
running: "运行中",
},
plan: {
failed: "更新失败",
@@ -148,7 +148,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "read",
label: "文件读取",
verb: "读取",
verb: "查看",
icon: Eye,
groupTitle: "探索",
actionKey: "read",
@@ -159,7 +159,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "read",
label: "文件读取",
verb: "读取",
verb: "查看",
icon: Eye,
groupTitle: "探索",
actionKey: "read",
@@ -170,7 +170,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "read",
label: "文档读取",
verb: "读取",
verb: "查看",
icon: FileText,
groupTitle: "探索",
actionKey: "read",
@@ -181,7 +181,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "read",
label: "资源读取",
verb: "读取",
verb: "查看",
icon: FileText,
groupTitle: "探索",
actionKey: "read",
@@ -192,7 +192,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "write",
label: "文件写入",
verb: "写入",
verb: "保存",
icon: FilePlus,
groupTitle: "写入",
actionKey: "write",
@@ -203,7 +203,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "write",
label: "文件写入",
verb: "写入",
verb: "保存",
icon: FilePlus,
groupTitle: "写入",
actionKey: "write",
@@ -214,7 +214,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "write",
label: "文件创建",
verb: "创建",
verb: "保存",
icon: FilePlus,
groupTitle: "写入",
actionKey: "write",
@@ -280,10 +280,15 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "文件匹配",
verb: "列出",
verb: "查找",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
actions: {
failed: "查找失败",
completed: "已找到",
running: "查找中",
},
},
],
[
@@ -291,7 +296,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "目录浏览",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -302,7 +307,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "目录浏览",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -313,7 +318,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "目录浏览",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -324,7 +329,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "目录浏览",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -335,7 +340,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "资源列表",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -346,7 +351,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "list",
label: "资源模板列表",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -511,7 +516,7 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "command",
label: "命令执行",
verb: "执行",
verb: "运行",
icon: Terminal,
groupTitle: "命令",
actionKey: "command",
@@ -651,14 +656,14 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
{
family: "task",
label: "任务输出",
verb: "读取输出",
verb: "查看结果",
icon: FileText,
groupTitle: "任务",
actionKey: "task",
actions: {
failed: "读取失败",
completed: "已读取输出",
running: "读取中",
failed: "查看结果失败",
completed: "已查看结果",
running: "查看结果中",
},
},
],
@@ -1501,6 +1506,41 @@ const TOOL_NAME_KEY_ALIASES: Record<string, string> = {
writetodos: "taskupdate",
};
const USER_FACING_TOOL_LABELS: Record<string, string> = {
文件读取: "查看文件",
文档读取: "查看文档",
资源读取: "查看内容",
文件写入: "保存文件",
文件创建: "保存文件",
文件编辑: "修改文件",
批量编辑: "修改文件",
笔记本编辑: "修改文件",
补丁应用: "修改文件",
文件匹配: "查找文件",
目录浏览: "查看文件夹",
资源列表: "查看资源",
资源模板列表: "查看资源模板",
内容检索: "查找内容",
工具搜索: "查找工具",
文档搜索: "查找文档",
文档查询: "查看文档",
网络搜索: "搜索网页",
图片搜索: "搜索图片",
联网搜图: "搜索图片",
命令执行: "运行命令",
技能执行: "使用技能",
技能列表: "查看技能",
技能加载: "加载技能",
任务输出: "查看任务结果",
工作区同步: "同步内容",
图像分析: "分析图片",
图片查看: "查看图片",
站点能力目录: "查看站点能力",
站点能力搜索: "搜索站点能力",
站点能力详情: "查看站点能力",
站点能力执行: "运行站点能力",
};
export const normalizeToolNameKey = (value: string): string => {
const normalized = value
.replace(/[\s_-]+/g, "")
@@ -1569,6 +1609,11 @@ export const resolveToolPrimarySubject = (
filePath?: string | null,
): string | null => {
const normalizedName = normalizeToolNameKey(toolName);
const searchQueryPreview = resolveToolArgumentPreview(args, [
"query",
"q",
"search_query",
]);
if (filePath) return getFileName(filePath);
@@ -1723,6 +1768,16 @@ export const resolveToolPrimarySubject = (
return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力";
}
if (normalizedName === "toolsearch") {
if (
searchQueryPreview &&
!/^(?:select|tool|tools|name|tag):/i.test(searchQueryPreview)
) {
return searchQueryPreview;
}
return "可用工具";
}
if (normalizedName === "askuserquestion") {
return resolveToolArgumentPreview(args, [
"question",
@@ -1867,7 +1922,7 @@ export const getToolDisplayInfo = (
{
family: "write",
label: "文件写入",
verb: "写入",
verb: "保存",
icon: FilePlus,
groupTitle: "写入",
actionKey: "write",
@@ -1881,7 +1936,7 @@ export const getToolDisplayInfo = (
{
family: "read",
label: "文件读取",
verb: "读取",
verb: "查看",
icon: Eye,
groupTitle: "探索",
actionKey: "read",
@@ -1899,7 +1954,7 @@ export const getToolDisplayInfo = (
{
family: "command",
label: "命令执行",
verb: "执行",
verb: "运行",
icon: Terminal,
groupTitle: "命令",
actionKey: "command",
@@ -1931,7 +1986,7 @@ export const getToolDisplayInfo = (
{
family: "list",
label: "目录浏览",
verb: "列出",
verb: "查看",
icon: FolderOpen,
groupTitle: "探索",
actionKey: "list",
@@ -1962,7 +2017,7 @@ export const buildToolHeadline = (params: {
}
if (toolDisplay.label !== humanizeToolName(toolName)) {
return `${toolDisplay.action} ${toolDisplay.label}`;
return toolDisplay.action;
}
return toolDisplay.label;
@@ -1971,6 +2026,14 @@ export const buildToolHeadline = (params: {
export const resolveToolDisplayLabel = (toolName: string): string =>
getToolDisplayInfo(toolName, "completed").label;
export const toUserFacingToolDisplayLabel = (label: string): string => {
const normalized = label.trim();
return USER_FACING_TOOL_LABELS[normalized] || normalized;
};
export const resolveUserFacingToolDisplayLabel = (toolName: string): string =>
toUserFacingToolDisplayLabel(resolveToolDisplayLabel(toolName).trim() || toolName);
export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => {
const first = toolCalls[0]!;
const info = getToolDisplayInfo(first.name, first.status);
@@ -1996,23 +2059,23 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => {
? "站点浏览失败"
: "已浏览站点能力";
}
return running ? "探索中" : failed ? "探索失败" : "已探索";
return running ? "查看中" : failed ? "查看失败" : "已查看";
}
if (info.family === "command") {
return failed
? `执行失败 ${toolCalls.length} 条命令`
? `运行失败 ${toolCalls.length} 条命令`
: running
? `执行中 ${toolCalls.length} 条命令`
: `已执行 ${toolCalls.length} 条命令`;
? `运行中 ${toolCalls.length} 条命令`
: `已运行 ${toolCalls.length} 条命令`;
}
if (info.family === "write") {
return failed
? `写入失败 ${toolCalls.length} 个文件`
? `保存失败 ${toolCalls.length} 个文件`
: running
? `写入中 ${toolCalls.length} 个文件`
: `已写入 ${toolCalls.length} 个文件`;
? `保存中 ${toolCalls.length} 个文件`
: `已保存 ${toolCalls.length} 个文件`;
}
if (info.family === "edit") {
@@ -2049,10 +2112,10 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => {
if (info.family === "plan") {
return failed
? `计划更新失败 ${toolCalls.length} 次`
? `安排处理失败 ${toolCalls.length} 项`
: running
? `计划更新中 ${toolCalls.length} 次`
: `已更新 ${toolCalls.length} 次计划`;
? `安排处理中 ${toolCalls.length} 项`
: `已处理 ${toolCalls.length} 项安排`;
}
if (info.family === "skill") {
@@ -3,6 +3,7 @@ import {
normalizeToolSearchResultSummary,
resolveToolSearchItemSourceLabel,
resolveToolSearchItemStatusLabel,
resolveUserFacingToolSearchItemLabel,
} from "./toolSearchResultSummary";
describe("toolSearchResultSummary", () => {
@@ -97,4 +98,16 @@ describe("toolSearchResultSummary", () => {
}),
).toBe("待加载");
});
it("应把常见工具名转换成更自然的搜索展示文案", () => {
expect(resolveUserFacingToolSearchItemLabel("Read")).toBe("查看文件");
expect(resolveUserFacingToolSearchItemLabel("Write")).toBe("保存文件");
expect(resolveUserFacingToolSearchItemLabel("glob")).toBe("查找文件");
expect(resolveUserFacingToolSearchItemLabel("TaskOutput")).toBe(
"查看任务结果",
);
expect(resolveUserFacingToolSearchItemLabel("mcp__playwright__browser_click")).toBe(
"页面点击",
);
});
});
@@ -1,3 +1,5 @@
import { resolveUserFacingToolDisplayLabel } from "./toolDisplayInfo";
export interface ToolSearchResultItemSummary {
name: string;
description?: string;
@@ -164,3 +166,7 @@ export function resolveToolSearchItemStatusLabel(
}
return null;
}
export function resolveUserFacingToolSearchItemLabel(toolName: string): string {
return resolveUserFacingToolDisplayLabel(toolName);
}
@@ -1,106 +0,0 @@
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import { GeneralWorkbenchSidebar } from "../components/GeneralWorkbenchSidebar";
import type {
GeneralWorkbenchSidebarExecLogContract,
GeneralWorkbenchSidebarProps,
} from "../components/generalWorkbenchSidebarContract";
import type { Message } from "../types";
type GeneralWorkbenchWorkflowProps = Pick<
GeneralWorkbenchSidebarProps,
| "branchMode"
| "onNewTopic"
| "onSwitchTopic"
| "onDeleteTopic"
| "branchItems"
| "onSetBranchStatus"
| "workflowSteps"
| "onAddImage"
| "onImportDocument"
| "activityLogs"
| "creationTaskEvents"
| "onViewRunDetail"
| "activeRunDetail"
| "activeRunDetailLoading"
>;
type GeneralWorkbenchContextWorkspaceProps = {
contextSearchQuery: GeneralWorkbenchSidebarProps["contextSearchQuery"];
setContextSearchQuery: GeneralWorkbenchSidebarProps["onContextSearchQueryChange"];
contextSearchMode: GeneralWorkbenchSidebarProps["contextSearchMode"];
setContextSearchMode: GeneralWorkbenchSidebarProps["onContextSearchModeChange"];
contextSearchLoading: GeneralWorkbenchSidebarProps["contextSearchLoading"];
contextSearchError?: GeneralWorkbenchSidebarProps["contextSearchError"];
contextSearchBlockedReason?: GeneralWorkbenchSidebarProps["contextSearchBlockedReason"];
submitContextSearch: GeneralWorkbenchSidebarProps["onSubmitContextSearch"];
addTextContext?: GeneralWorkbenchSidebarProps["onAddTextContext"];
addLinkContext?: GeneralWorkbenchSidebarProps["onAddLinkContext"];
addFileContext?: GeneralWorkbenchSidebarProps["onAddFileContext"];
sidebarContextItems: GeneralWorkbenchSidebarProps["contextItems"];
toggleContextActive: GeneralWorkbenchSidebarProps["onToggleContextActive"];
contextBudget: GeneralWorkbenchSidebarProps["contextBudget"];
};
interface GeneralWorkbenchHistoryProps {
hasMore?: boolean;
loading?: boolean;
onLoadMore?: GeneralWorkbenchSidebarExecLogContract["onLoadMoreHistory"];
skillDetailMap?: Record<string, SkillDetailInfo | null>;
messages?: Message[];
}
interface GeneralWorkbenchSidebarSectionProps {
visible: boolean;
workflowProps: GeneralWorkbenchWorkflowProps;
contextWorkspace: GeneralWorkbenchContextWorkspaceProps;
onViewContextDetail?: GeneralWorkbenchSidebarProps["onViewContextDetail"];
onRequestCollapse?: GeneralWorkbenchSidebarProps["onRequestCollapse"];
headerActionSlot?: GeneralWorkbenchSidebarProps["headerActionSlot"];
topSlot?: GeneralWorkbenchSidebarProps["topSlot"];
historyProps?: GeneralWorkbenchHistoryProps;
}
export function GeneralWorkbenchSidebarSection({
visible,
workflowProps,
contextWorkspace,
onViewContextDetail,
onRequestCollapse,
headerActionSlot,
topSlot,
historyProps,
}: GeneralWorkbenchSidebarSectionProps) {
if (!visible) {
return null;
}
return (
<GeneralWorkbenchSidebar
{...workflowProps}
branchMode={workflowProps.branchMode ?? "version"}
contextSearchQuery={contextWorkspace.contextSearchQuery}
onContextSearchQueryChange={contextWorkspace.setContextSearchQuery}
contextSearchMode={contextWorkspace.contextSearchMode}
onContextSearchModeChange={contextWorkspace.setContextSearchMode}
contextSearchLoading={contextWorkspace.contextSearchLoading}
contextSearchError={contextWorkspace.contextSearchError}
contextSearchBlockedReason={contextWorkspace.contextSearchBlockedReason}
onSubmitContextSearch={contextWorkspace.submitContextSearch}
onAddTextContext={contextWorkspace.addTextContext}
onAddLinkContext={contextWorkspace.addLinkContext}
onAddFileContext={contextWorkspace.addFileContext}
contextItems={contextWorkspace.sidebarContextItems}
onToggleContextActive={contextWorkspace.toggleContextActive}
onViewContextDetail={onViewContextDetail}
contextBudget={contextWorkspace.contextBudget}
onRequestCollapse={onRequestCollapse}
headerActionSlot={headerActionSlot}
topSlot={topSlot}
historyHasMore={historyProps?.hasMore}
historyLoading={historyProps?.loading}
onLoadMoreHistory={historyProps?.onLoadMore}
skillDetailMap={historyProps?.skillDetailMap}
messages={historyProps?.messages}
/>
);
}
@@ -1,40 +0,0 @@
import type { ComponentProps, ReactNode } from "react";
import { CanvasWorkbenchLayout } from "../components/CanvasWorkbenchLayout";
interface WorkspaceCanvasContentProps {
liveCanvasPreview: ReactNode;
currentImageWorkbenchActive: boolean;
shouldShowCanvasLoadingState: boolean;
teamWorkbenchView: ComponentProps<typeof CanvasWorkbenchLayout>["teamView"];
canvasWorkbenchLayoutProps: Omit<
ComponentProps<typeof CanvasWorkbenchLayout>,
"teamView"
>;
}
export function WorkspaceCanvasContent({
liveCanvasPreview,
currentImageWorkbenchActive,
shouldShowCanvasLoadingState,
teamWorkbenchView,
canvasWorkbenchLayoutProps,
}: WorkspaceCanvasContentProps) {
if (!liveCanvasPreview && !teamWorkbenchView) {
return null;
}
if (currentImageWorkbenchActive) {
return liveCanvasPreview;
}
if (!teamWorkbenchView && shouldShowCanvasLoadingState) {
return liveCanvasPreview;
}
return (
<CanvasWorkbenchLayout
{...canvasWorkbenchLayoutProps}
teamView={teamWorkbenchView}
/>
);
}
@@ -1,150 +0,0 @@
import type { ComponentProps, ReactNode } from "react";
import { Info } from "lucide-react";
import { StepProgress } from "@/lib/workspace/workbenchUi";
import { EmptyState } from "../components/EmptyState";
import { MessageList } from "../components/MessageList";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
import { WorkspacePendingA2UIPanel } from "./WorkspacePendingA2UIPanel";
import {
ChatContainer,
ChatContainerInner,
ChatContent,
ChatInputSlot,
EntryBanner,
EntryBannerClose,
MessageViewport,
} from "./WorkspaceStyles";
import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui";
import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice";
interface WorkspaceChatContentProps {
entryBannerVisible: boolean;
entryBannerMessage?: string;
onDismissEntryBanner: () => void;
serviceSkillExecutionCard?: ReactNode;
stepProgressProps?: ComponentProps<typeof StepProgress> | null;
showChatLayout: boolean;
compactChrome: boolean;
contextWorkspaceEnabled: boolean;
generalWorkbenchMessageViewportBottomPadding?: string;
messageListProps: ComponentProps<typeof MessageList>;
teamWorkspaceDockProps?: ComponentProps<typeof TeamWorkspaceDock> | null;
emptyStateProps: ComponentProps<typeof EmptyState>;
showWorkspaceAlert: boolean;
onSelectWorkspaceDirectory: () => void;
onDismissWorkspaceAlert: () => void;
pendingA2UIForm?: A2UIResponse | null;
onPendingA2UISubmit?: (formData: A2UIFormData) => void;
a2uiSubmissionNotice?: A2UISubmissionNoticeData | null;
showInlineInputbar: boolean;
inputbarNode: ReactNode;
}
export function WorkspaceChatContent({
entryBannerVisible,
entryBannerMessage,
onDismissEntryBanner,
serviceSkillExecutionCard,
stepProgressProps,
showChatLayout,
compactChrome,
contextWorkspaceEnabled,
generalWorkbenchMessageViewportBottomPadding,
messageListProps,
teamWorkspaceDockProps,
emptyStateProps,
showWorkspaceAlert,
onSelectWorkspaceDirectory,
onDismissWorkspaceAlert,
pendingA2UIForm,
onPendingA2UISubmit,
a2uiSubmissionNotice,
showInlineInputbar,
inputbarNode,
}: WorkspaceChatContentProps) {
const messageListNode = (
<MessageList
{...messageListProps}
compactLeadingSpacing={contextWorkspaceEnabled}
/>
);
return (
<ChatContainer>
<ChatContainerInner>
{entryBannerVisible && entryBannerMessage ? (
<EntryBanner>
<Info className="h-4 w-4 shrink-0" />
<span>{entryBannerMessage}</span>
<EntryBannerClose
type="button"
onClick={onDismissEntryBanner}
aria-label="关闭入口提示"
>
关闭
</EntryBannerClose>
</EntryBanner>
) : null}
{stepProgressProps ? <StepProgress {...stepProgressProps} /> : null}
{serviceSkillExecutionCard}
{showChatLayout ? (
<ChatContent $compact={compactChrome}>
<>
{contextWorkspaceEnabled ? (
<MessageViewport
$bottomPadding={generalWorkbenchMessageViewportBottomPadding}
>
{messageListNode}
</MessageViewport>
) : (
messageListNode
)}
{teamWorkspaceDockProps ? (
<TeamWorkspaceDock {...teamWorkspaceDockProps} />
) : null}
</>
</ChatContent>
) : (
<EmptyState {...emptyStateProps} />
)}
{showChatLayout && (
<>
{showWorkspaceAlert ? (
<div className="mx-4 mb-2 flex items-center gap-2 rounded-[18px] border border-amber-200/90 bg-amber-50/86 px-3.5 py-2.5 text-sm text-amber-800 shadow-sm shadow-amber-950/5 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
<span className="flex-1">
工作区目录不存在,请重新选择一个本地目录后继续
</span>
<button
type="button"
onClick={onSelectWorkspaceDirectory}
className="shrink-0 rounded-xl border border-amber-200 bg-white/84 px-2.5 py-1 text-xs font-medium text-amber-900 transition hover:border-amber-300 hover:bg-white dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
>
重新选择目录
</button>
<button
type="button"
onClick={onDismissWorkspaceAlert}
className="shrink-0 text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
aria-label="关闭"
>
✕
</button>
</div>
) : null}
<WorkspacePendingA2UIPanel
pendingA2UIForm={pendingA2UIForm}
onA2UISubmit={onPendingA2UISubmit}
a2uiSubmissionNotice={a2uiSubmissionNotice}
/>
{showInlineInputbar ? (
<ChatInputSlot>{inputbarNode}</ChatInputSlot>
) : null}
</>
)}
</ChatContainerInner>
</ChatContainer>
);
}
@@ -1,49 +0,0 @@
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
import type { SyncStatus } from "../hooks/useContentSync";
import { ContentSyncNotice, ContentSyncNoticeText } from "./WorkspaceStyles";
interface WorkspaceContentSyncNoticeProps {
status: Exclude<SyncStatus, "idle">;
}
function resolveContentSyncNoticeMeta(status: Exclude<SyncStatus, "idle">): {
label: string;
Icon: typeof Loader2;
animated?: boolean;
} {
switch (status) {
case "syncing":
return {
label: "正在同步到当前内容…",
Icon: Loader2,
animated: true,
};
case "success":
return {
label: "内容已同步",
Icon: CheckCircle2,
};
case "error":
default:
return {
label: "同步失败,将自动重试",
Icon: AlertTriangle,
};
}
}
export function WorkspaceContentSyncNotice({
status,
}: WorkspaceContentSyncNoticeProps) {
const notice = resolveContentSyncNoticeMeta(status);
const NoticeIcon = notice.Icon;
return (
<ContentSyncNotice $status={status}>
<NoticeIcon
className={notice.animated ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"}
/>
<ContentSyncNoticeText>{notice.label}</ContentSyncNoticeText>
</ContentSyncNotice>
);
}
@@ -1,19 +1,39 @@
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import { AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { StepProgress } from "@/lib/workspace/workbenchUi";
import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui";
import { CanvasWorkbenchLayout } from "../components/CanvasWorkbenchLayout";
import { ChatNavbar } from "../components/ChatNavbar";
import { EmptyState } from "../components/EmptyState";
import { WorkspaceChatContent } from "./WorkspaceChatContent";
import { WorkspaceMainScene } from "./WorkspaceMainScene";
import { MessageList } from "../components/MessageList";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
import { WorkspaceMainArea } from "./WorkspaceMainArea";
import { WorkspacePendingA2UIPanel } from "./WorkspacePendingA2UIPanel";
import {
buildWorkspaceEmptyStateProps,
buildWorkspaceNavbarProps,
} from "./chatSurfaceProps";
import { isCanvasStateEmpty } from "./generalWorkbenchHelpers";
import type { SyncStatus } from "../hooks/useContentSync";
import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice";
import {
ChatContainer,
ChatContainerInner,
ChatContent,
ChatInputSlot,
ContentSyncNotice,
ContentSyncNoticeText,
EntryBanner,
EntryBannerClose,
MessageViewport,
} from "./WorkspaceStyles";
type WorkspaceMainSceneProps = Omit<
ComponentProps<typeof WorkspaceMainScene>,
"chatContent" | "chatNavbarProps"
type WorkspaceMainAreaProps = Omit<
ComponentProps<typeof WorkspaceMainArea>,
"navbarNode" | "contentSyncNoticeNode" | "forceCanvasMode" | "chatContent" | "canvasContent"
>;
type CanvasWorkbenchLayoutProps = ComponentProps<typeof CanvasWorkbenchLayout>;
type ChatToolPreferences = {
webSearch: boolean;
thinking: boolean;
@@ -21,38 +41,186 @@ type ChatToolPreferences = {
subagent: boolean;
};
type ChatToolPreferenceKey = keyof ChatToolPreferences;
type StepProgressProps = ComponentProps<typeof StepProgress>;
type MessageListProps = ComponentProps<typeof MessageList>;
type TeamWorkspaceDockProps = ComponentProps<typeof TeamWorkspaceDock>;
type EmptyStateProps = ComponentProps<typeof EmptyState>;
interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps {
interface WorkspaceChatContentParams {
entryBannerVisible: boolean;
entryBannerMessage?: string;
onDismissEntryBanner: () => void;
serviceSkillExecutionCard?: ComponentProps<
typeof WorkspaceChatContent
>["serviceSkillExecutionCard"];
stepProgressProps?: ComponentProps<
typeof WorkspaceChatContent
>["stepProgressProps"];
serviceSkillExecutionCard?: ReactNode;
stepProgressProps?: StepProgressProps | null;
showChatLayout: boolean;
compactChrome: boolean;
contextWorkspaceEnabled: boolean;
generalWorkbenchMessageViewportBottomPadding?: string;
messageListProps: MessageListProps;
teamWorkspaceDockProps?: TeamWorkspaceDockProps | null;
emptyStateProps: EmptyStateProps;
showWorkspaceAlert: boolean;
onSelectWorkspaceDirectory: () => void;
onDismissWorkspaceAlert: () => void;
pendingA2UIForm?: A2UIResponse | null;
onPendingA2UISubmit?: (formData: A2UIFormData) => void;
a2uiSubmissionNotice?: A2UISubmissionNoticeData | null;
showInlineInputbar: boolean;
inputbarNode: ReactNode;
}
function resolveContentSyncNoticeMeta(status: Exclude<SyncStatus, "idle">): {
label: string;
Icon: typeof Loader2;
animated?: boolean;
} {
switch (status) {
case "syncing":
return {
label: "正在同步到当前内容…",
Icon: Loader2,
animated: true,
};
case "success":
return {
label: "内容已同步",
Icon: CheckCircle2,
};
case "error":
default:
return {
label: "同步失败,将自动重试",
Icon: AlertTriangle,
};
}
}
function renderWorkspaceChatContent({
entryBannerVisible,
entryBannerMessage,
onDismissEntryBanner,
serviceSkillExecutionCard,
stepProgressProps,
showChatLayout,
compactChrome,
contextWorkspaceEnabled,
generalWorkbenchMessageViewportBottomPadding,
messageListProps,
teamWorkspaceDockProps,
emptyStateProps,
showWorkspaceAlert,
onSelectWorkspaceDirectory,
onDismissWorkspaceAlert,
pendingA2UIForm,
onPendingA2UISubmit,
a2uiSubmissionNotice,
showInlineInputbar,
inputbarNode,
}: WorkspaceChatContentParams): ReactNode {
const messageListNode = (
<MessageList
{...messageListProps}
compactLeadingSpacing={contextWorkspaceEnabled}
/>
);
return (
<ChatContainer>
<ChatContainerInner>
{entryBannerVisible && entryBannerMessage ? (
<EntryBanner>
<Info className="h-4 w-4 shrink-0" />
<span>{entryBannerMessage}</span>
<EntryBannerClose
type="button"
onClick={onDismissEntryBanner}
aria-label="关闭入口提示"
>
关闭
</EntryBannerClose>
</EntryBanner>
) : null}
{stepProgressProps ? <StepProgress {...stepProgressProps} /> : null}
{serviceSkillExecutionCard}
{showChatLayout ? (
<ChatContent $compact={compactChrome}>
<>
{contextWorkspaceEnabled ? (
<MessageViewport
$bottomPadding={generalWorkbenchMessageViewportBottomPadding}
>
{messageListNode}
</MessageViewport>
) : (
messageListNode
)}
{teamWorkspaceDockProps ? (
<TeamWorkspaceDock {...teamWorkspaceDockProps} />
) : null}
</>
</ChatContent>
) : (
<EmptyState {...emptyStateProps} />
)}
{showChatLayout ? (
<>
{showWorkspaceAlert ? (
<div className="mx-4 mb-2 flex items-center gap-2 rounded-[18px] border border-amber-200/90 bg-amber-50/86 px-3.5 py-2.5 text-sm text-amber-800 shadow-sm shadow-amber-950/5 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
<span className="flex-1">
工作区目录不存在,请重新选择一个本地目录后继续
</span>
<button
type="button"
onClick={onSelectWorkspaceDirectory}
className="shrink-0 rounded-xl border border-amber-200 bg-white/84 px-2.5 py-1 text-xs font-medium text-amber-900 transition hover:border-amber-300 hover:bg-white dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
>
重新选择目录
</button>
<button
type="button"
onClick={onDismissWorkspaceAlert}
className="shrink-0 text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
aria-label="关闭"
>
✕
</button>
</div>
) : null}
<WorkspacePendingA2UIPanel
pendingA2UIForm={pendingA2UIForm}
onA2UISubmit={onPendingA2UISubmit}
a2uiSubmissionNotice={a2uiSubmissionNotice}
/>
{showInlineInputbar ? (
<ChatInputSlot>{inputbarNode}</ChatInputSlot>
) : null}
</>
) : null}
</ChatContainerInner>
</ChatContainer>
);
}
interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps {
entryBannerVisible: boolean;
entryBannerMessage?: string;
onDismissEntryBanner: () => void;
serviceSkillExecutionCard?: WorkspaceChatContentParams["serviceSkillExecutionCard"];
stepProgressProps?: WorkspaceChatContentParams["stepProgressProps"];
showChatLayout: boolean;
contextWorkspaceEnabled: boolean;
generalWorkbenchMessageViewportBottomPadding?: string;
messageListProps: ComponentProps<
typeof WorkspaceChatContent
>["messageListProps"];
teamWorkspaceDockProps?: ComponentProps<
typeof WorkspaceChatContent
>["teamWorkspaceDockProps"];
messageListProps: WorkspaceChatContentParams["messageListProps"];
teamWorkspaceDockProps?: WorkspaceChatContentParams["teamWorkspaceDockProps"];
workspaceAlertVisible: boolean;
onSelectWorkspaceDirectory: () => void;
onDismissWorkspaceAlert: () => void;
pendingA2UIForm?: ComponentProps<
typeof WorkspaceChatContent
>["pendingA2UIForm"];
onPendingA2UISubmit?: ComponentProps<
typeof WorkspaceChatContent
>["onPendingA2UISubmit"];
a2uiSubmissionNotice?: ComponentProps<
typeof WorkspaceChatContent
>["a2uiSubmissionNotice"];
pendingA2UIForm?: WorkspaceChatContentParams["pendingA2UIForm"];
onPendingA2UISubmit?: WorkspaceChatContentParams["onPendingA2UISubmit"];
a2uiSubmissionNotice?: WorkspaceChatContentParams["a2uiSubmissionNotice"];
shouldHideGeneralWorkbenchInputForTheme: boolean;
input: ComponentProps<typeof EmptyState>["input"];
setInput: ComponentProps<typeof EmptyState>["setInput"];
@@ -148,6 +316,15 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps {
typeof ChatNavbar
>["contextCompactionRunning"];
onCompactContext?: ComponentProps<typeof ChatNavbar>["onCompactContext"];
isThemeWorkbench: boolean;
contentId?: string;
syncStatus: SyncStatus;
hasLiveCanvasPreviewContent: boolean;
liveCanvasPreview: ReactNode;
currentImageWorkbenchActive: boolean;
shouldShowCanvasLoadingState: boolean;
teamWorkbenchView: CanvasWorkbenchLayoutProps["teamView"];
canvasWorkbenchLayoutProps: Omit<CanvasWorkbenchLayoutProps, "teamView">;
}
export function WorkspaceConversationScene({
@@ -296,34 +473,29 @@ export function WorkspaceConversationScene({
onOpenSettings,
});
const chatContent = (
<WorkspaceChatContent
entryBannerVisible={entryBannerVisible}
entryBannerMessage={entryBannerMessage}
onDismissEntryBanner={onDismissEntryBanner}
serviceSkillExecutionCard={serviceSkillExecutionCard}
stepProgressProps={stepProgressProps}
showChatLayout={showChatLayout}
compactChrome={compactChrome}
contextWorkspaceEnabled={contextWorkspaceEnabled}
generalWorkbenchMessageViewportBottomPadding={
generalWorkbenchMessageViewportBottomPadding
}
messageListProps={messageListProps}
teamWorkspaceDockProps={teamWorkspaceDockProps}
emptyStateProps={emptyStateProps}
showWorkspaceAlert={workspaceAlertVisible}
onSelectWorkspaceDirectory={onSelectWorkspaceDirectory}
onDismissWorkspaceAlert={onDismissWorkspaceAlert}
pendingA2UIForm={pendingA2UIForm}
onPendingA2UISubmit={onPendingA2UISubmit}
a2uiSubmissionNotice={a2uiSubmissionNotice}
showInlineInputbar={
!contextWorkspaceEnabled && !shouldHideGeneralWorkbenchInputForTheme
}
inputbarNode={inputbarNode}
/>
);
const chatContent = renderWorkspaceChatContent({
entryBannerVisible,
entryBannerMessage,
onDismissEntryBanner,
serviceSkillExecutionCard,
stepProgressProps,
showChatLayout,
compactChrome,
contextWorkspaceEnabled,
generalWorkbenchMessageViewportBottomPadding,
messageListProps,
teamWorkspaceDockProps,
emptyStateProps,
showWorkspaceAlert: workspaceAlertVisible,
onSelectWorkspaceDirectory,
onDismissWorkspaceAlert,
pendingA2UIForm,
onPendingA2UISubmit,
a2uiSubmissionNotice,
showInlineInputbar:
!contextWorkspaceEnabled && !shouldHideGeneralWorkbenchInputForTheme,
inputbarNode,
});
const chatNavbarProps = buildWorkspaceNavbarProps({
visible: navbarVisible,
@@ -353,22 +525,60 @@ export function WorkspaceConversationScene({
onOpenSettings,
});
const navbarNode = chatNavbarProps ? (
<ChatNavbar {...chatNavbarProps} />
) : null;
const shouldShowContentSyncNotice =
!isThemeWorkbench &&
Boolean(contentId) &&
(syncStatus === "syncing" ||
syncStatus === "success" ||
syncStatus === "error");
const contentSyncNoticeNode =
shouldShowContentSyncNotice
? (() => {
const notice = resolveContentSyncNoticeMeta(syncStatus);
const NoticeIcon = notice.Icon;
return (
<ContentSyncNotice $status={syncStatus}>
<NoticeIcon
className={
notice.animated
? "h-3.5 w-3.5 animate-spin"
: "h-3.5 w-3.5"
}
/>
<ContentSyncNoticeText>{notice.label}</ContentSyncNoticeText>
</ContentSyncNotice>
);
})()
: null;
const canvasContent =
!liveCanvasPreview && !teamWorkbenchView ? null : currentImageWorkbenchActive ||
(!teamWorkbenchView && shouldShowCanvasLoadingState) ? (
liveCanvasPreview
) : (
<CanvasWorkbenchLayout
{...canvasWorkbenchLayoutProps}
teamView={teamWorkbenchView}
/>
);
const forceCanvasMode = Boolean(
isThemeWorkbench &&
(hasLiveCanvasPreviewContent || Boolean(teamWorkbenchView)),
);
return (
<WorkspaceMainScene
chatNavbarProps={chatNavbarProps}
isThemeWorkbench={isThemeWorkbench}
contentId={contentId}
syncStatus={syncStatus}
hasLiveCanvasPreviewContent={hasLiveCanvasPreviewContent}
liveCanvasPreview={liveCanvasPreview}
currentImageWorkbenchActive={currentImageWorkbenchActive}
shouldShowCanvasLoadingState={shouldShowCanvasLoadingState}
teamWorkbenchView={teamWorkbenchView}
canvasWorkbenchLayoutProps={canvasWorkbenchLayoutProps}
<WorkspaceMainArea
compactChrome={compactChrome}
navbarNode={navbarNode}
contentSyncNoticeNode={contentSyncNoticeNode}
shellBottomInset={shellBottomInset}
layoutMode={layoutMode}
forceCanvasMode={forceCanvasMode}
chatContent={chatContent}
canvasContent={canvasContent}
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
generalWorkbenchDialog={generalWorkbenchDialog}
@@ -1,16 +1,38 @@
import type { ComponentProps } from "react";
import type { useThemeContextWorkspace } from "../hooks";
import { GeneralWorkbenchHarnessCard } from "../components/GeneralWorkbenchHarnessCard";
import { GeneralWorkbenchSidebarSection } from "./GeneralWorkbenchSidebarSection";
import { GeneralWorkbenchSidebar } from "../components/GeneralWorkbenchSidebar";
import type {
GeneralWorkbenchSidebarExecLogContract,
GeneralWorkbenchSidebarProps,
} from "../components/generalWorkbenchSidebarContract";
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import type { Message } from "../types";
type GeneralWorkbenchSidebarSectionProps = ComponentProps<
typeof GeneralWorkbenchSidebarSection
>;
type GeneralWorkbenchSidebarWorkflowProps =
GeneralWorkbenchSidebarSectionProps["workflowProps"];
type GeneralWorkbenchSidebarHistoryProps = NonNullable<
GeneralWorkbenchSidebarSectionProps["historyProps"]
type GeneralWorkbenchSidebarWorkflowProps = Pick<
GeneralWorkbenchSidebarProps,
| "branchMode"
| "onNewTopic"
| "onSwitchTopic"
| "onDeleteTopic"
| "branchItems"
| "onSetBranchStatus"
| "workflowSteps"
| "onAddImage"
| "onImportDocument"
| "activityLogs"
| "creationTaskEvents"
| "onViewRunDetail"
| "activeRunDetail"
| "activeRunDetailLoading"
>;
type GeneralWorkbenchSidebarHistoryProps = {
hasMore?: boolean;
loading?: boolean;
onLoadMore?: GeneralWorkbenchSidebarExecLogContract["onLoadMoreHistory"];
skillDetailMap?: Record<string, SkillDetailInfo | null>;
messages?: Message[];
};
type GeneralWorkbenchHarnessSummary = Pick<
ComponentProps<typeof GeneralWorkbenchHarnessCard>,
| "runState"
@@ -27,7 +49,7 @@ interface WorkspaceGeneralWorkbenchSidebarProps {
isThemeWorkbench: boolean;
enablePanelCollapse: boolean;
onRequestCollapse: NonNullable<
GeneralWorkbenchSidebarSectionProps["onRequestCollapse"]
GeneralWorkbenchSidebarProps["onRequestCollapse"]
>;
generalWorkbenchHarnessSummary: GeneralWorkbenchHarnessSummary | null;
harnessPanelVisible: boolean;
@@ -50,7 +72,7 @@ interface WorkspaceGeneralWorkbenchSidebarProps {
activeRunDetailLoading: GeneralWorkbenchSidebarWorkflowProps["activeRunDetailLoading"];
};
contextWorkspace: ReturnType<typeof useThemeContextWorkspace>;
onViewContextDetail?: GeneralWorkbenchSidebarSectionProps["onViewContextDetail"];
onViewContextDetail?: GeneralWorkbenchSidebarProps["onViewContextDetail"];
history?: {
hasMore?: GeneralWorkbenchSidebarHistoryProps["hasMore"];
loading?: GeneralWorkbenchSidebarHistoryProps["loading"];
@@ -89,51 +111,48 @@ export function WorkspaceGeneralWorkbenchSidebar({
/>
) : null;
if (!visible) {
return null;
}
return (
<GeneralWorkbenchSidebarSection
visible={visible}
workflowProps={{
branchMode: "version",
onNewTopic: workflow.onCreateVersionSnapshot,
onSwitchTopic: workflow.onSwitchBranchVersion,
onDeleteTopic: workflow.onDeleteTopic,
branchItems: workflow.branchItems,
onSetBranchStatus: workflow.onSetBranchStatus,
workflowSteps: workflow.workflowSteps,
onAddImage: workflow.onAddImage,
onImportDocument: workflow.onImportDocument,
activityLogs: workflow.activityLogs,
creationTaskEvents: workflow.creationTaskEvents,
onViewRunDetail: workflow.onViewRunDetail,
activeRunDetail: workflow.activeRunDetail,
activeRunDetailLoading: workflow.activeRunDetailLoading,
}}
contextWorkspace={{
contextSearchQuery: contextWorkspace.contextSearchQuery,
setContextSearchQuery: contextWorkspace.setContextSearchQuery,
contextSearchMode: contextWorkspace.contextSearchMode,
setContextSearchMode: contextWorkspace.setContextSearchMode,
contextSearchLoading: contextWorkspace.contextSearchLoading,
contextSearchError: contextWorkspace.contextSearchError,
contextSearchBlockedReason: contextWorkspace.contextSearchBlockedReason,
submitContextSearch: contextWorkspace.submitContextSearch,
addTextContext: contextWorkspace.addTextContext,
addLinkContext: contextWorkspace.addLinkContext,
addFileContext: contextWorkspace.addFileContext,
sidebarContextItems: contextWorkspace.sidebarContextItems,
toggleContextActive: contextWorkspace.toggleContextActive,
contextBudget: contextWorkspace.contextBudget,
}}
<GeneralWorkbenchSidebar
branchMode="version"
onNewTopic={workflow.onCreateVersionSnapshot}
onSwitchTopic={workflow.onSwitchBranchVersion}
onDeleteTopic={workflow.onDeleteTopic}
branchItems={workflow.branchItems}
onSetBranchStatus={workflow.onSetBranchStatus}
workflowSteps={workflow.workflowSteps}
onAddImage={workflow.onAddImage}
onImportDocument={workflow.onImportDocument}
activityLogs={workflow.activityLogs}
creationTaskEvents={workflow.creationTaskEvents}
onViewRunDetail={workflow.onViewRunDetail}
activeRunDetail={workflow.activeRunDetail}
activeRunDetailLoading={workflow.activeRunDetailLoading}
contextSearchQuery={contextWorkspace.contextSearchQuery}
onContextSearchQueryChange={contextWorkspace.setContextSearchQuery}
contextSearchMode={contextWorkspace.contextSearchMode}
onContextSearchModeChange={contextWorkspace.setContextSearchMode}
contextSearchLoading={contextWorkspace.contextSearchLoading}
contextSearchError={contextWorkspace.contextSearchError}
contextSearchBlockedReason={contextWorkspace.contextSearchBlockedReason}
onSubmitContextSearch={contextWorkspace.submitContextSearch}
onAddTextContext={contextWorkspace.addTextContext}
onAddLinkContext={contextWorkspace.addLinkContext}
onAddFileContext={contextWorkspace.addFileContext}
contextItems={contextWorkspace.sidebarContextItems}
onToggleContextActive={contextWorkspace.toggleContextActive}
onViewContextDetail={onViewContextDetail}
contextBudget={contextWorkspace.contextBudget}
onRequestCollapse={enablePanelCollapse ? onRequestCollapse : undefined}
headerActionSlot={headerActionSlot}
historyProps={{
hasMore: history?.hasMore,
loading: history?.loading,
onLoadMore: history?.hasMore ? history.onLoadMore : undefined,
skillDetailMap: history?.skillDetailMap,
messages: history?.messages,
}}
historyHasMore={history?.hasMore}
historyLoading={history?.loading}
onLoadMoreHistory={history?.hasMore ? history.onLoadMore : undefined}
skillDetailMap={history?.skillDetailMap}
messages={history?.messages}
/>
);
}
@@ -1,30 +0,0 @@
import type { ComponentProps } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { HarnessStatusPanel } from "../components/HarnessStatusPanel";
interface WorkspaceHarnessDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
maxWidth: string;
panelProps: ComponentProps<typeof HarnessStatusPanel>;
}
export function WorkspaceHarnessDialog({
open,
onOpenChange,
maxWidth,
panelProps,
}: WorkspaceHarnessDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
maxWidth={maxWidth}
className="flex h-[90vh] max-h-[90vh] flex-col overflow-hidden p-0"
draggable={true}
dragHandleSelector='[data-harness-drag-handle="true"]'
>
<HarnessStatusPanel {...panelProps} />
</DialogContent>
</Dialog>
);
}
@@ -1,8 +1,8 @@
import type { ComponentProps } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { AgentRuntimeStrip } from "../components/AgentRuntimeStrip";
import { HarnessStatusPanel } from "../components/HarnessStatusPanel";
import { TeamMemoryShadowCard } from "../components/TeamMemoryShadowCard";
import { WorkspaceHarnessDialog } from "./WorkspaceHarnessDialog";
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
type HarnessPanelBaseProps = Pick<
@@ -57,19 +57,25 @@ export function GeneralWorkbenchHarnessDialogSection({
}
return (
<WorkspaceHarnessDialog
open={open}
onOpenChange={onOpenChange}
maxWidth="max-w-7xl"
panelProps={{
...panelBaseProps,
layout: "dialog",
teamMemorySnapshot,
leadContent: teamMemorySnapshot ? (
<TeamMemoryShadowCard snapshot={teamMemorySnapshot} />
) : undefined,
}}
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
maxWidth="max-w-7xl"
className="flex h-[90vh] max-h-[90vh] flex-col overflow-hidden p-0"
draggable={true}
dragHandleSelector='[data-harness-drag-handle="true"]'
>
<HarnessStatusPanel
{...panelBaseProps}
layout="dialog"
teamMemorySnapshot={teamMemorySnapshot}
leadContent={
teamMemorySnapshot ? (
<TeamMemoryShadowCard snapshot={teamMemorySnapshot} />
) : undefined
}
/>
</DialogContent>
</Dialog>
);
}
@@ -114,39 +120,43 @@ export function GeneralWorkbenchDialogSection({
}
return (
<WorkspaceHarnessDialog
open={open}
onOpenChange={onOpenChange}
maxWidth="max-w-6xl"
panelProps={{
...panelBaseProps,
layout: "dialog",
teamMemorySnapshot,
title: "处理工作台",
description: "集中查看计划、待确认事项、任务进展、文件活动和处理结果。",
toggleLabel: "工作台详情",
leadContent: (
<div className="space-y-3">
<AgentRuntimeStrip
activeTheme={activeTheme}
toolPreferences={toolPreferences}
harnessState={panelBaseProps.harnessState}
childSubagentSessions={panelBaseProps.childSubagentSessions}
variant="embedded"
isSending={isSending}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
runtimeStatusTitle={runtimeStatusTitle}
selectedTeamLabel={panelBaseProps.selectedTeamLabel}
selectedTeamSummary={panelBaseProps.selectedTeamSummary}
selectedTeamRoleCount={selectedTeamRoleCount}
/>
{teamMemorySnapshot ? (
<TeamMemoryShadowCard snapshot={teamMemorySnapshot} />
) : null}
</div>
),
}}
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
maxWidth="max-w-6xl"
className="flex h-[90vh] max-h-[90vh] flex-col overflow-hidden p-0"
draggable={true}
dragHandleSelector='[data-harness-drag-handle="true"]'
>
<HarnessStatusPanel
{...panelBaseProps}
layout="dialog"
teamMemorySnapshot={teamMemorySnapshot}
title="处理工作台"
description="集中查看计划、待确认事项、任务进展、文件活动和处理结果。"
toggleLabel="工作台详情"
leadContent={
<div className="space-y-3">
<AgentRuntimeStrip
activeTheme={activeTheme}
toolPreferences={toolPreferences}
harnessState={panelBaseProps.harnessState}
childSubagentSessions={panelBaseProps.childSubagentSessions}
variant="embedded"
isSending={isSending}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
runtimeStatusTitle={runtimeStatusTitle}
selectedTeamLabel={panelBaseProps.selectedTeamLabel}
selectedTeamSummary={panelBaseProps.selectedTeamSummary}
selectedTeamRoleCount={selectedTeamRoleCount}
/>
{teamMemorySnapshot ? (
<TeamMemoryShadowCard snapshot={teamMemorySnapshot} />
) : null}
</div>
}
/>
</DialogContent>
</Dialog>
);
}
@@ -1,27 +0,0 @@
import { type ComponentProps, type ReactNode } from "react";
import { Inputbar } from "../components/Inputbar";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
interface WorkspaceInputbarProps {
inputbarProps: Omit<ComponentProps<typeof Inputbar>, "overlayAccessory">;
accessory?: ReactNode;
teamWorkspaceDockProps?: ComponentProps<typeof TeamWorkspaceDock> | null;
}
export function WorkspaceInputbar({
inputbarProps,
accessory,
teamWorkspaceDockProps,
}: WorkspaceInputbarProps) {
const overlayAccessory =
accessory || teamWorkspaceDockProps ? (
<>
{accessory}
{teamWorkspaceDockProps ? (
<TeamWorkspaceDock {...teamWorkspaceDockProps} />
) : null}
</>
) : undefined;
return <Inputbar {...inputbarProps} overlayAccessory={overlayAccessory} />;
}
@@ -1,99 +0,0 @@
import type { ComponentProps, ReactNode } from "react";
import type { SyncStatus } from "../hooks/useContentSync";
import { ChatNavbar } from "../components/ChatNavbar";
import { WorkspaceCanvasContent } from "./WorkspaceCanvasContent";
import { WorkspaceContentSyncNotice } from "./WorkspaceContentSyncNotice";
import { WorkspaceMainArea } from "./WorkspaceMainArea";
type WorkspaceCanvasContentProps = ComponentProps<
typeof WorkspaceCanvasContent
>;
type WorkspaceMainAreaProps = ComponentProps<typeof WorkspaceMainArea>;
interface WorkspaceMainSceneProps {
chatNavbarProps: ComponentProps<typeof ChatNavbar> | null;
isThemeWorkbench: boolean;
contentId?: string;
syncStatus: SyncStatus;
hasLiveCanvasPreviewContent: boolean;
liveCanvasPreview: ReactNode;
currentImageWorkbenchActive: WorkspaceCanvasContentProps["currentImageWorkbenchActive"];
shouldShowCanvasLoadingState: WorkspaceCanvasContentProps["shouldShowCanvasLoadingState"];
teamWorkbenchView: WorkspaceCanvasContentProps["teamWorkbenchView"];
canvasWorkbenchLayoutProps: WorkspaceCanvasContentProps["canvasWorkbenchLayoutProps"];
compactChrome: WorkspaceMainAreaProps["compactChrome"];
shellBottomInset: WorkspaceMainAreaProps["shellBottomInset"];
layoutMode: WorkspaceMainAreaProps["layoutMode"];
chatContent: WorkspaceMainAreaProps["chatContent"];
chatPanelWidth?: WorkspaceMainAreaProps["chatPanelWidth"];
chatPanelMinWidth?: WorkspaceMainAreaProps["chatPanelMinWidth"];
generalWorkbenchDialog: WorkspaceMainAreaProps["generalWorkbenchDialog"];
generalWorkbenchHarnessDialog: WorkspaceMainAreaProps["generalWorkbenchHarnessDialog"];
showFloatingInputOverlay: WorkspaceMainAreaProps["showFloatingInputOverlay"];
hasPendingA2UIForm: WorkspaceMainAreaProps["hasPendingA2UIForm"];
inputbarNode: WorkspaceMainAreaProps["inputbarNode"];
}
export function WorkspaceMainScene({
chatNavbarProps,
isThemeWorkbench,
contentId,
syncStatus,
hasLiveCanvasPreviewContent,
liveCanvasPreview,
currentImageWorkbenchActive,
shouldShowCanvasLoadingState,
teamWorkbenchView,
canvasWorkbenchLayoutProps,
compactChrome,
shellBottomInset,
layoutMode,
chatContent,
chatPanelWidth,
chatPanelMinWidth,
generalWorkbenchDialog,
generalWorkbenchHarnessDialog,
showFloatingInputOverlay,
hasPendingA2UIForm,
inputbarNode,
}: WorkspaceMainSceneProps) {
const navbarNode = chatNavbarProps ? (
<ChatNavbar {...chatNavbarProps} />
) : null;
const contentSyncNoticeNode =
!isThemeWorkbench && contentId && syncStatus !== "idle" ? (
<WorkspaceContentSyncNotice status={syncStatus} />
) : null;
const canvasContent = (
<WorkspaceCanvasContent
liveCanvasPreview={liveCanvasPreview}
currentImageWorkbenchActive={currentImageWorkbenchActive}
shouldShowCanvasLoadingState={shouldShowCanvasLoadingState}
teamWorkbenchView={teamWorkbenchView}
canvasWorkbenchLayoutProps={canvasWorkbenchLayoutProps}
/>
);
return (
<WorkspaceMainArea
compactChrome={compactChrome}
navbarNode={navbarNode}
contentSyncNoticeNode={contentSyncNoticeNode}
shellBottomInset={shellBottomInset}
layoutMode={layoutMode}
forceCanvasMode={Boolean(
isThemeWorkbench &&
(hasLiveCanvasPreviewContent || Boolean(teamWorkbenchView)),
)}
chatContent={chatContent}
canvasContent={canvasContent}
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
generalWorkbenchDialog={generalWorkbenchDialog}
generalWorkbenchHarnessDialog={generalWorkbenchHarnessDialog}
showFloatingInputOverlay={showFloatingInputOverlay}
hasPendingA2UIForm={hasPendingA2UIForm}
inputbarNode={inputbarNode}
/>
);
}
@@ -1,53 +0,0 @@
import type { ComponentProps, ReactNode } from "react";
import { PanelLeftOpen } from "lucide-react";
import { ChatSidebar } from "../components/ChatSidebar";
import {
PageContainer,
GeneralWorkbenchLeftExpandButton,
} from "./WorkspaceStyles";
interface WorkspacePageShellProps {
compactChrome: boolean;
isThemeWorkbench: boolean;
generalWorkbenchSidebarNode: ReactNode;
showChatPanel: boolean;
showSidebar: boolean;
chatSidebarProps: ComponentProps<typeof ChatSidebar> | null;
showGeneralWorkbenchLeftExpandButton: boolean;
onExpandGeneralWorkbenchSidebar: () => void;
mainAreaNode: ReactNode;
}
export function WorkspacePageShell({
compactChrome,
isThemeWorkbench,
generalWorkbenchSidebarNode,
showChatPanel,
showSidebar,
chatSidebarProps,
showGeneralWorkbenchLeftExpandButton,
onExpandGeneralWorkbenchSidebar,
mainAreaNode,
}: WorkspacePageShellProps) {
return (
<PageContainer $compact={compactChrome}>
{isThemeWorkbench ? (
generalWorkbenchSidebarNode
) : showChatPanel && showSidebar && chatSidebarProps ? (
<ChatSidebar {...chatSidebarProps} />
) : null}
{showGeneralWorkbenchLeftExpandButton ? (
<GeneralWorkbenchLeftExpandButton
type="button"
aria-label="展开上下文侧栏"
onClick={onExpandGeneralWorkbenchSidebar}
title="展开上下文侧栏"
>
<PanelLeftOpen size={14} />
</GeneralWorkbenchLeftExpandButton>
) : null}
{mainAreaNode}
</PageContainer>
);
}
@@ -1,14 +1,21 @@
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import { PanelLeftOpen } from "lucide-react";
import { ChatSidebar } from "../components/ChatSidebar";
import { buildWorkspaceChatSidebarProps } from "./chatSurfaceProps";
import { WorkspacePageShell } from "./WorkspacePageShell";
import {
GeneralWorkbenchLeftExpandButton,
PageContainer,
} from "./WorkspaceStyles";
type WorkspacePageShellProps = Omit<
ComponentProps<typeof WorkspacePageShell>,
"chatSidebarProps"
>;
interface WorkspaceShellSceneProps extends WorkspacePageShellProps {
interface WorkspaceShellSceneProps {
compactChrome: boolean;
isThemeWorkbench: boolean;
generalWorkbenchSidebarNode: ReactNode;
showChatPanel: boolean;
showSidebar: boolean;
showGeneralWorkbenchLeftExpandButton: boolean;
onExpandGeneralWorkbenchSidebar: () => void;
mainAreaNode: ReactNode;
sidebarContextVariant?: ComponentProps<typeof ChatSidebar>["contextVariant"];
currentTopicId: ComponentProps<typeof ChatSidebar>["currentTopicId"];
topics: ComponentProps<typeof ChatSidebar>["topics"];
@@ -87,18 +94,24 @@ export function WorkspaceShellScene({
: null;
return (
<WorkspacePageShell
compactChrome={compactChrome}
isThemeWorkbench={isThemeWorkbench}
generalWorkbenchSidebarNode={generalWorkbenchSidebarNode}
showChatPanel={showChatPanel}
showSidebar={showSidebar}
chatSidebarProps={chatSidebarProps}
showGeneralWorkbenchLeftExpandButton={
showGeneralWorkbenchLeftExpandButton
}
onExpandGeneralWorkbenchSidebar={onExpandGeneralWorkbenchSidebar}
mainAreaNode={mainAreaNode}
/>
<PageContainer $compact={compactChrome}>
{isThemeWorkbench ? (
generalWorkbenchSidebarNode
) : showChatPanel && showSidebar && chatSidebarProps ? (
<ChatSidebar {...chatSidebarProps} />
) : null}
{showGeneralWorkbenchLeftExpandButton ? (
<GeneralWorkbenchLeftExpandButton
type="button"
aria-label="展开上下文侧栏"
onClick={onExpandGeneralWorkbenchSidebar}
title="展开上下文侧栏"
>
<PanelLeftOpen size={14} />
</GeneralWorkbenchLeftExpandButton>
) : null}
{mainAreaNode}
</PageContainer>
);
}
@@ -1,284 +0,0 @@
import {
useEffect,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { getDefaultGuidePromptByTheme } from "../utils/defaultGuidePrompt";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { MessageImage } from "../types";
import type { ThemeType } from "@/lib/workspace/workbenchContract";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import { isCanvasStateEmpty } from "./generalWorkbenchHelpers";
import type { WorkspaceHandleSend } from "./useWorkspaceSendActions";
const shouldLogWorkspaceInfo = import.meta.env.MODE !== "test";
function logWorkspaceInfo(...args: Parameters<typeof console.log>) {
if (!shouldLogWorkspaceInfo) {
return;
}
console.log(...args);
}
interface UseWorkspaceAutoGuideRuntimeParams {
contentId?: string | null;
sessionId?: string | null;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
initialAutoSendRequestMetadata?: Record<string, unknown>;
autoRunInitialPromptOnMount: boolean;
initialDispatchKey: string | null;
messagesCount: number;
projectReady: boolean;
systemPromptReady: boolean;
isSending: boolean;
canvasState: CanvasStateUnion | null;
isThemeWorkbench: boolean;
mappedTheme: ThemeType;
shouldUseCompactGeneralWorkbench: boolean;
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt: boolean;
generalWorkbenchEntryCheckPending: boolean;
generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null;
chatToolPreferences: Pick<ChatToolPreferences, "thinking" | "webSearch">;
setInput: Dispatch<SetStateAction<string>>;
handleSend: WorkspaceHandleSend;
triggerAIGuide: () => void;
onInitialUserPromptConsumed?: () => void;
hasTriggeredGuideRef: MutableRefObject<boolean>;
consumedInitialPromptRef: MutableRefObject<string | null>;
}
export function useWorkspaceAutoGuideRuntime({
contentId,
sessionId,
initialUserPrompt,
initialUserImages,
initialAutoSendRequestMetadata,
autoRunInitialPromptOnMount,
initialDispatchKey,
messagesCount,
projectReady,
systemPromptReady,
isSending,
canvasState,
isThemeWorkbench,
mappedTheme,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
chatToolPreferences,
setInput,
handleSend,
triggerAIGuide,
onInitialUserPromptConsumed,
hasTriggeredGuideRef,
consumedInitialPromptRef,
}: UseWorkspaceAutoGuideRuntimeParams) {
const triggerAIGuideRef = useRef(triggerAIGuide);
triggerAIGuideRef.current = triggerAIGuide;
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const canvasEmpty = isCanvasStateEmpty(canvasState);
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
const defaultGuidePrompt =
contentId && canvasEmpty && !isThemeWorkbench
? getDefaultGuidePromptByTheme(mappedTheme)
: undefined;
if (
!contentId ||
messagesCount > 0 ||
!projectReady ||
!systemPromptReady ||
isSending ||
!canvasEmpty
) {
return;
}
if (!initialDispatchKey && generalWorkbenchEntryCheckPending) {
return;
}
if (initialDispatchKey) {
if (
isThemeWorkbench &&
pendingInitialImages.length === 0 &&
!autoRunInitialPromptOnMount
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
hasTriggeredGuideRef.current = true;
logWorkspaceInfo("[AgentChatPage] 自动发送首条创作意图消息");
void (async () => {
const started = await handleSend(
pendingInitialImages,
chatToolPreferences.webSearch,
chatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}
if (hasTriggeredGuideRef.current) {
return;
}
if (generalWorkbenchEntryPrompt?.kind === "resume") {
return;
}
if (defaultGuidePrompt) {
hasTriggeredGuideRef.current = true;
setInput((previous) => previous.trim() || defaultGuidePrompt);
return;
}
if (isThemeWorkbench) {
if (shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt) {
return;
}
hasTriggeredGuideRef.current = true;
logWorkspaceInfo("[AgentChatPage] 工作区上下文:触发 AI 引导");
triggerAIGuideRef.current();
return;
}
hasTriggeredGuideRef.current = true;
logWorkspaceInfo("[AgentChatPage] 自动触发 AI 创作引导");
triggerAIGuideRef.current();
}, [
canvasState,
chatToolPreferences.thinking,
chatToolPreferences.webSearch,
contentId,
handleSend,
initialDispatchKey,
initialAutoSendRequestMetadata,
initialUserImages,
initialUserPrompt,
autoRunInitialPromptOnMount,
isSending,
isThemeWorkbench,
mappedTheme,
messagesCount,
onInitialUserPromptConsumed,
projectReady,
setInput,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
shouldUseCompactGeneralWorkbench,
systemPromptReady,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
consumedInitialPromptRef,
hasTriggeredGuideRef,
]);
useEffect(() => {
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
shouldUseCompactGeneralWorkbench ||
!initialDispatchKey ||
contentId ||
!sessionId ||
messagesCount > 0 ||
isSending
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
void (async () => {
const started = await handleSend(
pendingInitialImages,
chatToolPreferences.webSearch,
chatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}, [
chatToolPreferences.thinking,
chatToolPreferences.webSearch,
contentId,
handleSend,
initialDispatchKey,
initialAutoSendRequestMetadata,
initialUserImages,
initialUserPrompt,
isSending,
messagesCount,
onInitialUserPromptConsumed,
sessionId,
shouldUseCompactGeneralWorkbench,
consumedInitialPromptRef,
]);
useEffect(() => {
hasTriggeredGuideRef.current = false;
consumedInitialPromptRef.current = null;
}, [contentId, consumedInitialPromptRef, hasTriggeredGuideRef]);
}
@@ -25,6 +25,7 @@ import type { Artifact } from "@/lib/artifact/types";
import type { Character } from "@/lib/api/memory";
import type { TaskFile } from "../components/TaskFiles";
import type { WorkspacePathMissingState } from "../hooks/agentChatShared";
import type { SyncStatus } from "../hooks/useContentSync";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import {
buildStepProgressProps,
@@ -271,7 +272,7 @@ interface UseWorkspaceConversationSceneRuntimeParams {
harnessToggleLabel: ConversationScenePresentationParams["scene"]["harnessToggleLabel"];
isAutoRestoringSession: boolean;
sessionId: string | null | undefined;
syncStatus: ConversationScenePresentationParams["scene"]["syncStatus"];
syncStatus: SyncStatus;
pendingA2UIForm: ConversationScenePresentationParams["scene"]["pendingA2UIForm"];
pendingA2UISource: PendingA2UISource | null;
a2uiSubmissionNotice: ConversationScenePresentationParams["scene"]["a2uiSubmissionNotice"];
@@ -6,8 +6,9 @@ import {
type ReactNode,
type SetStateAction,
} from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { Character } from "@/lib/api/memory";
import { GeneralWorkbenchEntryPromptAccessory } from "../components/GeneralWorkbenchEntryPromptAccessory";
import { Inputbar } from "../components/Inputbar";
import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock";
import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions";
@@ -23,8 +24,142 @@ import { isRenderableTaskFile } from "./generalWorkbenchHelpers";
import {
GeneralWorkbenchDialogSection,
} from "./WorkspaceHarnessDialogs";
import { WorkspaceInputbar } from "./WorkspaceInputbar";
import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps";
import type { GeneralWorkbenchEntryPromptState } from "./workspaceSendHelpers";
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const GeneralWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 48px));
max-width: min(420px, calc(100vw - 48px));
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(191, 219, 254, 0.92);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(239, 246, 255, 0.96) 100%
);
color: #0f172a;
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const GeneralWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const GeneralWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const GeneralWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const GeneralWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid
${({ $variant }) =>
$variant === "ghost"
? "rgba(191, 219, 254, 0.92)"
: "rgba(59, 130, 246, 0.94)"};
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(255, 255, 255, 0.92)"
: "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"};
color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.16s ease,
box-shadow 0.2s ease,
background 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46);
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(239, 246, 255, 0.98)"
: "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"};
}
`;
function renderGeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: GeneralWorkbenchEntryPromptAccessoryProps): ReactNode {
return (
<GeneralWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<GeneralWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<GeneralWorkbenchEntryPromptTitleWrap>
<GeneralWorkbenchEntryPromptTitle>
{prompt.title}
</GeneralWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptDescription>
{prompt.description}
</GeneralWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitleWrap>
</GeneralWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptActions>
<GeneralWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</GeneralWorkbenchEntryPromptButton>
<GeneralWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
void onContinue();
}}
>
{prompt.actionLabel}
</GeneralWorkbenchEntryPromptButton>
</GeneralWorkbenchEntryPromptActions>
</GeneralWorkbenchEntryPromptCard>
);
}
type WorkspaceInputbarBuilderParams = Omit<
ComponentProps<typeof Inputbar>,
@@ -84,9 +219,7 @@ interface UseWorkspaceInputbarScenePresentationRuntimeParams {
| "onSelectCharacter"
>;
floatingTeamWorkspaceDock: FloatingTeamWorkspaceDockParams;
generalWorkbenchEntryPrompt:
| ComponentProps<typeof GeneralWorkbenchEntryPromptAccessory>["prompt"]
| null;
generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null;
onRestartGeneralWorkbenchEntryPrompt: () => void;
onContinueGeneralWorkbenchEntryPrompt: () => Promise<void> | void;
generalWorkbenchDialog: ComponentProps<typeof GeneralWorkbenchDialogSection>;
@@ -209,11 +342,12 @@ function useWorkspaceInputbarScenePresentationRuntime({
const generalWorkbenchEntryPromptAccessory = useMemo(
() =>
inputbarPresentation.generalWorkbenchEntryPrompt ? (
<GeneralWorkbenchEntryPromptAccessory
prompt={inputbarPresentation.generalWorkbenchEntryPrompt}
onRestart={inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt}
onContinue={inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt}
/>
renderGeneralWorkbenchEntryPromptAccessory({
prompt: inputbarPresentation.generalWorkbenchEntryPrompt,
onRestart: inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt,
onContinue:
inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt,
})
) : null,
[
inputbarPresentation.generalWorkbenchEntryPrompt,
@@ -259,11 +393,19 @@ function useWorkspaceInputbarScenePresentationRuntime({
],
);
const overlayAccessory =
generalWorkbenchEntryPromptAccessory || floatingTeamWorkspaceDockProps ? (
<>
{generalWorkbenchEntryPromptAccessory}
{floatingTeamWorkspaceDockProps ? (
<TeamWorkspaceDock {...floatingTeamWorkspaceDockProps} />
) : null}
</>
) : undefined;
const inputbarNode = (
<WorkspaceInputbar
inputbarProps={workspaceInputbarProps}
accessory={generalWorkbenchEntryPromptAccessory}
teamWorkspaceDockProps={floatingTeamWorkspaceDockProps}
<Inputbar
{...workspaceInputbarProps}
overlayAccessory={overlayAccessory}
/>
);
const generalWorkbenchDialog = (
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import type { Message } from "../types";
import type { InitialDispatchPreviewSnapshot } from "./workspaceSendHelpers";
import {
listMentionEntryUsage,
recordMentionEntryUsage,
@@ -316,6 +317,16 @@ function createExistingMessages(count: number): Message[] {
}));
}
function createBootstrapDispatchSnapshot(
prompt = "请开始处理这个任务",
): InitialDispatchPreviewSnapshot {
return {
key: "bootstrap-dispatch-1",
prompt,
images: [],
};
}
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -356,7 +367,7 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
contentId: null,
workspaceRequestMetadataBase: undefined,
messages: [],
bootstrapDispatchPreviewMessages: [],
bootstrapDispatchPreview: null,
sendMessage: mockSendMessage,
resolveSendBoundary: (({ sourceText }) => ({
sourceText,
@@ -473,15 +484,21 @@ describe("useWorkspaceSendActions", () => {
});
it("无真实消息时应透传 bootstrap 预览消息", () => {
const bootstrapPreviewMessages = createExistingMessages(2);
const harness = mountHook({
bootstrapDispatchPreviewMessages: bootstrapPreviewMessages,
bootstrapDispatchPreview: createBootstrapDispatchSnapshot(),
});
try {
expect(harness.getValue().displayMessages).toEqual(
bootstrapPreviewMessages,
);
expect(harness.getValue().displayMessages).toHaveLength(2);
expect(harness.getValue().displayMessages[0]).toMatchObject({
role: "user",
content: "请开始处理这个任务",
});
expect(harness.getValue().displayMessages[1]).toMatchObject({
role: "assistant",
content: "正在开始处理任务…",
isThinking: true,
});
} finally {
harness.unmount();
}
@@ -62,16 +62,18 @@ import {
type ChatToolPreferences,
} from "../utils/chatToolPreferences";
import type { HandleSendOptions } from "../hooks/handleSendTypes";
import type { GeneralWorkbenchSendBoundaryState } from "../hooks/useGeneralWorkbenchSendBoundary";
import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation";
import type { SendMessageFn } from "../hooks/agentChatShared";
import type { Message, MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import type { AgentAccessMode } from "../hooks/agentChatStorage";
import {
buildInitialDispatchPreviewMessages,
buildRuntimeTeamDispatchPreview,
buildRuntimeTeamDispatchPreviewMessages,
buildSubmissionPreviewMessages,
type GeneralWorkbenchSendBoundaryState,
type InitialDispatchPreviewSnapshot,
resolveRuntimeTeamDispatchPreviewState,
type RuntimeTeamDispatchPreviewSnapshot,
createSubmissionPreviewSnapshot,
@@ -2323,7 +2325,7 @@ interface UseWorkspaceSendActionsParams {
browserAssistAutoLaunch?: boolean | null;
workspaceRequestMetadataBase?: Record<string, unknown>;
messages: Message[];
bootstrapDispatchPreviewMessages: Message[];
bootstrapDispatchPreview?: InitialDispatchPreviewSnapshot | null;
sendMessage: SendMessageFn;
resolveSendBoundary: (input: {
sourceText: string;
@@ -2430,7 +2432,7 @@ export function useWorkspaceSendActions({
browserAssistAutoLaunch,
workspaceRequestMetadataBase,
messages,
bootstrapDispatchPreviewMessages,
bootstrapDispatchPreview,
sendMessage,
resolveSendBoundary,
finalizeAfterSendSuccess,
@@ -2471,6 +2473,13 @@ export function useWorkspaceSendActions({
: [],
[messagesCount, submissionPreview],
);
const bootstrapDispatchPreviewMessages = useMemo(
() =>
bootstrapDispatchPreview
? buildInitialDispatchPreviewMessages(bootstrapDispatchPreview)
: [],
[bootstrapDispatchPreview],
);
const displayMessages = useMemo(() => {
if (runtimeTeamDispatchPreviewMessages.length > 0) {
return [...messages, ...runtimeTeamDispatchPreviewMessages];
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import {
buildGeneralWorkbenchSendBoundaryState,
buildGeneralWorkbenchResumePromptFromRunState,
buildInitialDispatchKey,
buildInitialDispatchPreviewMessages,
buildRuntimeTeamDispatchPreviewMessages,
buildSubmissionPreviewMessages,
createSubmissionPreviewSnapshot,
@@ -7,6 +11,107 @@ import {
} from "./workspaceSendHelpers";
describe("workspaceSendHelpers runtime team preview", () => {
it("initialDispatchKey 应稳定编码首轮 prompt 与图片签名", () => {
expect(
buildInitialDispatchKey("写一篇文章", [
{ data: "abcdef1234567890", mediaType: "image/png" },
]),
).toContain("写一篇文章");
});
it("bootstrap 预览消息应使用统一 initial-dispatch 结构", () => {
const messages = buildInitialDispatchPreviewMessages({
key: "initial-dispatch-1",
prompt: "请开始处理这个任务",
images: [],
});
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
id: "initial-dispatch:initial-dispatch-1:user",
role: "user",
content: "请开始处理这个任务",
});
expect(messages[1]).toMatchObject({
id: "initial-dispatch:initial-dispatch-1:assistant",
role: "assistant",
content: "正在开始处理任务…",
isThinking: true,
});
});
it("工作区首条创作意图应包装成 current send boundary", () => {
const boundary = buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench: true,
contentId: "content-1",
initialDispatchKey: "dispatch-1",
consumedInitialPromptKey: null,
initialUserImages: [],
mappedTheme: "general",
socialArticleSkillKey: "content_post_with_cover",
sourceText: "请生成今天的社媒主稿",
});
expect(boundary).toMatchObject({
sourceText: "/content_post_with_cover 请生成今天的社媒主稿",
shouldConsumePendingGeneralWorkbenchInitialPrompt: true,
shouldDismissGeneralWorkbenchEntryPrompt: true,
browserRequirementMatch: null,
});
});
it("浏览器任务应在 current send boundary 中保留 requirement 检测", () => {
const boundary = buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench: true,
contentId: "content-1",
initialDispatchKey: "dispatch-1",
consumedInitialPromptKey: null,
initialUserImages: [],
mappedTheme: "general",
socialArticleSkillKey: "content_post_with_cover",
sourceText: "帮我把这篇文章发布到微信公众号后台",
});
expect(boundary.sourceText).toBe(
"/content_post_with_cover 帮我把这篇文章发布到微信公众号后台",
);
expect(boundary.browserRequirementMatch).toEqual(
expect.objectContaining({
requirement: "required_with_user_step",
launchUrl: "https://mp.weixin.qq.com/",
platformLabel: "微信公众号后台",
}),
);
});
it("run-state 应生成 resume prompt", () => {
const prompt = buildGeneralWorkbenchResumePromptFromRunState({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
{
run_id: "run-1",
title: "撰写主稿",
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(),
});
expect(prompt).toMatchObject({
kind: "resume",
title: "发现上次未完成任务",
actionLabel: "继续上次任务",
description: expect.stringContaining("撰写主稿"),
});
});
it("应在失败预览中覆盖 formationState 的错误信息", () => {
const state = resolveRuntimeTeamDispatchPreviewState({
key: "runtime-team-failed",
@@ -1,4 +1,13 @@
import { preheatBrowserAssistInBackground } from "../utils/browserAssistPreheat";
import {
detectBrowserTaskRequirement,
type BrowserTaskRequirementMatch,
} from "../utils/browserTaskRequirement";
import type {
GeneralWorkbenchRunState,
GeneralWorkbenchRunTerminalItem,
GeneralWorkbenchRunTodoItem,
} from "@/lib/api/executionRun";
import {
buildHarnessRequestMetadata,
extractExistingHarnessMetadata,
@@ -52,6 +61,33 @@ export interface RuntimeTeamDispatchPreviewSnapshot {
failureMessage?: string | null;
}
export interface InitialDispatchPreviewSnapshot {
key: string;
prompt?: string;
images: MessageImage[];
}
export function buildInitialDispatchKey(
prompt?: string,
images?: MessageImage[],
): string | null {
const normalizedPrompt = (prompt || "").trim();
const normalizedImages = images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return null;
}
const imageSignature = normalizedImages
.map(
(image, index) =>
`${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`,
)
.join("|");
return `${normalizedPrompt}::${imageSignature}`;
}
export interface SubmissionPreviewSnapshot {
key: string;
prompt: string;
@@ -66,6 +102,34 @@ export interface ContextWorkspaceSummary {
prepareActiveContextPrompt: () => Promise<string>;
}
export interface GeneralWorkbenchEntryPromptState {
kind: "initial_prompt" | "resume";
signature: string;
title: string;
description: string;
actionLabel: string;
prompt: string;
}
interface BuildGeneralWorkbenchSendBoundaryStateOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
sourceText: string;
sendOptions?: HandleSendOptions;
}
export interface GeneralWorkbenchSendBoundaryState {
sourceText: string;
browserRequirementMatch: BrowserTaskRequirementMatch | null;
shouldConsumePendingGeneralWorkbenchInitialPrompt: boolean;
shouldDismissGeneralWorkbenchEntryPrompt: boolean;
}
type PreparedActiveContextPromptResult =
| {
ok: true;
@@ -76,6 +140,71 @@ type PreparedActiveContextPromptResult =
error: unknown;
};
function resolveGeneralWorkbenchGateLabel(
gateKey?: GeneralWorkbenchRunTodoItem["gate_key"],
): string | null {
switch (gateKey) {
case "topic_select":
return "选题确认";
case "write_mode":
return "写作推进";
case "publish_confirm":
return "发布确认";
case null:
case undefined:
default:
return null;
}
}
function resolveGeneralWorkbenchPendingRunCandidate(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchRunTodoItem | GeneralWorkbenchRunTerminalItem | null {
if (!state) {
return null;
}
const activeQueueItem = (state.queue_items || []).find((item) =>
["queued", "running", "error", "timeout"].includes(item.status),
);
if (activeQueueItem) {
return activeQueueItem;
}
if (
state.latest_terminal &&
["queued", "running", "error", "timeout"].includes(
state.latest_terminal.status,
)
) {
return state.latest_terminal;
}
return null;
}
export function buildGeneralWorkbenchResumePromptFromRunState(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchEntryPromptState | null {
const pendingRun = resolveGeneralWorkbenchPendingRunCandidate(state);
if (!pendingRun) {
return null;
}
const runTitle = pendingRun.title?.trim() || "最近一次创作任务";
const gateLabel = resolveGeneralWorkbenchGateLabel(pendingRun.gate_key);
const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : "";
return {
kind: "resume",
signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`,
title: "发现上次未完成任务",
description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`,
actionLabel: "继续上次任务",
prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`,
};
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -206,6 +335,55 @@ function readExistingTeamMemoryShadow(
};
}
export function buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}: BuildGeneralWorkbenchSendBoundaryStateOptions): GeneralWorkbenchSendBoundaryState {
const shouldConsumePendingGeneralWorkbenchInitialPrompt =
isThemeWorkbench &&
Boolean(contentId) &&
Boolean(initialDispatchKey) &&
consumedInitialPromptKey !== initialDispatchKey &&
(initialUserImages || []).length === 0 &&
!sendOptions?.purpose;
const shouldDismissGeneralWorkbenchEntryPrompt =
isThemeWorkbench && !sendOptions?.purpose;
const trimmedSourceText = sourceText.trim();
const shouldWrapWithGeneralWorkbenchSkill =
isThemeWorkbench &&
mappedTheme === "general" &&
!sendOptions?.purpose &&
trimmedSourceText.length > 0 &&
!trimmedSourceText.startsWith("/") &&
!trimmedSourceText.startsWith("@");
const nextSourceText = shouldWrapWithGeneralWorkbenchSkill
? `/${socialArticleSkillKey} ${trimmedSourceText}`
: sourceText;
const browserRequirementSourceText = shouldWrapWithGeneralWorkbenchSkill
? trimmedSourceText
: nextSourceText;
const browserRequirementMatch =
mappedTheme === "general" && !sendOptions?.purpose
? detectBrowserTaskRequirement(browserRequirementSourceText)
: null;
return {
sourceText: nextSourceText,
browserRequirementMatch,
shouldConsumePendingGeneralWorkbenchInitialPrompt,
shouldDismissGeneralWorkbenchEntryPrompt,
};
}
export interface EnsureBrowserAssistCanvasOptions {
silent?: boolean;
navigationMode?: "none" | "explicit-url" | "best-effort";
@@ -784,6 +962,41 @@ export function buildRuntimeTeamDispatchPreviewMessages(
];
}
export function buildInitialDispatchPreviewMessages(
snapshot: InitialDispatchPreviewSnapshot,
assistantPreviewText?: string,
): Message[] {
const normalizedPrompt = (snapshot.prompt || "").trim();
const normalizedImages = snapshot.images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return [];
}
const timestamp = new Date();
const normalizedAssistantPreviewText =
assistantPreviewText?.trim() || "正在开始处理任务…";
const isAssistantThinking =
normalizedAssistantPreviewText === "正在开始处理任务…";
return [
{
id: `initial-dispatch:${snapshot.key}:user`,
role: "user",
content: normalizedPrompt,
images: normalizedImages.length > 0 ? normalizedImages : undefined,
timestamp,
},
{
id: `initial-dispatch:${snapshot.key}:assistant`,
role: "assistant",
content: normalizedAssistantPreviewText,
timestamp: new Date(timestamp.getTime() + 1),
isThinking: isAssistantThinking,
},
];
}
interface CreateSubmissionPreviewSnapshotOptions {
key: string;
prompt: string;
@@ -1,5 +1,4 @@
import type { FileEntry } from "@/components/terminal/widgets/types";
import type { DirectoryListing } from "@/lib/api/fileBrowser";
import type { DirectoryListing, FileEntry } from "@/lib/api/fileBrowser";
const ROOT_HIDDEN_DIRECTORY_NAMES = new Set([".lime", "output"]);
const GLOBAL_HIDDEN_ENTRY_NAMES = new Set([".DS_Store", "Thumbs.db"]);
@@ -1,396 +0,0 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Page, PageParams } from "@/types/page";
import type { GeneratedImage } from "./types";
import {
cleanupMountedRoots,
flushEffects,
renderIntoDom,
setReactActEnvironment,
silenceConsole,
type MountedRoot,
} from "./test-utils";
const {
mockBackfillImagesToResource,
mockGenerateImage,
mockDeleteImage,
mockNewImage,
mockToast,
} = vi.hoisted(() => ({
mockBackfillImagesToResource: vi.fn(),
mockGenerateImage: vi.fn(),
mockDeleteImage: vi.fn(),
mockNewImage: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("sonner", () => ({
toast: mockToast,
}));
vi.mock("@/hooks/useProjects", () => ({
useProjects: () => {
const defaultProject = {
id: "project-default",
name: "默认项目",
workspaceType: "persistent",
rootPath: "/tmp/default",
isDefault: true,
icon: undefined,
color: undefined,
isFavorite: false,
isArchived: false,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
return {
projects: [defaultProject],
filteredProjects: [defaultProject],
defaultProject,
loading: false,
error: null,
filter: {},
setFilter: vi.fn(),
refresh: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
getOrCreateDefault: vi.fn(),
};
},
}));
vi.mock("@/hooks/useProject", () => ({
useProject: () => ({
project: {
id: "project-default",
name: "默认项目",
workspaceType: "persistent",
rootPath: "/tmp/default",
isDefault: true,
settings: {},
isFavorite: false,
isArchived: false,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
},
loading: false,
error: null,
refresh: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
toggleFavorite: vi.fn(),
}),
}));
vi.mock("./useImageGen", async () => {
const React = await import("react");
const images: GeneratedImage[] = [
{
id: "img-1",
url: "https://example.com/1.png",
prompt: "第一张提示词",
model: "fal-ai/nano-banana-pro",
size: "1024x1024",
providerId: "fal",
providerName: "Fal",
createdAt: 1700000000000,
status: "complete",
},
{
id: "img-2",
url: "https://example.com/2.png",
prompt: "第二张提示词",
model: "fal-ai/nano-banana-pro",
size: "1024x1024",
providerId: "fal",
providerName: "Fal",
createdAt: 1700000001000,
status: "complete",
},
];
return {
useImageGen: () => {
const [selectedImageId, setSelectedImageId] = React.useState<
string | null
>(images[0].id);
const selectedImage =
images.find((image) => image.id === selectedImageId) ?? images[0];
return {
availableProviders: [
{
id: "fal",
type: "fal",
name: "Fal",
enabled: true,
api_key_count: 1,
api_host: "https://fal.run",
},
],
selectedProvider: {
id: "fal",
type: "fal",
name: "Fal",
enabled: true,
api_key_count: 1,
api_host: "https://fal.run",
},
selectedProviderId: "fal",
setSelectedProviderId: vi.fn(),
providersLoading: false,
availableModels: [
{
id: "fal-ai/nano-banana-pro",
name: "Nano Banana Pro",
supportedSizes: ["1024x1024"],
},
],
selectedModel: {
id: "fal-ai/nano-banana-pro",
name: "Nano Banana Pro",
supportedSizes: ["1024x1024"],
},
selectedModelId: "fal-ai/nano-banana-pro",
setSelectedModelId: vi.fn(),
selectedSize: "1024x1024",
setSelectedSize: vi.fn(),
images,
selectedImage,
selectedImageId,
setSelectedImageId,
generating: false,
savingToResource: false,
generateImage: mockGenerateImage,
backfillImagesToResource: mockBackfillImagesToResource,
deleteImage: mockDeleteImage,
newImage: mockNewImage,
};
},
};
});
import { ImageGenPage } from "./ImageGenPage";
const mountedRoots: MountedRoot[] = [];
function renderPage(
onNavigate?: (page: Page, params?: PageParams) => void,
): HTMLDivElement {
return renderIntoDom(<ImageGenPage onNavigate={onNavigate} />, mountedRoots)
.container;
}
function findButtonByText(
container: HTMLElement,
text: string,
): HTMLButtonElement {
const target = Array.from(container.querySelectorAll("button")).find((node) =>
node.textContent?.includes(text),
);
if (!target) {
throw new Error(`未找到按钮: ${text}`);
}
return target as HTMLButtonElement;
}
function findButtonByAriaLabel(
container: HTMLElement,
ariaLabel: string,
): HTMLButtonElement {
const target = container.querySelector(
`button[aria-label='${ariaLabel}']`,
) as HTMLButtonElement | null;
if (!target) {
throw new Error(`未找到 tips 按钮: ${ariaLabel}`);
}
return target;
}
async function hoverElement(element: HTMLElement) {
await act(async () => {
element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
await Promise.resolve();
});
}
async function leaveElement(element: HTMLElement) {
await act(async () => {
element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
await Promise.resolve();
});
}
function getPromptChip(container: HTMLElement): HTMLButtonElement {
const label = Array.from(container.querySelectorAll("div")).find(
(node) => node.textContent === "当前图片提示词",
);
if (!label || !label.parentElement) {
throw new Error("未找到提示词历史区域");
}
const chip = label.parentElement.querySelector("button");
if (!chip) {
throw new Error("未找到提示词历史按钮");
}
return chip as HTMLButtonElement;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
beforeEach(() => {
setReactActEnvironment();
localStorage.clear();
vi.clearAllMocks();
silenceConsole();
mockBackfillImagesToResource.mockResolvedValue({
total: 2,
saved: 2,
failed: 0,
skipped: 0,
errors: [],
});
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.restoreAllMocks();
localStorage.clear();
});
describe("ImageGenPage", () => {
it("AI 生图区应把静态说明文案收进 tips", async () => {
const container = renderPage();
await flushEffects();
expect(container.textContent).not.toContain(
"左侧集中管理模型、参考图与输出规格;主画布负责预览结果与继续迭代。",
);
expect(container.textContent).not.toContain("当前服务商:Fal");
const introTip = findButtonByAriaLabel(container, "插图生成参数说明");
await hoverElement(introTip);
expect(document.body.textContent).toContain(
"左侧集中管理模型、参考图与输出规格;主画布负责预览结果与继续迭代。",
);
await leaveElement(introTip);
const modelTip = findButtonByAriaLabel(container, "图片模型说明");
await hoverElement(modelTip);
expect(document.body.textContent).toContain(
"当前服务商:Fal。模型切换会影响尺寸支持范围与图片编辑链路。",
);
await leaveElement(modelTip);
});
it("点击左上角返回按钮应回到新建任务页", async () => {
const onNavigate = vi.fn();
const container = renderPage(onNavigate);
await flushEffects();
const backButton = container.querySelector<HTMLButtonElement>(
'button[title="返回新建任务"]',
);
expect(backButton).not.toBeNull();
act(() => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
theme: "general",
lockTheme: false,
newChatAt: expect.any(Number),
}),
);
});
it("应仅显示当前选中图片的提示词历史", async () => {
const container = renderPage();
await flushEffects();
const chipBefore = getPromptChip(container);
expect(chipBefore.textContent).toContain("第一张提示词");
const secondHistoryItem = container.querySelector<HTMLElement>(
'[role="button"][title="第二张提示词"]',
);
expect(secondHistoryItem).not.toBeNull();
act(() => {
secondHistoryItem?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
const chipAfter = getPromptChip(container);
expect(chipAfter.textContent).toContain("第二张提示词");
});
it("点击补录按钮应使用目标项目触发历史补录", async () => {
const container = renderPage();
await flushEffects();
const backfillButton = findButtonByText(container, "补录历史到资源库");
expect(backfillButton.disabled).toBe(false);
await act(async () => {
backfillButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mockBackfillImagesToResource).toHaveBeenCalledTimes(1);
expect(mockBackfillImagesToResource).toHaveBeenCalledWith(
"project-default",
);
expect(mockToast.success).toHaveBeenCalled();
});
it("AI 生图布局应锁定父级高度避免小屏裁切输入区", async () => {
const mounted = renderIntoDom(
<div style={{ height: "640px", width: "960px" }}>
<ImageGenPage />
</div>,
mountedRoots,
);
await flushEffects();
const layout = mounted.container.querySelector<HTMLElement>(
'[data-testid="ai-image-gen-layout"]',
);
expect(layout).not.toBeNull();
const styles = Array.from(document.head.querySelectorAll("style"))
.map((node) => node.textContent || "")
.join("\n");
const hasExpectedRule = Array.from(layout?.classList || []).some(
(className) =>
new RegExp(
`\\.${escapeRegExp(className)}\\{[^}]*height:100%;[^}]*overflow:hidden;`,
).test(styles),
);
expect(hasExpectedRule).toBe(true);
});
});
-369
View File
@@ -1,369 +0,0 @@
/**
* @file 图片生成页面
* @description 插图功能 - 包含图片搜索、AI生图、本地图片、我的图片库四个 Tab
* @module components/image-gen/ImageGenPage
*/
import { useEffect, useMemo, useState } from "react";
import styled, { keyframes } from "styled-components";
import { ChevronDown } from "lucide-react";
import { useProjects } from "@/hooks/useProjects";
import {
getStoredResourceProjectId,
onResourceProjectChange,
setStoredResourceProjectId,
} from "@/lib/resourceProjectSelection";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { CanvasBreadcrumbHeader } from "@/lib/workspace/workbenchUi";
import type { Page, PageParams } from "@/types/page";
import { AiImageGenTab } from "./tabs/AiImageGenTab";
import { ImageSearchTab } from "./tabs/ImageSearchTab";
import { LocalImageTab } from "./tabs/LocalImageTab";
import { MyGalleryTab } from "./tabs/MyGalleryTab";
type PageNavigate = (page: Page, params?: PageParams) => void;
interface ImageGenPageProps {
onNavigate?: PageNavigate;
}
const PageLayout = styled.div`
position: relative;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%);
`;
const PageChrome = styled.div`
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
padding: 8px 10px 0;
flex-shrink: 0;
@media (max-width: 960px) {
padding: 8px 8px 0;
}
`;
const HeaderBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 18px;
border: 1px solid hsl(var(--border) / 0.78);
background: hsl(var(--background) / 0.82);
box-shadow:
0 12px 28px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.74);
backdrop-filter: blur(16px);
@media (max-width: 960px) {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
`;
const HeaderLead = styled.div`
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
@media (max-width: 960px) {
width: 100%;
}
`;
const ProjectSelectorWrapper = styled.div`
position: relative;
display: inline-flex;
align-items: center;
min-width: 180px;
@media (max-width: 960px) {
width: 100%;
}
`;
const ProjectSelector = styled.select`
appearance: none;
width: 100%;
height: 36px;
padding: 0 34px 0 12px;
border: 1px solid hsl(var(--border));
border-radius: 12px;
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(var(--muted) / 0.12)
);
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
border-color: hsl(214 68% 38% / 0.32);
transform: translateY(-1px);
}
&:focus {
outline: none;
border-color: hsl(214 68% 38% / 0.34);
box-shadow: 0 0 0 4px hsl(211 100% 96%);
}
`;
const SelectorIcon = styled.div`
position: absolute;
right: 12px;
pointer-events: none;
color: hsl(var(--muted-foreground));
`;
const MainContainer = styled.div`
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0 10px 10px;
@media (max-width: 960px) {
padding: 0 8px 8px;
}
`;
const TabsBar = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: 4px;
padding: 4px;
border-radius: 14px;
border: 1px solid hsl(var(--border) / 0.78);
background: hsl(var(--muted) / 0.18);
overflow-x: auto;
`;
const pulseIn = keyframes`
from {
opacity: 0.4;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
`;
const TabButton = styled.button<{ $active: boolean }>`
position: relative;
flex-shrink: 0;
height: 32px;
padding: 0 12px;
border: 1px solid
${({ $active }) => ($active ? "hsl(214 68% 38% / 0.18)" : "transparent")};
border-radius: 10px;
background: ${({ $active }) =>
$active
? "linear-gradient(180deg, hsl(var(--background)), hsl(203 100% 97%))"
: "transparent"};
font-size: 13px;
font-weight: ${({ $active }) => ($active ? 700 : 500)};
color: ${({ $active }) =>
$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
cursor: pointer;
transition:
color 0.2s ease,
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
animation: ${({ $active }) => ($active ? pulseIn : "none")} 0.24s ease;
&:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.18);
}
`;
const TabContent = styled.div`
flex: 1;
min-height: 0;
overflow: hidden;
margin-top: 8px;
border-radius: 22px;
border: 1px solid hsl(var(--border) / 0.72);
background: hsl(var(--background) / 0.52);
box-shadow:
0 12px 30px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.64);
backdrop-filter: blur(10px);
`;
const TABS = [
{ key: "ai-gen", label: "AI生图" },
{ key: "search", label: "图片搜索" },
{ key: "local", label: "本地图片" },
{ key: "gallery", label: "我的图片库" },
] as const;
export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
const [activeTab, setActiveTab] = useState("ai-gen");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const { projects, defaultProject, loading: projectsLoading } = useProjects();
const handleBackHome = () => {
onNavigate?.("agent", buildHomeAgentParams());
};
const availableProjects = useMemo(
() => projects.filter((project) => !project.isArchived),
[projects],
);
useEffect(() => {
if (projectsLoading) {
return;
}
setSelectedProjectId((current) => {
if (
current &&
availableProjects.some((project) => project.id === current)
) {
return current;
}
const storedProjectId = getStoredResourceProjectId({
includeLegacy: true,
});
if (
storedProjectId &&
availableProjects.some((project) => project.id === storedProjectId)
) {
return storedProjectId;
}
const preferredProject =
(defaultProject && !defaultProject.isArchived
? defaultProject
: null) ?? availableProjects[0];
return preferredProject?.id || null;
});
}, [projectsLoading, availableProjects, defaultProject]);
useEffect(() => {
if (selectedProjectId) {
setStoredResourceProjectId(selectedProjectId, {
source: "image-gen-target",
syncLegacy: true,
emitEvent: true,
});
}
}, [selectedProjectId]);
useEffect(() => {
return onResourceProjectChange((detail) => {
if (detail.source !== "resources") {
return;
}
const nextProjectId = detail.projectId;
if (!nextProjectId || nextProjectId === selectedProjectId) {
return;
}
if (!availableProjects.some((project) => project.id === nextProjectId)) {
return;
}
setSelectedProjectId(nextProjectId);
});
}, [availableProjects, selectedProjectId]);
return (
<PageLayout>
<PageChrome>
<HeaderBar>
<HeaderLead>
<CanvasBreadcrumbHeader label="插图" onBackHome={handleBackHome} />
<TabsBar>
{TABS.map((tab) => (
<TabButton
key={tab.key}
$active={activeTab === tab.key}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</TabButton>
))}
</TabsBar>
</HeaderLead>
<ProjectSelectorWrapper>
<ProjectSelector
value={selectedProjectId || ""}
onChange={(e) => setSelectedProjectId(e.target.value || null)}
disabled={projectsLoading}
>
<option value="">选择项目</option>
{availableProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</ProjectSelector>
<SelectorIcon>
<ChevronDown size={14} />
</SelectorIcon>
</ProjectSelectorWrapper>
</HeaderBar>
</PageChrome>
<MainContainer>
<TabContent>
{activeTab === "search" && (
<ImageSearchTab
projectId={selectedProjectId}
onNavigate={onNavigate}
/>
)}
{activeTab === "ai-gen" && (
<AiImageGenTab
projectId={selectedProjectId}
onNavigate={onNavigate}
/>
)}
{activeTab === "local" && (
<LocalImageTab projectId={selectedProjectId} />
)}
{activeTab === "gallery" && (
<MyGalleryTab
projectId={selectedProjectId}
onNavigate={onNavigate}
/>
)}
</TabContent>
</MainContainer>
</PageLayout>
);
}
+17 -30
View File
@@ -1,41 +1,28 @@
# 图片生成模块
AI 图片生成功能,支持多个提供商和模型。
这里现在只保留供现役链路复用的 AI 生图 runtime、插图浮层与测试辅助。
## 功能特性
## 当前事实源
- 支持多个图片生成提供商(智谱、AiHubMix、硅基流动等)
- 支持多种图片尺寸选择
- 历史记录管理
- 提供商配置管理
- `useImageGen.ts`: AI 生图主 Hook,负责 provider、任务、保存与插图联动
- `RecentImageInsertFloating.tsx`: 最近插图浮层
- `types.ts`: 共享类型
- `test-utils.ts`: 相关测试辅助
## 文件结构
## 导入约束
| 文件 | 说明 |
|------|------|
| `ImageGenPage.tsx` | 主页面组件 |
| `ProviderConfigModal.tsx` | 提供商配置弹窗 |
| `useImageGen.ts` | 状态管理 Hook |
| `types.ts` | 类型定义 |
| `index.ts` | 模块导出 |
- 不再保留 `@/components/image-gen` 目录级 barrel 导出
- 现役代码必须直连子路径,例如 `@/components/image-gen/useImageGen`
- 共享类型与测试辅助分别走 `@/components/image-gen/types`、`@/components/image-gen/test-utils`
## 支持的提供商
## 已收口的旧 surface
- 智谱开放平台 (CogView-3-Flash, CogView-4)
- AiHubMix (DALL-E 3)
- 硅基流动 (FLUX.1-schnell)
- DMXAPI (DALL-E 3)
- TokenFlux (DALL-E 3)
- New API (DALL-E 3)
- CherryIN (DALL-E 3)
- 独立“插图”页面已经下线,不再作为产品入口
- 联网图片搜索已经迁到 Claw `@素材`
- 本地图片与“我的图片库”已经迁到资料库图片视图
## 使用方式
1. 点击左侧导航栏的"图片生成"图标
2. 点击设置按钮添加提供商
3. 选择提供商、模型和尺寸
4. 输入描述文字,点击发送生成图片
## API 接口
使用 OpenAI 兼容的 `/v1/images/generations` 接口。
1. 在 Claw 工作台触发 AI 生图能力
2. 由 `useImageGen` 统一管理生成、保存与插图动作
3. 本地图片上传与图库浏览统一走资料库
@@ -1,261 +0,0 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupMountedRoots,
flushEffects,
renderIntoDom,
setReactActEnvironment,
waitForCondition,
type MountedRoot,
} from "../test-utils";
import { useImageSearch } from "./useImageSearch";
const { mockSearchPixabayImages, mockSearchWebImages } = vi.hoisted(() => ({
mockSearchPixabayImages: vi.fn(),
mockSearchWebImages: vi.fn(),
}));
vi.mock("@/lib/api/imageSearch", () => ({
searchPixabayImages: mockSearchPixabayImages,
searchWebImages: mockSearchWebImages,
}));
interface HookHarness {
getValue: () => ReturnType<typeof useImageSearch>;
}
const mountedRoots: MountedRoot[] = [];
function mountHook(): HookHarness {
let hookValue: ReturnType<typeof useImageSearch> | null = null;
function TestComponent() {
hookValue = useImageSearch();
return null;
}
renderIntoDom(<TestComponent />, mountedRoots);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
};
}
beforeEach(() => {
setReactActEnvironment();
vi.clearAllMocks();
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.restoreAllMocks();
});
describe("useImageSearch", () => {
it("应正确映射 Pixabay 结果", async () => {
mockSearchPixabayImages.mockResolvedValue({
total: 100,
total_hits: 2,
hits: [
{
id: 1,
preview_url: "https://pixabay.example/preview.jpg",
large_image_url: "https://pixabay.example/large.jpg",
image_width: 1200,
image_height: 800,
tags: "forest,tree",
page_url: "https://pixabay.com/photos/forest",
user: "pixabay-user",
},
],
});
const harness = mountHook();
await act(async () => {
await harness.getValue().search("pixabay", "forest", true);
});
const state = harness.getValue().sourceStates.pixabay;
expect(state.total).toBe(2);
expect(state.results).toHaveLength(1);
expect(state.results[0]).toMatchObject({
id: "1",
previewUrl: "https://pixabay.example/preview.jpg",
largeUrl: "https://pixabay.example/large.jpg",
provider: "pixabay",
});
});
it("应正确映射联网(Pexels)结果", async () => {
mockSearchWebImages.mockResolvedValue({
total: 1,
provider: "pexels",
hits: [
{
id: "pex-1",
thumbnail_url: "https://pexels.example/thumb.jpg",
content_url: "https://pexels.example/original.jpg",
width: 1080,
height: 1920,
name: "city night",
host_page_url: "https://www.pexels.com/photo/city-night",
},
],
});
const harness = mountHook();
await act(async () => {
await harness.getValue().search("web", "city", true);
});
const state = harness.getValue().sourceStates.web;
expect(state.total).toBe(1);
expect(state.results).toHaveLength(1);
expect(state.results[0]).toMatchObject({
id: "pex-1",
previewUrl: "https://pexels.example/thumb.jpg",
largeUrl: "https://pexels.example/original.jpg",
provider: "pexels",
tags: "city night",
});
});
it("应兼容联网(Pexels)camelCase 字段", async () => {
mockSearchWebImages.mockResolvedValue({
total: 2,
provider: "pexels",
hits: [
{
id: "pex-camel-1",
thumbnailUrl: "https://pexels.example/camel-thumb.jpg",
contentUrl: "https://pexels.example/camel-original.jpg",
width: 1200,
height: 800,
name: "camel city",
hostPageUrl: "https://www.pexels.com/photo/camel-city",
},
],
});
const harness = mountHook();
await act(async () => {
await harness.getValue().search("web", "city", true);
});
const state = harness.getValue().sourceStates.web;
expect(state.total).toBe(2);
expect(state.results).toHaveLength(1);
expect(state.results[0]).toMatchObject({
id: "pex-camel-1",
previewUrl: "https://pexels.example/camel-thumb.jpg",
largeUrl: "https://pexels.example/camel-original.jpg",
pageUrl: "https://www.pexels.com/photo/camel-city",
provider: "pexels",
});
});
it("loadMore 应使用下一页请求并追加结果", async () => {
mockSearchPixabayImages.mockImplementation(async (req) => {
const page = req.page;
return {
total: 40,
total_hits: 40,
hits: [
{
id: page,
preview_url: `https://pixabay.example/${page}-preview.jpg`,
large_image_url: `https://pixabay.example/${page}-large.jpg`,
image_width: 1200,
image_height: 800,
tags: `tag-${page}`,
page_url: `https://pixabay.com/photos/${page}`,
user: "pixabay-user",
},
],
};
});
const harness = mountHook();
await act(async () => {
await harness.getValue().search("pixabay", "mountain", true);
});
await act(async () => {
harness.getValue().loadMore("pixabay");
await flushEffects();
});
await waitForCondition(
() => harness.getValue().sourceStates.pixabay.results.length === 2,
50,
"分页加载未完成",
);
const state = harness.getValue().sourceStates.pixabay;
expect(state.page).toBe(2);
expect(state.results.map((item) => item.id)).toEqual(["1", "2"]);
const pageCalls = mockSearchPixabayImages.mock.calls.map(
([req]) => req.page,
);
expect(pageCalls).toEqual([1, 2]);
});
it("应维护来源独立缓存(互不污染)", async () => {
mockSearchPixabayImages.mockResolvedValue({
total: 1,
total_hits: 1,
hits: [
{
id: 100,
preview_url: "https://pixabay.example/p.jpg",
large_image_url: "https://pixabay.example/l.jpg",
image_width: 1000,
image_height: 700,
tags: "pixabay-only",
page_url: "https://pixabay.com/photos/pixabay-only",
user: "pix-user",
},
],
});
mockSearchWebImages.mockResolvedValue({
total: 1,
provider: "pexels",
hits: [
{
id: "w-1",
thumbnail_url: "https://pexels.example/w-thumb.jpg",
content_url: "https://pexels.example/w.jpg",
width: 700,
height: 1000,
name: "web-only",
host_page_url: "https://www.pexels.com/photo/web-only",
},
],
});
const harness = mountHook();
await act(async () => {
await harness.getValue().search("pixabay", "pixabay-query", true);
});
await act(async () => {
await harness.getValue().search("web", "web-query", true);
});
const pixabayState = harness.getValue().sourceStates.pixabay;
const webState = harness.getValue().sourceStates.web;
expect(pixabayState.results).toHaveLength(1);
expect(pixabayState.results[0].provider).toBe("pixabay");
expect(pixabayState.results[0].id).toBe("100");
expect(webState.results).toHaveLength(1);
expect(webState.results[0].provider).toBe("pexels");
expect(webState.results[0].id).toBe("w-1");
});
});
@@ -1,336 +0,0 @@
/**
* @file 图片搜索 Hook
* @description 调用后端 Pixabay / 联网图片搜索命令,支持分源缓存
* @module components/image-gen/hooks/useImageSearch
*/
import { useCallback, useState } from "react";
import {
searchPixabayImages,
searchWebImages,
type AspectRatioFilter,
type PixabaySearchRequest,
type WebImageSearchRequest,
} from "@/lib/api/imageSearch";
export type { AspectRatioFilter } from "@/lib/api/imageSearch";
export type SearchSource = "web" | "pixabay";
export interface SearchImageResult {
id: string;
previewUrl: string;
largeUrl: string;
width: number;
height: number;
tags: string;
pageUrl: string;
user: string;
provider: "pixabay" | "pexels";
}
interface SourceSearchState {
results: SearchImageResult[];
loading: boolean;
total: number;
page: number;
error: string | null;
lastQuery: string;
}
type SourceSearchStateMap = Record<SearchSource, SourceSearchState>;
const DEFAULT_PER_PAGE = 20;
function pickFirstString(...values: Array<string | undefined>): string {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return "";
}
function pickFirstNumber(...values: Array<number | undefined>): number {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
function createInitialSourceState(): SourceSearchState {
return {
results: [],
loading: false,
total: 0,
page: 1,
error: null,
lastQuery: "",
};
}
function mapPixabayOrientation(
aspectRatio: AspectRatioFilter,
): string | undefined {
if (aspectRatio === "landscape") return "horizontal";
if (aspectRatio === "portrait") return "vertical";
return undefined;
}
function mapWebAspect(
aspectRatio: AspectRatioFilter,
): AspectRatioFilter | undefined {
return aspectRatio === "all" ? undefined : aspectRatio;
}
function filterSquareIfNeeded(
results: SearchImageResult[],
aspectRatio: AspectRatioFilter,
): SearchImageResult[] {
if (aspectRatio !== "square") {
return results;
}
return results.filter((img) => {
const ratio = img.width / img.height;
return ratio > 0.9 && ratio < 1.1;
});
}
export function useImageSearch() {
const [query, setQuery] = useState("");
const [aspectRatio, setAspectRatio] = useState<AspectRatioFilter>("all");
const [sourceStates, setSourceStates] = useState<SourceSearchStateMap>({
web: createInitialSourceState(),
pixabay: createInitialSourceState(),
});
const search = useCallback(
async (
source: SearchSource,
newQuery?: string,
resetPage = true,
targetPage?: number,
) => {
const searchQuery = (newQuery ?? query).trim();
if (!searchQuery) {
setSourceStates((prev) => ({
...prev,
[source]: {
...prev[source],
results: [],
total: 0,
page: 1,
error: null,
lastQuery: "",
},
}));
return;
}
const page = targetPage ?? (resetPage ? 1 : sourceStates[source].page);
setSourceStates((prev) => ({
...prev,
[source]: {
...prev[source],
loading: true,
error: null,
page,
lastQuery: searchQuery,
},
}));
try {
if (source === "pixabay") {
const req: PixabaySearchRequest = {
query: searchQuery,
page,
perPage: DEFAULT_PER_PAGE,
orientation: mapPixabayOrientation(aspectRatio),
};
const response = await searchPixabayImages(req);
const mapped = response.hits.map((hit) => ({
id: String(hit.id),
previewUrl: pickFirstString(hit.preview_url, hit.previewUrl),
largeUrl: pickFirstString(hit.large_image_url, hit.largeImageUrl),
width: pickFirstNumber(hit.image_width, hit.imageWidth),
height: pickFirstNumber(hit.image_height, hit.imageHeight),
tags: hit.tags,
pageUrl: pickFirstString(hit.page_url, hit.pageUrl),
user: hit.user,
provider: "pixabay" as const,
}));
const filtered = filterSquareIfNeeded(mapped, aspectRatio);
setSourceStates((prev) => {
const nextResults = resetPage
? filtered
: [...prev[source].results, ...filtered];
return {
...prev,
[source]: {
...prev[source],
results: nextResults,
total:
response.total_hits ?? response.totalHits ?? response.total,
page,
loading: false,
error: null,
lastQuery: searchQuery,
},
};
});
return;
}
const req: WebImageSearchRequest = {
query: searchQuery,
page,
perPage: DEFAULT_PER_PAGE,
aspect: mapWebAspect(aspectRatio),
};
const response = await searchWebImages(req);
const normalizedHits =
response.hits?.length > 0
? response.hits.map((hit) => ({
id: hit.id,
previewUrl: pickFirstString(
hit.thumbnail_url,
hit.thumbnailUrl,
),
largeUrl: pickFirstString(hit.content_url, hit.contentUrl),
width: pickFirstNumber(hit.width),
height: pickFirstNumber(hit.height),
tags: hit.name,
pageUrl: pickFirstString(hit.host_page_url, hit.hostPageUrl),
user: response.provider || "pexels",
provider: "pexels" as const,
}))
: (response.photos || []).map((photo) => {
const contentUrl = pickFirstString(
photo.src.large2x,
photo.src.large,
photo.src.original,
photo.src.landscape,
photo.src.portrait,
photo.src.medium,
photo.src.small,
photo.src.tiny,
);
const previewUrl = pickFirstString(
photo.src.medium,
photo.src.small,
photo.src.tiny,
photo.src.landscape,
photo.src.portrait,
contentUrl,
);
return {
id: String(photo.id),
previewUrl,
largeUrl: contentUrl,
width: pickFirstNumber(photo.width),
height: pickFirstNumber(photo.height),
tags: photo.alt || "Pexels Image",
pageUrl: photo.url,
user: response.provider || "pexels",
provider: "pexels" as const,
};
});
const mapped = normalizedHits.filter(
(hit) =>
hit.previewUrl && hit.largeUrl && hit.width > 0 && hit.height > 0,
);
const filtered = filterSquareIfNeeded(mapped, aspectRatio);
setSourceStates((prev) => {
const nextResults = resetPage
? filtered
: [...prev[source].results, ...filtered];
return {
...prev,
[source]: {
...prev[source],
results: nextResults,
total: response.total || response.totalResults || filtered.length,
page,
loading: false,
error: null,
lastQuery: searchQuery,
},
};
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${source} 搜索失败:`, error);
setSourceStates((prev) => ({
...prev,
[source]: {
...prev[source],
loading: false,
error: message,
...(resetPage
? {
results: [],
total: 0,
page: 1,
}
: {}),
},
}));
}
},
[aspectRatio, query, sourceStates],
);
const loadMore = useCallback(
(source: SearchSource) => {
const current = sourceStates[source];
if (current.loading || current.results.length >= current.total) {
return;
}
const nextPage = current.page + 1;
const nextQuery = current.lastQuery || query.trim();
if (!nextQuery) {
return;
}
void search(source, nextQuery, false, nextPage);
},
[query, search, sourceStates],
);
const clear = useCallback((source?: SearchSource) => {
if (source) {
setSourceStates((prev) => ({
...prev,
[source]: createInitialSourceState(),
}));
return;
}
setSourceStates({
web: createInitialSourceState(),
pixabay: createInitialSourceState(),
});
setQuery("");
}, []);
return {
query,
setQuery,
aspectRatio,
setAspectRatio,
sourceStates,
search,
loadMore,
clear,
};
}
-9
View File
@@ -1,9 +0,0 @@
/**
* @file 图片生成模块导出
* @description AI 图片生成功能组件,复用凭证管理中的 API Key Provider
* @module components/image-gen
*/
export { ImageGenPage } from "./ImageGenPage";
export { useImageGen } from "./useImageGen";
export * from "./types";
File diff suppressed because it is too large Load Diff
@@ -1,313 +0,0 @@
import { act } from "react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupMountedRoots,
flushEffects,
renderIntoDom,
setReactActEnvironment,
waitForCondition,
type MountedRoot,
} from "../test-utils";
const { mockInvoke, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}));
const {
mockEmitCanvasImageInsertRequest,
mockOnCanvasImageInsertAck,
mockGetActiveContentTarget,
} = vi.hoisted(() => ({
mockEmitCanvasImageInsertRequest: vi.fn(),
mockOnCanvasImageInsertAck: vi.fn(),
mockGetActiveContentTarget: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children: ReactNode }) => (
<div data-testid="scroll-area">{children}</div>
),
}));
vi.mock("sonner", () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}));
vi.mock("@/lib/canvasImageInsertBus", () => ({
emitCanvasImageInsertRequest: mockEmitCanvasImageInsertRequest,
onCanvasImageInsertAck: mockOnCanvasImageInsertAck,
}));
vi.mock("@/lib/activeContentTarget", () => ({
getActiveContentTarget: mockGetActiveContentTarget,
}));
import { ImageSearchTab } from "./ImageSearchTab";
const mountedRoots: MountedRoot[] = [];
function renderTab(projectId = "project-1"): HTMLDivElement {
const mounted = renderIntoDom(
<ImageSearchTab projectId={projectId} />,
mountedRoots,
);
return mounted.container;
}
function findTextarea(container: HTMLElement): HTMLTextAreaElement {
const node = container.querySelector("textarea");
if (!node) {
throw new Error("未找到搜索输入框");
}
return node as HTMLTextAreaElement;
}
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
const target = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes(text),
);
if (!target) {
throw new Error(`未找到按钮: ${text}`);
}
return target as HTMLButtonElement;
}
async function setInputValue(input: HTMLTextAreaElement, value: string) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
if (!nativeSetter) {
throw new Error("未找到 textarea value setter");
}
await act(async () => {
nativeSetter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushEffects();
});
}
beforeEach(() => {
setReactActEnvironment();
vi.clearAllMocks();
vi.stubGlobal("open", vi.fn());
mockGetActiveContentTarget.mockReturnValue({
projectId: "project-1",
contentId: "content-1",
canvasType: "document",
});
mockOnCanvasImageInsertAck.mockReturnValue(() => undefined);
mockEmitCanvasImageInsertRequest.mockReturnValue({
requestId: "insert-1",
});
mockInvoke.mockImplementation((command, payload) => {
if (command === "search_pixabay_images") {
const page = payload.req.page;
return Promise.resolve({
total: 40,
total_hits: 40,
hits: [
{
id: page,
preview_url: `https://pixabay.example/${page}-preview.jpg`,
large_image_url: `https://pixabay.example/${page}-large.jpg`,
image_width: 1200,
image_height: 800,
tags: `pixabay-${page}`,
page_url: `https://pixabay.com/photos/${page}`,
user: "pixabay-user",
},
],
});
}
if (command === "search_web_images") {
const page = payload.req.page;
return Promise.resolve({
total: 40,
provider: "pexels",
hits: [
{
id: `w-${page}`,
thumbnail_url: `https://pexels.example/${page}-thumb.jpg`,
content_url: `https://pexels.example/${page}-image.jpg`,
width: 1080,
height: 1920,
name: `pexels-${page}`,
host_page_url: `https://www.pexels.com/photo/${page}`,
},
],
});
}
if (command === "import_material_from_url") {
return Promise.resolve({ id: "mock-material-id" });
}
return Promise.reject(new Error(`unexpected command: ${command}`));
});
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("ImageSearchTab", () => {
it("应支持切换来源并展示对应 attribution", async () => {
const container = renderTab();
await setInputValue(findTextarea(container), "city");
await act(async () => {
findButton(container, "搜索图片").click();
await flushEffects();
});
await waitForCondition(
() => container.textContent?.includes("图片来源: Pexels") ?? false,
50,
"未展示 Pexels 来源",
);
await act(async () => {
findButton(container, "Pixabay图库").click();
});
await act(async () => {
findButton(container, "搜索图片").click();
await flushEffects();
});
await waitForCondition(
() => container.textContent?.includes("图片来源: Pixabay") ?? false,
50,
"未展示 Pixabay 来源",
);
expect(mockInvoke).toHaveBeenCalledWith(
"search_web_images",
expect.objectContaining({
req: expect.objectContaining({
query: "city",
page: 1,
}),
}),
);
});
it("在联网来源保存图片时应使用 pexels 标签", async () => {
const container = renderTab();
await setInputValue(findTextarea(container), "city");
await act(async () => {
findButton(container, "联网搜索").click();
});
await act(async () => {
findButton(container, "搜索图片").click();
await flushEffects();
});
await waitForCondition(
() => container.textContent?.includes("保存") ?? false,
50,
"搜索结果未渲染保存按钮",
);
await act(async () => {
findButton(container, "保存").click();
await flushEffects();
});
expect(mockInvoke).toHaveBeenCalledWith(
"import_material_from_url",
expect.objectContaining({
req: expect.objectContaining({
projectId: "project-1",
type: "image",
tags: ["pexels"],
}),
}),
);
expect(mockToastSuccess).toHaveBeenCalled();
});
it("当前来源加载更多应请求下一页", async () => {
const container = renderTab();
await setInputValue(findTextarea(container), "beach");
await act(async () => {
findButton(container, "联网搜索").click();
await flushEffects();
});
await act(async () => {
findButton(container, "搜索图片").click();
await flushEffects();
});
await waitForCondition(
() => container.textContent?.includes("加载更多") ?? false,
50,
"未出现加载更多按钮",
);
await act(async () => {
findButton(container, "加载更多").click();
await flushEffects();
});
const webSearchPages = mockInvoke.mock.calls
.filter(([command]) => command === "search_web_images")
.map(([, payload]) => payload.req.page);
expect(webSearchPages).toEqual([1, 2]);
});
it("应支持将搜索图片插入当前画布", async () => {
const container = renderTab();
await setInputValue(findTextarea(container), "city");
await act(async () => {
findButton(container, "搜索图片").click();
await flushEffects();
});
await waitForCondition(
() => container.textContent?.includes("插入当前画布") ?? false,
50,
"搜索结果未渲染插入按钮",
);
await act(async () => {
findButton(container, "插入当前画布").click();
await flushEffects();
});
expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
contentId: "content-1",
canvasType: "document",
source: "pexels",
image: expect.objectContaining({
contentUrl: "https://pexels.example/1-image.jpg",
attributionName: "Pexels",
}),
}),
);
expect(mockToastSuccess).toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,432 +0,0 @@
/**
* @file 本地图片 Tab
* @description 从本地文件系统选择图片并保存到图片库
* @module components/image-gen/tabs/LocalImageTab
*/
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { ImagePlus, Loader2, Upload, Trash2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import styled, { keyframes } from "styled-components";
import { uploadMaterial } from "@/lib/api/materials";
export interface LocalImageTabProps {
/** 目标项目 ID */
projectId?: string | null;
}
// ==================== Animations ====================
const fadeIn = keyframes`
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
`;
const float = keyframes`
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
`;
// ==================== Styled Components ====================
const Container = styled.div`
height: 100%;
display: flex;
flex-direction: column;
padding: 10px;
background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%);
`;
const Surface = styled.div`
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
border-radius: 28px;
border: 1px solid hsl(var(--border) / 0.78);
background: hsl(var(--background) / 0.84);
box-shadow:
0 18px 42px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
overflow: hidden;
`;
const Content = styled.div`
flex: 1;
display: flex;
min-height: 0;
align-items: center;
justify-content: center;
padding: 28px;
gap: 18px;
`;
const DropZone = styled.div<{ $dragging?: boolean }>`
width: 100%;
max-width: 760px;
min-height: 360px;
border: 1.5px dashed
${({ $dragging }) =>
$dragging ? "hsl(214 68% 38% / 0.48)" : "hsl(var(--border) / 0.9)"};
border-radius: 28px;
padding: 40px 32px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: ${({ $dragging }) =>
$dragging
? "hsl(211 100% 96%)"
: "linear-gradient(180deg, hsl(var(--background)), hsl(201 42% 98% / 0.9))"};
box-shadow:
0 18px 38px hsl(215 32% 12% / 0.04),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
&:hover {
border-color: hsl(214 68% 38% / 0.34);
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(203 100% 97% / 0.94)
);
transform: translateY(-2px);
box-shadow: 0 20px 44px hsl(215 32% 12% / 0.08);
}
`;
const IconContainer = styled.div`
width: 76px;
height: 76px;
border-radius: 22px;
background: linear-gradient(135deg, hsl(203 100% 97%), hsl(201 52% 94%));
display: flex;
align-items: center;
justify-content: center;
color: hsl(211 58% 38%);
animation: ${float} 3s ease-in-out infinite;
`;
const DropTitle = styled.p`
margin: 0;
font-size: 24px;
font-weight: 700;
color: hsl(var(--foreground));
`;
const DropHint = styled.p`
margin: 0;
max-width: 420px;
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.6;
`;
const DropActionText = styled.div`
display: inline-flex;
align-items: center;
justify-content: center;
height: 38px;
padding: 0 18px;
border-radius: 14px;
border: 1px solid hsl(215 28% 17% / 0.92);
background: linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%));
color: hsl(var(--background));
font-size: 13px;
font-weight: 700;
box-shadow: 0 14px 28px hsl(220 40% 12% / 0.12);
`;
const PreviewContainer = styled.div`
width: 100%;
max-width: 1080px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
animation: ${fadeIn} 0.35s ease;
`;
const PreviewMeta = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
width: 100%;
`;
const PreviewChip = styled.div`
display: inline-flex;
align-items: center;
min-height: 30px;
max-width: min(100%, 680px);
padding: 0 12px;
border-radius: 999px;
border: 1px solid hsl(var(--border) / 0.84);
background: hsl(var(--background) / 0.86);
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const PreviewCard = styled.div`
position: relative;
width: 100%;
border-radius: 24px;
overflow: hidden;
border: 1px solid hsl(var(--border) / 0.82);
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(210 20% 98% / 0.96)
);
box-shadow:
0 20px 46px hsl(215 32% 12% / 0.08),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
padding: 14px;
img {
display: block;
width: 100%;
max-height: min(62vh, 640px);
object-fit: contain;
border-radius: 18px;
background: hsl(210 40% 99%);
}
`;
const Actions = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
`;
const ActionButton = styled.button<{ $primary?: boolean; $danger?: boolean }>`
height: 42px;
padding: 0 18px;
border: ${({ $primary }) =>
$primary ? "none" : "1px solid hsl(var(--border))"};
border-radius: 14px;
background: ${({ $primary, $danger }) =>
$primary
? "linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%))"
: $danger
? "transparent"
: "hsl(var(--background) / 0.9)"};
color: ${({ $primary, $danger }) =>
$primary
? "hsl(var(--background))"
: $danger
? "hsl(var(--destructive))"
: "hsl(var(--foreground))"};
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: ${({ $primary }) =>
$primary
? "0 16px 32px hsl(220 40% 12% / 0.16)"
: "0 10px 20px hsl(215 32% 12% / 0.08)"};
}
&:disabled {
opacity: 0.5;
cursor: wait;
}
`;
const NoProjectState = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 14px;
color: hsl(var(--muted-foreground));
text-align: center;
padding: 48px;
`;
const NoProjectIcon = styled.div`
width: 72px;
height: 72px;
border-radius: 20px;
background: linear-gradient(
135deg,
hsl(203 100% 97%),
hsl(201 52% 94% / 0.86)
);
display: flex;
align-items: center;
justify-content: center;
color: hsl(211 58% 38%);
`;
// ==================== Component ====================
export function LocalImageTab({ projectId }: LocalImageTabProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await open({
filters: [
{
name: "图片",
extensions: ["jpg", "jpeg", "png", "webp", "gif", "bmp"],
},
],
multiple: false,
});
if (filePath && typeof filePath === "string") {
setSelectedPath(filePath);
setPreviewUrl(`asset://localhost/${filePath}`);
}
} catch (error) {
console.error("选择文件失败:", error);
}
};
const handleSaveToGallery = async () => {
if (!projectId) {
toast.error("请先选择项目");
return;
}
if (!selectedPath) {
toast.error("请先选择图片");
return;
}
setSaving(true);
try {
await uploadMaterial({
projectId,
name: selectedPath.split("/").pop() || "本地图片",
type: "image",
filePath: selectedPath,
tags: ["local"],
});
toast.success("已保存到图片库");
// 清空选择
setPreviewUrl(null);
setSelectedPath(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(`保存失败: ${message}`);
} finally {
setSaving(false);
}
};
const handleClear = () => {
setPreviewUrl(null);
setSelectedPath(null);
};
if (!projectId) {
return (
<Container>
<Surface>
<NoProjectState>
<NoProjectIcon>
<Upload size={30} />
</NoProjectIcon>
<div
style={{
fontSize: 18,
fontWeight: 700,
color: "hsl(var(--foreground))",
}}
>
请先选择项目
</div>
<div style={{ fontSize: 13 }}>
在右上角选择一个项目后即可上传本地图片
</div>
</NoProjectState>
</Surface>
</Container>
);
}
return (
<Container>
<Surface>
<ScrollArea className="h-full">
<Content>
{previewUrl ? (
<PreviewContainer>
<PreviewMeta>
<PreviewChip title={selectedPath || undefined}>
{selectedPath?.split("/").pop() || "本地图片"}
</PreviewChip>
</PreviewMeta>
<PreviewCard>
<img src={previewUrl} alt="预览" />
</PreviewCard>
<Actions>
<ActionButton onClick={handleClear} disabled={saving}>
<Trash2 size={16} />
重新选择
</ActionButton>
<ActionButton
$primary
onClick={handleSaveToGallery}
disabled={saving}
>
{saving ? (
<>
<Loader2 size={16} className="animate-spin" />
保存中...
</>
) : (
<>
<Upload size={16} />
保存到图片库
</>
)}
</ActionButton>
</Actions>
</PreviewContainer>
) : (
<DropZone onClick={handleSelectFile}>
<IconContainer>
<ImagePlus size={34} />
</IconContainer>
<DropTitle>选择本地图片</DropTitle>
<DropHint>
支持 JPG、PNG、WebP、GIF、BMP
格式,点击后直接选文件并进入预览。
</DropHint>
<DropActionText>选择文件</DropActionText>
</DropZone>
)}
</Content>
</ScrollArea>
</Surface>
</Container>
);
}
export default LocalImageTab;
@@ -1,496 +0,0 @@
/**
* @file 我的图片库 Tab
* @description 显示用户已保存的图片素材库
* @module components/image-gen/tabs/MyGalleryTab
*/
import { convertLocalFileSrc } from "@/lib/api/fileSystem";
import type { GalleryMaterial } from "@/types/gallery-material";
import { toast } from "sonner";
import { Images } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import styled from "styled-components";
import { getActiveContentTarget } from "@/lib/activeContentTarget";
import {
emitCanvasImageInsertRequest,
onCanvasImageInsertAck,
type CanvasImageInsertAck,
type CanvasImageTargetType,
} from "@/lib/canvasImageInsertBus";
import {
addCanvasImageInsertHistory,
getCanvasImageInsertHistory,
type CanvasImageInsertHistoryEntry,
} from "@/lib/canvasImageInsertHistory";
import { ImageGallery } from "@/lib/workspace/workbenchUi";
import type { Page, PageParams } from "@/types/page";
export interface MyGalleryTabProps {
/** 项目 ID */
projectId?: string | null;
/** 页面跳转 */
onNavigate?: (page: Page, params?: PageParams) => void;
}
const Container = styled.div`
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%);
`;
const ActionBar = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid hsl(var(--border) / 0.78);
border-radius: 24px;
background: hsl(var(--background) / 0.84);
box-shadow:
0 16px 38px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
`;
const ActionRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
`;
const ActionCopy = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
`;
const ActionEyebrow = styled.span`
display: inline-flex;
align-items: center;
width: fit-content;
border-radius: 999px;
border: 1px solid hsl(203 82% 88%);
background: hsl(200 100% 97%);
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: hsl(211 58% 38%);
`;
const ActionTitle = styled.div`
font-size: 22px;
line-height: 1.1;
font-weight: 700;
color: hsl(var(--foreground));
`;
const ActionHint = styled.div`
font-size: 13px;
line-height: 1.6;
color: hsl(var(--muted-foreground));
`;
const ActionButtons = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const InsertButton = styled.button`
height: 40px;
border: 1px solid hsl(215 28% 17% / 0.92);
background: linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%));
color: hsl(var(--background));
border-radius: 14px;
font-size: 13px;
font-weight: 700;
padding: 0 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 16px 32px hsl(220 40% 12% / 0.14);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const RelocateButton = styled(InsertButton)`
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 12px;
font-weight: 600;
padding: 0 14px;
box-shadow: none;
`;
const RecentList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const RecentCard = styled.div`
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 8px 10px;
border-radius: 16px;
border: 1px solid hsl(var(--border) / 0.82);
background: hsl(var(--background) / 0.84);
`;
const RecentMeta = styled.div`
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
`;
const RecentTitle = styled.div`
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const RecentHint = styled.div`
font-size: 11px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const GalleryPanel = styled.div`
flex: 1;
min-height: 0;
padding: 14px;
border-radius: 28px;
border: 1px solid hsl(var(--border) / 0.78);
background: hsl(var(--background) / 0.84);
box-shadow:
0 18px 42px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
overflow: hidden;
`;
const GalleryContent = styled(ImageGallery)`
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
> div:last-child {
flex: 1;
min-height: 0;
}
`;
const EmptyState = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
color: hsl(var(--muted-foreground));
text-align: center;
padding: 48px 24px;
border-radius: 28px;
border: 1px solid hsl(var(--border) / 0.78);
background: hsl(var(--background) / 0.84);
box-shadow:
0 18px 42px hsl(215 32% 12% / 0.05),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
`;
const EmptyIcon = styled.div`
width: 72px;
height: 72px;
border-radius: 20px;
background: linear-gradient(
135deg,
hsl(203 100% 97%),
hsl(201 52% 94% / 0.86)
);
display: flex;
align-items: center;
justify-content: center;
color: hsl(211 58% 38%);
`;
const EmptyTitle = styled.p`
margin: 0;
font-size: 18px;
font-weight: 700;
color: hsl(var(--foreground));
`;
const EmptyHint = styled.p`
margin: 0;
font-size: 13px;
line-height: 1.6;
`;
function normalizeCanvasType(
value: string | null | undefined,
): CanvasImageTargetType {
if (value === "document" || value === "video") {
return value;
}
if (value === "script") {
return "video";
}
return "document";
}
function mapCanvasTypeToTheme(canvasType: CanvasImageTargetType): string {
switch (canvasType) {
case "video":
return "video";
case "document":
case "auto":
default:
return "document";
}
}
function getVisibleInsertHistory(
projectId?: string | null,
): CanvasImageInsertHistoryEntry[] {
const history = getCanvasImageInsertHistory();
const filtered = projectId
? history.filter((entry) => entry.projectId === projectId)
: history;
return filtered.slice(0, 3);
}
export function MyGalleryTab({ projectId, onNavigate }: MyGalleryTabProps) {
const [selectedMaterial, setSelectedMaterial] =
useState<GalleryMaterial | null>(null);
const [recentInsertHistory, setRecentInsertHistory] = useState<
CanvasImageInsertHistoryEntry[]
>(() => getVisibleInsertHistory(projectId));
const pendingInsertRequestMetaRef = useRef<
Map<
string,
{
projectId: string;
contentId: string | null;
canvasType: CanvasImageTargetType;
theme: string;
imageTitle?: string;
}
>
>(new Map());
useEffect(() => {
setRecentInsertHistory(getVisibleInsertHistory(projectId));
}, [projectId]);
useEffect(() => {
const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => {
const pendingMeta = pendingInsertRequestMetaRef.current.get(
ack.requestId,
);
if (!pendingMeta) {
return;
}
pendingInsertRequestMetaRef.current.delete(ack.requestId);
if (!ack.success) {
toast.error("插图失败,请返回创作区重试");
return;
}
const nextHistory = addCanvasImageInsertHistory({
requestId: ack.requestId,
projectId: pendingMeta.projectId,
contentId: pendingMeta.contentId,
canvasType: pendingMeta.canvasType,
theme: pendingMeta.theme,
imageTitle: pendingMeta.imageTitle,
locationLabel: ack.locationLabel,
});
setRecentInsertHistory(
(projectId
? nextHistory.filter((entry) => entry.projectId === projectId)
: nextHistory
).slice(0, 3),
);
});
return unsubscribe;
}, [projectId]);
const handleInsertFromGallery = (material: GalleryMaterial) => {
if (!projectId) {
toast.error("请先选择项目");
return;
}
const imageUrl = material.filePath
? convertLocalFileSrc(material.filePath)
: material.metadata?.thumbnail || "";
if (!imageUrl) {
toast.error("该素材缺少可用图片地址,无法插入");
return;
}
const target = getActiveContentTarget();
const sameProjectTarget = target?.projectId === projectId ? target : null;
const targetContentId = sameProjectTarget?.contentId ?? null;
const targetCanvasType = normalizeCanvasType(sameProjectTarget?.canvasType);
const targetTheme = mapCanvasTypeToTheme(targetCanvasType);
const request = emitCanvasImageInsertRequest({
projectId,
contentId: targetContentId,
canvasType: targetCanvasType,
anchorHint:
targetCanvasType === "video" ? "video_start_frame" : "section_end",
source: "gallery",
image: {
id: material.id,
previewUrl: material.metadata?.thumbnail || imageUrl,
contentUrl: imageUrl,
title: material.name,
width: material.metadata?.width,
height: material.metadata?.height,
attributionName: "项目素材库",
provider: "gallery",
},
});
pendingInsertRequestMetaRef.current.set(request.requestId, {
projectId,
contentId: targetContentId,
canvasType: targetCanvasType,
theme: targetTheme,
imageTitle: material.name,
});
onNavigate?.("agent", {
projectId,
contentId: targetContentId ?? undefined,
theme: targetTheme,
lockTheme: false,
});
toast.success("已发送到当前画布,正在自动定位");
};
const handleRelocate = (entry: CanvasImageInsertHistoryEntry) => {
onNavigate?.("agent", {
projectId: entry.projectId,
contentId: entry.contentId ?? undefined,
theme: entry.theme,
lockTheme: false,
});
toast.success("正在定位到插图位置");
};
if (!projectId) {
return (
<Container>
<EmptyState>
<EmptyIcon>
<Images size={28} />
</EmptyIcon>
<EmptyTitle>请先选择项目</EmptyTitle>
<EmptyHint>在右上角选择一个项目后即可查看图片库</EmptyHint>
</EmptyState>
</Container>
);
}
return (
<Container>
<ActionBar>
<ActionRow>
<ActionCopy>
<ActionEyebrow>GALLERY</ActionEyebrow>
<ActionTitle>我的图片库</ActionTitle>
<ActionHint>
{selectedMaterial
? `已选中:${selectedMaterial.name}`
: "双击图片可直接插入当前画布,或先单击选中后再执行插入。"}
</ActionHint>
</ActionCopy>
<ActionButtons>
{recentInsertHistory[0] && (
<RelocateButton
type="button"
onClick={() => handleRelocate(recentInsertHistory[0])}
>
再次定位
</RelocateButton>
)}
<InsertButton
type="button"
disabled={!selectedMaterial}
onClick={() => {
if (!selectedMaterial) {
return;
}
handleInsertFromGallery(selectedMaterial);
}}
>
插入选中图片到当前画布
</InsertButton>
</ActionButtons>
</ActionRow>
{recentInsertHistory.length > 0 && (
<RecentList>
{recentInsertHistory.map((entry) => (
<RecentCard key={entry.requestId}>
<RecentMeta>
<RecentTitle>
{entry.imageTitle?.trim() || "图片"}
</RecentTitle>
<RecentHint>{entry.locationLabel || "已插入"}</RecentHint>
</RecentMeta>
<RelocateButton
type="button"
onClick={() => handleRelocate(entry)}
>
定位
</RelocateButton>
</RecentCard>
))}
</RecentList>
)}
</ActionBar>
<GalleryPanel>
<GalleryContent
projectId={projectId}
className="h-full"
maxHeight="calc(100vh - 250px)"
selectedIds={selectedMaterial ? [selectedMaterial.id] : []}
onSelect={(materials) => {
setSelectedMaterial(materials[0] || null);
}}
onDoubleClick={handleInsertFromGallery}
/>
</GalleryPanel>
</Container>
);
}
export default MyGalleryTab;
+134 -2
View File
@@ -311,7 +311,7 @@ describe("ModelSelector", () => {
expect(pageText).toContain("无多模态");
});
it("anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => {
it("未知 anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => {
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
@@ -321,7 +321,7 @@ describe("ModelSelector", () => {
fallbackRegistryId: "anthropic",
type: "anthropic-compatible",
providerId: "custom-anthropic-compatible",
apiHost: "https://open.bigmodel.cn/api/anthropic",
apiHost: "https://api.example.com/anthropic",
},
],
loading: false,
@@ -367,6 +367,138 @@ describe("ModelSelector", () => {
expect(pageText).toContain("cache_control");
});
it.each([
{
label: "GLM Anthropic",
apiHost: "https://open.bigmodel.cn/api/anthropic",
model: "glm-5.1",
},
{
label: "Kimi Anthropic",
apiHost: "https://api.moonshot.cn/anthropic",
model: "kimi-k2.5",
},
{
label: "MiniMax Anthropic",
apiHost: "https://api.minimaxi.com/anthropic",
model: "minimax-m1",
},
{
label: "MiMo Anthropic",
apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic",
model: "mimo-v2-flash",
},
])("$label 不应在选择器中误报显式缓存提示", ({ label, apiHost, model }) => {
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-anthropic-compatible",
label,
registryId: "custom-anthropic-compatible",
fallbackRegistryId: "anthropic",
type: "anthropic-compatible",
providerId: "custom-anthropic-compatible",
apiHost,
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
modelIds: [model],
models: [
{
id: model,
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
},
],
loading: false,
error: null,
});
const { container } = renderModelSelector({
providerType: "custom-anthropic-compatible",
model,
});
const trigger = container.querySelector(
'button[role="combobox"]',
) as HTMLButtonElement | null;
if (!trigger) {
throw new Error("未找到模型选择触发器");
}
act(() => {
trigger.click();
});
const pageText = document.body.textContent || "";
expect(pageText).not.toContain("显式缓存");
expect(pageText).not.toContain("未声明自动 Prompt Cache");
});
it("显式声明 automatic 的 anthropic-compatible Provider 不应在选择器中误报显式缓存提示", () => {
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-anthropic-compatible",
label: "GLM Anthropic Automatic",
registryId: "custom-anthropic-compatible",
fallbackRegistryId: "anthropic",
type: "anthropic-compatible",
providerId: "custom-anthropic-compatible",
apiHost: "https://open.bigmodel.cn/api/anthropic",
promptCacheMode: "automatic",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
modelIds: ["glm-5.1"],
models: [
{
id: "glm-5.1",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
},
],
loading: false,
error: null,
});
const { container } = renderModelSelector({
providerType: "custom-anthropic-compatible",
model: "glm-5.1",
});
const trigger = container.querySelector(
'button[role="combobox"]',
) as HTMLButtonElement | null;
if (!trigger) {
throw new Error("未找到模型选择触发器");
}
act(() => {
trigger.click();
});
const pageText = document.body.textContent || "";
expect(pageText).not.toContain("显式缓存");
expect(pageText).not.toContain("未声明自动 Prompt Cache");
});
it("无 Provider 引导关闭后应隐藏,并在重新挂载时保持关闭状态", () => {
mockUseConfiguredProviders.mockReturnValue({
providers: [],
+10 -1
View File
@@ -207,8 +207,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
resolvePromptCacheSupportNotice({
providerType,
configuredProviderType: selectedProvider?.type,
configuredApiHost: selectedProvider?.apiHost,
configuredPromptCacheMode: selectedProvider?.promptCacheMode,
}),
[providerType, selectedProvider?.type],
[
providerType,
selectedProvider?.apiHost,
selectedProvider?.promptCacheMode,
selectedProvider?.type,
],
);
const incompatibleModelCount = useMemo(
@@ -485,6 +492,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const isSelected = selectedProvider?.key === provider.key;
const providerPromptCacheMode = getProviderPromptCacheMode(
provider.type,
provider.promptCacheMode,
provider.apiHost,
);
return (
+17
View File
@@ -60,3 +60,20 @@ ProviderPoolPage 支持四种分类:
2. **API Key** - 使用左右分栏布局(ApiKeyProviderSection)
3. **Connect** - 中转商列表,支持浏览和一键获取 API Key
4. **语音服务** - 语音 Provider 管理入口
## Prompt Cache 认知边界
Provider Pool 页面当前已把 Prompt Cache 能力前置到 Provider UI,而不是等到对话发出后才暴露:
- `anthropic`:按官方 Anthropic 能力链展示
- `anthropic-compatible`:先按已知官方 Anthropic 兼容端点识别自动缓存,未知端点才回退为“仅显式缓存”
- 其它 Provider:默认不展示 Prompt Cache 标签或 notice
当前页面上的主要提示落点:
- 左侧 Provider 列表:`显式缓存` badge
- 右侧 Provider 详情头部:`显式缓存` badge
- 新增自定义 Provider:amber Prompt Cache 提示
- 编辑 Provider 配置:amber Prompt Cache 提示
这条语义的当前事实源位于 `src/lib/model/providerPromptCacheSupport.ts`。如果以后要新增新的缓存能力提示,优先扩这份 helper,不要在单个组件里自行判断。
@@ -17,7 +17,10 @@ import {
hasRequiredFields,
} from "./AddCustomProviderModal";
import { PROVIDER_TYPE_VALUES } from "./ProviderConfigForm.utils";
import type { ProviderType } from "@/lib/types/provider";
import type {
ProviderDeclaredPromptCacheMode,
ProviderType,
} from "@/lib/types/provider";
// ============================================================================
// 测试数据生成器
@@ -32,6 +35,8 @@ const VALID_PROVIDER_TYPES: ProviderType[] = PROVIDER_TYPE_VALUES;
const providerTypeArbitrary: fc.Arbitrary<ProviderType> = fc.constantFrom(
...VALID_PROVIDER_TYPES,
);
const promptCacheModeArbitrary: fc.Arbitrary<ProviderDeclaredPromptCacheMode> =
fc.constantFrom("explicit_only", "automatic");
/**
* 生成有效的 URL
@@ -85,6 +90,7 @@ const invalidUrlArbitrary: fc.Arbitrary<string> = fc.oneof(
const validFormStateArbitrary = fc.record({
name: nonEmptyStringArbitrary,
type: providerTypeArbitrary,
promptCacheMode: promptCacheModeArbitrary,
apiHost: validUrlArbitrary,
apiKey: nonEmptyStringArbitrary,
apiVersion: fc.string({ maxLength: 30 }),
@@ -99,6 +105,7 @@ const validFormStateArbitrary = fc.record({
const formStateMissingNameArbitrary = fc.record({
name: whitespaceStringArbitrary,
type: providerTypeArbitrary,
promptCacheMode: promptCacheModeArbitrary,
apiHost: validUrlArbitrary,
apiKey: nonEmptyStringArbitrary,
apiVersion: fc.string({ maxLength: 30 }),
@@ -113,6 +120,7 @@ const formStateMissingNameArbitrary = fc.record({
const formStateMissingApiHostArbitrary = fc.record({
name: nonEmptyStringArbitrary,
type: providerTypeArbitrary,
promptCacheMode: promptCacheModeArbitrary,
apiHost: whitespaceStringArbitrary,
apiKey: nonEmptyStringArbitrary,
apiVersion: fc.string({ maxLength: 30 }),
@@ -127,6 +135,7 @@ const formStateMissingApiHostArbitrary = fc.record({
const formStateMissingApiKeyArbitrary = fc.record({
name: nonEmptyStringArbitrary,
type: providerTypeArbitrary,
promptCacheMode: promptCacheModeArbitrary,
apiHost: validUrlArbitrary,
apiKey: whitespaceStringArbitrary,
apiVersion: fc.string({ maxLength: 30 }),
@@ -141,6 +150,7 @@ const formStateMissingApiKeyArbitrary = fc.record({
const formStateInvalidApiHostArbitrary = fc.record({
name: nonEmptyStringArbitrary,
type: providerTypeArbitrary,
promptCacheMode: promptCacheModeArbitrary,
apiHost: invalidUrlArbitrary,
apiKey: nonEmptyStringArbitrary,
apiVersion: fc.string({ maxLength: 30 }),
@@ -297,6 +307,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => {
const formState = {
name: "a".repeat(51),
type: "openai" as ProviderType,
promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode,
apiHost: "https://api.example.com",
apiKey: "sk-test-key",
apiVersion: "",
@@ -314,6 +325,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => {
const formState = {
name: "a".repeat(50),
type: "openai" as ProviderType,
promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode,
apiHost: "https://api.example.com",
apiKey: "sk-test-key",
apiVersion: "",
@@ -332,6 +344,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => {
const formState = {
name: "",
type: "openai" as ProviderType,
promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode,
apiHost: "",
apiKey: "",
apiVersion: "",
@@ -351,6 +364,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => {
const formState = {
name: "",
type: "openai" as ProviderType,
promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode,
apiHost: "",
apiKey: "",
apiVersion: "",
@@ -30,7 +30,10 @@ import {
X,
} from "lucide-react";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import type { ProviderType } from "@/lib/types/provider";
import type {
ProviderDeclaredPromptCacheMode,
ProviderType,
} from "@/lib/types/provider";
import { resolvePromptCacheSupportNotice } from "@/lib/model/providerPromptCacheSupport";
import {
apiKeyProviderApi,
@@ -41,8 +44,11 @@ import {
getProviderTypeLabel,
getSpecialProtocolHint,
isSupportedProviderType,
isPromptCacheModeConfigurableProviderType,
PROMPT_CACHE_MODE_OPTIONS,
PROVIDER_TYPE_FIELDS,
PROVIDER_TYPE_OPTIONS,
resolvePromptCacheModeRequestValue,
} from "./ProviderConfigForm.utils";
// ============================================================================
@@ -288,6 +294,7 @@ export interface AddCustomProviderModalProps {
interface FormState {
name: string;
type: ProviderType;
promptCacheMode: ProviderDeclaredPromptCacheMode;
apiHost: string;
apiKey: string;
apiVersion: string;
@@ -311,6 +318,7 @@ interface FormErrors {
const INITIAL_FORM_STATE: FormState = {
name: "",
type: "openai",
promptCacheMode: "explicit_only",
apiHost: "",
apiKey: "",
apiVersion: "",
@@ -584,6 +592,11 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
name: formState.name.trim(),
type: formState.type,
api_host: formState.apiHost.trim(),
prompt_cache_mode: resolvePromptCacheModeRequestValue(
formState.type,
formState.promptCacheMode,
formState.apiHost,
),
};
// 添加额外字段
@@ -636,12 +649,19 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
() => getSpecialProtocolHint(formState.type),
[formState.type],
);
const showPromptCacheModeField = useMemo(
() =>
isPromptCacheModeConfigurableProviderType(formState.type, formState.apiHost),
[formState.apiHost, formState.type],
);
const promptCacheSupportNotice = useMemo(
() =>
resolvePromptCacheSupportNotice({
configuredProviderType: formState.type,
configuredApiHost: formState.apiHost,
configuredPromptCacheMode: formState.promptCacheMode,
}),
[formState.type],
[formState.apiHost, formState.promptCacheMode, formState.type],
);
const visibleProviders = useMemo(
@@ -1077,6 +1097,63 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
</div>
</div>
{showPromptCacheModeField ? (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="space-y-1.5">
<Label
htmlFor="prompt-cache-mode"
className="text-sm font-medium"
>
Prompt Cache 模式
</Label>
<Select
value={formState.promptCacheMode}
onValueChange={(value) =>
updateField(
"promptCacheMode",
value as ProviderDeclaredPromptCacheMode,
)
}
disabled={isSubmitting}
>
<SelectTrigger
id="prompt-cache-mode"
className="border-slate-200 bg-white"
data-testid="prompt-cache-mode-select"
>
<span>
{PROMPT_CACHE_MODE_OPTIONS.find(
(option) =>
option.value === formState.promptCacheMode,
)?.label ?? formState.promptCacheMode}
</span>
</SelectTrigger>
<SelectContent>
{PROMPT_CACHE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-[20px] border border-slate-200/80 bg-white p-4">
<p className="text-sm font-semibold text-slate-900">
选择建议
</p>
<p className="mt-2 text-xs leading-6 text-slate-500">
{
PROMPT_CACHE_MODE_OPTIONS.find(
(option) =>
option.value === formState.promptCacheMode,
)?.description
}
</p>
</div>
</div>
) : null}
{specialProtocolHint ? (
<div
className="mt-4 rounded-[20px] border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900"
@@ -1,4 +1,4 @@
import { act } from "react";
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -30,22 +30,30 @@ interface MountedRoot {
const mountedRoots: MountedRoot[] = [];
function renderModal() {
return renderModalWithProps();
}
function renderModalWithProps(
props: Partial<ComponentProps<typeof AddCustomProviderModal>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: ComponentProps<typeof AddCustomProviderModal> = {
isOpen: true,
onClose: vi.fn(),
onAdd: vi.fn().mockResolvedValue({ id: "provider-001" }),
onAddApiKey: vi.fn().mockResolvedValue(undefined),
...props,
};
act(() => {
root.render(
<AddCustomProviderModal
isOpen
onClose={vi.fn()}
onAdd={vi.fn().mockResolvedValue({ id: "provider-001" })}
onAddApiKey={vi.fn().mockResolvedValue(undefined)}
/>,
);
root.render(<AddCustomProviderModal {...mergedProps} />);
});
mountedRoots.push({ container, root });
return { container, props: mergedProps };
}
async function settleModal() {
@@ -75,6 +83,16 @@ function findDivByText(text: string): HTMLDivElement {
return target;
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -237,7 +255,7 @@ describe("AddCustomProviderModal", () => {
});
it("切换到 anthropic-compatible 时应展示显式 Prompt Cache 提示", async () => {
renderModal();
renderModalWithProps();
await settleModal();
@@ -256,5 +274,79 @@ describe("AddCustomProviderModal", () => {
);
expect(notice.textContent ?? "").toContain("未声明支持自动 Prompt Cache");
expect(notice.textContent ?? "").toContain("显式 cache_control");
expect(document.body.textContent ?? "").toContain(
"已知官方 Anthropic 兼容端点",
);
expect(
document.querySelector('[data-testid="prompt-cache-mode-select"]'),
).not.toBeNull();
});
it("anthropic-compatible 切换到 automatic 后应隐藏 Prompt Cache 提示", async () => {
renderModal();
await settleModal();
await act(async () => {
findByTestId<HTMLElement>("provider-type-select").click();
});
await act(async () => {
findDivByText("Anthropic 兼容").click();
});
await act(async () => {
findByTestId<HTMLElement>("prompt-cache-mode-select").click();
});
await act(async () => {
findDivByText("已声明自动缓存").click();
});
expect(
document.querySelector('[data-testid="provider-prompt-cache-notice"]'),
).toBeNull();
});
it("已知官方 Anthropic 兼容 Host 提交时应自动带上 automatic prompt_cache_mode", async () => {
const onAdd = vi.fn().mockResolvedValue({ id: "provider-automatic" });
renderModalWithProps({ onAdd });
await settleModal();
await act(async () => {
findByTestId<HTMLElement>("provider-type-select").click();
});
await act(async () => {
findDivByText("Anthropic 兼容").click();
});
await act(async () => {
setInputValue(findByTestId<HTMLInputElement>("provider-name-input"), "GLM");
setInputValue(
findByTestId<HTMLInputElement>("api-host-input"),
"https://open.bigmodel.cn/api/anthropic",
);
setInputValue(findByTestId<HTMLInputElement>("api-key-input"), "test-key");
});
expect(
document.querySelector('[data-testid="prompt-cache-mode-select"]'),
).toBeNull();
await act(async () => {
findByTestId<HTMLButtonElement>("submit-button").click();
await Promise.resolve();
});
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
name: "GLM",
type: "anthropic-compatible",
api_host: "https://open.bigmodel.cn/api/anthropic",
prompt_cache_mode: "automatic",
}),
);
});
});
@@ -14,6 +14,7 @@ import * as fc from "fast-check";
import {
getLatestSelectableModel,
getFieldsForProviderType,
getSpecialProtocolHint,
parseCustomModelsValue,
providerTypeRequiresField,
PROVIDER_TYPE_FIELDS,
@@ -224,7 +225,7 @@ describe("Property 7: Provider 类型处理正确性", () => {
expect(fields).toEqual(["apiHost"]);
});
test("new-api 类型只需要 apiHost", () => {
test("new-api 类型只需要 apiHost", () => {
const fields = getFieldsForProviderType("new-api");
expect(fields).toEqual(["apiHost"]);
});
@@ -233,6 +234,12 @@ describe("Property 7: Provider 类型处理正确性", () => {
const fields = getFieldsForProviderType("gateway");
expect(fields).toEqual(["apiHost"]);
});
test("anthropic-compatible 协议提示不应再暗示自动 Prompt Cache", () => {
expect(getSpecialProtocolHint("anthropic-compatible")).toContain(
"已知官方 Anthropic 兼容端点",
);
});
});
// 边界情况测试
@@ -31,7 +31,10 @@ import type {
ProviderWithKeysDisplay,
UpdateProviderRequest,
} from "@/lib/api/apiKeyProvider";
import type { ProviderType } from "@/lib/types/provider";
import type {
ProviderDeclaredPromptCacheMode,
ProviderType,
} from "@/lib/types/provider";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
@@ -43,9 +46,13 @@ import {
getSpecialProtocolHint,
getLatestSelectableModel,
getProviderTypeLabel,
isPromptCacheModeConfigurableProviderType,
parseCustomModelsValue,
PROMPT_CACHE_MODE_OPTIONS,
PROVIDER_TYPE_FIELDS,
PROVIDER_TYPE_OPTIONS,
resolvePromptCacheModeFormValue,
resolvePromptCacheModeRequestValue,
serializeCustomModels,
} from "./ProviderConfigForm.utils";
import { Plus, Save, Star, X } from "lucide-react";
@@ -105,6 +112,7 @@ export interface ProviderConfigFormRef {
interface FormState {
providerName: string;
providerType: ProviderType;
promptCacheMode: ProviderDeclaredPromptCacheMode;
apiHost: string;
apiVersion: string;
project: string;
@@ -160,6 +168,11 @@ export const ProviderConfigForm = forwardRef<
const [formState, setFormState] = useState<FormState>({
providerName: provider.name || "",
providerType: (provider.type as ProviderType) || "openai",
promptCacheMode: resolvePromptCacheModeFormValue(
provider.prompt_cache_mode,
provider.type as ProviderType,
provider.api_host,
),
apiHost: provider.api_host || "",
apiVersion: provider.api_version || "",
project: provider.project || "",
@@ -197,10 +210,12 @@ export const ProviderConfigForm = forwardRef<
type: formState.providerType,
providerId: provider.id,
apiHost: formState.apiHost,
promptCacheMode: formState.promptCacheMode,
customModels: selectedModels,
}),
[
formState.apiHost,
formState.promptCacheMode,
formState.providerType,
provider.id,
provider.name,
@@ -317,6 +332,11 @@ export const ProviderConfigForm = forwardRef<
setFormState({
providerName: provider.name || "",
providerType: (provider.type as ProviderType) || "openai",
promptCacheMode: resolvePromptCacheModeFormValue(
provider.prompt_cache_mode,
provider.type as ProviderType,
provider.api_host,
),
apiHost: provider.api_host || "",
apiVersion: provider.api_version || "",
project: provider.project || "",
@@ -330,6 +350,7 @@ export const ProviderConfigForm = forwardRef<
provider.id,
provider.name,
provider.type,
provider.prompt_cache_mode,
provider.api_host,
provider.api_version,
provider.project,
@@ -360,6 +381,11 @@ export const ProviderConfigForm = forwardRef<
project: state.project,
location: state.location,
region: state.region,
prompt_cache_mode: resolvePromptCacheModeRequestValue(
state.providerType,
state.promptCacheMode,
state.apiHost,
),
custom_models: customModels,
};
@@ -511,13 +537,19 @@ export const ProviderConfigForm = forwardRef<
}, [onRecommendedLatestModelChange, recommendedLatestModel]);
const extraFields = PROVIDER_TYPE_FIELDS[formState.providerType] || [];
const showPromptCacheModeField = isPromptCacheModeConfigurableProviderType(
formState.providerType,
formState.apiHost,
);
const specialProtocolHint = getSpecialProtocolHint(formState.providerType);
const promptCacheSupportNotice = useMemo(
() =>
resolvePromptCacheSupportNotice({
configuredProviderType: formState.providerType,
configuredApiHost: formState.apiHost,
configuredPromptCacheMode: formState.promptCacheMode,
}),
[formState.providerType],
[formState.apiHost, formState.promptCacheMode, formState.providerType],
);
const defaultModelId =
visibleSelectedModels[0] ?? recommendedLatestModel?.id ?? null;
@@ -718,6 +750,53 @@ export const ProviderConfigForm = forwardRef<
</div>
);
})}
{showPromptCacheModeField ? (
<div className="space-y-1.5 lg:col-span-2">
<Label
htmlFor="prompt-cache-mode"
className="text-sm font-medium"
>
Prompt Cache 模式
</Label>
<Select
value={formState.promptCacheMode}
onValueChange={(value) =>
handleFieldChange(
"promptCacheMode",
value as ProviderDeclaredPromptCacheMode,
)
}
disabled={loading || isSaving}
>
<SelectTrigger
id="prompt-cache-mode"
className="border-slate-200 bg-white"
data-testid="prompt-cache-mode-select"
>
<span>
{PROMPT_CACHE_MODE_OPTIONS.find(
(option) => option.value === formState.promptCacheMode,
)?.label ?? formState.promptCacheMode}
</span>
</SelectTrigger>
<SelectContent>
{PROMPT_CACHE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-5 text-slate-500">
{
PROMPT_CACHE_MODE_OPTIONS.find(
(option) => option.value === formState.promptCacheMode,
)?.description
}
</p>
</div>
) : null}
</div>
{promptCacheSupportNotice ? (
@@ -34,6 +34,7 @@ function createProvider(
sort_order: 1,
api_key_count: 1,
custom_models: ["gpt-4.1"],
prompt_cache_mode: null,
created_at: new Date("2026-03-15T00:00:00.000Z").toISOString(),
updated_at: new Date("2026-03-15T00:00:00.000Z").toISOString(),
api_keys: [],
@@ -185,4 +186,91 @@ describe("ProviderConfigForm", () => {
expect(notice?.textContent ?? "").toContain("未声明支持自动 Prompt Cache");
expect(notice?.textContent ?? "").toContain("显式 cache_control");
});
it("显式声明 automatic 的 anthropic-compatible Provider 不应展示 Prompt Cache 提示", () => {
const provider = createProvider({
id: "custom-anthropic-compatible-automatic",
name: "Anthropic 兼容自动缓存渠道",
is_system: false,
type: "anthropic-compatible",
prompt_cache_mode: "automatic",
api_host: "https://example.com/anthropic",
});
const { container } = renderForm(provider);
expect(
container.querySelector('[data-testid="provider-prompt-cache-notice"]'),
).toBeNull();
});
it("anthropic-compatible Provider 切换到 automatic 后应带上 prompt_cache_mode 保存", async () => {
const provider = createProvider({
id: "custom-anthropic-compatible",
name: "Anthropic 兼容渠道",
is_system: false,
type: "anthropic-compatible",
api_host: "https://example.com/anthropic",
});
const { container, onUpdate } = renderForm(provider);
const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="prompt-cache-mode-select"]',
);
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.click();
});
await act(async () => {
findDivByText("已声明自动缓存").click();
});
await act(async () => {
vi.advanceTimersByTime(600);
await Promise.resolve();
});
expect(onUpdate).toHaveBeenCalledWith(
"custom-anthropic-compatible",
expect.objectContaining({
type: "anthropic-compatible",
prompt_cache_mode: "automatic",
}),
);
expect(
container.querySelector('[data-testid="provider-prompt-cache-notice"]'),
).toBeNull();
});
it("anthropic-compatible Provider 的协议说明弹层应明确未默认声明自动 Prompt Cache", async () => {
const provider = createProvider({
id: "custom-anthropic-compatible",
name: "Anthropic 兼容渠道",
is_system: false,
type: "anthropic-compatible",
api_host: "https://example.com/anthropic",
});
const { container } = renderForm(provider);
const infoButton = container.querySelector<HTMLButtonElement>(
'[data-testid="provider-config-info-button"]',
);
expect(infoButton).not.toBeNull();
await act(async () => {
infoButton?.click();
});
const specialHint = document.querySelector<HTMLElement>(
'[data-testid="protocol-special-hint"]',
);
expect(specialHint).not.toBeNull();
expect(specialHint?.textContent ?? "").toContain(
"已知官方 Anthropic 兼容端点",
);
});
});
@@ -4,7 +4,11 @@
* @module components/provider-pool/api-key/ProviderConfigForm.utils
*/
import type { ProviderType } from "@/lib/types/provider";
import type {
ProviderDeclaredPromptCacheMode,
ProviderType,
} from "@/lib/types/provider";
import { getProviderPromptCacheMode } from "@/lib/model/providerPromptCacheSupport";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
/** 支持的 Provider 类型列表 */
@@ -52,11 +56,30 @@ const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial<Record<ProviderType, string>> = {
anthropic:
"Anthropic 继续使用原生协议,不会被收敛到普通 OpenAI 兼容请求格式。",
"anthropic-compatible":
"Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,仍按 Anthropic 语义处理请求与模型映射。",
"Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,会沿用 Anthropic 请求结构与模型映射。Lime 会自动识别已知官方 Anthropic 兼容端点(如 GLM / Kimi / MiniMax / MiMo);未知端点默认回退为仅显式缓存。",
gemini:
"Gemini 保留原生协议能力与专属模型映射,不按普通 OpenAI 兼容 Provider 处理。",
};
export const PROMPT_CACHE_MODE_OPTIONS: Array<{
value: ProviderDeclaredPromptCacheMode;
label: string;
description: string;
}> = [
{
value: "explicit_only",
label: "仅显式缓存",
description:
"默认选项。只有显式写入 cache_control 时才请求上游复用前缀。",
},
{
value: "automatic",
label: "已声明自动缓存",
description:
"仅在上游明确声明兼容 Anthropic Automatic Prompt Cache 时使用。",
},
];
export function isSupportedProviderType(
providerType: string,
): providerType is ProviderType {
@@ -78,6 +101,39 @@ export function getSpecialProtocolHint(type: ProviderType): string | null {
return SPECIAL_PROVIDER_PROTOCOL_HINTS[type] ?? null;
}
export function isPromptCacheModeConfigurableProviderType(
type: ProviderType,
apiHost?: string | null,
): boolean {
return (
type === "anthropic-compatible" &&
getProviderPromptCacheMode(type, null, apiHost) === "explicit_only"
);
}
export function resolvePromptCacheModeFormValue(
promptCacheMode?: ProviderDeclaredPromptCacheMode | null,
providerType?: ProviderType,
apiHost?: string | null,
): ProviderDeclaredPromptCacheMode {
return getProviderPromptCacheMode(providerType, promptCacheMode, apiHost) ===
"automatic"
? "automatic"
: "explicit_only";
}
export function resolvePromptCacheModeRequestValue(
type: ProviderType,
promptCacheMode: ProviderDeclaredPromptCacheMode,
apiHost?: string | null,
): ProviderDeclaredPromptCacheMode | null {
if (type !== "anthropic-compatible") {
return null;
}
return resolvePromptCacheModeFormValue(promptCacheMode, type, apiHost);
}
export function dedupeModelIds(modelIds: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
@@ -57,7 +57,11 @@ export const ProviderListItem: React.FC<ProviderListItemProps> = ({
const apiKeyCount = provider.api_keys?.length ?? 0;
const isEnabled = provider.enabled;
const showExplicitPromptCacheBadge =
getProviderPromptCacheMode(provider.type) === "explicit_only";
getProviderPromptCacheMode(
provider.type,
provider.prompt_cache_mode,
provider.api_host,
) === "explicit_only";
return (
<div
@@ -95,6 +95,57 @@ describe("ProviderListItem", () => {
expect(badge?.textContent).toContain("显式缓存");
});
it("显式声明 automatic 的 anthropic-compatible Provider 不应展示显式缓存标签", () => {
const container = renderItem(
createProvider({
id: "anthropic-proxy-automatic",
name: "Anthropic Proxy Automatic",
type: "anthropic-compatible",
prompt_cache_mode: "automatic",
}),
);
expect(
container.querySelector('[data-testid="provider-prompt-cache-badge"]'),
).toBeNull();
});
it.each([
{
id: "glm-anthropic",
name: "GLM Anthropic",
apiHost: "https://open.bigmodel.cn/api/anthropic",
},
{
id: "kimi-anthropic",
name: "Kimi Anthropic",
apiHost: "https://api.moonshot.cn/anthropic",
},
{
id: "minimax-anthropic",
name: "MiniMax Anthropic",
apiHost: "https://api.minimaxi.com/anthropic",
},
{
id: "mimo-anthropic",
name: "MiMo Anthropic",
apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic",
},
])("$name 官方 Host 不应展示显式缓存标签", ({ id, name, apiHost }) => {
const container = renderItem(
createProvider({
id,
name,
type: "anthropic-compatible",
api_host: apiHost,
}),
);
expect(
container.querySelector('[data-testid="provider-prompt-cache-badge"]'),
).toBeNull();
});
it("非 anthropic-compatible Provider 不应展示显式缓存标签", () => {
const container = renderItem(createProvider());
@@ -237,7 +237,11 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
? "读取真实模型目录前,不展示旧模型,避免把历史缓存误认为当前可用模型。"
: null;
const showExplicitPromptCacheBadge =
getProviderPromptCacheMode(provider.type) === "explicit_only";
getProviderPromptCacheMode(
provider.type,
provider.prompt_cache_mode,
provider.api_host,
) === "explicit_only";
// 处理启用/禁用切换
const handleToggleEnabled = async (enabled: boolean) => {
@@ -166,4 +166,55 @@ describe("ProviderSetting", () => {
expect(badge).not.toBeNull();
expect(badge?.textContent ?? "").toContain("显式缓存");
});
it("显式声明 automatic 的 anthropic-compatible Provider 不应在头部展示显式缓存标签", () => {
const container = renderSetting(
createProvider({
id: "anthropic-proxy-automatic",
name: "Anthropic Proxy Automatic",
type: "anthropic-compatible",
prompt_cache_mode: "automatic",
}),
);
expect(
container.querySelector('[data-testid="provider-prompt-cache-badge"]'),
).toBeNull();
});
it.each([
{
id: "glm-anthropic",
name: "GLM Anthropic",
apiHost: "https://open.bigmodel.cn/api/anthropic",
},
{
id: "kimi-anthropic",
name: "Kimi Anthropic",
apiHost: "https://api.moonshot.cn/anthropic",
},
{
id: "minimax-anthropic",
name: "MiniMax Anthropic",
apiHost: "https://api.minimaxi.com/anthropic",
},
{
id: "mimo-anthropic",
name: "MiMo Anthropic",
apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic",
},
])("$name 官方 Host 不应在头部展示显式缓存标签", ({ id, name, apiHost }) => {
const container = renderSetting(
createProvider({
id,
name,
type: "anthropic-compatible",
api_host: apiHost,
}),
);
expect(
container.querySelector('[data-testid="provider-prompt-cache-badge"]'),
).toBeNull();
});
});

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