mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-29 18:16:06 +08:00
chore: finalize v0.95.0 release
This commit is contained in:
@@ -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({
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user