mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.10.0
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
|
||||
- `settings-v2/` - 设置页面组件(当前主实现)
|
||||
- `skills/` - 技能管理组件
|
||||
- `terminal/` - 内置终端组件(使用 Tauri Commands)
|
||||
- `tools/` - 工具页面组件
|
||||
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-6
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user