chore: finalize v0.95.0 release

This commit is contained in:
coso
2026-03-24 02:34:01 +08:00
parent 818c8fef7b
commit 544a2fa6c9
57 changed files with 5021 additions and 132 deletions
@@ -9,9 +9,11 @@ import { SettingsTabs } from "@/types/settings";
const {
mockBuildClawAgentParams,
mockCreateAutomationJob,
mockHomeShellExecutionStrategy,
mockHomeShellModel,
mockHomeShellProviderType,
mockListProjects,
mockSetExecutionStrategy,
mockSetModel,
mockSetProviderType,
@@ -23,6 +25,9 @@ const {
mockUseClawSolutions,
mockRecordClawSolutionUsage,
mockClawSolutions,
mockUseServiceSkills,
mockRecordServiceSkillUsage,
mockServiceSkills,
} = vi.hoisted(() => {
const mockClawSolutions = [
{
@@ -56,16 +61,113 @@ const {
},
];
const mockServiceSkills = [
{
id: "short-video-script-replication",
title: "复制短视频脚本",
summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。",
category: "视频创作",
outputHint: "脚本大纲 + 镜头节奏",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
executionLocation: "client_default",
themeTarget: "video",
version: "seed-v1",
slotSchema: [
{
key: "reference_video",
label: "参考视频链接/素材",
type: "url",
required: true,
placeholder: "输入视频链接",
},
],
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
},
{
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台与关键词输出趋势摘要。",
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
themeTarget: "social-media",
version: "seed-v1",
slotSchema: [
{
key: "platform",
label: "监测平台",
type: "platform",
required: true,
placeholder: "选择平台",
defaultValue: "x",
options: [{ value: "x", label: "X / Twitter" }],
},
{
key: "industry_keywords",
label: "行业关键词",
type: "textarea",
required: true,
placeholder: "输入关键词",
},
{
key: "schedule_time",
label: "推送时间",
type: "schedule_time",
required: false,
placeholder: "例如 每天 09:00",
defaultValue: "每天 09:00",
},
],
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地计划任务",
runnerTone: "sky",
runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
},
];
const mockRecordClawSolutionUsage = vi.fn();
const mockRecordServiceSkillUsage = vi.fn();
return {
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockCreateAutomationJob: vi.fn(async (request: Record<string, unknown>) => ({
id: "automation-job-1",
...request,
})),
mockHomeShellProviderType: { current: "mock-provider" },
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
mockListProjects: vi.fn(async () => [
{
id: "project-1",
name: "项目一",
workspaceType: "general",
rootPath: "/tmp/project-1",
isDefault: false,
createdAt: 0,
updatedAt: 0,
isFavorite: false,
isArchived: false,
tags: [],
},
]),
mockSetProviderType: vi.fn(),
mockSetModel: vi.fn(),
mockSetExecutionStrategy: vi.fn(),
@@ -94,6 +196,15 @@ const {
})),
mockRecordClawSolutionUsage,
mockClawSolutions,
mockUseServiceSkills: vi.fn(() => ({
skills: mockServiceSkills,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordServiceSkillUsage,
})),
mockRecordServiceSkillUsage,
mockServiceSkills,
};
});
@@ -200,6 +311,14 @@ vi.mock("@/lib/api/clawSolutions", () => ({
prepareClawSolution: mockPrepareClawSolution,
}));
vi.mock("@/lib/api/automation", () => ({
createAutomationJob: mockCreateAutomationJob,
}));
vi.mock("@/lib/api/project", () => ({
listProjects: mockListProjects,
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
}));
@@ -239,6 +358,192 @@ vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({
),
}));
vi.mock("./service-skills/useServiceSkills", () => ({
useServiceSkills: mockUseServiceSkills,
}));
vi.mock("./service-skills/ServiceSkillHomePanel", () => ({
ServiceSkillHomePanel: ({
skills,
onSelect,
}: {
skills: Array<{ id: string; title: string }>;
onSelect: (skill: { id: string; title: string }) => void;
}) => (
<>
{skills.map((skill) => (
<button
key={skill.id}
type="button"
data-testid={`home-shell-service-skill-${skill.id}`}
onClick={() => onSelect(skill)}
>
{skill.title}
</button>
))}
</>
),
}));
vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
ServiceSkillLaunchDialog: ({
skill,
open,
onLaunch,
onCreateAutomation,
}: {
skill: { id: string; title: string; runnerType?: string } | null;
open: boolean;
onLaunch: (
skill: { id: string; title: string; runnerType?: string },
slotValues: Record<string, string>,
) => void;
onCreateAutomation?: (
skill: { id: string; title: string; runnerType?: string },
slotValues: Record<string, string>,
) => void;
}) =>
open && skill ? (
<>
<button
type="button"
data-testid="home-shell-service-skill-launch"
onClick={() =>
onLaunch(
skill,
skill.id === "daily-trend-briefing"
? {
platform: "x",
industry_keywords: "AI Agent,创作者工具",
schedule_time: "每天 09:00",
}
: {
reference_video: "https://example.com/video",
},
)
}
>
启动服务型技能
</button>
{skill.runnerType === "scheduled" && onCreateAutomation ? (
<button
type="button"
data-testid="home-shell-service-skill-create-automation"
onClick={() =>
onCreateAutomation(skill, {
platform: "x",
industry_keywords: "AI Agent,创作者工具",
schedule_time: "每天 09:00",
})
}
>
创建自动化任务
</button>
) : null}
</>
) : null,
}));
vi.mock("@/components/settings-v2/system/automation/AutomationJobDialog", () => ({
AutomationJobDialog: ({
open,
mode,
initialValues,
onSubmit,
}: {
open: boolean;
mode: "create" | "edit";
initialValues?: Record<string, unknown> | null;
onSubmit: (payload: {
mode: "create";
request: Record<string, unknown>;
}) => Promise<void>;
}) =>
open ? (
<div data-testid="home-shell-automation-dialog">
<span data-testid="home-shell-automation-dialog-mode">{mode}</span>
<span data-testid="home-shell-automation-dialog-schedule">
{typeof initialValues?.schedule_kind === "string"
? initialValues.schedule_kind
: "-"}
</span>
<span data-testid="home-shell-automation-dialog-name">
{typeof initialValues?.name === "string" ? initialValues.name : "-"}
</span>
<button
type="button"
data-testid="home-shell-automation-submit"
onClick={() =>
void onSubmit({
mode: "create",
request: {
name:
typeof initialValues?.name === "string"
? initialValues.name
: "自动化任务",
description:
typeof initialValues?.description === "string"
? initialValues.description
: null,
workspace_id:
typeof initialValues?.workspace_id === "string"
? initialValues.workspace_id
: "project-1",
execution_mode:
typeof initialValues?.execution_mode === "string"
? initialValues.execution_mode
: "skill",
schedule:
initialValues?.schedule_kind === "cron"
? {
kind: "cron",
expr:
typeof initialValues?.cron_expr === "string"
? initialValues.cron_expr
: "0 9 * * *",
tz:
typeof initialValues?.cron_tz === "string"
? initialValues.cron_tz
: "Asia/Shanghai",
}
: {
kind: "every",
every_secs: Number(initialValues?.every_secs ?? 86400),
},
payload: {
kind: "agent_turn",
prompt:
typeof initialValues?.prompt === "string"
? initialValues.prompt
: "",
system_prompt:
typeof initialValues?.system_prompt === "string"
? initialValues.system_prompt
: null,
web_search:
typeof initialValues?.web_search === "boolean"
? initialValues.web_search
: false,
},
delivery: {
mode: "none",
channel: null,
target: null,
best_effort: true,
output_schema: "text",
output_format: "text",
},
max_retries: Number(initialValues?.max_retries ?? 2),
},
})
}
>
提交自动化
</button>
</div>
) : null,
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
@@ -257,8 +562,29 @@ beforeEach(() => {
refresh: vi.fn(),
recordUsage: mockRecordClawSolutionUsage,
}));
mockUseServiceSkills.mockImplementation(() => ({
skills: mockServiceSkills,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordServiceSkillUsage,
}));
mockLoadConfiguredProviders.mockResolvedValue([]);
mockLoadProviderModels.mockResolvedValue([]);
mockListProjects.mockResolvedValue([
{
id: "project-1",
name: "项目一",
workspaceType: "general",
rootPath: "/tmp/project-1",
isDefault: false,
createdAt: 0,
updatedAt: 0,
isFavorite: false,
isArchived: false,
tags: [],
},
]);
mockFilterModelsByTheme.mockImplementation(
(_theme: string | undefined, models: unknown[]) => ({
models,
@@ -667,6 +993,143 @@ describe("AgentChatHomeShell", () => {
});
});
it("点击服务型技能后应完成补参并进入对应工作区", async () => {
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-short-video-script-replication"]',
) as HTMLButtonElement | null;
expect(serviceSkillButton).toBeTruthy();
act(() => {
serviceSkillButton?.click();
});
await flushEffects();
const launchButton = container.querySelector(
'[data-testid="home-shell-service-skill-launch"]',
) as HTMLButtonElement | null;
expect(launchButton).toBeTruthy();
act(() => {
launchButton?.click();
});
await flushEffects();
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "video",
initialCreationMode: "guided",
initialUserPrompt: expect.stringContaining(
"[服务型技能] 复制短视频脚本",
),
}),
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
initialUserPrompt: expect.stringContaining(
"- 参考视频链接/素材: https://example.com/video",
),
}),
);
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "short-video-script-replication",
runnerType: "instant",
});
});
it("点击定时服务型技能创建任务后应先建本地 automation 再进入工作区", async () => {
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-daily-trend-briefing"]',
) as HTMLButtonElement | null;
expect(serviceSkillButton).toBeTruthy();
act(() => {
serviceSkillButton?.click();
});
await flushEffects();
const createAutomationButton = container.querySelector(
'[data-testid="home-shell-service-skill-create-automation"]',
) as HTMLButtonElement | null;
expect(createAutomationButton).toBeTruthy();
act(() => {
createAutomationButton?.click();
});
await flushEffects();
const automationDialog = container.querySelector(
'[data-testid="home-shell-automation-dialog"]',
) as HTMLDivElement | null;
const automationSubmitButton = container.querySelector(
'[data-testid="home-shell-automation-submit"]',
) as HTMLButtonElement | null;
expect(automationDialog).toBeTruthy();
expect(container.textContent).toContain("create");
expect(container.textContent).toContain("cron");
expect(container.textContent).toContain("每日趋势摘要");
expect(automationSubmitButton).toBeTruthy();
act(() => {
automationSubmitButton?.click();
});
await flushEffects();
expect(mockCreateAutomationJob).toHaveBeenCalledWith(
expect.objectContaining({
workspace_id: "project-1",
execution_mode: "skill",
schedule: {
kind: "cron",
expr: "00 09 * * *",
tz: expect.any(String),
},
payload: expect.objectContaining({
kind: "agent_turn",
prompt: expect.stringContaining("[服务型技能] 每日趋势摘要"),
}),
}),
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "social-media",
initialCreationMode: "guided",
initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"),
}),
);
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "daily-trend-briefing",
runnerType: "scheduled",
});
});
it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
@@ -1,7 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import { createAutomationJob } from "@/lib/api/automation";
import { prepareClawSolution } from "@/lib/api/clawSolutions";
import { listProjects, type Project } from "@/lib/api/project";
import {
AutomationJobDialog,
type AutomationJobDialogInitialValues,
type AutomationJobDialogSubmit,
} from "@/components/settings-v2/system/automation/AutomationJobDialog";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import { EmptyState } from "./components/EmptyState";
@@ -35,6 +42,18 @@ import {
resolveHomeShellWorkspaceEntry,
type HomeShellEnterWorkspacePayload,
} from "./homeShellEntry";
import { useServiceSkills } from "./service-skills/useServiceSkills";
import { ServiceSkillHomePanel } from "./service-skills/ServiceSkillHomePanel";
import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog";
import { composeServiceSkillPrompt } from "./service-skills/promptComposer";
import {
buildServiceSkillAutomationInitialValues,
supportsServiceSkillLocalAutomation,
} from "./service-skills/automationDraft";
import type {
ServiceSkillHomeItem,
ServiceSkillSlotValues,
} from "./service-skills/types";
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
@@ -142,6 +161,67 @@ function getErrorMessage(error: unknown): string {
return "请稍后重试";
}
function resolveFallbackProjectType(theme?: string): Project["workspaceType"] {
switch (theme) {
case "social-media":
case "poster":
case "music":
case "knowledge":
case "planning":
case "document":
case "video":
case "novel":
case "general":
return theme;
default:
return "general";
}
}
function buildFallbackAutomationWorkspace(
projectId: string,
theme?: string,
): Project {
return {
id: projectId,
name: projectId,
workspaceType: resolveFallbackProjectType(theme),
rootPath: "",
isDefault: false,
createdAt: 0,
updatedAt: 0,
isFavorite: false,
isArchived: false,
tags: [],
};
}
function prioritizeAutomationWorkspaces(
workspaces: Project[],
projectId?: string | null,
theme?: string,
): Project[] {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
return workspaces;
}
const matched = workspaces.find((workspace) => workspace.id === normalizedProjectId);
const fallbackWorkspace =
matched ?? buildFallbackAutomationWorkspace(normalizedProjectId, theme);
const remaining = workspaces.filter((workspace) => workspace.id !== normalizedProjectId);
return [fallbackWorkspace, ...remaining];
}
interface PendingServiceSkillAutomationLaunch {
enterWorkspacePayload: HomeShellEnterWorkspacePayload;
usage: {
skillId: string;
runnerType: ServiceSkillHomeItem["runnerType"];
};
}
export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry";
interface AgentChatHomeShellProps {
@@ -196,6 +276,22 @@ export function AgentChatHomeShell({
error: clawSolutionsError,
recordUsage: recordClawSolutionUsage,
} = useClawSolutions(activeTheme === "general");
const {
skills: serviceSkills,
isLoading: serviceSkillsLoading,
error: serviceSkillsError,
recordUsage: recordServiceSkillUsage,
} = useServiceSkills(activeTheme === "general");
const [selectedServiceSkill, setSelectedServiceSkill] =
useState<ServiceSkillHomeItem | null>(null);
const [serviceSkillDialogOpen, setServiceSkillDialogOpen] = useState(false);
const [automationDialogOpen, setAutomationDialogOpen] = useState(false);
const [automationDialogInitialValues, setAutomationDialogInitialValues] =
useState<AutomationJobDialogInitialValues | null>(null);
const [automationWorkspaces, setAutomationWorkspaces] = useState<Project[]>([]);
const [automationJobSaving, setAutomationJobSaving] = useState(false);
const [pendingServiceSkillAutomation, setPendingServiceSkillAutomation] =
useState<PendingServiceSkillAutomationLaunch | null>(null);
useEffect(() => {
setActiveTheme(normalizeInitialTheme(initialTheme));
@@ -216,6 +312,14 @@ export function AgentChatHomeShell({
toast.error(`加载 Claw 方案失败:${clawSolutionsError}`);
}, [activeTheme, clawSolutionsError]);
useEffect(() => {
if (activeTheme !== "general" || !serviceSkillsError) {
return;
}
toast.error(`加载服务型技能失败:${serviceSkillsError}`);
}, [activeTheme, serviceSkillsError]);
const handleRefreshSkills = useCallback(async () => {
await refreshSkills(true);
}, [refreshSkills]);
@@ -366,6 +470,156 @@ export function AgentChatHomeShell({
],
);
const handleServiceSkillSelect = useCallback((skill: ServiceSkillHomeItem) => {
setSelectedServiceSkill(skill);
setServiceSkillDialogOpen(true);
}, []);
const handleServiceSkillLaunch = useCallback(
async (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
) => {
const prompt = composeServiceSkillPrompt({
skill,
slotValues,
userInput: input.trim() || undefined,
});
if (skill.runnerType !== "instant") {
toast.info("当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。");
}
const entered = handleEnterWorkspace({
prompt,
themeOverride: skill.themeTarget,
});
if (!entered) {
return;
}
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
},
[handleEnterWorkspace, input, recordServiceSkillUsage],
);
const handleServiceSkillAutomationSetup = useCallback(
async (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
) => {
if (!supportsServiceSkillLocalAutomation(skill)) {
await handleServiceSkillLaunch(skill, slotValues);
return;
}
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (!normalizedProjectId) {
toast.error("缺少项目工作区,请先选择项目后再创建本地自动化任务。");
return;
}
const prompt = composeServiceSkillPrompt({
skill,
slotValues,
userInput: input.trim() || undefined,
});
try {
let workspaces: Project[];
try {
workspaces = prioritizeAutomationWorkspaces(
await listProjects(),
normalizedProjectId,
skill.themeTarget ?? activeTheme,
);
} catch {
workspaces = [
buildFallbackAutomationWorkspace(
normalizedProjectId,
skill.themeTarget ?? activeTheme,
),
];
}
setAutomationWorkspaces(workspaces);
setAutomationDialogInitialValues(
buildServiceSkillAutomationInitialValues({
skill,
slotValues,
userInput: input.trim() || undefined,
workspaceId: normalizedProjectId,
}),
);
setPendingServiceSkillAutomation({
enterWorkspacePayload: {
prompt,
themeOverride: skill.themeTarget,
},
usage: {
skillId: skill.id,
runnerType: skill.runnerType,
},
});
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
setAutomationDialogOpen(true);
} catch (error) {
toast.error(`准备本地自动化任务失败:${getErrorMessage(error)}`);
}
},
[activeTheme, currentProjectId, handleServiceSkillLaunch, input],
);
const handleAutomationDialogOpenChange = useCallback((open: boolean) => {
setAutomationDialogOpen(open);
if (!open) {
setAutomationDialogInitialValues(null);
setPendingServiceSkillAutomation(null);
}
}, []);
const handleAutomationDialogSubmit = useCallback(
async (payload: AutomationJobDialogSubmit) => {
if (payload.mode !== "create") {
throw new Error("服务型技能入口当前只支持创建新的本地自动化任务");
}
setAutomationJobSaving(true);
try {
const createdJob = await createAutomationJob(payload.request);
toast.success(`本地自动化任务已创建:${createdJob.name}`);
const pendingLaunch = pendingServiceSkillAutomation;
setAutomationDialogOpen(false);
setAutomationDialogInitialValues(null);
setPendingServiceSkillAutomation(null);
if (!pendingLaunch) {
return;
}
recordServiceSkillUsage(pendingLaunch.usage);
const entered = handleEnterWorkspace(pendingLaunch.enterWorkspacePayload);
if (!entered) {
toast.error("自动化任务已创建,但进入工作区失败,请稍后手动打开。");
}
} catch (error) {
toast.error(`创建本地自动化任务失败:${getErrorMessage(error)}`);
throw error;
} finally {
setAutomationJobSaving(false);
}
},
[handleEnterWorkspace, pendingServiceSkillAutomation, recordServiceSkillUsage],
);
const handleRecommendationClick = useCallback(
(shortLabel: string, fullPrompt: string) => {
setInput(fullPrompt);
@@ -473,11 +727,18 @@ export function AgentChatHomeShell({
onRecommendationClick={handleRecommendationClick}
supportingSlotOverride={
activeTheme === "general" ? (
<ClawHomeSolutionsPanel
solutions={clawSolutions}
loading={clawSolutionsLoading}
onSelect={handleClawSolutionSelect}
/>
<>
<ServiceSkillHomePanel
skills={serviceSkills}
loading={serviceSkillsLoading}
onSelect={handleServiceSkillSelect}
/>
<ClawHomeSolutionsPanel
solutions={clawSolutions}
loading={clawSolutionsLoading}
onSelect={handleClawSolutionSelect}
/>
</>
) : undefined
}
characters={projectMemory?.characters || []}
@@ -511,6 +772,27 @@ export function AgentChatHomeShell({
});
}}
/>
<ServiceSkillLaunchDialog
skill={selectedServiceSkill}
open={serviceSkillDialogOpen}
onOpenChange={(open) => {
setServiceSkillDialogOpen(open);
if (!open) {
setSelectedServiceSkill(null);
}
}}
onLaunch={handleServiceSkillLaunch}
onCreateAutomation={handleServiceSkillAutomationSetup}
/>
<AutomationJobDialog
open={automationDialogOpen}
mode="create"
workspaces={automationWorkspaces}
initialValues={automationDialogInitialValues}
saving={automationJobSaving}
onOpenChange={handleAutomationDialogOpenChange}
onSubmit={handleAutomationDialogSubmit}
/>
</ChatContainerInner>
</ChatContainer>
</ThemeWorkbenchLayoutShell>
@@ -2015,7 +2015,9 @@ export function AgentChatWorkspace({
taskFilesRef,
socialStageLogRef,
setDocumentVersionStatusMap,
saveSessionFile,
saveSessionFile: async (fileName, content) => {
await saveSessionFile(fileName, content);
},
syncGeneralArtifactToResource,
upsertGeneralArtifact,
setSelectedArtifactId,
@@ -2222,12 +2224,14 @@ export function AgentChatWorkspace({
threadRead,
pendingActions,
submittedActionsInFlight,
messages: displayMessages,
queuedTurns,
resumeThread,
replayPendingAction,
promoteQueuedTurn,
removeQueuedTurn,
latestAssistantMessageId,
sessionIdForDiagnostics: sessionId || null,
themeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
handleContinueThemeWorkbenchEntryPrompt,
@@ -2419,7 +2423,7 @@ export function AgentChatWorkspace({
selectedFileId,
handleHarnessLoadFilePreview,
setCanvasWorkbenchLayoutMode,
workspacePathMissing,
workspacePathMissing: Boolean(workspacePathMissing),
workspaceHealthError,
});
@@ -59,6 +59,7 @@ describe("AgentIncidentPanel", () => {
const container = renderPanel([
{
id: "incident-1",
incidentType: "approval_timeout",
title: "审批等待超过阈值",
detail: "当前线程等待工具确认时间过长",
statusLabel: "进行中",
@@ -67,6 +68,7 @@ describe("AgentIncidentPanel", () => {
},
{
id: "incident-2",
incidentType: "waiting_user_input",
title: "线程正在等待人工处理",
detail: "等待你确认是否继续发布",
statusLabel: "进行中",
@@ -4,8 +4,14 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types";
import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
Message,
} from "../types";
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
const { mockToast } = vi.hoisted(() => ({
mockToast: {
@@ -67,10 +73,21 @@ function renderPanel(props?: {
currentTurnId?: string | null;
canInterrupt?: boolean;
onInterruptCurrentTurn?: () => void | Promise<void>;
onResumeThread?: () => void | Promise<boolean>;
onReplayPendingRequest?: (requestId: string) => void | Promise<boolean>;
onResumeThread?: () => boolean | Promise<boolean>;
onReplayPendingRequest?: (requestId: string) => boolean | Promise<boolean>;
onLocatePendingRequest?: (requestId: string) => void;
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
harnessState?: HarnessSessionState | null;
messages?: Message[];
diagnosticRuntimeContext?: {
sessionId?: string | null;
workspaceId?: string | null;
providerType?: string | null;
model?: string | null;
executionStrategy?: string | null;
activeTheme?: string | null;
selectedTeamLabel?: string | null;
} | null;
}) {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -91,6 +108,9 @@ function renderPanel(props?: {
onReplayPendingRequest={props?.onReplayPendingRequest}
onLocatePendingRequest={props?.onLocatePendingRequest}
onPromoteQueuedTurn={props?.onPromoteQueuedTurn}
harnessState={props?.harnessState}
messages={props?.messages}
diagnosticRuntimeContext={props?.diagnosticRuntimeContext}
/>,
);
});
@@ -484,6 +504,51 @@ describe("AgentThreadReliabilityPanel", () => {
thread_id: "thread-1",
status: "aborted",
active_turn_id: "turn-1",
diagnostics: {
latest_turn_status: "aborted",
latest_turn_started_at: "2026-03-23T10:00:00Z",
latest_turn_completed_at: "2026-03-23T10:02:00Z",
latest_turn_updated_at: "2026-03-23T10:03:00Z",
latest_turn_elapsed_seconds: 120,
latest_turn_error_message: "浏览器页面已关闭",
interrupt_reason: "浏览器页面已关闭",
runtime_interrupt_source: "user",
runtime_interrupt_requested_at: "2026-03-23T10:01:58Z",
runtime_interrupt_wait_seconds: 2,
warning_count: 1,
context_compaction_count: 1,
failed_tool_call_count: 1,
failed_command_count: 1,
pending_request_count: 0,
primary_blocking_kind: "tool_failed",
primary_blocking_summary: "页面上下文已销毁",
latest_warning: {
item_id: "warning-1",
code: "context_compaction_accuracy",
message: "长对话和多次上下文压缩会降低模型准确性",
updated_at: "2026-03-23T10:01:30Z",
},
latest_context_compaction: {
item_id: "compaction-1",
stage: "runtime",
trigger: "token_budget",
detail: "保留研究目标与最近来源摘要",
updated_at: "2026-03-23T10:01:00Z",
},
latest_failed_tool: {
item_id: "tool-1",
tool_name: "browser_click",
error: "页面上下文已销毁",
updated_at: "2026-03-23T10:02:00Z",
},
latest_failed_command: {
item_id: "cmd-1",
command: "npm run build",
exit_code: 1,
error: "Command failed with exit code 1",
updated_at: "2026-03-23T10:01:50Z",
},
},
pending_requests: [],
last_outcome: {
thread_id: "thread-1",
@@ -520,6 +585,80 @@ describe("AgentThreadReliabilityPanel", () => {
},
],
currentTurnId: "turn-1",
harnessState: {
runtimeStatus: {
phase: "context",
title: "正在整理研究上下文",
detail: "最近一次压缩后继续生成研究简报。",
checkpoints: ["已整理来源", "正在回填摘要"],
},
pendingApprovals: [],
latestContextTrace: [
{
stage: "context_compaction",
detail: "保留研究目标与最近来源摘要",
},
],
plan: {
phase: "ready",
items: [
{ id: "todo-1", content: "归纳研究目标", status: "completed" },
{ id: "todo-2", content: "输出风险点", status: "in_progress" },
],
summaryText: "先回填研究简报,再补风险追踪建议。",
},
activity: {
planning: 1,
filesystem: 0,
execution: 0,
web: 2,
skills: 0,
delegation: 0,
},
delegatedTasks: [],
outputSignals: [
{
id: "signal-1",
toolCallId: "tool-1",
toolName: "web_search",
title: "联网检索摘要",
summary: "已检索 3 个来源",
preview: "来源覆盖官网、新闻和公告",
},
],
activeFileWrites: [],
recentFileEvents: [],
hasSignals: true,
},
messages: [
{
id: "msg-user-1",
role: "user",
content: "请围绕这个主题先给我做一版网页研究简报",
timestamp: new Date("2026-03-23T09:59:00Z"),
},
{
id: "msg-assistant-1",
role: "assistant",
content: "我先整理研究目标、来源、核心发现和风险点。",
timestamp: new Date("2026-03-23T10:00:00Z"),
runtimeStatus: {
phase: "context",
title: "正在整理研究上下文",
detail: "压缩后继续生成简报",
checkpoints: ["研究目标", "来源摘要"],
},
},
],
diagnosticRuntimeContext: {
sessionId: "session-diag-1",
workspaceId: "workspace-diag-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "general",
selectedTeamLabel: "研究协作队",
},
});
const copyButton = container.querySelector(
@@ -538,6 +677,30 @@ describe("AgentThreadReliabilityPanel", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("请按以下结构输出:"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("### 运行环境"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("gpt-5.4"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("### Harness 过程信号"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("### 后端诊断聚合"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("主阻塞类型:tool_failed"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("中断来源:user"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("最近失败命令:npm run build"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("### 最近消息片段"),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("### Incident"),
);
@@ -556,6 +719,15 @@ describe("AgentThreadReliabilityPanel", () => {
thread_id: "thread-json-1",
status: "waiting_request",
active_turn_id: "turn-json-1",
diagnostics: {
latest_turn_status: "running",
warning_count: 0,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
primary_blocking_kind: "waiting_user_input",
},
pending_requests: [
{
id: "req-json-1",
@@ -574,7 +746,7 @@ describe("AgentThreadReliabilityPanel", () => {
id: "turn-json-1",
thread_id: "thread-json-1",
prompt_text: "继续执行 JSON 校验任务",
status: "waiting_request",
status: "running",
started_at: "2026-03-23T10:00:00Z",
created_at: "2026-03-23T10:00:00Z",
updated_at: "2026-03-23T10:01:00Z",
@@ -589,6 +761,45 @@ describe("AgentThreadReliabilityPanel", () => {
status: "pending",
},
],
harnessState: {
runtimeStatus: null,
pendingApprovals: [],
latestContextTrace: [],
plan: {
phase: "idle",
items: [],
},
activity: {
planning: 0,
filesystem: 0,
execution: 0,
web: 0,
skills: 0,
delegation: 0,
},
delegatedTasks: [],
outputSignals: [],
activeFileWrites: [],
recentFileEvents: [],
hasSignals: true,
},
messages: [
{
id: "msg-json-1",
role: "assistant",
content: "正在等待你确认是否继续执行",
timestamp: new Date("2026-03-23T10:00:30Z"),
},
],
diagnosticRuntimeContext: {
sessionId: "session-json-1",
workspaceId: "workspace-json-1",
providerType: "openai",
model: "gpt-5.4-mini",
executionStrategy: "react",
activeTheme: "general",
selectedTeamLabel: "默认协作",
},
});
const jsonButton = container.querySelector(
@@ -601,6 +812,18 @@ describe("AgentThreadReliabilityPanel", () => {
await Promise.resolve();
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('"runtime_context"'),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('"backend_diagnostics"'),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('"harness_state"'),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('"recent_messages"'),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('"thread_read"'),
);
@@ -18,7 +18,13 @@ import type {
AgentRuntimeThreadReadModel,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types";
import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
Message,
} from "../types";
import type { HarnessSessionState } from "../utils/harnessState";
import {
buildThreadReliabilityView,
type ThreadReliabilityTone,
@@ -26,6 +32,16 @@ import {
import { AgentIncidentPanel } from "./AgentIncidentPanel";
import { AgentThreadOutcomeSummary } from "./AgentThreadOutcomeSummary";
interface AgentThreadReliabilityDiagnosticContext {
sessionId?: string | null;
workspaceId?: string | null;
providerType?: string | null;
model?: string | null;
executionStrategy?: string | null;
activeTheme?: string | null;
selectedTeamLabel?: string | null;
}
interface AgentThreadReliabilityPanelProps {
threadRead?: AgentRuntimeThreadReadModel | null;
turns?: AgentThreadTurn[];
@@ -43,6 +59,9 @@ interface AgentThreadReliabilityPanelProps {
queuedTurnId: string,
) => boolean | Promise<boolean>;
className?: string;
harnessState?: HarnessSessionState | null;
messages?: Message[];
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
}
function serializeClipboardPayload(value: unknown): string {
@@ -53,6 +72,168 @@ function serializeClipboardPayload(value: unknown): string {
);
}
function normalizeDiagnosticText(value?: string | null): string {
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
}
function truncateDiagnosticText(value?: string | null, maxLength = 240): string {
const normalized = normalizeDiagnosticText(value);
if (!normalized) {
return "";
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function summarizeThreadItemSignals(threadItems: AgentThreadItem[]) {
const warningItems = threadItems.filter((item) => item.type === "warning");
const contextCompactionItems = threadItems.filter(
(item) => item.type === "context_compaction",
);
const failedToolCalls = threadItems.filter(
(item): item is Extract<AgentThreadItem, { type: "tool_call" }> =>
item.type === "tool_call" &&
(item.status === "failed" || item.success === false),
);
return {
warningCount: warningItems.length,
contextCompactionCount: contextCompactionItems.length,
failedToolCallCount: failedToolCalls.length,
latestWarnings: warningItems.slice(-3).map((item) => ({
id: item.id,
code: item.code || null,
message: truncateDiagnosticText(item.message, 180),
status: item.status,
updated_at: item.updated_at,
})),
latestCompactions: contextCompactionItems.slice(-3).map((item) => ({
id: item.id,
stage: item.stage,
trigger: item.trigger || null,
detail: truncateDiagnosticText(item.detail, 180),
status: item.status,
updated_at: item.updated_at,
})),
latestFailedTools: failedToolCalls.slice(-3).map((item) => ({
id: item.id,
tool_name: item.tool_name,
error: truncateDiagnosticText(item.error, 180),
updated_at: item.updated_at,
})),
};
}
function summarizeRecentMessages(messages: Message[]) {
return messages.slice(-6).map((message) => ({
id: message.id,
role: message.role,
timestamp:
message.timestamp instanceof Date
? message.timestamp.toISOString()
: String(message.timestamp),
content_preview: truncateDiagnosticText(message.content, 320),
runtime_status: message.runtimeStatus
? {
phase: message.runtimeStatus.phase,
title: message.runtimeStatus.title,
detail: truncateDiagnosticText(message.runtimeStatus.detail, 180),
checkpoints: message.runtimeStatus.checkpoints?.slice(0, 4) || [],
}
: null,
action_request_count: message.actionRequests?.length || 0,
action_request_titles:
message.actionRequests?.slice(0, 3).map((request) =>
truncateDiagnosticText(request.prompt || request.toolName || request.requestId, 120),
) || [],
tool_calls:
message.toolCalls?.slice(0, 4).map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
status: toolCall.status,
error: truncateDiagnosticText(toolCall.result?.error, 120),
})) || [],
context_trace:
message.contextTrace?.slice(-3).map((step) => ({
stage: step.stage,
detail: truncateDiagnosticText(step.detail, 120),
})) || [],
artifact_titles:
message.artifacts?.slice(0, 4).map((artifact) => artifact.title) || [],
}));
}
function summarizeHarnessState(harnessState?: HarnessSessionState | null) {
if (!harnessState) {
return null;
}
return {
runtime_status: harnessState.runtimeStatus
? {
phase: harnessState.runtimeStatus.phase,
title: harnessState.runtimeStatus.title,
detail: truncateDiagnosticText(harnessState.runtimeStatus.detail, 220),
checkpoints: harnessState.runtimeStatus.checkpoints?.slice(0, 6) || [],
metadata: harnessState.runtimeStatus.metadata || null,
}
: null,
plan: {
phase: harnessState.plan.phase,
summary_text: truncateDiagnosticText(harnessState.plan.summaryText, 220),
items: harnessState.plan.items.slice(0, 6),
},
activity: harnessState.activity,
pending_approvals_count: harnessState.pendingApprovals.length,
latest_context_trace:
harnessState.latestContextTrace.slice(-5).map((step) => ({
stage: step.stage,
detail: truncateDiagnosticText(step.detail, 160),
})) || [],
delegated_tasks: harnessState.delegatedTasks.slice(0, 6).map((task) => ({
id: task.id,
title: task.title,
status: task.status,
task_type: task.taskType || null,
role: task.role || null,
model: task.model || null,
summary: truncateDiagnosticText(task.summary, 160),
})),
output_signals: harnessState.outputSignals.slice(0, 8).map((signal) => ({
id: signal.id,
tool_name: signal.toolName,
title: signal.title,
summary: truncateDiagnosticText(signal.summary, 180),
preview: truncateDiagnosticText(signal.preview, 180),
output_file: signal.outputFile || null,
offload_file: signal.offloadFile || null,
artifact_path: signal.artifactPath || null,
exit_code: signal.exitCode,
truncated: signal.truncated || false,
offloaded: signal.offloaded || false,
})),
active_file_writes: harnessState.activeFileWrites.slice(0, 6).map((write) => ({
id: write.id,
path: write.path,
display_name: write.displayName,
phase: write.phase,
status: write.status,
preview: truncateDiagnosticText(write.preview || write.latestChunk, 160),
})),
recent_file_events: harnessState.recentFileEvents.slice(0, 8).map((event) => ({
id: event.id,
path: event.path,
display_name: event.displayName,
kind: event.kind,
action: event.action,
source_tool_name: event.sourceToolName,
preview: truncateDiagnosticText(event.preview, 140),
})),
};
}
function resolveToneClassName(tone: ThreadReliabilityTone) {
switch (tone) {
case "running":
@@ -88,16 +269,34 @@ function resolveStatShellClassName(tone: ThreadReliabilityTone) {
}
function buildReliabilityDiagnosticText(params: {
threadRead?: AgentRuntimeThreadReadModel | null;
statusLabel: string;
summary: string;
view: ReturnType<typeof buildThreadReliabilityView>;
threadItems: AgentThreadItem[];
messages: Message[];
harnessState?: HarnessSessionState | null;
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
}): string {
const { statusLabel, summary, view } = params;
const {
threadRead,
statusLabel,
summary,
view,
threadItems,
messages,
harnessState,
diagnosticRuntimeContext,
} = params;
const threadItemSignals = summarizeThreadItemSignals(threadItems);
const recentMessages = summarizeRecentMessages(messages);
const sections: string[] = [
"# Lime 线程可靠性诊断任务",
"",
"你现在是一名 AI 任务可靠性分析助手。请基于下面的线程可靠性数据,判断这次任务执行得好不好;如果执行不好,请找出根因,并给出可落地的修复建议。",
"",
"如果“后端诊断聚合”与前端界面信号存在冲突,请优先以后端诊断聚合作为高可信事实源,再结合其他上下文判断。",
"",
"请重点回答以下问题:",
"1. 这次任务整体表现属于:好 / 一般 / 差?请先给结论。",
"2. 直接导致中断、失败、等待或漂移的主要原因是什么?",
@@ -117,6 +316,15 @@ function buildReliabilityDiagnosticText(params: {
"",
"## 诊断数据",
"",
"### 运行环境",
`- 会话 ID:${diagnosticRuntimeContext?.sessionId || "未知"}`,
`- 工作区 ID:${diagnosticRuntimeContext?.workspaceId || "未知"}`,
`- Provider:${diagnosticRuntimeContext?.providerType || "未知"}`,
`- 模型:${diagnosticRuntimeContext?.model || "未知"}`,
`- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`,
`- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`,
`- 协作方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`,
"",
"### 当前状态",
`- 状态:${statusLabel}`,
`- 当前回合:${view.activeTurnLabel || "未知"}`,
@@ -129,6 +337,21 @@ function buildReliabilityDiagnosticText(params: {
`- 活跃 Incident:${view.activeIncidentCount}`,
`- 排队回合:${view.queuedTurnCount}`,
"",
"### 线程项信号",
`- warning 数量:${threadItemSignals.warningCount}`,
`- context compaction 数量:${threadItemSignals.contextCompactionCount}`,
`- 失败工具调用数量:${threadItemSignals.failedToolCallCount}`,
"",
"### Harness 过程信号",
`- runtimeStatus:${harnessState?.runtimeStatus?.title || "无"}`,
`- plan phase:${harnessState?.plan.phase || "无"}`,
`- plan items:${harnessState?.plan.items.length || 0}`,
`- output signals:${harnessState?.outputSignals.length || 0}`,
`- active file writes:${harnessState?.activeFileWrites.length || 0}`,
`- recent file events:${harnessState?.recentFileEvents.length || 0}`,
`- delegated tasks:${harnessState?.delegatedTasks.length || 0}`,
`- context trace steps:${harnessState?.latestContextTrace.length || 0}`,
"",
"### 待处理请求",
];
@@ -193,6 +416,122 @@ function buildReliabilityDiagnosticText(params: {
sections.push("- 暂无额外建议");
}
sections.push("", "### 最近 warning");
if (threadItemSignals.latestWarnings.length > 0) {
for (const warning of threadItemSignals.latestWarnings) {
sections.push(
`- ${warning.code || "warning"}|${warning.message || "无消息"}|${warning.status}`,
);
}
} else {
sections.push("- 无");
}
sections.push("", "### 最近 context compaction");
if (threadItemSignals.latestCompactions.length > 0) {
for (const compaction of threadItemSignals.latestCompactions) {
sections.push(
`- ${compaction.stage}|${compaction.trigger || "未知触发"}|${compaction.detail || "无详情"}`,
);
}
} else {
sections.push("- 无");
}
sections.push("", "### 后端诊断聚合");
if (threadRead?.diagnostics) {
const diagnostics = threadRead.diagnostics;
sections.push(`- 最新回合状态:${diagnostics.latest_turn_status || "未知"}`);
sections.push(
`- 最新回合开始时间:${diagnostics.latest_turn_started_at || "未知"}`,
);
sections.push(
`- 最新回合结束时间:${diagnostics.latest_turn_completed_at || "未知"}`,
);
sections.push(
`- 最新回合更新时间:${diagnostics.latest_turn_updated_at || "未知"}`,
);
sections.push(
`- 最新回合累计耗时(秒):${diagnostics.latest_turn_elapsed_seconds ?? "未知"}`,
);
sections.push(
`- 最新回合停滞时长(秒):${diagnostics.latest_turn_stalled_seconds ?? "无"}`,
);
sections.push(
`- 最新回合错误:${diagnostics.latest_turn_error_message || "无"}`,
);
sections.push(`- 中断原因:${diagnostics.interrupt_reason || "未知"}`);
sections.push(`- 中断来源:${diagnostics.runtime_interrupt_source || "未知"}`);
sections.push(
`- 中断请求时间:${diagnostics.runtime_interrupt_requested_at || "未知"}`,
);
sections.push(
`- 中断请求后等待时长(秒):${diagnostics.runtime_interrupt_wait_seconds ?? "无"}`,
);
sections.push(`- 后端 warning 数量:${diagnostics.warning_count}`);
sections.push(
`- 后端 context compaction 数量:${diagnostics.context_compaction_count}`,
);
sections.push(
`- 后端失败工具调用数量:${diagnostics.failed_tool_call_count}`,
);
sections.push(`- 后端失败命令数量:${diagnostics.failed_command_count}`);
sections.push(`- 后端待处理请求数量:${diagnostics.pending_request_count}`);
sections.push(
`- 最老待处理请求等待时长(秒):${diagnostics.oldest_pending_request_wait_seconds ?? "无"}`,
);
sections.push(`- 主阻塞类型:${diagnostics.primary_blocking_kind || "未知"}`);
sections.push(`- 主阻塞摘要:${diagnostics.primary_blocking_summary || "未知"}`);
sections.push(
`- 最近 warning:${
diagnostics.latest_warning
? `${diagnostics.latest_warning.code || "warning"}|${diagnostics.latest_warning.message}`
: "无"
}`,
);
sections.push(
`- 最近 context compaction:${
diagnostics.latest_context_compaction
? `${diagnostics.latest_context_compaction.stage}|${diagnostics.latest_context_compaction.trigger || "未知触发"}|${diagnostics.latest_context_compaction.detail || "无详情"}`
: "无"
}`,
);
sections.push(
`- 最近失败工具:${
diagnostics.latest_failed_tool
? `${diagnostics.latest_failed_tool.tool_name}|${diagnostics.latest_failed_tool.error || "无错误详情"}`
: "无"
}`,
);
sections.push(
`- 最近失败命令:${
diagnostics.latest_failed_command
? `${diagnostics.latest_failed_command.command}|exit=${diagnostics.latest_failed_command.exit_code ?? "未知"}|${diagnostics.latest_failed_command.error || "无错误详情"}`
: "无"
}`,
);
sections.push(
`- 最近待处理请求:${
diagnostics.latest_pending_request
? `${diagnostics.latest_pending_request.request_type}|${diagnostics.latest_pending_request.title || diagnostics.latest_pending_request.request_id}|等待 ${diagnostics.latest_pending_request.waited_seconds ?? "未知"} 秒`
: "无"
}`,
);
} else {
sections.push("- 无");
}
sections.push("", "### 最近消息片段");
if (recentMessages.length > 0) {
for (const message of recentMessages) {
sections.push(
`- ${message.role}|${message.timestamp}|${message.content_preview || "<空>"}`,
);
}
} else {
sections.push("- 无");
}
return sections.join("\n");
}
@@ -205,9 +544,14 @@ function buildReliabilityRawPayload(params: {
submittedActionsInFlight: ActionRequired[];
queuedTurns: QueuedTurnSnapshot[];
view: ReturnType<typeof buildThreadReliabilityView>;
harnessState?: HarnessSessionState | null;
messages: Message[];
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
}): Record<string, unknown> {
return {
exported_at: new Date().toISOString(),
runtime_context: params.diagnosticRuntimeContext || null,
backend_diagnostics: params.threadRead?.diagnostics || null,
current_turn_id: params.currentTurnId || null,
thread_read: params.threadRead || null,
turns: params.turns,
@@ -215,6 +559,9 @@ function buildReliabilityRawPayload(params: {
pending_actions: params.pendingActions,
submitted_actions_in_flight: params.submittedActionsInFlight,
queued_turns: params.queuedTurns,
harness_state: summarizeHarnessState(params.harnessState),
recent_messages: summarizeRecentMessages(params.messages),
thread_item_signals: summarizeThreadItemSignals(params.threadItems),
reliability_view: params.view,
};
}
@@ -236,6 +583,9 @@ export const AgentThreadReliabilityPanel: React.FC<
onLocatePendingRequest,
onPromoteQueuedTurn,
className,
harnessState = null,
messages = [],
diagnosticRuntimeContext = null,
}) => {
const [isInterrupting, setIsInterrupting] = useState(false);
const [isResumingThread, setIsResumingThread] = useState(false);
@@ -343,9 +693,14 @@ export const AgentThreadReliabilityPanel: React.FC<
try {
await navigator.clipboard.writeText(
buildReliabilityDiagnosticText({
threadRead,
statusLabel,
summary,
view,
threadItems,
messages,
harnessState,
diagnosticRuntimeContext,
}),
);
toast.success("AI 诊断内容已复制");
@@ -374,6 +729,9 @@ export const AgentThreadReliabilityPanel: React.FC<
submittedActionsInFlight,
queuedTurns,
view,
harnessState,
messages,
diagnosticRuntimeContext,
}),
),
);
@@ -447,7 +805,7 @@ export const AgentThreadReliabilityPanel: React.FC<
</div>
</div>
<div className="mt-2 text-[11px] leading-5 text-muted-foreground">
“复制给 AI” 会附带诊断任务说明;“复制原始 JSON” 适合程序化分析、存档或二次处理。
“复制给 AI” 会附带诊断任务说明、运行环境、过程信号与最近消息;“复制原始 JSON” 适合程序化分析、存档或二次处理。
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
@@ -175,7 +175,7 @@ function buildCompactReliabilityBadges(
if (nextTone === "failed") {
return nextTone;
}
if (nextTone === "waiting" && currentTone !== "failed") {
if (nextTone === "waiting") {
return nextTone;
}
return currentTone;
@@ -63,6 +63,7 @@ import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
Message,
} from "../types";
import type {
HarnessFileAction,
@@ -144,6 +145,16 @@ interface HarnessStatusPanelProps {
onPromoteQueuedTurn?: (
queuedTurnId: string,
) => boolean | Promise<boolean>;
messages?: Message[];
diagnosticRuntimeContext?: {
sessionId?: string | null;
workspaceId?: string | null;
providerType?: string | null;
model?: string | null;
executionStrategy?: string | null;
activeTheme?: string | null;
selectedTeamLabel?: string | null;
} | null;
}
interface PreviewDialogState {
@@ -1409,6 +1420,8 @@ export function HarnessStatusPanel({
onResumeThread,
onReplayPendingRequest,
onPromoteQueuedTurn,
messages = [],
diagnosticRuntimeContext = null,
}: HarnessStatusPanelProps) {
const [expanded, setExpanded] = useState(true);
const isDialogLayout = layout === "dialog";
@@ -2327,6 +2340,9 @@ export function HarnessStatusPanel({
onResumeThread={onResumeThread}
onReplayPendingRequest={onReplayPendingRequest}
onPromoteQueuedTurn={onPromoteQueuedTurn}
harnessState={harnessState}
messages={messages}
diagnosticRuntimeContext={diagnosticRuntimeContext}
/>
</Section>
) : null}
@@ -0,0 +1,94 @@
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 { ServiceSkillHomePanel } from "./ServiceSkillHomePanel";
import type { ServiceSkillHomeItem } from "./types";
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: React.ComponentProps<typeof ServiceSkillHomePanel>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ServiceSkillHomePanel {...props} />);
});
mountedRoots.push({ root, container });
return container;
}
describe("ServiceSkillHomePanel", () => {
it("应渲染服务型技能信息并透传选择回调", () => {
const skills: ServiceSkillHomeItem[] = [
{
id: "short-video-script-replication",
title: "复制短视频脚本",
summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。",
category: "视频创作",
outputHint: "脚本大纲 + 镜头节奏",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
executionLocation: "client_default",
slotSchema: [],
version: "seed-v1",
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
},
];
const onSelect = vi.fn();
const container = renderPanel({
skills,
onSelect,
});
expect(container.textContent).toContain("服务型技能");
expect(container.textContent).toContain("复制短视频脚本");
expect(container.textContent).toContain("产出:脚本大纲 + 镜头节奏");
expect(container.textContent).toContain("本地即时执行");
expect(container.textContent).toContain("填写参数");
const button = container.querySelector(
'[data-testid="service-skill-short-video-script-replication"]',
) as HTMLButtonElement | null;
expect(button).toBeTruthy();
act(() => {
button?.click();
});
expect(onSelect).toHaveBeenCalledWith(skills[0]);
});
});
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions";
import type { ServiceSkillHomeItem } from "./types";
interface ServiceSkillHomePanelProps {
skills: ServiceSkillHomeItem[];
loading?: boolean;
onSelect: (skill: ServiceSkillHomeItem) => void | Promise<void>;
}
export function ServiceSkillHomePanel({
skills,
loading = false,
onSelect,
}: ServiceSkillHomePanelProps) {
const items = useMemo(
() =>
skills.map((skill) => ({
key: skill.id,
title: skill.title,
description: skill.summary,
badge: skill.badge,
prompt: "",
actionLabel: skill.actionLabel,
outputHint: skill.outputHint,
statusLabel: skill.runnerLabel,
statusTone: skill.runnerTone,
statusDescription: skill.runnerDescription,
testId: `service-skill-${skill.id}`,
})),
[skills],
);
return (
<EmptyStateQuickActions
title="服务型技能"
description="先选一个结果导向入口,补齐关键参数后直接进入对应工作模式。"
items={items}
embedded
loading={loading}
onAction={(item) => {
const skill = skills.find((candidate) => candidate.id === item.key);
if (skill) {
void onSelect(skill);
}
}}
/>
);
}
export default ServiceSkillHomePanel;
@@ -0,0 +1,265 @@
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 { ServiceSkillLaunchDialog } from "./ServiceSkillLaunchDialog";
import type { ServiceSkillHomeItem } from "./types";
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <div data-testid="service-skill-dialog">{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
vi.mock("@/components/ui/textarea", () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea {...props} />
),
}));
vi.mock("@/components/ui/label", () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => <label {...props}>{children}</label>,
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const MOCK_SKILL: ServiceSkillHomeItem = {
id: "short-video-script-replication",
title: "复制短视频脚本",
summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。",
category: "视频创作",
outputHint: "脚本大纲 + 镜头节奏",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
executionLocation: "client_default",
themeTarget: "video",
version: "seed-v1",
slotSchema: [
{
key: "reference_video",
label: "参考视频链接/素材",
type: "url",
required: true,
placeholder: "输入视频链接",
},
{
key: "platform",
label: "发布平台",
type: "platform",
required: true,
defaultValue: "douyin",
placeholder: "选择平台",
options: [{ value: "douyin", label: "抖音" }],
},
],
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
};
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 renderDialog(
props: Partial<React.ComponentProps<typeof ServiceSkillLaunchDialog>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof ServiceSkillLaunchDialog> = {
skill: MOCK_SKILL,
open: true,
onOpenChange: vi.fn(),
onLaunch: vi.fn(),
};
act(() => {
root.render(<ServiceSkillLaunchDialog {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return {
container,
props: {
...defaultProps,
...props,
},
};
}
async function flushEffects(times = 4) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
function setFormValue(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
value: string,
) {
const prototype =
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: element instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
descriptor?.set?.call(element, value);
element.dispatchEvent(
new Event(
element instanceof HTMLSelectElement ? "change" : "input",
{ bubbles: true },
),
);
}
describe("ServiceSkillLaunchDialog", () => {
it("应在必填参数补齐后允许进入工作区并透传槽位值", async () => {
const onLaunch = vi.fn();
renderDialog({
onLaunch,
});
await flushEffects();
const referenceInput = document.body.querySelector(
'[data-testid="service-skill-slot-reference_video"]',
) as HTMLInputElement | null;
const launchButton = document.body.querySelector(
'[data-testid="service-skill-launch"]',
) as HTMLButtonElement | null;
expect(referenceInput).toBeTruthy();
expect(launchButton).toBeTruthy();
expect(launchButton?.disabled).toBe(true);
act(() => {
if (referenceInput) {
setFormValue(referenceInput, "https://example.com/video");
}
});
await flushEffects();
const enabledLaunchButton = document.body.querySelector(
'[data-testid="service-skill-launch"]',
) as HTMLButtonElement | null;
expect(enabledLaunchButton?.disabled).toBe(false);
act(() => {
enabledLaunchButton?.click();
});
expect(onLaunch).toHaveBeenCalledWith(MOCK_SKILL, {
reference_video: "https://example.com/video",
platform: "douyin",
});
});
it("定时服务型技能应提供创建自动化任务入口", async () => {
const onCreateAutomation = vi.fn();
renderDialog({
skill: {
...MOCK_SKILL,
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
title: "每日趋势摘要",
},
onCreateAutomation,
});
await flushEffects();
const referenceInput = document.body.querySelector(
'[data-testid="service-skill-slot-reference_video"]',
) as HTMLInputElement | null;
const createAutomationButton = document.body.querySelector(
'[data-testid="service-skill-create-automation"]',
) as HTMLButtonElement | null;
const enterWorkspaceButton = document.body.querySelector(
'[data-testid="service-skill-enter-workspace"]',
) as HTMLButtonElement | null;
expect(createAutomationButton).toBeTruthy();
expect(enterWorkspaceButton).toBeTruthy();
act(() => {
if (referenceInput) {
setFormValue(referenceInput, "https://example.com/video");
}
});
await flushEffects();
act(() => {
createAutomationButton?.click();
});
expect(onCreateAutomation).toHaveBeenCalledWith(
expect.objectContaining({
runnerType: "scheduled",
}),
{
reference_video: "https://example.com/video",
platform: "douyin",
},
);
});
});
@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
createDefaultServiceSkillSlotValues,
formatServiceSkillPromptPreview,
validateServiceSkillSlotValues,
} from "./promptComposer";
import { supportsServiceSkillLocalAutomation } from "./automationDraft";
import type {
ServiceSkillHomeItem,
ServiceSkillSlotDefinition,
ServiceSkillSlotValues,
} from "./types";
interface ServiceSkillLaunchDialogProps {
skill: ServiceSkillHomeItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onLaunch: (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
) => void | Promise<void>;
onCreateAutomation?: (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
) => void | Promise<void>;
}
function renderFieldControl(params: {
slot: ServiceSkillSlotDefinition;
value: string;
onChange: (value: string) => void;
}) {
const { slot, value, onChange } = params;
const sharedProps = {
id: `service-skill-slot-${slot.key}`,
"data-testid": `service-skill-slot-${slot.key}`,
};
if (slot.type === "textarea" || slot.type === "account_list") {
return (
<Textarea
{...sharedProps}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={slot.placeholder}
className="min-h-[96px]"
/>
);
}
if (slot.type === "enum" || slot.type === "platform") {
return (
<select
{...sharedProps}
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
)}
>
<option value="">
{slot.placeholder}
</option>
{(slot.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
return (
<Input
{...sharedProps}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={slot.placeholder}
type={slot.type === "url" ? "url" : "text"}
/>
);
}
export function ServiceSkillLaunchDialog({
skill,
open,
onOpenChange,
onLaunch,
onCreateAutomation,
}: ServiceSkillLaunchDialogProps) {
const [slotValues, setSlotValues] = useState<ServiceSkillSlotValues>({});
useEffect(() => {
if (!skill || !open) {
return;
}
setSlotValues(createDefaultServiceSkillSlotValues(skill));
}, [open, skill]);
const validation = useMemo(() => {
if (!skill) {
return {
valid: false,
missing: [],
};
}
return validateServiceSkillSlotValues(skill, slotValues);
}, [skill, slotValues]);
const preview = useMemo(() => {
if (!skill) {
return "";
}
return formatServiceSkillPromptPreview(skill, slotValues);
}, [skill, slotValues]);
const supportsAutomation = useMemo(
() => (skill ? supportsServiceSkillLocalAutomation(skill) : false),
[skill],
);
const canCreateAutomation = supportsAutomation && typeof onCreateAutomation === "function";
if (!skill) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[720px] space-y-4">
<DialogHeader>
<DialogTitle>{skill.title}</DialogTitle>
<DialogDescription className="space-y-2">
<span className="block">{skill.summary}</span>
<span className="block text-xs text-slate-500">
{skill.runnerLabel} · 产出:{skill.outputHint}
</span>
</DialogDescription>
</DialogHeader>
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 text-xs leading-5 text-slate-600">
<div className="font-medium text-slate-800">预览</div>
<div className="mt-1">{preview}</div>
{canCreateAutomation ? (
<div className="mt-2 text-[11px] text-slate-500">
这类任务支持直接生成本地自动化草稿;你也可以只先进入工作区做首版结果。
</div>
) : null}
</div>
<div className="grid gap-4">
{skill.slotSchema.map((slot) => (
<div key={slot.key} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={`service-skill-slot-${slot.key}`}>
{slot.label}
</Label>
{slot.required ? (
<span className="text-[11px] font-medium text-rose-500">
必填
</span>
) : null}
</div>
{renderFieldControl({
slot,
value: slotValues[slot.key] ?? "",
onChange: (nextValue) =>
setSlotValues((previous) => ({
...previous,
[slot.key]: nextValue,
})),
})}
{slot.helpText ? (
<p className="text-[11px] leading-5 text-slate-500">
{slot.helpText}
</p>
) : null}
</div>
))}
</div>
{!validation.valid ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-700">
还缺少必填参数:{validation.missing.map((slot) => slot.label).join("、")}
</div>
) : null}
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
取消
</Button>
{canCreateAutomation ? (
<Button
type="button"
variant="outline"
data-testid="service-skill-enter-workspace"
disabled={!validation.valid}
onClick={() => {
void onLaunch(skill, slotValues);
}}
>
先进入工作区
</Button>
) : null}
<Button
type="button"
data-testid={
canCreateAutomation
? "service-skill-create-automation"
: "service-skill-launch"
}
disabled={!validation.valid}
onClick={() => {
if (canCreateAutomation && onCreateAutomation) {
void onCreateAutomation(skill, slotValues);
return;
}
void onLaunch(skill, slotValues);
}}
>
{canCreateAutomation ? "创建任务并进入工作区" : "进入工作区"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ServiceSkillLaunchDialog;
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
buildServiceSkillAutomationInitialValues,
supportsServiceSkillLocalAutomation,
} from "./automationDraft";
import type { ServiceSkillItem } from "./types";
const SCHEDULED_SKILL: ServiceSkillItem = {
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台与关键词输出趋势摘要。",
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
version: "seed-v1",
slotSchema: [
{
key: "platform",
label: "监测平台",
type: "platform",
required: true,
placeholder: "选择平台",
defaultValue: "x",
options: [{ value: "x", label: "X / Twitter" }],
},
{
key: "industry_keywords",
label: "行业关键词",
type: "textarea",
required: true,
placeholder: "输入关键词",
},
{
key: "schedule_time",
label: "推送时间",
type: "schedule_time",
required: false,
defaultValue: "每天 09:00",
placeholder: "例如 每天 09:00",
},
],
};
describe("service skill automation draft", () => {
it("应识别可转本地自动化的服务型技能", () => {
expect(supportsServiceSkillLocalAutomation(SCHEDULED_SKILL)).toBe(true);
expect(
supportsServiceSkillLocalAutomation({
...SCHEDULED_SKILL,
runnerType: "instant",
}),
).toBe(false);
});
it("应把 schedule_time 预填为 automation 创建表单", () => {
const initialValues = buildServiceSkillAutomationInitialValues({
skill: SCHEDULED_SKILL,
slotValues: {
platform: "x",
industry_keywords: "AI Agent,创作者工具",
schedule_time: "每天 09:00",
},
userInput: "重点关注新增热点与异常波动。",
workspaceId: "project-1",
});
expect(initialValues.name).toContain("每日趋势摘要");
expect(initialValues.workspace_id).toBe("project-1");
expect(initialValues.execution_mode).toBe("skill");
expect(initialValues.payload_kind).toBe("agent_turn");
expect(initialValues.schedule_kind).toBe("cron");
expect(initialValues.cron_expr).toBe("00 09 * * *");
expect(initialValues.prompt).toContain("[服务型技能] 每日趋势摘要");
expect(initialValues.prompt).toContain("[自动化执行要求]");
});
});
@@ -0,0 +1,195 @@
import type { AutomationJobDialogInitialValues } from "@/components/settings-v2/system/automation/AutomationJobDialog";
import { composeServiceSkillAutomationPrompt } from "./promptComposer";
import type {
ServiceSkillItem,
ServiceSkillSlotDefinition,
ServiceSkillSlotValues,
} from "./types";
const DEFAULT_AUTOMATION_INTERVAL_SECS = 86_400;
const WEEKDAY_TO_CRON_DAY: Record<string, string> = {
一: "1",
二: "2",
三: "3",
四: "4",
五: "5",
六: "6",
日: "0",
天: "0",
};
interface BuildServiceSkillAutomationInitialValuesInput {
skill: ServiceSkillItem;
slotValues: ServiceSkillSlotValues;
userInput?: string;
workspaceId: string;
}
function resolveSlotValue(
slot: ServiceSkillSlotDefinition,
slotValues: ServiceSkillSlotValues,
): string {
const currentValue = slotValues[slot.key]?.trim();
if (currentValue) {
return currentValue;
}
return slot.defaultValue?.trim() || "";
}
function resolveLocalTimeZone(): string {
if (
typeof Intl !== "undefined" &&
typeof Intl.DateTimeFormat === "function"
) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (typeof timeZone === "string" && timeZone.trim()) {
return timeZone;
}
}
return "Asia/Shanghai";
}
function resolveScheduleSlotValue(
skill: ServiceSkillItem,
slotValues: ServiceSkillSlotValues,
): string {
const scheduleSlot = skill.slotSchema.find((slot) => slot.type === "schedule_time");
if (!scheduleSlot) {
return "";
}
return resolveSlotValue(scheduleSlot, slotValues);
}
function buildDefaultSchedulePrefill(): Pick<
AutomationJobDialogInitialValues,
"schedule_kind" | "every_secs"
> {
return {
schedule_kind: "every",
every_secs: String(DEFAULT_AUTOMATION_INTERVAL_SECS),
};
}
function buildCronPrefill(expr: string): Pick<
AutomationJobDialogInitialValues,
"schedule_kind" | "cron_expr" | "cron_tz"
> {
return {
schedule_kind: "cron",
cron_expr: expr,
cron_tz: resolveLocalTimeZone(),
};
}
function parseScheduleTextToPrefill(
rawValue: string,
): Pick<
AutomationJobDialogInitialValues,
"schedule_kind" | "every_secs" | "cron_expr" | "cron_tz"
> {
const value = rawValue.trim();
if (!value) {
return buildDefaultSchedulePrefill();
}
const everyMatch = value.match(/^每\s*(\d+)\s*(秒|分钟|分|小时|时)$/);
if (everyMatch) {
const amount = Number(everyMatch[1]);
const unit = everyMatch[2];
if (Number.isFinite(amount) && amount > 0) {
const everySecs =
unit === "秒"
? amount
: unit === "分钟" || unit === "分"
? amount * 60
: amount * 3_600;
return {
schedule_kind: "every",
every_secs: String(Math.max(60, everySecs)),
};
}
}
const dailyMatch = value.match(/^(?:每天|每日)\s*(\d{1,2}):(\d{2})$/);
if (dailyMatch) {
return buildCronPrefill(`${dailyMatch[2]} ${dailyMatch[1]} * * *`);
}
const weekdayMatch = value.match(/^每周([一二三四五六日天])\s*(\d{1,2}):(\d{2})$/);
if (weekdayMatch) {
const cronDay = WEEKDAY_TO_CRON_DAY[weekdayMatch[1]];
if (cronDay) {
return buildCronPrefill(
`${weekdayMatch[3]} ${weekdayMatch[2]} * * ${cronDay}`,
);
}
}
const weekdayDailyMatch = value.match(/^(?:工作日|每个工作日)\s*(\d{1,2}):(\d{2})$/);
if (weekdayDailyMatch) {
return buildCronPrefill(
`${weekdayDailyMatch[2]} ${weekdayDailyMatch[1]} * * 1-5`,
);
}
return buildDefaultSchedulePrefill();
}
function buildServiceSkillAutomationName(skill: ServiceSkillItem): string {
if (skill.runnerType === "managed") {
return `${skill.title}|持续跟踪`;
}
if (skill.runnerType === "scheduled") {
return `${skill.title}|定时执行`;
}
return `${skill.title}|本地任务`;
}
function buildServiceSkillAutomationDescription(
skill: ServiceSkillItem,
scheduleValue: string,
): string {
const lines = [skill.summary, "来源:服务型技能本地自动化草稿。"];
if (scheduleValue.trim()) {
lines.push(`预设调度:${scheduleValue.trim()}`);
}
return lines.join("\n");
}
export function supportsServiceSkillLocalAutomation(
skill: ServiceSkillItem,
): boolean {
return (
skill.executionLocation === "client_default" &&
skill.runnerType !== "instant"
);
}
export function buildServiceSkillAutomationInitialValues({
skill,
slotValues,
userInput,
workspaceId,
}: BuildServiceSkillAutomationInitialValuesInput): AutomationJobDialogInitialValues {
const scheduleValue = resolveScheduleSlotValue(skill, slotValues);
const schedulePrefill = parseScheduleTextToPrefill(scheduleValue);
return {
name: buildServiceSkillAutomationName(skill),
description: buildServiceSkillAutomationDescription(skill, scheduleValue),
workspace_id: workspaceId,
enabled: true,
execution_mode: "skill",
payload_kind: "agent_turn",
prompt: composeServiceSkillAutomationPrompt({
skill,
slotValues,
userInput,
}),
system_prompt: "",
web_search: false,
max_retries: "2",
delivery_mode: "none",
...schedulePrefill,
};
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
composeServiceSkillAutomationPrompt,
composeServiceSkillPrompt,
createDefaultServiceSkillSlotValues,
formatServiceSkillPromptPreview,
validateServiceSkillSlotValues,
} from "./promptComposer";
import type { ServiceSkillItem } from "./types";
const MOCK_SKILL: ServiceSkillItem = {
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台与关键词输出趋势摘要。",
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
version: "seed-v1",
slotSchema: [
{
key: "platform",
label: "监测平台",
type: "platform",
required: true,
placeholder: "选择平台",
defaultValue: "x",
options: [{ value: "x", label: "X / Twitter" }],
},
{
key: "industry_keywords",
label: "行业关键词",
type: "textarea",
required: true,
placeholder: "输入关键词",
},
],
};
describe("service skill prompt composer", () => {
it("应创建默认槽位值并校验缺失字段", () => {
const defaults = createDefaultServiceSkillSlotValues(MOCK_SKILL);
expect(defaults).toEqual({
platform: "x",
industry_keywords: "",
});
const validation = validateServiceSkillSlotValues(MOCK_SKILL, defaults);
expect(validation.valid).toBe(false);
expect(validation.missing.map((slot) => slot.key)).toEqual([
"industry_keywords",
]);
});
it("应生成预览与结构化 prompt", () => {
const slotValues = {
platform: "x",
industry_keywords: "AI Agent,创作者工具",
};
expect(formatServiceSkillPromptPreview(MOCK_SKILL, slotValues)).toContain(
"每日趋势摘要",
);
const prompt = composeServiceSkillPrompt({
skill: MOCK_SKILL,
slotValues,
userInput: "重点关注过去 24 小时的新增热点。",
});
expect(prompt).toContain("[服务型技能] 每日趋势摘要");
expect(prompt).toContain("- 行业关键词: AI Agent,创作者工具");
expect(prompt).toContain("[补充要求] 重点关注过去 24 小时的新增热点。");
expect(prompt).toContain("当前为客户端起步版");
});
it("应为 automation 生成独立执行要求", () => {
const prompt = composeServiceSkillAutomationPrompt({
skill: MOCK_SKILL,
slotValues: {
platform: "x",
industry_keywords: "AI Agent,创作者工具",
},
});
expect(prompt).toContain("[自动化执行要求]");
expect(prompt).not.toContain("当前为客户端起步版");
});
});
@@ -0,0 +1,156 @@
import type {
ServiceSkillItem,
ServiceSkillSlotDefinition,
ServiceSkillSlotValues,
} from "./types";
export interface ServiceSkillSlotValidationResult {
valid: boolean;
missing: ServiceSkillSlotDefinition[];
}
export interface ComposeServiceSkillPromptInput {
skill: ServiceSkillItem;
slotValues: ServiceSkillSlotValues;
userInput?: string;
}
const RUNNER_LABELS = {
instant: "一次性交付",
scheduled: "定时任务",
managed: "持续跟踪",
} as const;
const EXECUTION_LOCATION_LABELS = {
client_default: "客户端默认执行",
cloud_required: "服务端特例执行",
} as const;
function resolveSlotValue(
slot: ServiceSkillSlotDefinition,
slotValues: ServiceSkillSlotValues,
): string {
const currentValue = slotValues[slot.key]?.trim();
if (currentValue) {
return currentValue;
}
return slot.defaultValue?.trim() || "";
}
function buildServiceSkillPromptLines(
skill: ServiceSkillItem,
slotValues: ServiceSkillSlotValues,
userInput?: string,
): string[] {
const lines: string[] = [
`[服务型技能] ${skill.title}`,
`[目录来源] ${skill.source === "cloud_catalog" ? "云目录(客户端起步版)" : "本地目录"}`,
`[任务形态] ${RUNNER_LABELS[skill.runnerType]}`,
`[执行位置] ${EXECUTION_LOCATION_LABELS[skill.executionLocation]}`,
`[任务摘要] ${skill.summary}`,
"[参数]",
];
for (const slot of skill.slotSchema) {
lines.push(`- ${slot.label}: ${resolveSlotValue(slot, slotValues) || "未提供"}`);
}
if (userInput?.trim()) {
lines.push(`[补充要求] ${userInput.trim()}`);
}
return lines;
}
export function createDefaultServiceSkillSlotValues(
skill: ServiceSkillItem,
): ServiceSkillSlotValues {
return skill.slotSchema.reduce<ServiceSkillSlotValues>((acc, slot) => {
acc[slot.key] = slot.defaultValue ?? "";
return acc;
}, {});
}
export function validateServiceSkillSlotValues(
skill: ServiceSkillItem,
slotValues: ServiceSkillSlotValues,
): ServiceSkillSlotValidationResult {
const missing = skill.slotSchema.filter((slot) => {
if (!slot.required) {
return false;
}
return !resolveSlotValue(slot, slotValues);
});
return {
valid: missing.length === 0,
missing,
};
}
export function formatServiceSkillPromptPreview(
skill: ServiceSkillItem,
slotValues: ServiceSkillSlotValues,
): string {
const resolvedValues = skill.slotSchema
.map((slot) => `${slot.label}:${resolveSlotValue(slot, slotValues) || "待补充"}`)
.slice(0, 3);
return `${skill.title}|${resolvedValues.join("|")}`;
}
export function composeServiceSkillPrompt({
skill,
slotValues,
userInput,
}: ComposeServiceSkillPromptInput): string {
const lines = buildServiceSkillPromptLines(skill, slotValues, userInput);
if (skill.runnerType === "instant") {
lines.push(
"[执行要求] 现在直接开始,优先产出一版可交付结果;若信息不足,先给出最小缺口,再继续推进。",
);
} else if (skill.runnerType === "scheduled") {
lines.push(
"[执行要求] 当前为客户端起步版。请先产出一版首轮结果,并补充后续本地定时任务的执行步骤、调度建议、失败处理点和需要保留的参数。",
);
} else {
lines.push(
"[执行要求] 当前为客户端起步版。请先产出一版首轮分析或执行方案,并补充后续本地持续跟踪任务的关键指标、告警阈值、回报节奏和自动化建议。",
);
}
if (skill.themeTarget) {
lines.push(`[建议主题] ${skill.themeTarget}`);
}
return lines.join("\n");
}
export function composeServiceSkillAutomationPrompt({
skill,
slotValues,
userInput,
}: ComposeServiceSkillPromptInput): string {
const lines = buildServiceSkillPromptLines(skill, slotValues, userInput);
if (skill.runnerType === "scheduled") {
lines.push(
"[自动化执行要求] 这是一个由本地自动化定时触发的任务。每次运行都要完成本轮结果,优先输出当前周期的摘要、变化、异常和下一步建议;若本轮没有明显变化,也要明确说明“本轮无显著变化”。",
);
} else if (skill.runnerType === "managed") {
lines.push(
"[自动化执行要求] 这是一个由本地自动化持续跟踪的任务。每次运行都要先比较关键指标和上轮变化,再输出风险、告警、原因判断和建议动作;若触发阈值,必须把异常项单独列出。",
);
} else {
lines.push(
"[自动化执行要求] 当前任务由本地自动化触发。请直接产出本轮结果,并在必要时明确补充信息缺口。",
);
}
if (skill.themeTarget) {
lines.push(`[建议主题] ${skill.themeTarget}`);
}
return lines.join("\n");
}
@@ -0,0 +1,85 @@
import type {
RecordServiceSkillUsageInput,
ServiceSkillUsageRecord,
} from "./types";
const SERVICE_SKILL_USAGE_STORAGE_KEY = "lime:service-skill-usage:v1";
const MAX_SERVICE_SKILL_USAGE_RECORDS = 12;
function isValidUsageRecord(value: unknown): value is ServiceSkillUsageRecord {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<ServiceSkillUsageRecord>;
return (
typeof record.skillId === "string" &&
record.skillId.length > 0 &&
typeof record.usedAt === "number" &&
Number.isFinite(record.usedAt) &&
typeof record.runnerType === "string"
);
}
export function listServiceSkillUsage(): ServiceSkillUsageRecord[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(SERVICE_SKILL_USAGE_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(isValidUsageRecord)
.sort((left, right) => right.usedAt - left.usedAt)
.slice(0, MAX_SERVICE_SKILL_USAGE_RECORDS);
} catch {
return [];
}
}
export function getServiceSkillUsageMap(): Map<string, ServiceSkillUsageRecord> {
return new Map(
listServiceSkillUsage().map((record) => [record.skillId, record]),
);
}
export function recordServiceSkillUsage(
input: RecordServiceSkillUsageInput,
): ServiceSkillUsageRecord[] {
const nextRecord: ServiceSkillUsageRecord = {
skillId: input.skillId,
usedAt: input.usedAt ?? Date.now(),
runnerType: input.runnerType,
};
const nextRecords = [
nextRecord,
...listServiceSkillUsage().filter(
(record) => record.skillId !== nextRecord.skillId,
),
].slice(0, MAX_SERVICE_SKILL_USAGE_RECORDS);
if (typeof window === "undefined") {
return nextRecords;
}
try {
window.localStorage.setItem(
SERVICE_SKILL_USAGE_STORAGE_KEY,
JSON.stringify(nextRecords),
);
} catch {
// ignore write errors
}
return nextRecords;
}
@@ -0,0 +1,51 @@
import type {
ServiceSkillCatalog,
ServiceSkillExecutionLocation,
ServiceSkillExecutorBinding,
ServiceSkillItem,
ServiceSkillReadinessRequirements,
ServiceSkillRunnerType,
ServiceSkillSlotDefinition,
ServiceSkillSlotOption,
ServiceSkillSlotType,
ServiceSkillSource,
} from "@/lib/api/serviceSkills";
export type {
ServiceSkillCatalog,
ServiceSkillExecutionLocation,
ServiceSkillExecutorBinding,
ServiceSkillItem,
ServiceSkillReadinessRequirements,
ServiceSkillRunnerType,
ServiceSkillSlotDefinition,
ServiceSkillSlotOption,
ServiceSkillSlotType,
ServiceSkillSource,
};
export type ServiceSkillTone = "slate" | "sky" | "emerald" | "amber";
export type ServiceSkillSlotValues = Record<string, string>;
export interface ServiceSkillHomeItem extends ServiceSkillItem {
badge: string;
recentUsedAt: number | null;
isRecent: boolean;
runnerLabel: string;
runnerTone: ServiceSkillTone;
runnerDescription: string;
actionLabel: string;
}
export interface ServiceSkillUsageRecord {
skillId: string;
usedAt: number;
runnerType: ServiceSkillRunnerType;
}
export interface RecordServiceSkillUsageInput {
skillId: string;
usedAt?: number;
runnerType: ServiceSkillRunnerType;
}
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listServiceSkills } from "@/lib/api/serviceSkills";
import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage";
import type {
RecordServiceSkillUsageInput,
ServiceSkillHomeItem,
ServiceSkillItem,
ServiceSkillRunnerType,
ServiceSkillTone,
} from "./types";
const RUNNER_LABELS: Record<ServiceSkillRunnerType, string> = {
instant: "本地即时执行",
scheduled: "本地计划任务",
managed: "本地持续跟踪",
};
const RUNNER_TONES: Record<ServiceSkillRunnerType, ServiceSkillTone> = {
instant: "emerald",
scheduled: "sky",
managed: "amber",
};
const RUNNER_DESCRIPTIONS: Record<ServiceSkillRunnerType, string> = {
instant: "客户端起步版可直接进入工作区执行。",
scheduled: "当前先进入工作区生成首版任务方案,后续再接本地自动化。",
managed: "当前先进入工作区生成首版跟踪方案,后续再接本地持续任务。",
};
const ACTION_LABELS: Record<ServiceSkillRunnerType, string> = {
instant: "填写参数",
scheduled: "先做方案",
managed: "先定指标",
};
function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string {
if (isRecent) {
return "最近使用";
}
if (item.source === "cloud_catalog") {
return "云目录";
}
return "本地技能";
}
function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] {
const usageMap = getServiceSkillUsageMap();
const mapped: Array<ServiceSkillHomeItem & { _sortIndex: number }> = items.map(
(item, index) => {
const recent = usageMap.get(item.id);
const recentUsedAt = recent?.usedAt ?? null;
const isRecent = typeof recentUsedAt === "number";
return {
...item,
badge: getSkillBadge(item, isRecent),
recentUsedAt,
isRecent,
runnerLabel: RUNNER_LABELS[item.runnerType],
runnerTone: RUNNER_TONES[item.runnerType],
runnerDescription: RUNNER_DESCRIPTIONS[item.runnerType],
actionLabel: ACTION_LABELS[item.runnerType],
_sortIndex: index,
};
},
);
return mapped
.sort((left, right) => {
if (left.recentUsedAt && right.recentUsedAt) {
if (left.recentUsedAt !== right.recentUsedAt) {
return right.recentUsedAt - left.recentUsedAt;
}
} else if (left.recentUsedAt) {
return -1;
} else if (right.recentUsedAt) {
return 1;
}
return left._sortIndex - right._sortIndex;
})
.map(({ _sortIndex, ...item }) => item);
}
interface UseServiceSkillsResult {
skills: ServiceSkillHomeItem[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
recordUsage: (input: RecordServiceSkillUsageInput) => void;
}
export function useServiceSkills(enabled = true): UseServiceSkillsResult {
const [items, setItems] = useState<ServiceSkillItem[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [usageVersion, setUsageVersion] = useState(0);
const refresh = useCallback(async () => {
if (!enabled) {
setItems([]);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const nextItems = await listServiceSkills();
setItems(nextItems);
setError(null);
} catch (reason) {
setItems([]);
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
const recordUsageAndRefresh = useCallback(
(input: RecordServiceSkillUsageInput) => {
recordServiceSkillUsage(input);
setUsageVersion((previous) => previous + 1);
},
[],
);
const skills = useMemo(() => {
void usageVersion;
return buildHomeItems(items);
}, [items, usageVersion]);
return {
skills,
isLoading,
error,
refresh,
recordUsage: recordUsageAndRefresh,
};
}
@@ -58,6 +58,7 @@ describe("buildThreadReliabilityView", () => {
prompt_text: "继续执行发布流程",
status: "failed",
error_message: "Provider 429",
started_at: "2026-03-23T09:00:00Z",
created_at: "2026-03-23T09:00:00Z",
updated_at: "2026-03-23T09:00:10Z",
completed_at: "2026-03-23T09:00:12Z",
@@ -176,4 +177,39 @@ describe("buildThreadReliabilityView", () => {
expect(view.recommendations).toContain("优先响应当前待处理请求");
expect(view.recommendations).toContain("审批等待过久,建议尽快处理或停止当前执行");
});
it("应把 runtime 中断请求展示为中断中而非普通运行中", () => {
const threadRead: AgentRuntimeThreadReadModel = {
thread_id: "thread-1",
status: "interrupting",
active_turn_id: "turn-4",
pending_requests: [],
incidents: [],
interrupt_state: "interrupting",
updated_at: "2026-03-23T09:59:58Z",
};
const view = buildThreadReliabilityView({
threadRead,
turns: [
{
id: "turn-4",
thread_id: "thread-1",
prompt_text: "继续停止当前执行",
status: "running",
created_at: "2026-03-23T09:59:00Z",
started_at: "2026-03-23T09:59:00Z",
updated_at: "2026-03-23T09:59:58Z",
},
],
currentTurnId: "turn-4",
});
expect(view.statusLabel).toBe("中断中");
expect(view.interruptStateLabel).toBe("运行时正在处理中断");
expect(view.summary).toContain("请等待运行时回填最终状态");
expect(view.recommendations).toContain(
"正在停止当前执行,请等待运行时回填最终状态",
);
});
});
@@ -601,6 +601,9 @@ function normalizeInterruptStateLabel(
if (!normalized) {
return null;
}
if (normalized.includes("interrupting")) {
return "运行时正在处理中断";
}
if (normalized.includes("interrupt")) {
return "运行时已确认中断";
}
@@ -705,6 +708,7 @@ function buildSummary(params: {
incidents: ThreadReliabilityIncidentDisplay[];
outcome: ThreadReliabilityOutcomeDisplay | null;
queuedTurnCount: number;
interruptState?: string | null;
interruptStateLabel?: string | null;
nextQueuedTurn: ThreadReliabilityQueuedTurnDisplay | null;
}): string {
@@ -723,6 +727,9 @@ function buildSummary(params: {
}
if (params.interruptStateLabel) {
if ((params.interruptState || "").toLowerCase().includes("interrupting")) {
return `${params.interruptStateLabel},请等待运行时回填最终状态。`;
}
if (params.nextQueuedTurn) {
return `${params.interruptStateLabel},可继续 ${params.nextQueuedTurn.title}`;
}
@@ -750,6 +757,7 @@ function buildRecommendations(params: {
incidents: ThreadReliabilityIncidentDisplay[];
outcome: ThreadReliabilityOutcomeDisplay | null;
nextQueuedTurn: ThreadReliabilityQueuedTurnDisplay | null;
interruptState?: string | null;
interruptStateLabel?: string | null;
}): string[] {
const recommendations = new Set<string>();
@@ -782,9 +790,13 @@ function buildRecommendations(params: {
recommendations.add("请先检查失败工具的参数或环境,再尝试重试");
}
if (params.interruptStateLabel) {
recommendations.add("当前执行已被运行时确认中断");
if ((params.interruptState || "").toLowerCase().includes("interrupting")) {
recommendations.add("正在停止当前执行,请等待运行时回填最终状态");
} else {
recommendations.add("当前执行已被运行时确认中断");
}
}
if (params.nextQueuedTurn) {
if (params.nextQueuedTurn && !((params.interruptState || "").toLowerCase().includes("interrupting"))) {
recommendations.add(`可继续排队回合:${params.nextQueuedTurn.title}`);
}
if (params.outcome?.retryable) {
@@ -858,6 +870,7 @@ export function buildThreadReliabilityView(
incidents,
outcome,
queuedTurnCount,
interruptState: params.threadRead?.interrupt_state,
interruptStateLabel,
nextQueuedTurn,
}),
@@ -882,6 +895,7 @@ export function buildThreadReliabilityView(
incidents,
outcome,
nextQueuedTurn,
interruptState: params.threadRead?.interrupt_state,
interruptStateLabel,
}),
};
@@ -24,6 +24,8 @@ type HarnessPanelBaseProps = Pick<
| "onResumeThread"
| "onReplayPendingRequest"
| "onPromoteQueuedTurn"
| "messages"
| "diagnosticRuntimeContext"
| "toolInventory"
| "toolInventoryLoading"
| "toolInventoryError"
@@ -3,24 +3,18 @@ import { toast } from "sonner";
import type { A2UIFormData, A2UIResponse } from "@/components/content-creator/a2ui/types";
import type { ConfirmResponse } from "../types";
import type { SendMessageFn } from "../hooks/agentChatShared";
import type { ActionRequired } from "../types";
import {
buildActionRequestSubmissionPayload,
} from "../utils/actionRequestA2UI";
import { buildLegacyQuestionnaireSubmissionPayload } from "../utils/legacyQuestionnaireA2UI";
interface PendingPromotedActionRequest {
requestId: string;
actionType: ConfirmResponse["actionType"];
}
interface UseWorkspaceA2UISubmitActionsParams {
handlePermissionResponseWithBrowserPreflight: (
response: ConfirmResponse,
) => Promise<void>;
pendingLegacyQuestionnaireA2UIForm: A2UIResponse | null;
pendingPromotedA2UIActionRequest:
| (PendingPromotedActionRequest & Record<string, unknown>)
| null;
pendingPromotedA2UIActionRequest: ActionRequired | null;
sendMessage: SendMessageFn;
}
@@ -52,9 +46,7 @@ export function useWorkspaceA2UISubmitActions({
(formData: A2UIFormData) => {
if (pendingPromotedA2UIActionRequest) {
const payload = buildActionRequestSubmissionPayload(
pendingPromotedA2UIActionRequest as Parameters<
typeof buildActionRequestSubmissionPayload
>[0],
pendingPromotedA2UIActionRequest,
formData,
);
@@ -68,7 +68,7 @@ function buildCanvasStateFromContent(params: {
}
interface UseWorkspaceArtifactPreviewActionsParams {
activeTheme: ThemeType;
activeTheme: string;
mappedTheme: ThemeType;
layoutMode: LayoutMode;
isThemeWorkbench: boolean;
@@ -83,7 +83,7 @@ interface UseWorkspaceArtifactPreviewActionsParams {
setArtifactViewMode: Dispatch<SetStateAction<"source" | "preview">>;
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>;
setTaskFiles: Dispatch<SetStateAction<TaskFile[]>>;
setSelectedFileId: Dispatch<SetStateAction<string | null>>;
setSelectedFileId: (fileId: string) => void;
setCanvasState: Dispatch<SetStateAction<CanvasStateUnion | null>>;
upsertNovelCanvasState: (
previous: CanvasStateUnion | null,
@@ -11,7 +11,7 @@ import type { WorkspaceHandleSend } from "./useWorkspaceSendActions";
interface UseWorkspaceAutoGuideRuntimeParams {
contentId?: string | null;
sessionId?: string;
sessionId?: string | null;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
initialDispatchKey: string | null;
@@ -8,7 +8,7 @@ import {
type SetStateAction,
} from "react";
import { toast } from "sonner";
import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import type { LayoutMode } from "@/components/content-creator/types";
import { browserExecuteAction, launchBrowserSession } from "@/lib/webview-api";
import type { Artifact } from "@/lib/artifact/types";
import type {
@@ -64,7 +64,7 @@ type EnsureBrowserAssistCanvasHandler = (
) => Promise<boolean>;
interface UseWorkspaceBrowserAssistRuntimeParams {
activeTheme: ThemeType;
activeTheme: string;
projectId?: string | null;
sessionId?: string | null;
input: string;
@@ -170,7 +170,7 @@ interface UseWorkspaceCanvasPreviewPresentationParams {
}
interface WorkspaceCanvasPreviewPresentationResult {
canvasWorkbenchDefaultPreview: CanvasWorkbenchDefaultPreview;
canvasWorkbenchDefaultPreview: CanvasWorkbenchDefaultPreview | null;
handleOpenCanvasWorkbenchPath: (path: string) => Promise<void>;
handleRevealCanvasWorkbenchPath: (path: string) => Promise<void>;
liveCanvasPreview: ReactNode;
@@ -76,7 +76,7 @@ export function useWorkspaceContextHarnessRuntime({
shouldAlwaysShowHarnessToggle ||
shouldAlwaysShowGeneralWorkbenchToggle ||
hasHarnessActivity;
const harnessAttentionLevel =
const harnessAttentionLevel: "idle" | "active" | "warning" =
harnessPendingCount > 0
? "warning"
: hasHarnessActivity
@@ -8,10 +8,11 @@ import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CreationMode } from "../components/types";
import type { WriteArtifactContext } from "../types";
import type { ThemeType } from "@/components/content-creator/types";
import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import type { Artifact } from "@/lib/artifact/types";
import type { Character } from "@/lib/api/memory";
import type { TaskFile } from "../components/TaskFiles";
import type { WorkspacePathMissingState } from "../hooks/agentChatShared";
type NavigationActions = ReturnType<typeof useWorkspaceNavigationActions>;
type InputbarScene = ReturnType<typeof useWorkspaceInputbarSceneRuntime>;
@@ -82,7 +83,7 @@ interface UseWorkspaceConversationSceneRuntimeParams {
handleToggleHarnessPanel: ConversationScenePresentationParams["scene"]["onToggleHarnessPanel"];
harnessPendingCount: ConversationScenePresentationParams["scene"]["harnessPendingCount"];
harnessAttentionLevel: ConversationScenePresentationParams["scene"]["harnessAttentionLevel"];
sessionId: string | undefined;
sessionId: string | null | undefined;
syncStatus: ConversationScenePresentationParams["scene"]["syncStatus"];
pendingA2UIForm: unknown;
handleToggleCanvas: ConversationScenePresentationParams["scene"]["onToggleCanvas"];
@@ -123,7 +124,7 @@ interface UseWorkspaceConversationSceneRuntimeParams {
shouldCollapseCodeBlockInChat: ConversationScenePresentationParams["messageList"]["shouldCollapseCodeBlock"];
handleCodeBlockClick: ConversationScenePresentationParams["messageList"]["onCodeBlockClick"];
showTeamWorkspaceBoard: ConversationScenePresentationParams["teamWorkspaceDock"]["enabled"];
layoutMode: ConversationScenePresentationParams["teamWorkspaceDock"]["layoutMode"];
layoutMode: LayoutMode;
handleActivateTeamWorkbench: ConversationScenePresentationParams["teamWorkspaceDock"]["onActivateWorkbench"];
isThemeWorkbench: boolean;
showNovelNavbarControls: ConversationScenePresentationParams["novelCanvas"]["visible"];
@@ -137,7 +138,7 @@ interface UseWorkspaceConversationSceneRuntimeParams {
projectRootPath: string | null;
handleHarnessLoadFilePreview: ConversationScenePresentationParams["canvasWorkbenchLayout"]["loadFilePreview"];
setCanvasWorkbenchLayoutMode: ConversationScenePresentationParams["canvasWorkbenchLayout"]["onLayoutModeChange"];
workspacePathMissing: boolean;
workspacePathMissing: WorkspacePathMissingState | boolean | null;
workspaceHealthError: boolean;
}
@@ -253,6 +254,9 @@ export function useWorkspaceConversationSceneRuntime({
workspacePathMissing,
workspaceHealthError,
}: UseWorkspaceConversationSceneRuntimeParams) {
const teamWorkspaceDockLayoutMode =
layoutMode === "chat" ? "chat" : "chat-canvas";
return useWorkspaceConversationScenePresentation({
scene: {
entryBannerVisible,
@@ -392,7 +396,7 @@ export function useWorkspaceConversationSceneRuntime({
enabled: showTeamWorkspaceBoard,
shouldShowFloatingInputOverlay:
shellChromeRuntime.shouldShowThemeWorkbenchFloatingInputOverlay,
layoutMode,
layoutMode: teamWorkspaceDockLayoutMode,
onActivateWorkbench: handleActivateTeamWorkbench,
withBottomOverlay:
isThemeWorkbench &&
@@ -401,7 +405,7 @@ export function useWorkspaceConversationSceneRuntime({
surfaceProps: inputbarScene.teamWorkbenchSurfaceProps,
},
workspaceAlert: {
workspacePathMissing,
workspacePathMissing: Boolean(workspacePathMissing),
workspaceHealthError,
},
novelCanvas: {
@@ -141,6 +141,14 @@ export function useWorkspaceConversationShellSceneRuntime({
projectRootPath: project?.rootPath || null,
});
const pendingActions = conversationScene.pendingActions ?? [];
const queuedTurns = conversationScene.queuedTurns ?? [];
const handleBackHome = conversationScene.handleBackHome ?? (() => undefined);
const handleOpenSubagentSession =
conversationScene.handleOpenSubagentSession ?? (() => undefined);
const displayMessages = conversationScene.displayMessages ?? [];
const isSending = conversationScene.isSending ?? false;
return useWorkspaceShellSceneRuntime({
compactChrome: shellChromeRuntime.isWorkspaceCompactChrome,
isThemeWorkbench: conversationScene.isThemeWorkbench,
@@ -150,18 +158,18 @@ export function useWorkspaceConversationShellSceneRuntime({
conversationSceneRuntime,
sessionId: conversationScene.sessionId,
topics,
handleBackHome: conversationScene.handleBackHome,
handleBackHome,
switchTopic,
handleResumeSidebarTask,
deleteTopic,
renameTopic,
displayMessages: conversationScene.displayMessages,
isSending: conversationScene.isSending,
pendingActionCount: conversationScene.pendingActions.length,
queuedTurnCount: conversationScene.queuedTurns.length,
displayMessages,
isSending,
pendingActionCount: pendingActions.length,
queuedTurnCount: queuedTurns.length,
childSubagentSessions,
subagentParentContext,
handleOpenSubagentSession: conversationScene.handleOpenSubagentSession,
handleOpenSubagentSession,
handleReturnToParentSession,
});
}
@@ -15,7 +15,7 @@ interface UseWorkspaceDisplayMessagesRuntimeParams {
pendingActionCount: number;
queuedTurnCount: number;
runtimeTeamDispatchPreview: RuntimeTeamDispatchPreviewSnapshot | null;
sessionId?: string;
sessionId?: string | null;
updateTopicSnapshot: (
sessionId: string,
snapshot: ReturnType<typeof buildLiveTaskSnapshot>,
@@ -17,8 +17,8 @@ import {
interface UseWorkspaceGeneralResourceSyncParams {
activeTheme: string;
projectId?: string;
sessionId?: string;
projectId?: string | null;
sessionId?: string | null;
projectRootPath?: string | null;
}
@@ -3,21 +3,11 @@ import {
getAgentRuntimeToolInventory,
type AgentRuntimeToolInventory,
} from "@/lib/api/agentRuntime";
interface HarnessRunSummary {
title?: string | null;
started_at?: number | null;
artifact_paths?: string[] | null;
}
interface HarnessTerminalSummary extends HarnessRunSummary {
finished_at?: number | null;
}
interface HarnessBackendRunStateSummary {
updated_at?: number | null;
latest_terminal?: HarnessTerminalSummary | null;
}
import type {
ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
ThemeWorkbenchRunTerminalItem,
ThemeWorkbenchRunTodoItem,
} from "@/lib/api/executionRun";
interface UseWorkspaceHarnessInventoryRuntimeParams {
chatMode: "agent" | "general" | "creator";
@@ -30,8 +20,8 @@ interface UseWorkspaceHarnessInventoryRuntimeParams {
title: string;
description: string;
};
themeWorkbenchBackendRunState: HarnessBackendRunStateSummary | null;
themeWorkbenchActiveQueueItem: HarnessRunSummary | null | undefined;
themeWorkbenchBackendRunState: BackendThemeWorkbenchRunState | null;
themeWorkbenchActiveQueueItem: ThemeWorkbenchRunTodoItem | null | undefined;
harnessPendingCount: number;
}
@@ -104,7 +94,7 @@ export function useWorkspaceHarnessInventoryRuntime({
return null;
}
const latestTerminal =
const latestTerminal: ThemeWorkbenchRunTerminalItem | null =
themeWorkbenchBackendRunState?.latest_terminal ?? null;
const activeRun = themeWorkbenchActiveQueueItem ?? latestTerminal;
const artifactPaths =
@@ -42,9 +42,9 @@ interface UseWorkspaceImageWorkbenchEventRuntimeParams {
projectId?: string;
contentId?: string | null;
imageWorkbenchProviders: ImageWorkbenchProviderSummary[];
setImageWorkbenchSelectedProviderId: Dispatch<SetStateAction<string>>;
setImageWorkbenchSelectedModelId: Dispatch<SetStateAction<string>>;
setImageWorkbenchSelectedSize: Dispatch<SetStateAction<string>>;
setImageWorkbenchSelectedProviderId: (providerId: string) => void;
setImageWorkbenchSelectedModelId: (modelId: string) => void;
setImageWorkbenchSelectedSize: (size: string) => void;
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>;
setCanvasState: Dispatch<SetStateAction<CanvasStateUnion | null>>;
updateCurrentImageWorkbenchState: (
@@ -6,6 +6,12 @@ import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime
import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime";
import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./useWorkspaceThemeWorkbenchSidebarRuntime";
import type { Message } from "../types";
import type { LayoutMode } from "@/components/content-creator/types";
import {
DEFAULT_CHAT_TOOL_PREFERENCES,
type ChatToolPreferences,
} from "../utils/chatToolPreferences";
type InputbarScenePresentationParams = Parameters<
typeof useWorkspaceInputbarScenePresentation
@@ -74,7 +80,7 @@ interface UseWorkspaceInputbarSceneRuntimeParams {
handleEnableSuggestedTeam: InputbarParams["onEnableSuggestedTeam"];
handleClearMessages: InputbarParams["onClearMessages"];
handleToggleCanvas: InputbarParams["onToggleCanvas"];
layoutMode: FloatingTeamWorkspaceDockParams["layoutMode"];
layoutMode: LayoutMode;
handleTaskFileClick: InputbarParams["onTaskFileClick"];
characters: InputbarParams["characters"];
skills: InputbarParams["skills"];
@@ -88,15 +94,17 @@ interface UseWorkspaceInputbarSceneRuntimeParams {
threadRead: GeneralWorkbenchDialogParams["threadRead"];
pendingActions: GeneralWorkbenchDialogParams["pendingActions"];
submittedActionsInFlight: GeneralWorkbenchDialogParams["submittedActionsInFlight"];
messages: Message[];
queuedTurns: InputbarParams["queuedTurns"];
resumeThread: GeneralWorkbenchDialogParams["onResumeThread"];
replayPendingAction?: (
requestId: string,
assistantMessageId: string,
) => boolean | Promise<boolean>;
promoteQueuedTurn: InputbarParams["onPromoteQueuedTurn"];
promoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
removeQueuedTurn: InputbarParams["onRemoveQueuedTurn"];
latestAssistantMessageId: string | null;
sessionIdForDiagnostics: string | null;
themeWorkbenchEntryPrompt: InputbarPresentationParams["themeWorkbenchEntryPrompt"];
handleRestartThemeWorkbenchEntryPrompt: InputbarPresentationParams["onRestartThemeWorkbenchEntryPrompt"];
handleContinueThemeWorkbenchEntryPrompt: InputbarPresentationParams["onContinueThemeWorkbenchEntryPrompt"];
@@ -110,7 +118,7 @@ interface UseWorkspaceInputbarSceneRuntimeParams {
handleFileClick: GeneralWorkbenchDialogParams["onOpenFile"];
shellChromeRuntime: ShellChromeRuntime;
handleActivateTeamWorkbench: FloatingTeamWorkspaceDockParams["onActivateWorkbench"];
chatToolPreferences: InputbarParams["toolStates"];
chatToolPreferences?: ChatToolPreferences;
}
export function useWorkspaceInputbarSceneRuntime({
@@ -169,12 +177,14 @@ export function useWorkspaceInputbarSceneRuntime({
threadRead,
pendingActions,
submittedActionsInFlight,
messages,
queuedTurns,
resumeThread,
replayPendingAction,
promoteQueuedTurn,
removeQueuedTurn,
latestAssistantMessageId,
sessionIdForDiagnostics,
themeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
handleContinueThemeWorkbenchEntryPrompt,
@@ -190,6 +200,11 @@ export function useWorkspaceInputbarSceneRuntime({
handleActivateTeamWorkbench,
chatToolPreferences,
}: UseWorkspaceInputbarSceneRuntimeParams) {
const resolvedQueuedTurns = queuedTurns ?? [];
const resolvedChatToolPreferences =
chatToolPreferences ?? DEFAULT_CHAT_TOOL_PREFERENCES;
const dockLayoutMode = layoutMode === "chat" ? "chat" : "chat-canvas";
return useWorkspaceInputbarScenePresentation({
setMentionedCharacters,
taskFiles,
@@ -199,14 +214,14 @@ export function useWorkspaceInputbarSceneRuntime({
isThemeWorkbench,
inputbarPresentation: {
teamWorkbench: {
shellVisible: chatToolPreferences.subagent,
shellVisible: resolvedChatToolPreferences.subagent,
currentSessionId: sessionId,
currentSessionName: teamSessionRuntime.currentSessionTitle,
currentSessionRuntimeStatus:
teamSessionRuntime.currentSessionRuntimeStatus,
currentSessionLatestTurnStatus:
teamSessionRuntime.currentSessionLatestTurnStatus,
currentSessionQueuedTurnCount: queuedTurns.length,
currentSessionQueuedTurnCount: resolvedQueuedTurns.length,
childSubagentSessions,
subagentParentContext,
liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId,
@@ -247,7 +262,7 @@ export function useWorkspaceInputbarSceneRuntime({
themeWorkbenchRunState,
onSend: handleSend,
onStop: teamSessionControlRuntime.handleStopSending,
isLoading: isSending || queuedTurns.length > 0,
isLoading: isSending || resolvedQueuedTurns.length > 0,
providerType,
setProviderType,
model,
@@ -268,17 +283,21 @@ export function useWorkspaceInputbarSceneRuntime({
characters,
skills,
isSkillsLoading: skillsLoading,
toolStates: chatToolPreferences,
toolStates: resolvedChatToolPreferences,
onToolStatesChange: setChatToolPreferences,
onNavigateToSettings: handleNavigateToSkillSettings,
onRefreshSkills: handleRefreshSkills,
queuedTurns,
onPromoteQueuedTurn: promoteQueuedTurn,
queuedTurns: resolvedQueuedTurns,
onPromoteQueuedTurn: promoteQueuedTurn
? async (queuedTurnId: string) => {
return Boolean(await promoteQueuedTurn(queuedTurnId));
}
: undefined,
onRemoveQueuedTurn: removeQueuedTurn,
},
floatingTeamWorkspaceDock: {
enabled: teamSessionRuntime.showTeamWorkspaceBoard,
layoutMode,
layoutMode: dockLayoutMode,
showFloatingInputOverlay:
shellChromeRuntime.shouldShowThemeWorkbenchFloatingInputOverlay,
onActivateWorkbench: handleActivateTeamWorkbench,
@@ -305,7 +324,8 @@ export function useWorkspaceInputbarSceneRuntime({
currentTurnId,
pendingActions,
submittedActionsInFlight,
queuedTurns,
messages,
queuedTurns: resolvedQueuedTurns,
canInterrupt: isSending,
onInterruptCurrentTurn: teamSessionControlRuntime.handleStopSending,
onResumeThread: resumeThread,
@@ -315,12 +335,21 @@ export function useWorkspaceInputbarSceneRuntime({
replayPendingAction(requestId, latestAssistantMessageId)
: undefined,
onPromoteQueuedTurn: promoteQueuedTurn,
diagnosticRuntimeContext: {
sessionId: sessionIdForDiagnostics,
workspaceId: projectId,
providerType: providerType || null,
model: model || null,
executionStrategy: executionStrategy || null,
activeTheme: activeTheme || null,
selectedTeamLabel: selectedTeamLabel || null,
},
toolInventory: harnessInventoryRuntime.toolInventory,
toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading,
toolInventoryError: harnessInventoryRuntime.toolInventoryError,
onRefreshToolInventory: harnessInventoryRuntime.refreshToolInventory,
activeTheme: mappedTheme,
toolPreferences: chatToolPreferences,
toolPreferences: resolvedChatToolPreferences,
isSending,
runtimeStatusTitle: contextHarnessRuntime.activeRuntimeStatusTitle,
selectedTeamRoleCount: selectedTeam?.roles.length || 0,
@@ -5,6 +5,7 @@ import { updateProject as updateProjectById } from "@/lib/api/project";
import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import type { WorkspacePathMissingState } from "../hooks/agentChatShared";
interface UseWorkspaceNavigationActionsParams {
applyProjectSelection: (projectId: string) => void;
@@ -15,7 +16,7 @@ interface UseWorkspaceNavigationActionsParams {
projectId?: string;
setEntryBannerVisible: Dispatch<SetStateAction<boolean>>;
setWorkspaceHealthError: Dispatch<SetStateAction<boolean>>;
workspacePathMissing: boolean;
workspacePathMissing: WorkspacePathMissingState | boolean | null;
}
export function useWorkspaceNavigationActions({
@@ -5,7 +5,7 @@ import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview";
interface UseWorkspaceRuntimeTeamDispatchPreviewRuntimeParams {
messagesLength: number;
runtimeTeamState: TeamWorkspaceRuntimeFormationState | null;
sessionId?: string;
sessionId?: string | null;
}
export function useWorkspaceRuntimeTeamDispatchPreviewRuntime({
@@ -45,7 +45,7 @@ const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist";
type ExecutionStrategy = "react" | "code_orchestrated" | "auto";
type ChatMode = "agent" | "general" | "creator";
type SetStringState = Dispatch<SetStateAction<string>>;
type SetStringState = (value: string) => void;
interface ContextWorkspaceSummary {
enabled: boolean;
@@ -71,16 +71,16 @@ interface UseWorkspaceSendActionsParams {
isThemeWorkbench: boolean;
contextWorkspace: ContextWorkspaceSummary;
runtimeStyleMessagePrompt: string;
projectId?: string;
sessionId?: string;
projectId?: string | null;
sessionId?: string | null;
providerType: string;
model: string;
setModel: SetStringState;
executionStrategy: ExecutionStrategy;
preferredTeamPresetId?: string | null;
selectedTeam?: TeamDefinition | null;
selectedTeamLabel: string;
selectedTeamSummary: string;
selectedTeamLabel?: string;
selectedTeamSummary?: string;
currentGateKey: string;
themeWorkbenchActiveQueueTitle?: string;
contentId?: string | null;
@@ -109,7 +109,7 @@ interface UseWorkspaceSendActionsParams {
ensureBrowserAssistCanvas: (
target: string,
options?: EnsureBrowserAssistCanvasOptions,
) => Promise<unknown>;
) => Promise<boolean>;
handleImageWorkbenchCommand: (input: {
rawText: string;
parsedCommand: NonNullable<ReturnType<typeof parseImageWorkbenchCommand>>;
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef } from "react";
import type { TaskFile } from "../components/TaskFiles";
import type { CreationMode } from "@/components/content-creator/types";
import type { CreationMode, ThemeType } from "@/components/content-creator/types";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
import { normalizeInitialTheme } from "../agentChatWorkspaceShared";
import { normalizeSessionTaskFileType } from "./themeWorkbenchHelpers";
interface SessionFileSummary {
@@ -18,14 +19,14 @@ interface SessionMetaSummary {
}
interface UseWorkspaceSessionRestoreParams {
sessionId?: string;
sessionId?: string | null;
sessionMeta?: SessionMetaSummary | null;
lockTheme: boolean;
initialTheme?: string;
sessionFiles: SessionFileSummary[];
readSessionFile: (name: string) => Promise<string | null | undefined>;
taskFilesLength: number;
setActiveTheme: (theme: string) => void;
setActiveTheme: (theme: ThemeType) => void;
setCreationMode: (mode: CreationMode) => void;
setTaskFiles: (files: TaskFile[]) => void;
}
@@ -75,7 +76,7 @@ export function useWorkspaceSessionRestore({
);
} else {
console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme);
setActiveTheme(sessionMeta.theme);
setActiveTheme(normalizeInitialTheme(sessionMeta.theme));
}
}
@@ -126,14 +127,14 @@ export function useWorkspaceSessionRestore({
id: crypto.randomUUID(),
name: file.name,
type: normalizeSessionTaskFileType(
file.fileType,
file.fileType ?? "other",
file.name,
content,
),
content,
version: 1,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
createdAt: file.createdAt ?? Date.now(),
updatedAt: file.updatedAt ?? file.createdAt ?? Date.now(),
});
}
} catch (err) {
@@ -1,3 +1,4 @@
import type { ComponentProps } from "react";
import { WorkspaceShellScene } from "./WorkspaceShellScene";
import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime";
import { useWorkspaceThemeWorkbenchShellRuntime } from "./useWorkspaceThemeWorkbenchShellRuntime";
@@ -8,6 +9,7 @@ type ThemeWorkbenchShellRuntime = ReturnType<
type ConversationSceneRuntime = ReturnType<
typeof useWorkspaceConversationSceneRuntime
>;
type WorkspaceShellSceneProps = ComponentProps<typeof WorkspaceShellScene>;
interface UseWorkspaceShellSceneRuntimeParams {
compactChrome: boolean;
@@ -16,21 +18,21 @@ interface UseWorkspaceShellSceneRuntimeParams {
showSidebar: boolean;
themeWorkbenchShellRuntime: ThemeWorkbenchShellRuntime;
conversationSceneRuntime: ConversationSceneRuntime;
sessionId?: string;
topics: Array<{ id: string; title: string }>;
handleBackHome: () => void;
switchTopic: (topicId: string) => Promise<void> | void;
handleResumeSidebarTask: (taskId: string) => void;
deleteTopic: (topicId: string) => Promise<void> | void;
renameTopic: (topicId: string, title: string) => Promise<void> | void;
displayMessages: Array<unknown>;
isSending: boolean;
sessionId?: WorkspaceShellSceneProps["currentTopicId"];
topics: WorkspaceShellSceneProps["topics"];
handleBackHome: WorkspaceShellSceneProps["onNewChat"];
switchTopic: WorkspaceShellSceneProps["onSwitchTopic"];
handleResumeSidebarTask: WorkspaceShellSceneProps["onResumeTask"];
deleteTopic: WorkspaceShellSceneProps["onDeleteTopic"];
renameTopic: WorkspaceShellSceneProps["onRenameTopic"];
displayMessages: WorkspaceShellSceneProps["currentMessages"];
isSending: WorkspaceShellSceneProps["isSending"];
pendingActionCount: number;
queuedTurnCount: number;
childSubagentSessions: Array<unknown>;
subagentParentContext: unknown;
handleOpenSubagentSession: (sessionId: string) => void;
handleReturnToParentSession: () => void;
childSubagentSessions: WorkspaceShellSceneProps["childSubagentSessions"];
subagentParentContext: WorkspaceShellSceneProps["subagentParentContext"];
handleOpenSubagentSession: WorkspaceShellSceneProps["onOpenSubagentSession"];
handleReturnToParentSession: WorkspaceShellSceneProps["onReturnToParentSession"];
}
export function useWorkspaceShellSceneRuntime({
@@ -71,7 +73,7 @@ export function useWorkspaceShellSceneRuntime({
themeWorkbenchShellRuntime.onExpandThemeWorkbenchSidebar
}
mainAreaNode={conversationSceneRuntime.mainAreaNode}
currentTopicId={sessionId}
currentTopicId={sessionId ?? null}
topics={topics}
onNewChat={handleBackHome}
onSwitchTopic={switchTopic}
@@ -15,7 +15,7 @@ interface WorkspaceSessionTopicSummary {
}
interface UseWorkspaceTeamSessionRuntimeParams {
sessionId?: string;
sessionId?: string | null;
topics: WorkspaceSessionTopicSummary[];
turns: Array<{ status: string }>;
queuedTurnCount: number;
@@ -12,7 +12,7 @@ interface UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams {
hasRealTeamGraph: boolean;
layoutMode: LayoutMode;
runtimeTeamRequestId: string | null;
sessionId?: string;
sessionId?: string | null;
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>;
}
@@ -27,7 +27,7 @@ type ThemeWorkbenchRuntimeState =
interface UseWorkspaceThemeWorkbenchRuntimeParams {
isThemeWorkbench: boolean;
sessionId?: string;
sessionId?: string | null;
isSending: boolean;
pendingActionRequest: ActionRequired | null;
}
@@ -53,8 +53,8 @@ function normalizeThemeWorkbenchCreationTaskEvent(
interface UseWorkspaceThemeWorkbenchScaffoldRuntimeParams {
isThemeWorkbench: boolean;
mappedTheme: ThemeType;
sessionId?: string;
projectId?: string;
sessionId?: string | null;
projectId?: string | null;
canvasState: CanvasStateUnion | null;
documentVersionStatusMap: Record<string, TopicBranchStatus>;
setDocumentVersionStatusMap: Dispatch<
@@ -205,7 +205,7 @@ export function useWorkspaceThemeWorkbenchScaffoldRuntime({
const { branchItems, setTopicStatus } = useTopicBranchBoard({
enabled: isThemeWorkbench && canvasState?.type === "document",
projectId,
projectId: projectId ?? undefined,
currentTopicId: currentVersionId,
topics: versionTopics,
externalStatusMap: documentVersionStatusMap,
@@ -27,7 +27,7 @@ import {
interface UseWorkspaceThemeWorkbenchSidebarRuntimeParams {
isThemeWorkbench: boolean;
sessionId?: string;
sessionId?: string | null;
messages: Message[];
isSending: boolean;
themeWorkbenchBackendRunState: BackendThemeWorkbenchRunState | null;
@@ -35,7 +35,7 @@ interface ThemeWorkbenchActiveQueueSummary {
}
interface UseWorkspaceWriteFileActionParams {
activeTheme: ThemeType;
activeTheme: string;
artifacts: Artifact[];
contentId?: string | null;
currentGateKey: string;
@@ -51,7 +51,7 @@ interface UseWorkspaceWriteFileActionParams {
setDocumentVersionStatusMap: Dispatch<
SetStateAction<Record<string, TopicBranchStatus>>
>;
saveSessionFile: (fileName: string, content: string) => Promise<void>;
saveSessionFile: (fileName: string, content: string) => Promise<unknown>;
syncGeneralArtifactToResource: (input: {
rawFilePath: string;
preferredName?: string;
@@ -64,7 +64,7 @@ interface UseWorkspaceWriteFileActionParams {
aiOutput: { fileName: string; preview: string };
}) => void;
setTaskFiles: Dispatch<SetStateAction<TaskFile[]>>;
setSelectedFileId: Dispatch<SetStateAction<string | null>>;
setSelectedFileId: (fileId: string) => void;
setCanvasState: Dispatch<SetStateAction<CanvasStateUnion | null>>;
upsertNovelCanvasState: (
previous: CanvasStateUnion | null,
+27
View File
@@ -290,6 +290,21 @@ describe("Agent API 治理护栏", () => {
mockSafeInvoke.mockResolvedValueOnce({
thread_id: "thread-runtime",
status: "waiting_request",
diagnostics: {
latest_turn_status: "aborted",
warning_count: 2,
context_compaction_count: 1,
failed_tool_call_count: 1,
failed_command_count: 0,
pending_request_count: 0,
primary_blocking_kind: "context_risk",
latest_warning: {
item_id: "warning-1",
code: "context_compaction_accuracy",
message: "长对话和多次上下文压缩会降低模型准确性",
updated_at: "2026-03-23T10:00:00Z",
},
},
queued_turns: [
{
queued_turn_id: "queued-turn-1",
@@ -303,6 +318,18 @@ describe("Agent API 治理护栏", () => {
await expect(getAgentRuntimeThreadRead("session-runtime")).resolves.toMatchObject({
thread_id: "thread-runtime",
status: "waiting_request",
diagnostics: {
latest_turn_status: "aborted",
warning_count: 2,
context_compaction_count: 1,
failed_tool_call_count: 1,
failed_command_count: 0,
pending_request_count: 0,
primary_blocking_kind: "context_risk",
latest_warning: {
code: "context_compaction_accuracy",
},
},
queued_turns: [
expect.objectContaining({
queued_turn_id: "queued-turn-1",
+71
View File
@@ -139,6 +139,76 @@ export interface AgentRuntimeIncidentView {
cleared_at?: string | number;
}
export interface AgentRuntimeDiagnosticWarningSample {
item_id: string;
turn_id?: string;
code?: string;
message: string;
updated_at: string | number;
}
export interface AgentRuntimeDiagnosticContextCompactionSample {
item_id: string;
turn_id?: string;
stage: string;
trigger?: string;
detail?: string;
updated_at: string | number;
}
export interface AgentRuntimeDiagnosticFailedToolSample {
item_id: string;
turn_id?: string;
tool_name: string;
error?: string;
updated_at: string | number;
}
export interface AgentRuntimeDiagnosticFailedCommandSample {
item_id: string;
turn_id?: string;
command: string;
exit_code?: number;
error?: string;
updated_at: string | number;
}
export interface AgentRuntimeDiagnosticPendingRequestSample {
request_id: string;
turn_id?: string;
request_type: string;
title?: string;
waited_seconds?: number;
created_at?: string | number;
}
export interface AgentRuntimeThreadDiagnostics {
latest_turn_status?: string;
latest_turn_started_at?: string | number;
latest_turn_completed_at?: string | number;
latest_turn_updated_at?: string | number;
latest_turn_elapsed_seconds?: number;
latest_turn_stalled_seconds?: number;
latest_turn_error_message?: string;
interrupt_reason?: string;
runtime_interrupt_source?: string;
runtime_interrupt_requested_at?: string | number;
runtime_interrupt_wait_seconds?: number;
warning_count: number;
context_compaction_count: number;
failed_tool_call_count: number;
failed_command_count: number;
pending_request_count: number;
oldest_pending_request_wait_seconds?: number;
primary_blocking_kind?: string;
primary_blocking_summary?: string;
latest_warning?: AgentRuntimeDiagnosticWarningSample | null;
latest_context_compaction?: AgentRuntimeDiagnosticContextCompactionSample | null;
latest_failed_tool?: AgentRuntimeDiagnosticFailedToolSample | null;
latest_failed_command?: AgentRuntimeDiagnosticFailedCommandSample | null;
latest_pending_request?: AgentRuntimeDiagnosticPendingRequestSample | null;
}
export interface AgentRuntimeThreadReadModel {
thread_id: string;
status?: string;
@@ -149,6 +219,7 @@ export interface AgentRuntimeThreadReadModel {
queued_turns?: QueuedTurnSnapshot[];
interrupt_state?: string;
updated_at?: string | number;
diagnostics?: AgentRuntimeThreadDiagnostics | null;
}
export interface AsterSubagentSessionInfo {
+532
View File
@@ -0,0 +1,532 @@
export type ServiceSkillSource = "cloud_catalog" | "local_custom";
export type ServiceSkillRunnerType = "instant" | "scheduled" | "managed";
export type ServiceSkillExecutionLocation =
| "client_default"
| "cloud_required";
export type ServiceSkillExecutorBinding =
| "native_skill"
| "agent_turn"
| "browser_assist"
| "automation_job"
| "cloud_scene";
export type ServiceSkillSlotType =
| "text"
| "textarea"
| "url"
| "enum"
| "platform"
| "schedule_time"
| "account_list";
export interface ServiceSkillSlotOption {
value: string;
label: string;
}
export interface ServiceSkillSlotDefinition {
key: string;
label: string;
type: ServiceSkillSlotType;
required: boolean;
placeholder: string;
defaultValue?: string;
helpText?: string;
options?: ServiceSkillSlotOption[];
}
export interface ServiceSkillReadinessRequirements {
requiresModel?: boolean;
requiresBrowser?: boolean;
requiresSkillKey?: string;
requiresProject?: boolean;
}
export interface ServiceSkillItem {
id: string;
title: string;
summary: string;
category: string;
outputHint: string;
source: ServiceSkillSource;
runnerType: ServiceSkillRunnerType;
defaultExecutorBinding: ServiceSkillExecutorBinding;
executionLocation: ServiceSkillExecutionLocation;
readinessRequirements?: ServiceSkillReadinessRequirements;
slotSchema: ServiceSkillSlotDefinition[];
themeTarget?: string;
version: string;
}
export interface ServiceSkillCatalog {
version: string;
tenantId: string;
syncedAt: string;
items: ServiceSkillItem[];
}
const SERVICE_SKILL_CATALOG_STORAGE_KEY = "lime:service-skill-catalog:v1";
const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-03-24";
const PLATFORM_OPTIONS: ServiceSkillSlotOption[] = [
{ value: "xiaohongshu", label: "小红书" },
{ value: "douyin", label: "抖音" },
{ value: "x", label: "X / Twitter" },
{ value: "bilibili", label: "Bilibili" },
{ value: "general", label: "通用平台" },
];
const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
tenantId: "local-seeded",
syncedAt: "2026-03-24T00:00:00.000Z",
items: [
{
id: "carousel-post-replication",
title: "复制轮播帖",
summary: "拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。",
category: "社媒内容",
outputHint: "轮播结构 + 文案初稿",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "native_skill",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "social-media",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "reference_post",
label: "参考帖子",
type: "textarea",
required: true,
placeholder: "粘贴参考轮播帖链接、正文或结构摘要",
},
{
key: "delivery_mode",
label: "执行方式",
type: "enum",
required: true,
defaultValue: "one_to_one",
placeholder: "选择复制方式",
options: [
{ value: "one_to_one", label: "1:1 复刻" },
{ value: "expand", label: "同风格扩写" },
],
},
{
key: "platform",
label: "发布平台",
type: "platform",
required: true,
defaultValue: "xiaohongshu",
placeholder: "选择发布平台",
options: PLATFORM_OPTIONS,
},
{
key: "must_keep",
label: "必须保留的信息",
type: "textarea",
required: false,
placeholder: "例如品牌名、核心结论、活动信息",
},
],
},
{
id: "short-video-script-replication",
title: "复制短视频脚本",
summary: "围绕参考视频的结构和节奏,输出一版可直接继续加工的脚本。",
category: "视频创作",
outputHint: "脚本大纲 + 镜头节奏",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "video",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "reference_video",
label: "参考视频链接/素材",
type: "url",
required: true,
placeholder: "输入视频链接,或粘贴素材描述",
},
{
key: "script_mode",
label: "脚本模式",
type: "enum",
required: true,
defaultValue: "replicate",
placeholder: "选择脚本模式",
options: [
{ value: "replicate", label: "贴近原结构" },
{ value: "expand", label: "同风格扩展" },
],
},
{
key: "platform",
label: "发布平台",
type: "platform",
required: true,
defaultValue: "douyin",
placeholder: "选择发布平台",
options: PLATFORM_OPTIONS,
},
{
key: "focus_changes",
label: "重点调整点",
type: "textarea",
required: false,
placeholder: "例如语气更克制、减少夸张表述、加强转化 CTA",
},
],
},
{
id: "article-to-slide-video-outline",
title: "文章转 Slide 视频提纲",
summary: "把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。",
category: "知识转化",
outputHint: "Slide 分镜 + 提纲结构",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "native_skill",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "knowledge",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "article_source",
label: "文章链接/正文",
type: "textarea",
required: true,
placeholder: "输入文章链接、正文,或文章摘要",
},
{
key: "target_duration",
label: "目标时长",
type: "text",
required: true,
defaultValue: "60-90 秒",
placeholder: "例如 60-90 秒",
},
{
key: "platform",
label: "发布平台",
type: "platform",
required: true,
defaultValue: "bilibili",
placeholder: "选择发布平台",
options: PLATFORM_OPTIONS,
},
],
},
{
id: "video-dubbing-language",
title: "视频配音成其他语言",
summary: "先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。",
category: "视频创作",
outputHint: "配音脚本 + 语言说明",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "video",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "video_source",
label: "视频链接/素材",
type: "url",
required: true,
placeholder: "输入视频链接,或补充素材说明",
},
{
key: "target_language",
label: "目标语言",
type: "text",
required: true,
defaultValue: "英文",
placeholder: "例如 英文、日文、西班牙语",
},
{
key: "subtitle_preference",
label: "字幕要求",
type: "enum",
required: false,
defaultValue: "keep_original",
placeholder: "选择字幕要求",
options: [
{ value: "keep_original", label: "保留原字幕" },
{ value: "bilingual", label: "中英双语字幕" },
{ value: "dub_only", label: "只做配音稿" },
],
},
],
},
{
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台、行业和地区,先产出一版趋势摘要和后续本地定时任务建议。",
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "social-media",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "platform",
label: "监测平台",
type: "platform",
required: true,
defaultValue: "x",
placeholder: "选择监测平台",
options: PLATFORM_OPTIONS,
},
{
key: "industry_keywords",
label: "行业关键词",
type: "textarea",
required: true,
placeholder: "例如 AI Agent、短剧出海、跨境电商",
},
{
key: "time_window",
label: "时间范围",
type: "text",
required: true,
defaultValue: "过去 24 小时",
placeholder: "例如 过去 24 小时、过去 7 天",
},
{
key: "region",
label: "地区",
type: "text",
required: false,
defaultValue: "全球",
placeholder: "例如 中国、北美、全球",
},
{
key: "schedule_time",
label: "推送时间",
type: "schedule_time",
required: false,
defaultValue: "每天 09:00",
placeholder: "例如 每天 09:00",
},
],
},
{
id: "account-performance-tracking",
title: "跟踪账号表现",
summary: "围绕指定账号先做一版表现分析,并整理后续本地跟踪任务需要的指标和告警条件。",
category: "社媒运营",
outputHint: "账号分析 + 跟踪指标",
source: "cloud_catalog",
runnerType: "managed",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
readinessRequirements: {
requiresModel: true,
requiresProject: true,
},
themeTarget: "social-media",
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
slotSchema: [
{
key: "platform",
label: "账号平台",
type: "platform",
required: true,
defaultValue: "x",
placeholder: "选择账号平台",
options: PLATFORM_OPTIONS,
},
{
key: "account_list",
label: "账号列表",
type: "account_list",
required: true,
placeholder: "每行一个账号,或用逗号分隔多个账号",
},
{
key: "report_cadence",
label: "回报频率",
type: "schedule_time",
required: false,
defaultValue: "每天 10:00",
placeholder: "例如 每天 10:00",
},
{
key: "alert_threshold",
label: "告警阈值",
type: "text",
required: false,
placeholder: "例如 日增粉低于 1% 或互动率骤降 20%",
},
],
},
],
};
function isSlotOptionArray(value: unknown): value is ServiceSkillSlotOption[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
item &&
typeof item === "object" &&
typeof (item as ServiceSkillSlotOption).value === "string" &&
typeof (item as ServiceSkillSlotOption).label === "string",
)
);
}
function isServiceSkillSlotDefinition(
value: unknown,
): value is ServiceSkillSlotDefinition {
if (!value || typeof value !== "object") {
return false;
}
const slot = value as Partial<ServiceSkillSlotDefinition>;
return (
typeof slot.key === "string" &&
typeof slot.label === "string" &&
typeof slot.type === "string" &&
typeof slot.required === "boolean" &&
typeof slot.placeholder === "string" &&
(slot.defaultValue === undefined || typeof slot.defaultValue === "string") &&
(slot.helpText === undefined || typeof slot.helpText === "string") &&
(slot.options === undefined || isSlotOptionArray(slot.options))
);
}
function isServiceSkillItem(value: unknown): value is ServiceSkillItem {
if (!value || typeof value !== "object") {
return false;
}
const item = value as Partial<ServiceSkillItem>;
return (
typeof item.id === "string" &&
typeof item.title === "string" &&
typeof item.summary === "string" &&
typeof item.category === "string" &&
typeof item.outputHint === "string" &&
typeof item.source === "string" &&
typeof item.runnerType === "string" &&
typeof item.defaultExecutorBinding === "string" &&
typeof item.executionLocation === "string" &&
Array.isArray(item.slotSchema) &&
item.slotSchema.every(isServiceSkillSlotDefinition) &&
typeof item.version === "string"
);
}
function isServiceSkillCatalog(value: unknown): value is ServiceSkillCatalog {
if (!value || typeof value !== "object") {
return false;
}
const catalog = value as Partial<ServiceSkillCatalog>;
return (
typeof catalog.version === "string" &&
typeof catalog.tenantId === "string" &&
typeof catalog.syncedAt === "string" &&
Array.isArray(catalog.items) &&
catalog.items.every(isServiceSkillItem)
);
}
function readCachedServiceSkillCatalog(): ServiceSkillCatalog | null {
if (typeof window === "undefined") {
return null;
}
try {
const raw = window.localStorage.getItem(SERVICE_SKILL_CATALOG_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!isServiceSkillCatalog(parsed)) {
return null;
}
return parsed;
} catch {
return null;
}
}
function persistServiceSkillCatalog(catalog: ServiceSkillCatalog): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
SERVICE_SKILL_CATALOG_STORAGE_KEY,
JSON.stringify(catalog),
);
} catch {
// ignore local cache errors
}
}
export function getSeededServiceSkillCatalog(): ServiceSkillCatalog {
return {
...SEEDED_SERVICE_SKILL_CATALOG,
items: SEEDED_SERVICE_SKILL_CATALOG.items.map((item) => ({
...item,
slotSchema: item.slotSchema.map((slot) => ({
...slot,
options: slot.options ? [...slot.options] : undefined,
})),
readinessRequirements: item.readinessRequirements
? { ...item.readinessRequirements }
: undefined,
})),
};
}
export async function getServiceSkillCatalog(): Promise<ServiceSkillCatalog> {
const cached = readCachedServiceSkillCatalog();
if (cached && cached.version === SEEDED_SERVICE_SKILL_CATALOG_VERSION) {
return cached;
}
const seeded = getSeededServiceSkillCatalog();
persistServiceSkillCatalog(seeded);
return seeded;
}
export async function listServiceSkills(): Promise<ServiceSkillItem[]> {
const catalog = await getServiceSkillCatalog();
return catalog.items.filter((item) => item.source === "cloud_catalog");
}