release: v1.4.0

This commit is contained in:
coso
2026-04-06 22:50:50 +08:00
parent acc573818d
commit 7c06f84e16
467 changed files with 23371 additions and 15151 deletions
+311 -147
View File
@@ -34,6 +34,7 @@ import { type TaskFile } from "./components/TaskFiles";
import { useWorkflow } from "@/lib/workspace/workbenchWorkflow";
import {
createInitialCanvasState,
createInitialVideoState,
type CanvasStateUnion,
} from "@/lib/workspace/workbenchCanvas";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
@@ -48,12 +49,12 @@ import {
} from "@/lib/artifact/store";
import type { Artifact } from "@/lib/artifact/types";
import { useAtomValue, useSetAtom } from "jotai";
import { generateThemeWorkbenchPrompt } from "@/lib/workspace/workbenchPrompt";
import { generateGeneralWorkbenchPrompt } from "@/lib/workspace/workbenchPrompt";
import { generateProjectMemoryPrompt } from "@/lib/workspace/workbenchPrompt";
import {
getProject,
getContent,
getThemeWorkbenchDocumentState,
getGeneralWorkbenchDocumentState,
ensureWorkspaceReady,
type Project,
} from "@/lib/api/project";
@@ -80,6 +81,7 @@ import {
import type {
Message,
MessagePreviewTarget,
SiteSavedContentTarget,
WriteArtifactContext,
} from "./types";
@@ -119,21 +121,18 @@ import { useServiceSkills } from "./service-skills/useServiceSkills";
import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection";
import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview";
import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation";
import { useThemeWorkbenchEntryPrompt } from "./hooks/useThemeWorkbenchEntryPrompt";
import { useThemeWorkbenchEntryPromptActions } from "./hooks/useThemeWorkbenchEntryPromptActions";
import { useThemeWorkbenchSendBoundary } from "./hooks/useThemeWorkbenchSendBoundary";
import type { BrowserTaskPreflight } from "./hooks/handleSendTypes";
import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntryPrompt";
import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions";
import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary";
import { mergeThreadItems } from "./utils/threadTimelineView";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
import {
asRecord,
isResumableBrowserTaskReason,
mergeMessageArtifactsIntoStore,
readFirstString,
} from "./workspace/browserAssistArtifact";
import { ServiceSkillExecutionCard } from "./workspace/ServiceSkillExecutionCard";
import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime";
import { useWorkspaceBrowserPreflightRuntime } from "./workspace/useWorkspaceBrowserPreflightRuntime";
import { useWorkspaceA2UISubmitActions } from "./workspace/useWorkspaceA2UISubmitActions";
import { useWorkspaceContextHarnessRuntime } from "./workspace/useWorkspaceContextHarnessRuntime";
import { useWorkspaceHarnessInventoryRuntime } from "./workspace/useWorkspaceHarnessInventoryRuntime";
@@ -152,26 +151,31 @@ import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasL
import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync";
import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync";
import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions";
import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime";
import {
useWorkspaceImageWorkbenchActionRuntime,
type SubmitImageWorkbenchAgentCommandParams,
} from "./workspace/useWorkspaceImageWorkbenchActionRuntime";
import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime";
import { buildImageSkillLaunchRequestMetadata } from "./workspace/imageSkillLaunch";
import { useWorkspaceImageTaskPreviewRuntime } from "./workspace/useWorkspaceImageTaskPreviewRuntime";
import { useWorkspaceVideoTaskPreviewRuntime } from "./workspace/useWorkspaceVideoTaskPreviewRuntime";
import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime";
import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore";
import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime";
import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions";
import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime";
import { useWorkspaceTeamWorkbenchAutoOpenRuntime } from "./workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime";
import { useWorkspaceThemeWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceThemeWorkbenchScaffoldRuntime";
import { useWorkspaceThemeWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime";
import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime";
import { useWorkspaceGeneralWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime";
import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch";
import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime";
import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime";
import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./workspace/useWorkspaceThemeWorkbenchSidebarRuntime";
import { useWorkspaceThemeWorkbenchRuntime } from "./workspace/useWorkspaceThemeWorkbenchRuntime";
import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspaceThemeWorkbenchShellRuntime";
import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime";
import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime";
import { useWorkspaceGeneralWorkbenchShellRuntime } from "./workspace/useWorkspaceGeneralWorkbenchShellRuntime";
import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions";
import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
@@ -183,13 +187,13 @@ import {
} from "./workspace/imageWorkbenchHelpers";
import {
SOCIAL_ARTICLE_SKILL_KEY,
THEME_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendThemeWorkbenchDocumentState,
isCorruptedThemeWorkbenchDocumentContent,
GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendGeneralWorkbenchDocumentState,
isCorruptedGeneralWorkbenchDocumentContent,
isSyncContentEmpty,
readPersistedThemeWorkbenchDocument,
readPersistedGeneralWorkbenchDocument,
serializeCanvasStateForSync,
} from "./workspace/themeWorkbenchHelpers";
} from "./workspace/generalWorkbenchHelpers";
import {
normalizeInitialTheme,
projectTypeToTheme,
@@ -222,6 +226,87 @@ function resolveDefaultSelectedArtifact(
return null;
}
function resolveVideoCanvasStatusFromPreview(
target: Extract<MessagePreviewTarget, { kind: "task" }>,
): "idle" | "generating" | "success" | "error" {
const preview = target.preview;
if (preview.kind !== "video_generate") {
return "idle";
}
if (
(preview.status === "complete" || preview.status === "partial") &&
preview.videoUrl
) {
return "success";
}
if (preview.status === "failed" || preview.status === "cancelled") {
return "error";
}
return "generating";
}
function normalizeTaskPreviewArtifactPath(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/\\/g, "/");
}
function resolveTaskPreviewArtifact(
message: Message,
target: Extract<MessagePreviewTarget, { kind: "task" }>,
): Artifact | null {
const normalizedArtifactPath = normalizeTaskPreviewArtifactPath(
target.preview.kind === "video_generate"
? null
: target.preview.artifactPath || null,
);
const messageArtifacts = message.artifacts || [];
if (normalizedArtifactPath) {
const matchedArtifact = messageArtifacts.find(
(artifact) =>
normalizeTaskPreviewArtifactPath(resolveArtifactProtocolFilePath(artifact)) ===
normalizedArtifactPath,
);
if (matchedArtifact) {
return matchedArtifact;
}
}
return messageArtifacts.length > 0
? (messageArtifacts[messageArtifacts.length - 1] ?? null)
: null;
}
function normalizeVideoAspectRatio(
value?: string,
): "adaptive" | "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9" {
switch (value) {
case "16:9":
case "9:16":
case "1:1":
case "4:3":
case "3:4":
case "21:9":
return value;
default:
return "adaptive";
}
}
function normalizeVideoResolution(
value?: string,
): "480p" | "720p" | "1080p" {
switch (value) {
case "480p":
case "1080p":
return value;
case "720p":
default:
return "720p";
}
}
export type {
AgentChatWorkspaceProps,
WorkflowProgressSnapshot,
@@ -574,7 +659,7 @@ export function AgentChatWorkspace({
}, [activeTheme, serviceSkillsError]);
const combinedSkillsLoading = skillsLoading || serviceSkillsLoading;
// Workbench Store(用于主题工作台右侧面板状态同步)
// Workbench Store(用于工作区右侧技能面板状态同步)
const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey);
const clearThemeSkillsRailState = useWorkbenchStore(
(state) => state.clearThemeSkillsRailState,
@@ -641,8 +726,6 @@ export function AgentChatWorkspace({
>("desktop");
const [canvasWorkbenchLayoutMode, setCanvasWorkbenchLayoutMode] =
useState<CanvasWorkbenchLayoutMode>("split");
const [browserTaskPreflight, setBrowserTaskPreflight] =
useState<BrowserTaskPreflight | null>(null);
const [focusedArtifactBlockId, setFocusedArtifactBlockId] = useState<
string | null
>(null);
@@ -654,13 +737,6 @@ export function AgentChatWorkspace({
const [timelineFocusRequestKey, setTimelineFocusRequestKey] = useState(0);
const autoCollapsedTopicSidebarRef = useRef(false);
useEffect(() => {
if (activeTheme === "general") {
return;
}
setBrowserTaskPreflight(null);
}, [activeTheme]);
// 跳转到技能主页面
const handleNavigateToSkillSettings = useCallback(() => {
_onNavigate?.("skills");
@@ -809,7 +885,7 @@ export function AgentChatWorkspace({
: theme
) as ThemeType;
const rawBody = content.body || "";
const sanitizedBody = isCorruptedThemeWorkbenchDocumentContent(rawBody)
const sanitizedBody = isCorruptedGeneralWorkbenchDocumentContent(rawBody)
? ""
: rawBody;
@@ -826,11 +902,11 @@ export function AgentChatWorkspace({
createInitialDocumentState(sanitizedBody);
if (initialState.type === "document") {
const backendDocumentState = await getThemeWorkbenchDocumentState(
const backendDocumentState = await getGeneralWorkbenchDocumentState(
content.id,
).catch((error) => {
console.warn(
"[AgentChatPage] 读取主题工作台版本状态失败,降级为 metadata 解析:",
"[AgentChatPage] 读取工作区文稿版本状态失败,降级为 metadata 解析:",
error,
);
logAgentDebug(
@@ -851,7 +927,7 @@ export function AgentChatWorkspace({
hasBackendDocumentState: Boolean(backendDocumentState),
});
const backendApplied = backendDocumentState
? applyBackendThemeWorkbenchDocumentState(
? applyBackendGeneralWorkbenchDocumentState(
initialState,
backendDocumentState,
sanitizedBody,
@@ -862,7 +938,7 @@ export function AgentChatWorkspace({
initialState = backendApplied.state;
setDocumentVersionStatusMap(backendApplied.statusMap);
} else {
const persisted = readPersistedThemeWorkbenchDocument(
const persisted = readPersistedGeneralWorkbenchDocument(
content.metadata,
);
if (persisted) {
@@ -1043,7 +1119,7 @@ export function AgentChatWorkspace({
},
});
} else if (isSpecializedThemeMode) {
prompt = generateThemeWorkbenchPrompt(mappedTheme, creationMode);
prompt = generateGeneralWorkbenchPrompt(mappedTheme, creationMode);
}
// 注入项目 Memory
@@ -1103,8 +1179,10 @@ export function AgentChatWorkspace({
submittedActionsInFlight = [],
triggerAIGuide,
topics = [],
isAutoRestoringSession = false,
sessionId,
createFreshSession,
ensureSession = async () => null,
switchTopic: originalSwitchTopic,
deleteTopic,
renameTopic,
@@ -1319,26 +1397,47 @@ export function AgentChatWorkspace({
},
[imageWorkbenchSessionKey],
);
const appendLocalDispatchMessages = useCallback(
(nextMessages: Message[]) => {
setChatMessages((previous) => {
const next = [...previous];
const updateImageWorkbenchStateForSession = useCallback(
(
sessionKey: string,
updater: (
current: SessionImageWorkbenchState,
) => SessionImageWorkbenchState,
options?: {
fallbackState?: SessionImageWorkbenchState;
removeSessionKeys?: string[];
},
) => {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return;
}
for (const message of nextMessages) {
const existingIndex = next.findIndex(
(candidate) => candidate.id === message.id,
);
if (existingIndex === -1) {
next.push(message);
continue;
setImageWorkbenchBySessionId((previous) => {
const current =
previous[normalizedSessionKey] ||
options?.fallbackState ||
createInitialSessionImageWorkbenchState();
const nextState = {
...previous,
[normalizedSessionKey]: updater(current),
};
options?.removeSessionKeys?.forEach((candidateKey) => {
const normalizedCandidateKey = candidateKey.trim();
if (
!normalizedCandidateKey ||
normalizedCandidateKey === normalizedSessionKey
) {
return;
}
next[existingIndex] = message;
}
delete nextState[normalizedCandidateKey];
});
return next;
return nextState;
});
},
[setChatMessages],
[],
);
const teamSessionRuntime = useWorkspaceTeamSessionRuntime({
sessionId,
@@ -1468,8 +1567,6 @@ export function AgentChatWorkspace({
browserAssistLaunching,
browserAssistSessionState,
siteSkillExecutionState,
isBrowserAssistReady,
isBrowserAssistCanvasVisible,
currentBrowserAssistScopeKey,
ensureBrowserAssistCanvas,
suppressBrowserAssistCanvasAutoOpen,
@@ -1776,9 +1873,9 @@ export function AgentChatWorkspace({
harnessAttentionLevel,
navbarHarnessPanelVisible,
} = contextHarnessRuntime;
const themeWorkbenchScaffoldRuntime =
useWorkspaceThemeWorkbenchScaffoldRuntime({
isThemeWorkbench,
const generalWorkbenchScaffoldRuntime =
useWorkspaceGeneralWorkbenchScaffoldRuntime({
isGeneralWorkbench: isThemeWorkbench,
mappedTheme,
sessionId,
projectId,
@@ -1790,12 +1887,12 @@ export function AgentChatWorkspace({
setLayoutMode,
});
const {
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
setTopicStatus,
} = themeWorkbenchScaffoldRuntime;
} = generalWorkbenchScaffoldRuntime;
useWorkspaceThemeWorkbenchDocumentPersistenceRuntime({
useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime({
isThemeWorkbench,
contentId,
canvasState,
@@ -1824,14 +1921,14 @@ export function AgentChatWorkspace({
themeWorkbenchActiveQueueItem,
themeWorkbenchBackendRunState,
themeWorkbenchRunState,
} = useWorkspaceThemeWorkbenchRuntime({
} = useWorkspaceGeneralWorkbenchRuntime({
isThemeWorkbench,
sessionId,
isSending,
pendingActionRequest,
});
const themeWorkbenchSidebarRuntime = useWorkspaceThemeWorkbenchSidebarRuntime(
const generalWorkbenchSidebarRuntime = useWorkspaceGeneralWorkbenchSidebarRuntime(
{
isThemeWorkbench,
sessionId,
@@ -1839,7 +1936,7 @@ export function AgentChatWorkspace({
isSending,
themeWorkbenchBackendRunState,
contextActivityLogs: contextWorkspace.activityLogs,
historyPageSize: THEME_WORKBENCH_HISTORY_PAGE_SIZE,
historyPageSize: GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
},
);
@@ -1857,7 +1954,7 @@ export function AgentChatWorkspace({
task: effectiveChatToolPreferences.task,
subagent: effectiveChatToolPreferences.subagent,
},
sessionMode: isThemeWorkbench ? "theme_workbench" : "default",
sessionMode: isThemeWorkbench ? "general_workbench" : "default",
gateKey: isThemeWorkbench ? currentGate.key : undefined,
runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined,
contentId: contentId || undefined,
@@ -1912,7 +2009,7 @@ export function AgentChatWorkspace({
harnessPendingCount,
});
useWorkspaceThemeWorkbenchVersionStatusRuntime({
useWorkspaceGeneralWorkbenchVersionStatusRuntime({
isThemeWorkbench,
themeWorkbenchRunState,
canvasState,
@@ -1977,25 +2074,24 @@ export function AgentChatWorkspace({
} = useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
browserTaskPreflight,
messagesCount: messages.length,
isSending,
queuedTurnCount: queuedTurns.length,
consumedInitialPromptKey: consumedInitialPromptRef.current,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
});
const {
themeWorkbenchEntryPrompt,
themeWorkbenchEntryCheckPending,
clearThemeWorkbenchEntryPrompt,
dismissThemeWorkbenchEntryPrompt,
} = useThemeWorkbenchEntryPrompt({
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId: contentId ?? undefined,
sessionId: sessionId ?? undefined,
isThemeWorkbench,
autoRunInitialPromptOnMount,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
messagesCount: messages.length,
initialDispatchKey,
initialUserPrompt,
@@ -2020,18 +2116,11 @@ export function AgentChatWorkspace({
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, []);
const prepareBrowserTaskPreflight = useCallback(
(preflight: BrowserTaskPreflight) => {
setBrowserTaskPreflight(preflight);
},
[],
);
const {
resolveSendBoundary,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
} = useThemeWorkbenchSendBoundary({
} = useGeneralWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
@@ -2039,11 +2128,9 @@ export function AgentChatWorkspace({
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
isBrowserAssistReady,
onConsumeInitialPrompt: consumeInitialPrompt,
onResetConsumedInitialPrompt: resetConsumedInitialPrompt,
onClearEntryPrompt: clearThemeWorkbenchEntryPrompt,
onPrepareBrowserTaskPreflight: prepareBrowserTaskPreflight,
onClearEntryPrompt: clearGeneralWorkbenchEntryPrompt,
});
const { resetRestoredSessionState } = useWorkspaceSessionRestore({
sessionId,
@@ -2085,7 +2172,6 @@ export function AgentChatWorkspace({
setGeneralCanvasState,
setTaskFiles,
setSelectedFileId,
setBrowserTaskPreflight,
setMentionedCharacters,
setProject,
setProjectMemory,
@@ -2129,11 +2215,16 @@ export function AgentChatWorkspace({
setCanvasState,
});
const submitImageWorkbenchAgentCommandRef =
useRef<
((
params: SubmitImageWorkbenchAgentCommandParams,
) => Promise<boolean>) | null
>(null);
const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({
appendLocalDispatchMessages,
canvasState,
cancelImageTask: cancelMediaTaskArtifact,
contentId,
createFreshSession,
createImageGenerationTask: createImageGenerationTaskArtifact,
getImageTask: getMediaTaskArtifact,
currentImageWorkbenchState,
@@ -2144,10 +2235,12 @@ export function AgentChatWorkspace({
projectId,
projectRootPath: project?.rootPath || null,
saveImageWorkbenchImagesToResource,
submitImageWorkbenchAgentCommand: async (params) =>
(await submitImageWorkbenchAgentCommandRef.current?.(params)) ?? false,
setCanvasState,
setInput,
setLayoutMode,
setMentionedCharacters,
updateImageWorkbenchStateForSession,
updateCurrentImageWorkbenchState,
});
const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } =
@@ -2160,7 +2253,6 @@ export function AgentChatWorkspace({
contentId,
input,
chatToolPreferences: effectiveChatToolPreferences,
serviceSkills: activeTheme === "general" ? serviceSkills : [],
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -2174,6 +2266,7 @@ export function AgentChatWorkspace({
handleRecommendationClick,
handleSendRef,
webSearchPreferenceRef,
isPreparingSend,
submissionPreview,
} = useWorkspaceSendActions({
input,
@@ -2187,7 +2280,7 @@ export function AgentChatWorkspace({
mappedTheme,
isThemeWorkbench,
contextWorkspace: {
enabled: contextWorkspace.enabled,
enabled: contextWorkspace.generalWorkbenchEnabled,
activeContextPrompt: contextWorkspace.activeContextPrompt,
prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt,
},
@@ -2209,7 +2302,6 @@ export function AgentChatWorkspace({
messagesCount: messages.length,
sendMessage,
resolveSendBoundary,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
prepareRuntimeTeamBeforeSend,
@@ -2217,17 +2309,36 @@ export function AgentChatWorkspace({
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill:
workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch:
workspaceServiceSkillEntryActions.handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
ensureSessionForCommandMetadata: ensureSession,
resolveImageWorkbenchSkillRequest,
});
const submitImageWorkbenchAgentCommand = useCallback(
async (params: SubmitImageWorkbenchAgentCommandParams) =>
await handleSendRef.current(
params.images,
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
params.rawText,
undefined,
undefined,
{
displayContent: params.displayContent,
requestMetadata: buildImageSkillLaunchRequestMetadata(
undefined,
params.requestContext,
),
},
),
[effectiveChatToolPreferences.thinking, handleSendRef, webSearchPreferenceRef],
);
submitImageWorkbenchAgentCommandRef.current =
submitImageWorkbenchAgentCommand;
const {
handleContinueThemeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
} = useThemeWorkbenchEntryPromptActions({
themeWorkbenchEntryPrompt,
handleContinueGeneralWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPromptActions({
generalWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt: async (promptToSend) => {
@@ -2238,7 +2349,7 @@ export function AgentChatWorkspace({
promptToSend,
);
},
dismissThemeWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt: (dispatchKey) => {
consumedInitialPromptRef.current = dispatchKey;
onInitialUserPromptConsumed?.();
@@ -2248,18 +2359,6 @@ export function AgentChatWorkspace({
toast.info("请先补充要继续执行的内容");
},
});
const {
browserAssistEntryLabel,
browserAssistAttentionLevel,
handlePermissionResponseWithBrowserPreflight,
} = useWorkspaceBrowserPreflightRuntime({
browserTaskPreflight,
setBrowserTaskPreflight,
browserAssistLaunching,
isBrowserAssistReady,
ensureBrowserAssistCanvas,
handlePermissionResponse,
});
const {
handleDocumentThinkingEnabledChange,
handleDocumentAutoContinueRun,
@@ -2286,7 +2385,7 @@ export function AgentChatWorkspace({
onRunImageWorkbenchCommand: handleImageWorkbenchCommand,
});
const { handleInputbarA2UISubmit } = useWorkspaceA2UISubmitActions({
handlePermissionResponseWithBrowserPreflight,
handlePermissionResponse,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
resolvePendingA2UISubmit,
@@ -2302,7 +2401,7 @@ export function AgentChatWorkspace({
[handleInputbarA2UISubmit],
);
// 监听主题工作台技能触发
// 监听工作区技能触发
useEffect(() => {
if (!pendingSkillKey || !isThemeWorkbench) {
return;
@@ -2318,7 +2417,6 @@ export function AgentChatWorkspace({
}, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]);
const { displayMessages } = useWorkspaceDisplayMessagesRuntime({
browserTaskPreflight,
bootstrapDispatchPreviewMessages,
isSending,
messages,
@@ -2346,17 +2444,15 @@ export function AgentChatWorkspace({
(agentEntry === "new-task" &&
(hasDisplayMessages ||
isThemeWorkbench ||
(!shouldUseCompactThemeWorkbench && isBootstrapDispatchPending) ||
(!shouldUseCompactGeneralWorkbench && isBootstrapDispatchPending) ||
isSending ||
queuedTurns.length > 0 ||
Boolean(browserTaskPreflight)));
queuedTurns.length > 0));
const shouldRestoreImageTasksFromWorkspace = !(
agentEntry === "new-task" &&
!contentId &&
!hasDisplayMessages &&
!isSending &&
queuedTurns.length === 0 &&
!browserTaskPreflight &&
!isBootstrapDispatchPending
);
@@ -2401,7 +2497,6 @@ export function AgentChatWorkspace({
hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact),
currentCanvasArtifactType: currentCanvasArtifact?.type,
currentImageWorkbenchActive: currentImageWorkbenchState.active,
isBrowserAssistCanvasVisible,
onHasMessagesChange,
dismissActiveTeamWorkbenchAutoOpen,
suppressGeneralCanvasArtifactAutoOpen,
@@ -2438,15 +2533,10 @@ export function AgentChatWorkspace({
}, [activeTheme, liveArtifact, settledLiveArtifact, upsertGeneralArtifact]);
const handleResumeSidebarTask = useCallback(
async (topicId: string, statusReason?: TaskStatusReason) => {
if (topicId === sessionId && isResumableBrowserTaskReason(statusReason)) {
handleOpenBrowserRuntimeForBrowserAssist();
return;
}
async (topicId: string, _statusReason?: TaskStatusReason) => {
await switchTopic(topicId);
},
[handleOpenBrowserRuntimeForBrowserAssist, sessionId, switchTopic],
[switchTopic],
);
const handleWriteFile = useWorkspaceWriteFileAction({
@@ -2576,6 +2666,75 @@ export function AgentChatWorkspace({
},
[handleArtifactClick],
);
const handleOpenMessagePreview = useCallback(
(target: MessagePreviewTarget, message: Message) => {
if (target.kind === "image_workbench") {
updateCurrentImageWorkbenchState((current) => {
if (current.active) {
return current;
}
return {
...current,
active: true,
};
});
setLayoutMode("chat-canvas");
return;
}
if (target.preview.kind === "video_generate") {
const preview = target.preview;
const initialState = createInitialVideoState(preview.prompt);
setCanvasState({
...initialState,
providerId: preview.providerId?.trim() || "",
model: preview.model?.trim() || "",
duration: preview.durationSeconds || initialState.duration,
aspectRatio: normalizeVideoAspectRatio(preview.aspectRatio),
resolution: normalizeVideoResolution(preview.resolution),
status: resolveVideoCanvasStatusFromPreview(target),
videoUrl: preview.videoUrl || undefined,
errorMessage:
preview.status === "failed" || preview.status === "cancelled"
? preview.statusMessage?.trim() || "视频任务未成功完成"
: undefined,
});
setLayoutMode("chat-canvas");
return;
}
const matchedArtifact = resolveTaskPreviewArtifact(message, target);
if (matchedArtifact) {
handleWorkspaceArtifactClick(matchedArtifact);
return;
}
const normalizedArtifactPath = normalizeTaskPreviewArtifactPath(
target.preview.artifactPath || null,
);
if (normalizedArtifactPath) {
const matchedTaskFile = taskFiles.find(
(file) =>
normalizeTaskPreviewArtifactPath(file.name) ===
normalizedArtifactPath,
);
if (matchedTaskFile?.content?.trim()) {
handleWorkspaceFileClick(matchedTaskFile.name, matchedTaskFile.content);
return;
}
}
toast.info("当前任务产物还未同步完成,请稍后再试");
},
[
handleWorkspaceArtifactClick,
handleWorkspaceFileClick,
setCanvasState,
setLayoutMode,
taskFiles,
updateCurrentImageWorkbenchState,
],
);
const handleOpenArtifactFromTimeline = useCallback(
(target: ArtifactTimelineOpenTarget) => {
handleWorkspaceFileClick(target.filePath, target.content);
@@ -2618,10 +2777,11 @@ export function AgentChatWorkspace({
canvasState,
isThemeWorkbench,
mappedTheme,
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
themeWorkbenchEntryCheckPending,
themeWorkbenchEntryPrompt,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt:
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
chatToolPreferences: effectiveChatToolPreferences,
setInput,
handleSend,
@@ -2656,11 +2816,14 @@ export function AgentChatWorkspace({
setChatMessages,
updateCurrentImageWorkbenchState,
});
useWorkspaceVideoTaskPreviewRuntime({
messages,
setChatMessages,
});
const shellChromeRuntime = useWorkspaceShellChromeRuntime({
agentEntry,
browserTaskPreflight,
contextWorkspaceEnabled: contextWorkspace.enabled,
contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled,
hasDisplayMessages,
hideTopBar,
isBootstrapDispatchPending,
@@ -2669,7 +2832,7 @@ export function AgentChatWorkspace({
isThemeWorkbench,
layoutMode,
queuedTurnCount: queuedTurns.length,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard,
topBarChrome,
themeWorkbenchRunState,
@@ -2677,13 +2840,13 @@ export function AgentChatWorkspace({
hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph,
teamDispatchPreviewState,
});
const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({
const generalWorkbenchShellRuntime = useWorkspaceGeneralWorkbenchShellRuntime({
showChatPanel: effectiveShowChatPanel,
showSidebar,
hasPendingA2UIForm,
contextHarnessRuntime,
themeWorkbenchScaffoldRuntime,
themeWorkbenchSidebarRuntime,
generalWorkbenchScaffoldRuntime,
generalWorkbenchSidebarRuntime,
harnessInventoryRuntime,
handleCreateVersionSnapshot,
handleSwitchBranchVersion,
@@ -2743,10 +2906,11 @@ export function AgentChatWorkspace({
input,
setInput,
currentGate,
themeWorkbenchSidebarRuntime,
generalWorkbenchSidebarRuntime,
steps,
themeWorkbenchRunState,
workflowRunState: themeWorkbenchRunState,
handleSend,
isPreparingSend,
isSending,
providerType,
setProviderType,
@@ -2789,9 +2953,9 @@ export function AgentChatWorkspace({
removeQueuedTurn,
latestAssistantMessageId,
sessionIdForDiagnostics: sessionId || null,
themeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
handleContinueThemeWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
handleContinueGeneralWorkbenchEntryPrompt,
generalWorkbenchEnabled: workspaceHarnessEnabled && chatMode === "general",
contextHarnessRuntime,
harnessState,
@@ -2868,7 +3032,7 @@ export function AgentChatWorkspace({
inputbarScene,
canvasScene,
shellChromeRuntime,
themeWorkbenchShellRuntime,
generalWorkbenchShellRuntime,
contextHarnessRuntime,
teamSessionRuntime,
currentImageWorkbenchState,
@@ -2889,7 +3053,7 @@ export function AgentChatWorkspace({
entryBannerVisible,
entryBannerMessage,
serviceSkillExecutionCard,
contextWorkspaceEnabled: contextWorkspace.enabled,
contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled,
input,
setInput,
providerType,
@@ -2929,13 +3093,11 @@ export function AgentChatWorkspace({
handleBackHome,
handleToggleSidebar,
chatMode,
isBrowserAssistCanvasVisible,
browserAssistAttentionLevel,
browserAssistEntryLabel,
showHarnessToggle,
navbarHarnessPanelVisible,
harnessPendingCount,
harnessAttentionLevel,
isAutoRestoringSession,
sessionId,
syncStatus,
pendingA2UIForm,
@@ -2957,6 +3119,7 @@ export function AgentChatWorkspace({
pendingActions,
submittedActionsInFlight,
queuedTurns,
isPreparingSend,
isSending,
stopSending,
resumeThread,
@@ -2970,8 +3133,9 @@ export function AgentChatWorkspace({
handleOpenArtifactFromTimeline,
handleOpenSavedSiteContent,
handleArtifactClick: handleWorkspaceArtifactClick,
handleOpenMessagePreview,
handleOpenSubagentSession,
handlePermissionResponseWithBrowserPreflight,
handlePermissionResponse,
pendingPromotedA2UIActionRequest,
shouldCollapseCodeBlocks,
shouldCollapseCodeBlockInChat,
@@ -586,13 +586,13 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).toContain("Mac mini 最新价格");
});
it("浏览器前置等待时应显示轻量待继续提示", () => {
it("存在待处理请求时应显示轻量待处理提示", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
...createBaseItem("action-1", 1),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://mp.weixin.qq.com" },
tool_name: "write_file",
arguments: { path: "publish.md" },
},
];
@@ -602,13 +602,11 @@ describe("AgentThreadTimeline", () => {
},
actionRequests: [
{
requestId: "req-browser",
requestId: "req-title",
actionType: "ask_user",
status: "pending",
uiKind: "browser_preflight",
browserPrepState: "awaiting_user",
prompt: "请先在浏览器完成登录。",
detail: "浏览器已经打开,请先完成登录、扫码或验证码后继续。",
prompt: "请先确认文章标题。",
questions: [{ question: "这篇文章的最终标题是什么?" }],
},
],
});
@@ -616,8 +614,8 @@ describe("AgentThreadTimeline", () => {
expect(
container.querySelector('[data-testid="agent-thread-inline-status"]')
?.textContent,
).toContain("待继续");
expect(container.textContent).toContain("完成登录");
).toContain("待处理");
expect(container.textContent).toContain("确认文章标题");
expect(container.textContent).not.toContain("已中断");
});
@@ -604,37 +604,6 @@ function isThinkingTimelineItem(
);
}
function normalizeToolMarker(value: string | undefined): string {
return (value || "").replace(/[\s_-]+/g, "").trim().toLowerCase();
}
function isBrowserTimelineItem(item: AgentThreadItem): boolean {
if (item.type !== "tool_call") {
return false;
}
const normalized = normalizeToolMarker(item.tool_name);
return [
"browser",
"page",
"runtime",
"dom",
"cdp",
"playwright",
"navigate",
"screenshot",
"snapshot",
"click",
"hover",
"presskey",
"type",
"selectoption",
"drag",
"evaluate",
"goto",
].some((marker) => normalized.includes(marker));
}
function isToolExecutionTimelineItem(item: AgentThreadItem): boolean {
return (
item.type === "tool_call" ||
@@ -998,16 +967,6 @@ function resolveFocusBlockIndex(params: {
const pendingAction = findLatestPendingAction(actionRequests);
if (pendingAction?.uiKind === "browser_preflight") {
const browserIndex = findLastBlockIndex(
blocks,
(block) => block.items.some((item) => isBrowserTimelineItem(item)),
);
if (browserIndex >= 0) {
return browserIndex;
}
}
if (pendingAction) {
const pendingIndex = findLastBlockIndex(
blocks,
@@ -1211,17 +1170,6 @@ function resolveThreadInlineStatusHint(params: {
}) {
const pendingAction = findLatestPendingAction(params.actionRequests);
if (pendingAction?.uiKind === "browser_preflight") {
return {
tone: "warning" as const,
label: "待继续",
detail:
pendingAction.detail?.trim() ||
pendingAction.prompt?.trim() ||
"浏览器已经打开,等待你完成登录、授权或验证后继续。",
};
}
if (pendingAction) {
return {
tone: "warning" as const,
@@ -188,87 +188,35 @@ describe("ChatNavbar", () => {
expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull();
});
it("通用对话应支持从顶栏打开浏览器协助", () => {
const onOpenBrowserAssist = vi.fn();
it("Harness 告警态应使用强调样式", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
onOpenBrowserAssist,
showHarnessToggle: true,
harnessAttentionLevel: "warning",
harnessToggleLabel: "执行提醒",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
'button[aria-label="展开执行提醒"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("浏览器工作台");
act(() => {
button?.click();
});
expect(onOpenBrowserAssist).toHaveBeenCalledTimes(1);
});
it("应支持显示浏览器协助状态文案", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistLabel: "等待登录",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button?.textContent).toContain("等待登录");
});
it("浏览器已就绪时不应再占用顶栏状态入口", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistLabel: "浏览器已就绪",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button).toBeNull();
expect(container.textContent).not.toContain("浏览器已就绪");
});
it("浏览器待继续时顶栏按钮应显示恢复态语义", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistAttentionLevel: "warning",
browserAssistLabel: "等待登录",
});
const button = container.querySelector(
'button[aria-label="恢复浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("等待登录");
expect(button?.className).toContain("border-amber-300");
expect(button?.className).toContain("text-amber-800");
});
it("浏览器启动中时顶栏按钮应显示启动态语义", () => {
it("压缩上下文运行中时应禁用顶栏操作", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistAttentionLevel: "info",
browserAssistLoading: true,
showContextCompactionAction: true,
contextCompactionRunning: true,
});
const button = container.querySelector(
'button[aria-label="查看浏览器工作台状态"]',
'button[aria-label="压缩上下文"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.disabled).toBe(true);
expect(button?.textContent).toContain("启动中...");
expect(button?.className).toContain("border-sky-300");
expect(button?.className).toContain("text-sky-800");
expect(button?.textContent).toContain("压缩中...");
});
it("通用对话项目选择器应启用管理能力", () => {
@@ -3,7 +3,6 @@ import {
Box,
ChevronDown,
FolderOpen,
Globe,
Home,
PanelRightClose,
PanelRightOpen,
@@ -40,26 +39,6 @@ interface ChatNavbarProps {
showContextCompactionAction?: boolean;
contextCompactionRunning?: boolean;
onCompactContext?: () => void;
showBrowserAssistEntry?: boolean;
browserAssistActive?: boolean;
browserAssistLoading?: boolean;
browserAssistAttentionLevel?: "idle" | "info" | "warning";
browserAssistLabel?: string;
onOpenBrowserAssist?: () => void;
}
function resolveBrowserAssistTitle(
attentionLevel: NonNullable<ChatNavbarProps["browserAssistAttentionLevel"]>,
): string {
if (attentionLevel === "warning") {
return "恢复浏览器工作台";
}
if (attentionLevel === "info") {
return "查看浏览器工作台状态";
}
return "打开浏览器工作台";
}
const toolbarGroupClassName =
@@ -102,17 +81,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
showContextCompactionAction = false,
contextCompactionRunning = false,
onCompactContext,
showBrowserAssistEntry = false,
browserAssistActive = false,
browserAssistLoading = false,
browserAssistAttentionLevel = "idle",
browserAssistLabel,
onOpenBrowserAssist,
}) => {
const isWorkspaceCompact = chrome === "workspace-compact";
const browserAssistTitle = resolveBrowserAssistTitle(
browserAssistAttentionLevel,
);
const groupClassName = cn(
toolbarGroupClassName,
isWorkspaceCompact && "rounded-[18px] p-1",
@@ -129,15 +99,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
toolbarGhostIconButtonClassName,
isWorkspaceCompact && "h-8 w-8 rounded-[18px]",
);
const shouldShowBrowserAssistEntry =
showBrowserAssistEntry &&
(browserAssistLoading ||
browserAssistAttentionLevel !== "idle" ||
browserAssistLabel?.trim() !== "浏览器已就绪");
const showStatusTools =
shouldShowBrowserAssistEntry ||
showHarnessToggle ||
showContextCompactionAction;
const showStatusTools = showHarnessToggle || showContextCompactionAction;
const showNavigationTools =
!isWorkspaceCompact &&
(Boolean(onBackHome) ||
@@ -298,51 +260,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
) : null}
{showContextCompactionAction &&
(shouldShowBrowserAssistEntry || showHarnessToggle) ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
{shouldShowBrowserAssistEntry ? (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
embeddedButtonClassName,
toolbarTextButtonClassName,
browserAssistActive && "bg-slate-100 text-slate-900",
browserAssistAttentionLevel === "warning" &&
"border-amber-300 bg-amber-50/80 text-amber-800 hover:bg-amber-100 hover:text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15 dark:hover:text-amber-100",
browserAssistAttentionLevel === "info" &&
"border-sky-300 bg-sky-50/80 text-sky-800 hover:bg-sky-100 hover:text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-200 dark:hover:bg-sky-500/15 dark:hover:text-sky-100",
)}
onClick={onOpenBrowserAssist}
disabled={browserAssistLoading}
aria-label={browserAssistTitle}
title={browserAssistTitle}
>
<Globe size={14} />
{browserAssistAttentionLevel !== "idle" ? (
<span
aria-hidden="true"
className={cn(
"h-2 w-2 rounded-full shadow-sm shadow-slate-950/10",
browserAssistAttentionLevel === "warning"
? "bg-amber-500"
: "bg-sky-500",
)}
/>
) : null}
<span>
{browserAssistLoading
? "启动中..."
: browserAssistLabel?.trim() || "浏览器工作台"}
</span>
</Button>
) : null}
{shouldShowBrowserAssistEntry && showHarnessToggle ? (
{showContextCompactionAction && showHarnessToggle ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
@@ -99,24 +99,21 @@ function renderSidebar(
}
describe("ChatSidebar", () => {
function createBrowserPreflightMessage(
phase: "launching" | "awaiting_user" | "failed",
detail: string,
function createPendingActionMessage(
prompt: string,
question = "请补充需要继续执行的信息。",
): Message {
return {
id: `msg-preflight-${phase}`,
id: "msg-pending-action",
role: "assistant",
content: "",
timestamp: new Date("2026-03-15T09:45:00.001Z"),
actionRequests: [
{
requestId: `browser-preflight-${phase}`,
requestId: "req-user-action",
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt: "该任务需要真实浏览器执行,不能仅靠网页检索完成。",
detail,
prompt,
questions: [{ question }],
},
],
};
@@ -278,26 +275,26 @@ describe("ChatSidebar", () => {
expect(container.textContent).toContain("进行中");
});
it("浏览器未就绪时应显示可操作的失败前置态", () => {
it("当前任务存在待处理请求时应覆盖失败态并显示待处理摘要", () => {
const now = new Date("2026-03-15T09:45:00.000Z");
const topics: Topic[] = [
{
...defaultTopics[0],
status: "failed",
lastPreview: "执行失败:browser_connect",
lastPreview: "执行失败:write_file",
},
];
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我整理一篇公众号发布文案",
timestamp: now,
},
{
...createBrowserPreflightMessage(
"failed",
"还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。",
...createPendingActionMessage(
"请先确认发布标题后继续执行。",
"这篇文章的最终标题是什么?",
),
timestamp: new Date(now.getTime() + 1),
},
@@ -311,22 +308,22 @@ describe("ChatSidebar", () => {
pendingActionCount: 0,
});
expect(container.textContent).toContain("浏览器未就绪");
expect(container.textContent).toContain("浏览器会话");
expect(container.textContent).toContain("待处理");
expect(container.textContent).toContain("确认发布标题");
expect(container.textContent).not.toContain("执行失败");
});
it("等待用户在浏览器完成登录时应显示待继续", () => {
it("等待用户补充信息时应显示待处理提示", () => {
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我写一篇活动预热文案",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"awaiting_user",
"已为你打开浏览器。请先完成登录、扫码或验证码,然后继续当前任务。",
createPendingActionMessage(
"请先补充活动标题后继续。",
"这次活动的正式标题是什么?",
),
];
@@ -343,48 +340,17 @@ describe("ChatSidebar", () => {
pendingActionCount: 0,
});
expect(container.textContent).toContain("待继续");
expect(container.textContent).toContain("完成登录");
expect(container.textContent).toContain("待处理");
expect(container.textContent).toContain("补充活动标题");
});
it("浏览器启动中时应显示连接浏览器", () => {
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"launching",
"正在尝试建立浏览器会话,请稍候...",
),
];
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
status: "waiting",
},
],
currentMessages,
currentTopicId: "topic-1",
isSending: false,
pendingActionCount: 0,
});
expect(container.textContent).toContain("连接浏览器");
expect(container.textContent).toContain("正在建立浏览器会话");
});
it("浏览器介入任务应单独归入待继续分组", () => {
it("待处理任务应统一归入待处理分组", () => {
const topics: Topic[] = [
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充文章标题。",
},
{
...defaultTopics[0],
@@ -403,24 +369,23 @@ describe("ChatSidebar", () => {
currentMessages: [],
});
expect(container.textContent).toContain("待继续1");
expect(container.textContent).toContain("待处理1");
expect(container.textContent).toContain("待处理2");
expect(container.textContent).toContain("任务一");
expect(container.textContent).toContain("任务二");
});
it("当前待继续任务应在顶部提供打开浏览器入口", () => {
it("当前待处理任务应提供继续任务入口", () => {
const onResumeTask = vi.fn();
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我把文章整理成周报",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"awaiting_user",
"已为你打开浏览器。请先完成登录、扫码或验证码,然后继续当前任务。",
createPendingActionMessage(
"请先补充周报标题后继续。",
"本周周报的标题是什么?",
),
];
const container = renderSidebar({
@@ -429,35 +394,31 @@ describe("ChatSidebar", () => {
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充周报标题后继续。",
},
],
currentTopicId: "topic-1",
currentMessages,
});
expect(container.textContent).toContain("当前任务待继续");
expect(container.textContent).toContain("打开浏览器");
expect(container.textContent).toContain("继续任务");
const openBrowserButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("打开浏览器"),
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("继续任务"),
);
expect(openBrowserButton).toBeTruthy();
expect(resumeButton).toBeTruthy();
act(() => {
openBrowserButton?.dispatchEvent(
resumeButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(onResumeTask).toHaveBeenCalledWith(
"topic-1",
"browser_awaiting_user",
);
expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action");
});
it("非当前待继续任务应提供进入任务动作", () => {
it("非当前待处理任务也应提供继续任务动作", () => {
const onResumeTask = vi.fn();
const container = renderSidebar({
onResumeTask,
@@ -465,65 +426,25 @@ describe("ChatSidebar", () => {
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充周报标题后继续。",
},
],
currentTopicId: null,
});
expect(container.textContent).toContain("有 1 个任务待继续");
expect(container.textContent).toContain("进入任务");
expect(container.textContent).toContain("继续任务");
const enterTaskButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("进入任务"),
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("继续任务"),
);
expect(enterTaskButton).toBeTruthy();
expect(resumeButton).toBeTruthy();
act(() => {
enterTaskButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onResumeTask).toHaveBeenCalledWith(
"topic-1",
"browser_awaiting_user",
);
});
it("可按待继续筛选任务", () => {
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
},
{
...defaultTopics[0],
id: "topic-2",
title: "任务二",
sourceSessionId: "topic-2",
status: "done",
lastPreview: "已完成。",
},
],
currentTopicId: null,
});
const resumableFilterButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("待继续 1"));
expect(resumableFilterButton).toBeTruthy();
act(() => {
resumableFilterButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).toContain("任务一");
expect(container.textContent).not.toContain("任务二");
expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action");
});
it("父线程应在侧栏展示真实子代理并支持打开", () => {
@@ -5,7 +5,6 @@ import {
ChevronDown,
Clock3,
GitBranch,
Globe,
ListTodo,
Loader2,
MoreHorizontal,
@@ -87,7 +86,6 @@ const STATUS_META: Record<
type TaskSectionKey =
| "running"
| "resumable"
| "waiting"
| "recent"
| "older";
@@ -134,14 +132,6 @@ interface ChatSidebarProps {
onReturnToParentSession?: () => void | Promise<void>;
}
function isResumableStatusReason(statusReason?: TaskStatusReason) {
return (
statusReason === "browser_launching" ||
statusReason === "browser_awaiting_user" ||
statusReason === "browser_failed"
);
}
function formatRelativeTime(date: Date) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60)));
@@ -222,15 +212,6 @@ function resolveCurrentStatusPreview(
if (status === "running") {
return "正在生成回复或执行工具,请稍候。";
}
if (status === "waiting" && statusReason === "browser_launching") {
return "正在建立浏览器会话,请稍候。";
}
if (status === "waiting" && statusReason === "browser_awaiting_user") {
return fallbackPreview || "请先在浏览器完成登录或授权后继续。";
}
if (status === "waiting" && statusReason === "browser_failed") {
return fallbackPreview || "浏览器/CDP 还未连接,请重试启动后继续。";
}
if (status === "waiting" && pendingActionCount > 0) {
return "等待你确认或补充信息后继续执行。";
}
@@ -244,18 +225,6 @@ function resolveStatusLabel(
status: TaskStatus,
statusReason?: TaskStatusReason,
): string {
if (status === "waiting" && statusReason === "browser_launching") {
return "连接浏览器";
}
if (status === "waiting" && statusReason === "browser_awaiting_user") {
return "待继续";
}
if (status === "waiting" && statusReason === "browser_failed") {
return "浏览器未就绪";
}
if (status === "failed" && statusReason === "workspace_error") {
return "工作区异常";
}
@@ -263,26 +232,6 @@ function resolveStatusLabel(
return STATUS_META[status].label;
}
function resolveResumableActionLabel(
statusReason?: TaskStatusReason,
isCurrent = false,
): string {
if (!isCurrent) {
return "进入任务";
}
switch (statusReason) {
case "browser_launching":
return "查看浏览器";
case "browser_awaiting_user":
return "打开浏览器";
case "browser_failed":
return "重试浏览器";
default:
return "继续处理";
}
}
function resolveTaskStatus(params: {
topic: Topic;
currentTopicId: string | null;
@@ -321,7 +270,6 @@ function resolveTaskStatus(params: {
function buildTaskSections(items: TaskCardViewModel[]) {
const now = Date.now();
const running: TaskCardViewModel[] = [];
const resumable: TaskCardViewModel[] = [];
const waiting: TaskCardViewModel[] = [];
const recent: TaskCardViewModel[] = [];
const older: TaskCardViewModel[] = [];
@@ -332,14 +280,6 @@ function buildTaskSections(items: TaskCardViewModel[]) {
continue;
}
if (
item.status === "waiting" &&
isResumableStatusReason(item.statusReason)
) {
resumable.push(item);
continue;
}
if (
item.status === "waiting" ||
item.status === "draft" ||
@@ -359,7 +299,6 @@ function buildTaskSections(items: TaskCardViewModel[]) {
return [
{ key: "running", title: "进行中", items: sortTaskItems(running) },
{ key: "resumable", title: "待继续", items: sortTaskItems(resumable) },
{ key: "waiting", title: "待处理", items: sortTaskItems(waiting) },
{ key: "recent", title: "最近完成", items: sortTaskItems(recent) },
{ key: "older", title: "更早任务", items: sortTaskItems(older) },
@@ -488,9 +427,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<
"all" | "active" | "resumable"
>("all");
const [statusFilter, setStatusFilter] = useState<"all" | "active">("all");
const [showAllOlder, setShowAllOlder] = useState(false);
const [pinnedTaskIds, setPinnedTaskIds] = useState<string[]>(() =>
loadPinnedTaskIds(),
@@ -499,7 +436,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
Record<TaskSectionKey, boolean>
>({
running: false,
resumable: false,
waiting: false,
recent: false,
older: false,
@@ -647,16 +583,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
const filteredTaskItems = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
return taskItems.filter((item) => {
if (
statusFilter === "resumable" &&
!(
item.status === "waiting" &&
isResumableStatusReason(item.statusReason)
)
) {
return false;
}
if (
statusFilter === "active" &&
item.status !== "running" &&
@@ -679,32 +605,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
() => buildTaskSections(filteredTaskItems),
[filteredTaskItems],
);
const resumableTaskCount = useMemo(
() =>
taskItems.filter(
(item) =>
item.status === "waiting" &&
isResumableStatusReason(item.statusReason),
).length,
[taskItems],
);
const resumableItems = useMemo(
() =>
filteredTaskItems.filter(
(item) =>
item.status === "waiting" &&
isResumableStatusReason(item.statusReason),
),
[filteredTaskItems],
);
const primaryResumableItem = useMemo(() => {
if (resumableItems.length === 0) {
return null;
}
return resumableItems.find((item) => item.isCurrent) || resumableItems[0];
}, [resumableItems]);
const hasAnyTasks = topics.length > 0;
const hasFilteredResults = filteredTaskItems.length > 0;
@@ -891,59 +791,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
新建任务
</button>
{primaryResumableItem ? (
<div className="rounded-[24px] border border-amber-200/80 bg-[linear-gradient(180deg,rgba(255,251,235,0.96)_0%,rgba(255,255,255,0.94)_100%)] px-3.5 py-3.5 shadow-sm shadow-amber-950/5 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-200">
<Globe className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{primaryResumableItem.isCurrent
? "当前任务待继续"
: `有 ${resumableItems.length} 个任务待继续`}
</div>
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-white/10 dark:text-amber-200">
{primaryResumableItem.statusLabel}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-amber-800/90 dark:text-amber-200/90">
{primaryResumableItem.isCurrent
? "当前任务卡在浏览器环节,先恢复浏览器会话再继续执行后续动作。"
: `优先恢复“${primaryResumableItem.title}”,避免关键浏览器步骤继续堆积。`}
</p>
<div className="mt-2 line-clamp-2 text-xs leading-5 text-amber-800/85 dark:text-amber-200/85">
{primaryResumableItem.lastPreview}
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-xl bg-amber-600 px-3 text-xs font-semibold text-white transition hover:bg-amber-700"
onClick={() => handleResumeTask(primaryResumableItem)}
>
{resolveResumableActionLabel(
primaryResumableItem.statusReason,
primaryResumableItem.isCurrent,
)}
</button>
{!primaryResumableItem.isCurrent ? (
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-xl border border-amber-200 bg-white/80 px-3 text-xs font-medium text-amber-800 transition hover:border-amber-300 hover:bg-white dark:border-amber-500/20 dark:bg-white/5 dark:text-amber-200 dark:hover:bg-white/10"
onClick={() => {
void onSwitchTopic(primaryResumableItem.id);
}}
>
查看任务
</button>
) : null}
</div>
</div>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 rounded-[22px] border border-white/85 bg-white/72 p-2 shadow-sm shadow-slate-950/5">
<button
type="button"
@@ -969,20 +816,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
>
仅看进行中
</button>
{resumableTaskCount > 0 ? (
<button
type="button"
onClick={() => setStatusFilter("resumable")}
className={cn(
"inline-flex h-9 items-center justify-center rounded-2xl border px-3 text-xs font-medium transition",
statusFilter === "resumable"
? "border-amber-500 bg-amber-500 text-white dark:border-amber-400 dark:bg-amber-400 dark:text-slate-900"
: "border-amber-200/80 bg-amber-50/90 text-amber-700 hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200",
)}
>
待继续 {resumableTaskCount}
</button>
) : null}
</div>
</div>
@@ -1229,10 +1062,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="space-y-3">
{sections.map((section) => {
const isOlderSection = section.key === "older";
const isResumableSection = section.key === "resumable";
const isSectionCollapsed = isResumableSection
? false
: collapsedSections[section.key];
const isSectionCollapsed = collapsedSections[section.key];
const visibleItems =
isOlderSection && !showAllOlder
? section.items.slice(0, OLDER_TASKS_INITIAL_COUNT)
@@ -1244,57 +1074,40 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
return (
<section key={section.key} className="space-y-2">
{isResumableSection ? (
<div className="rounded-[20px] border border-amber-200/80 bg-amber-50/80 px-3 py-3 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-200">
{section.title}
</span>
</div>
<span className="text-[11px] text-amber-700/80 dark:text-amber-300/80">
{section.items.length}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-amber-700/90 dark:text-amber-300/90">
这些任务需要你先在浏览器完成登录、授权或恢复连接,再继续执行。
</p>
</div>
) : (
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
</span>
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
</span>
</button>
)}
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
</span>
</button>
{isSectionCollapsed ? null : (
<div className="space-y-2">
{visibleItems.map((item) => {
const statusMeta = STATUS_META[item.status];
const isResumableItem = isResumableStatusReason(
item.statusReason,
);
const isResumableItem =
item.status === "waiting" ||
(item.status === "failed" &&
item.statusReason === "workspace_error");
return (
<div
@@ -1469,19 +1282,16 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
? `${item.messagesCount} 条消息`
: "尚未开始执行"}
</span>
{isResumableItem ? (
{isResumableItem && onResumeTask ? (
<button
type="button"
className="ml-auto inline-flex h-7 items-center justify-center rounded-lg bg-amber-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-amber-700"
onClick={(event) => {
event.stopPropagation();
handleResumeTask(item);
}}
className="inline-flex items-center rounded-full border border-amber-200/80 bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15"
>
{resolveResumableActionLabel(
item.statusReason,
item.isCurrent,
)}
继续任务
</button>
) : null}
</div>
@@ -178,22 +178,6 @@ function createSubmittedAskUserRequest(requestId: string): ActionRequired {
};
}
function createBrowserPreflightRequest(
requestId: string,
phase: ActionRequired["browserPrepState"] = "failed",
): ActionRequired {
return {
requestId,
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt:
"该任务需要真实浏览器执行,不能仅靠网页检索完成。请先准备右侧浏览器会话。",
detail: "请在右侧浏览器中完成登录、扫码或验证码,然后继续当前任务。",
};
}
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
@@ -489,50 +473,3 @@ describe("DecisionPanel copywriting", () => {
});
});
});
describe("DecisionPanel browser preflight", () => {
it("浏览器未就绪时应支持重试打开浏览器", () => {
const request = createBrowserPreflightRequest("req-browser-preflight");
const { container, onSubmit } = renderDecisionPanel(request);
expect(container.textContent).toContain("浏览器未就绪");
expect(container.textContent).toContain("必须浏览器执行");
clickButton(findButtonByText(container, "重试打开浏览器"));
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-browser-preflight",
confirmed: true,
response: "重新打开浏览器",
actionType: "ask_user",
userData: {
answer: "重新打开浏览器",
browserAction: "launch",
},
});
});
it("等待用户完成登录时不应再提供继续执行确认", () => {
const request = createBrowserPreflightRequest(
"req-browser-awaiting",
"awaiting_user",
);
const { container, onSubmit } = renderDecisionPanel(request);
expect(container.textContent).toContain("请先完成浏览器准备");
expect(container.textContent).not.toContain("我已完成登录,继续执行");
clickButton(findButtonByText(container, "重新打开浏览器"));
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-browser-awaiting",
confirmed: true,
response: "重新打开浏览器",
actionType: "ask_user",
userData: {
answer: "重新打开浏览器",
browserAction: "launch",
},
});
});
});
@@ -46,10 +46,6 @@ function isPromiseLike(value: unknown): value is Promise<unknown> {
);
}
function isBrowserPreflightRequest(request: ActionRequired): boolean {
return request.uiKind === "browser_preflight";
}
/** 获取工具图标 */
function getToolIcon(toolName?: string) {
if (!toolName) return <HelpCircle className="h-4 w-4" />;
@@ -614,161 +610,6 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
);
}
if (isBrowserPreflightRequest(request)) {
const phase = request.browserPrepState || "idle";
const isLaunching = phase === "launching";
const isAwaitingUser =
phase === "awaiting_user" || phase === "ready_to_resume";
const isFailed = phase === "failed";
const allowsFallback = request.allowCapabilityFallback === true;
const requirementLabel =
request.browserRequirement === "required_with_user_step"
? "必须浏览器执行 · 需要你完成登录/授权"
: request.browserRequirement === "required"
? "必须浏览器执行"
: "优先浏览器执行";
const detailText =
request.detail ||
(request.browserRequirement === "required_with_user_step"
? "请先在浏览器工作台完成登录、扫码、验证码或授权,然后回到原入口重新发起任务。"
: "请先让浏览器工作台中的页面处于可操作状态,然后回到原入口重新发起任务。");
const title = isLaunching
? "正在准备浏览器执行环境"
: isAwaitingUser
? "请先完成浏览器准备"
: isFailed
? "浏览器未就绪"
: "此任务需要先准备浏览器";
const handleBrowserAction = (
browserAction: "launch" | "continue" | "fallback",
response: string,
) => {
void submitResponse(
{
requestId: request.requestId,
confirmed: true,
response,
actionType: request.actionType,
userData: {
answer: response,
browserAction,
},
},
{ key: `browser:${browserAction}:${response}`, kind: "allow" },
);
};
return (
<Card
className="border-amber-200 bg-amber-50/60 dark:border-amber-800 dark:bg-amber-950/20"
{...requestAnchorProps}
>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-200">
{isLaunching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isFailed ? (
<AlertTriangle className="h-4 w-4" />
) : (
<Globe className="h-4 w-4" />
)}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="secondary" className="bg-amber-100 text-amber-900">
{requirementLabel}
</Badge>
{request.prompt ? (
<p className="text-sm text-foreground whitespace-pre-wrap">
{request.prompt}
</p>
) : null}
<div className="rounded-lg border border-amber-200/80 bg-background/80 px-3 py-2 text-sm text-muted-foreground dark:border-amber-900/60">
{detailText}
</div>
<div className="flex flex-wrap gap-2 pt-1">
{isLaunching ? (
<Button
size="sm"
disabled
className="bg-amber-600 hover:bg-amber-600"
>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
启动中...
</Button>
) : (
<Button
size="sm"
onClick={() =>
handleBrowserAction(
"launch",
isFailed ? "重新打开浏览器" : "打开浏览器工作台",
)
}
className="bg-amber-600 hover:bg-amber-700"
disabled={isSubmitting}
>
{submissionState?.key ===
`browser:launch:${isFailed ? "重新打开浏览器" : "打开浏览器工作台"}` ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Globe className="mr-1 h-4 w-4" />
)}
{submissionState?.key ===
`browser:launch:${isFailed ? "重新打开浏览器" : "打开浏览器工作台"}`
? "启动中..."
: isFailed
? "重试打开浏览器"
: "打开浏览器工作台"}
</Button>
)}
{isAwaitingUser ? (
<Button
size="sm"
variant="outline"
onClick={() => handleBrowserAction("launch", "重新打开浏览器")}
disabled={isSubmitting}
>
{submissionState?.key === "browser:launch:重新打开浏览器" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Globe className="mr-1 h-4 w-4" />
)}
{submissionState?.key === "browser:launch:重新打开浏览器"
? "处理中..."
: "重新打开浏览器"}
</Button>
) : null}
{allowsFallback ? (
<Button
size="sm"
variant="outline"
onClick={() =>
handleBrowserAction("fallback", "改为仅做网页检索")
}
disabled={isSubmitting}
>
{submissionState?.key ===
"browser:fallback:改为仅做网页检索" ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
处理中...
</>
) : (
"改为仅做网页检索"
)}
</Button>
) : null}
</div>
</CardContent>
</Card>
);
}
// 渲染 elicitation 面板
if (request.actionType === "elicitation" && !usesQuestionnaireUi) {
return (
@@ -91,6 +91,8 @@ interface EmptyStateProps extends SkillSelectionSourceProps {
executionStrategy?: "react" | "code_orchestrated" | "auto",
images?: MessageImage[],
) => void;
isLoading?: boolean;
disabled?: boolean;
/** 创作模式 */
creationMode?: CreationMode;
/** 创作模式变更回调 */
@@ -219,6 +221,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
projectId = null,
onProjectChange,
onOpenSettings,
isLoading = false,
disabled = false,
}) => {
const { wrapTextWithSkill, buildSkillSelection } = useActiveSkill();
const skillSelection = buildSkillSelection({
@@ -292,6 +296,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
const isGeneralTheme = isGeneralResearchTheme(activeTheme);
const isComposerBusy = isLoading || disabled;
const wrapTextWithDefaultSkill = (text: string) => {
const wrappedByActiveSkill = wrapTextWithSkill(text);
@@ -391,7 +396,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
};
const handleSend = () => {
if (!input.trim() && pendingImages.length === 0) return;
if (isComposerBusy || (!input.trim() && pendingImages.length === 0)) {
return;
}
const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined;
onSend(
@@ -733,6 +740,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
accessMode={accessMode}
setAccessMode={setAccessMode}
onManageProviders={onManageProviders}
isLoading={isComposerBusy}
disabled={isComposerBusy}
isGeneralTheme={isGeneralTheme}
characters={characters}
skillSelection={skillSelection}
@@ -303,6 +303,34 @@ describe("EmptyStateComposerPanel", () => {
expect(onPaste).toHaveBeenCalledTimes(1);
});
it("发送准备中应禁用首页发送入口并展示忙碌态", () => {
const onSend = vi.fn();
const container = renderPanel({
input: "请帮我梳理首页首次发送链路",
isLoading: true,
disabled: true,
onSend,
});
const textarea = container.querySelector("textarea") as
| HTMLTextAreaElement
| null;
const pendingButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("稍后处理"),
) as HTMLButtonElement | undefined;
expect(textarea?.disabled).toBe(true);
expect(pendingButton).toBeTruthy();
expect(pendingButton?.disabled).toBe(true);
expect(container.querySelector('button[title="发送"]')).toBeNull();
act(() => {
pendingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSend).not.toHaveBeenCalled();
});
it("有待发送图片时应显示预览并支持删除", () => {
const onRemoveImage = vi.fn();
const container = renderPanel({
@@ -38,6 +38,8 @@ interface EmptyStateComposerPanelProps {
setInput: (value: string) => void;
placeholder: string;
onSend: () => void;
isLoading?: boolean;
disabled?: boolean;
activeTheme: string;
providerType: string;
setProviderType: (type: string) => void;
@@ -78,6 +80,8 @@ export function EmptyStateComposerPanel({
setInput,
placeholder,
onSend,
isLoading = false,
disabled = false,
activeTheme,
providerType,
setProviderType,
@@ -314,6 +318,8 @@ export function EmptyStateComposerPanel({
text={input}
setText={setInput}
onSend={onSend}
isLoading={isLoading}
disabled={disabled}
onToolClick={handleToolAction}
activeTools={{
thinking: thinkingEnabled,
@@ -25,17 +25,17 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type {
ThemeWorkbenchContextBudget,
ThemeWorkbenchContextItem,
} from "./themeWorkbenchContextData";
GeneralWorkbenchContextBudget,
GeneralWorkbenchContextItem,
} from "./generalWorkbenchContextData";
interface ThemeWorkbenchContextPanelProps {
contextItems: ThemeWorkbenchContextItem[];
searchContextItems: ThemeWorkbenchContextItem[];
orderedContextItems: ThemeWorkbenchContextItem[];
selectedSearchResult: ThemeWorkbenchContextItem | null;
interface GeneralWorkbenchContextPanelProps {
contextItems: GeneralWorkbenchContextItem[];
searchContextItems: GeneralWorkbenchContextItem[];
orderedContextItems: GeneralWorkbenchContextItem[];
selectedSearchResult: GeneralWorkbenchContextItem | null;
latestSearchLabel: string;
contextBudget: ThemeWorkbenchContextBudget;
contextBudget: GeneralWorkbenchContextBudget;
contextSearchQuery: string;
contextSearchMode: "web" | "social";
contextSearchLoading: boolean;
@@ -221,8 +221,8 @@ function formatContextCreatedAt(createdAt?: number): string | null {
}
function resolveContextSourceSubLabel(
source: ThemeWorkbenchContextItem["source"],
searchMode?: ThemeWorkbenchContextItem["searchMode"],
source: GeneralWorkbenchContextItem["source"],
searchMode?: GeneralWorkbenchContextItem["searchMode"],
): string {
if (source === "material") {
return "素材库";
@@ -278,7 +278,7 @@ function getContextConfirmButtonClassName(disabled: boolean) {
}
function renderContextList(
items: ThemeWorkbenchContextItem[],
items: GeneralWorkbenchContextItem[],
emptyText: string,
onSelectSearchResult: (contextId: string | null) => void,
onToggleContextActive: (contextId: string) => void,
@@ -340,7 +340,7 @@ function renderContextList(
);
}
function ThemeWorkbenchContextPanelComponent({
function GeneralWorkbenchContextPanelComponent({
contextItems,
searchContextItems,
orderedContextItems,
@@ -379,7 +379,7 @@ function ThemeWorkbenchContextPanelComponent({
onContextDropActiveChange,
onSubmitTextContext,
onSubmitLinkContext,
}: ThemeWorkbenchContextPanelProps) {
}: GeneralWorkbenchContextPanelProps) {
return (
<>
<section className={CONTEXT_SECTION_CLASSNAME}>
@@ -828,4 +828,4 @@ function ThemeWorkbenchContextPanelComponent({
);
}
export const ThemeWorkbenchContextPanel = memo(ThemeWorkbenchContextPanelComponent);
export const GeneralWorkbenchContextPanel = memo(GeneralWorkbenchContextPanelComponent);
@@ -1,8 +1,8 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchEntryPromptAccessory } from "./ThemeWorkbenchEntryPromptAccessory";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
import { GeneralWorkbenchEntryPromptAccessory } from "./GeneralWorkbenchEntryPromptAccessory";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface MountedHarness {
container: HTMLDivElement;
@@ -36,13 +36,13 @@ afterEach(() => {
});
function renderAccessory(
props?: Partial<ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory>>,
props?: Partial<ComponentProps<typeof GeneralWorkbenchEntryPromptAccessory>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultPrompt: ThemeWorkbenchEntryPromptState = {
const defaultPrompt: GeneralWorkbenchEntryPromptState = {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
@@ -50,8 +50,9 @@ function renderAccessory(
actionLabel: "继续生成",
prompt: "请先生成主稿",
};
const defaultProps: ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory> =
{
const defaultProps: ComponentProps<
typeof GeneralWorkbenchEntryPromptAccessory
> = {
prompt: defaultPrompt,
onRestart: vi.fn(),
onContinue: vi.fn(async () => undefined),
@@ -59,7 +60,7 @@ function renderAccessory(
act(() => {
root.render(
<ThemeWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
<GeneralWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
);
});
@@ -73,7 +74,7 @@ function renderAccessory(
};
}
describe("ThemeWorkbenchEntryPromptAccessory", () => {
describe("GeneralWorkbenchEntryPromptAccessory", () => {
it("应渲染提示文案与操作按钮", () => {
const { container } = renderAccessory();
@@ -102,7 +103,7 @@ describe("ThemeWorkbenchEntryPromptAccessory", () => {
);
if (!restartButton || !continueButton) {
throw new Error("未找到主题工作台入口提示操作按钮");
throw new Error("未找到通用工作台入口提示操作按钮");
}
act(() => {
@@ -1,15 +1,15 @@
import { memo } from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface ThemeWorkbenchEntryPromptAccessoryProps {
prompt: ThemeWorkbenchEntryPromptState;
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const ThemeWorkbenchEntryPromptCard = styled.div`
const GeneralWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
@@ -27,38 +27,38 @@ const ThemeWorkbenchEntryPromptCard = styled.div`
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const ThemeWorkbenchEntryPromptHeader = styled.div`
const GeneralWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptTitleWrap = styled.div`
const GeneralWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const ThemeWorkbenchEntryPromptTitle = styled.span`
const GeneralWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const ThemeWorkbenchEntryPromptDescription = styled.span`
const GeneralWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const ThemeWorkbenchEntryPromptActions = styled.div`
const GeneralWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptButton = styled.button<{
const GeneralWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
@@ -96,35 +96,35 @@ const ThemeWorkbenchEntryPromptButton = styled.button<{
}
`;
export const ThemeWorkbenchEntryPromptAccessory = memo(
function ThemeWorkbenchEntryPromptAccessory({
export const GeneralWorkbenchEntryPromptAccessory = memo(
function GeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: ThemeWorkbenchEntryPromptAccessoryProps) {
}: GeneralWorkbenchEntryPromptAccessoryProps) {
return (
<ThemeWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<ThemeWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<GeneralWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<ThemeWorkbenchEntryPromptTitleWrap>
<ThemeWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptTitleWrap>
<GeneralWorkbenchEntryPromptTitle>
{prompt.title}
</ThemeWorkbenchEntryPromptTitle>
<ThemeWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptDescription>
{prompt.description}
</ThemeWorkbenchEntryPromptDescription>
</ThemeWorkbenchEntryPromptTitleWrap>
</ThemeWorkbenchEntryPromptHeader>
<ThemeWorkbenchEntryPromptActions>
<ThemeWorkbenchEntryPromptButton
</GeneralWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitleWrap>
</GeneralWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptActions>
<GeneralWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</ThemeWorkbenchEntryPromptButton>
<ThemeWorkbenchEntryPromptButton
</GeneralWorkbenchEntryPromptButton>
<GeneralWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
@@ -132,9 +132,9 @@ export const ThemeWorkbenchEntryPromptAccessory = memo(
}}
>
{prompt.actionLabel}
</ThemeWorkbenchEntryPromptButton>
</ThemeWorkbenchEntryPromptActions>
</ThemeWorkbenchEntryPromptCard>
</GeneralWorkbenchEntryPromptButton>
</GeneralWorkbenchEntryPromptActions>
</GeneralWorkbenchEntryPromptCard>
);
},
);
@@ -164,7 +164,7 @@ function getExecLogMoreButtonClassName(disabled?: boolean) {
);
}
interface ThemeWorkbenchExecLogProps {
interface GeneralWorkbenchExecLogProps {
entries: ExecLogEntry[];
totalEntriesCount: number;
wasCleared: boolean;
@@ -174,7 +174,7 @@ interface ThemeWorkbenchExecLogProps {
historyLoading?: boolean;
}
export function ThemeWorkbenchExecLog({
export function GeneralWorkbenchExecLog({
entries,
totalEntriesCount,
wasCleared,
@@ -182,7 +182,7 @@ export function ThemeWorkbenchExecLog({
onLoadMoreHistory,
historyHasMore = false,
historyLoading = false,
}: ThemeWorkbenchExecLogProps) {
}: GeneralWorkbenchExecLogProps) {
const [filter, setFilter] = useState<ExecLogFilter>("all");
const [expandedEntryIds, setExpandedEntryIds] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
@@ -2,13 +2,13 @@ import { Clock3, FolderOpen, Sparkles, Workflow } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
type ThemeWorkbenchHarnessRunState =
type GeneralWorkbenchHarnessRunState =
| "idle"
| "auto_running"
| "await_user_decision";
interface ThemeWorkbenchHarnessCardProps {
runState: ThemeWorkbenchHarnessRunState;
interface GeneralWorkbenchHarnessCardProps {
runState: GeneralWorkbenchHarnessRunState;
stageTitle: string;
stageDescription: string;
runTitle?: string | null;
@@ -36,7 +36,7 @@ function formatUpdatedAt(value?: string | null): string {
});
}
function resolveRunStateMeta(runState: ThemeWorkbenchHarnessRunState): {
function resolveRunStateMeta(runState: GeneralWorkbenchHarnessRunState): {
label: string;
variant: "secondary" | "default" | "destructive";
description: string;
@@ -63,7 +63,7 @@ function resolveRunStateMeta(runState: ThemeWorkbenchHarnessRunState): {
}
}
export function ThemeWorkbenchHarnessCard({
export function GeneralWorkbenchHarnessCard({
runState,
stageTitle,
stageDescription,
@@ -74,7 +74,7 @@ export function ThemeWorkbenchHarnessCard({
harnessPanelVisible,
layout = "card",
onToggleHarnessPanel,
}: ThemeWorkbenchHarnessCardProps) {
}: GeneralWorkbenchHarnessCardProps) {
const runStateMeta = resolveRunStateMeta(runState);
const resolvedRunTitle = runTitle?.trim() || "暂无运行记录";
const artifactSummary = artifactCount > 0 ? `${artifactCount} 个产物` : "暂无产物";
@@ -2,7 +2,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchSidebar } from "./ThemeWorkbenchSidebar";
import { GeneralWorkbenchSidebar } from "./GeneralWorkbenchSidebar";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const mockWriteClipboardText = vi.fn();
@@ -76,13 +76,13 @@ afterEach(() => {
});
function renderSidebar(
props?: Partial<React.ComponentProps<typeof ThemeWorkbenchSidebar>>,
props?: Partial<React.ComponentProps<typeof GeneralWorkbenchSidebar>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof ThemeWorkbenchSidebar> = {
const defaultProps: React.ComponentProps<typeof GeneralWorkbenchSidebar> = {
onNewTopic: vi.fn(),
onSwitchTopic: vi.fn(),
onDeleteTopic: vi.fn(),
@@ -171,14 +171,14 @@ function renderSidebar(
};
act(() => {
root.render(<ThemeWorkbenchSidebar {...defaultProps} {...props} />);
root.render(<GeneralWorkbenchSidebar {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return { container, props: { ...defaultProps, ...props } };
}
describe("ThemeWorkbenchSidebar", () => {
describe("GeneralWorkbenchSidebar", () => {
it("传入折叠回调时应显示折叠按钮并可触发", () => {
const onRequestCollapse = vi.fn();
const { container } = renderSidebar({ onRequestCollapse });
@@ -279,7 +279,7 @@ describe("ThemeWorkbenchSidebar", () => {
container.querySelector('button[aria-label="打开上下文管理"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="打开编排工作台"]'),
container.querySelector('button[aria-label="打开编排面板"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="打开执行日志"]'),
@@ -681,7 +681,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -709,7 +709,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -736,7 +736,7 @@ describe("ThemeWorkbenchSidebar", () => {
it("活动日志应展示后端闸门与运行标识", () => {
const { container } = renderSidebar();
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -789,7 +789,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -822,7 +822,7 @@ describe("ThemeWorkbenchSidebar", () => {
const onViewRunDetail = vi.fn();
const { container } = renderSidebar({ onViewRunDetail });
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -872,7 +872,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -914,7 +914,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -989,7 +989,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1084,7 +1084,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1165,7 +1165,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1220,7 +1220,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -0,0 +1,66 @@
import React, { memo, useState } from "react";
import {
GeneralWorkbenchSidebarShell,
type GeneralWorkbenchSidebarTab,
} from "./GeneralWorkbenchSidebarShell";
import { GeneralWorkbenchSidebarPanels } from "./GeneralWorkbenchSidebarPanels";
import { buildGeneralWorkbenchSidebarOrchestrationSource } from "./buildGeneralWorkbenchSidebarOrchestrationSource";
import { createGeneralWorkbenchSidebarOrchestrationInput } from "./generalWorkbenchSidebarOrchestrationContract";
import { type GeneralWorkbenchSidebarProps } from "./generalWorkbenchSidebarContract";
import { areGeneralWorkbenchSidebarPropsEqual } from "./generalWorkbenchSidebarComparator";
import { useGeneralWorkbenchSidebarOrchestration } from "./useGeneralWorkbenchSidebarOrchestration";
function GeneralWorkbenchSidebarComponent({
branchMode = "version",
onRequestCollapse,
headerActionSlot,
topSlot,
...props
}: GeneralWorkbenchSidebarProps) {
const [activeTab, setActiveTab] =
useState<GeneralWorkbenchSidebarTab>("context");
const isVersionMode = branchMode === "version";
const orchestrationInput = createGeneralWorkbenchSidebarOrchestrationInput(
buildGeneralWorkbenchSidebarOrchestrationSource({
isVersionMode,
props,
}),
);
const {
branchCount,
activeContextCount,
visibleExecLogCount,
contextPanelProps,
workflowPanelProps,
execLogProps,
} = useGeneralWorkbenchSidebarOrchestration({
activeTab,
input: orchestrationInput,
});
return (
<GeneralWorkbenchSidebarShell
activeTab={activeTab}
isVersionMode={isVersionMode}
activeContextCount={activeContextCount}
branchCount={branchCount}
visibleExecLogCount={visibleExecLogCount}
onTabChange={setActiveTab}
onRequestCollapse={onRequestCollapse}
headerActionSlot={headerActionSlot}
topSlot={topSlot}
>
<GeneralWorkbenchSidebarPanels
activeTab={activeTab}
contextPanelProps={contextPanelProps}
workflowPanelProps={workflowPanelProps}
execLogProps={execLogProps}
/>
</GeneralWorkbenchSidebarShell>
);
}
export const GeneralWorkbenchSidebar = memo(
GeneralWorkbenchSidebarComponent,
areGeneralWorkbenchSidebarPropsEqual,
);
@@ -0,0 +1,28 @@
import { GeneralWorkbenchContextPanel } from "./GeneralWorkbenchContextPanel";
import { GeneralWorkbenchExecLog } from "./GeneralWorkbenchExecLog";
import { GeneralWorkbenchWorkflowPanel } from "./GeneralWorkbenchWorkflowPanel";
import type { GeneralWorkbenchSidebarTab } from "./GeneralWorkbenchSidebarShell";
import type { GeneralWorkbenchSidebarContentProps } from "./generalWorkbenchSidebarContentContract";
export interface GeneralWorkbenchSidebarPanelsProps
extends GeneralWorkbenchSidebarContentProps {
activeTab: GeneralWorkbenchSidebarTab;
}
export function GeneralWorkbenchSidebarPanels({
activeTab,
contextPanelProps,
workflowPanelProps,
execLogProps,
}: GeneralWorkbenchSidebarPanelsProps) {
if (activeTab === "context") {
return <GeneralWorkbenchContextPanel {...contextPanelProps} />;
}
if (activeTab === "workflow") {
return <GeneralWorkbenchWorkflowPanel {...workflowPanelProps} />;
}
if (activeTab === "log") {
return <GeneralWorkbenchExecLog {...execLogProps} />;
}
return null;
}
@@ -2,15 +2,15 @@ import { type ReactNode } from "react";
import { ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
export type ThemeWorkbenchSidebarTab = "context" | "workflow" | "log";
export type GeneralWorkbenchSidebarTab = "context" | "workflow" | "log";
interface ThemeWorkbenchSidebarShellProps {
activeTab: ThemeWorkbenchSidebarTab;
interface GeneralWorkbenchSidebarShellProps {
activeTab: GeneralWorkbenchSidebarTab;
isVersionMode: boolean;
activeContextCount: number;
branchCount: number;
visibleExecLogCount: number;
onTabChange: (tab: ThemeWorkbenchSidebarTab) => void;
onTabChange: (tab: GeneralWorkbenchSidebarTab) => void;
onRequestCollapse?: () => void;
headerActionSlot?: ReactNode;
topSlot?: ReactNode;
@@ -68,7 +68,7 @@ function getSidebarTabCountClassName(active: boolean) {
}
function resolveSidebarTitle(
activeTab: ThemeWorkbenchSidebarTab,
activeTab: GeneralWorkbenchSidebarTab,
isVersionMode: boolean,
): string {
if (activeTab === "context") {
@@ -77,14 +77,16 @@ function resolveSidebarTitle(
return isVersionMode ? "编排与版本" : "编排与分支";
}
function resolveSidebarDescription(activeTab: ThemeWorkbenchSidebarTab): string {
function resolveSidebarDescription(
activeTab: GeneralWorkbenchSidebarTab,
): string {
if (activeTab === "context") {
return "检索、筛选并启用当前创作真正会用到的上下文。";
}
return "跟踪编排进度、产物版本与运行记录。";
}
export function ThemeWorkbenchSidebarShell({
export function GeneralWorkbenchSidebarShell({
activeTab,
isVersionMode,
activeContextCount,
@@ -95,19 +97,19 @@ export function ThemeWorkbenchSidebarShell({
headerActionSlot,
topSlot,
children,
}: ThemeWorkbenchSidebarShellProps) {
}: GeneralWorkbenchSidebarShellProps) {
return (
<div
className={SIDEBAR_CONTAINER_CLASSNAME}
data-testid="theme-workbench-sidebar"
data-testid="general-workbench-sidebar"
>
<div className={SIDEBAR_HEADER_CLASSNAME}>
<div className={SIDEBAR_HEADER_META_ROW_CLASSNAME}>
<div className={SIDEBAR_EYEBROW_CLASSNAME}>Theme Workbench</div>
<div className={SIDEBAR_EYEBROW_CLASSNAME}>工作区编排</div>
{headerActionSlot ? (
<div
className={SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME}
data-testid="theme-workbench-sidebar-header-action"
data-testid="general-workbench-sidebar-header-action"
>
{headerActionSlot}
</div>
@@ -134,8 +136,8 @@ export function ThemeWorkbenchSidebarShell({
</button>
<button
type="button"
aria-label="打开编排工作台"
title="编排工作台"
aria-label="打开编排面板"
title="编排面板"
className={getSidebarTabButtonClassName(activeTab === "workflow")}
onClick={() => onTabChange("workflow")}
>
@@ -172,7 +174,7 @@ export function ThemeWorkbenchSidebarShell({
{topSlot ? (
<div
className={SIDEBAR_TOP_SLOT_CLASSNAME}
data-testid="theme-workbench-sidebar-top-slot"
data-testid="general-workbench-sidebar-top-slot"
>
{topSlot}
</div>
@@ -23,12 +23,12 @@ import { cn } from "@/lib/utils";
import type { TopicBranchItem, TopicBranchStatus } from "../hooks/useTopicBranchBoard";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type {
ThemeWorkbenchActivityLogGroup,
ThemeWorkbenchCreationTaskGroup,
ThemeWorkbenchRunMetadataSummary,
} from "./themeWorkbenchWorkflowData";
GeneralWorkbenchActivityLogGroup,
GeneralWorkbenchCreationTaskGroup,
GeneralWorkbenchRunMetadataSummary,
} from "./generalWorkbenchWorkflowData";
interface ThemeWorkbenchWorkflowPanelProps {
interface GeneralWorkbenchWorkflowPanelProps {
isVersionMode: boolean;
onNewTopic: () => void;
onSwitchTopic: (topicId: string) => void;
@@ -43,16 +43,16 @@ interface ThemeWorkbenchWorkflowPanelProps {
creationTaskEventsCount: number;
showCreationTasks: boolean;
onToggleCreationTasks: () => void;
groupedCreationTaskEvents: ThemeWorkbenchCreationTaskGroup[];
groupedCreationTaskEvents: GeneralWorkbenchCreationTaskGroup[];
showActivityLogs: boolean;
onToggleActivityLogs: () => void;
groupedActivityLogs: ThemeWorkbenchActivityLogGroup[];
groupedActivityLogs: GeneralWorkbenchActivityLogGroup[];
onViewRunDetail?: (runId: string) => void;
activeRunDetail?: AgentRun | null;
activeRunDetailLoading?: boolean;
activeRunStagesLabel?: string | null;
runMetadataText: string;
runMetadataSummary: ThemeWorkbenchRunMetadataSummary;
runMetadataSummary: GeneralWorkbenchRunMetadataSummary;
onCopyText: (text: string) => Promise<void> | void;
onRevealArtifactInFinder: (
artifactPath: string,
@@ -312,10 +312,10 @@ function formatRunStatusLabel(status: AgentRun["status"]): string {
}
function renderActivityLogItem(
group: ThemeWorkbenchActivityLogGroup,
onViewRunDetail: ThemeWorkbenchWorkflowPanelProps["onViewRunDetail"],
onRevealArtifactInFinder: ThemeWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"],
onOpenArtifactWithDefaultApp: ThemeWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"],
group: GeneralWorkbenchActivityLogGroup,
onViewRunDetail: GeneralWorkbenchWorkflowPanelProps["onViewRunDetail"],
onRevealArtifactInFinder: GeneralWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"],
onOpenArtifactWithDefaultApp: GeneralWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"],
) {
const gateLabel = formatGateLabel(group.gateKey);
const runLabel = formatRunIdShort(group.runId);
@@ -384,8 +384,8 @@ function ActivityMetaFragment({
}: {
artifactPath: string;
sessionId?: string | null;
onRevealArtifactInFinder: ThemeWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"];
onOpenArtifactWithDefaultApp: ThemeWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"];
onRevealArtifactInFinder: GeneralWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"];
onOpenArtifactWithDefaultApp: GeneralWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"];
}) {
return (
<>
@@ -411,7 +411,7 @@ function ActivityMetaFragment({
);
}
function ThemeWorkbenchWorkflowPanelComponent({
function GeneralWorkbenchWorkflowPanelComponent({
isVersionMode,
onNewTopic,
onSwitchTopic,
@@ -439,7 +439,7 @@ function ThemeWorkbenchWorkflowPanelComponent({
onCopyText,
onRevealArtifactInFinder,
onOpenArtifactWithDefaultApp,
}: ThemeWorkbenchWorkflowPanelProps) {
}: GeneralWorkbenchWorkflowPanelProps) {
return (
<>
<section className={cn(WORKFLOW_SECTION_CLASSNAME, "relative z-10")}>
@@ -738,4 +738,4 @@ function ThemeWorkbenchWorkflowPanelComponent({
);
}
export const ThemeWorkbenchWorkflowPanel = memo(ThemeWorkbenchWorkflowPanelComponent);
export const GeneralWorkbenchWorkflowPanel = memo(GeneralWorkbenchWorkflowPanelComponent);
@@ -7,6 +7,7 @@ import { RenderableTaskImage } from "./RenderableTaskImage";
interface ImageWorkbenchMessagePreviewProps {
preview: MessageImageWorkbenchPreview;
onOpen?: (preview: MessageImageWorkbenchPreview) => void;
}
function resolveModeEyebrow(
@@ -208,18 +209,22 @@ function resolveSourcePlaceholderLabel(
export const ImageWorkbenchMessagePreview: React.FC<
ImageWorkbenchMessagePreviewProps
> = ({ preview }) => {
> = ({ preview, onOpen }) => {
const showSourcePanel = shouldShowSourcePanel(preview);
return (
<button
type="button"
onClick={() =>
onClick={() => {
if (onOpen) {
onOpen(preview);
return;
}
emitImageWorkbenchFocus({
projectId: preview.projectId ?? null,
contentId: preview.contentId ?? null,
})
}
});
}}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
@@ -11,7 +11,7 @@ import { InputbarCore } from "./InputbarCore";
import { SkillSelector } from "../../../skill-selection/SkillSelector";
import type { BuiltinInputCommand } from "../../../skill-selection/builtinCommands";
import { TeamSelector } from "./TeamSelector";
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
@@ -25,16 +25,16 @@ import {
} from "../../../skill-selection/skillSelectionBindings";
import type { AgentAccessMode } from "../../../hooks/agentChatStorage";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
interface InputbarComposerSectionProps {
renderThemeWorkbenchGeneratingPanel: boolean;
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
themeWorkbenchQuickActions: ThemeWorkbenchQuickAction[];
themeWorkbenchQueueItems: ThemeWorkbenchWorkflowStep[];
renderWorkflowGeneratingPanel: boolean;
workflowGate?: WorkflowGateState | null;
workflowQuickActions: WorkflowQuickAction[];
workflowQueueItems: WorkflowStep[];
inputAdapter: ChatInputAdapter;
characters: Character[];
skillSelection: SkillSelectionProps;
@@ -54,7 +54,7 @@ interface InputbarComposerSectionProps {
onRemoveImage: (index: number) => void;
onPaste: (event: React.ClipboardEvent) => void;
isFullscreen: boolean;
isThemeWorkbenchVariant: boolean;
isWorkspaceVariant: boolean;
activeTheme?: string;
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
@@ -72,10 +72,10 @@ interface InputbarComposerSectionProps {
export const InputbarComposerSection: React.FC<
InputbarComposerSectionProps
> = ({
renderThemeWorkbenchGeneratingPanel,
themeWorkbenchGate,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderWorkflowGeneratingPanel,
workflowGate,
workflowQuickActions,
workflowQueueItems,
inputAdapter,
characters,
skillSelection,
@@ -95,7 +95,7 @@ export const InputbarComposerSection: React.FC<
onRemoveImage,
onPaste,
isFullscreen,
isThemeWorkbenchVariant,
isWorkspaceVariant,
activeTheme,
onManageProviders,
executionRuntime,
@@ -111,7 +111,7 @@ export const InputbarComposerSection: React.FC<
number | null
>(null);
const showSkillSelector =
!isThemeWorkbenchVariant && isGeneralResearchTheme(activeTheme);
!isWorkspaceVariant && isGeneralResearchTheme(activeTheme);
const currentPendingImages =
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
pendingImages;
@@ -150,12 +150,12 @@ export const InputbarComposerSection: React.FC<
onToolClick(tool);
};
if (renderThemeWorkbenchGeneratingPanel) {
if (renderWorkflowGeneratingPanel) {
return (
<ThemeWorkbenchStatusPanel
gate={themeWorkbenchGate}
quickActions={themeWorkbenchQuickActions}
queueItems={themeWorkbenchQueueItems}
<InputbarWorkflowStatusPanel
gate={workflowGate}
quickActions={workflowQuickActions}
queueItems={workflowQueueItems}
renderGeneratingPanel
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
@@ -165,10 +165,10 @@ export const InputbarComposerSection: React.FC<
return (
<>
<ThemeWorkbenchStatusPanel
gate={themeWorkbenchGate}
quickActions={themeWorkbenchQuickActions}
queueItems={themeWorkbenchQueueItems}
<InputbarWorkflowStatusPanel
gate={workflowGate}
quickActions={workflowQuickActions}
queueItems={workflowQueueItems}
renderGeneratingPanel={false}
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
@@ -197,15 +197,15 @@ export const InputbarComposerSection: React.FC<
onPaste={onPaste}
isFullscreen={isFullscreen}
placeholder={
isThemeWorkbenchVariant
? themeWorkbenchGate?.status === "waiting"
isWorkspaceVariant
? workflowGate?.status === "waiting"
? "说说你的选择,剩下的交给我"
: "试着输入任何指令,剩下的交给我"
: undefined
}
toolMode={isThemeWorkbenchVariant ? "attach-only" : "default"}
showDragHandle={!isThemeWorkbenchVariant}
visualVariant={isThemeWorkbenchVariant ? "floating" : "default"}
toolMode={isWorkspaceVariant ? "attach-only" : "default"}
showDragHandle={!isWorkspaceVariant}
visualVariant={isWorkspaceVariant ? "floating" : "default"}
topExtra={resolvedTopExtra}
activeTheme={activeTheme}
queuedTurns={queuedTurns}
@@ -234,7 +234,7 @@ export const InputbarComposerSection: React.FC<
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
{!isThemeWorkbenchVariant ? (
{!isWorkspaceVariant ? (
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={inputAdapter.model?.providerType}
@@ -8,15 +8,15 @@ import {
} from "lucide-react";
import styled from "styled-components";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
interface ThemeWorkbenchStatusPanelProps {
gate?: ThemeWorkbenchGateState | null;
quickActions: ThemeWorkbenchQuickAction[];
queueItems: ThemeWorkbenchWorkflowStep[];
interface InputbarWorkflowStatusPanelProps {
gate?: WorkflowGateState | null;
quickActions: WorkflowQuickAction[];
queueItems: WorkflowStep[];
renderGeneratingPanel: boolean;
onQuickAction: (prompt: string) => void;
onStop?: () => void;
@@ -296,14 +296,14 @@ const StopGlyph = styled.span`
}
`;
export function ThemeWorkbenchStatusPanel({
export function InputbarWorkflowStatusPanel({
gate,
quickActions,
queueItems,
renderGeneratingPanel,
onQuickAction,
onStop,
}: ThemeWorkbenchStatusPanelProps) {
}: InputbarWorkflowStatusPanelProps) {
const [queueCollapsed, setQueueCollapsed] = useState(false);
if (renderGeneratingPanel) {
@@ -374,7 +374,7 @@ export function ThemeWorkbenchStatusPanel({
<RunningSub>切换项目或关闭网页将中断任务</RunningSub>
<StopButton
type="button"
data-testid="theme-workbench-stop"
data-testid="workflow-stop"
onClick={() => onStop?.()}
aria-label="停止生成"
>
@@ -7,15 +7,15 @@ import { useImageAttachments } from "./useImageAttachments";
import { useInputbarAdapter } from "./useInputbarAdapter";
import { useInputbarSend } from "./useInputbarSend";
import {
useInputbarToolState,
type InputbarToolStates,
useInputbarToolState,
} from "./useInputbarToolState";
import type { SkillSelectionSourceProps } from "../../../skill-selection/skillSelectionBindings";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
import { useThemeWorkbenchInputState } from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowStep,
} from "../../../utils/workflowInputState";
import { useWorkflowInputState } from "../../../utils/workflowInputState";
import { TeamSuggestionBar } from "@/components/agent/chat/components/TeamSuggestionBar";
import { getTeamSuggestion } from "@/components/agent/chat/utils/teamSuggestion";
import type { MessageImage } from "../../../types";
@@ -42,10 +42,10 @@ interface UseInputbarControllerParams {
toolStates?: Partial<InputbarToolStates>;
onToolStatesChange?: (states: InputbarToolStates) => void;
activeTheme?: string;
variant?: "default" | "theme_workbench";
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
workflowSteps?: ThemeWorkbenchWorkflowStep[];
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
variant?: "default" | "workspace";
workflowGate?: WorkflowGateState | null;
workflowSteps?: WorkflowStep[];
workflowRunState?: "idle" | "auto_running" | "await_user_decision";
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
}
@@ -65,9 +65,9 @@ export function useInputbarController({
onToolStatesChange,
activeTheme,
variant = "default",
themeWorkbenchGate,
workflowGate,
workflowSteps = [],
themeWorkbenchRunState,
workflowRunState,
onEnableSuggestedTeam,
skills,
serviceSkills,
@@ -93,7 +93,7 @@ export function useInputbarController({
openFileDialog,
} = useImageAttachments();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isThemeWorkbenchVariant = variant === "theme_workbench";
const isWorkspaceVariant = variant === "workspace";
const {
activeTools,
@@ -150,14 +150,14 @@ export function useInputbarController({
});
const {
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
} = useThemeWorkbenchInputState({
isThemeWorkbenchVariant,
themeWorkbenchGate,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
} = useWorkflowInputState({
isWorkspaceVariant,
workflowGate,
workflowSteps,
themeWorkbenchRunState,
workflowRunState,
isSending: inputAdapter.state.isSending,
});
@@ -229,7 +229,7 @@ export function useInputbarController({
return {
textareaRef,
isThemeWorkbenchVariant,
isWorkspaceVariant,
pendingImages,
fileInputRef,
handleFileSelect,
@@ -248,9 +248,9 @@ export function useInputbarController({
handleSend,
inputAdapter,
topExtra,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
skillSelection,
setActiveBuiltinCommand: (command: BuiltinInputCommand | null) => {
if (command) {
@@ -11,7 +11,7 @@ import {
} from "@/lib/api/asrProvider";
import { useVoiceSound } from "@/hooks/useVoiceSound";
export type InputbarDictationState =
type InputbarDictationState =
| "idle"
| "listening"
| "transcribing"
@@ -730,9 +730,9 @@ describe("Inputbar", () => {
);
});
it("主题工作台模式应启用 PRD 浮层输入配置", async () => {
it("工作区工作流模式应启用 PRD 浮层输入配置", async () => {
renderInputbar({
variant: "theme_workbench",
variant: "workspace",
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
@@ -757,10 +757,10 @@ describe("Inputbar", () => {
expect(latestCall.leftExtra).toBeDefined();
});
it("主题工作台在待启动状态下不应显示闸门条", async () => {
it("工作区工作流在待启动状态下不应显示闸门条", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
themeWorkbenchGate: {
variant: "workspace",
workflowGate: {
key: "draft_start",
title: "编排待启动",
status: "idle",
@@ -777,12 +777,12 @@ describe("Inputbar", () => {
expect(container.textContent).not.toContain("待启动");
});
it("主题工作台闸门快捷操作应能快速填充输入", async () => {
it("工作区工作流闸门快捷操作应能快速填充输入", async () => {
const setInput = vi.fn();
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
setInput,
themeWorkbenchGate: {
workflowGate: {
key: "topic_select",
title: "选题闸门",
status: "waiting",
@@ -815,10 +815,10 @@ describe("Inputbar", () => {
);
});
it("主题工作台生成中应展示任务面板并支持停止", async () => {
it("工作区工作流生成中应展示任务面板并支持停止", async () => {
const onStop = vi.fn();
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
onStop,
workflowSteps: [
@@ -837,7 +837,7 @@ describe("Inputbar", () => {
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
const stopButton = container.querySelector(
'[data-testid="theme-workbench-stop"]',
'[data-testid="workflow-stop"]',
) as HTMLButtonElement | null;
expect(stopButton).toBeTruthy();
act(() => {
@@ -846,9 +846,9 @@ describe("Inputbar", () => {
expect(onStop).toHaveBeenCalledTimes(1);
});
it("主题工作台生成中应支持折叠与展开待办列表", async () => {
it("工作区工作流生成中应支持折叠与展开待办列表", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
workflowSteps: [
{ id: "research", title: "检索项目素材", status: "active" },
@@ -886,11 +886,11 @@ describe("Inputbar", () => {
expect(container.textContent).toContain("检索项目素材");
});
it("主题工作台在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
it("工作区工作流在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: false,
themeWorkbenchRunState: "auto_running",
workflowRunState: "auto_running",
workflowSteps: [
{ id: "research", title: "检索项目素材", status: "active" },
{ id: "write", title: "编写正文草稿", status: "pending" },
@@ -907,11 +907,11 @@ describe("Inputbar", () => {
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
});
it("主题工作台在 await_user_decision 状态下应显示输入框", async () => {
it("工作区工作流在 await_user_decision 状态下应显示输入框", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
themeWorkbenchRunState: "await_user_decision",
workflowRunState: "await_user_decision",
workflowSteps: [
{ id: "topic", title: "等待用户确认选题", status: "pending" },
],
@@ -13,9 +13,9 @@ import { TaskFilesPanel } from "./components/TaskFilesPanel";
import { InputbarSurface } from "./components/InputbarSurface";
import type { SkillSelectionSourceProps } from "../../skill-selection/skillSelectionBindings";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchWorkflowStep,
} from "../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowStep,
} from "../../utils/workflowInputState";
import { type InputbarToolStates } from "./hooks/useInputbarToolState";
import { useInputbarController } from "./hooks/useInputbarController";
import type { TeamDefinition } from "../../utils/teamDefinitions";
@@ -86,10 +86,10 @@ interface InputbarProps extends SkillSelectionSourceProps {
onToolStatesChange?: (states: InputbarToolStates) => void;
activeTheme?: string;
onManageProviders?: () => void;
variant?: "default" | "theme_workbench";
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
workflowSteps?: ThemeWorkbenchWorkflowStep[];
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
variant?: "default" | "workspace";
workflowGate?: WorkflowGateState | null;
workflowSteps?: WorkflowStep[];
workflowRunState?: "idle" | "auto_running" | "await_user_decision";
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
@@ -136,9 +136,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
activeTheme,
onManageProviders,
variant = "default",
themeWorkbenchGate,
workflowGate,
workflowSteps = [],
themeWorkbenchRunState,
workflowRunState,
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
@@ -150,7 +150,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
}) => {
const {
textareaRef,
isThemeWorkbenchVariant,
isWorkspaceVariant,
pendingImages,
fileInputRef,
handleFileSelect,
@@ -169,9 +169,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
handleSend,
inputAdapter,
topExtra,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
skillSelection,
setActiveBuiltinCommand,
} = useInputbarController({
@@ -190,9 +190,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
onToolStatesChange,
activeTheme,
variant,
themeWorkbenchGate,
workflowGate,
workflowSteps,
themeWorkbenchRunState,
workflowRunState,
onEnableSuggestedTeam,
skills,
serviceSkills,
@@ -238,12 +238,10 @@ export const Inputbar: React.FC<InputbarProps> = ({
onChange={handleFileSelect}
/>
<InputbarComposerSection
renderThemeWorkbenchGeneratingPanel={
renderThemeWorkbenchGeneratingPanel
}
themeWorkbenchGate={themeWorkbenchGate}
themeWorkbenchQuickActions={themeWorkbenchQuickActions}
themeWorkbenchQueueItems={themeWorkbenchQueueItems}
renderWorkflowGeneratingPanel={renderWorkflowGeneratingPanel}
workflowGate={workflowGate}
workflowQuickActions={workflowQuickActions}
workflowQueueItems={workflowQueueItems}
inputAdapter={inputAdapter}
characters={characters}
skillSelection={skillSelection}
@@ -263,7 +261,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
onRemoveImage={handleRemoveImage}
onPaste={handlePaste}
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
isWorkspaceVariant={isWorkspaceVariant}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
@@ -444,12 +444,6 @@ export const ToolButton = styled.button`
}
`;
export const Divider = styled.div`
width: 1px;
height: 16px;
background-color: rgba(148, 163, 184, 0.28);
`;
export const InputIconButton = styled.button<{
$primary?: boolean;
$destructive?: boolean;
@@ -2,10 +2,11 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IMAGE_WORKBENCH_FOCUS_EVENT } from "@/lib/imageWorkbenchEvents";
import { MessageList } from "./MessageList";
import type { Message } from "../types";
const IMAGE_WORKBENCH_FOCUS_EVENT = "lime:image-workbench-focus";
vi.mock("./MarkdownRenderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content || "<empty>"}</div>
@@ -138,6 +139,19 @@ function render(
}
describe("MessageList", () => {
it("自动恢复任务中心时应展示恢复占位而不是空白引导", () => {
const container = render([], { isRestoringSession: true });
expect(
container.querySelector('[data-testid="message-list-restoring-session"]'),
).not.toBeNull();
expect(container.textContent).toContain("正在恢复任务中心...");
expect(container.textContent).toContain(
"正在同步最近一次任务会话,请稍候。",
);
expect(container.textContent).not.toContain("开始一段新的对话吧");
});
it("应过滤空白 user 消息,避免渲染空白气泡", () => {
const now = new Date();
const messages: Message[] = [
@@ -246,6 +260,167 @@ describe("MessageList", () => {
window.removeEventListener(IMAGE_WORKBENCH_FOCUS_EVENT, handleFocus);
});
it("视频任务消息卡应在聊天区渲染预览并支持打开工作区查看", () => {
const now = new Date();
const onOpenMessagePreview = vi.fn();
const messages: Message[] = [
{
id: "msg-assistant-video-task",
role: "assistant",
content: "视频任务已提交,正在生成。",
timestamp: now,
taskPreview: {
kind: "video_generate",
taskId: "task-video-1",
taskType: "video_generate",
prompt: "新品发布会短视频,镜头缓慢推进主角产品",
status: "running",
progress: 42,
durationSeconds: 15,
aspectRatio: "16:9",
resolution: "720p",
projectId: "project-video-1",
contentId: "content-video-1",
},
},
];
const container = render(messages, { onOpenMessagePreview });
const previewCard = container.querySelector(
'[data-testid="task-message-preview-task-video-1"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("视频生成");
expect(previewCard?.textContent).toContain("16:9");
expect(previewCard?.textContent).toContain("720p");
expect(previewCard?.textContent).toContain("42%");
act(() => {
previewCard?.click();
});
expect(onOpenMessagePreview).toHaveBeenCalledWith(
{
kind: "task",
preview: expect.objectContaining({
kind: "video_generate",
taskId: "task-video-1",
}),
},
expect.objectContaining({
id: "msg-assistant-video-task",
}),
);
});
it("通用任务消息卡应在聊天区渲染预览并支持打开对应产物", () => {
const now = new Date();
const onOpenMessagePreview = vi.fn();
const messages: Message[] = [
{
id: "msg-assistant-resource-task",
role: "assistant",
content: "素材检索任务已提交。",
timestamp: now,
taskPreview: {
kind: "modal_resource_search",
taskId: "task-resource-1",
taskType: "modal_resource_search",
prompt: "咖啡馆木桌背景",
title: "公众号头图素材",
status: "running",
artifactPath:
".lime/tasks/modal_resource_search/task-resource-1.json",
metaItems: ["image", "公众号头图", "8 个候选"],
},
},
];
const container = render(messages, { onOpenMessagePreview });
const previewCard = container.querySelector(
'[data-testid="task-message-preview-task-resource-1"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("素材检索");
expect(previewCard?.textContent).toContain("公众号头图素材");
expect(previewCard?.textContent).toContain("8 个候选");
act(() => {
previewCard?.click();
});
expect(onOpenMessagePreview).toHaveBeenCalledWith(
{
kind: "task",
preview: expect.objectContaining({
kind: "modal_resource_search",
taskId: "task-resource-1",
}),
},
expect.objectContaining({
id: "msg-assistant-resource-task",
}),
);
});
it("联网搜图结果消息卡应展示缩略图候选", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-resource-search-preview",
role: "assistant",
content: "已找到一组图片素材候选。",
timestamp: now,
taskPreview: {
kind: "modal_resource_search",
taskId: "resource-search:tool-1",
taskType: "modal_resource_search",
prompt: "cozy coffee table",
title: "Pexels 图片候选",
status: "complete",
artifactPath: ".lime/runtime/resource-search/tool-1.md",
metaItems: ["Pexels", "3 个候选"],
imageCandidates: [
{
id: "hit-1",
thumbnailUrl: "https://pexels.example/1-thumb.jpg",
contentUrl: "https://pexels.example/1.jpg",
name: "cozy coffee table 1",
},
{
id: "hit-2",
thumbnailUrl: "https://pexels.example/2-thumb.jpg",
contentUrl: "https://pexels.example/2.jpg",
name: "cozy coffee table 2",
},
{
id: "hit-3",
thumbnailUrl: "https://pexels.example/3-thumb.jpg",
contentUrl: "https://pexels.example/3.jpg",
name: "cozy coffee table 3",
},
],
},
},
];
const container = render(messages);
const media = container.querySelector(
'[data-testid="task-message-preview-media-resource-search:tool-1"]',
);
expect(media).not.toBeNull();
expect(
container.querySelector('img[src="https://pexels.example/1-thumb.jpg"]'),
).toBeTruthy();
expect(
container.querySelector('img[src="https://pexels.example/2-thumb.jpg"]'),
).toBeTruthy();
expect(
container.querySelector('img[src="https://pexels.example/3-thumb.jpg"]'),
).toBeTruthy();
});
it("修图任务消息卡应展示来源图区域与修图语义", () => {
const now = new Date();
const messages: Message[] = [
@@ -1355,17 +1530,16 @@ describe("MessageList", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-browser",
id: "msg-assistant-action",
role: "assistant",
content: "请先完成浏览器登录。",
content: "请先确认文章标题。",
timestamp: now,
actionRequests: [
{
requestId: "req-browser",
requestId: "req-ask-title",
actionType: "ask_user",
uiKind: "browser_preflight",
browserPrepState: "awaiting_user",
prompt: "请先在浏览器完成登录",
prompt: "请先确认文章标题",
questions: [{ question: "这篇文章的最终标题是什么?" }],
},
],
},
@@ -1374,9 +1548,9 @@ describe("MessageList", () => {
render(messages, {
turns: [
{
id: "turn-browser",
id: "turn-action",
thread_id: "thread-1",
prompt_text: "发布到公众号",
prompt_text: "确认文章标题",
status: "aborted",
started_at: "2026-03-15T09:00:00Z",
completed_at: "2026-03-15T09:00:05Z",
@@ -1386,9 +1560,9 @@ describe("MessageList", () => {
],
threadItems: [
{
id: "item-browser-1",
id: "item-action-1",
thread_id: "thread-1",
turn_id: "turn-browser",
turn_id: "turn-action",
sequence: 1,
status: "completed",
started_at: "2026-03-15T09:00:01Z",
@@ -1,4 +1,10 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import React, {
useState,
useRef,
useEffect,
useMemo,
useLayoutEffect,
} from "react";
import {
Copy,
Quote,
@@ -23,6 +29,7 @@ import { StreamingRenderer } from "./StreamingRenderer";
import { TokenUsageDisplay } from "./TokenUsageDisplay";
import { AgentThreadTimeline } from "./AgentThreadTimeline";
import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview";
import { TaskMessagePreview } from "./TaskMessagePreview";
import {
formatArtifactWritePhaseLabel,
resolveArtifactPreviewText,
@@ -38,6 +45,7 @@ import {
type ActionRequired,
type AgentThreadItem,
type AgentThreadTurn,
type MessagePreviewTarget,
type SiteSavedContentTarget,
type WriteArtifactContext,
type PendingA2UISource,
@@ -89,6 +97,8 @@ interface MessageListProps {
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
/** Artifact 点击回调 */
onArtifactClick?: (artifact: Artifact) => void;
/** 打开消息结果预览 */
onOpenMessagePreview?: (target: MessagePreviewTarget, message: Message) => void;
/** 打开子代理会话 */
onOpenSubagentSession?: (sessionId: string) => void;
/** 权限确认响应回调 */
@@ -101,6 +111,8 @@ interface MessageListProps {
onCodeBlockClick?: (language: string, code: string) => void;
/** 是否将待处理问答提升为输入区 A2UI 表单 */
promoteActionRequestsToA2UI?: boolean;
/** 会话是否仍在自动恢复 */
isRestoringSession?: boolean;
/** 中断当前执行 */
onInterruptCurrentTurn?: () => void | Promise<void>;
/** 恢复当前线程排队执行 */
@@ -232,12 +244,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onArtifactClick,
onOpenMessagePreview,
onOpenSubagentSession,
onPermissionResponse,
collapseCodeBlocks,
shouldCollapseCodeBlock,
onCodeBlockClick,
promoteActionRequestsToA2UI = false,
isRestoringSession = false,
compactLeadingSpacing = false,
focusedTimelineItemId = null,
timelineFocusRequestKey = 0,
@@ -245,6 +259,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousVisibleMessageCountRef = useRef<number | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
@@ -321,12 +336,26 @@ const MessageListInner: React.FC<MessageListProps> = ({
};
}, []);
// 智能自动滚动:只在用户没有手动滚动且在底部时才自动滚动
useEffect(() => {
if (shouldAutoScroll && !isUserScrolling && scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" });
// 恢复历史会话时需要在首帧前把视口定位到底部,避免先闪顶部空白再平滑滚动。
useLayoutEffect(() => {
const previousVisibleMessageCount = previousVisibleMessageCountRef.current;
previousVisibleMessageCountRef.current = visibleMessages.length;
if (!shouldAutoScroll || isUserScrolling || !scrollRef.current) {
return;
}
}, [visibleMessages, shouldAutoScroll, isUserScrolling]);
const shouldAnimateScroll =
!isRestoringSession &&
previousVisibleMessageCount !== null &&
previousVisibleMessageCount > 0 &&
visibleMessages.length <= previousVisibleMessageCount + 1;
scrollRef.current.scrollIntoView({
behavior: shouldAnimateScroll ? "smooth" : "auto",
block: "end",
});
}, [visibleMessages, shouldAutoScroll, isUserScrolling, isRestoringSession]);
const handleCopy = async (content: string, id: string) => {
try {
@@ -517,6 +546,35 @@ const MessageListInner: React.FC<MessageListProps> = ({
{msg.imageWorkbenchPreview ? (
<ImageWorkbenchMessagePreview
preview={msg.imageWorkbenchPreview}
onOpen={
onOpenMessagePreview
? (preview) =>
onOpenMessagePreview(
{
kind: "image_workbench",
preview,
},
msg,
)
: undefined
}
/>
) : null}
{msg.taskPreview ? (
<TaskMessagePreview
preview={msg.taskPreview}
onOpen={
onOpenMessagePreview
? (preview) =>
onOpenMessagePreview(
{
kind: "task",
preview,
},
msg,
)
: undefined
}
/>
) : null}
</>
@@ -700,16 +758,36 @@ const MessageListInner: React.FC<MessageListProps> = ({
: "mx-auto flex w-full max-w-[1040px] flex-col gap-4 py-4 pl-4 pr-4"
}
>
{messageGroups.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
)}
{messageGroups.length === 0 &&
(isRestoringSession ? (
<div
className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground"
data-testid="message-list-restoring-session"
role="status"
aria-live="polite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-border/70 bg-background/80 shadow-sm">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
<div className="space-y-1 text-center">
<p className="text-lg font-medium text-foreground">
正在恢复任务中心...
</p>
<p className="text-sm text-muted-foreground">
正在同步最近一次任务会话,请稍候。
</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
))}
{messageGroups.map((group, groupIndex) => {
return (
@@ -0,0 +1,406 @@
import React from "react";
import {
ArrowUpRight,
Clapperboard,
FileText,
Link2,
LoaderCircle,
Mic,
PlayCircle,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { MessageTaskPreview } from "../types";
interface TaskMessagePreviewProps {
preview: MessageTaskPreview;
onOpen?: (preview: MessageTaskPreview) => void;
}
function resolveTaskLabel(preview: MessageTaskPreview): string {
switch (preview.kind) {
case "video_generate":
return "视频生成";
case "broadcast_generate":
return "播报整理";
case "modal_resource_search":
return "素材检索";
case "transcription_generate":
return "内容转写";
case "url_parse":
return "链接解析";
case "typesetting":
return "排版优化";
}
const exhaustiveCheck: never = preview;
return exhaustiveCheck;
}
function resolveStatusLabel(preview: MessageTaskPreview): string {
switch (preview.status) {
case "complete":
return "已完成";
case "partial":
return "部分完成";
case "failed":
return "执行失败";
case "cancelled":
return "已取消";
case "running":
default:
if ((preview.phase || "").trim().toLowerCase() === "queued") {
return "排队中";
}
return "执行中";
}
}
function resolveStatusTone(preview: MessageTaskPreview): string {
switch (preview.status) {
case "complete":
return "border-emerald-200 bg-emerald-50 text-emerald-700";
case "partial":
return "border-amber-200 bg-amber-50 text-amber-700";
case "failed":
return "border-rose-200 bg-rose-50 text-rose-700";
case "cancelled":
return "border-slate-200 bg-slate-100 text-slate-600";
case "running":
default:
return "border-sky-200 bg-sky-50 text-sky-700";
}
}
function resolveDescription(preview: MessageTaskPreview): string {
const statusMessage = preview.statusMessage?.trim();
if (statusMessage) {
return statusMessage;
}
if (preview.kind === "video_generate") {
switch (preview.status) {
case "complete":
return "视频已经生成完成,打开查看即可继续预览和管理任务。";
case "partial":
return "任务返回了部分结果,打开查看可继续确认可用片段。";
case "failed":
return "这次没有拿到可用视频结果,请调整参数后重试。";
case "cancelled":
return "任务已经取消,当前不会继续生成新的结果。";
case "running":
default:
return "任务已提交到异步队列,工作区会继续同步最新生成状态。";
}
}
switch (preview.status) {
case "complete":
case "partial":
return "任务结果已同步,打开查看即可继续处理。";
case "failed":
return "任务执行失败,请调整输入后重试。";
case "cancelled":
return "任务已经取消,当前不会继续执行。";
case "running":
default:
return "任务已进入统一执行主链,工作区会继续同步最新状态。";
}
}
function formatDurationLabel(durationSeconds?: number): string | null {
if (
typeof durationSeconds !== "number" ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
return `${durationSeconds} 秒`;
}
function formatProgressLabel(progress?: number | null): string | null {
if (
typeof progress !== "number" ||
!Number.isFinite(progress) ||
progress <= 0
) {
return null;
}
return `${Math.max(0, Math.min(100, Math.round(progress)))}%`;
}
function buildMetaItems(preview: MessageTaskPreview): string[] {
if (preview.kind === "video_generate") {
const items = [
preview.aspectRatio?.trim(),
preview.resolution?.trim(),
formatDurationLabel(preview.durationSeconds),
formatProgressLabel(preview.progress),
].filter((item): item is string => Boolean(item));
if (preview.model?.trim()) {
items.push(preview.model.trim());
}
return items;
}
const items = [...(preview.metaItems || [])];
if (preview.model?.trim()) {
items.push(preview.model.trim());
}
return items.filter((item) => item.trim().length > 0);
}
function resolveGenericTaskIcon(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
) {
switch (preview.kind) {
case "broadcast_generate":
return Mic;
case "modal_resource_search":
return Search;
case "url_parse":
return Link2;
case "transcription_generate":
case "typesetting":
default:
return FileText;
}
}
function renderGenericTaskMedia(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
Icon: ReturnType<typeof resolveGenericTaskIcon>,
) {
if (
preview.kind === "modal_resource_search" &&
preview.imageCandidates &&
preview.imageCandidates.length > 0
) {
return (
<div
data-testid={`task-message-preview-media-${preview.taskId}`}
className="grid h-20 w-28 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-[18px] border border-slate-200 bg-slate-100 p-1"
>
{preview.imageCandidates.slice(0, 4).map((candidate, index) => (
<div
key={`${candidate.id}-${index}`}
className={cn(
"overflow-hidden rounded-xl bg-slate-200",
preview.imageCandidates!.length === 1 ? "col-span-2" : "",
preview.imageCandidates!.length === 3 && index === 0
? "row-span-2"
: "",
)}
>
<img
src={candidate.thumbnailUrl}
alt={candidate.name || "素材候选"}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
}
return (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-[18px] border border-slate-200 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] text-sky-600">
<Icon className="h-7 w-7" />
</div>
);
}
function renderVideoTaskPreview(
preview: Extract<MessageTaskPreview, { kind: "video_generate" }>,
onOpen?: (preview: MessageTaskPreview) => void,
metaItems: string[] = [],
) {
return (
<button
type="button"
onClick={() => onOpen?.(preview)}
data-testid={`task-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:shadow-slate-950/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusTone(preview),
)}
>
{preview.status === "running" ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Clapperboard className="h-3.5 w-3.5" />
)}
{resolveStatusLabel(preview)}
</span>
<span className="truncate text-[11px] font-semibold text-slate-500">
{resolveTaskLabel(preview)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<span>打开查看</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
<div className="grid gap-3 px-4 pb-4 sm:grid-cols-[220px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50">
{preview.thumbnailUrl ? (
<img
src={preview.thumbnailUrl}
alt={preview.prompt || "视频任务封面"}
className="aspect-[16/10] h-full w-full object-cover"
/>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="flex flex-col items-center gap-2 text-slate-500">
{preview.videoUrl ? (
<PlayCircle className="h-9 w-9 text-sky-500" />
) : (
<Clapperboard className="h-9 w-9 text-sky-500" />
)}
<span className="text-sm font-medium">
{preview.videoUrl
? "已同步视频结果"
: resolveStatusLabel(preview)}
</span>
</div>
</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-3 py-1">
<div className="space-y-1.5">
<div className="line-clamp-2 text-sm font-semibold leading-6 text-slate-900">
{preview.prompt || "视频任务"}
</div>
<p className="text-sm leading-6 text-slate-600">
{resolveDescription(preview)}
</p>
</div>
{metaItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{metaItems.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600"
>
{item}
</span>
))}
</div>
) : null}
{preview.providerId?.trim() ? (
<div className="text-xs text-slate-500">
服务商: {preview.providerId.trim()}
</div>
) : null}
</div>
</div>
</div>
</button>
);
}
function renderGenericTaskPreview(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
onOpen?: (preview: MessageTaskPreview) => void,
metaItems: string[] = [],
) {
const Icon = resolveGenericTaskIcon(preview);
const media = renderGenericTaskMedia(preview, Icon);
return (
<button
type="button"
onClick={() => onOpen?.(preview)}
data-testid={`task-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:shadow-slate-950/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusTone(preview),
)}
>
{preview.status === "running" ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon className="h-3.5 w-3.5" />
)}
{resolveStatusLabel(preview)}
</span>
<span className="truncate text-[11px] font-semibold text-slate-500">
{resolveTaskLabel(preview)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<span>打开查看</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex gap-3 px-4 pb-4">
{media}
<div className="min-w-0 flex-1 space-y-2.5 py-1">
<div className="space-y-1.5">
<div className="line-clamp-2 text-sm font-semibold leading-6 text-slate-900">
{preview.title?.trim() ||
preview.prompt ||
resolveTaskLabel(preview)}
</div>
<p className="text-sm leading-6 text-slate-600">
{resolveDescription(preview)}
</p>
</div>
{metaItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{metaItems.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600"
>
{item}
</span>
))}
</div>
) : null}
{preview.artifactPath?.trim() ? (
<div className="truncate text-xs text-slate-500">
任务文件: {preview.artifactPath.trim()}
</div>
) : null}
</div>
</div>
</div>
</button>
);
}
export const TaskMessagePreview: React.FC<TaskMessagePreviewProps> = ({
preview,
onOpen,
}) => {
const metaItems = buildMetaItems(preview);
if (preview.kind === "video_generate") {
return renderVideoTaskPreview(preview, onOpen, metaItems);
}
return renderGenericTaskPreview(preview, onOpen, metaItems);
};
@@ -1,65 +0,0 @@
import React, { memo, useState } from "react";
import {
ThemeWorkbenchSidebarShell,
type ThemeWorkbenchSidebarTab,
} from "./ThemeWorkbenchSidebarShell";
import { ThemeWorkbenchSidebarPanels } from "./ThemeWorkbenchSidebarPanels";
import { buildThemeWorkbenchSidebarOrchestrationSource } from "./buildThemeWorkbenchSidebarOrchestrationSource";
import { createThemeWorkbenchSidebarOrchestrationInput } from "./themeWorkbenchSidebarOrchestrationContract";
import { type ThemeWorkbenchSidebarProps } from "./themeWorkbenchSidebarContract";
import { areThemeWorkbenchSidebarPropsEqual } from "./themeWorkbenchSidebarComparator";
import { useThemeWorkbenchSidebarOrchestration } from "./useThemeWorkbenchSidebarOrchestration";
function ThemeWorkbenchSidebarComponent({
branchMode = "version",
onRequestCollapse,
headerActionSlot,
topSlot,
...props
}: ThemeWorkbenchSidebarProps) {
const [activeTab, setActiveTab] = useState<ThemeWorkbenchSidebarTab>("context");
const isVersionMode = branchMode === "version";
const orchestrationInput = createThemeWorkbenchSidebarOrchestrationInput(
buildThemeWorkbenchSidebarOrchestrationSource({
isVersionMode,
props,
}),
);
const {
branchCount,
activeContextCount,
visibleExecLogCount,
contextPanelProps,
workflowPanelProps,
execLogProps,
} = useThemeWorkbenchSidebarOrchestration({
activeTab,
input: orchestrationInput,
});
return (
<ThemeWorkbenchSidebarShell
activeTab={activeTab}
isVersionMode={isVersionMode}
activeContextCount={activeContextCount}
branchCount={branchCount}
visibleExecLogCount={visibleExecLogCount}
onTabChange={setActiveTab}
onRequestCollapse={onRequestCollapse}
headerActionSlot={headerActionSlot}
topSlot={topSlot}
>
<ThemeWorkbenchSidebarPanels
activeTab={activeTab}
contextPanelProps={contextPanelProps}
workflowPanelProps={workflowPanelProps}
execLogProps={execLogProps}
/>
</ThemeWorkbenchSidebarShell>
);
}
export const ThemeWorkbenchSidebar = memo(
ThemeWorkbenchSidebarComponent,
areThemeWorkbenchSidebarPropsEqual,
);
@@ -1,28 +0,0 @@
import { ThemeWorkbenchContextPanel } from "./ThemeWorkbenchContextPanel";
import { ThemeWorkbenchExecLog } from "./ThemeWorkbenchExecLog";
import { ThemeWorkbenchWorkflowPanel } from "./ThemeWorkbenchWorkflowPanel";
import type { ThemeWorkbenchSidebarTab } from "./ThemeWorkbenchSidebarShell";
import type { ThemeWorkbenchSidebarContentProps } from "./themeWorkbenchSidebarContentContract";
export interface ThemeWorkbenchSidebarPanelsProps
extends ThemeWorkbenchSidebarContentProps {
activeTab: ThemeWorkbenchSidebarTab;
}
export function ThemeWorkbenchSidebarPanels({
activeTab,
contextPanelProps,
workflowPanelProps,
execLogProps,
}: ThemeWorkbenchSidebarPanelsProps) {
if (activeTab === "context") {
return <ThemeWorkbenchContextPanel {...contextPanelProps} />;
}
if (activeTab === "workflow") {
return <ThemeWorkbenchWorkflowPanel {...workflowPanelProps} />;
}
if (activeTab === "log") {
return <ThemeWorkbenchExecLog {...execLogProps} />;
}
return null;
}
@@ -1,140 +0,0 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchSkillsPanel } from "./ThemeWorkbenchSkillsPanel";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
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 renderPanel(
props?: Partial<React.ComponentProps<typeof ThemeWorkbenchSkillsPanel>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof ThemeWorkbenchSkillsPanel> = {
skills: [
{
key: "content_post_with_cover",
name: "content_post_with_cover",
description: "内容文案与封面生成",
directory: "content_post_with_cover",
installed: true,
sourceKind: "builtin",
},
{
key: "research",
name: "research",
description: "信息检索与趋势分析",
directory: "research",
installed: true,
sourceKind: "builtin",
},
{
key: "typesetting",
name: "typesetting",
description: "主稿排版与润色",
directory: "typesetting",
installed: true,
sourceKind: "builtin",
},
],
currentGate: {
key: "topic_select",
title: "选题闸门",
status: "waiting",
description: "请确认本轮选题方向",
},
workspaceSummary: {
activeContextCount: 2,
searchResultCount: 5,
versionCount: 3,
runState: "await_user_decision",
},
onTriggerSkill: vi.fn(),
};
act(() => {
root.render(<ThemeWorkbenchSkillsPanel {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return {
container,
props: { ...defaultProps, ...props },
};
}
describe("ThemeWorkbenchSkillsPanel", () => {
it("传入折叠回调时应显示折叠按钮并可触发", () => {
const onRequestCollapse = vi.fn();
const { container } = renderPanel({ onRequestCollapse });
const collapseButton = container.querySelector(
'button[aria-label="折叠操作面板"]',
) as HTMLButtonElement | null;
expect(collapseButton).toBeTruthy();
if (collapseButton) {
act(() => {
collapseButton.click();
});
}
expect(onRequestCollapse).toHaveBeenCalledTimes(1);
});
it("应显示操作面板、阶段摘要、推荐动作与统计信息", () => {
const { container } = renderPanel();
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("版本快照");
expect(container.textContent).toContain("待决策");
expect(container.textContent).toContain("research");
expect(container.textContent).toContain("content_post_with_cover");
});
it("点击推荐技能应触发 onTriggerSkill 回调", () => {
const onTriggerSkill = vi.fn();
const { container } = renderPanel({ onTriggerSkill });
const skillButton = container.querySelector(
'button[aria-label="执行技能 research"]',
) as HTMLButtonElement | null;
expect(skillButton).not.toBeNull();
if (skillButton) {
act(() => {
skillButton.click();
});
}
expect(onTriggerSkill).toHaveBeenCalledTimes(1);
expect(onTriggerSkill.mock.calls[0][0]?.key).toBe("research");
});
});
@@ -1,492 +0,0 @@
import { useMemo } from "react";
import {
ChevronRight,
FileText,
Image,
PanelRightClose,
Search,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Skill } from "@/lib/api/skills";
import { CONTENT_POST_SKILL_KEY } from "../utils/contentPostSkill";
type SkillGroupKey = "text" | "visual" | "audio" | "video" | "resource";
type ThemeWorkbenchRunState = "idle" | "auto_running" | "await_user_decision";
interface SkillGroup {
key: SkillGroupKey;
title: string;
items: Skill[];
}
interface CurrentGate {
key: string;
title: string;
status: "running" | "waiting" | "idle" | "done";
description: string;
}
interface ThemeWorkbenchWorkspaceSummary {
activeContextCount: number;
searchResultCount: number;
versionCount: number;
runState: ThemeWorkbenchRunState;
}
const PANEL_CLASSNAME =
"flex h-full w-[320px] min-w-[320px] flex-col overflow-hidden border-l border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.94)_0%,rgba(241,245,249,0.82)_100%)]";
const HEADER_CLASSNAME =
"border-b border-slate-200/80 bg-white/88 px-4 py-3 backdrop-blur-sm";
const SECTION_CLASSNAME = "border-b border-slate-200/80 px-4 py-3";
const SCROLL_SECTION_CLASSNAME =
"flex-1 min-h-0 overflow-y-auto px-4 py-3 [scrollbar-gutter:stable]";
const SECTION_TITLE_CLASSNAME =
"mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-slate-500";
const CARD_CLASSNAME =
"rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.92)_100%)] p-3 shadow-sm shadow-slate-950/5";
const METRIC_CARD_CLASSNAME =
"rounded-[16px] border border-slate-200/80 bg-white/94 p-3";
const ACTION_CARD_CLASSNAME =
"rounded-[18px] border p-3 shadow-sm shadow-slate-950/5 transition-colors";
const ACTION_BUTTON_CLASSNAME =
"mt-3 inline-flex h-9 w-full items-center justify-center rounded-full border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50";
function getGateStatusClassName(status: CurrentGate["status"]) {
return cn(
"ml-auto inline-flex min-h-6 items-center rounded-full border px-2.5 text-[10px] font-semibold",
status === "waiting" &&
"border-amber-200 bg-amber-50/90 text-amber-700",
status === "running" && "border-sky-200 bg-sky-50/90 text-sky-700",
status === "idle" && "border-slate-200 bg-slate-100/90 text-slate-600",
status === "done" &&
"border-emerald-200 bg-emerald-50/90 text-emerald-700",
);
}
function getActionCardClassName(featured = false) {
return cn(
ACTION_CARD_CLASSNAME,
featured
? "border-emerald-200/90 bg-[linear-gradient(180deg,rgba(236,253,245,0.92)_0%,rgba(255,255,255,0.98)_100%)]"
: "border-slate-200/80 bg-white/92 hover:border-slate-300 hover:bg-white",
);
}
function getActionIconClassName(featured = false) {
return cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl border",
featured
? "border-emerald-200 bg-emerald-100/90 text-emerald-700"
: "border-slate-200 bg-slate-100/90 text-slate-600",
);
}
function getActionButtonClassName(featured = false) {
return cn(
ACTION_BUTTON_CLASSNAME,
featured
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-100/80"
: "border-slate-200/80 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900",
);
}
function resolveSkillGroup(skill: Skill): SkillGroupKey {
const feature = `${skill.key} ${skill.name} ${skill.description}`.toLowerCase();
if (
feature.includes("cover") ||
feature.includes("image") ||
feature.includes("illustration")
) {
return "visual";
}
if (
feature.includes("broadcast") ||
feature.includes("audio") ||
feature.includes("podcast")
) {
return "audio";
}
if (feature.includes("video")) {
return "video";
}
if (
feature.includes("resource") ||
feature.includes("research") ||
feature.includes("library") ||
feature.includes("url") ||
feature.includes("search")
) {
return "resource";
}
return "text";
}
function getGroupTitle(groupKey: SkillGroupKey): string {
if (groupKey === "text") return "文字能力";
if (groupKey === "visual") return "视觉能力";
if (groupKey === "audio") return "音频能力";
if (groupKey === "video") return "视频能力";
return "检索与资源";
}
function resolveGateStatusText(status: CurrentGate["status"]): string {
if (status === "waiting") return "等待决策";
if (status === "running") return "自动执行";
if (status === "idle") return "待启动";
return "已完成";
}
function resolveRunStateText(runState: ThemeWorkbenchRunState): string {
if (runState === "auto_running") return "执行中";
if (runState === "await_user_decision") return "待决策";
return "空闲";
}
function resolveSkillIcon(skill: Skill): LucideIcon {
const group = resolveSkillGroup(skill);
if (group === "resource") {
return Search;
}
if (group === "visual") {
return Image;
}
if (group === "text") {
return FileText;
}
return Sparkles;
}
function resolveSkillActionLabel(skill: Skill): string {
const group = resolveSkillGroup(skill);
if (group === "resource") {
return "开始检索";
}
if (group === "visual") {
return "生成素材";
}
return "立即执行";
}
function buildSkillFeatureProbe(skill: Skill): string {
return `${skill.key} ${skill.name} ${skill.description || ""}`.toLowerCase();
}
function pickRecommendedSkills(skills: Skill[], gateKey: string): Skill[] {
const tagsByGate: Record<string, string[]> = {
topic_select: ["research", CONTENT_POST_SKILL_KEY],
write_mode: [CONTENT_POST_SKILL_KEY, "typesetting", "cover"],
publish_confirm: ["typesetting", "cover", CONTENT_POST_SKILL_KEY],
};
const preferredTags = tagsByGate[gateKey] || [CONTENT_POST_SKILL_KEY, "research"];
const selected: Skill[] = [];
preferredTags.forEach((tag) => {
const found = skills.find((skill) => {
if (selected.some((item) => item.key === skill.key)) {
return false;
}
return buildSkillFeatureProbe(skill).includes(tag);
});
if (found) {
selected.push(found);
}
});
if (selected.length < 2) {
skills.forEach((skill) => {
if (selected.length >= 2) {
return;
}
if (!selected.some((item) => item.key === skill.key)) {
selected.push(skill);
}
});
}
return selected.slice(0, 2);
}
interface ThemeWorkbenchSkillsPanelProps {
skills: Skill[];
currentGate: CurrentGate;
disabled?: boolean;
workspaceSummary?: ThemeWorkbenchWorkspaceSummary;
onTriggerSkill?: (skill: Skill) => void;
onRequestCollapse?: () => void;
}
export function ThemeWorkbenchSkillsPanel({
skills,
currentGate,
disabled = false,
workspaceSummary,
onTriggerSkill,
onRequestCollapse,
}: ThemeWorkbenchSkillsPanelProps) {
const fallbackSkills: Skill[] = useMemo(
() => [
{
key: CONTENT_POST_SKILL_KEY,
name: CONTENT_POST_SKILL_KEY,
description: "内容主稿与封面图生成",
directory: CONTENT_POST_SKILL_KEY,
installed: true,
sourceKind: "builtin",
},
{
key: "cover_generate",
name: "cover_generate",
description: "封面图生成",
directory: "cover_generate",
installed: true,
sourceKind: "builtin",
},
{
key: "research",
name: "research",
description: "信息检索与趋势分析",
directory: "research",
installed: true,
sourceKind: "builtin",
},
{
key: "typesetting",
name: "typesetting",
description: "主稿排版与润色",
directory: "typesetting",
installed: true,
sourceKind: "builtin",
},
],
[],
);
const availableSkills = useMemo(() => {
const installed = skills.filter((skill) => skill.installed);
return installed.length > 0 ? installed : fallbackSkills;
}, [fallbackSkills, skills]);
const recommendedSkills = useMemo(
() => pickRecommendedSkills(availableSkills, currentGate.key),
[availableSkills, currentGate.key],
);
const groupedSkills = useMemo<SkillGroup[]>(() => {
const recommendedSkillKeys = new Set(
recommendedSkills.map((skill) => skill.key),
);
const buckets: Record<SkillGroupKey, Skill[]> = {
text: [],
visual: [],
audio: [],
video: [],
resource: [],
};
availableSkills.forEach((skill) => {
if (recommendedSkillKeys.has(skill.key)) {
return;
}
buckets[resolveSkillGroup(skill)].push(skill);
});
return (Object.keys(buckets) as SkillGroupKey[])
.map((key) => ({
key,
title: getGroupTitle(key),
items: buckets[key],
}))
.filter((group) => group.items.length > 0);
}, [availableSkills, recommendedSkills]);
return (
<aside className={PANEL_CLASSNAME}>
<div className={HEADER_CLASSNAME}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-slate-500">
Theme Workbench
</div>
<div className="mt-1 text-base font-semibold text-slate-900">
操作面板
</div>
</div>
{onRequestCollapse ? (
<button
type="button"
aria-label="折叠操作面板"
onClick={onRequestCollapse}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white text-slate-500 shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
<PanelRightClose size={16} />
</button>
) : null}
</div>
<div className="mt-2 text-[12px] leading-5 text-slate-500">
右侧聚焦当前阶段推荐动作,中间主稿区保持结果优先,减少来回跳转。
</div>
</div>
<section className={SECTION_CLASSNAME}>
<div className={SECTION_TITLE_CLASSNAME}>阶段摘要</div>
<div className={CARD_CLASSNAME}>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-xl border border-slate-200 bg-slate-100/90 text-slate-600">
<ChevronRight size={14} />
</div>
<div className="min-w-0 text-sm font-semibold text-slate-900">
{currentGate.title}
</div>
<span className={getGateStatusClassName(currentGate.status)}>
{resolveGateStatusText(currentGate.status)}
</span>
</div>
<div className="mt-2 text-[12px] leading-5 text-slate-500">
{currentGate.description}
</div>
{workspaceSummary ? (
<div className="mt-3 grid grid-cols-2 gap-2">
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.activeContextCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
启用上下文
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.searchResultCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
搜索结果
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.versionCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
版本快照
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{resolveRunStateText(workspaceSummary.runState)}
</div>
<div className="mt-1 text-[11px] text-slate-500">
运行状态
</div>
</div>
</div>
) : null}
<div className="mt-3 text-[11px] leading-5 text-slate-500">
{disabled
? "当前有任务执行中,建议等当前处理完成后再触发新的技能。"
: "先看推荐动作,再按需要选择更多能力,避免重复操作。"}
</div>
</div>
</section>
<section className={SCROLL_SECTION_CLASSNAME}>
<div className={SECTION_TITLE_CLASSNAME}>推荐动作</div>
<div className="flex flex-col gap-3">
{recommendedSkills.map((skill) => {
const Icon = resolveSkillIcon(skill);
return (
<div key={skill.key} className={getActionCardClassName(true)}>
<div className="flex items-start gap-3">
<div className={getActionIconClassName(true)}>
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold leading-5 text-slate-900">
{skill.name}
</div>
<div className="mt-1 text-[12px] leading-5 text-slate-500">
{skill.description || "使用当前能力继续推进当前工作台任务。"}
</div>
<span className="mt-2 inline-flex min-h-6 items-center rounded-full border border-emerald-200 bg-emerald-50/90 px-2.5 text-[10px] font-semibold text-emerald-700">
推荐优先执行
</span>
</div>
</div>
<button
type="button"
aria-label={`执行技能 ${skill.key}`}
disabled={disabled}
onClick={() => onTriggerSkill?.(skill)}
className={getActionButtonClassName(true)}
>
{resolveSkillActionLabel(skill)}
</button>
</div>
);
})}
</div>
<div className="mt-4 text-[12px] font-semibold text-slate-900">
可执行能力
</div>
{groupedSkills.length === 0 ? (
<div className="mt-2 text-[11px] leading-5 text-slate-500">
当前可用技能已全部展示在推荐动作中,可直接开始执行。
</div>
) : (
<div className="mt-3 space-y-4">
{groupedSkills.map((group) => (
<div key={group.key}>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-slate-500">
{group.title}
</div>
<div className="flex flex-col gap-3">
{group.items.map((skill) => {
const Icon = resolveSkillIcon(skill);
return (
<div key={skill.key} className={getActionCardClassName()}>
<div className="flex items-start gap-3">
<div className={getActionIconClassName()}>
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold leading-5 text-slate-900">
{skill.name}
</div>
<div className="mt-1 text-[12px] leading-5 text-slate-500">
{skill.description ||
"使用当前能力继续处理工作台内容。"}
</div>
</div>
</div>
<button
type="button"
aria-label={`执行技能 ${skill.key}`}
disabled={disabled}
onClick={() => onTriggerSkill?.(skill)}
className={getActionButtonClassName()}
>
{resolveSkillActionLabel(skill)}
</button>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</section>
</aside>
);
}
@@ -1,23 +1,23 @@
import type { ThemeWorkbenchSidebarContextPanelProps } from "./themeWorkbenchSidebarContentContract";
import type { ThemeWorkbenchContextPanelState } from "./useThemeWorkbenchContextPanelState";
import type { GeneralWorkbenchSidebarContextPanelProps } from "./generalWorkbenchSidebarContentContract";
import type { GeneralWorkbenchContextPanelState } from "./useGeneralWorkbenchContextPanelState";
export interface BuildThemeWorkbenchContextPanelPropsParams {
contextPanelState: ThemeWorkbenchContextPanelState;
contextBudget: ThemeWorkbenchSidebarContextPanelProps["contextBudget"];
contextItems: ThemeWorkbenchSidebarContextPanelProps["contextItems"];
contextSearchBlockedReason?: ThemeWorkbenchSidebarContextPanelProps["contextSearchBlockedReason"];
contextSearchError?: ThemeWorkbenchSidebarContextPanelProps["contextSearchError"];
contextSearchLoading: ThemeWorkbenchSidebarContextPanelProps["contextSearchLoading"];
contextSearchMode: ThemeWorkbenchSidebarContextPanelProps["contextSearchMode"];
contextSearchQuery: ThemeWorkbenchSidebarContextPanelProps["contextSearchQuery"];
onContextSearchModeChange: ThemeWorkbenchSidebarContextPanelProps["onContextSearchModeChange"];
onContextSearchQueryChange: ThemeWorkbenchSidebarContextPanelProps["onContextSearchQueryChange"];
onSubmitContextSearch: ThemeWorkbenchSidebarContextPanelProps["onSubmitContextSearch"];
onToggleContextActive: ThemeWorkbenchSidebarContextPanelProps["onToggleContextActive"];
onViewContextDetail?: ThemeWorkbenchSidebarContextPanelProps["onViewContextDetail"];
export interface BuildGeneralWorkbenchContextPanelPropsParams {
contextPanelState: GeneralWorkbenchContextPanelState;
contextBudget: GeneralWorkbenchSidebarContextPanelProps["contextBudget"];
contextItems: GeneralWorkbenchSidebarContextPanelProps["contextItems"];
contextSearchBlockedReason?: GeneralWorkbenchSidebarContextPanelProps["contextSearchBlockedReason"];
contextSearchError?: GeneralWorkbenchSidebarContextPanelProps["contextSearchError"];
contextSearchLoading: GeneralWorkbenchSidebarContextPanelProps["contextSearchLoading"];
contextSearchMode: GeneralWorkbenchSidebarContextPanelProps["contextSearchMode"];
contextSearchQuery: GeneralWorkbenchSidebarContextPanelProps["contextSearchQuery"];
onContextSearchModeChange: GeneralWorkbenchSidebarContextPanelProps["onContextSearchModeChange"];
onContextSearchQueryChange: GeneralWorkbenchSidebarContextPanelProps["onContextSearchQueryChange"];
onSubmitContextSearch: GeneralWorkbenchSidebarContextPanelProps["onSubmitContextSearch"];
onToggleContextActive: GeneralWorkbenchSidebarContextPanelProps["onToggleContextActive"];
onViewContextDetail?: GeneralWorkbenchSidebarContextPanelProps["onViewContextDetail"];
}
export function buildThemeWorkbenchContextPanelProps({
export function buildGeneralWorkbenchContextPanelProps({
contextBudget,
contextItems,
contextPanelState,
@@ -31,7 +31,7 @@ export function buildThemeWorkbenchContextPanelProps({
onSubmitContextSearch,
onToggleContextActive,
onViewContextDetail,
}: BuildThemeWorkbenchContextPanelPropsParams): ThemeWorkbenchSidebarContextPanelProps {
}: BuildGeneralWorkbenchContextPanelPropsParams): GeneralWorkbenchSidebarContextPanelProps {
return {
contextItems,
searchContextItems: contextPanelState.searchContextItems,
@@ -0,0 +1,26 @@
import type { GeneralWorkbenchSidebarExecLogProps } from "./generalWorkbenchSidebarContentContract";
import type { GeneralWorkbenchExecLogState } from "./useGeneralWorkbenchExecLogState";
export interface BuildGeneralWorkbenchExecLogPropsParams {
execLogState: GeneralWorkbenchExecLogState;
historyHasMore?: GeneralWorkbenchSidebarExecLogProps["historyHasMore"];
historyLoading?: GeneralWorkbenchSidebarExecLogProps["historyLoading"];
onLoadMoreHistory?: GeneralWorkbenchSidebarExecLogProps["onLoadMoreHistory"];
}
export function buildGeneralWorkbenchExecLogProps({
execLogState,
historyHasMore = false,
historyLoading = false,
onLoadMoreHistory,
}: BuildGeneralWorkbenchExecLogPropsParams): GeneralWorkbenchSidebarExecLogProps {
return {
entries: execLogState.visibleExecLogEntries,
totalEntriesCount: execLogState.execLogEntries.length,
wasCleared: execLogState.wasExecLogCleared,
onClear: execLogState.clearExecLog,
onLoadMoreHistory,
historyHasMore,
historyLoading,
};
}
@@ -1,24 +1,24 @@
import type {
ThemeWorkbenchSidebarContextContract,
ThemeWorkbenchSidebarExecLogContract,
ThemeWorkbenchSidebarWorkflowContract,
} from "./themeWorkbenchSidebarContract";
import type { ThemeWorkbenchSidebarOrchestrationSource } from "./themeWorkbenchSidebarOrchestrationContract";
GeneralWorkbenchSidebarContextContract,
GeneralWorkbenchSidebarExecLogContract,
GeneralWorkbenchSidebarWorkflowContract,
} from "./generalWorkbenchSidebarContract";
import type { GeneralWorkbenchSidebarOrchestrationSource } from "./generalWorkbenchSidebarOrchestrationContract";
type ThemeWorkbenchSidebarOrchestrationSourceProps =
ThemeWorkbenchSidebarContextContract &
ThemeWorkbenchSidebarWorkflowContract &
ThemeWorkbenchSidebarExecLogContract;
type GeneralWorkbenchSidebarOrchestrationSourceProps =
GeneralWorkbenchSidebarContextContract &
GeneralWorkbenchSidebarWorkflowContract &
GeneralWorkbenchSidebarExecLogContract;
interface BuildThemeWorkbenchSidebarOrchestrationSourceParams {
interface BuildGeneralWorkbenchSidebarOrchestrationSourceParams {
isVersionMode: boolean;
props: ThemeWorkbenchSidebarOrchestrationSourceProps;
props: GeneralWorkbenchSidebarOrchestrationSourceProps;
}
export function buildThemeWorkbenchSidebarOrchestrationSource({
export function buildGeneralWorkbenchSidebarOrchestrationSource({
isVersionMode,
props,
}: BuildThemeWorkbenchSidebarOrchestrationSourceParams): ThemeWorkbenchSidebarOrchestrationSource {
}: BuildGeneralWorkbenchSidebarOrchestrationSourceParams): GeneralWorkbenchSidebarOrchestrationSource {
return {
isVersionMode,
context: {
@@ -0,0 +1,77 @@
import type { GeneralWorkbenchSidebarWorkflowPanelProps } from "./generalWorkbenchSidebarContentContract";
import { writeGeneralWorkbenchClipboardText } from "./generalWorkbenchSidebarShared";
import type { GeneralWorkbenchWorkflowPanelState } from "./useGeneralWorkbenchWorkflowPanelState";
export interface BuildGeneralWorkbenchWorkflowPanelPropsParams {
isVersionMode: GeneralWorkbenchSidebarWorkflowPanelProps["isVersionMode"];
activeRunDetail: GeneralWorkbenchSidebarWorkflowPanelProps["activeRunDetail"];
activeRunDetailLoading: GeneralWorkbenchSidebarWorkflowPanelProps["activeRunDetailLoading"];
branchItems: GeneralWorkbenchSidebarWorkflowPanelProps["branchItems"];
creationTaskEventsCount: GeneralWorkbenchSidebarWorkflowPanelProps["creationTaskEventsCount"];
onAddImage?: GeneralWorkbenchSidebarWorkflowPanelProps["onAddImage"];
onDeleteTopic: GeneralWorkbenchSidebarWorkflowPanelProps["onDeleteTopic"];
onImportDocument?: GeneralWorkbenchSidebarWorkflowPanelProps["onImportDocument"];
onNewTopic: GeneralWorkbenchSidebarWorkflowPanelProps["onNewTopic"];
onOpenArtifactWithDefaultApp: (
artifactPath: string,
sessionId?: string | null,
) => Promise<void> | void;
onRevealArtifactInFinder: (
artifactPath: string,
sessionId?: string | null,
) => Promise<void> | void;
onSetBranchStatus: GeneralWorkbenchSidebarWorkflowPanelProps["onSetBranchStatus"];
onSwitchTopic: GeneralWorkbenchSidebarWorkflowPanelProps["onSwitchTopic"];
onViewRunDetail?: GeneralWorkbenchSidebarWorkflowPanelProps["onViewRunDetail"];
workflowPanelState: GeneralWorkbenchWorkflowPanelState;
workflowSteps: GeneralWorkbenchSidebarWorkflowPanelProps["workflowSteps"];
}
export function buildGeneralWorkbenchWorkflowPanelProps({
isVersionMode,
branchItems,
creationTaskEventsCount,
onAddImage,
onDeleteTopic,
onImportDocument,
onNewTopic,
onOpenArtifactWithDefaultApp,
onRevealArtifactInFinder,
onSetBranchStatus,
onSwitchTopic,
onViewRunDetail,
workflowPanelState,
workflowSteps,
activeRunDetail,
activeRunDetailLoading,
}: BuildGeneralWorkbenchWorkflowPanelPropsParams): GeneralWorkbenchSidebarWorkflowPanelProps {
return {
isVersionMode,
onNewTopic,
onSwitchTopic,
onDeleteTopic,
branchItems,
onSetBranchStatus,
workflowSteps,
completedSteps: workflowPanelState.completedSteps,
progressPercent: workflowPanelState.progressPercent,
onAddImage,
onImportDocument,
creationTaskEventsCount,
showCreationTasks: workflowPanelState.showCreationTasks,
onToggleCreationTasks: workflowPanelState.toggleCreationTasks,
groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents,
showActivityLogs: workflowPanelState.showActivityLogs,
onToggleActivityLogs: workflowPanelState.toggleActivityLogs,
groupedActivityLogs: workflowPanelState.groupedActivityLogs,
onViewRunDetail,
activeRunDetail,
activeRunDetailLoading,
activeRunStagesLabel: workflowPanelState.activeRunStagesLabel,
runMetadataText: workflowPanelState.runMetadataText,
runMetadataSummary: workflowPanelState.runMetadataSummary,
onCopyText: writeGeneralWorkbenchClipboardText,
onRevealArtifactInFinder,
onOpenArtifactWithDefaultApp,
};
}
@@ -1,26 +0,0 @@
import type { ThemeWorkbenchSidebarExecLogProps } from "./themeWorkbenchSidebarContentContract";
import type { ThemeWorkbenchExecLogState } from "./useThemeWorkbenchExecLogState";
export interface BuildThemeWorkbenchExecLogPropsParams {
execLogState: ThemeWorkbenchExecLogState;
historyHasMore?: ThemeWorkbenchSidebarExecLogProps["historyHasMore"];
historyLoading?: ThemeWorkbenchSidebarExecLogProps["historyLoading"];
onLoadMoreHistory?: ThemeWorkbenchSidebarExecLogProps["onLoadMoreHistory"];
}
export function buildThemeWorkbenchExecLogProps({
execLogState,
historyHasMore = false,
historyLoading = false,
onLoadMoreHistory,
}: BuildThemeWorkbenchExecLogPropsParams): ThemeWorkbenchSidebarExecLogProps {
return {
entries: execLogState.visibleExecLogEntries,
totalEntriesCount: execLogState.execLogEntries.length,
wasCleared: execLogState.wasExecLogCleared,
onClear: execLogState.clearExecLog,
onLoadMoreHistory,
historyHasMore,
historyLoading,
};
}
@@ -1,77 +0,0 @@
import type { ThemeWorkbenchSidebarWorkflowPanelProps } from "./themeWorkbenchSidebarContentContract";
import { writeThemeWorkbenchClipboardText } from "./themeWorkbenchSidebarShared";
import type { ThemeWorkbenchWorkflowPanelState } from "./useThemeWorkbenchWorkflowPanelState";
export interface BuildThemeWorkbenchWorkflowPanelPropsParams {
isVersionMode: ThemeWorkbenchSidebarWorkflowPanelProps["isVersionMode"];
activeRunDetail: ThemeWorkbenchSidebarWorkflowPanelProps["activeRunDetail"];
activeRunDetailLoading: ThemeWorkbenchSidebarWorkflowPanelProps["activeRunDetailLoading"];
branchItems: ThemeWorkbenchSidebarWorkflowPanelProps["branchItems"];
creationTaskEventsCount: ThemeWorkbenchSidebarWorkflowPanelProps["creationTaskEventsCount"];
onAddImage?: ThemeWorkbenchSidebarWorkflowPanelProps["onAddImage"];
onDeleteTopic: ThemeWorkbenchSidebarWorkflowPanelProps["onDeleteTopic"];
onImportDocument?: ThemeWorkbenchSidebarWorkflowPanelProps["onImportDocument"];
onNewTopic: ThemeWorkbenchSidebarWorkflowPanelProps["onNewTopic"];
onOpenArtifactWithDefaultApp: (
artifactPath: string,
sessionId?: string | null,
) => Promise<void> | void;
onRevealArtifactInFinder: (
artifactPath: string,
sessionId?: string | null,
) => Promise<void> | void;
onSetBranchStatus: ThemeWorkbenchSidebarWorkflowPanelProps["onSetBranchStatus"];
onSwitchTopic: ThemeWorkbenchSidebarWorkflowPanelProps["onSwitchTopic"];
onViewRunDetail?: ThemeWorkbenchSidebarWorkflowPanelProps["onViewRunDetail"];
workflowPanelState: ThemeWorkbenchWorkflowPanelState;
workflowSteps: ThemeWorkbenchSidebarWorkflowPanelProps["workflowSteps"];
}
export function buildThemeWorkbenchWorkflowPanelProps({
isVersionMode,
branchItems,
creationTaskEventsCount,
onAddImage,
onDeleteTopic,
onImportDocument,
onNewTopic,
onOpenArtifactWithDefaultApp,
onRevealArtifactInFinder,
onSetBranchStatus,
onSwitchTopic,
onViewRunDetail,
workflowPanelState,
workflowSteps,
activeRunDetail,
activeRunDetailLoading,
}: BuildThemeWorkbenchWorkflowPanelPropsParams): ThemeWorkbenchSidebarWorkflowPanelProps {
return {
isVersionMode,
onNewTopic,
onSwitchTopic,
onDeleteTopic,
branchItems,
onSetBranchStatus,
workflowSteps,
completedSteps: workflowPanelState.completedSteps,
progressPercent: workflowPanelState.progressPercent,
onAddImage,
onImportDocument,
creationTaskEventsCount,
showCreationTasks: workflowPanelState.showCreationTasks,
onToggleCreationTasks: workflowPanelState.toggleCreationTasks,
groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents,
showActivityLogs: workflowPanelState.showActivityLogs,
onToggleActivityLogs: workflowPanelState.toggleActivityLogs,
groupedActivityLogs: workflowPanelState.groupedActivityLogs,
onViewRunDetail,
activeRunDetail,
activeRunDetailLoading,
activeRunStagesLabel: workflowPanelState.activeRunStagesLabel,
runMetadataText: workflowPanelState.runMetadataText,
runMetadataSummary: workflowPanelState.runMetadataSummary,
onCopyText: writeThemeWorkbenchClipboardText,
onRevealArtifactInFinder,
onOpenArtifactWithDefaultApp,
};
}
@@ -1,4 +1,4 @@
export interface ThemeWorkbenchContextItem {
export interface GeneralWorkbenchContextItem {
id: string;
name: string;
source: "material" | "content" | "search";
@@ -10,28 +10,28 @@ export interface ThemeWorkbenchContextItem {
active: boolean;
}
export interface ThemeWorkbenchContextBudget {
export interface GeneralWorkbenchContextBudget {
activeCount: number;
activeCountLimit: number;
estimatedTokens: number;
tokenLimit: number;
}
export function buildThemeWorkbenchActiveContextItems(
contextItems: ThemeWorkbenchContextItem[],
): ThemeWorkbenchContextItem[] {
export function buildGeneralWorkbenchActiveContextItems(
contextItems: GeneralWorkbenchContextItem[],
): GeneralWorkbenchContextItem[] {
return contextItems.filter((item) => item.active);
}
export function buildThemeWorkbenchSearchContextItems(
contextItems: ThemeWorkbenchContextItem[],
): ThemeWorkbenchContextItem[] {
export function buildGeneralWorkbenchSearchContextItems(
contextItems: GeneralWorkbenchContextItem[],
): GeneralWorkbenchContextItem[] {
return contextItems.filter((item) => item.source === "search");
}
export function buildThemeWorkbenchOrderedContextItems(
contextItems: ThemeWorkbenchContextItem[],
): ThemeWorkbenchContextItem[] {
export function buildGeneralWorkbenchOrderedContextItems(
contextItems: GeneralWorkbenchContextItem[],
): GeneralWorkbenchContextItem[] {
return [...contextItems].sort((left, right) => {
if (left.active !== right.active) {
return left.active ? -1 : 1;
@@ -47,8 +47,8 @@ export function buildThemeWorkbenchOrderedContextItems(
});
}
export function resolveThemeWorkbenchLatestSearchLabel(
searchContextItems: ThemeWorkbenchContextItem[],
export function resolveGeneralWorkbenchLatestSearchLabel(
searchContextItems: GeneralWorkbenchContextItem[],
): string {
if (searchContextItems.length === 0) {
return "尚未联网检索";
@@ -72,10 +72,10 @@ export function resolveThemeWorkbenchLatestSearchLabel(
})}`;
}
export function resolveThemeWorkbenchSelectedSearchResult(
searchContextItems: ThemeWorkbenchContextItem[],
export function resolveGeneralWorkbenchSelectedSearchResult(
searchContextItems: GeneralWorkbenchContextItem[],
selectedSearchResultId: string | null,
): ThemeWorkbenchContextItem | null {
): GeneralWorkbenchContextItem | null {
if (!selectedSearchResultId) {
return null;
}
@@ -3,17 +3,17 @@ import type { Message } from "../types";
import type {
ExecLogEntry,
ExecLogEntryDetail,
} from "./ThemeWorkbenchExecLog";
} from "./GeneralWorkbenchExecLog";
import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo";
import type {
ThemeWorkbenchActivityLogGroup,
ThemeWorkbenchCreationTaskGroup,
} from "./themeWorkbenchWorkflowData";
GeneralWorkbenchActivityLogGroup,
GeneralWorkbenchCreationTaskGroup,
} from "./generalWorkbenchWorkflowData";
interface BuildThemeWorkbenchExecLogEntriesParams {
interface BuildGeneralWorkbenchExecLogEntriesParams {
messages: Message[];
groupedActivityLogs: ThemeWorkbenchActivityLogGroup[];
groupedCreationTaskEvents: ThemeWorkbenchCreationTaskGroup[];
groupedActivityLogs: GeneralWorkbenchActivityLogGroup[];
groupedCreationTaskEvents: GeneralWorkbenchCreationTaskGroup[];
skillDetailMap: Record<string, SkillDetailInfo | null>;
}
@@ -127,7 +127,7 @@ function buildToolCallEntry(
}
function buildRunEntry(
group: ThemeWorkbenchActivityLogGroup,
group: GeneralWorkbenchActivityLogGroup,
skillDetailMap: Record<string, SkillDetailInfo | null>,
): ExecLogEntry {
const timestamp = buildTimestampFromActivityTimeLabel(group.timeLabel);
@@ -188,12 +188,12 @@ function buildRunEntry(
};
}
export function buildThemeWorkbenchExecLogEntries({
export function buildGeneralWorkbenchExecLogEntries({
messages,
groupedActivityLogs,
groupedCreationTaskEvents,
skillDetailMap,
}: BuildThemeWorkbenchExecLogEntriesParams): ExecLogEntry[] {
}: BuildGeneralWorkbenchExecLogEntriesParams): ExecLogEntry[] {
const entries: ExecLogEntry[] = [];
let toolCallIndex = 0;
@@ -262,7 +262,7 @@ export function buildThemeWorkbenchExecLogEntries({
return entries;
}
export function filterThemeWorkbenchExecLogEntries(
export function filterGeneralWorkbenchExecLogEntries(
entries: ExecLogEntry[],
clearedAt: number | null,
): ExecLogEntry[] {
@@ -1,14 +1,14 @@
import type {
ThemeWorkbenchSidebarContextContract,
ThemeWorkbenchSidebarExecLogContract,
ThemeWorkbenchSidebarProps,
ThemeWorkbenchSidebarShellContract,
ThemeWorkbenchSidebarWorkflowContract,
} from "./themeWorkbenchSidebarContract";
GeneralWorkbenchSidebarContextContract,
GeneralWorkbenchSidebarExecLogContract,
GeneralWorkbenchSidebarProps,
GeneralWorkbenchSidebarShellContract,
GeneralWorkbenchSidebarWorkflowContract,
} from "./generalWorkbenchSidebarContract";
function areThemeWorkbenchContextBudgetsEqual(
previous: ThemeWorkbenchSidebarProps["contextBudget"],
next: ThemeWorkbenchSidebarProps["contextBudget"],
function areGeneralWorkbenchContextBudgetsEqual(
previous: GeneralWorkbenchSidebarProps["contextBudget"],
next: GeneralWorkbenchSidebarProps["contextBudget"],
): boolean {
return (
previous.activeCount === next.activeCount &&
@@ -18,9 +18,9 @@ function areThemeWorkbenchContextBudgetsEqual(
);
}
function areThemeWorkbenchSidebarShellPropsEqual(
previous: ThemeWorkbenchSidebarShellContract,
next: ThemeWorkbenchSidebarShellContract,
function areGeneralWorkbenchSidebarShellPropsEqual(
previous: GeneralWorkbenchSidebarShellContract,
next: GeneralWorkbenchSidebarShellContract,
): boolean {
return (
previous.branchMode === next.branchMode &&
@@ -30,9 +30,9 @@ function areThemeWorkbenchSidebarShellPropsEqual(
);
}
function areThemeWorkbenchSidebarContextPropsEqual(
previous: ThemeWorkbenchSidebarContextContract,
next: ThemeWorkbenchSidebarContextContract,
function areGeneralWorkbenchSidebarContextPropsEqual(
previous: GeneralWorkbenchSidebarContextContract,
next: GeneralWorkbenchSidebarContextContract,
): boolean {
return (
previous.contextSearchQuery === next.contextSearchQuery &&
@@ -49,16 +49,16 @@ function areThemeWorkbenchSidebarContextPropsEqual(
previous.contextItems === next.contextItems &&
previous.onToggleContextActive === next.onToggleContextActive &&
previous.onViewContextDetail === next.onViewContextDetail &&
areThemeWorkbenchContextBudgetsEqual(
areGeneralWorkbenchContextBudgetsEqual(
previous.contextBudget,
next.contextBudget,
)
);
}
function areThemeWorkbenchSidebarWorkflowPropsEqual(
previous: ThemeWorkbenchSidebarWorkflowContract,
next: ThemeWorkbenchSidebarWorkflowContract,
function areGeneralWorkbenchSidebarWorkflowPropsEqual(
previous: GeneralWorkbenchSidebarWorkflowContract,
next: GeneralWorkbenchSidebarWorkflowContract,
): boolean {
return (
previous.onNewTopic === next.onNewTopic &&
@@ -77,9 +77,9 @@ function areThemeWorkbenchSidebarWorkflowPropsEqual(
);
}
function areThemeWorkbenchSidebarExecLogPropsEqual(
previous: ThemeWorkbenchSidebarExecLogContract,
next: ThemeWorkbenchSidebarExecLogContract,
function areGeneralWorkbenchSidebarExecLogPropsEqual(
previous: GeneralWorkbenchSidebarExecLogContract,
next: GeneralWorkbenchSidebarExecLogContract,
): boolean {
return (
previous.historyHasMore === next.historyHasMore &&
@@ -90,14 +90,14 @@ function areThemeWorkbenchSidebarExecLogPropsEqual(
);
}
export function areThemeWorkbenchSidebarPropsEqual(
previous: ThemeWorkbenchSidebarProps,
next: ThemeWorkbenchSidebarProps,
export function areGeneralWorkbenchSidebarPropsEqual(
previous: GeneralWorkbenchSidebarProps,
next: GeneralWorkbenchSidebarProps,
): boolean {
return (
areThemeWorkbenchSidebarShellPropsEqual(previous, next) &&
areThemeWorkbenchSidebarContextPropsEqual(previous, next) &&
areThemeWorkbenchSidebarWorkflowPropsEqual(previous, next) &&
areThemeWorkbenchSidebarExecLogPropsEqual(previous, next)
areGeneralWorkbenchSidebarShellPropsEqual(previous, next) &&
areGeneralWorkbenchSidebarContextPropsEqual(previous, next) &&
areGeneralWorkbenchSidebarWorkflowPropsEqual(previous, next) &&
areGeneralWorkbenchSidebarExecLogPropsEqual(previous, next)
);
}
@@ -0,0 +1,22 @@
import type { ComponentProps } from "react";
import { GeneralWorkbenchContextPanel } from "./GeneralWorkbenchContextPanel";
import { GeneralWorkbenchExecLog } from "./GeneralWorkbenchExecLog";
import { GeneralWorkbenchWorkflowPanel } from "./GeneralWorkbenchWorkflowPanel";
export type GeneralWorkbenchSidebarContextPanelProps = ComponentProps<
typeof GeneralWorkbenchContextPanel
>;
export type GeneralWorkbenchSidebarWorkflowPanelProps = ComponentProps<
typeof GeneralWorkbenchWorkflowPanel
>;
export type GeneralWorkbenchSidebarExecLogProps = ComponentProps<
typeof GeneralWorkbenchExecLog
>;
export interface GeneralWorkbenchSidebarContentProps {
contextPanelProps: GeneralWorkbenchSidebarContextPanelProps;
workflowPanelProps: GeneralWorkbenchSidebarWorkflowPanelProps;
execLogProps: GeneralWorkbenchSidebarExecLogProps;
}
@@ -9,26 +9,26 @@ import type {
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type { Message } from "../types";
import type {
ThemeWorkbenchContextBudget,
ThemeWorkbenchContextItem,
} from "./themeWorkbenchContextData";
import type { ThemeWorkbenchCreationTaskEvent } from "./themeWorkbenchWorkflowData";
GeneralWorkbenchContextBudget,
GeneralWorkbenchContextItem,
} from "./generalWorkbenchContextData";
import type { GeneralWorkbenchCreationTaskEvent } from "./generalWorkbenchWorkflowData";
import type {
ThemeWorkbenchAddFileContextAction,
ThemeWorkbenchAddLinkContextAction,
ThemeWorkbenchAddTextContextAction,
} from "./useThemeWorkbenchContextPanelState";
GeneralWorkbenchAddFileContextAction,
GeneralWorkbenchAddLinkContextAction,
GeneralWorkbenchAddTextContextAction,
} from "./useGeneralWorkbenchContextPanelState";
export type BranchMode = "topic" | "version";
export interface ThemeWorkbenchSidebarShellContract {
export interface GeneralWorkbenchSidebarShellContract {
branchMode?: BranchMode;
onRequestCollapse?: () => void;
headerActionSlot?: ReactNode;
topSlot?: ReactNode;
}
export interface ThemeWorkbenchSidebarWorkflowContract {
export interface GeneralWorkbenchSidebarWorkflowContract {
onNewTopic: () => void;
onSwitchTopic: (topicId: string) => void;
onDeleteTopic: (topicId: string) => void;
@@ -38,13 +38,13 @@ export interface ThemeWorkbenchSidebarWorkflowContract {
onAddImage?: () => Promise<void> | void;
onImportDocument?: () => Promise<void> | void;
activityLogs: SidebarActivityLog[];
creationTaskEvents?: ThemeWorkbenchCreationTaskEvent[];
creationTaskEvents?: GeneralWorkbenchCreationTaskEvent[];
onViewRunDetail?: (runId: string) => void;
activeRunDetail?: AgentRun | null;
activeRunDetailLoading?: boolean;
}
export interface ThemeWorkbenchSidebarContextContract {
export interface GeneralWorkbenchSidebarContextContract {
contextSearchQuery: string;
onContextSearchQueryChange: (value: string) => void;
contextSearchMode: "web" | "social";
@@ -53,16 +53,16 @@ export interface ThemeWorkbenchSidebarContextContract {
contextSearchError?: string | null;
contextSearchBlockedReason?: string | null;
onSubmitContextSearch: () => Promise<void> | void;
onAddTextContext?: ThemeWorkbenchAddTextContextAction;
onAddLinkContext?: ThemeWorkbenchAddLinkContextAction;
onAddFileContext?: ThemeWorkbenchAddFileContextAction;
contextItems: ThemeWorkbenchContextItem[];
onAddTextContext?: GeneralWorkbenchAddTextContextAction;
onAddLinkContext?: GeneralWorkbenchAddLinkContextAction;
onAddFileContext?: GeneralWorkbenchAddFileContextAction;
contextItems: GeneralWorkbenchContextItem[];
onToggleContextActive: (contextId: string) => void;
onViewContextDetail?: (contextId: string) => void;
contextBudget: ThemeWorkbenchContextBudget;
contextBudget: GeneralWorkbenchContextBudget;
}
export interface ThemeWorkbenchSidebarExecLogContract {
export interface GeneralWorkbenchSidebarExecLogContract {
historyHasMore?: boolean;
historyLoading?: boolean;
onLoadMoreHistory?: () => void;
@@ -70,8 +70,8 @@ export interface ThemeWorkbenchSidebarExecLogContract {
messages?: Message[];
}
export interface ThemeWorkbenchSidebarProps
extends ThemeWorkbenchSidebarShellContract,
ThemeWorkbenchSidebarWorkflowContract,
ThemeWorkbenchSidebarContextContract,
ThemeWorkbenchSidebarExecLogContract {}
export interface GeneralWorkbenchSidebarProps
extends GeneralWorkbenchSidebarShellContract,
GeneralWorkbenchSidebarWorkflowContract,
GeneralWorkbenchSidebarContextContract,
GeneralWorkbenchSidebarExecLogContract {}
@@ -0,0 +1,102 @@
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type { Message } from "../types";
import type { BuildGeneralWorkbenchContextPanelPropsParams } from "./buildGeneralWorkbenchContextPanelProps";
import type { BuildGeneralWorkbenchExecLogPropsParams } from "./buildGeneralWorkbenchExecLogProps";
import type { BuildGeneralWorkbenchWorkflowPanelPropsParams } from "./buildGeneralWorkbenchWorkflowPanelProps";
import type { GeneralWorkbenchCreationTaskEvent } from "./generalWorkbenchWorkflowData";
import type {
GeneralWorkbenchAddFileContextAction,
GeneralWorkbenchAddLinkContextAction,
GeneralWorkbenchAddTextContextAction,
} from "./useGeneralWorkbenchContextPanelState";
export interface GeneralWorkbenchSidebarContextOrchestrationInput
extends Omit<BuildGeneralWorkbenchContextPanelPropsParams, "contextPanelState"> {
onAddTextContext?: GeneralWorkbenchAddTextContextAction;
onAddLinkContext?: GeneralWorkbenchAddLinkContextAction;
onAddFileContext?: GeneralWorkbenchAddFileContextAction;
}
export interface GeneralWorkbenchSidebarWorkflowOrchestrationInput
extends Omit<
BuildGeneralWorkbenchWorkflowPanelPropsParams,
| "creationTaskEventsCount"
| "isVersionMode"
| "onOpenArtifactWithDefaultApp"
| "onRevealArtifactInFinder"
| "workflowPanelState"
> {
activityLogs: SidebarActivityLog[];
creationTaskEvents: GeneralWorkbenchCreationTaskEvent[];
}
export interface GeneralWorkbenchSidebarExecLogOrchestrationInput
extends Omit<BuildGeneralWorkbenchExecLogPropsParams, "execLogState"> {
messages: Message[];
skillDetailMap: Record<string, SkillDetailInfo | null>;
}
export interface GeneralWorkbenchSidebarOrchestrationInput {
isVersionMode: boolean;
context: GeneralWorkbenchSidebarContextOrchestrationInput;
workflow: GeneralWorkbenchSidebarWorkflowOrchestrationInput;
execLog: GeneralWorkbenchSidebarExecLogOrchestrationInput;
}
export type GeneralWorkbenchSidebarContextOrchestrationSource =
GeneralWorkbenchSidebarContextOrchestrationInput;
export interface GeneralWorkbenchSidebarWorkflowOrchestrationSource
extends Omit<
GeneralWorkbenchSidebarWorkflowOrchestrationInput,
"creationTaskEvents"
> {
creationTaskEvents?: GeneralWorkbenchCreationTaskEvent[];
}
export interface GeneralWorkbenchSidebarExecLogOrchestrationSource
extends Omit<
GeneralWorkbenchSidebarExecLogOrchestrationInput,
"messages" | "skillDetailMap"
> {
messages?: Message[];
skillDetailMap?: Record<string, SkillDetailInfo | null>;
}
export interface GeneralWorkbenchSidebarOrchestrationSource {
isVersionMode: boolean;
context: GeneralWorkbenchSidebarContextOrchestrationSource;
workflow: GeneralWorkbenchSidebarWorkflowOrchestrationSource;
execLog: GeneralWorkbenchSidebarExecLogOrchestrationSource;
}
export function createGeneralWorkbenchSidebarOrchestrationInput(
source: GeneralWorkbenchSidebarOrchestrationSource,
): GeneralWorkbenchSidebarOrchestrationInput {
const { isVersionMode, context, workflow, execLog } = source;
const { creationTaskEvents = [], activeRunDetailLoading = false } = workflow;
const {
historyHasMore = false,
historyLoading = false,
messages = [],
skillDetailMap = {},
} = execLog;
return {
isVersionMode,
context,
workflow: {
...workflow,
activeRunDetailLoading,
creationTaskEvents,
},
execLog: {
...execLog,
historyHasMore,
historyLoading,
messages,
skillDetailMap,
},
};
}
@@ -1,4 +1,4 @@
export function formatThemeWorkbenchActionErrorMessage(
export function formatGeneralWorkbenchActionErrorMessage(
prefix: string,
error: unknown,
): string {
@@ -28,7 +28,7 @@ export function formatThemeWorkbenchActionErrorMessage(
return `${prefix}:${detail}`;
}
export async function writeThemeWorkbenchClipboardText(text: string): Promise<void> {
export async function writeGeneralWorkbenchClipboardText(text: string): Promise<void> {
const value = text.trim();
if (!value) {
return;
@@ -40,7 +40,7 @@ export async function writeThemeWorkbenchClipboardText(text: string): Promise<vo
await clipboard.writeText(value);
}
export function resolveThemeWorkbenchFileNameFromPath(path: string): string {
export function resolveGeneralWorkbenchFileNameFromPath(path: string): string {
const normalized = path.replace(/\\/g, "/");
const segments = normalized.split("/");
return segments[segments.length - 1] || "上下文文件";
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest";
import {
buildThemeWorkbenchActivityLogGroups,
parseThemeWorkbenchRunMetadataSummary,
} from "./themeWorkbenchWorkflowData";
buildGeneralWorkbenchActivityLogGroups,
parseGeneralWorkbenchRunMetadataSummary,
} from "./generalWorkbenchWorkflowData";
describe("themeWorkbenchWorkflowData", () => {
describe("generalWorkbenchWorkflowData", () => {
it("应通过 artifact protocol 统一解析运行元数据里的产物路径", () => {
expect(
parseThemeWorkbenchRunMetadataSummary(
parseGeneralWorkbenchRunMetadataSummary(
JSON.stringify({
workflow: "social",
execution_id: "exec-1",
@@ -33,7 +33,7 @@ describe("themeWorkbenchWorkflowData", () => {
it("应合并同一运行日志中的产物路径并保持规范化", () => {
expect(
buildThemeWorkbenchActivityLogGroups([
buildGeneralWorkbenchActivityLogGroups([
{
id: "log-1",
name: "排版优化",
@@ -1,7 +1,7 @@
import { extractArtifactProtocolPathsFromRecord } from "@/lib/artifact-protocol";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
export interface ThemeWorkbenchCreationTaskEvent {
export interface GeneralWorkbenchCreationTaskEvent {
taskId: string;
taskType: string;
path: string;
@@ -10,14 +10,14 @@ export interface ThemeWorkbenchCreationTaskEvent {
timeLabel: string;
}
type ThemeWorkbenchActivityStatus = SidebarActivityLog["status"];
type GeneralWorkbenchActivityStatus = SidebarActivityLog["status"];
export interface ThemeWorkbenchActivityLogGroup {
export interface GeneralWorkbenchActivityLogGroup {
key: string;
runId?: string;
sessionId?: string;
messageId?: string;
status: ThemeWorkbenchActivityStatus;
status: GeneralWorkbenchActivityStatus;
source?: string;
gateKey?: SidebarActivityLog["gateKey"];
timeLabel: string;
@@ -25,15 +25,15 @@ export interface ThemeWorkbenchActivityLogGroup {
logs: SidebarActivityLog[];
}
export interface ThemeWorkbenchCreationTaskGroup {
export interface GeneralWorkbenchCreationTaskGroup {
key: string;
taskType: string;
label: string;
latestTimeLabel: string;
tasks: ThemeWorkbenchCreationTaskEvent[];
tasks: GeneralWorkbenchCreationTaskEvent[];
}
export interface ThemeWorkbenchRunMetadataSummary {
export interface GeneralWorkbenchRunMetadataSummary {
workflow: string | null;
executionId: string | null;
versionId: string | null;
@@ -41,7 +41,7 @@ export interface ThemeWorkbenchRunMetadataSummary {
artifactPaths: string[];
}
export function formatThemeWorkbenchRunMetadata(raw: string | null): string {
export function formatGeneralWorkbenchRunMetadata(raw: string | null): string {
if (!raw || !raw.trim()) {
return "-";
}
@@ -71,9 +71,9 @@ function mergeArtifactPaths(current: string[], incoming?: string[]): string[] {
}
function mergeActivityStatus(
previous: ThemeWorkbenchActivityStatus,
next: ThemeWorkbenchActivityStatus,
): ThemeWorkbenchActivityStatus {
previous: GeneralWorkbenchActivityStatus,
next: GeneralWorkbenchActivityStatus,
): GeneralWorkbenchActivityStatus {
if (previous === "running" || next === "running") {
return "running";
}
@@ -138,10 +138,10 @@ function formatCreationTaskTypeLabel(taskType: string): string {
return taskType.trim() || "未分类任务";
}
export function parseThemeWorkbenchRunMetadataSummary(
export function parseGeneralWorkbenchRunMetadataSummary(
raw: string | null,
): ThemeWorkbenchRunMetadataSummary {
const fallback: ThemeWorkbenchRunMetadataSummary = {
): GeneralWorkbenchRunMetadataSummary {
const fallback: GeneralWorkbenchRunMetadataSummary = {
workflow: null,
executionId: null,
versionId: null,
@@ -182,7 +182,7 @@ export function parseThemeWorkbenchRunMetadataSummary(
}
}
export function formatThemeWorkbenchStageLabel(raw: string): string {
export function formatGeneralWorkbenchStageLabel(raw: string): string {
if (raw === "topic_select") {
return "选题闸门";
}
@@ -195,32 +195,32 @@ export function formatThemeWorkbenchStageLabel(raw: string): string {
return raw;
}
export function formatThemeWorkbenchStagesLabel(
export function formatGeneralWorkbenchStagesLabel(
stages: string[],
): string | null {
if (stages.length === 0) {
return null;
}
return stages
.map((stage) => formatThemeWorkbenchStageLabel(stage))
.map((stage) => formatGeneralWorkbenchStageLabel(stage))
.join(" → ");
}
export function buildThemeWorkbenchActivityLogGroups(
export function buildGeneralWorkbenchActivityLogGroups(
activityLogs: SidebarActivityLog[],
): ThemeWorkbenchActivityLogGroup[] {
): GeneralWorkbenchActivityLogGroup[] {
if (activityLogs.length === 0) {
return [];
}
const groups: ThemeWorkbenchActivityLogGroup[] = [];
const groupByKey = new Map<string, ThemeWorkbenchActivityLogGroup>();
const groups: GeneralWorkbenchActivityLogGroup[] = [];
const groupByKey = new Map<string, GeneralWorkbenchActivityLogGroup>();
activityLogs.forEach((log) => {
const identity = resolveActivityGroupIdentity(log);
const existingGroup = groupByKey.get(identity.key);
if (!existingGroup) {
const nextGroup: ThemeWorkbenchActivityLogGroup = {
const nextGroup: GeneralWorkbenchActivityLogGroup = {
key: identity.key,
runId: identity.runId,
sessionId: log.sessionId?.trim() || undefined,
@@ -267,14 +267,14 @@ export function buildThemeWorkbenchActivityLogGroups(
return groups;
}
export function buildThemeWorkbenchCreationTaskGroups(
creationTaskEvents: ThemeWorkbenchCreationTaskEvent[],
): ThemeWorkbenchCreationTaskGroup[] {
export function buildGeneralWorkbenchCreationTaskGroups(
creationTaskEvents: GeneralWorkbenchCreationTaskEvent[],
): GeneralWorkbenchCreationTaskGroup[] {
if (creationTaskEvents.length === 0) {
return [];
}
const groupMap = new Map<string, ThemeWorkbenchCreationTaskGroup>();
const groupMap = new Map<string, GeneralWorkbenchCreationTaskGroup>();
creationTaskEvents.forEach((task) => {
const groupKey = task.taskType.trim().toLowerCase() || "unknown";
const existing = groupMap.get(groupKey);
@@ -1,22 +0,0 @@
import type { ComponentProps } from "react";
import { ThemeWorkbenchContextPanel } from "./ThemeWorkbenchContextPanel";
import { ThemeWorkbenchExecLog } from "./ThemeWorkbenchExecLog";
import { ThemeWorkbenchWorkflowPanel } from "./ThemeWorkbenchWorkflowPanel";
export type ThemeWorkbenchSidebarContextPanelProps = ComponentProps<
typeof ThemeWorkbenchContextPanel
>;
export type ThemeWorkbenchSidebarWorkflowPanelProps = ComponentProps<
typeof ThemeWorkbenchWorkflowPanel
>;
export type ThemeWorkbenchSidebarExecLogProps = ComponentProps<
typeof ThemeWorkbenchExecLog
>;
export interface ThemeWorkbenchSidebarContentProps {
contextPanelProps: ThemeWorkbenchSidebarContextPanelProps;
workflowPanelProps: ThemeWorkbenchSidebarWorkflowPanelProps;
execLogProps: ThemeWorkbenchSidebarExecLogProps;
}
@@ -1,102 +0,0 @@
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type { Message } from "../types";
import type { BuildThemeWorkbenchContextPanelPropsParams } from "./buildThemeWorkbenchContextPanelProps";
import type { BuildThemeWorkbenchExecLogPropsParams } from "./buildThemeWorkbenchExecLogProps";
import type { BuildThemeWorkbenchWorkflowPanelPropsParams } from "./buildThemeWorkbenchWorkflowPanelProps";
import type { ThemeWorkbenchCreationTaskEvent } from "./themeWorkbenchWorkflowData";
import type {
ThemeWorkbenchAddFileContextAction,
ThemeWorkbenchAddLinkContextAction,
ThemeWorkbenchAddTextContextAction,
} from "./useThemeWorkbenchContextPanelState";
export interface ThemeWorkbenchSidebarContextOrchestrationInput
extends Omit<BuildThemeWorkbenchContextPanelPropsParams, "contextPanelState"> {
onAddTextContext?: ThemeWorkbenchAddTextContextAction;
onAddLinkContext?: ThemeWorkbenchAddLinkContextAction;
onAddFileContext?: ThemeWorkbenchAddFileContextAction;
}
export interface ThemeWorkbenchSidebarWorkflowOrchestrationInput
extends Omit<
BuildThemeWorkbenchWorkflowPanelPropsParams,
| "creationTaskEventsCount"
| "isVersionMode"
| "onOpenArtifactWithDefaultApp"
| "onRevealArtifactInFinder"
| "workflowPanelState"
> {
activityLogs: SidebarActivityLog[];
creationTaskEvents: ThemeWorkbenchCreationTaskEvent[];
}
export interface ThemeWorkbenchSidebarExecLogOrchestrationInput
extends Omit<BuildThemeWorkbenchExecLogPropsParams, "execLogState"> {
messages: Message[];
skillDetailMap: Record<string, SkillDetailInfo | null>;
}
export interface ThemeWorkbenchSidebarOrchestrationInput {
isVersionMode: boolean;
context: ThemeWorkbenchSidebarContextOrchestrationInput;
workflow: ThemeWorkbenchSidebarWorkflowOrchestrationInput;
execLog: ThemeWorkbenchSidebarExecLogOrchestrationInput;
}
export type ThemeWorkbenchSidebarContextOrchestrationSource =
ThemeWorkbenchSidebarContextOrchestrationInput;
export interface ThemeWorkbenchSidebarWorkflowOrchestrationSource
extends Omit<
ThemeWorkbenchSidebarWorkflowOrchestrationInput,
"creationTaskEvents"
> {
creationTaskEvents?: ThemeWorkbenchCreationTaskEvent[];
}
export interface ThemeWorkbenchSidebarExecLogOrchestrationSource
extends Omit<
ThemeWorkbenchSidebarExecLogOrchestrationInput,
"messages" | "skillDetailMap"
> {
messages?: Message[];
skillDetailMap?: Record<string, SkillDetailInfo | null>;
}
export interface ThemeWorkbenchSidebarOrchestrationSource {
isVersionMode: boolean;
context: ThemeWorkbenchSidebarContextOrchestrationSource;
workflow: ThemeWorkbenchSidebarWorkflowOrchestrationSource;
execLog: ThemeWorkbenchSidebarExecLogOrchestrationSource;
}
export function createThemeWorkbenchSidebarOrchestrationInput(
source: ThemeWorkbenchSidebarOrchestrationSource,
): ThemeWorkbenchSidebarOrchestrationInput {
const { isVersionMode, context, workflow, execLog } = source;
const { creationTaskEvents = [], activeRunDetailLoading = false } = workflow;
const {
historyHasMore = false,
historyLoading = false,
messages = [],
skillDetailMap = {},
} = execLog;
return {
isVersionMode,
context,
workflow: {
...workflow,
activeRunDetailLoading,
creationTaskEvents,
},
execLog: {
...execLog,
historyHasMore,
historyLoading,
messages,
skillDetailMap,
},
};
}
@@ -4,15 +4,15 @@ import {
openFileWithDefaultApp as openSessionFileWithDefaultApp,
revealFileInFinder as revealSessionFileInFinder,
} from "@/lib/api/session-files";
import { formatThemeWorkbenchActionErrorMessage } from "./themeWorkbenchSidebarShared";
import { formatGeneralWorkbenchActionErrorMessage } from "./generalWorkbenchSidebarShared";
interface UseThemeWorkbenchArtifactActionsParams {
interface UseGeneralWorkbenchArtifactActionsParams {
runDetailSessionId: string | null;
}
export function useThemeWorkbenchArtifactActions({
export function useGeneralWorkbenchArtifactActions({
runDetailSessionId,
}: UseThemeWorkbenchArtifactActionsParams) {
}: UseGeneralWorkbenchArtifactActionsParams) {
const handleRevealArtifactInFinder = useCallback(
async (artifactPath: string, sessionId?: string | null) => {
const resolvedSessionId = sessionId?.trim() || runDetailSessionId;
@@ -23,9 +23,9 @@ export function useThemeWorkbenchArtifactActions({
try {
await revealSessionFileInFinder(resolvedSessionId, artifactPath);
} catch (error) {
console.warn("[ThemeWorkbenchSidebar] 定位产物文件失败:", error);
console.warn("[GeneralWorkbenchSidebar] 定位产物文件失败:", error);
toast.error(
formatThemeWorkbenchActionErrorMessage("定位产物文件失败", error),
formatGeneralWorkbenchActionErrorMessage("定位产物文件失败", error),
);
}
},
@@ -42,9 +42,9 @@ export function useThemeWorkbenchArtifactActions({
try {
await openSessionFileWithDefaultApp(resolvedSessionId, artifactPath);
} catch (error) {
console.warn("[ThemeWorkbenchSidebar] 打开产物文件失败:", error);
console.warn("[GeneralWorkbenchSidebar] 打开产物文件失败:", error);
toast.error(
formatThemeWorkbenchActionErrorMessage("打开产物文件失败", error),
formatGeneralWorkbenchActionErrorMessage("打开产物文件失败", error),
);
}
},
@@ -9,48 +9,48 @@ import {
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner";
import {
buildThemeWorkbenchActiveContextItems,
buildThemeWorkbenchOrderedContextItems,
buildThemeWorkbenchSearchContextItems,
resolveThemeWorkbenchLatestSearchLabel,
resolveThemeWorkbenchSelectedSearchResult,
type ThemeWorkbenchContextItem,
} from "./themeWorkbenchContextData";
buildGeneralWorkbenchActiveContextItems,
buildGeneralWorkbenchOrderedContextItems,
buildGeneralWorkbenchSearchContextItems,
resolveGeneralWorkbenchLatestSearchLabel,
resolveGeneralWorkbenchSelectedSearchResult,
type GeneralWorkbenchContextItem,
} from "./generalWorkbenchContextData";
import {
formatThemeWorkbenchActionErrorMessage,
resolveThemeWorkbenchFileNameFromPath,
} from "./themeWorkbenchSidebarShared";
formatGeneralWorkbenchActionErrorMessage,
resolveGeneralWorkbenchFileNameFromPath,
} from "./generalWorkbenchSidebarShared";
export type ThemeWorkbenchAddTextContextAction = (payload: {
export type GeneralWorkbenchAddTextContextAction = (payload: {
content: string;
name?: string;
}) => Promise<void> | void;
export type ThemeWorkbenchAddLinkContextAction = (payload: {
export type GeneralWorkbenchAddLinkContextAction = (payload: {
url: string;
name?: string;
}) => Promise<void> | void;
export type ThemeWorkbenchAddFileContextAction = (payload: {
export type GeneralWorkbenchAddFileContextAction = (payload: {
path: string;
name?: string;
}) => Promise<void> | void;
interface UseThemeWorkbenchContextPanelStateParams {
contextItems: ThemeWorkbenchContextItem[];
interface UseGeneralWorkbenchContextPanelStateParams {
contextItems: GeneralWorkbenchContextItem[];
contextSearchQuery: string;
contextSearchLoading: boolean;
contextSearchBlockedReason?: string | null;
onAddTextContext?: ThemeWorkbenchAddTextContextAction;
onAddLinkContext?: ThemeWorkbenchAddLinkContextAction;
onAddFileContext?: ThemeWorkbenchAddFileContextAction;
onAddTextContext?: GeneralWorkbenchAddTextContextAction;
onAddLinkContext?: GeneralWorkbenchAddLinkContextAction;
onAddFileContext?: GeneralWorkbenchAddFileContextAction;
}
export interface ThemeWorkbenchContextPanelState {
activeContextItems: ThemeWorkbenchContextItem[];
searchContextItems: ThemeWorkbenchContextItem[];
orderedContextItems: ThemeWorkbenchContextItem[];
selectedSearchResult: ThemeWorkbenchContextItem | null;
export interface GeneralWorkbenchContextPanelState {
activeContextItems: GeneralWorkbenchContextItem[];
searchContextItems: GeneralWorkbenchContextItem[];
orderedContextItems: GeneralWorkbenchContextItem[];
selectedSearchResult: GeneralWorkbenchContextItem | null;
latestSearchLabel: string;
searchInputRef: RefObject<HTMLInputElement>;
isSearchActionDisabled: boolean;
@@ -76,7 +76,7 @@ export interface ThemeWorkbenchContextPanelState {
handleSubmitLinkContext: () => Promise<void>;
}
export function useThemeWorkbenchContextPanelState({
export function useGeneralWorkbenchContextPanelState({
contextItems,
contextSearchQuery,
contextSearchLoading,
@@ -84,7 +84,7 @@ export function useThemeWorkbenchContextPanelState({
onAddTextContext,
onAddLinkContext,
onAddFileContext,
}: UseThemeWorkbenchContextPanelStateParams): ThemeWorkbenchContextPanelState {
}: UseGeneralWorkbenchContextPanelStateParams): GeneralWorkbenchContextPanelState {
const [selectedSearchResultId, setSelectedSearchResultId] = useState<string | null>(null);
const [addContextDialogOpen, setAddContextDialogOpen] = useState(false);
const [addTextDialogOpen, setAddTextDialogOpen] = useState(false);
@@ -102,25 +102,25 @@ export function useThemeWorkbenchContextPanelState({
contextSearchQuery.trim().length === 0;
const activeContextItems = useMemo(
() => buildThemeWorkbenchActiveContextItems(contextItems),
() => buildGeneralWorkbenchActiveContextItems(contextItems),
[contextItems],
);
const searchContextItems = useMemo(
() => buildThemeWorkbenchSearchContextItems(contextItems),
() => buildGeneralWorkbenchSearchContextItems(contextItems),
[contextItems],
);
const orderedContextItems = useMemo(
() => buildThemeWorkbenchOrderedContextItems(contextItems),
() => buildGeneralWorkbenchOrderedContextItems(contextItems),
[contextItems],
);
const latestSearchLabel = useMemo(() => {
return resolveThemeWorkbenchLatestSearchLabel(searchContextItems);
return resolveGeneralWorkbenchLatestSearchLabel(searchContextItems);
}, [searchContextItems]);
const selectedSearchResult = useMemo(
() =>
resolveThemeWorkbenchSelectedSearchResult(
resolveGeneralWorkbenchSelectedSearchResult(
searchContextItems,
selectedSearchResultId,
),
@@ -185,7 +185,7 @@ export function useThemeWorkbenchContextPanelState({
toast.success(successMessage);
closeAllContextDialogs();
} catch (error) {
const nextError = formatThemeWorkbenchActionErrorMessage(
const nextError = formatGeneralWorkbenchActionErrorMessage(
"添加上下文失败",
error,
);
@@ -216,13 +216,13 @@ export function useThemeWorkbenchContextPanelState({
async () => {
await onAddFileContext({
path: selected,
name: resolveThemeWorkbenchFileNameFromPath(selected),
name: resolveGeneralWorkbenchFileNameFromPath(selected),
});
},
"已添加文件上下文",
);
} catch (error) {
const nextError = formatThemeWorkbenchActionErrorMessage(
const nextError = formatGeneralWorkbenchActionErrorMessage(
"读取文件失败",
error,
);
@@ -2,39 +2,39 @@ import { useCallback, useMemo, useState } from "react";
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import type { Message } from "../types";
import type {
ThemeWorkbenchActivityLogGroup,
ThemeWorkbenchCreationTaskGroup,
} from "./themeWorkbenchWorkflowData";
GeneralWorkbenchActivityLogGroup,
GeneralWorkbenchCreationTaskGroup,
} from "./generalWorkbenchWorkflowData";
import {
buildThemeWorkbenchExecLogEntries,
filterThemeWorkbenchExecLogEntries,
} from "./themeWorkbenchExecLogData";
buildGeneralWorkbenchExecLogEntries,
filterGeneralWorkbenchExecLogEntries,
} from "./generalWorkbenchExecLogData";
interface UseThemeWorkbenchExecLogStateParams {
interface UseGeneralWorkbenchExecLogStateParams {
messages: Message[];
groupedActivityLogs: ThemeWorkbenchActivityLogGroup[];
groupedCreationTaskEvents: ThemeWorkbenchCreationTaskGroup[];
groupedActivityLogs: GeneralWorkbenchActivityLogGroup[];
groupedCreationTaskEvents: GeneralWorkbenchCreationTaskGroup[];
skillDetailMap: Record<string, SkillDetailInfo | null>;
}
export interface ThemeWorkbenchExecLogState {
execLogEntries: ReturnType<typeof buildThemeWorkbenchExecLogEntries>;
visibleExecLogEntries: ReturnType<typeof buildThemeWorkbenchExecLogEntries>;
export interface GeneralWorkbenchExecLogState {
execLogEntries: ReturnType<typeof buildGeneralWorkbenchExecLogEntries>;
visibleExecLogEntries: ReturnType<typeof buildGeneralWorkbenchExecLogEntries>;
wasExecLogCleared: boolean;
clearExecLog: () => void;
}
export function useThemeWorkbenchExecLogState({
export function useGeneralWorkbenchExecLogState({
messages,
groupedActivityLogs,
groupedCreationTaskEvents,
skillDetailMap,
}: UseThemeWorkbenchExecLogStateParams): ThemeWorkbenchExecLogState {
}: UseGeneralWorkbenchExecLogStateParams): GeneralWorkbenchExecLogState {
const [execLogClearedAt, setExecLogClearedAt] = useState<number | null>(null);
const execLogEntries = useMemo(
() =>
buildThemeWorkbenchExecLogEntries({
buildGeneralWorkbenchExecLogEntries({
messages,
groupedActivityLogs,
groupedCreationTaskEvents,
@@ -44,7 +44,7 @@ export function useThemeWorkbenchExecLogState({
);
const visibleExecLogEntries = useMemo(
() => filterThemeWorkbenchExecLogEntries(execLogEntries, execLogClearedAt),
() => filterGeneralWorkbenchExecLogEntries(execLogEntries, execLogClearedAt),
[execLogClearedAt, execLogEntries],
);
@@ -1,50 +1,50 @@
import type { ThemeWorkbenchSidebarTab } from "./ThemeWorkbenchSidebarShell";
import type { ThemeWorkbenchSidebarContentProps } from "./themeWorkbenchSidebarContentContract";
import type { ThemeWorkbenchSidebarOrchestrationInput } from "./themeWorkbenchSidebarOrchestrationContract";
import { buildThemeWorkbenchContextPanelProps } from "./buildThemeWorkbenchContextPanelProps";
import { buildThemeWorkbenchExecLogProps } from "./buildThemeWorkbenchExecLogProps";
import { buildThemeWorkbenchWorkflowPanelProps } from "./buildThemeWorkbenchWorkflowPanelProps";
import { useThemeWorkbenchArtifactActions } from "./useThemeWorkbenchArtifactActions";
import { useThemeWorkbenchContextPanelState } from "./useThemeWorkbenchContextPanelState";
import { useThemeWorkbenchExecLogState } from "./useThemeWorkbenchExecLogState";
import { useThemeWorkbenchSidebarTelemetry } from "./useThemeWorkbenchSidebarTelemetry";
import { useThemeWorkbenchWorkflowPanelState } from "./useThemeWorkbenchWorkflowPanelState";
import type { GeneralWorkbenchSidebarTab } from "./GeneralWorkbenchSidebarShell";
import type { GeneralWorkbenchSidebarContentProps } from "./generalWorkbenchSidebarContentContract";
import type { GeneralWorkbenchSidebarOrchestrationInput } from "./generalWorkbenchSidebarOrchestrationContract";
import { buildGeneralWorkbenchContextPanelProps } from "./buildGeneralWorkbenchContextPanelProps";
import { buildGeneralWorkbenchExecLogProps } from "./buildGeneralWorkbenchExecLogProps";
import { buildGeneralWorkbenchWorkflowPanelProps } from "./buildGeneralWorkbenchWorkflowPanelProps";
import { useGeneralWorkbenchArtifactActions } from "./useGeneralWorkbenchArtifactActions";
import { useGeneralWorkbenchContextPanelState } from "./useGeneralWorkbenchContextPanelState";
import { useGeneralWorkbenchExecLogState } from "./useGeneralWorkbenchExecLogState";
import { useGeneralWorkbenchSidebarTelemetry } from "./useGeneralWorkbenchSidebarTelemetry";
import { useGeneralWorkbenchWorkflowPanelState } from "./useGeneralWorkbenchWorkflowPanelState";
interface UseThemeWorkbenchSidebarOrchestrationParams {
activeTab: ThemeWorkbenchSidebarTab;
input: ThemeWorkbenchSidebarOrchestrationInput;
interface UseGeneralWorkbenchSidebarOrchestrationParams {
activeTab: GeneralWorkbenchSidebarTab;
input: GeneralWorkbenchSidebarOrchestrationInput;
}
export interface ThemeWorkbenchSidebarOrchestration {
export interface GeneralWorkbenchSidebarOrchestration {
branchCount: number;
isVersionMode: boolean;
activeContextCount: number;
visibleExecLogCount: number;
contextPanelProps: ThemeWorkbenchSidebarContentProps["contextPanelProps"];
workflowPanelProps: ThemeWorkbenchSidebarContentProps["workflowPanelProps"];
execLogProps: ThemeWorkbenchSidebarContentProps["execLogProps"];
contextPanelProps: GeneralWorkbenchSidebarContentProps["contextPanelProps"];
workflowPanelProps: GeneralWorkbenchSidebarContentProps["workflowPanelProps"];
execLogProps: GeneralWorkbenchSidebarContentProps["execLogProps"];
}
export function useThemeWorkbenchSidebarOrchestration({
export function useGeneralWorkbenchSidebarOrchestration({
activeTab,
input,
}: UseThemeWorkbenchSidebarOrchestrationParams): ThemeWorkbenchSidebarOrchestration {
}: UseGeneralWorkbenchSidebarOrchestrationParams): GeneralWorkbenchSidebarOrchestration {
const { isVersionMode, context, workflow, execLog } = input;
const branchCount = workflow.branchItems.length;
const runDetailSessionId = workflow.activeRunDetail?.session_id?.trim() || null;
const { handleRevealArtifactInFinder, handleOpenArtifactWithDefaultApp } =
useThemeWorkbenchArtifactActions({
useGeneralWorkbenchArtifactActions({
runDetailSessionId,
});
const workflowPanelState = useThemeWorkbenchWorkflowPanelState({
const workflowPanelState = useGeneralWorkbenchWorkflowPanelState({
workflowSteps: workflow.workflowSteps,
activityLogs: workflow.activityLogs,
creationTaskEvents: workflow.creationTaskEvents,
activeRunMetadata: workflow.activeRunDetail?.metadata ?? null,
});
const contextPanelState = useThemeWorkbenchContextPanelState({
const contextPanelState = useGeneralWorkbenchContextPanelState({
contextItems: context.contextItems,
contextSearchQuery: context.contextSearchQuery,
contextSearchLoading: context.contextSearchLoading,
@@ -54,7 +54,7 @@ export function useThemeWorkbenchSidebarOrchestration({
onAddFileContext: context.onAddFileContext,
});
useThemeWorkbenchSidebarTelemetry({
useGeneralWorkbenchSidebarTelemetry({
activeTab,
showActivityLogs: workflowPanelState.showActivityLogs,
contextSearchLoading: context.contextSearchLoading,
@@ -67,7 +67,7 @@ export function useThemeWorkbenchSidebarOrchestration({
hasActiveRunDetail: Boolean(workflow.activeRunDetail),
});
const execLogState = useThemeWorkbenchExecLogState({
const execLogState = useGeneralWorkbenchExecLogState({
messages: execLog.messages,
groupedActivityLogs: workflowPanelState.groupedActivityLogs,
groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents,
@@ -79,7 +79,7 @@ export function useThemeWorkbenchSidebarOrchestration({
isVersionMode,
activeContextCount: contextPanelState.activeContextItems.length,
visibleExecLogCount: execLogState.visibleExecLogEntries.length,
contextPanelProps: buildThemeWorkbenchContextPanelProps({
contextPanelProps: buildGeneralWorkbenchContextPanelProps({
contextBudget: context.contextBudget,
contextItems: context.contextItems,
contextPanelState,
@@ -94,7 +94,7 @@ export function useThemeWorkbenchSidebarOrchestration({
onToggleContextActive: context.onToggleContextActive,
onViewContextDetail: context.onViewContextDetail,
}),
workflowPanelProps: buildThemeWorkbenchWorkflowPanelProps({
workflowPanelProps: buildGeneralWorkbenchWorkflowPanelProps({
isVersionMode,
branchItems: workflow.branchItems,
creationTaskEventsCount: workflow.creationTaskEvents.length,
@@ -112,7 +112,7 @@ export function useThemeWorkbenchSidebarOrchestration({
activeRunDetail: workflow.activeRunDetail,
activeRunDetailLoading: workflow.activeRunDetailLoading,
}),
execLogProps: buildThemeWorkbenchExecLogProps({
execLogProps: buildGeneralWorkbenchExecLogProps({
execLogState,
historyHasMore: execLog.historyHasMore,
historyLoading: execLog.historyLoading,
@@ -1,9 +1,9 @@
import { useEffect, useRef } from "react";
import { logRenderPerf } from "@/lib/perfDebug";
import type { ThemeWorkbenchSidebarTab } from "./ThemeWorkbenchSidebarShell";
import type { GeneralWorkbenchSidebarTab } from "./GeneralWorkbenchSidebarShell";
interface UseThemeWorkbenchSidebarTelemetryParams {
activeTab: ThemeWorkbenchSidebarTab;
interface UseGeneralWorkbenchSidebarTelemetryParams {
activeTab: GeneralWorkbenchSidebarTab;
showActivityLogs: boolean;
contextSearchLoading: boolean;
branchItemsCount: number;
@@ -15,7 +15,7 @@ interface UseThemeWorkbenchSidebarTelemetryParams {
hasActiveRunDetail: boolean;
}
export function useThemeWorkbenchSidebarTelemetry({
export function useGeneralWorkbenchSidebarTelemetry({
activeTab,
showActivityLogs,
contextSearchLoading,
@@ -26,7 +26,7 @@ export function useThemeWorkbenchSidebarTelemetry({
activityLogsCount,
creationTaskEventsCount,
hasActiveRunDetail,
}: UseThemeWorkbenchSidebarTelemetryParams) {
}: UseGeneralWorkbenchSidebarTelemetryParams) {
const renderCountRef = useRef(0);
const lastCommitAtRef = useRef<number | null>(null);
renderCountRef.current += 1;
@@ -37,7 +37,7 @@ export function useThemeWorkbenchSidebarTelemetry({
lastCommitAtRef.current === null ? null : now - lastCommitAtRef.current;
lastCommitAtRef.current = now;
logRenderPerf(
"ThemeWorkbenchSidebar",
"GeneralWorkbenchSidebar",
renderCountRef.current,
sinceLastCommitMs,
{
@@ -2,44 +2,44 @@ import { useCallback, useMemo, useState } from "react";
import type { StepStatus } from "@/lib/workspace/workbenchContract";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import {
buildThemeWorkbenchActivityLogGroups,
buildThemeWorkbenchCreationTaskGroups,
formatThemeWorkbenchRunMetadata,
formatThemeWorkbenchStagesLabel,
parseThemeWorkbenchRunMetadataSummary,
type ThemeWorkbenchCreationTaskEvent,
type ThemeWorkbenchRunMetadataSummary,
} from "./themeWorkbenchWorkflowData";
buildGeneralWorkbenchActivityLogGroups,
buildGeneralWorkbenchCreationTaskGroups,
formatGeneralWorkbenchRunMetadata,
formatGeneralWorkbenchStagesLabel,
parseGeneralWorkbenchRunMetadataSummary,
type GeneralWorkbenchCreationTaskEvent,
type GeneralWorkbenchRunMetadataSummary,
} from "./generalWorkbenchWorkflowData";
interface UseThemeWorkbenchWorkflowPanelStateParams {
interface UseGeneralWorkbenchWorkflowPanelStateParams {
workflowSteps: Array<{ id: string; title: string; status: StepStatus }>;
activityLogs: SidebarActivityLog[];
creationTaskEvents: ThemeWorkbenchCreationTaskEvent[];
creationTaskEvents: GeneralWorkbenchCreationTaskEvent[];
activeRunMetadata: string | null;
}
export interface ThemeWorkbenchWorkflowPanelState {
export interface GeneralWorkbenchWorkflowPanelState {
completedSteps: number;
progressPercent: number;
groupedActivityLogs: ReturnType<typeof buildThemeWorkbenchActivityLogGroups>;
groupedActivityLogs: ReturnType<typeof buildGeneralWorkbenchActivityLogGroups>;
groupedCreationTaskEvents: ReturnType<
typeof buildThemeWorkbenchCreationTaskGroups
typeof buildGeneralWorkbenchCreationTaskGroups
>;
activeRunStagesLabel: string | null;
runMetadataText: string;
runMetadataSummary: ThemeWorkbenchRunMetadataSummary;
runMetadataSummary: GeneralWorkbenchRunMetadataSummary;
showActivityLogs: boolean;
showCreationTasks: boolean;
toggleActivityLogs: () => void;
toggleCreationTasks: () => void;
}
export function useThemeWorkbenchWorkflowPanelState({
export function useGeneralWorkbenchWorkflowPanelState({
workflowSteps,
activityLogs,
creationTaskEvents,
activeRunMetadata,
}: UseThemeWorkbenchWorkflowPanelStateParams): ThemeWorkbenchWorkflowPanelState {
}: UseGeneralWorkbenchWorkflowPanelStateParams): GeneralWorkbenchWorkflowPanelState {
const [showActivityLogs, setShowActivityLogs] = useState(false);
const [showCreationTasks, setShowCreationTasks] = useState(true);
@@ -52,27 +52,27 @@ export function useThemeWorkbenchWorkflowPanelState({
workflowSteps.length > 0 ? (completedSteps / workflowSteps.length) * 100 : 0;
const groupedActivityLogs = useMemo(
() => buildThemeWorkbenchActivityLogGroups(activityLogs),
() => buildGeneralWorkbenchActivityLogGroups(activityLogs),
[activityLogs],
);
const groupedCreationTaskEvents = useMemo(
() => buildThemeWorkbenchCreationTaskGroups(creationTaskEvents),
() => buildGeneralWorkbenchCreationTaskGroups(creationTaskEvents),
[creationTaskEvents],
);
const runMetadataSummary = useMemo(
() => parseThemeWorkbenchRunMetadataSummary(activeRunMetadata),
() => parseGeneralWorkbenchRunMetadataSummary(activeRunMetadata),
[activeRunMetadata],
);
const runMetadataText = useMemo(
() => formatThemeWorkbenchRunMetadata(activeRunMetadata),
() => formatGeneralWorkbenchRunMetadata(activeRunMetadata),
[activeRunMetadata],
);
const activeRunStagesLabel = useMemo(
() => formatThemeWorkbenchStagesLabel(runMetadataSummary.stages),
() => formatGeneralWorkbenchStagesLabel(runMetadataSummary.stages),
[runMetadataSummary.stages],
);
@@ -8,77 +8,37 @@ import {
extractTaskPreviewFromMessages,
} from "./agentChatShared";
function createBrowserPreflightMessages(): Message[] {
return createBrowserPreflightMessagesByPhase("failed");
}
function createBrowserPreflightMessagesByPhase(
phase: "launching" | "awaiting_user" | "ready_to_resume" | "failed" = "failed",
function createPendingActionMessages(
prompt = "请先确认发布标题后继续执行。",
): Message[] {
const startedAt = new Date("2026-03-15T09:45:00.000Z");
const detail =
phase === "launching"
? "正在尝试建立浏览器会话,请稍候..."
: phase === "awaiting_user" || phase === "ready_to_resume"
? "已为你打开浏览器。请先完成登录、扫码或验证码,然后继续当前任务。"
: "还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。";
return [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我写一篇公众号发布文案",
timestamp: startedAt,
},
{
id: "msg-assistant-failed",
id: "msg-pending-action",
role: "assistant",
content: "",
timestamp: new Date(startedAt.getTime() + 1000),
toolCalls: [
{
id: "tool-cdp-failed",
name: "browser_connect",
arguments: JSON.stringify({ url: "https://mp.weixin.qq.com" }),
status: "failed",
startTime: new Date(startedAt.getTime() + 1000),
endTime: new Date(startedAt.getTime() + 2000),
},
],
},
{
id: "msg-preflight-user",
role: "user",
content: "帮我把文章发布到微信公众号",
timestamp: new Date(startedAt.getTime() + 3000),
},
{
id: "msg-preflight-assistant",
role: "assistant",
content: "",
timestamp: new Date(startedAt.getTime() + 3001),
actionRequests: [
{
requestId: "browser-preflight-1",
requestId: "req-user-action-1",
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt: "该任务需要真实浏览器执行,不能仅靠网页检索完成。",
detail,
prompt,
},
],
contentParts: [
{
type: "action_required",
actionRequired: {
requestId: "browser-preflight-1",
requestId: "req-user-action-1",
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt: "该任务需要真实浏览器执行,不能仅靠网页检索完成。",
detail,
prompt,
},
},
],
@@ -87,8 +47,8 @@ function createBrowserPreflightMessagesByPhase(
}
describe("agentChatShared", () => {
it("浏览器预检未提交时应优先判定为待处理", () => {
const messages = createBrowserPreflightMessages();
it("待处理 action request 未提交时应优先判定为待处理", () => {
const messages = createPendingActionMessages();
expect(
deriveTaskStatusFromLiveState({
@@ -109,7 +69,7 @@ describe("agentChatShared", () => {
).toEqual(
expect.objectContaining({
status: "waiting",
statusReason: "browser_failed",
statusReason: "user_action",
}),
);
@@ -122,15 +82,17 @@ describe("agentChatShared", () => {
}),
).toEqual({
status: "waiting",
statusReason: "browser_failed",
statusReason: "user_action",
});
});
it("应优先展示浏览器预检的待继续摘要", () => {
const messages = createBrowserPreflightMessages();
it("应优先展示待处理请求的摘要", () => {
const messages = createPendingActionMessages(
"请先确认发布标题后继续执行。",
);
expect(extractTaskPreviewFromMessages(messages)).toBe(
"还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。",
"请先确认发布标题后继续执行。",
);
});
@@ -168,29 +130,34 @@ describe("agentChatShared", () => {
expect(extractTaskPreviewFromMessages(messages)).toBe("图片处理中");
});
it("应按浏览器预检阶段映射不同状态原因", () => {
it("最新工具调用失败时应映射为工具失败", () => {
expect(
deriveTaskLiveState({
messages: createBrowserPreflightMessagesByPhase("launching"),
messages: [
{
id: "msg-tool-failed",
role: "assistant",
content: "",
timestamp: new Date("2026-03-15T09:45:01.000Z"),
toolCalls: [
{
id: "tool-1",
name: "write_file",
arguments: "{}",
status: "failed",
startTime: new Date("2026-03-15T09:45:01.000Z"),
endTime: new Date("2026-03-15T09:45:02.000Z"),
},
],
},
],
isSending: false,
pendingActionCount: 0,
workspaceError: false,
}),
).toEqual({
status: "waiting",
statusReason: "browser_launching",
});
expect(
deriveTaskLiveState({
messages: createBrowserPreflightMessagesByPhase("awaiting_user"),
isSending: false,
pendingActionCount: 0,
workspaceError: false,
}),
).toEqual({
status: "waiting",
statusReason: "browser_awaiting_user",
status: "failed",
statusReason: "tool_failure",
});
});
});
@@ -18,9 +18,6 @@ export type TaskStatus = "draft" | "running" | "waiting" | "done" | "failed";
export type TaskStatusReason =
| "default"
| "workspace_error"
| "browser_launching"
| "browser_awaiting_user"
| "browser_failed"
| "user_action"
| "tool_failure";
@@ -197,14 +194,6 @@ function extractActionRequestPreview(message: Message): string {
return "";
}
if (pendingAction.uiKind === "browser_preflight") {
return normalizeTaskPreviewText(
pendingAction.detail ||
pendingAction.prompt ||
"等待你启动或恢复浏览器后继续执行。",
);
}
const questionText = pendingAction.questions
?.map((question) => question.question || question.header || "")
.join(" ");
@@ -230,24 +219,9 @@ function resolveLatestPendingAction(messages: Message[]) {
}
function resolvePendingActionStatusReason(
pendingAction: NonNullable<ReturnType<typeof resolveLatestPendingAction>>,
_pendingAction: NonNullable<ReturnType<typeof resolveLatestPendingAction>>,
): TaskStatusReason {
if (pendingAction.uiKind !== "browser_preflight") {
return "user_action";
}
if (pendingAction.browserPrepState === "launching") {
return "browser_launching";
}
if (
pendingAction.browserPrepState === "awaiting_user" ||
pendingAction.browserPrepState === "ready_to_resume"
) {
return "browser_awaiting_user";
}
return "browser_failed";
return "user_action";
}
export function extractTaskPreviewFromMessages(messages: Message[]): string {
@@ -45,6 +45,11 @@ import {
} from "../utils/artifactToolSources";
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
import { buildContextRuntimeStatus } from "../utils/agentRuntimeStatus";
import {
buildImageTaskPreviewFromToolResult,
buildTaskPreviewFromToolResult,
buildToolResultArtifactFromToolResult,
} from "../utils/taskPreviewFromToolResult";
interface BaseProcessorContext {
assistantMsgId: string;
@@ -623,6 +628,16 @@ export function handleToolEndEvent({
return message;
}
const currentToolCall = message.toolCalls?.find(
(toolCall) => toolCall.id === data.tool_id,
);
const currentToolArguments = currentToolCall?.arguments;
const normalizedResultRecord =
normalizedResult &&
typeof normalizedResult === "object" &&
!Array.isArray(normalizedResult)
? (normalizedResult as unknown as Record<string, unknown>)
: undefined;
const updatedToolCalls = (message.toolCalls || []).map((toolCall) =>
toolCall.id === data.tool_id
? {
@@ -649,14 +664,94 @@ export function handleToolEndEvent({
};
});
const imageTaskPreview = buildImageTaskPreviewFromToolResult({
toolId: data.tool_id,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: normalizedResultRecord,
fallbackPrompt: message.content || "图片任务进行中",
});
const taskPreview = buildTaskPreviewFromToolResult({
toolId: data.tool_id,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: normalizedResultRecord,
fallbackPrompt: message.content || "任务进行中",
});
return {
...message,
toolCalls: updatedToolCalls,
contentParts: updatedContentParts,
imageWorkbenchPreview: imageTaskPreview
? {
...(message.imageWorkbenchPreview || {}),
...imageTaskPreview,
}
: message.imageWorkbenchPreview,
taskPreview: taskPreview
? {
...(message.taskPreview || {}),
...taskPreview,
}
: message.taskPreview,
};
}),
);
const normalizedResultRecord =
normalizedResult &&
typeof normalizedResult === "object" &&
!Array.isArray(normalizedResult)
? (normalizedResult as unknown as Record<string, unknown>)
: undefined;
const toolResultArtifact = buildToolResultArtifactFromToolResult({
toolId: data.tool_id,
toolName,
toolArguments: undefined,
toolResult: normalizedResultRecord,
fallbackPrompt: "",
});
if (toolResultArtifact) {
const writeContext: WriteArtifactContext = {
artifactId: `artifact:${assistantMsgId}:${toolResultArtifact.filePath}`,
source: "tool_result",
sourceMessageId: assistantMsgId,
status: isSuccess ? "complete" : "error",
metadata: buildWriteMetadata(toolResultArtifact.metadata, {
source: "tool_result",
phase: isSuccess ? "completed" : "failed",
content: toolResultArtifact.content,
isPartial: false,
}),
};
const nextArtifact = upsertAssistantWriteArtifact({
assistantMsgId,
setMessages,
filePath: toolResultArtifact.filePath,
content: toolResultArtifact.content,
context: writeContext,
});
const emittedArtifact =
nextArtifact ||
buildArtifactFromWrite({
filePath: toolResultArtifact.filePath,
content: toolResultArtifact.content,
context: writeContext,
});
if (emittedArtifact) {
onWriteFile?.(emittedArtifact.content, toolResultArtifact.filePath, {
artifact: emittedArtifact,
artifactId: emittedArtifact.id,
source: "tool_result",
sourceMessageId: assistantMsgId,
status: emittedArtifact.status,
metadata: emittedArtifact.meta,
});
}
}
const artifactPaths = extractArtifactProtocolPaths(normalizedResult.metadata);
if (artifactPaths.length === 0) {
refreshAssistantArtifactDocumentsFromToolSources({
@@ -55,6 +55,9 @@ const preparedSend: PreparedAgentStreamUserInputSend = {
describe("agentStreamPreparedSendDispatch", () => {
function createEnv(): AgentStreamPreparedSendEnv {
const runPreparedSubmit = vi.fn(
async <T,>(task: () => Promise<T>) => task(),
) as unknown as AgentStreamPreparedSendEnv["runPreparedSubmit"];
return {
runtime: {} as never,
ensureSession: async () => "session-1",
@@ -65,6 +68,8 @@ describe("agentStreamPreparedSendDispatch", () => {
sessionIdRef: { current: null } as MutableRefObject<string | null>,
getQueuedTurnsCount: () => 0,
isThreadBusy: () => false,
hasPendingPreparedSubmit: () => false,
runPreparedSubmit,
getRequiredWorkspaceId: () => "workspace-1",
getSyncedSessionModelPreference: () => null,
getSyncedSessionExecutionStrategy: (_sessionId) => "react",
@@ -106,12 +111,14 @@ describe("agentStreamPreparedSendDispatch", () => {
it("slash preflight 未处理时继续普通 user_input submit", async () => {
vi.mocked(maybeHandleSlashSkillBeforeSend).mockResolvedValueOnce(false);
const env = createEnv();
await dispatchPreparedAgentStreamSend({
preparedSend,
env: createEnv(),
env,
});
expect(vi.mocked(env.runPreparedSubmit)).toHaveBeenCalledTimes(1);
expect(submitAgentStreamUserInput).toHaveBeenCalledWith(
expect.objectContaining({ preparedSend }),
);
@@ -22,8 +22,10 @@ export async function dispatchPreparedAgentStreamSend(
return;
}
await submitAgentStreamUserInput({
preparedSend,
env,
});
await env.runPreparedSubmit(() =>
submitAgentStreamUserInput({
preparedSend,
env,
}),
);
}
@@ -26,6 +26,8 @@ describe("createAgentStreamPreparedSendEnv", () => {
providerTypeRef: { current: "openai" } as MutableRefObject<string>,
modelRef: { current: "gpt-5.4" } as MutableRefObject<string>,
sessionIdRef: { current: "session-1" } as MutableRefObject<string | null>,
hasPendingPreparedSubmit: () => false,
runPreparedSubmit: async (task) => task(),
getRequiredWorkspaceId: () => "workspace-1",
getSyncedSessionModelPreference: () => null,
getSyncedSessionExecutionStrategy: () => "react",
@@ -30,6 +30,8 @@ export interface AgentStreamPreparedSendEnv {
sessionIdRef: MutableRefObject<string | null>;
getQueuedTurnsCount: () => number;
isThreadBusy: () => boolean;
hasPendingPreparedSubmit: () => boolean;
runPreparedSubmit: <T>(task: () => Promise<T>) => Promise<T>;
getRequiredWorkspaceId: () => string;
getSyncedSessionModelPreference: (
sessionId: string,
@@ -70,7 +72,10 @@ export interface AgentStreamPreparedSendEnv {
}
export interface CreateAgentStreamPreparedSendEnvOptions
extends Omit<AgentStreamPreparedSendEnv, "getQueuedTurnsCount" | "isThreadBusy"> {
extends Omit<
AgentStreamPreparedSendEnv,
"getQueuedTurnsCount" | "isThreadBusy"
> {
queuedTurnsCount: number;
threadBusy: boolean;
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { AgentStreamSubmitGate } from "./agentStreamSubmitGate";
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
describe("AgentStreamSubmitGate", () => {
it("应串行执行 submit 任务,并在排队期间暴露 pending 状态", async () => {
const gate = new AgentStreamSubmitGate();
const firstDeferred = createDeferred<void>();
const executionOrder: string[] = [];
const firstRun = gate.run(async () => {
executionOrder.push("first:start");
await firstDeferred.promise;
executionOrder.push("first:end");
});
expect(gate.hasPending()).toBe(true);
expect(gate.getPendingCount()).toBe(1);
const secondRun = gate.run(async () => {
executionOrder.push("second:start");
executionOrder.push("second:end");
});
expect(gate.hasPending()).toBe(true);
expect(gate.getPendingCount()).toBe(2);
await Promise.resolve();
expect(executionOrder).toEqual(["first:start"]);
firstDeferred.resolve();
await firstRun;
await secondRun;
expect(executionOrder).toEqual([
"first:start",
"first:end",
"second:start",
"second:end",
]);
expect(gate.hasPending()).toBe(false);
expect(gate.getPendingCount()).toBe(0);
});
});
@@ -0,0 +1,33 @@
export class AgentStreamSubmitGate {
private tail: Promise<void> = Promise.resolve();
private pendingCount = 0;
hasPending() {
return this.pendingCount > 0;
}
getPendingCount() {
return this.pendingCount;
}
async run<T>(task: () => Promise<T>): Promise<T> {
const previous = this.tail;
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
this.pendingCount += 1;
this.tail = previous.then(() => current);
await previous;
try {
return await task();
} finally {
this.pendingCount = Math.max(0, this.pendingCount - 1);
releaseCurrent();
}
}
}
@@ -45,6 +45,7 @@ describe("agentStreamUserInputSendPreparation", () => {
} as MutableRefObject<ActiveStreamState | null>,
getQueuedTurnsCount: () => options?.queuedTurnsCount ?? 0,
isThreadBusy: () => options?.threadBusy ?? false,
hasPendingPreparedSubmit: () => false,
getSyncedSessionModelPreference: () => ({
providerType: "openai",
model: "gpt-5.4",
@@ -218,4 +219,37 @@ describe("agentStreamUserInputSendPreparation", () => {
expect(messages[1]?.runtimeStatus?.title).toBe("已加入排队列表");
expect(isSending).toBe(false);
});
it("首轮 submit 尚未完成时也应提前进入 queue 模式", () => {
vi.spyOn(crypto, "randomUUID")
.mockReturnValueOnce("00000000-0000-0000-0000-000000000006")
.mockReturnValueOnce("00000000-0000-0000-0000-000000000007");
let messages: Message[] = [];
let isSending = false;
const env = {
...createEnv({
sessionId: null,
}),
hasPendingPreparedSubmit: () => true,
setMessages: createStateSetter(() => messages, (value) => {
messages = value;
}),
setIsSending: createStateSetter(() => isSending, (value) => {
isSending = value;
}),
};
const result = prepareAgentStreamUserInputSend({
content: "继续处理首页首轮提交",
images: [],
skipUserMessage: false,
env,
});
expect(result.expectingQueue).toBe(true);
expect(messages).toHaveLength(2);
expect(messages[1]?.runtimeStatus?.title).toBe("已加入排队列表");
expect(isSending).toBe(false);
});
});
@@ -18,6 +18,7 @@ export type AgentStreamUserInputSendPreparationEnv = Pick<
| "activeStreamRef"
| "getQueuedTurnsCount"
| "isThreadBusy"
| "hasPendingPreparedSubmit"
| "getSyncedSessionModelPreference"
| "setMessages"
| "setIsSending"
@@ -94,7 +95,8 @@ export function prepareAgentStreamUserInputSend(
const expectingQueue =
Boolean(env.activeStreamRef.current) ||
env.getQueuedTurnsCount() > 0 ||
env.isThreadBusy();
env.isThreadBusy() ||
env.hasPendingPreparedSubmit();
const assistantMsgId = crypto.randomUUID();
const userMsgId = skipUserMessage ? null : crypto.randomUUID();
const { assistantMsg } = prepareAgentStreamSubmitDraft({
@@ -64,6 +64,8 @@ describe("agentStreamUserInputSubmission", () => {
sessionIdRef: { current: null } as MutableRefObject<string | null>,
getQueuedTurnsCount: () => 0,
isThreadBusy: () => false,
hasPendingPreparedSubmit: () => false,
runPreparedSubmit: async (task) => task(),
getRequiredWorkspaceId: () => "workspace-1",
getSyncedSessionModelPreference: () => null,
getSyncedSessionExecutionStrategy: () => "react",
@@ -1,7 +1,5 @@
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
import type { SlashSkillRequest } from "./agentChatShared";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { BrowserPreflightState, BrowserTaskRequirement, MessageImage } from "../types";
export interface HandleSendObserver {
onComplete?: (content: string) => void;
@@ -17,21 +15,3 @@ export interface HandleSendOptions {
displayContent?: string;
skillRequest?: SlashSkillRequest;
}
export interface BrowserTaskPreflight {
requestId: string;
createdAt: number;
sourceText: string;
images: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
requirement: BrowserTaskRequirement;
reason: string;
phase: BrowserPreflightState;
launchUrl: string;
platformLabel?: string;
detail?: string;
}
@@ -20,10 +20,9 @@ vi.mock("@/lib/dev-bridge", () => ({
}));
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
const actual = await vi.importActual<
typeof import("@/lib/api/agentProtocol")
>("@/lib/api/agentProtocol");
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
@@ -88,11 +87,12 @@ afterEach(() => {
describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
it("不应再归一非当前 skill 名", () => {
expect(parseSkillSlashCommand("/legacy_content_post 写一版主稿"))
.toMatchObject({
skillName: "legacy_content_post",
userInput: "写一版主稿",
});
expect(
parseSkillSlashCommand("/legacy_content_post 写一版主稿"),
).toMatchObject({
skillName: "legacy_content_post",
userInput: "写一版主稿",
});
});
it("当后端连续发出 write_file 工具事件时应写入主稿与辅助产物", async () => {
@@ -433,7 +433,8 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
task_type: "image_generate",
task_family: "image",
status: "pending_submit",
artifact_path: ".lime/tasks/image_generate/task-image-skill-1.json",
artifact_path:
".lime/tasks/image_generate/task-image-skill-1.json",
},
},
},
@@ -479,6 +480,234 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
});
});
it("视频 skill 返回媒体任务 metadata 时应直接挂载通用任务预览卡", async () => {
const store = createMessageStore([buildBaseMessage("assistant-video-1")]);
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
mockListExecutableSkills.mockResolvedValueOnce([
{
name: "video_generate",
display_name: "video_generate",
description: "video",
execution_mode: "prompt",
has_workflow: false,
},
]);
mockSafeListen.mockImplementation(async (_eventName, handler) => {
streamHandler = handler as (event: { payload: unknown }) => void;
return () => {
streamHandler = null;
};
});
mockExecuteSkill.mockImplementation(async () => {
streamHandler?.({
payload: {
type: "tool_start",
tool_id: "tool-video-1",
tool_name: "Bash",
arguments: JSON.stringify({
command:
"lime task create video --prompt '新品发布会短视频' --duration 15 --aspect-ratio 16:9 --resolution 720p --json",
}),
},
});
streamHandler?.({
payload: {
type: "tool_end",
tool_id: "tool-video-1",
result: {
success: true,
output: "任务已提交",
metadata: {
task_id: "task-video-skill-1",
task_type: "video_generate",
task_family: "video",
status: "pending_submit",
project_id: "project-video-1",
content_id: "content-video-1",
artifact_path:
".lime/tasks/video_generate/task-video-skill-1.json",
},
},
},
});
streamHandler?.({ payload: { type: "final_done" } });
return {
success: true,
output:
"任务类型:video_generate\n任务 ID:task-video-skill-1\n状态:pending_submit",
steps_completed: [],
};
});
const handled = await tryExecuteSlashSkillCommand({
command: {
skillName: "video_generate",
userInput: "新品发布会短视频",
},
rawContent: "/video_generate 新品发布会短视频",
assistantMsgId: "assistant-video-1",
providerType: "openai",
model: "gpt-5.4",
ensureSession: async () => "session-video-1",
setMessages: store.setMessages,
setIsSending: vi.fn(),
setCurrentAssistantMsgId: vi.fn(),
setStreamUnlisten: vi.fn(),
setActiveSessionIdForStop: vi.fn(),
isExecutionCancelled: () => false,
playTypewriterSound: vi.fn(),
playToolcallSound: vi.fn(),
});
expect(handled).toBe(true);
expect(store.getMessages()[0]).toMatchObject({
taskPreview: {
kind: "video_generate",
taskId: "task-video-skill-1",
prompt: "新品发布会短视频",
status: "running",
durationSeconds: 15,
aspectRatio: "16:9",
resolution: "720p",
projectId: "project-video-1",
contentId: "content-video-1",
phase: "queued",
},
});
});
it("素材 skill 直搜图片时应挂载图片候选预览与 artifact", async () => {
const store = createMessageStore([
buildBaseMessage("assistant-resource-1"),
]);
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
mockListExecutableSkills.mockResolvedValueOnce([
{
name: "modal_resource_search",
display_name: "modal_resource_search",
description: "resource",
execution_mode: "prompt",
has_workflow: false,
},
]);
mockSafeListen.mockImplementation(async (_eventName, handler) => {
streamHandler = handler as (event: { payload: unknown }) => void;
return () => {
streamHandler = null;
};
});
mockExecuteSkill.mockImplementation(async () => {
streamHandler?.({
payload: {
type: "tool_start",
tool_id: "tool-resource-web-1",
tool_name: "lime_search_web_images",
arguments: JSON.stringify({
query: "cozy coffee table",
count: 3,
aspect: "landscape",
}),
},
});
streamHandler?.({
payload: {
type: "tool_end",
tool_id: "tool-resource-web-1",
result: {
success: true,
output: "已找到 3 张图片候选",
metadata: {
provider: "pexels",
result: {
provider: "pexels",
query: "cozy coffee table",
returnedCount: 3,
aspect: "landscape",
hits: [
{
id: "hit-1",
thumbnail_url: "https://pexels.example/1-thumb.jpg",
content_url: "https://pexels.example/1.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 1",
host_page_url: "https://www.pexels.com/photo/1",
},
{
id: "hit-2",
thumbnail_url: "https://pexels.example/2-thumb.jpg",
content_url: "https://pexels.example/2.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 2",
host_page_url: "https://www.pexels.com/photo/2",
},
{
id: "hit-3",
thumbnail_url: "https://pexels.example/3-thumb.jpg",
content_url: "https://pexels.example/3.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 3",
host_page_url: "https://www.pexels.com/photo/3",
},
],
},
},
},
},
});
streamHandler?.({ payload: { type: "final_done" } });
return {
success: true,
output: "已返回 3 张图片候选",
steps_completed: [],
};
});
const handled = await tryExecuteSlashSkillCommand({
command: {
skillName: "modal_resource_search",
userInput: "找一组咖啡馆木桌背景图",
},
rawContent: "/modal_resource_search 找一组咖啡馆木桌背景图",
assistantMsgId: "assistant-resource-1",
providerType: "openai",
model: "gpt-5.4",
ensureSession: async () => "session-resource-1",
setMessages: store.setMessages,
setIsSending: vi.fn(),
setCurrentAssistantMsgId: vi.fn(),
setStreamUnlisten: vi.fn(),
setActiveSessionIdForStop: vi.fn(),
isExecutionCancelled: () => false,
playTypewriterSound: vi.fn(),
playToolcallSound: vi.fn(),
});
expect(handled).toBe(true);
expect(store.getMessages()[0]).toMatchObject({
taskPreview: {
kind: "modal_resource_search",
taskId: "resource-search:tool-resource-web-1",
status: "complete",
artifactPath: ".lime/runtime/resource-search/tool-resource-web-1.md",
},
artifacts: [
expect.objectContaining({
title: "tool-resource-web-1.md",
type: "document",
}),
],
});
});
it("slash skill 执行时应透传图片与结构化请求上下文", async () => {
const store = createMessageStore([buildBaseMessage()]);
mockListExecutableSkills.mockResolvedValueOnce([
@@ -556,7 +785,9 @@ describe("tryExecuteSlashSkillCommand 浏览器工具链路", () => {
it("收到 tool_end error 时应清洗 Lime 元数据块", async () => {
let messages: Message[] = [buildBaseMessage("assistant-skill-1")];
const setMessages = (next: Message[] | ((prev: Message[]) => Message[])) => {
const setMessages = (
next: Message[] | ((prev: Message[]) => Message[]),
) => {
messages = typeof next === "function" ? next(messages) : next;
};
+128 -193
View File
@@ -12,6 +12,7 @@ import type {
Message,
MessageImage,
MessageImageWorkbenchPreview,
MessageTaskPreview,
WriteArtifactContext,
} from "../types";
import {
@@ -26,6 +27,16 @@ import {
CONTENT_POST_OUTPUT_DIR,
CONTENT_POST_SKILL_KEY,
} from "../utils/contentPostSkill";
import {
buildImageTaskPreviewFromToolResult,
buildTaskPreviewFromToolResult,
buildToolResultArtifactFromToolResult,
} from "../utils/taskPreviewFromToolResult";
import {
buildArtifactFromWrite,
findMessageArtifact,
upsertMessageArtifact,
} from "../utils/messageArtifacts";
/** 解析 /skill-name args 命令 */
export interface ParsedSkillCommand {
@@ -271,189 +282,6 @@ function resolveSnapshotStatus(
return "complete";
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readMetadataString(
candidates: Array<Record<string, unknown> | null | undefined>,
keys: string[],
): string | undefined {
for (const candidate of candidates) {
if (!candidate) {
continue;
}
for (const key of keys) {
const value = candidate[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
}
return undefined;
}
function readMetadataPositiveNumber(
candidates: Array<Record<string, unknown> | null | undefined>,
keys: string[],
): number | undefined {
for (const candidate of candidates) {
if (!candidate) {
continue;
}
for (const key of keys) {
const value = candidate[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
}
}
return undefined;
}
function resolveImageTaskPreviewStatus(
status: string | undefined,
): MessageImageWorkbenchPreview["status"] {
switch ((status || "").trim().toLowerCase()) {
case "completed":
case "success":
case "succeeded":
return "complete";
case "partial":
return "partial";
case "failed":
case "error":
return "failed";
case "cancelled":
case "canceled":
return "cancelled";
case "running":
case "processing":
case "in_progress":
case "queued":
case "pending_submit":
case "pending":
default:
return "running";
}
}
function resolveImageTaskPreviewPhase(status: string | undefined): string {
switch ((status || "").trim().toLowerCase()) {
case "queued":
case "pending_submit":
case "pending":
return "queued";
case "running":
case "processing":
case "in_progress":
return "running";
default:
return "queued";
}
}
function extractImageTaskPromptFromToolArguments(
toolName: string,
toolArguments: string | undefined,
): { prompt?: string; size?: string; imageCount?: number } {
if (!toolArguments) {
return {};
}
try {
const parsed = JSON.parse(toolArguments) as Record<string, unknown>;
const prompt = readMetadataString([parsed], ["prompt"]);
const size = readMetadataString([parsed], ["size", "resolution"]);
const imageCount = readMetadataPositiveNumber([parsed], [
"count",
"image_count",
"imageCount",
]);
const command =
typeof parsed.command === "string" ? parsed.command.trim() : undefined;
if (prompt || size || imageCount || !command) {
return { prompt, size, imageCount };
}
if (
toolName.trim().toLowerCase() === "bash" &&
(command.includes("lime media image generate") ||
command.includes("lime task create image"))
) {
const promptMatch =
command.match(/--prompt\s+"([^"]+)"/) ||
command.match(/--prompt\s+'([^']+)'/) ||
command.match(/--prompt\s+(\S+)/);
const sizeMatch = command.match(/--size\s+(\S+)/);
const countMatch = command.match(/--count\s+(\d+)/);
return {
prompt: promptMatch?.[1]?.trim(),
size: sizeMatch?.[1]?.trim(),
imageCount: countMatch?.[1]
? Number.parseInt(countMatch[1], 10)
: undefined,
};
}
} catch {
return {};
}
return {};
}
function buildImageTaskPreviewFromToolResult(params: {
toolName: string;
toolArguments: string | undefined;
toolResult: Record<string, unknown> | undefined;
fallbackPrompt: string;
}): MessageImageWorkbenchPreview | null {
const resultRecord = asRecord(params.toolResult);
const metadata = asRecord(resultRecord?.metadata);
const taskId = readMetadataString([metadata], ["task_id", "taskId"]);
const taskType = readMetadataString([metadata], ["task_type", "taskType"]);
if (!taskId || !taskType) {
return null;
}
const normalizedTaskType = taskType.trim().toLowerCase();
if (
!normalizedTaskType.includes("image") &&
!normalizedTaskType.includes("cover")
) {
return null;
}
const parsedArguments = extractImageTaskPromptFromToolArguments(
params.toolName,
params.toolArguments,
);
const status = readMetadataString([metadata], ["status"]);
return {
taskId,
prompt:
parsedArguments.prompt ||
params.fallbackPrompt.trim() ||
"图片任务进行中",
status: resolveImageTaskPreviewStatus(status),
imageCount: parsedArguments.imageCount,
size: parsedArguments.size,
phase: resolveImageTaskPreviewPhase(status),
statusMessage: "任务已提交到异步队列,正在同步任务状态。",
};
}
function mergeImageTaskPreviewIntoAssistantMessage(params: {
messages: Message[];
assistantMsgId: string;
@@ -481,6 +309,33 @@ function mergeImageTaskPreviewIntoAssistantMessage(params: {
);
}
function mergeTaskPreviewIntoAssistantMessage(params: {
messages: Message[];
assistantMsgId: string;
preview: MessageTaskPreview;
}): Message[] {
const duplicatePreviewMessage = params.messages.find(
(message) =>
message.role === "assistant" &&
message.id !== params.assistantMsgId &&
message.taskPreview?.taskId === params.preview.taskId,
);
return params.messages
.filter((message) => message.id !== duplicatePreviewMessage?.id)
.map((message) =>
message.id === params.assistantMsgId
? {
...message,
taskPreview: {
...(message.taskPreview || {}),
...params.preview,
},
}
: message,
);
}
interface MatchedSkillResult {
matchedSkill: ExecutableSkillInfo | null;
catalogLoadFailed: boolean;
@@ -721,11 +576,23 @@ export async function tryExecuteSlashSkillCommand(
case "tool_end": {
streamCounters.tool_end += 1;
const normalizedResult =
normalizeIncomingToolResult(streamEvent.result) || streamEvent.result;
normalizeIncomingToolResult(streamEvent.result) ||
streamEvent.result;
const success = isToolResultSuccessful(normalizedResult);
const normalizedResultRecord =
normalizedResult &&
typeof normalizedResult === "object" &&
!Array.isArray(normalizedResult)
? (normalizedResult as unknown as Record<string, unknown>)
: undefined;
let emittedArtifact: ReturnType<
typeof buildArtifactFromWrite
> | null = null;
let emittedArtifactPath: string | null = null;
setMessages((prev) =>
(() => {
let imageTaskPreview: MessageImageWorkbenchPreview | null = null;
let taskPreview: MessageTaskPreview | null = null;
const patched = prev.map((msg) => {
if (msg.id !== assistantMsgId) return msg;
const currentToolCall = msg.toolCalls?.find(
@@ -733,11 +600,27 @@ export async function tryExecuteSlashSkillCommand(
);
const currentToolArguments = currentToolCall?.arguments;
imageTaskPreview = buildImageTaskPreviewFromToolResult({
toolId: streamEvent.tool_id,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: asRecord(normalizedResult) || undefined,
toolResult: normalizedResultRecord,
fallbackPrompt: command.userInput || rawContent,
});
taskPreview = buildTaskPreviewFromToolResult({
toolId: streamEvent.tool_id,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: normalizedResultRecord,
fallbackPrompt: command.userInput || rawContent,
});
const toolResultArtifact =
buildToolResultArtifactFromToolResult({
toolId: streamEvent.tool_id,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: normalizedResultRecord,
fallbackPrompt: command.userInput || rawContent,
});
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
tc.id === streamEvent.tool_id
@@ -772,24 +655,76 @@ export async function tryExecuteSlashSkillCommand(
return part;
});
return {
const nextMessage = {
...msg,
toolCalls: updatedToolCalls,
contentParts: updatedParts,
};
if (!toolResultArtifact) {
return nextMessage;
}
const existingArtifact = findMessageArtifact(nextMessage, {
filePath: toolResultArtifact.filePath,
});
emittedArtifactPath = toolResultArtifact.filePath;
emittedArtifact = buildArtifactFromWrite({
filePath: toolResultArtifact.filePath,
content: toolResultArtifact.content,
context: {
artifact: existingArtifact,
artifactId:
existingArtifact?.id ||
`artifact:${assistantMsgId}:${toolResultArtifact.filePath}`,
source: "tool_result",
sourceMessageId: assistantMsgId,
status: success ? "complete" : "error",
metadata: {
...toolResultArtifact.metadata,
writePhase: success ? "completed" : "failed",
},
},
});
return upsertMessageArtifact(nextMessage, emittedArtifact);
});
if (!imageTaskPreview) {
return patched;
let nextMessages = patched;
if (imageTaskPreview) {
nextMessages = mergeImageTaskPreviewIntoAssistantMessage({
messages: nextMessages,
assistantMsgId,
preview: imageTaskPreview,
});
}
return mergeImageTaskPreviewIntoAssistantMessage({
messages: patched,
assistantMsgId,
preview: imageTaskPreview,
});
if (taskPreview) {
nextMessages = mergeTaskPreviewIntoAssistantMessage({
messages: nextMessages,
assistantMsgId,
preview: taskPreview,
});
}
return nextMessages;
})(),
);
const finalizedArtifact =
emittedArtifact as ReturnType<typeof buildArtifactFromWrite> | null;
const finalizedArtifactPath = emittedArtifactPath;
if (finalizedArtifact && finalizedArtifactPath) {
onWriteFile?.(finalizedArtifact.content, finalizedArtifactPath, {
artifact: finalizedArtifact,
artifactId: finalizedArtifact.id,
source: "tool_result",
sourceMessageId: assistantMsgId,
status: finalizedArtifact.status,
metadata: finalizedArtifact.meta,
});
}
break;
}
case "artifact_snapshot": {
@@ -184,10 +184,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
useState<AsterSubagentParentContext | null>(null);
const [topics, setTopics] = useState<Topic[]>([]);
const [topicsReady, setTopicsReady] = useState(false);
const [isAutoRestoringSession, setIsAutoRestoringSession] = useState(
() => !disableSessionRestore && Boolean(workspaceId?.trim()),
);
const restoredWorkspaceRef = useRef<string | null>(null);
const hydratedSessionRef = useRef<string | null>(null);
const skipAutoRestoreRef = useRef(false);
const createFreshSessionPromiseRef = useRef<Promise<string | null> | null>(
null,
);
const missingSessionVerificationRef = useRef<string | null>(null);
const sessionStateWorkspaceRef = useRef<string | null>(
workspaceId?.trim() || null,
@@ -345,6 +351,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
if (disableSessionRestore || !workspaceId?.trim()) {
sessionStateWorkspaceRef.current = null;
applySessionSnapshot(createEmptyAgentSessionSnapshot());
setIsAutoRestoringSession(false);
resetPendingActions();
resetStreamingRefs();
restoredWorkspaceRef.current = null;
@@ -355,6 +362,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}
sessionStateWorkspaceRef.current = workspaceId.trim();
setIsAutoRestoringSession(true);
const scopedSessionCandidate = loadScopedSessionRestoreCandidate();
const scopedMessages = loadTransient<Message[]>(scopedKeys.messagesKey, []);
const scopedTurns = loadTransient<AgentThreadTurn[]>(
@@ -494,114 +502,114 @@ export function useAgentSession(options: UseAgentSessionOptions) {
const createFreshSession = useCallback(
async (sessionName?: string): Promise<string | null> => {
if (createFreshSessionPromiseRef.current) {
return createFreshSessionPromiseRef.current;
}
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
toast.error("缺少项目工作区,请先选择项目");
return null;
}
try {
logAgentDebug("useAgentSession", "createFreshSession.start", {
executionStrategy,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
});
const newSessionId = await runtime.createSession(
resolvedWorkspaceId,
sessionName,
executionStrategy,
);
const now = new Date();
setSessionId(newSessionId);
setThreadTurns([]);
setThreadItems([]);
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
setTopics((prev) => [
{
id: newSessionId,
title: sessionName?.trim() || "新任务",
createdAt: now,
updatedAt: now,
workspaceId: resolvedWorkspaceId,
messagesCount: 0,
const creationPromise = (async () => {
try {
logAgentDebug("useAgentSession", "createFreshSession.start", {
executionStrategy,
status: "draft",
lastPreview: "等待你补充任务需求后开始执行。",
isPinned: false,
hasUnread: false,
tag: null,
sourceSessionId: newSessionId,
},
...prev.filter((topic) => topic.id !== newSessionId),
]);
resetPendingActions();
resetStreamingRefs();
hydratedSessionRef.current = newSessionId;
skipAutoRestoreRef.current = false;
restoredWorkspaceRef.current = resolvedWorkspaceId;
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
});
const newSessionId = await runtime.createSession(
resolvedWorkspaceId,
sessionName,
executionStrategy,
);
persistSessionModelPreference(
newSessionId,
providerTypeRef.current,
modelRef.current,
);
persistSessionAccessMode(newSessionId, accessMode);
markSessionExecutionStrategySynced(newSessionId, executionStrategy);
void runtime
.setSessionProviderSelection(
sessionIdRef.current = newSessionId;
const now = new Date();
setSessionId(newSessionId);
setThreadTurns([]);
setThreadItems([]);
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
setIsAutoRestoringSession(false);
setTopics((prev) => [
{
id: newSessionId,
title: sessionName?.trim() || "新任务",
createdAt: now,
updatedAt: now,
workspaceId: resolvedWorkspaceId,
messagesCount: 0,
executionStrategy,
status: "draft",
lastPreview: "等待你补充任务需求后开始执行。",
isPinned: false,
hasUnread: false,
tag: null,
sourceSessionId: newSessionId,
},
...prev.filter((topic) => topic.id !== newSessionId),
]);
resetPendingActions();
resetStreamingRefs();
hydratedSessionRef.current = newSessionId;
skipAutoRestoreRef.current = false;
restoredWorkspaceRef.current = resolvedWorkspaceId;
persistSessionModelPreference(
newSessionId,
providerTypeRef.current,
modelRef.current,
)
.then(() => {
markSessionModelPreferenceSynced(
newSessionId,
providerTypeRef.current,
modelRef.current,
);
})
.catch((error) => {
console.warn("[AsterChat] 新会话回写 provider/model 失败:", error);
});
void runtime
.setSessionAccessMode?.(newSessionId, accessMode)
.catch((error) => {
console.warn("[AsterChat] 新会话回写 accessMode 失败:", error);
});
persistSessionRestoreCandidate(newSessionId);
saveTransient(scopedKeys.messagesKey, []);
saveTransient(scopedKeys.turnsKey, []);
saveTransient(scopedKeys.itemsKey, []);
saveTransient(scopedKeys.currentTurnKey, null);
);
persistSessionAccessMode(newSessionId, accessMode);
markSessionExecutionStrategySynced(newSessionId, executionStrategy);
persistSessionRestoreCandidate(newSessionId);
saveTransient(scopedKeys.messagesKey, []);
saveTransient(scopedKeys.turnsKey, []);
saveTransient(scopedKeys.itemsKey, []);
saveTransient(scopedKeys.currentTurnKey, null);
void loadTopics();
logAgentDebug("useAgentSession", "createFreshSession.success", {
newSessionId,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
});
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建新任务失败:", error);
logAgentDebug(
"useAgentSession",
"createFreshSession.error",
{
error,
void loadTopics();
logAgentDebug("useAgentSession", "createFreshSession.success", {
newSessionId,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
},
{ level: "error" },
);
toast.error(`创建新任务失败: ${error}`);
return null;
}
});
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建新任务失败:", error);
logAgentDebug(
"useAgentSession",
"createFreshSession.error",
{
error,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
},
{ level: "error" },
);
toast.error(`创建新任务失败: ${error}`);
return null;
}
})();
const trackCreationPromise = (promise: Promise<string | null>) =>
promise.finally(() => {
if (createFreshSessionPromiseRef.current === promise) {
createFreshSessionPromiseRef.current = null;
}
});
const trackedCreationPromise = trackCreationPromise(creationPromise);
createFreshSessionPromiseRef.current = trackedCreationPromise;
return trackedCreationPromise;
},
[
accessMode,
@@ -611,12 +619,12 @@ export function useAgentSession(options: UseAgentSessionOptions) {
markSessionExecutionStrategySynced,
persistSessionModelPreference,
persistSessionAccessMode,
markSessionModelPreferenceSynced,
persistSessionRestoreCandidate,
providerTypeRef,
resetPendingActions,
resetStreamingRefs,
runtime,
sessionIdRef,
scopedKeys,
workspaceId,
],
@@ -633,6 +641,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
executionRuntime: executionRuntimeRef.current,
}),
);
setIsAutoRestoringSession(false);
resetPendingActions();
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
@@ -909,6 +918,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
);
});
}
setIsAutoRestoringSession(false);
} catch (error) {
console.error("[AsterChat] 切换话题失败:", error);
console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2));
@@ -926,10 +936,12 @@ export function useAgentSession(options: UseAgentSessionOptions) {
applySessionSnapshot(createEmptyAgentSessionSnapshot());
persistSessionRestoreCandidate(null);
void loadTopics();
setIsAutoRestoringSession(false);
return;
}
applySessionSnapshot(createEmptyAgentSessionSnapshot());
persistSessionRestoreCandidate(null);
setIsAutoRestoringSession(false);
toast.error(
`加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`,
);
@@ -1040,6 +1052,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
topics,
);
if (!targetSessionId) {
setIsAutoRestoringSession(false);
logAgentDebug(
"useAgentSession",
"autoRestore.skipWithoutTarget",
@@ -1053,6 +1066,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
return;
}
let cancelled = false;
setIsAutoRestoringSession(true);
logAgentDebug("useAgentSession", "autoRestore.start", {
candidateSessionId: scopedCandidate,
targetSessionId,
@@ -1073,7 +1088,15 @@ export function useAgentSession(options: UseAgentSessionOptions) {
{ level: "warn" },
);
persistSessionRestoreCandidate(null);
}).finally(() => {
if (!cancelled) {
setIsAutoRestoringSession(false);
}
});
return () => {
cancelled = true;
};
}, [
disableSessionRestore,
persistSessionRestoreCandidate,
@@ -1087,6 +1110,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
useEffect(() => {
if (sessionId) {
skipAutoRestoreRef.current = false;
setIsAutoRestoringSession(false);
}
}, [sessionId]);
@@ -1425,6 +1449,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
topics,
setTopics,
topicsReady,
isAutoRestoringSession,
loadTopics,
createFreshSession,
ensureSession,
@@ -1,6 +1,7 @@
import {
useCallback,
useMemo,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction,
@@ -30,6 +31,7 @@ import {
createAgentStreamPreparedSendEnv,
type AgentStreamPreparedSendEnv,
} from "./agentStreamPreparedSendEnv";
import { AgentStreamSubmitGate } from "./agentStreamSubmitGate";
import {
normalizeAgentStreamCompactionError,
runAgentStreamCompaction,
@@ -158,6 +160,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
currentStreamingSessionIdRef,
currentStreamingEventNameRef,
});
const preparedSubmitGateRef = useRef(new AgentStreamSubmitGate());
const preparedSendEnv = useMemo<AgentStreamPreparedSendEnv>(
() =>
@@ -171,6 +174,9 @@ export function useAgentStream(options: UseAgentStreamOptions) {
providerTypeRef,
modelRef,
sessionIdRef,
hasPendingPreparedSubmit: () =>
preparedSubmitGateRef.current.hasPending(),
runPreparedSubmit: (task) => preparedSubmitGateRef.current.run(task),
getRequiredWorkspaceId,
getSyncedSessionModelPreference,
getSyncedSessionExecutionStrategy,
@@ -112,6 +112,16 @@ interface HookHarness {
unmount: () => void;
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function mountHook(
workspaceId = "ws-test",
currentOptions: {
@@ -438,7 +448,7 @@ describe("useAsterAgentChat 首页新会话", () => {
.getValue()
.topics.some((topic) => topic.id === "session-live-missing"),
).toBe(true);
expect(mockUpdateAgentRuntimeSession).toHaveBeenCalledWith({
expect(mockUpdateAgentRuntimeSession).not.toHaveBeenCalledWith({
session_id: "session-live-missing",
provider_name: harness.getValue().providerType,
model_name: harness.getValue().model,
@@ -2509,6 +2519,185 @@ describe("useAsterAgentChat runtime routing", () => {
harness.unmount();
}
});
it("收到通用任务 tool_end 后应把任务预览挂到当前助手消息", async () => {
const workspaceId = "ws-generic-task-preview";
seedSession(workspaceId, "session-generic-task-preview");
const harness = mountHook(workspaceId);
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage(
"帮我找一组咖啡馆木桌背景素材",
[],
false,
false,
false,
"react",
);
});
act(() => {
stream.emit({
type: "tool_start",
tool_name: "Bash",
tool_id: "tool-resource-task-1",
arguments: JSON.stringify({
command:
"lime task create resource-search --json --resource-type image --query 'cozy coffee table'",
}),
});
stream.emit({
type: "tool_end",
tool_id: "tool-resource-task-1",
result: {
success: true,
output: "任务已提交",
metadata: {
task_id: "task-resource-1",
task_type: "modal_resource_search",
status: "pending_submit",
artifact_path:
".lime/tasks/modal_resource_search/task-resource-1.json",
project_id: "project-resource-1",
content_id: "content-resource-1",
},
},
});
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(assistantMessage?.taskPreview).toMatchObject({
kind: "modal_resource_search",
taskId: "task-resource-1",
taskType: "modal_resource_search",
status: "running",
artifactPath: ".lime/tasks/modal_resource_search/task-resource-1.json",
projectId: "project-resource-1",
contentId: "content-resource-1",
});
} finally {
harness.unmount();
}
});
it("收到联网搜图 tool_end 后应挂载素材预览并生成可打开的 artifact", async () => {
const workspaceId = "ws-web-image-search-preview";
seedSession(workspaceId, "session-web-image-search-preview");
const harness = mountHook(workspaceId);
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage(
"帮我找一组咖啡馆木桌背景图",
[],
false,
false,
false,
"react",
);
});
act(() => {
stream.emit({
type: "tool_start",
tool_name: "lime_search_web_images",
tool_id: "tool-web-search-1",
arguments: JSON.stringify({
query: "cozy coffee table",
count: 3,
aspect: "landscape",
}),
});
stream.emit({
type: "tool_end",
tool_id: "tool-web-search-1",
result: {
success: true,
output: "已返回 3 张候选图片",
metadata: {
provider: "pexels",
result: {
provider: "pexels",
query: "cozy coffee table",
returnedCount: 3,
aspect: "landscape",
hits: [
{
id: "hit-1",
thumbnail_url: "https://pexels.example/1-thumb.jpg",
content_url: "https://pexels.example/1.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 1",
host_page_url: "https://www.pexels.com/photo/1",
},
{
id: "hit-2",
thumbnail_url: "https://pexels.example/2-thumb.jpg",
content_url: "https://pexels.example/2.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 2",
host_page_url: "https://www.pexels.com/photo/2",
},
{
id: "hit-3",
thumbnail_url: "https://pexels.example/3-thumb.jpg",
content_url: "https://pexels.example/3.jpg",
width: 1600,
height: 900,
name: "cozy coffee table 3",
host_page_url: "https://www.pexels.com/photo/3",
},
],
},
},
},
});
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(assistantMessage?.taskPreview).toMatchObject({
kind: "modal_resource_search",
taskId: "resource-search:tool-web-search-1",
taskType: "modal_resource_search",
status: "complete",
artifactPath: ".lime/runtime/resource-search/tool-web-search-1.md",
metaItems: expect.arrayContaining(["Pexels", "3 个候选"]),
});
expect(
assistantMessage?.taskPreview &&
"imageCandidates" in assistantMessage.taskPreview
? assistantMessage.taskPreview.imageCandidates
: undefined,
).toHaveLength(3);
expect(assistantMessage?.artifacts?.[0]?.meta).toMatchObject({
artifactKind: "brief",
provider: "pexels",
query: "cozy coffee table",
returnedCount: 3,
});
} finally {
harness.unmount();
}
});
});
describe("useAsterAgentChat slash skill 执行链路", () => {
@@ -2684,6 +2873,141 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
}
});
it("并发 ensureSession 应复用同一个新会话创建请求", async () => {
const deferredSession = createDeferred<string>();
const harness = mountHook();
mockCreateAgentRuntimeSession.mockImplementationOnce(
async () => deferredSession.promise,
);
try {
let firstEnsurePromise: Promise<string | null> | null = null;
let secondEnsurePromise: Promise<string | null> | null = null;
await act(async () => {
firstEnsurePromise = harness.getValue().ensureSession();
secondEnsurePromise = harness.getValue().ensureSession();
await Promise.resolve();
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledTimes(1);
await act(async () => {
deferredSession.resolve("session-concurrent");
await Promise.all([firstEnsurePromise, secondEnsurePromise]);
});
expect(await firstEnsurePromise).toBe("session-concurrent");
expect(await secondEnsurePromise).toBe("session-concurrent");
await act(async () => {
expect(await harness.getValue().ensureSession()).toBe(
"session-concurrent",
);
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
});
it("首条发送创建新会话时不应额外回写 provider/model 或 accessMode", async () => {
const workspaceId = "ws-first-send-no-eager-sync";
const harness = mountHook(workspaceId);
mockCreateAgentRuntimeSession.mockResolvedValue("session-first-send");
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage(
"请先开始处理这个任务",
[],
false,
false,
false,
"react",
);
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledWith(
workspaceId,
undefined,
"react",
);
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
expect(mockUpdateAgentRuntimeSession).not.toHaveBeenCalledWith({
session_id: "session-first-send",
provider_name: harness.getValue().providerType,
model_name: harness.getValue().model,
});
expect(mockUpdateAgentRuntimeSession).not.toHaveBeenCalledWith({
session_id: "session-first-send",
recent_access_mode: harness.getValue().accessMode,
});
} finally {
harness.unmount();
}
});
it("首轮发送尚未完成建会话时,后续发送应转入排队并复用同一个会话", async () => {
const workspaceId = "ws-first-send-gated-queue";
const deferredSession = createDeferred<string>();
const harness = mountHook(workspaceId);
mockCreateAgentRuntimeSession.mockImplementationOnce(
async () => deferredSession.promise,
);
try {
await flushEffects();
let firstSendPromise: Promise<void> | null = null;
await act(async () => {
firstSendPromise = harness
.getValue()
.sendMessage("先处理第一条任务", [], false, false, false, "react");
await Promise.resolve();
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledTimes(1);
expect(harness.getValue().messages).toHaveLength(2);
expect(harness.getValue().messages[1]?.runtimeStatus?.title).toBe(
"正在准备处理",
);
let secondSendPromise: Promise<void> | null = null;
await act(async () => {
secondSendPromise = harness
.getValue()
.sendMessage("再补充第二条任务", [], false, false, false, "react");
await Promise.resolve();
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledTimes(1);
expect(harness.getValue().messages).toHaveLength(4);
expect(harness.getValue().messages[3]?.runtimeStatus?.title).toBe(
"已加入排队列表",
);
await act(async () => {
deferredSession.resolve("session-first-send-gated");
await Promise.all([firstSendPromise, secondSendPromise]);
});
expect(mockCreateAgentRuntimeSession).toHaveBeenCalledTimes(1);
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(2);
expect(harness.getValue().sessionId).toBe("session-first-send-gated");
} finally {
harness.unmount();
}
});
it("命中 /review 时应转换为预置 prompt 后走 chat_stream", async () => {
const workspaceId = "ws-slash-review";
const harness = mountHook(workspaceId);
@@ -5995,7 +6319,7 @@ describe("useAsterAgentChat 兼容接口", () => {
requestMetadata: {
harness: {
theme: "general",
session_mode: "theme_workbench",
session_mode: "general_workbench",
},
},
},
@@ -6011,7 +6335,7 @@ describe("useAsterAgentChat 兼容接口", () => {
} | null
)?.harness,
).toEqual({
session_mode: "theme_workbench",
session_mode: "general_workbench",
});
} finally {
harness.unmount();
@@ -353,6 +353,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
triggerAIGuide: context.triggerAIGuide,
topics: session.topics,
isAutoRestoringSession: session.isAutoRestoringSession,
sessionId: session.sessionId,
createFreshSession: session.createFreshSession,
ensureSession: session.ensureSession,
@@ -1,7 +1,6 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { BrowserTaskPreflight } from "./handleSendTypes";
import {
buildInitialDispatchKey,
useBootstrapDispatchPreview,
@@ -13,12 +12,11 @@ interface HookHarness {
props?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
browserTaskPreflight?: BrowserTaskPreflight | null;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
) => void;
unmount: () => void;
@@ -28,12 +26,11 @@ function mountHook(
initialProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
browserTaskPreflight?: BrowserTaskPreflight | null;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
): HookHarness {
const container = document.createElement("div");
@@ -44,12 +41,11 @@ function mountHook(
let currentProps = {
initialUserPrompt: "",
initialUserImages: [],
browserTaskPreflight: null,
messagesCount: 0,
isSending: false,
queuedTurnCount: 0,
consumedInitialPromptKey: null,
shouldUseCompactThemeWorkbench: false,
shouldUseCompactGeneralWorkbench: false,
...initialProps,
};
@@ -62,12 +58,11 @@ function mountHook(
nextProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
browserTaskPreflight?: BrowserTaskPreflight | null;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
shouldUseCompactGeneralWorkbench?: boolean;
}>,
) => {
currentProps = {
@@ -163,25 +158,13 @@ describe("useBootstrapDispatchPreview", () => {
}
});
it("浏览器前置引导期间应继续展示 bootstrap 预览,避免空白工作台", () => {
it("初始意图已标记消费后,只要仍在排队中就继续展示 bootstrap 预览", () => {
const prompt = "帮我把这篇文章发布到微信公众号后台";
const dispatchKey = buildInitialDispatchKey(prompt, [])!;
const harness = mountHook({
initialUserPrompt: prompt,
consumedInitialPromptKey: dispatchKey,
browserTaskPreflight: {
requestId: "browser-preflight-1",
createdAt: 1,
sourceText: prompt,
images: [],
requirement: "required_with_user_step",
reason: "publish_requires_browser",
phase: "awaiting_user",
launchUrl: "https://mp.weixin.qq.com",
platformLabel: "微信公众号后台",
detail:
"已打开微信公众号后台。请先完成登录、扫码、验证码或授权,然后回到当前任务重新发起。",
},
queuedTurnCount: 1,
});
try {
@@ -189,8 +172,8 @@ describe("useBootstrapDispatchPreview", () => {
expect(value.shouldShowBootstrapDispatchPreview).toBe(true);
expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2);
expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(prompt);
expect(value.bootstrapDispatchPreviewMessages[1]?.content).toContain(
"回到当前任务重新发起",
expect(value.bootstrapDispatchPreviewMessages[1]?.content).toBe(
"正在开始处理任务…",
);
} finally {
harness.unmount();
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import type { BrowserTaskPreflight } from "./handleSendTypes";
import type { Message, MessageImage } from "../types";
export interface InitialDispatchPreviewSnapshot {
@@ -11,12 +10,11 @@ export interface InitialDispatchPreviewSnapshot {
interface UseBootstrapDispatchPreviewOptions {
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
browserTaskPreflight?: BrowserTaskPreflight | null;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
shouldUseCompactGeneralWorkbench?: boolean;
}
export function buildInitialDispatchKey(
@@ -80,12 +78,11 @@ export function buildInitialDispatchPreviewMessages(
export function useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
browserTaskPreflight,
messagesCount,
isSending,
queuedTurnCount,
consumedInitialPromptKey,
shouldUseCompactThemeWorkbench = false,
shouldUseCompactGeneralWorkbench = false,
}: UseBootstrapDispatchPreviewOptions) {
const initialDispatchKey = useMemo(
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
@@ -140,15 +137,11 @@ export function useBootstrapDispatchPreview({
const isBootstrapDispatchPending =
activeBootstrapDispatch !== null &&
consumedInitialPromptKey !== activeBootstrapDispatch.key;
const bootstrapDispatchPreviewDetail =
browserTaskPreflight?.detail?.trim() || undefined;
const shouldShowBootstrapDispatchPreview =
!shouldUseCompactThemeWorkbench &&
!shouldUseCompactGeneralWorkbench &&
Boolean(activeBootstrapDispatch) &&
messagesCount === 0 &&
(isSending ||
queuedTurnCount > 0 ||
Boolean(bootstrapDispatchPreviewDetail));
(isSending || queuedTurnCount > 0);
const bootstrapDispatchPreviewMessages = useMemo(() => {
if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) {
return [] as Message[];
@@ -158,13 +151,8 @@ export function useBootstrapDispatchPreview({
activeBootstrapDispatch.key,
activeBootstrapDispatch.prompt,
activeBootstrapDispatch.images,
bootstrapDispatchPreviewDetail,
);
}, [
activeBootstrapDispatch,
bootstrapDispatchPreviewDetail,
shouldShowBootstrapDispatchPreview,
]);
}, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]);
return {
initialDispatchKey,
@@ -1,14 +1,14 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ThemeWorkbenchRunState as BackendThemeWorkbenchRunState } from "@/lib/api/executionRun";
import type { GeneralWorkbenchRunState as BackendGeneralWorkbenchRunState } from "@/lib/api/executionRun";
import {
useThemeWorkbenchEntryPrompt,
type ThemeWorkbenchResumeWorkflowState,
} from "./useThemeWorkbenchEntryPrompt";
useGeneralWorkbenchEntryPrompt,
type GeneralWorkbenchResumeWorkflowState,
} from "./useGeneralWorkbenchEntryPrompt";
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPrompt>;
getValue: () => ReturnType<typeof useGeneralWorkbenchEntryPrompt>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onHydrateInitialPrompt: ReturnType<typeof vi.fn>;
@@ -20,7 +20,7 @@ interface HookProps {
sessionId?: string;
isThemeWorkbench: boolean;
autoRunInitialPromptOnMount: boolean;
shouldUseCompactThemeWorkbench: boolean;
shouldUseCompactGeneralWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
@@ -37,21 +37,21 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const loadWorkflow = vi.fn(
async (
_contentId: string,
): Promise<ThemeWorkbenchResumeWorkflowState | null> => null,
): Promise<GeneralWorkbenchResumeWorkflowState | null> => null,
);
const loadRunState = vi.fn(
async (_sessionId: string): Promise<BackendThemeWorkbenchRunState | null> =>
async (_sessionId: string): Promise<BackendGeneralWorkbenchRunState | null> =>
null,
);
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null = null;
let hookValue: ReturnType<typeof useGeneralWorkbenchEntryPrompt> | null = null;
let currentProps: HookProps = {
activeTheme: "general",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
autoRunInitialPromptOnMount: false,
shouldUseCompactThemeWorkbench: false,
shouldUseCompactGeneralWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
@@ -61,7 +61,7 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
};
function TestComponent() {
hookValue = useThemeWorkbenchEntryPrompt({
hookValue = useGeneralWorkbenchEntryPrompt({
...currentProps,
onHydrateInitialPrompt,
loadWorkflow,
@@ -108,7 +108,7 @@ async function flushEffects(times = 4) {
}
}
describe("useThemeWorkbenchEntryPrompt", () => {
describe("useGeneralWorkbenchEntryPrompt", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -134,7 +134,7 @@ describe("useThemeWorkbenchEntryPrompt", () => {
"请先生成内容主稿",
"initial-dispatch",
);
expect(harness.getValue().themeWorkbenchEntryPrompt).toMatchObject({
expect(harness.getValue().generalWorkbenchEntryPrompt).toMatchObject({
kind: "initial_prompt",
prompt: "请先生成内容主稿",
});
@@ -153,7 +153,7 @@ describe("useThemeWorkbenchEntryPrompt", () => {
try {
await flushEffects();
expect(harness.onHydrateInitialPrompt).not.toHaveBeenCalled();
expect(harness.getValue().themeWorkbenchEntryPrompt).toBeNull();
expect(harness.getValue().generalWorkbenchEntryPrompt).toBeNull();
} finally {
harness.unmount();
}
@@ -165,7 +165,7 @@ describe("useThemeWorkbenchEntryPrompt", () => {
const root = createRoot(container);
const onHydrateInitialPrompt = vi.fn();
const loadRunState = vi.fn(
async (_sessionId: string): Promise<BackendThemeWorkbenchRunState | null> => ({
async (_sessionId: string): Promise<BackendGeneralWorkbenchRunState | null> => ({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
@@ -185,17 +185,17 @@ describe("useThemeWorkbenchEntryPrompt", () => {
}),
);
const hookValueRef: {
current: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null;
current: ReturnType<typeof useGeneralWorkbenchEntryPrompt> | null;
} = { current: null };
function TestComponent() {
hookValueRef.current = useThemeWorkbenchEntryPrompt({
hookValueRef.current = useGeneralWorkbenchEntryPrompt({
activeTheme: "general",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
autoRunInitialPromptOnMount: false,
shouldUseCompactThemeWorkbench: false,
shouldUseCompactGeneralWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
@@ -214,12 +214,12 @@ describe("useThemeWorkbenchEntryPrompt", () => {
try {
await flushEffects();
expect(loadRunState).toHaveBeenCalledWith("session-1");
expect(hookValueRef.current?.themeWorkbenchEntryPrompt).toMatchObject({
expect(hookValueRef.current?.generalWorkbenchEntryPrompt).toMatchObject({
kind: "resume",
title: "发现上次未完成任务",
description: expect.stringContaining("撰写主稿"),
});
expect(hookValueRef.current?.themeWorkbenchEntryCheckPending).toBe(false);
expect(hookValueRef.current?.generalWorkbenchEntryCheckPending).toBe(false);
} finally {
act(() => {
root.unmount();
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
executionRunGetThemeWorkbenchState,
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
type ThemeWorkbenchRunTerminalItem,
type ThemeWorkbenchRunTodoItem,
executionRunGetGeneralWorkbenchState,
type GeneralWorkbenchRunState,
type GeneralWorkbenchRunTerminalItem,
type GeneralWorkbenchRunTodoItem,
} from "@/lib/api/executionRun";
import type { MessageImage } from "../types";
export interface ThemeWorkbenchEntryPromptState {
export interface GeneralWorkbenchEntryPromptState {
kind: "initial_prompt" | "resume";
signature: string;
title: string;
@@ -16,27 +16,27 @@ export interface ThemeWorkbenchEntryPromptState {
prompt: string;
}
export interface ThemeWorkbenchResumeWorkflowStep {
export interface GeneralWorkbenchResumeWorkflowStep {
id: string;
title: string;
status: "pending" | "active" | "completed" | "skipped" | "error";
result?: unknown;
}
export interface ThemeWorkbenchResumeWorkflowState {
export interface GeneralWorkbenchResumeWorkflowState {
id: string;
current_step_index: number;
updated_at: number;
steps: ThemeWorkbenchResumeWorkflowStep[];
steps: GeneralWorkbenchResumeWorkflowStep[];
}
interface UseThemeWorkbenchEntryPromptOptions {
interface UseGeneralWorkbenchEntryPromptOptions {
activeTheme: string;
contentId?: string;
sessionId?: string;
isThemeWorkbench: boolean;
autoRunInitialPromptOnMount: boolean;
shouldUseCompactThemeWorkbench: boolean;
shouldUseCompactGeneralWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
@@ -45,17 +45,17 @@ interface UseThemeWorkbenchEntryPromptOptions {
onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void;
loadWorkflow?: (
contentId: string,
) => Promise<ThemeWorkbenchResumeWorkflowState | null>;
) => Promise<GeneralWorkbenchResumeWorkflowState | null>;
loadRunState?: (
sessionId: string,
) => Promise<BackendThemeWorkbenchRunState | null>;
) => Promise<GeneralWorkbenchRunState | null>;
}
const defaultLoadThemeWorkbenchRunState = (sessionId: string) =>
executionRunGetThemeWorkbenchState(sessionId, 3);
const defaultLoadGeneralWorkbenchRunState = (sessionId: string) =>
executionRunGetGeneralWorkbenchState(sessionId, 3);
function resolveThemeWorkbenchGateLabel(
gateKey?: ThemeWorkbenchRunTodoItem["gate_key"],
function resolveGeneralWorkbenchGateLabel(
gateKey?: GeneralWorkbenchRunTodoItem["gate_key"],
): string | null {
switch (gateKey) {
case "topic_select":
@@ -72,7 +72,7 @@ function resolveThemeWorkbenchGateLabel(
}
function hasWorkflowMeaningfulProgress(
workflow: ThemeWorkbenchResumeWorkflowState | null,
workflow: GeneralWorkbenchResumeWorkflowState | null,
): boolean {
if (!workflow) {
return false;
@@ -91,9 +91,9 @@ function hasWorkflowMeaningfulProgress(
);
}
export function buildThemeWorkbenchResumePromptFromWorkflow(
workflow: ThemeWorkbenchResumeWorkflowState | null,
): ThemeWorkbenchEntryPromptState | null {
export function buildGeneralWorkbenchResumePromptFromWorkflow(
workflow: GeneralWorkbenchResumeWorkflowState | null,
): GeneralWorkbenchEntryPromptState | null {
if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) {
return null;
}
@@ -124,9 +124,9 @@ export function buildThemeWorkbenchResumePromptFromWorkflow(
};
}
function resolveThemeWorkbenchPendingRunCandidate(
state: BackendThemeWorkbenchRunState | null,
): ThemeWorkbenchRunTodoItem | ThemeWorkbenchRunTerminalItem | null {
function resolveGeneralWorkbenchPendingRunCandidate(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchRunTodoItem | GeneralWorkbenchRunTerminalItem | null {
if (!state) {
return null;
}
@@ -150,16 +150,16 @@ function resolveThemeWorkbenchPendingRunCandidate(
return null;
}
export function buildThemeWorkbenchResumePromptFromRunState(
state: BackendThemeWorkbenchRunState | null,
): ThemeWorkbenchEntryPromptState | null {
const pendingRun = resolveThemeWorkbenchPendingRunCandidate(state);
export function buildGeneralWorkbenchResumePromptFromRunState(
state: GeneralWorkbenchRunState | null,
): GeneralWorkbenchEntryPromptState | null {
const pendingRun = resolveGeneralWorkbenchPendingRunCandidate(state);
if (!pendingRun) {
return null;
}
const runTitle = pendingRun.title?.trim() || "最近一次创作任务";
const gateLabel = resolveThemeWorkbenchGateLabel(pendingRun.gate_key);
const gateLabel = resolveGeneralWorkbenchGateLabel(pendingRun.gate_key);
const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : "";
return {
@@ -172,13 +172,13 @@ export function buildThemeWorkbenchResumePromptFromRunState(
};
}
export function useThemeWorkbenchEntryPrompt({
export function useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId,
sessionId,
isThemeWorkbench,
autoRunInitialPromptOnMount,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
messagesCount,
initialDispatchKey,
initialUserPrompt,
@@ -186,11 +186,11 @@ export function useThemeWorkbenchEntryPrompt({
consumedInitialPromptKey,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState = defaultLoadThemeWorkbenchRunState,
}: UseThemeWorkbenchEntryPromptOptions) {
const [themeWorkbenchEntryPrompt, setThemeWorkbenchEntryPrompt] =
useState<ThemeWorkbenchEntryPromptState | null>(null);
const [themeWorkbenchEntryCheckPending, setThemeWorkbenchEntryCheckPending] =
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);
@@ -198,12 +198,12 @@ export function useThemeWorkbenchEntryPrompt({
useEffect(() => {
hydratedPromptSignatureRef.current = null;
dismissedPromptSignatureRef.current = null;
setThemeWorkbenchEntryPrompt(null);
setThemeWorkbenchEntryCheckPending(false);
setGeneralWorkbenchEntryPrompt(null);
setGeneralWorkbenchEntryCheckPending(false);
}, [activeTheme, contentId, initialDispatchKey]);
useEffect(() => {
if (shouldUseCompactThemeWorkbench) {
if (shouldUseCompactGeneralWorkbench) {
return;
}
@@ -230,7 +230,7 @@ export function useThemeWorkbenchEntryPrompt({
hydratedPromptSignatureRef.current = initialDispatchKey;
onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey);
setThemeWorkbenchEntryPrompt({
setGeneralWorkbenchEntryPrompt({
kind: "initial_prompt",
signature: initialDispatchKey,
title: "已恢复待执行创作意图",
@@ -248,12 +248,12 @@ export function useThemeWorkbenchEntryPrompt({
messagesCount,
onHydrateInitialPrompt,
autoRunInitialPromptOnMount,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
]);
useEffect(() => {
if (shouldUseCompactThemeWorkbench) {
setThemeWorkbenchEntryCheckPending(false);
if (shouldUseCompactGeneralWorkbench) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
@@ -264,12 +264,12 @@ export function useThemeWorkbenchEntryPrompt({
messagesCount > 0 ||
Boolean(initialDispatchKey)
) {
setThemeWorkbenchEntryCheckPending(false);
setGeneralWorkbenchEntryCheckPending(false);
return;
}
let disposed = false;
setThemeWorkbenchEntryCheckPending(true);
setGeneralWorkbenchEntryCheckPending(true);
void (async () => {
try {
@@ -283,11 +283,11 @@ export function useThemeWorkbenchEntryPrompt({
}
const nextPrompt =
buildThemeWorkbenchResumePromptFromWorkflow(workflow) ??
buildThemeWorkbenchResumePromptFromRunState(backendState);
buildGeneralWorkbenchResumePromptFromWorkflow(workflow) ??
buildGeneralWorkbenchResumePromptFromRunState(backendState);
if (!nextPrompt) {
setThemeWorkbenchEntryPrompt((current) =>
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "resume" ? null : current,
);
return;
@@ -297,12 +297,12 @@ export function useThemeWorkbenchEntryPrompt({
return;
}
setThemeWorkbenchEntryPrompt((current) =>
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "initial_prompt" ? current : nextPrompt,
);
} finally {
if (!disposed) {
setThemeWorkbenchEntryCheckPending(false);
setGeneralWorkbenchEntryCheckPending(false);
}
}
})();
@@ -318,19 +318,19 @@ export function useThemeWorkbenchEntryPrompt({
loadWorkflow,
messagesCount,
sessionId,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
]);
const clearThemeWorkbenchEntryPrompt = useCallback(() => {
setThemeWorkbenchEntryPrompt(null);
const clearGeneralWorkbenchEntryPrompt = useCallback(() => {
setGeneralWorkbenchEntryPrompt(null);
}, []);
const dismissThemeWorkbenchEntryPrompt = useCallback(
const dismissGeneralWorkbenchEntryPrompt = useCallback(
(options?: {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}) => {
setThemeWorkbenchEntryPrompt((current) => {
setGeneralWorkbenchEntryPrompt((current) => {
if (!current) {
return current;
}
@@ -352,9 +352,9 @@ export function useThemeWorkbenchEntryPrompt({
);
return {
themeWorkbenchEntryPrompt,
themeWorkbenchEntryCheckPending,
clearThemeWorkbenchEntryPrompt,
dismissThemeWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
};
}
@@ -1,21 +1,21 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useThemeWorkbenchEntryPromptActions } from "./useThemeWorkbenchEntryPromptActions";
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
import { useGeneralWorkbenchEntryPromptActions } from "./useGeneralWorkbenchEntryPromptActions";
import type { GeneralWorkbenchEntryPromptState } from "./useGeneralWorkbenchEntryPrompt";
interface HookProps {
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
}
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPromptActions>;
getValue: () => ReturnType<typeof useGeneralWorkbenchEntryPromptActions>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onContinuePrompt: ReturnType<typeof vi.fn>;
dismissThemeWorkbenchEntryPrompt: ReturnType<typeof vi.fn>;
dismissGeneralWorkbenchEntryPrompt: ReturnType<typeof vi.fn>;
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
onInputChange: ReturnType<typeof vi.fn>;
onRequirePrompt: ReturnType<typeof vi.fn>;
@@ -27,25 +27,25 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const root = createRoot(container);
const onContinuePrompt = vi.fn(async () => undefined);
const dismissThemeWorkbenchEntryPrompt = vi.fn();
const dismissGeneralWorkbenchEntryPrompt = vi.fn();
const onConsumeInitialPrompt = vi.fn();
const onInputChange = vi.fn();
const onRequirePrompt = vi.fn();
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPromptActions> | null =
let hookValue: ReturnType<typeof useGeneralWorkbenchEntryPromptActions> | null =
null;
let currentProps: HookProps = {
themeWorkbenchEntryPrompt: null,
generalWorkbenchEntryPrompt: null,
input: "",
initialDispatchKey: null,
...initialProps,
};
function TestComponent() {
hookValue = useThemeWorkbenchEntryPromptActions({
hookValue = useGeneralWorkbenchEntryPromptActions({
...currentProps,
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
@@ -80,14 +80,14 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
container.remove();
},
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
};
}
describe("useThemeWorkbenchEntryPromptActions", () => {
describe("useGeneralWorkbenchEntryPromptActions", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -103,7 +103,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
generalWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
@@ -119,7 +119,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
.handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿");
@@ -130,7 +130,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
.handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenLastCalledWith(
"我已经补充了额外要求",
@@ -142,7 +142,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
it("继续时没有任何可发送内容应提示补充", async () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
generalWorkbenchEntryPrompt: {
kind: "resume",
signature: "resume-1",
title: "发现上次未完成任务",
@@ -157,7 +157,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
.handleContinueGeneralWorkbenchEntryPrompt();
});
expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1);
expect(harness.onContinuePrompt).not.toHaveBeenCalled();
@@ -168,7 +168,7 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
it("重新开始初始提示时应消费意图并清空输入", () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
generalWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
@@ -182,12 +182,12 @@ describe("useThemeWorkbenchEntryPromptActions", () => {
try {
act(() => {
harness.getValue().handleRestartThemeWorkbenchEntryPrompt();
harness.getValue().handleRestartGeneralWorkbenchEntryPrompt();
});
expect(harness.dismissThemeWorkbenchEntryPrompt).toHaveBeenCalledTimes(1);
expect(harness.dismissGeneralWorkbenchEntryPrompt).toHaveBeenCalledTimes(1);
const options =
harness.dismissThemeWorkbenchEntryPrompt.mock.calls[0]?.[0];
harness.dismissGeneralWorkbenchEntryPrompt.mock.calls[0]?.[0];
expect(options?.consumeInitialPrompt).toBe(true);
options?.onConsumeInitialPrompt?.();
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
@@ -0,0 +1,72 @@
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,
};
}
@@ -2,8 +2,8 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
useThemeWorkbenchSendBoundary,
} from "./useThemeWorkbenchSendBoundary";
useGeneralWorkbenchSendBoundary,
} from "./useGeneralWorkbenchSendBoundary";
interface HookHarnessProps {
isThemeWorkbench: boolean;
@@ -11,17 +11,15 @@ interface HookHarnessProps {
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
mappedTheme: string;
isBrowserAssistReady: boolean;
}
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchSendBoundary>;
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>;
onPrepareBrowserTaskPreflight: ReturnType<typeof vi.fn>;
}
function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
@@ -32,28 +30,25 @@ function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
const onConsumeInitialPrompt = vi.fn();
const onResetConsumedInitialPrompt = vi.fn();
const onClearEntryPrompt = vi.fn();
const onPrepareBrowserTaskPreflight = vi.fn();
let hookValue: ReturnType<typeof useThemeWorkbenchSendBoundary> | null = null;
let hookValue: ReturnType<typeof useGeneralWorkbenchSendBoundary> | null = null;
let currentProps: HookHarnessProps = {
isThemeWorkbench: true,
contentId: "content-1",
initialDispatchKey: "dispatch-1",
consumedInitialPromptKey: null,
mappedTheme: "general",
isBrowserAssistReady: true,
...initialProps,
};
function TestComponent() {
hookValue = useThemeWorkbenchSendBoundary({
hookValue = useGeneralWorkbenchSendBoundary({
...currentProps,
initialUserImages: [],
socialArticleSkillKey: "content_post_with_cover",
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
});
return null;
}
@@ -88,11 +83,10 @@ function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
};
}
describe("useThemeWorkbenchSendBoundary", () => {
describe("useGeneralWorkbenchSendBoundary", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -106,7 +100,7 @@ describe("useThemeWorkbenchSendBoundary", () => {
vi.clearAllMocks();
});
it("应识别内容主题工作台的首条意图消费", () => {
it("应识别工作区内容编排的首条意图消费", () => {
const harness = mountHook();
try {
@@ -114,47 +108,39 @@ describe("useThemeWorkbenchSendBoundary", () => {
sourceText: "请生成今天的社媒主稿",
});
expect(boundary.sourceText).toBe("请生成今天的社媒主稿");
expect(boundary.shouldConsumePendingThemeWorkbenchInitialPrompt).toBe(
expect(boundary.sourceText).toBe(
"/content_post_with_cover 请生成今天的社媒主稿",
);
expect(boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt).toBe(
true,
);
expect(boundary.shouldDismissThemeWorkbenchEntryPrompt).toBe(true);
expect(boundary.shouldDismissGeneralWorkbenchEntryPrompt).toBe(true);
expect(boundary.browserRequirementMatch).toBeNull();
} finally {
harness.unmount();
}
});
it("需要真实浏览器时应创建前置引导并同步消费初始意图", () => {
const harness = mountHook({
mappedTheme: "general",
isBrowserAssistReady: false,
});
it("需要真实浏览器时应仅保留 requirement 检测,不再启动前置引导", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "帮我把这篇文章发布到微信公众号后台",
});
const started = harness.getValue().maybeStartBrowserTaskPreflight({
boundary,
images: [],
webSearch: false,
thinking: false,
sendExecutionStrategy: "auto",
});
expect(started).toBe(true);
expect(harness.onPrepareBrowserTaskPreflight).toHaveBeenCalledWith(
expect(boundary.sourceText).toBe(
"/content_post_with_cover 帮我把这篇文章发布到微信公众号后台",
);
expect(boundary.browserRequirementMatch).toEqual(
expect.objectContaining({
sourceText: "帮我把这篇文章发布到微信公众号后台",
requirement: "required_with_user_step",
launchUrl: "https://mp.weixin.qq.com/",
phase: "launching",
platformLabel: "微信公众号后台",
}),
);
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
expect(harness.onClearEntryPrompt).toHaveBeenCalledTimes(1);
expect(harness.onConsumeInitialPrompt).not.toHaveBeenCalled();
expect(harness.onClearEntryPrompt).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
@@ -0,0 +1,154 @@
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,
};
}
@@ -85,7 +85,7 @@ describe("useSelectedTeamPreference", () => {
localStorage.clear();
});
it("切换 theme 时应读取对应 Team,而不是把旧主题选择写回新主题", async () => {
it("general 作用域应读取并回写当前 Team 偏好", async () => {
const engineeringTeam = createTeamDefinitionFromPreset(
"code-triage-team",
) as TeamDefinition;
@@ -94,22 +94,27 @@ describe("useSelectedTeamPreference", () => {
) as TeamDefinition;
persistSelectedTeam(engineeringTeam, "general");
persistSelectedTeam(researchTeam, "general");
const harness = mountHook("general");
try {
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
harness.rerender("general");
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("research-team");
expect(loadSelectedTeamReference("general")).toEqual({
id: "code-triage-team",
source: "builtin",
});
harness.rerender("general");
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
act(() => {
harness.getValue().setSelectedTeam(researchTeam);
});
expect(harness.getValue().selectedTeam?.id).toBe("research-team");
expect(loadSelectedTeamReference("general")).toEqual({
id: "research-team",
source: "builtin",
@@ -120,10 +125,6 @@ describe("useSelectedTeamPreference", () => {
});
expect(loadSelectedTeamReference("general")).toBeNull();
expect(loadSelectedTeamReference("general")).toEqual({
id: "code-triage-team",
source: "builtin",
});
} finally {
harness.unmount();
}
@@ -130,6 +130,7 @@ describe("useThemeContextWorkspace", () => {
});
await flushEffects();
expect(snapshot!.generalWorkbenchEnabled).toBe(false);
expect(snapshot!.enabled).toBe(false);
expect(snapshot!.sidebarContextItems).toEqual([]);
expect(snapshot!.activeContextPrompt).toBe("");
@@ -182,6 +183,7 @@ describe("useThemeContextWorkspace", () => {
});
await flushEffects(12);
expect(snapshot!.generalWorkbenchEnabled).toBe(true);
expect(snapshot!.enabled).toBe(true);
expect(snapshot!.contextBudget.activeCount).toBe(3);
expect(snapshot!.activeContextPrompt).toContain("[生效上下文]");
@@ -81,6 +81,7 @@ export interface SidebarActivityLog {
}
export interface ThemeContextWorkspaceState {
generalWorkbenchEnabled: boolean;
enabled: boolean;
contextSearchQuery: string;
setContextSearchQuery: (value: string) => void;
@@ -1158,6 +1159,7 @@ export function useThemeContextWorkspace({
);
return {
generalWorkbenchEnabled: enabled,
enabled,
contextSearchQuery,
setContextSearchQuery,
@@ -1,71 +0,0 @@
import { useCallback } from "react";
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
interface DismissThemeWorkbenchEntryPromptOptions {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}
interface UseThemeWorkbenchEntryPromptActionsOptions {
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
onContinuePrompt: (prompt: string) => Promise<void> | void;
dismissThemeWorkbenchEntryPrompt: (
options?: DismissThemeWorkbenchEntryPromptOptions,
) => void;
onConsumeInitialPrompt?: (dispatchKey: string | null) => void;
onInputChange: (value: string) => void;
onRequirePrompt?: () => void;
}
export function useThemeWorkbenchEntryPromptActions({
themeWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
}: UseThemeWorkbenchEntryPromptActionsOptions) {
const handleContinueThemeWorkbenchEntryPrompt = useCallback(async () => {
if (!themeWorkbenchEntryPrompt) {
return;
}
const promptToSend =
input.trim() || themeWorkbenchEntryPrompt.prompt.trim();
if (!promptToSend) {
onRequirePrompt?.();
return;
}
await onContinuePrompt(promptToSend);
}, [input, onContinuePrompt, onRequirePrompt, themeWorkbenchEntryPrompt]);
const handleRestartThemeWorkbenchEntryPrompt = useCallback(() => {
if (!themeWorkbenchEntryPrompt) {
return;
}
dismissThemeWorkbenchEntryPrompt({
consumeInitialPrompt: themeWorkbenchEntryPrompt.kind === "initial_prompt",
onConsumeInitialPrompt: () => {
onConsumeInitialPrompt?.(initialDispatchKey);
},
});
onInputChange("");
}, [
dismissThemeWorkbenchEntryPrompt,
initialDispatchKey,
onConsumeInitialPrompt,
onInputChange,
themeWorkbenchEntryPrompt,
]);
return {
handleContinueThemeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
};
}
@@ -1,255 +0,0 @@
import { useCallback } from "react";
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement";
import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement";
import type { MessageImage } from "../types";
import type { BrowserTaskPreflight, HandleSendOptions } from "./handleSendTypes";
interface BuildThemeWorkbenchSendBoundaryStateOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
sourceText: string;
sendOptions?: HandleSendOptions;
}
export interface ThemeWorkbenchSendBoundaryState {
sourceText: string;
browserRequirementMatch: BrowserTaskRequirementMatch | null;
shouldConsumePendingThemeWorkbenchInitialPrompt: boolean;
shouldDismissThemeWorkbenchEntryPrompt: boolean;
}
interface CreateBrowserTaskPreflightOptions {
sourceText: string;
images?: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
browserRequirementMatch: BrowserTaskRequirementMatch;
createRequestId?: () => string;
now?: () => number;
}
interface UseThemeWorkbenchSendBoundaryOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
isBrowserAssistReady: boolean;
onConsumeInitialPrompt: (dispatchKey: string) => void;
onResetConsumedInitialPrompt: () => void;
onClearEntryPrompt: () => void;
onPrepareBrowserTaskPreflight: (preflight: BrowserTaskPreflight) => void;
}
interface StartBrowserTaskPreflightOptions {
boundary: ThemeWorkbenchSendBoundaryState;
images?: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
}
function defaultCreateBrowserTaskPreflightRequestId() {
return `browser-preflight:${crypto.randomUUID()}`;
}
export function buildThemeWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}: BuildThemeWorkbenchSendBoundaryStateOptions): ThemeWorkbenchSendBoundaryState {
const shouldConsumePendingThemeWorkbenchInitialPrompt =
isThemeWorkbench &&
Boolean(contentId) &&
Boolean(initialDispatchKey) &&
consumedInitialPromptKey !== initialDispatchKey &&
(initialUserImages || []).length === 0 &&
!sendOptions?.purpose;
const shouldDismissThemeWorkbenchEntryPrompt =
isThemeWorkbench && !sendOptions?.purpose;
let nextSourceText = sourceText;
void socialArticleSkillKey;
const browserRequirementMatch =
mappedTheme === "general" && !sendOptions?.purpose
? detectBrowserTaskRequirement(nextSourceText)
: null;
return {
sourceText: nextSourceText,
browserRequirementMatch,
shouldConsumePendingThemeWorkbenchInitialPrompt,
shouldDismissThemeWorkbenchEntryPrompt,
};
}
export function createBrowserTaskPreflight({
sourceText,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
browserRequirementMatch,
createRequestId = defaultCreateBrowserTaskPreflightRequestId,
now = () => Date.now(),
}: CreateBrowserTaskPreflightOptions): BrowserTaskPreflight {
return {
requestId: createRequestId(),
createdAt: now(),
sourceText,
images: images || [],
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
requirement: browserRequirementMatch.requirement,
reason: browserRequirementMatch.reason,
phase: "launching",
launchUrl: browserRequirementMatch.launchUrl,
platformLabel: browserRequirementMatch.platformLabel,
detail: "正在尝试建立浏览器会话,请稍候...",
};
}
export function useThemeWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
isBrowserAssistReady,
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
}: UseThemeWorkbenchSendBoundaryOptions) {
const resolveSendBoundary = useCallback(
({ sourceText, sendOptions }: { sourceText: string; sendOptions?: HandleSendOptions }) =>
buildThemeWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}),
[
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserImages,
isThemeWorkbench,
mappedTheme,
socialArticleSkillKey,
],
);
const maybeStartBrowserTaskPreflight = useCallback(
({
boundary,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
}: StartBrowserTaskPreflightOptions) => {
if (!boundary.browserRequirementMatch || isBrowserAssistReady) {
return false;
}
const preflight = createBrowserTaskPreflight({
sourceText: boundary.sourceText,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
browserRequirementMatch: boundary.browserRequirementMatch,
});
onPrepareBrowserTaskPreflight(preflight);
if (
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
initialDispatchKey
) {
onConsumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
onClearEntryPrompt();
}
return true;
},
[
initialDispatchKey,
isBrowserAssistReady,
onClearEntryPrompt,
onConsumeInitialPrompt,
onPrepareBrowserTaskPreflight,
],
);
const finalizeAfterSendSuccess = useCallback(
(boundary: ThemeWorkbenchSendBoundaryState) => {
if (
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
initialDispatchKey
) {
onConsumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
onClearEntryPrompt();
}
},
[initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt],
);
const rollbackAfterSendFailure = useCallback(
(boundary: ThemeWorkbenchSendBoundaryState) => {
if (boundary.shouldConsumePendingThemeWorkbenchInitialPrompt) {
onResetConsumedInitialPrompt();
}
},
[onResetConsumedInitialPrompt],
);
return {
resolveSendBoundary,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
};
}
File diff suppressed because it is too large Load Diff
@@ -114,7 +114,7 @@ describe("service skill automation draft", () => {
}),
harness: expect.objectContaining({
theme: "general",
session_mode: "theme_workbench",
session_mode: "general_workbench",
run_title: "每日趋势摘要",
}),
}),
@@ -145,7 +145,7 @@ describe("service skill automation draft", () => {
}),
harness: expect.objectContaining({
theme: "general",
session_mode: "theme_workbench",
session_mode: "general_workbench",
content_id: "content-1",
}),
}),
@@ -274,7 +274,7 @@ function buildServiceSkillAutomationRequestMetadata(
task: false,
subagent: false,
},
sessionMode: workspaceSeed ? "theme_workbench" : "default",
sessionMode: workspaceSeed ? "general_workbench" : "default",
runTitle: skill.title,
contentId: contentId || undefined,
}),
@@ -189,6 +189,12 @@ async function flushEffects(times = 3) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
if (typeof vi.dynamicImportSettled === "function") {
await vi.dynamicImportSettled();
}
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 0);
});
});
}
}
@@ -412,6 +412,10 @@ describe("CharacterMention", () => {
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("@读PDF");
expect(document.body.textContent).toContain("@转写");
expect(document.body.textContent).toContain("@链接解析");
});
@@ -551,6 +555,276 @@ describe("CharacterMention", () => {
);
});
it("提供 onSelectBuiltinCommand 时,选择播报命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@播报"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "broadcast_generate",
commandPrefix: "@播报",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择素材命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@素材"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "modal_resource_search",
commandPrefix: "@素材",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择搜索命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@搜索"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "research",
commandPrefix: "@搜索",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择深搜命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@深搜"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "deep_search",
commandPrefix: "@深搜",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择研报命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@研报"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "research_report",
commandPrefix: "@研报",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择站点搜索命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@站点搜索"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "site_search",
commandPrefix: "@站点搜索",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择读PDF命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@读PDF"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "read_pdf",
commandPrefix: "@读PDF",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择总结命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@总结"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "summary",
commandPrefix: "@总结",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择翻译命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@翻译"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "translation",
commandPrefix: "@翻译",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择分析命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@分析"));
expect(builtinButton).toBeTruthy();
act(() => {
builtinButton?.click();
});
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "analysis",
commandPrefix: "@分析",
}),
);
});
it("提供 onSelectBuiltinCommand 时,选择转写命令应交给父组件接管", async () => {
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
+74 -17
View File
@@ -38,6 +38,77 @@ export interface MessageImageWorkbenchPreview {
placeholderText?: string | null;
}
export type MessageTaskPreviewStatus = MessageImageWorkbenchPreview["status"];
export interface MessageVideoTaskPreview {
kind: "video_generate";
taskId: string;
taskType: "video_generate";
prompt: string;
status: MessageTaskPreviewStatus;
projectId?: string | null;
contentId?: string | null;
videoUrl?: string | null;
thumbnailUrl?: string | null;
durationSeconds?: number;
aspectRatio?: string;
resolution?: string;
providerId?: string | null;
model?: string | null;
progress?: number | null;
phase?: string | null;
statusMessage?: string | null;
retryable?: boolean;
}
export interface MessageTaskPreviewImageCandidate {
id: string;
thumbnailUrl: string;
contentUrl?: string | null;
hostPageUrl?: string | null;
width?: number;
height?: number;
name?: string;
}
export interface MessageGenericTaskPreview {
kind:
| "broadcast_generate"
| "modal_resource_search"
| "transcription_generate"
| "url_parse"
| "typesetting";
taskId: string;
taskType: MessageGenericTaskPreview["kind"];
prompt: string;
title?: string;
status: MessageTaskPreviewStatus;
projectId?: string | null;
contentId?: string | null;
artifactPath?: string | null;
providerId?: string | null;
model?: string | null;
phase?: string | null;
statusMessage?: string | null;
retryable?: boolean;
metaItems?: string[];
imageCandidates?: MessageTaskPreviewImageCandidate[];
}
export type MessageTaskPreview =
| MessageVideoTaskPreview
| MessageGenericTaskPreview;
export type MessagePreviewTarget =
| {
kind: "image_workbench";
preview: MessageImageWorkbenchPreview;
}
| {
kind: "task";
preview: MessageTaskPreview;
};
/**
* 内容片段类型(用于交错显示)
*
@@ -58,14 +129,6 @@ export type BrowserTaskRequirement =
| "required"
| "required_with_user_step";
export type BrowserPreflightState =
| "idle"
| "launching"
| "awaiting_user"
| "ready_to_resume"
| "failed"
| "degraded";
export interface SiteSavedContentTarget {
projectId: string;
contentId: string;
@@ -129,15 +192,7 @@ export interface ActionRequired {
submittedResponse?: string;
/** 已提交的原始用户数据 */
submittedUserData?: unknown;
/** 前端专用渲染类型 */
uiKind?: "browser_preflight";
/** 浏览器任务要求等级(前端本地引导使用) */
browserRequirement?: BrowserTaskRequirement;
/** 浏览器前置阶段(前端本地引导使用) */
browserPrepState?: BrowserPreflightState;
/** 是否允许改为能力降级路径 */
allowCapabilityFallback?: boolean;
/** 浏览器引导失败或附加说明 */
/** 附加说明 */
detail?: string;
/** 单轮澄清治理元数据 */
governance?: ActionRequestGovernanceMeta;
@@ -264,6 +319,8 @@ export interface Message {
artifacts?: Artifact[];
/** 图片工作台消息卡预览 */
imageWorkbenchPreview?: MessageImageWorkbenchPreview;
/** 通用任务消息卡预览 */
taskPreview?: MessageTaskPreview;
/** 首个流式事件到达前的本地运行态 */
runtimeStatus?: AgentRuntimeStatus;
/** 消息用途(用于跳过特定副作用) */
@@ -275,15 +275,26 @@ describe("actionRequestA2UI", () => {
});
});
it("browser_preflight 不应被提升为 A2UI 表单", () => {
it("缺少问题的 ask_user 应回退为默认文本输入表单", () => {
const request: ActionRequired = {
requestId: "req-browser",
requestId: "req-ask-empty",
actionType: "ask_user",
uiKind: "browser_preflight",
status: "pending",
};
expect(isActionRequestA2UICompatible(request)).toBe(false);
expect(buildActionRequestA2UI(request)).toBeNull();
expect(isActionRequestA2UICompatible(request)).toBe(true);
expect(buildActionRequestA2UI(request)).toMatchObject({
id: "action-request-req-ask-empty",
submitAction: {
label: "确认并继续",
},
components: expect.arrayContaining([
expect.objectContaining({
component: "TextField",
id: "req-ask-empty_answer",
label: "请输入你的回答",
}),
]),
});
});
});
@@ -365,7 +365,7 @@ function summarizeValue(value: unknown): string | null {
export function isActionRequestA2UICompatible(
request: ActionRequired | null | undefined,
): request is ActionRequired {
if (!request || request.uiKind === "browser_preflight") {
if (!request) {
return false;
}

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