mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.4.0
This commit is contained in:
@@ -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,
|
||||
|
||||
+15
-15
@@ -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);
|
||||
+10
-9
@@ -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(() => {
|
||||
+29
-29
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
+3
-3
@@ -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);
|
||||
+6
-6
@@ -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} 个产物` : "暂无产物";
|
||||
+17
-17
@@ -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;
|
||||
}
|
||||
+16
-14
@@ -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>
|
||||
+17
-17
@@ -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"
|
||||
>
|
||||
|
||||
+31
-31
@@ -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}
|
||||
|
||||
+11
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
+18
-18
@@ -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,
|
||||
};
|
||||
}
|
||||
+13
-13
@@ -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,
|
||||
};
|
||||
}
|
||||
+16
-16
@@ -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;
|
||||
}
|
||||
+11
-11
@@ -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[] {
|
||||
+29
-29
@@ -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;
|
||||
}
|
||||
+23
-23
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -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] || "上下文文件";
|
||||
+6
-6
@@ -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: "排版优化",
|
||||
+26
-26
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+8
-8
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
+33
-33
@@ -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,
|
||||
);
|
||||
+16
-16
@@ -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],
|
||||
);
|
||||
|
||||
+28
-28
@@ -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,
|
||||
+6
-6
@@ -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,
|
||||
{
|
||||
+21
-21
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-20
@@ -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();
|
||||
+58
-58
@@ -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,
|
||||
};
|
||||
}
|
||||
+21
-21
@@ -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,
|
||||
};
|
||||
}
|
||||
+21
-35
@@ -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>();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user