chore: finalize v1.3.0 follow-up fixes

This commit is contained in:
coso
2026-04-05 14:10:33 +08:00
parent e9ba195ff6
commit e4b93c38d8
24 changed files with 584 additions and 721 deletions
-5
View File
@@ -10,20 +10,15 @@ React 组件层,包含 UI 组件和业务组件。
## 文件索引
- `agent/` - AI Agent 聊天页面组件
- `clients/` - 客户端管理组件
- `config/` - 配置管理组件
- `connect/` - Lime Connect 组件(中转商 API Key 添加)
- `flow-monitor/` - LLM 流量监控组件
- `general-chat/` - 通用对话功能组件(三栏布局:会话列表 + 聊天区域 + 画布)
- `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用)
- `plugins/` - 插件管理组件
- `provider-pool/` - Provider 凭证池管理组件
- `resilience/` - 弹性策略配置组件
- `routing/` - 路由规则配置组件
- `screenshot-chat/` - 截图对话功能组件(实验室功能)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `switch/` - 开关控制组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
- `tools/` - 工具页面组件
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
@@ -2058,40 +2058,40 @@ export function AgentChatWorkspace({
setTaskFiles,
});
const { handleBackHome, resetTopicLocalState } = useWorkspaceResetRuntime({
clearMessages,
clearRuntimeTeamState,
clearProjectSelectionRuntime,
resetRestoredSessionState,
resetProjectSelection,
resetGuideState,
hasHandledNewChatRequest,
markNewChatRequestHandled,
createFreshSession,
defaultTopicSidebarVisible,
normalizedInitialTheme: normalizedEntryTheme,
initialCreationMode,
newChatAt,
initialSessionName,
projectId,
externalProjectId,
onNavigate: _onNavigate,
autoCollapsedTopicSidebarRef,
processedMessageIdsRef: processedMessageIds,
setInput,
setSelectedText,
setLayoutMode,
setShowSidebar,
setCanvasState,
setGeneralCanvasState,
setTaskFiles,
setSelectedFileId,
setBrowserTaskPreflight,
setMentionedCharacters,
setProject,
setProjectMemory,
setActiveTheme,
setCreationMode,
});
clearMessages,
clearRuntimeTeamState,
clearProjectSelectionRuntime,
resetRestoredSessionState,
resetProjectSelection,
resetGuideState,
hasHandledNewChatRequest,
markNewChatRequestHandled,
createFreshSession,
defaultTopicSidebarVisible,
normalizedInitialTheme: normalizedEntryTheme,
initialCreationMode,
newChatAt,
initialSessionName,
projectId,
externalProjectId,
onNavigate: _onNavigate,
autoCollapsedTopicSidebarRef,
processedMessageIdsRef: processedMessageIds,
setInput,
setSelectedText,
setLayoutMode,
setShowSidebar,
setCanvasState,
setGeneralCanvasState,
setTaskFiles,
setSelectedFileId,
setBrowserTaskPreflight,
setMentionedCharacters,
setProject,
setProjectMemory,
setActiveTheme,
setCreationMode,
});
const { switchTopic } = useWorkspaceTopicSwitch({
projectId,
@@ -2216,6 +2216,7 @@ export function AgentChatWorkspace({
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill:
workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
});
@@ -2739,9 +2740,6 @@ export function AgentChatWorkspace({
input,
setInput,
currentGate,
pendingA2UIForm,
handleInputbarA2UISubmit,
a2uiSubmissionNotice,
themeWorkbenchSidebarRuntime,
steps,
themeWorkbenchRunState,
@@ -119,6 +119,9 @@ export const InputbarComposerSection: React.FC<
buildSkillSelectionBindings(skillSelection);
const resolvedProviderType = inputAdapter.model?.providerType;
const resolvedModel = inputAdapter.model?.model;
const resolvedSetProviderType =
inputAdapter.actions.setProviderType || (() => undefined);
const resolvedSetModel = inputAdapter.actions.setModel || (() => undefined);
const shouldShowVisionNotice =
currentPendingImages.length > 0 &&
Boolean(resolvedProviderType?.trim()) &&
@@ -127,7 +130,7 @@ export const InputbarComposerSection: React.FC<
topExtra || shouldShowVisionNotice ? (
<>
{topExtra}
{shouldShowVisionNotice ? (
{shouldShowVisionNotice && resolvedProviderType && resolvedModel ? (
<InputbarVisionCapabilityNotice
providerType={resolvedProviderType}
model={resolvedModel}
@@ -235,9 +238,9 @@ export const InputbarComposerSection: React.FC<
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={inputAdapter.model?.providerType}
setProviderType={inputAdapter.actions.setProviderType}
setProviderType={resolvedSetProviderType}
model={inputAdapter.model?.model}
setModel={inputAdapter.actions.setModel}
setModel={resolvedSetModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
@@ -218,10 +218,7 @@ describe("TeamSelectorPanel", () => {
});
it("命中稳妥模式模型时不应再展示 Team 横幅提示", async () => {
const { container } = renderPanel({
providerType: "openai",
model: "glm-4.7",
});
const { container } = renderPanel();
await flushEffects();
@@ -728,12 +728,12 @@ export async function tryExecuteSlashSkillCommand(
let imageTaskPreview: MessageImageWorkbenchPreview | null = null;
const patched = prev.map((msg) => {
if (msg.id !== assistantMsgId) return msg;
const currentToolArguments =
streamEvent.arguments ||
msg.toolCalls?.find((tc) => tc.id === streamEvent.tool_id)
?.arguments;
const currentToolCall = msg.toolCalls?.find(
(tc) => tc.id === streamEvent.tool_id,
);
const currentToolArguments = currentToolCall?.arguments;
imageTaskPreview = buildImageTaskPreviewFromToolResult({
toolName: streamEvent.tool_name,
toolName: currentToolCall?.name || "",
toolArguments: currentToolArguments,
toolResult: asRecord(normalizedResult) || undefined,
fallbackPrompt: command.userInput || rawContent,
@@ -7,6 +7,11 @@ import {
readFilePreview,
type FilePreview,
} from "@/lib/api/fileBrowser";
import {
getMediaTaskArtifact,
listMediaTaskArtifacts,
type MediaTaskArtifactOutput,
} from "@/lib/api/mediaTasks";
import { safeListen } from "@/lib/dev-bridge";
import type { Message } from "../types";
import {
@@ -26,6 +31,11 @@ vi.mock("@/lib/api/fileBrowser", () => ({
readFilePreview: vi.fn(),
}));
vi.mock("@/lib/api/mediaTasks", () => ({
getMediaTaskArtifact: vi.fn(),
listMediaTaskArtifacts: vi.fn(),
}));
type HookProps = Parameters<typeof useWorkspaceImageTaskPreviewRuntime>[0];
type RuntimeHarnessProps = Omit<
HookProps,
@@ -85,6 +95,27 @@ function createDirectoryListingResult(
};
}
function createArtifactOutput(
overrides: Partial<MediaTaskArtifactOutput> & {
record: MediaTaskArtifactOutput["record"];
task_id: string;
task_type: string;
},
): MediaTaskArtifactOutput {
return {
success: true,
task_family: "image",
status: "completed",
normalized_status: "succeeded",
path: `.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`,
absolute_path: `/workspace/project-image-1/.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`,
artifact_path: `.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`,
absolute_artifact_path: `/workspace/project-image-1/.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`,
reused_existing: false,
...overrides,
};
}
function renderHook(
props?: Partial<RuntimeHarnessProps>,
options?: { initialMessages?: Message[] },
@@ -160,6 +191,15 @@ function renderHook(
};
}
function getDocumentCanvasContent(
canvasState: RuntimeHarnessProps["canvasState"],
): string {
if (!canvasState || canvasState.type !== "document") {
throw new Error("期望 document canvasState");
}
return canvasState.content;
}
describe("useWorkspaceImageTaskPreviewRuntime", () => {
beforeEach(() => {
(
@@ -170,6 +210,12 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
vi.resetAllMocks();
vi.useFakeTimers();
vi.mocked(safeListen).mockResolvedValue(vi.fn());
vi.mocked(getMediaTaskArtifact).mockRejectedValue(
new Error("task artifact unavailable"),
);
vi.mocked(listMediaTaskArtifacts).mockRejectedValue(
new Error("task artifact list unavailable"),
);
vi.mocked(listDirectory).mockResolvedValue(
createDirectoryListingResult(
"/workspace/project-image-1/.lime/tasks",
@@ -419,6 +465,98 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
]);
});
it("task file 预览被截断时,应回退到媒体任务接口继续回填图片结果", async () => {
let listener: CreationTaskListener | null = null;
vi.mocked(safeListen).mockImplementationOnce(async (_event, handler) => {
listener = handler;
return vi.fn();
});
const taskId = "task-image-large-preview-1";
const dataUrl = `data:image/png;base64,${"A".repeat(4096)}`;
vi.mocked(readFilePreview).mockResolvedValueOnce({
path: `/workspace/project-image-1/.lime/tasks/image_generate/${taskId}.json`,
content:
'{"task_id":"task-image-large-preview-1","result":{"images":[{"url":"data:image/png;base64,AAAA',
isBinary: false,
size: 512 * 1024,
error: null,
});
vi.mocked(getMediaTaskArtifact).mockResolvedValueOnce(
createArtifactOutput({
task_id: taskId,
task_type: "image_generate",
current_attempt_id: "attempt-large-preview-1",
record: withDefaultTaskContext({
task_id: taskId,
task_type: "image_generate",
task_family: "image",
status: "completed",
normalized_status: "succeeded",
created_at: "2026-04-04T11:00:00Z",
current_attempt_id: "attempt-large-preview-1",
payload: {
prompt: "[img:高保真青柠品牌主视觉]",
count: 1,
size: "1024x1024",
},
result: {
images: [{ url: dataUrl }],
},
attempts: [
{
attempt_id: "attempt-large-preview-1",
provider: "fal",
model: "flux-pro",
result_snapshot: {
images: [{ url: dataUrl }],
},
},
],
}),
}),
);
const { render, getValue } = renderHook();
await render();
await act(async () => {
listener?.({
payload: withDefaultTaskContext({
task_id: taskId,
task_type: "image_generate",
task_family: "image",
status: "completed",
path: `.lime/tasks/image_generate/${taskId}.json`,
}),
});
await Promise.resolve();
await Promise.resolve();
});
expect(getMediaTaskArtifact).toHaveBeenCalledWith({
projectRootPath: DEFAULT_PROJECT_ROOT_PATH,
taskRef: taskId,
});
expect(getValue().messages).toEqual([
expect.objectContaining({
id: `image-workbench:${taskId}:assistant`,
imageWorkbenchPreview: expect.objectContaining({
taskId,
status: "complete",
imageUrl: dataUrl,
prompt: "高保真青柠品牌主视觉",
}),
}),
]);
expect(getValue().imageWorkbenchState.outputs).toEqual([
expect.objectContaining({
taskId,
url: dataUrl,
}),
]);
});
it("已存在同 taskId 的 skill 消息时,应把 task 预览合并回原消息而不是再插一条伪造消息", async () => {
let listener: CreationTaskListener | null = null;
vi.mocked(safeListen).mockImplementationOnce(async (event, handler) => {
@@ -713,6 +851,66 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
]);
});
it("应优先通过媒体任务接口恢复最近的图片任务,避免大 task file 截断", async () => {
const taskId = "task-image-restored-api";
vi.mocked(listMediaTaskArtifacts).mockResolvedValueOnce({
success: true,
workspace_root: DEFAULT_PROJECT_ROOT_PATH,
artifact_root: `${DEFAULT_PROJECT_ROOT_PATH}/.lime/tasks`,
filters: {
task_family: "image",
limit: 32,
},
total: 1,
tasks: [
createArtifactOutput({
task_id: taskId,
task_type: "image_generate",
record: withDefaultTaskContext({
task_id: taskId,
task_type: "image_generate",
task_family: "image",
status: "completed",
normalized_status: "succeeded",
created_at: "2026-04-04T10:00:00Z",
payload: {
prompt: "[img:通过 API 恢复的青柠主视觉]",
count: 1,
size: "1024x1024",
},
result: {
images: [{ url: "https://example.com/restored-api-lime.png" }],
},
}),
}),
],
});
const { render, getValue } = renderHook();
await render();
await vi.waitFor(() => {
expect(getValue().messages).toEqual([
expect.objectContaining({
id: `image-workbench:${taskId}:assistant`,
imageWorkbenchPreview: expect.objectContaining({
taskId,
imageUrl: "https://example.com/restored-api-lime.png",
prompt: "通过 API 恢复的青柠主视觉",
}),
}),
]);
});
expect(listMediaTaskArtifacts).toHaveBeenCalledWith({
projectRootPath: DEFAULT_PROJECT_ROOT_PATH,
taskFamily: "image",
limit: 32,
});
expect(listDirectory).not.toHaveBeenCalled();
expect(readFilePreview).not.toHaveBeenCalled();
});
it("应在进入会话时从 task file 恢复最近的图片任务", async () => {
vi.mocked(listDirectory)
.mockResolvedValueOnce(
@@ -1022,10 +1220,10 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
});
expect(getValue().canvasState?.type).toBe("document");
expect(getValue().canvasState?.content).toContain(
expect(getDocumentCanvasContent(getValue().canvasState)).toContain(
"https://example.com/restored-inline-lime.png",
);
expect(getValue().canvasState?.content).not.toContain(
expect(getDocumentCanvasContent(getValue().canvasState)).not.toContain(
"pending-image-task://",
);
});
@@ -1122,7 +1320,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
await Promise.resolve();
});
const nextContent = getValue().canvasState?.content || "";
const nextContent = getDocumentCanvasContent(getValue().canvasState);
const imageIndex = nextContent.indexOf(
"https://example.com/restored-inline-section.png",
);
@@ -1442,11 +1640,13 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
});
expect(getValue().canvasState?.type).toBe("document");
expect(getValue().canvasState?.content).toContain("pending-image-task://");
expect(getValue().canvasState?.content).toContain(
expect(getDocumentCanvasContent(getValue().canvasState)).toContain(
"pending-image-task://",
);
expect(getDocumentCanvasContent(getValue().canvasState)).toContain(
"lime:image-task-slot:document-slot-inline-1",
);
const pendingContent = getValue().canvasState?.content || "";
const pendingContent = getDocumentCanvasContent(getValue().canvasState);
const placeholderIndex = pendingContent.indexOf("pending-image-task://");
expect(placeholderIndex).toBeGreaterThan(
pendingContent.indexOf("这里是被选中的核心观点段落。"),
@@ -1499,10 +1699,10 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
await Promise.resolve();
});
expect(getValue().canvasState?.content).toContain(
expect(getDocumentCanvasContent(getValue().canvasState)).toContain(
"https://example.com/generated-inline-lime.png",
);
const finalContent = getValue().canvasState?.content || "";
const finalContent = getDocumentCanvasContent(getValue().canvasState);
const imageIndex = finalContent.indexOf(
"https://example.com/generated-inline-lime.png",
);
@@ -1512,7 +1712,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect(imageIndex).toBeLessThan(
finalContent.indexOf("这里是核心观点补充说明。"),
);
expect(getValue().canvasState?.content).not.toContain(
expect(getDocumentCanvasContent(getValue().canvasState)).not.toContain(
"pending-image-task://",
);
});
@@ -1,6 +1,10 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { listDirectory, readFilePreview } from "@/lib/api/fileBrowser";
import type { MediaTaskArtifactOutput } from "@/lib/api/mediaTasks";
import {
getMediaTaskArtifact,
listMediaTaskArtifacts,
type MediaTaskArtifactOutput,
} from "@/lib/api/mediaTasks";
import { safeListen } from "@/lib/dev-bridge";
import { resolveAbsoluteWorkspacePath } from "./workspacePath";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
@@ -92,6 +96,17 @@ interface TaskContextMetadata {
contentId?: string;
}
interface LoadedImageTaskSnapshot {
snapshot: ParsedImageTaskSnapshot;
taskRecord: Record<string, unknown>;
}
interface RestoredImageTaskSnapshot extends LoadedImageTaskSnapshot {
absolutePath: string;
taskType: string;
taskFamily: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -1257,12 +1272,7 @@ export function buildImageTaskSnapshotFromArtifactOutput(params: {
contentId?: string | null;
canvasState: CanvasStateUnion | null;
}): ParsedImageTaskSnapshot | null {
const record =
params.artifact.record &&
typeof params.artifact.record === "object" &&
!Array.isArray(params.artifact.record)
? (params.artifact.record as Record<string, unknown>)
: null;
const record = asRecord(params.artifact.record);
if (record) {
return (
@@ -1313,12 +1323,17 @@ function upsertPreviewMessage(
);
if (taskMatchedIndex >= 0) {
const existingMessage = nextMessages[taskMatchedIndex];
const existingPreview = existingMessage.imageWorkbenchPreview;
const nextPreview = nextMessage.imageWorkbenchPreview;
nextMessages[taskMatchedIndex] = {
...existingMessage,
imageWorkbenchPreview: {
...(existingMessage.imageWorkbenchPreview || {}),
...nextMessage.imageWorkbenchPreview,
},
imageWorkbenchPreview:
existingPreview && nextPreview
? {
...existingPreview,
...nextPreview,
}
: nextPreview || existingPreview,
runtimeStatus:
nextMessage.runtimeStatus ?? existingMessage.runtimeStatus,
};
@@ -1419,6 +1434,63 @@ export function useWorkspaceImageTaskPreviewRuntime({
let cancelled = false;
let unlisten: (() => void) | null = null;
const loadTaskSnapshotFromArtifactApi = async (
taskId: string,
): Promise<LoadedImageTaskSnapshot | null> => {
const currentProjectRootPath =
runtimeContextRef.current.projectRootPath?.trim();
if (!currentProjectRootPath) {
return null;
}
try {
const artifact = await getMediaTaskArtifact({
projectRootPath: currentProjectRootPath,
taskRef: taskId,
});
if (cancelled) {
return null;
}
const taskRecord = asRecord(artifact.record);
if (!taskRecord) {
return null;
}
const snapshot = buildImageTaskSnapshotFromArtifactOutput({
artifact,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
canvasState: runtimeContextRef.current.canvasState,
});
if (!snapshot) {
return null;
}
return {
snapshot,
taskRecord,
};
} catch {
return null;
}
};
const applyLoadedTaskSnapshot = (params: LoadedImageTaskSnapshot) => {
setChatMessages((previous) =>
upsertPreviewMessage(previous, params.snapshot.message),
);
updateCurrentImageWorkbenchState((current) =>
mergeImageTaskSnapshot(current, params.snapshot),
);
syncDocumentInlineImageTask({
taskRecord: params.taskRecord,
taskId: params.snapshot.taskId,
outputs: params.snapshot.outputs,
setCanvasState,
});
};
const scheduleNextPoll = (taskId: string) => {
const trackedTask = trackedTasks.get(taskId);
if (!trackedTask || cancelled) {
@@ -1442,47 +1514,52 @@ export function useWorkspaceImageTaskPreviewRuntime({
trackedTask.polling = true;
try {
const preview = await readFilePreview(
trackedTask.absolutePath,
IMAGE_TASK_FILE_PREVIEW_MAX_SIZE,
);
if (cancelled || !trackedTasks.has(taskId)) {
return;
let loadedSnapshot: LoadedImageTaskSnapshot | null = null;
try {
const preview = await readFilePreview(
trackedTask.absolutePath,
IMAGE_TASK_FILE_PREVIEW_MAX_SIZE,
);
if (cancelled || !trackedTasks.has(taskId)) {
return;
}
if (!preview.error && preview.content?.trim()) {
const parsed = JSON.parse(preview.content) as Record<
string,
unknown
>;
const snapshot = buildParsedImageTaskSnapshot({
taskRecord: parsed,
taskId: trackedTask.taskId,
taskType: trackedTask.taskType,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
canvasState: runtimeContextRef.current.canvasState,
});
if (snapshot) {
loadedSnapshot = {
snapshot,
taskRecord: parsed,
};
}
}
} catch {
loadedSnapshot = null;
}
if (preview.error || !preview.content?.trim()) {
if (!loadedSnapshot) {
loadedSnapshot = await loadTaskSnapshotFromArtifactApi(taskId);
}
if (!loadedSnapshot) {
scheduleNextPoll(taskId);
return;
}
const parsed = JSON.parse(preview.content) as Record<string, unknown>;
const snapshot = buildParsedImageTaskSnapshot({
taskRecord: parsed,
taskId: trackedTask.taskId,
taskType: trackedTask.taskType,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
canvasState: runtimeContextRef.current.canvasState,
});
if (!snapshot) {
scheduleNextPoll(taskId);
return;
}
applyLoadedTaskSnapshot(loadedSnapshot);
setChatMessages((previous) =>
upsertPreviewMessage(previous, snapshot.message),
);
updateCurrentImageWorkbenchState((current) =>
mergeImageTaskSnapshot(current, snapshot),
);
syncDocumentInlineImageTask({
taskRecord: parsed,
taskId,
outputs: snapshot.outputs,
setCanvasState,
});
if (snapshot.terminal) {
if (loadedSnapshot.snapshot.terminal) {
trackedTasks.delete(taskId);
return;
}
@@ -1505,81 +1582,141 @@ export function useWorkspaceImageTaskPreviewRuntime({
return;
}
const candidatePaths = await collectImageTaskCandidatePaths(
currentProjectRootPath,
);
if (cancelled || candidatePaths.length === 0) {
return;
}
let restoredSnapshots: RestoredImageTaskSnapshot[] | null = null;
const restoredSnapshots: Array<{
snapshot: ParsedImageTaskSnapshot;
taskRecord: Record<string, unknown>;
absolutePath: string;
taskType: string;
taskFamily: string;
}> = [];
const seenTaskIds = new Set<string>();
try {
const artifactList = await listMediaTaskArtifacts({
projectRootPath: currentProjectRootPath,
taskFamily: "image",
limit: IMAGE_TASK_RESTORE_LIMIT * 4,
});
if (cancelled) {
return;
}
for (const candidatePath of candidatePaths) {
try {
const preview = await readFilePreview(
candidatePath,
IMAGE_TASK_FILE_PREVIEW_MAX_SIZE,
);
if (cancelled || preview.error || !preview.content?.trim()) {
continue;
restoredSnapshots = artifactList.tasks.reduce<
RestoredImageTaskSnapshot[]
>((items, artifact) => {
const taskRecord = asRecord(artifact.record);
if (!taskRecord) {
return items;
}
const parsed = JSON.parse(preview.content) as Record<string, unknown>;
if (
!shouldRestoreImageTaskRecord({
taskRecord: parsed,
taskRecord,
sessionId: runtimeContextRef.current.sessionId,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
})
) {
continue;
return items;
}
const taskId = readString([parsed], ["task_id", "taskId"]);
const taskType = readString([parsed], ["task_type", "taskType"]);
const taskFamily = normalizeTaskFamily(
taskType || "",
readString([parsed], ["task_family", "taskFamily"]),
artifact.task_type,
artifact.task_family,
);
if (
!taskId ||
!taskType ||
taskFamily !== "image" ||
seenTaskIds.has(taskId)
) {
continue;
if (taskFamily !== "image") {
return items;
}
const snapshot = buildParsedImageTaskSnapshot({
taskRecord: parsed,
taskId,
taskType,
const snapshot = buildImageTaskSnapshotFromArtifactOutput({
artifact,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
canvasState: runtimeContextRef.current.canvasState,
});
if (!snapshot) {
continue;
return items;
}
seenTaskIds.add(taskId);
restoredSnapshots.push({
items.push({
snapshot,
taskRecord: parsed,
absolutePath: candidatePath,
taskType,
taskRecord,
absolutePath: artifact.absolute_path,
taskType: artifact.task_type,
taskFamily,
});
} catch {
continue;
return items;
}, []);
} catch {
restoredSnapshots = null;
}
if (restoredSnapshots === null) {
const candidatePaths = await collectImageTaskCandidatePaths(
currentProjectRootPath,
);
if (cancelled || candidatePaths.length === 0) {
return;
}
restoredSnapshots = [];
const seenTaskIds = new Set<string>();
for (const candidatePath of candidatePaths) {
try {
const preview = await readFilePreview(
candidatePath,
IMAGE_TASK_FILE_PREVIEW_MAX_SIZE,
);
if (cancelled || preview.error || !preview.content?.trim()) {
continue;
}
const parsed = JSON.parse(preview.content) as Record<
string,
unknown
>;
if (
!shouldRestoreImageTaskRecord({
taskRecord: parsed,
sessionId: runtimeContextRef.current.sessionId,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
})
) {
continue;
}
const taskId = readString([parsed], ["task_id", "taskId"]);
const taskType = readString([parsed], ["task_type", "taskType"]);
const taskFamily = normalizeTaskFamily(
taskType || "",
readString([parsed], ["task_family", "taskFamily"]),
);
if (
!taskId ||
!taskType ||
taskFamily !== "image" ||
seenTaskIds.has(taskId)
) {
continue;
}
const snapshot = buildParsedImageTaskSnapshot({
taskRecord: parsed,
taskId,
taskType,
projectId: runtimeContextRef.current.projectId,
contentId: runtimeContextRef.current.contentId,
canvasState: runtimeContextRef.current.canvasState,
});
if (!snapshot) {
continue;
}
seenTaskIds.add(taskId);
restoredSnapshots.push({
snapshot,
taskRecord: parsed,
absolutePath: candidatePath,
taskType,
taskFamily,
});
} catch {
continue;
}
}
}
@@ -1689,7 +1826,7 @@ export function useWorkspaceImageTaskPreviewRuntime({
canvasState: runtimeContextRef.current.canvasState,
})
: null;
if (pendingSnapshot) {
if (pendingSnapshot && taskId && taskType) {
setChatMessages((previous) =>
upsertPreviewMessage(previous, pendingSnapshot.message),
);
@@ -906,8 +906,8 @@ export function useWorkspaceImageWorkbenchActionRuntime({
: "claw-image-workbench",
slotId: documentInlineSlotId,
anchorHint: documentInlineAnchorHint,
anchorSectionTitle: documentInlineAnchorSectionTitle,
anchorText: documentInlineAnchorText,
anchorSectionTitle: documentInlineAnchorSectionTitle ?? undefined,
anchorText: documentInlineAnchorText ?? undefined,
providerId: imageWorkbenchSelectedProviderId,
model: imageWorkbenchSelectedModelId,
sessionId: imageWorkbenchSessionKey,
@@ -932,11 +932,7 @@ export function useWorkspaceImageWorkbenchActionRuntime({
);
syncDocumentInlineImageTask({
taskRecord:
createdArtifact.record &&
typeof createdArtifact.record === "object" &&
!Array.isArray(createdArtifact.record)
? (createdArtifact.record as Record<string, unknown>)
: {
asRecord(createdArtifact.record) || {
task_id: createdArtifact.task_id,
task_type: createdArtifact.task_type,
status: createdArtifact.status,
@@ -31,7 +31,12 @@ const mockSetChatToolPreferences = vi.fn();
const mockSetRuntimeTeamDispatchPreview = vi.fn();
const mockEnsureBrowserAssistCanvas = vi.fn(async () => true);
const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined);
const mockResolveImageWorkbenchSkillRequest = vi.fn(() => null);
const mockHandleImageWorkbenchCommand = vi.fn<
HookProps["handleImageWorkbenchCommand"]
>(async () => true);
const mockResolveImageWorkbenchSkillRequest = vi.fn<
HookProps["resolveImageWorkbenchSkillRequest"]
>(() => null);
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
@@ -198,6 +203,8 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
mockEnsureBrowserAssistCanvas as HookProps["ensureBrowserAssistCanvas"],
handleAutoLaunchMatchedSiteSkill:
mockHandleAutoLaunchMatchedSiteSkill as HookProps["handleAutoLaunchMatchedSiteSkill"],
handleImageWorkbenchCommand:
mockHandleImageWorkbenchCommand as HookProps["handleImageWorkbenchCommand"],
resolveImageWorkbenchSkillRequest:
mockResolveImageWorkbenchSkillRequest as HookProps["resolveImageWorkbenchSkillRequest"],
...initialProps,
@@ -237,6 +244,7 @@ describe("useWorkspaceSendActions", () => {
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockHandleImageWorkbenchCommand.mockResolvedValue(true);
mockResolveImageWorkbenchSkillRequest.mockReturnValue(null);
});
@@ -433,17 +441,7 @@ describe("useWorkspaceSendActions", () => {
}
});
it("纯文本 @配图 应保留原始消息,并通过 image_skill_launch metadata 交给 Agent 调度技能", async () => {
mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({
images: [],
requestContext: {
kind: "image_task",
image_task: {
mode: "generate",
prompt: "一张春日咖啡馆插画",
},
},
});
it("纯文本 @配图 应直接命中本地图片任务主链,而不是走 Agent 工具搜索", async () => {
const harness = mountHook({
input: "@配图 生成 一张春日咖啡馆插画,16:9,出 2 张",
});
@@ -454,22 +452,22 @@ describe("useWorkspaceSendActions", () => {
expect(started).toBe(true);
});
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage.mock.calls[0]?.[0]).toBe(
"@配图 生成 一张春日咖啡馆插画,16:9,出 2 张",
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1);
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith(
expect.objectContaining({
rawText: "@配图 生成 一张春日咖啡馆插画,16:9,出 2 张",
parsedCommand: expect.objectContaining({
trigger: "@配图",
mode: "generate",
prompt: "一张春日咖啡馆插画",
aspectRatio: "16:9",
count: 2,
}),
images: [],
}),
);
expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({
requestMetadata: {
harness: {
allow_model_skills: true,
image_skill_launch: {
skill_name: "image_generate",
kind: "image_task",
},
},
},
});
expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
@@ -516,16 +514,7 @@ describe("useWorkspaceSendActions", () => {
}
});
it("带引用图的图片命令应保留原始消息,并把结构化 image_skill_launch metadata 交给 Agent", async () => {
mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({
images: [],
requestContext: {
kind: "image_task",
image_task: {
mode: "edit",
},
},
});
it("带引用图的图片命令应直接交给本地图片任务运行时", async () => {
const harness = mountHook({
input: "@配图 编辑 #img-2 去掉角标,保留主体",
});
@@ -536,8 +525,8 @@ describe("useWorkspaceSendActions", () => {
expect(started).toBe(true);
});
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1);
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith(
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1);
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith(
expect.objectContaining({
rawText: "@配图 编辑 #img-2 去掉角标,保留主体",
parsedCommand: expect.objectContaining({
@@ -548,40 +537,14 @@ describe("useWorkspaceSendActions", () => {
images: [],
}),
);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage.mock.calls[0]?.[0]).toBe(
"@配图 编辑 #img-2 去掉角标,保留主体",
);
expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({
requestMetadata: {
harness: {
allow_model_skills: true,
image_skill_launch: {
skill_name: "image_generate",
kind: "image_task",
image_task: {
mode: "edit",
},
},
},
},
});
expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("@修图 应接入当前图片 skillRequest 主链", async () => {
mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({
images: [],
requestContext: {
kind: "image_task",
image_task: {
mode: "edit",
target_output_ref_id: "img-2",
},
},
});
it("@修图 应直达当前图片任务主链", async () => {
const harness = mountHook({
input: "@修图 #img-2 去掉角标,保留主体",
});
@@ -592,8 +555,8 @@ describe("useWorkspaceSendActions", () => {
expect(started).toBe(true);
});
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1);
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith(
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1);
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith(
expect.objectContaining({
rawText: "@修图 #img-2 去掉角标,保留主体",
parsedCommand: expect.objectContaining({
@@ -604,41 +567,14 @@ describe("useWorkspaceSendActions", () => {
images: [],
}),
);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage.mock.calls[0]?.[0]).toBe(
"@修图 #img-2 去掉角标,保留主体",
);
expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({
requestMetadata: {
harness: {
allow_model_skills: true,
image_skill_launch: {
skill_name: "image_generate",
kind: "image_task",
image_task: {
mode: "edit",
target_output_ref_id: "img-2",
},
},
},
},
});
expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("@重绘 应接入当前图片 skillRequest 主链", async () => {
mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({
images: [],
requestContext: {
kind: "image_task",
image_task: {
mode: "variation",
target_output_ref_id: "img-2",
},
},
});
it("@重绘 应直达当前图片任务主链", async () => {
const harness = mountHook({
input: "@重绘 #img-2 更偏插画风,保留主视觉",
});
@@ -649,8 +585,8 @@ describe("useWorkspaceSendActions", () => {
expect(started).toBe(true);
});
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1);
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith(
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1);
expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith(
expect.objectContaining({
rawText: "@重绘 #img-2 更偏插画风,保留主视觉",
parsedCommand: expect.objectContaining({
@@ -661,9 +597,39 @@ describe("useWorkspaceSendActions", () => {
images: [],
}),
);
expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("/image 仍应保留原始消息,并通过 image_skill_launch metadata 交给 Agent", async () => {
mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({
images: [],
requestContext: {
kind: "image_task",
image_task: {
mode: "generate",
prompt: "春日咖啡馆插画",
},
},
});
const harness = mountHook({
input: "/image 春日咖啡馆插画,16:9,出 2 张",
});
try {
await act(async () => {
const started = await harness.getValue().handleSend();
expect(started).toBe(true);
});
expect(mockHandleImageWorkbenchCommand).not.toHaveBeenCalled();
expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage.mock.calls[0]?.[0]).toBe(
"@重绘 #img-2 更偏插画风,保留主视觉",
"/image 春日咖啡馆插画,16:9,出 2 张",
);
expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({
requestMetadata: {
@@ -672,10 +638,6 @@ describe("useWorkspaceSendActions", () => {
image_skill_launch: {
skill_name: "image_generate",
kind: "image_task",
image_task: {
mode: "variation",
target_output_ref_id: "img-2",
},
},
},
},
@@ -358,6 +358,11 @@ interface UseWorkspaceSendActionsParams {
handleAutoLaunchMatchedSiteSkill: (
match: AutoMatchedSiteSkill<ServiceSkillHomeItem>,
) => Promise<void>;
handleImageWorkbenchCommand: (input: {
rawText: string;
parsedCommand: ParsedImageWorkbenchCommand;
images: MessageImage[];
}) => Promise<boolean>;
resolveImageWorkbenchSkillRequest: (input: {
rawText: string;
parsedCommand: ParsedImageWorkbenchCommand;
@@ -440,6 +445,7 @@ export function useWorkspaceSendActions({
setRuntimeTeamDispatchPreview,
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
}: UseWorkspaceSendActionsParams) {
const [submissionPreview, setSubmissionPreview] =
@@ -494,6 +500,16 @@ export function useWorkspaceSendActions({
? parseImageWorkbenchCommand(sourceText)
: null;
if (parsedImageWorkbenchCommand) {
if (parsedImageWorkbenchCommand.trigger.trim().startsWith("@")) {
return {
kind: "done",
result: await handleImageWorkbenchCommand({
rawText: sourceText,
parsedCommand: parsedImageWorkbenchCommand,
images: effectiveImages,
}),
};
}
const skillRequest = resolveImageWorkbenchSkillRequest({
rawText: sourceText,
parsedCommand: parsedImageWorkbenchCommand,
@@ -712,6 +728,7 @@ export function useWorkspaceSendActions({
ensureBrowserAssistCanvas,
executionStrategy,
handleAutoLaunchMatchedSiteSkill,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
input,
maybeStartBrowserTaskPreflight,
-3
View File
@@ -429,7 +429,6 @@
"查看会话详情回调": "View Session Detail Callback",
"主组件": "Main Component",
"状态": "Status",
"// === Resilience Settings (src/components/resilience/*.tsx) ===": "",
"重试配置": "Retry Configuration",
"故障转移": "Failover",
"容错配置": "Fault Tolerance Configuration",
@@ -2602,7 +2601,6 @@
"系统每": "System checks every",
"秒自动检查凭证文件变化": "seconds for credential file changes automatically",
"如有更新会自动重新加载并记录日志": "Will automatically reload and log if there are updates",
"// === components\\resilience\\FailoverSettings.tsx ===": "",
"发生故障时": "When a failure occurs",
"系统可自动切换到其他可用": "System can automatically switch to other available",
"切换日志记录所有自动切换事件": "Switch log records all automatic switching events",
@@ -2617,7 +2615,6 @@
"暂无切换记录": "No switch records",
"当发生自动切换时": "When automatic switching occurs",
"记录将显示在这里": "Records will be displayed here",
"// === components\\resilience\\RetrySettings.tsx ===": "",
"当请求失败时": "When a request fails",
"系统会自动重试指定次数": "System will automatically retry the specified number of times",
"使用指数退避策略": "Using exponential backoff strategy",
-3
View File
@@ -404,7 +404,6 @@
"查看会话详情回调": "查看会话详情回调",
"主组件": "主组件",
"状态": "状态",
"// === Resilience Settings (src/components/resilience/*.tsx) ===": "",
"重试配置": "重试配置",
"故障转移": "故障转移",
"容错配置": "容错配置",
@@ -2584,7 +2583,6 @@
"系统每": "系统每",
"秒自动检查凭证文件变化": "秒自动检查凭证文件变化",
"如有更新会自动重新加载并记录日志": "如有更新会自动重新加载并记录日志",
"// === components\\resilience\\FailoverSettings.tsx ===": "",
"发生故障时": "发生故障时",
"系统可自动切换到其他可用": "系统可自动切换到其他可用",
"切换日志记录所有自动切换事件": "切换日志记录所有自动切换事件",
@@ -2599,7 +2597,6 @@
"暂无切换记录": "暂无切换记录",
"当发生自动切换时": "当发生自动切换时",
"记录将显示在这里": "记录将显示在这里",
"// === components\\resilience\\RetrySettings.tsx ===": "",
"当请求失败时": "当请求失败时",
"系统会自动重试指定次数": "系统会自动重试指定次数",
"使用指数退避策略": "使用指数退避策略",
-52
View File
@@ -1,52 +0,0 @@
import { safeInvoke } from "@/lib/dev-bridge";
// Retry configuration
export interface RetryConfig {
max_retries: number;
base_delay_ms: number;
max_delay_ms: number;
retryable_codes: number[];
}
// Failover configuration
export interface FailoverConfig {
auto_switch: boolean;
switch_on_quota: boolean;
}
// Switch log entry
export interface SwitchLogEntry {
from_provider: string;
to_provider: string;
failure_type: string;
timestamp: string;
}
export const resilienceApi = {
// Retry config
async getRetryConfig(): Promise<RetryConfig> {
return safeInvoke("get_retry_config");
},
async updateRetryConfig(config: RetryConfig): Promise<void> {
return safeInvoke("update_retry_config", { config });
},
// Failover config
async getFailoverConfig(): Promise<FailoverConfig> {
return safeInvoke("get_failover_config");
},
async updateFailoverConfig(config: FailoverConfig): Promise<void> {
return safeInvoke("update_failover_config", { config });
},
// Switch log
async getSwitchLog(): Promise<SwitchLogEntry[]> {
return safeInvoke("get_switch_log");
},
async clearSwitchLog(): Promise<void> {
return safeInvoke("clear_switch_log");
},
};
-55
View File
@@ -1,55 +0,0 @@
import { safeInvoke } from "@/lib/dev-bridge";
export interface RateLimitConfig {
enabled: boolean;
requests_per_minute: number;
window_secs: number;
}
export interface ConversationConfig {
trim_enabled: boolean;
max_messages: number;
summary_enabled: boolean;
}
export interface HintRouteEntry {
hint: string;
provider: string;
model: string;
}
export interface PairingConfig {
enabled: boolean;
}
export async function getRateLimitConfig(): Promise<RateLimitConfig> {
return await safeInvoke("get_rate_limit_config");
}
export async function updateRateLimitConfig(config: RateLimitConfig): Promise<void> {
return await safeInvoke("update_rate_limit_config", { config });
}
export async function getConversationConfig(): Promise<ConversationConfig> {
return await safeInvoke("get_conversation_config");
}
export async function updateConversationConfig(config: ConversationConfig): Promise<void> {
return await safeInvoke("update_conversation_config", { config });
}
export async function getHintRoutes(): Promise<HintRouteEntry[]> {
return await safeInvoke("get_hint_routes");
}
export async function updateHintRoutes(routes: HintRouteEntry[]): Promise<void> {
return await safeInvoke("update_hint_routes", { routes });
}
export async function getPairingConfig(): Promise<PairingConfig> {
return await safeInvoke("get_pairing_config");
}
export async function updatePairingConfig(config: PairingConfig): Promise<void> {
return await safeInvoke("update_pairing_config", { config });
}
-8
View File
@@ -3858,14 +3858,6 @@ const defaultMocks: Record<string, any> = {
// Usage 相关
get_kiro_usage: () => ({ usage: {} }),
// Resilience 相关
get_retry_config: () => ({ config: {} }),
update_retry_config: () => ({ success: true }),
get_failover_config: () => ({ config: {} }),
update_failover_config: () => ({ success: true }),
get_switch_log: () => ({ logs: [] }),
clear_switch_log: () => ({ success: true }),
// Machine ID 相关
get_current_machine_id: () => ({ machine_id: "" }),
set_machine_id: () => ({ success: true }),