chore: release v0.96.0

This commit is contained in:
coso
2026-03-25 22:04:38 +08:00
parent 307b2d3670
commit 0ee56c1098
328 changed files with 40949 additions and 6948 deletions
+11 -2
View File
@@ -26,6 +26,7 @@ import { showRegistryLoadError } from "./lib/utils/connectError";
import { useDeepLink } from "./hooks/useDeepLink";
import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { useServiceSkillCatalogBootstrap } from "./hooks/useServiceSkillCatalogBootstrap";
import { useSiteAdapterCatalogBootstrap } from "./hooks/useSiteAdapterCatalogBootstrap";
import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync";
import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync";
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
@@ -33,6 +34,7 @@ import { SoundProvider } from "./contexts/SoundProvider";
import { ComponentDebugOverlay } from "./components/dev";
import {
AgentPageParams,
AutomationPageParams,
getThemeByWorkspacePage,
getThemeWorkspacePage,
isThemeWorkspacePage,
@@ -246,6 +248,7 @@ function AppContent() {
} | null>(null);
useServiceSkillCatalogBootstrap();
useSiteAdapterCatalogBootstrap();
useOemLimeHubProviderSync();
useGlobalTrayModelSync({
currentPage,
@@ -575,7 +578,10 @@ function AppContent() {
flexDirection: "column",
}}
>
<AutomationPage onNavigate={handleNavigate} />
<AutomationPage
onNavigate={handleNavigate}
pageParams={pageParams as AutomationPageParams}
/>
</div>
);
}
@@ -595,6 +601,9 @@ function AppContent() {
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
initialRequestMetadata={
(pageParams as AgentPageParams).initialRequestMetadata
}
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
@@ -698,7 +707,7 @@ function AppContent() {
if (currentPage === "browser-runtime") {
return (
<PageWrapper $isActive={true}>
<BrowserRuntimeWorkspace active={true} />
<BrowserRuntimeWorkspace active={true} onNavigate={handleNavigate} />
</PageWrapper>
);
}
+113 -27
View File
@@ -91,19 +91,81 @@ function scheduleSidebarPluginLoad(task: () => void): () => void {
};
}
const Container = styled.aside<{ $collapsed?: boolean }>`
const Container = styled.aside<{
$collapsed?: boolean;
$themeMode: "light" | "dark";
}>`
--sidebar-surface-top: ${({ $themeMode }) =>
$themeMode === "dark" ? "#15202b" : "#eef6f0"};
--sidebar-surface-middle: ${({ $themeMode }) =>
$themeMode === "dark" ? "#17232d" : "#f5f5ef"};
--sidebar-surface-bottom: ${({ $themeMode }) =>
$themeMode === "dark" ? "#1a2530" : "#f4f8f8"};
--sidebar-foreground: ${({ $themeMode }) =>
$themeMode === "dark" ? "#eef4f7" : "#1f2937"};
--sidebar-muted: ${({ $themeMode }) =>
$themeMode === "dark" ? "#a1afbd" : "#60707d"};
--sidebar-border: ${({ $themeMode }) =>
$themeMode === "dark" ? "#2d3a46" : "#d8e4dc"};
--sidebar-divider: ${({ $themeMode }) =>
$themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" : "rgba(148, 163, 184, 0.18)"};
--sidebar-hover: ${({ $themeMode }) =>
$themeMode === "dark" ? "#22303c" : "#e4ede6"};
--sidebar-active: ${({ $themeMode }) =>
$themeMode === "dark" ? "#2a3e3b" : "#d9e9df"};
--sidebar-active-foreground: ${({ $themeMode }) =>
$themeMode === "dark" ? "#dff4ea" : "#1b4332"};
--sidebar-search-bg: ${({ $themeMode }) =>
$themeMode === "dark" ? "#1f2b36" : "#fbfcf9"};
--sidebar-search-hover: ${({ $themeMode }) =>
$themeMode === "dark" ? "#24313d" : "#f2f6f2"};
--sidebar-search-border-hover: ${({ $themeMode }) =>
$themeMode === "dark" ? "#3a4a57" : "#cfded4"};
display: flex;
flex-direction: column;
width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")};
min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")};
height: 100vh;
padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")};
background-color: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
position: relative;
isolation: isolate;
background:
linear-gradient(
180deg,
var(--sidebar-surface-top) 0%,
var(--sidebar-surface-middle) 34%,
var(--sidebar-surface-bottom) 100%
);
border-right: 1px solid var(--sidebar-border);
box-shadow: 16px 0 36px -34px rgba(15, 23, 42, 0.42);
transition:
width 180ms ease,
min-width 180ms ease,
padding 180ms ease;
&::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(
circle at top left,
rgba(88, 160, 123, 0.14) 0%,
rgba(88, 160, 123, 0) 44%
),
radial-gradient(
circle at bottom left,
rgba(109, 153, 219, 0.12) 0%,
rgba(109, 153, 219, 0) 36%
);
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
`;
const HeaderArea = styled.div<{ $collapsed?: boolean }>`
@@ -135,11 +197,14 @@ const UserButton = styled.button<{ $collapsed?: boolean }>`
border-radius: 10px;
padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")};
cursor: pointer;
color: hsl(var(--foreground));
color: var(--sidebar-foreground);
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
background: hsl(var(--muted) / 0.55);
background: var(--sidebar-hover);
}
`;
@@ -149,6 +214,9 @@ const Avatar = styled.div`
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
box-shadow:
0 8px 18px -14px rgba(15, 23, 42, 0.35),
inset 0 0 0 1px rgba(255, 255, 255, 0.78);
img {
width: 100%;
@@ -174,21 +242,29 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>`
gap: 8px;
width: 100%;
height: 34px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
border-radius: 12px;
border: 1px solid var(--sidebar-border);
background: var(--sidebar-search-bg);
color: var(--sidebar-muted);
padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")};
cursor: pointer;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
&:hover {
border-color: hsl(var(--primary) / 0.35);
color: hsl(var(--foreground));
border-color: var(--sidebar-search-border-hover);
background: var(--sidebar-search-hover);
color: var(--sidebar-foreground);
}
span {
font-size: 13px;
font-weight: 500;
display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
}
`;
@@ -209,7 +285,7 @@ const MenuScroll = styled.div`
}
&::-webkit-scrollbar-thumb {
background: hsl(var(--border));
background: var(--sidebar-border);
border-radius: 9999px;
}
`;
@@ -225,7 +301,7 @@ const SectionTitle = styled.div<{ $collapsed?: boolean }>`
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: hsl(var(--muted-foreground));
color: var(--sidebar-muted);
opacity: 0.9;
display: ${({ $collapsed }) => ($collapsed ? "none" : "block")};
`;
@@ -237,26 +313,31 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>`
width: 100%;
height: 38px;
border: none;
border-radius: 10px;
border-radius: 12px;
padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")};
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
$active ? "var(--sidebar-active)" : "transparent"};
color: ${({ $active }) =>
$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"};
cursor: pointer;
transition: all 0.18s ease;
transition:
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
&:hover {
background: hsl(var(--accent));
color: hsl(var(--foreground));
background: ${({ $active }) =>
$active ? "var(--sidebar-active)" : "var(--sidebar-hover)"};
color: ${({ $active }) =>
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"};
}
svg {
width: 17px;
height: 17px;
flex-shrink: 0;
opacity: 0.9;
opacity: ${({ $active }) => ($active ? 1 : 0.92)};
}
`;
@@ -274,7 +355,7 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>`
const FooterArea = styled.div<{ $collapsed?: boolean }>`
margin-top: auto;
padding-top: 10px;
border-top: 1px solid hsl(var(--border));
border-top: 1px solid var(--sidebar-divider);
display: flex;
flex-direction: column;
gap: 8px;
@@ -291,19 +372,24 @@ const IconActionButton = styled.button<{ $active?: boolean }>`
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
$active ? "var(--sidebar-active)" : "transparent"};
color: ${({ $active }) =>
$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"};
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
background: hsl(var(--accent));
color: hsl(var(--foreground));
background: ${({ $active }) =>
$active ? "var(--sidebar-active)" : "var(--sidebar-hover)"};
color: ${({ $active }) =>
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"};
}
svg {
@@ -637,7 +723,7 @@ export function AppSidebar({
return (
<TooltipProvider>
<Container $collapsed={collapsed}>
<Container $collapsed={collapsed} $themeMode={theme}>
<HeaderArea $collapsed={collapsed}>
<HeaderTopRow $collapsed={collapsed}>
{maybeWrapWithTooltip(
@@ -18,7 +18,9 @@ const {
mockSetModel,
mockSetProviderType,
mockLoadConfiguredProviders,
mockLoadProviderModels,
mockGetModelRegistry,
mockGetProviderAliasConfig,
mockFetchProviderModelsAuto,
mockFilterModelsByTheme,
mockSaveChatToolPreferences,
mockPrepareClawSolution,
@@ -27,6 +29,14 @@ const {
mockClawSolutions,
mockUseServiceSkills,
mockRecordServiceSkillUsage,
mockRecordServiceSkillAutomationLink,
mockCreateServiceSkillRun,
mockGetServiceSkillRun,
mockIsTerminalServiceSkillRunStatus,
mockToastLoading,
mockToastSuccess,
mockToastError,
mockToastInfo,
mockServiceSkills,
} = vi.hoisted(() => {
const mockClawSolutions = [
@@ -90,6 +100,7 @@ const {
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
automationStatus: null,
},
{
id: "daily-trend-briefing",
@@ -134,23 +145,41 @@ const {
isRecent: false,
runnerLabel: "本地计划任务",
runnerTone: "sky",
runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。",
runnerDescription:
"当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
automationStatus: {
jobId: "automation-job-daily-trend",
jobName: "每日趋势摘要",
statusLabel: "成功",
tone: "emerald",
detail: "下次 03/24 09:00",
},
},
];
const mockRecordClawSolutionUsage = vi.fn();
const mockRecordServiceSkillUsage = vi.fn();
const mockRecordServiceSkillAutomationLink = vi.fn();
const mockCreateServiceSkillRun = vi.fn();
const mockGetServiceSkillRun = vi.fn();
const mockIsTerminalServiceSkillRunStatus = vi.fn();
const mockToastLoading = vi.fn();
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
const mockToastInfo = 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,
})),
mockCreateAutomationJob: vi.fn(
async (request: Record<string, unknown>) => ({
id: "automation-job-1",
...request,
}),
),
mockHomeShellProviderType: { current: "mock-provider" },
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
@@ -174,9 +203,15 @@ const {
mockLoadConfiguredProviders: vi.fn(
async (): Promise<ConfiguredProvider[]> => [],
),
mockLoadProviderModels: vi.fn(
mockGetModelRegistry: vi.fn(
async (): Promise<EnhancedModelMetadata[]> => [],
),
mockGetProviderAliasConfig: vi.fn(async () => null),
mockFetchProviderModelsAuto: vi.fn(async () => ({
models: [],
source: "LocalFallback",
error: null,
})),
mockFilterModelsByTheme: vi.fn(
(_theme: string | undefined, models: unknown[]) => ({
models,
@@ -202,8 +237,17 @@ const {
error: null,
refresh: vi.fn(),
recordUsage: mockRecordServiceSkillUsage,
catalogMeta: null,
})),
mockRecordServiceSkillUsage,
mockRecordServiceSkillAutomationLink,
mockCreateServiceSkillRun,
mockGetServiceSkillRun,
mockIsTerminalServiceSkillRunStatus,
mockToastLoading,
mockToastSuccess,
mockToastError,
mockToastInfo,
mockServiceSkills,
};
});
@@ -315,16 +359,35 @@ vi.mock("@/lib/api/automation", () => ({
createAutomationJob: mockCreateAutomationJob,
}));
vi.mock("@/lib/api/serviceSkillRuns", () => ({
createServiceSkillRun: mockCreateServiceSkillRun,
getServiceSkillRun: mockGetServiceSkillRun,
isTerminalServiceSkillRunStatus: mockIsTerminalServiceSkillRunStatus,
}));
vi.mock("@/lib/api/project", () => ({
listProjects: mockListProjects,
}));
vi.mock("sonner", () => ({
toast: {
loading: mockToastLoading,
success: mockToastSuccess,
error: mockToastError,
info: mockToastInfo,
},
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
}));
vi.mock("@/hooks/useProviderModels", () => ({
loadProviderModels: mockLoadProviderModels,
vi.mock("@/lib/api/modelRegistry", () => ({
modelRegistryApi: {
getModelRegistry: mockGetModelRegistry,
getProviderAliasConfig: mockGetProviderAliasConfig,
fetchProviderModelsAuto: mockFetchProviderModelsAuto,
},
}));
vi.mock("./utils/modelThemePolicy", () => ({
@@ -362,24 +425,48 @@ vi.mock("./service-skills/useServiceSkills", () => ({
useServiceSkills: mockUseServiceSkills,
}));
vi.mock("./service-skills/automationLinkStorage", () => ({
recordServiceSkillAutomationLink: mockRecordServiceSkillAutomationLink,
}));
vi.mock("./service-skills/ServiceSkillHomePanel", () => ({
ServiceSkillHomePanel: ({
skills,
onSelect,
onOpenAutomationJob,
}: {
skills: Array<{ id: string; title: string }>;
skills: Array<{
id: string;
title: string;
automationStatus?: { jobId: string } | null;
}>;
onSelect: (skill: { id: string; title: string }) => void;
onOpenAutomationJob?: (skill: {
id: string;
title: string;
automationStatus?: { jobId: string } | null;
}) => void;
}) => (
<>
{skills.map((skill) => (
<button
key={skill.id}
type="button"
data-testid={`home-shell-service-skill-${skill.id}`}
onClick={() => onSelect(skill)}
>
{skill.title}
</button>
<React.Fragment key={skill.id}>
<button
type="button"
data-testid={`home-shell-service-skill-${skill.id}`}
onClick={() => onSelect(skill)}
>
{skill.title}
</button>
{skill.automationStatus && onOpenAutomationJob ? (
<button
type="button"
data-testid={`home-shell-service-skill-open-automation-${skill.id}`}
onClick={() => onOpenAutomationJob(skill)}
>
打开任务
</button>
) : null}
</React.Fragment>
))}
</>
),
@@ -444,105 +531,110 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
) : 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
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,
web_search:
typeof initialValues?.web_search === "boolean"
? initialValues.web_search
: false,
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),
},
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,
}));
})
}
>
提交自动化
</button>
</div>
) : null,
}),
);
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
@@ -568,9 +660,16 @@ beforeEach(() => {
error: null,
refresh: vi.fn(),
recordUsage: mockRecordServiceSkillUsage,
catalogMeta: null,
}));
mockLoadConfiguredProviders.mockResolvedValue([]);
mockLoadProviderModels.mockResolvedValue([]);
mockGetModelRegistry.mockResolvedValue([]);
mockGetProviderAliasConfig.mockResolvedValue(null);
mockFetchProviderModelsAuto.mockResolvedValue({
models: [],
source: "LocalFallback",
error: null,
});
mockListProjects.mockResolvedValue([
{
id: "project-1",
@@ -593,6 +692,22 @@ beforeEach(() => {
policyName: "mock",
}),
);
mockCreateServiceSkillRun.mockResolvedValue({
id: "service-skill-run-1",
status: "success",
outputSummary: "云端结果已生成",
});
mockGetServiceSkillRun.mockResolvedValue({
id: "service-skill-run-1",
status: "success",
});
mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) =>
["success", "failed", "canceled", "timeout"].includes(status),
);
mockToastLoading.mockImplementation(() => "toast-loading");
mockToastSuccess.mockImplementation(() => undefined);
mockToastError.mockImplementation(() => undefined);
mockToastInfo.mockImplementation(() => undefined);
});
afterEach(() => {
@@ -745,7 +860,7 @@ describe("AgentChatHomeShell", () => {
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
mockGetModelRegistry.mockResolvedValueOnce([
{
id: "social-model-1",
display_name: "Social Model 1",
@@ -862,7 +977,7 @@ describe("AgentChatHomeShell", () => {
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
mockGetModelRegistry.mockResolvedValueOnce([
{
id: "custom-social-model",
display_name: "Custom Social Model",
@@ -925,11 +1040,8 @@ describe("AgentChatHomeShell", () => {
expect(mockSetProviderType).not.toHaveBeenCalled();
expect(mockSetModel).toHaveBeenCalledWith("custom-social-model");
expect(mockLoadProviderModels).toHaveBeenCalledWith(
expect.objectContaining({
key: "custom-social-provider",
}),
);
expect(mockGetModelRegistry).toHaveBeenCalled();
expect(mockFetchProviderModelsAuto).not.toHaveBeenCalled();
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
@@ -1031,9 +1143,8 @@ describe("AgentChatHomeShell", () => {
projectId: "project-1",
theme: "video",
initialCreationMode: "guided",
initialUserPrompt: expect.stringContaining(
"[服务型技能] 复制短视频脚本",
),
initialUserPrompt:
expect.stringContaining("[服务型技能] 复制短视频脚本"),
}),
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
@@ -1049,6 +1160,84 @@ describe("AgentChatHomeShell", () => {
});
});
it("cloud_required 服务型技能应提交云端运行且不进入本地工作区", async () => {
const onEnterWorkspace = vi.fn();
mockUseServiceSkills.mockImplementation(() => ({
skills: [
{
...mockServiceSkills[0],
id: "cloud-video-dubbing",
title: "云端视频配音",
executionLocation: "cloud_required",
defaultExecutorBinding: "cloud_scene",
runnerLabel: "云端托管执行",
runnerTone: "slate",
runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。",
actionLabel: "提交云端",
},
],
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordServiceSkillUsage,
catalogMeta: null,
}));
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-cloud-video-dubbing"]',
) 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(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[服务型技能] 云端视频配音"),
);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("- 参考视频链接/素材: https://example.com/video"),
);
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
});
expect(mockToastLoading).toHaveBeenCalledWith(
"正在提交 云端视频配音 到云端...",
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"云端视频配音 云端运行完成:云端结果已生成",
{
id: "toast-loading",
},
);
});
it("点击定时服务型技能创建任务后应先建本地 automation 再进入工作区", async () => {
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
@@ -1124,12 +1313,41 @@ describe("AgentChatHomeShell", () => {
initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"),
}),
);
expect(mockRecordServiceSkillAutomationLink).toHaveBeenCalledWith({
skillId: "daily-trend-briefing",
jobId: "automation-job-1",
jobName: expect.any(String),
});
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "daily-trend-briefing",
runnerType: "scheduled",
});
});
it("点击服务型技能任务状态后应跳转到 automation 对应任务", async () => {
const onNavigate = vi.fn();
const { container } = renderShell({
onNavigate,
});
await flushEffects();
const openAutomationButton = container.querySelector(
'[data-testid="home-shell-service-skill-open-automation-daily-trend-briefing"]',
) as HTMLButtonElement | null;
expect(openAutomationButton).toBeTruthy();
act(() => {
openAutomationButton?.click();
});
expect(onNavigate).toHaveBeenCalledWith("automation", {
selectedJobId: "automation-job-daily-trend",
workspaceTab: "tasks",
});
});
it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
@@ -3,6 +3,12 @@ import styled from "styled-components";
import { toast } from "sonner";
import { createAutomationJob } from "@/lib/api/automation";
import { prepareClawSolution } from "@/lib/api/clawSolutions";
import {
createServiceSkillRun,
getServiceSkillRun,
isTerminalServiceSkillRunStatus,
type ServiceSkillRun,
} from "@/lib/api/serviceSkillRuns";
import { listProjects, type Project } from "@/lib/api/project";
import {
AutomationJobDialog,
@@ -50,6 +56,7 @@ import {
buildServiceSkillAutomationInitialValues,
supportsServiceSkillLocalAutomation,
} from "./service-skills/automationDraft";
import { recordServiceSkillAutomationLink } from "./service-skills/automationLinkStorage";
import type {
ServiceSkillHomeItem,
ServiceSkillSlotValues,
@@ -214,6 +221,37 @@ function prioritizeAutomationWorkspaces(
return [fallbackWorkspace, ...remaining];
}
const SERVICE_SKILL_RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "运行中",
success: "已完成",
failed: "执行失败",
canceled: "已取消",
timeout: "已超时",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function getServiceSkillRunStatusLabel(status: string): string {
return SERVICE_SKILL_RUN_STATUS_LABELS[status] ?? status;
}
function buildServiceSkillRunSuccessMessage(
skill: ServiceSkillHomeItem,
run: ServiceSkillRun,
): string {
const summary = run.outputSummary || run.outputText || run.inputSummary;
if (summary) {
return `${skill.title} 云端运行完成:${summary}`;
}
return `${skill.title} 云端运行完成。`;
}
interface PendingServiceSkillAutomationLaunch {
enterWorkspacePayload: HomeShellEnterWorkspacePayload;
usage: {
@@ -278,6 +316,7 @@ export function AgentChatHomeShell({
} = useClawSolutions(activeTheme === "general");
const {
skills: serviceSkills,
catalogMeta: serviceSkillCatalogMeta,
isLoading: serviceSkillsLoading,
error: serviceSkillsError,
recordUsage: recordServiceSkillUsage,
@@ -475,6 +514,21 @@ export function AgentChatHomeShell({
setServiceSkillDialogOpen(true);
}, []);
const handleOpenServiceSkillAutomationJob = useCallback(
(skill: ServiceSkillHomeItem) => {
const jobId = skill.automationStatus?.jobId;
if (!jobId || !onNavigate) {
return;
}
onNavigate("automation", {
selectedJobId: jobId,
workspaceTab: "tasks",
});
},
[onNavigate],
);
const handleServiceSkillLaunch = useCallback(
async (
skill: ServiceSkillHomeItem,
@@ -486,6 +540,64 @@ export function AgentChatHomeShell({
userInput: input.trim() || undefined,
});
if (skill.executionLocation === "cloud_required") {
const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`);
try {
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
let run = await createServiceSkillRun(skill.id, prompt);
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
if (!isTerminalServiceSkillRunStatus(run.status)) {
toast.loading(
`${skill.title} ${getServiceSkillRunStatusLabel(run.status)},正在等待结果...`,
{
id: toastId,
},
);
for (let attempt = 0; attempt < 12; attempt += 1) {
await sleep(2_000);
run = await getServiceSkillRun(run.id);
if (isTerminalServiceSkillRunStatus(run.status)) {
break;
}
}
}
if (run.status === "success") {
toast.success(buildServiceSkillRunSuccessMessage(skill, run), {
id: toastId,
});
return;
}
if (isTerminalServiceSkillRunStatus(run.status)) {
throw new Error(
run.errorMessage ||
`${skill.title} ${getServiceSkillRunStatusLabel(run.status)}`,
);
}
toast.info(
`${skill.title} 已提交云端,当前仍在 ${getServiceSkillRunStatusLabel(run.status)}。`,
{
id: toastId,
},
);
} catch (error) {
toast.error(`提交云端运行失败:${getErrorMessage(error)}`, {
id: toastId,
});
}
return;
}
if (skill.runnerType !== "instant") {
toast.info("当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。");
}
@@ -605,6 +717,11 @@ export function AgentChatHomeShell({
return;
}
recordServiceSkillAutomationLink({
skillId: pendingLaunch.usage.skillId,
jobId: createdJob.id,
jobName: createdJob.name,
});
recordServiceSkillUsage(pendingLaunch.usage);
const entered = handleEnterWorkspace(pendingLaunch.enterWorkspacePayload);
if (!entered) {
@@ -730,8 +847,10 @@ export function AgentChatHomeShell({
<>
<ServiceSkillHomePanel
skills={serviceSkills}
catalogMeta={serviceSkillCatalogMeta}
loading={serviceSkillsLoading}
onSelect={handleServiceSkillSelect}
onOpenAutomationJob={handleOpenServiceSkillAutomationJob}
/>
<ClawHomeSolutionsPanel
solutions={clawSolutions}
+170 -33
View File
@@ -79,6 +79,7 @@ import { resolveMediaGenerationPreference } from "@/lib/mediaGeneration";
import type {
Message,
SiteSavedContentTarget,
WriteArtifactContext,
} from "./types";
import type {
@@ -94,6 +95,9 @@ import {
mergeArtifacts,
resolveDefaultArtifactViewMode,
} from "./utils/messageArtifacts";
import {
createChatToolPreferencesFromExecutionRuntime,
} from "./utils/sessionExecutionRuntime";
import {
buildRealSubagentTimelineItems,
buildSyntheticSubagentTimelineItems,
@@ -167,6 +171,9 @@ import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspace
import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
import {
createInitialSessionImageWorkbenchState,
type SessionImageWorkbenchState,
@@ -200,6 +207,7 @@ export function AgentChatWorkspace({
onNavigate: _onNavigate,
projectId: externalProjectId,
contentId,
initialRequestMetadata,
agentEntry = "claw",
theme: initialTheme,
initialCreationMode,
@@ -245,7 +253,11 @@ export function AgentChatWorkspace({
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const { chatToolPreferences, setChatToolPreferences } =
const {
chatToolPreferences,
setChatToolPreferences,
syncChatToolPreferencesSource,
} =
useThemeScopedChatToolPreferences(activeTheme);
const {
projectId,
@@ -593,6 +605,15 @@ export function AgentChatWorkspace({
useState<CanvasWorkbenchLayoutMode>("split");
const [browserTaskPreflight, setBrowserTaskPreflight] =
useState<BrowserTaskPreflight | null>(null);
const [focusedArtifactBlockId, setFocusedArtifactBlockId] = useState<
string | null
>(null);
const [artifactBlockFocusRequestKey, setArtifactBlockFocusRequestKey] =
useState(0);
const [focusedTimelineItemId, setFocusedTimelineItemId] = useState<
string | null
>(null);
const [timelineFocusRequestKey, setTimelineFocusRequestKey] = useState(0);
const autoCollapsedTopicSidebarRef = useRef(false);
useEffect(() => {
@@ -606,6 +627,17 @@ export function AgentChatWorkspace({
const handleNavigateToSkillSettings = useCallback(() => {
_onNavigate?.("settings", { tab: SettingsTabs.Skills });
}, [_onNavigate]);
const handleOpenSavedSiteContent = useCallback(
({ projectId, contentId }: SiteSavedContentTarget) => {
_onNavigate?.("agent", {
projectId,
contentId,
lockTheme: true,
fromResources: true,
});
},
[_onNavigate],
);
const handleRefreshSkills = useCallback(async () => {
await loadSkills(true);
@@ -1034,6 +1066,8 @@ export function AgentChatWorkspace({
subagentParentContext = null,
queuedTurns = [],
threadRead = null,
executionRuntime = null,
activeExecutionRuntime = null,
isSending,
sendMessage,
compactSession = async () => undefined,
@@ -1081,20 +1115,45 @@ export function AgentChatWorkspace({
}
void originalSwitchTopic(parentSessionId);
}, [originalSwitchTopic, subagentParentContext?.parent_session_id]);
const runtimeChatToolPreferences = useMemo(
() => createChatToolPreferencesFromExecutionRuntime(executionRuntime),
[executionRuntime],
);
useEffect(() => {
syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences);
}, [
activeTheme,
runtimeChatToolPreferences,
syncChatToolPreferencesSource,
]);
const hasRealTeamGraph =
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
const {
runtimeTeamState,
clearRuntimeTeamState,
clearRuntimeTeamState: clearPreparedRuntimeTeamState,
prepareRuntimeTeamBeforeSend,
} = useRuntimeTeamFormation({
activeTheme,
projectId,
sessionId,
selectedTeam,
subagentEnabled: chatToolPreferences.subagent,
hasRealTeamGraph,
});
const {
runtimeTeamDispatchPreview,
runtimeTeamPreviewState,
clearRuntimeTeamDispatchPreview,
setRuntimeTeamDispatchPreview,
} = useWorkspaceRuntimeTeamDispatchPreviewRuntime({
messagesLength: messages.length,
sessionId,
});
const teamDispatchPreviewState = runtimeTeamPreviewState;
const clearRuntimeTeamState = useCallback(() => {
clearPreparedRuntimeTeamState();
clearRuntimeTeamDispatchPreview();
}, [clearPreparedRuntimeTeamState, clearRuntimeTeamDispatchPreview]);
const imageWorkbenchSessionKey = useMemo(
() => sessionId?.trim() || "__local_image_workbench__",
[sessionId],
@@ -1136,7 +1195,6 @@ export function AgentChatWorkspace({
queuedTurnCount: queuedTurns.length,
isSending,
subagentEnabled: chatToolPreferences.subagent,
runtimeTeamState,
childSubagentSessions,
subagentParentContext,
});
@@ -1152,7 +1210,7 @@ export function AgentChatWorkspace({
} = useWorkspaceTeamWorkbenchAutoOpenRuntime({
hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph,
layoutMode,
runtimeTeamRequestId: runtimeTeamState?.requestId ?? null,
runtimeTeamRequestId: runtimeTeamDispatchPreview?.key ?? null,
sessionId,
setLayoutMode,
});
@@ -1452,12 +1510,12 @@ export function AgentChatWorkspace({
() =>
buildHarnessRequestMetadata({
theme: mappedTheme,
creationMode,
chatMode,
webSearchEnabled: chatToolPreferences.webSearch,
thinkingEnabled: chatToolPreferences.thinking,
taskModeEnabled: chatToolPreferences.task,
subagentModeEnabled: chatToolPreferences.subagent,
preferences: {
webSearch: chatToolPreferences.webSearch,
thinking: chatToolPreferences.thinking,
task: chatToolPreferences.task,
subagent: chatToolPreferences.subagent,
},
sessionMode: isThemeWorkbench ? "theme_workbench" : "default",
gateKey: isThemeWorkbench ? currentGate.key : undefined,
runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined,
@@ -1474,13 +1532,11 @@ export function AgentChatWorkspace({
selectedTeamRoles: selectedTeam?.roles,
}),
[
chatMode,
chatToolPreferences.subagent,
chatToolPreferences.task,
chatToolPreferences.thinking,
chatToolPreferences.webSearch,
contentId,
creationMode,
currentGate.key,
isThemeWorkbench,
mappedTheme,
@@ -1564,12 +1620,6 @@ export function AgentChatWorkspace({
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
const consumedInitialPromptRef = useRef<string | null>(null);
const { runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview } =
useWorkspaceRuntimeTeamDispatchPreviewRuntime({
messagesLength: messages.length,
runtimeTeamState,
sessionId,
});
const {
initialDispatchKey,
isBootstrapDispatchPending,
@@ -1766,8 +1816,6 @@ export function AgentChatWorkspace({
setChatToolPreferences,
activeTheme,
mappedTheme,
creationMode,
chatMode,
isThemeWorkbench,
contextWorkspace: {
enabled: contextWorkspace.enabled,
@@ -1775,10 +1823,6 @@ export function AgentChatWorkspace({
},
runtimeStyleMessagePrompt,
projectId,
sessionId,
providerType,
model,
setModel,
executionStrategy,
preferredTeamPresetId,
selectedTeam,
@@ -1787,6 +1831,7 @@ export function AgentChatWorkspace({
currentGateKey: currentGate.key,
themeWorkbenchActiveQueueTitle: themeWorkbenchActiveQueueItem?.title,
contentId,
workspaceRequestMetadataBase: initialRequestMetadata,
messagesCount: messages.length,
sendMessage,
resolveSendBoundary,
@@ -2035,6 +2080,48 @@ export function AgentChatWorkspace({
handleWriteFileRef.current = handleWriteFile;
}, [handleWriteFile]);
const handleSaveArtifactDocument = useCallback(
async (artifact: Artifact, document: ArtifactDocumentV1) => {
const filePath = resolveArtifactProtocolFilePath(artifact);
const serializedDocument = JSON.stringify(document, null, 2);
await Promise.resolve(
handleWriteFile(serializedDocument, filePath, {
artifactId: artifact.id,
source: "message_content",
status: "complete",
artifact: {
...artifact,
content: serializedDocument,
status: "complete",
meta: {
...artifact.meta,
artifactDocument: document,
language: "json",
filePath:
typeof artifact.meta.filePath === "string" &&
artifact.meta.filePath.trim()
? artifact.meta.filePath
: filePath,
filename:
typeof artifact.meta.filename === "string" &&
artifact.meta.filename.trim()
? artifact.meta.filename
: artifact.title,
},
updatedAt: Date.now(),
},
metadata: {
writePhase: "persisted",
previewText: document.summary || document.title,
lastUpdateSource: "message_content",
},
}),
);
},
[handleWriteFile],
);
const {
handleHarnessLoadFilePreview,
handleArtifactClick,
@@ -2063,6 +2150,44 @@ export function AgentChatWorkspace({
setCanvasState,
upsertNovelCanvasState,
});
const handleWorkspaceFileClick = useCallback(
(fileName: string, content: string) => {
setFocusedArtifactBlockId(null);
handleFileClick(fileName, content);
},
[handleFileClick],
);
const handleWorkspaceArtifactClick = useCallback(
(artifact: Artifact) => {
setFocusedArtifactBlockId(null);
handleArtifactClick(artifact);
},
[handleArtifactClick],
);
const handleOpenArtifactFromTimeline = useCallback(
(target: ArtifactTimelineOpenTarget) => {
handleWorkspaceFileClick(target.filePath, target.content);
const normalizedBlockId = target.blockId?.trim();
if (!normalizedBlockId) {
return;
}
setFocusedArtifactBlockId(normalizedBlockId);
setArtifactBlockFocusRequestKey((current) => current + 1);
},
[handleWorkspaceFileClick],
);
const handleJumpToTimelineItem = useCallback((itemId: string) => {
const normalizedItemId = itemId.trim();
if (!normalizedItemId) {
return;
}
setLayoutMode((current) => (current === "canvas" ? "chat-canvas" : current));
setFocusedTimelineItemId(normalizedItemId);
setTimelineFocusRequestKey((current) => current + 1);
}, []);
useWorkspaceAutoGuideRuntime({
contentId,
@@ -2123,7 +2248,7 @@ export function AgentChatWorkspace({
themeWorkbenchRunState,
currentGateStatus: currentGate.status,
hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph,
runtimeTeamState,
teamDispatchPreviewState,
});
const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({
showChatPanel,
@@ -2147,7 +2272,7 @@ export function AgentChatWorkspace({
selectedTeamRoles: selectedTeam?.roles,
handleOpenSubagentSession,
handleHarnessLoadFilePreview,
handleFileClick,
handleFileClick: handleWorkspaceFileClick,
});
useWorkspaceWorkflowProgressSync({
@@ -2180,7 +2305,7 @@ export function AgentChatWorkspace({
subagentParentContext,
selectedTeamLabel,
selectedTeamSummary,
runtimeTeamState,
teamDispatchPreviewState,
teamSessionRuntime,
teamSessionControlRuntime,
handleOpenSubagentSession,
@@ -2200,6 +2325,8 @@ export function AgentChatWorkspace({
setProviderType,
model,
setModel,
sessionExecutionRuntime: executionRuntime,
isExecutionRuntimeActive: Boolean(activeExecutionRuntime),
projectId: projectId ?? null,
executionStrategy,
setExecutionStrategy,
@@ -2222,6 +2349,7 @@ export function AgentChatWorkspace({
threadItems: effectiveThreadItems,
currentTurnId,
threadRead,
activeExecutionRuntime,
pendingActions,
submittedActionsInFlight,
messages: displayMessages,
@@ -2242,7 +2370,7 @@ export function AgentChatWorkspace({
harnessInventoryRuntime,
mappedTheme,
handleHarnessLoadFilePreview,
handleFileClick,
handleFileClick: handleWorkspaceFileClick,
shellChromeRuntime,
handleActivateTeamWorkbench,
chatToolPreferences,
@@ -2269,6 +2397,11 @@ export function AgentChatWorkspace({
setArtifactViewMode,
artifactPreviewSize,
setArtifactPreviewSize,
onSaveArtifactDocument: handleSaveArtifactDocument,
threadItems: effectiveThreadItems,
focusedBlockId: focusedArtifactBlockId,
blockFocusRequestKey: artifactBlockFocusRequestKey,
onJumpToTimelineItem: handleJumpToTimelineItem,
handleCloseCanvas,
currentImageWorkbenchState,
imageWorkbenchPreferenceSummary,
@@ -2297,7 +2430,7 @@ export function AgentChatWorkspace({
teamSessionRuntime,
teamSessionControlRuntime,
teamWorkbenchAutoFocusToken,
runtimeTeamState,
teamDispatchPreviewState,
});
const workspaceShellSceneRuntime =
@@ -2402,8 +2535,10 @@ export function AgentChatWorkspace({
editMessage,
handleA2UISubmit,
handleWriteFile,
handleFileClick,
handleArtifactClick,
handleFileClick: handleWorkspaceFileClick,
handleOpenArtifactFromTimeline,
handleOpenSavedSiteContent,
handleArtifactClick: handleWorkspaceArtifactClick,
handleOpenSubagentSession,
handlePermissionResponseWithBrowserPreflight,
pendingPromotedA2UIActionRequest,
@@ -2425,6 +2560,8 @@ export function AgentChatWorkspace({
setCanvasWorkbenchLayoutMode,
workspacePathMissing: Boolean(workspacePathMissing),
workspaceHealthError,
focusedTimelineItemId,
timelineFocusRequestKey,
});
return workspaceShellSceneRuntime.shellSceneNode;
@@ -16,6 +16,7 @@ export interface AgentChatWorkspaceProps {
onNavigate?: (page: Page, params?: PageParams) => void;
projectId?: string;
contentId?: string;
initialRequestMetadata?: Record<string, unknown>;
agentEntry?: "new-task" | "claw";
immersiveHome?: boolean;
theme?: string;
@@ -1,11 +1,18 @@
import React, { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
import type {
AsterSessionExecutionRuntime,
AsterSubagentSessionInfo,
} from "@/lib/api/agentRuntime";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
import {
getExecutionRuntimeDisplayLabel,
getOutputSchemaRuntimeLabel,
} from "../utils/sessionExecutionRuntime";
interface AgentRuntimeStripProps {
activeTheme?: string;
@@ -18,6 +25,8 @@ interface AgentRuntimeStripProps {
>;
variant?: "standalone" | "embedded";
isSending?: boolean;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
runtimeStatusTitle?: string | null;
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
@@ -50,6 +59,8 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
childSubagentSessions = [],
variant = "standalone",
isSending = false,
executionRuntime = null,
isExecutionRuntimeActive = false,
runtimeStatusTitle = null,
selectedTeamLabel = null,
selectedTeamSummary = null,
@@ -77,6 +88,13 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
const statusItems = useMemo<StatusItem[]>(() => {
const nextItems: StatusItem[] = [];
const executionRuntimeLabel = getExecutionRuntimeDisplayLabel(
executionRuntime,
{ active: isExecutionRuntimeActive },
);
const outputSchemaLabel = getOutputSchemaRuntimeLabel(
executionRuntime?.output_schema_runtime,
);
const runningTeamSessions = childSubagentSessions.filter(
(session) => session.runtime_status === "running",
).length;
@@ -99,6 +117,22 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
});
}
if (executionRuntimeLabel) {
nextItems.push({
key: "execution_runtime",
label: executionRuntimeLabel,
tone: isExecutionRuntimeActive ? "secondary" : "outline",
});
}
if (outputSchemaLabel) {
nextItems.push({
key: "output_schema_runtime",
label: `结构化输出 ${outputSchemaLabel}`,
tone: "outline",
});
}
if (harnessState.plan.phase === "planning") {
nextItems.push({
key: "planning",
@@ -182,7 +216,9 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
childSubagentSessions,
compatSubagentRuntime.isRunning,
compatSubagentRuntime.progress,
executionRuntime,
harnessState,
isExecutionRuntimeActive,
isSending,
runtimeStatusTitle,
]);
@@ -10,8 +10,29 @@ import type {
AgentThreadTurn,
} from "../types";
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
const parseAIResponseMock = vi.fn();
const mockToolCallItem = vi.fn(
({
toolCall,
onOpenSavedSiteContent,
}: {
toolCall: { name: string };
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => (
<div
data-testid="tool-call-item"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
>
{toolCall.name}
</div>
),
);
vi.mock("@/components/content-creator/a2ui/parser", () => ({
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
@@ -29,9 +50,14 @@ vi.mock("./A2UITaskCard", () => ({
}));
vi.mock("./ToolCallDisplay", () => ({
ToolCallItem: ({ toolCall }: { toolCall: { name: string } }) => (
<div data-testid="tool-call-item">{toolCall.name}</div>
),
ToolCallItem: (props: {
toolCall: { name: string };
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => mockToolCallItem(props),
}));
vi.mock("./DecisionPanel", () => ({
@@ -59,6 +85,7 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
HTMLElement.prototype.scrollIntoView = vi.fn();
parseAIResponseMock.mockImplementation((content: string) => ({
parts: content.trim() ? [{ type: "text", content: content.trim() }] : [],
hasA2UI: false,
@@ -133,7 +160,15 @@ function renderTimeline(
turn?: Partial<AgentThreadTurn>;
threadRead?: AgentRuntimeThreadReadModel | null;
actionRequests?: ActionRequired[];
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
focusedItemId?: string | null;
focusRequestKey?: number;
},
): HTMLDivElement {
const container = document.createElement("div");
@@ -148,7 +183,11 @@ function renderTimeline(
threadRead={props?.threadRead}
actionRequests={props?.actionRequests}
isCurrentTurn={props?.isCurrentTurn}
onOpenArtifactFromTimeline={props?.onOpenArtifactFromTimeline}
onOpenSavedSiteContent={props?.onOpenSavedSiteContent}
onOpenSubagentSession={props?.onOpenSubagentSession}
focusedItemId={props?.focusedItemId}
focusRequestKey={props?.focusRequestKey}
/>,
);
});
@@ -170,7 +209,126 @@ function clickTimelineToggle(container: HTMLElement) {
});
}
function createFileArtifactItem(
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>> = {},
): Extract<AgentThreadItem, { type: "file_artifact" }> {
return {
...createBaseItem("artifact-1", 1),
type: "file_artifact",
path: ".lime/artifacts/thread-1/demo.artifact.json",
source: "artifact_snapshot",
content: JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis",
title: "季度复盘",
status: "ready",
language: "zh-CN",
blocks: [
{ id: "hero-1", type: "hero_summary", summary: "摘要" },
{ id: "body-1", type: "rich_text", markdown: "正文" },
],
sources: [],
metadata: {},
}),
metadata: {
artifact_id: "artifact-document:demo",
artifact_block_id: ["hero-1", "body-1"],
},
...overrides,
};
}
describe("AgentThreadTimeline", () => {
it("file_artifact 命中多个 block 时应提供精确跳转按钮", () => {
const onOpenArtifactFromTimeline = vi.fn();
const container = renderTimeline([createFileArtifactItem()], {
onOpenArtifactFromTimeline,
});
clickTimelineToggle(container);
const heroJumpButton = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.includes("跳到 block hero-1"));
expect(heroJumpButton).not.toBeUndefined();
act(() => {
heroJumpButton?.click();
});
expect(onOpenArtifactFromTimeline).toHaveBeenCalledWith(
expect.objectContaining({
timelineItemId: "artifact-1",
filePath: ".lime/artifacts/thread-1/demo.artifact.json",
blockId: "hero-1",
}),
);
});
it("收到 timeline 聚焦请求时应自动展开并高亮目标项", () => {
const container = renderTimeline(
[
{
...createBaseItem("browser-1", 1),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#publish" },
},
],
{
turn: {
status: "completed",
},
focusedItemId: "browser-1",
focusRequestKey: 1,
},
);
expect(
container.querySelector('[data-testid="agent-thread-details"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).toBeNull();
const focusedEntry = container.querySelector<HTMLElement>(
'[data-thread-item-id="browser-1"]',
);
expect(focusedEntry?.className).toContain("ring-2");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});
it("应向时间线内的工具项透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
const container = renderTimeline(
[
{
...createBaseItem("site-tool-1", 1),
type: "tool_call",
tool_name: "lime_site_run",
arguments: { adapter_name: "github/search" },
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-1",
project_id: "project-1",
title: "GitHub 搜索结果",
},
},
},
],
{ onOpenSavedSiteContent },
);
clickTimelineToggle(container);
expect(mockToolCallItem).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("应在时间线头部展示当前 turn 的 compact outcome 与 incident 徽标", () => {
const container = renderTimeline(
[
@@ -22,12 +22,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
ConfirmResponse,
SiteSavedContentTarget,
} from "../types";
import {
buildAgentThreadDisplayModel,
@@ -53,6 +54,10 @@ import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
import { ToolCallItem } from "./ToolCallDisplay";
import { DecisionPanel } from "./DecisionPanel";
import { AgentPlanBlock } from "./AgentPlanBlock";
import {
resolveTimelineArtifactNavigation,
type ArtifactTimelineOpenTarget,
} from "../utils/artifactTimelineNavigation";
interface AgentThreadTimelineProps {
turn: AgentThreadTurn;
@@ -61,8 +66,12 @@ interface AgentThreadTimelineProps {
actionRequests?: ActionRequired[];
isCurrentTurn?: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
focusedItemId?: string | null;
focusRequestKey?: number;
}
interface TurnStatusMeta {
@@ -802,6 +811,8 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
function renderGroupItemDetails(
item: AgentThreadItem,
onFileClick?: (fileName: string, content: string) => void,
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void,
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void,
onOpenSubagentSession?: (sessionId: string) => void,
onPermissionResponse?: (response: ConfirmResponse) => void,
) {
@@ -869,37 +880,69 @@ function renderGroupItemDetails(
toolCall={toolCall}
defaultExpanded={item.status !== "completed"}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
</div>
);
}
if (item.type === "file_artifact") {
const navigation = resolveTimelineArtifactNavigation(item);
const blockTargets = navigation?.blockTargets || [];
const shouldOpenFocusedBlock =
Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1;
return (
<button
type="button"
className="w-full rounded-xl border border-border/70 bg-background/80 px-3 py-3 text-left transition-colors hover:bg-muted/40"
onClick={() => onFileClick?.(item.path, item.content || "")}
>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-foreground">{item.path}</div>
<Badge variant={resolveStatusBadgeVariant(item.status)} className="ml-auto">
{resolveArtifactSourceLabel(item.source)}
</Badge>
{timestamp ? (
<span className="text-xs text-muted-foreground">{timestamp}</span>
) : null}
</div>
{item.content?.trim() ? (
<div className="mt-2 line-clamp-4 whitespace-pre-wrap text-xs text-muted-foreground">
{item.content}
<div className="rounded-xl border border-border/70 bg-background/80 px-3 py-3">
<button
type="button"
className="w-full text-left transition-colors hover:bg-muted/40"
onClick={() => {
if (onOpenArtifactFromTimeline && navigation) {
onOpenArtifactFromTimeline(
shouldOpenFocusedBlock ? blockTargets[0] : navigation.rootTarget,
);
return;
}
onFileClick?.(item.path, item.content || "");
}}
>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-foreground">{item.path}</div>
<Badge variant={resolveStatusBadgeVariant(item.status)} className="ml-auto">
{resolveArtifactSourceLabel(item.source)}
</Badge>
{timestamp ? (
<span className="text-xs text-muted-foreground">{timestamp}</span>
) : null}
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">
点击在画布中打开文件
{item.content?.trim() ? (
<div className="mt-2 line-clamp-4 whitespace-pre-wrap text-xs text-muted-foreground">
{item.content}
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">
点击在画布中打开文件
</div>
)}
</button>
{onOpenArtifactFromTimeline && blockTargets.length > 1 ? (
<div className="mt-3 flex flex-wrap gap-2">
{blockTargets.slice(0, 4).map((target) => (
<button
key={`${item.id}:${target.blockId}`}
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700"
onClick={() => onOpenArtifactFromTimeline(target)}
>
跳到 block {target.blockId}
</button>
))}
</div>
)}
</button>
) : null}
</div>
);
}
@@ -1372,8 +1415,12 @@ function TimelineBlockCard({
emphasis,
isExpanded,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
focusedItemId,
focusRequestKey,
}: {
block: AgentThreadOrderedBlock;
index: number;
@@ -1381,8 +1428,12 @@ function TimelineBlockCard({
emphasis: "active" | "default" | "quiet";
isExpanded: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
focusedItemId?: string | null;
focusRequestKey?: number;
}) {
const Icon = resolveGroupIcon(block.kind);
const timestamp = formatTimestamp(block.startedAt);
@@ -1391,6 +1442,10 @@ function TimelineBlockCard({
const isActive = emphasis === "active";
const isQuiet = emphasis === "quiet";
const stageLabel = `步骤 ${String(index + 1).padStart(2, "0")}`;
const focusedEntryRef = useRef<HTMLDivElement | null>(null);
const hasFocusedItem = Boolean(
focusedItemId && block.items.some((item) => item.id === focusedItemId),
);
const detailEntries = block.items.flatMap((item) => {
const content =
block.kind === "thinking"
@@ -1398,6 +1453,8 @@ function TimelineBlockCard({
: renderGroupItemDetails(
item,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
);
@@ -1405,6 +1462,7 @@ function TimelineBlockCard({
return content ? [{ id: item.id, content }] : [];
});
const hasDetailEntries = detailEntries.length > 0;
const detailsExpanded = isExpanded || hasFocusedItem;
const cardClassName = isActive
? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10"
: isCompact
@@ -1420,6 +1478,17 @@ function TimelineBlockCard({
hasDetailEntries ? "cursor-pointer" : "cursor-default",
);
useEffect(() => {
if (!hasFocusedItem || !focusRequestKey) {
return;
}
focusedEntryRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, [focusRequestKey, hasFocusedItem]);
return (
<div
className="relative pl-14"
@@ -1465,7 +1534,7 @@ function TimelineBlockCard({
className={cardClassName}
data-testid={dataTestId}
data-emphasis={emphasis}
open={isExpanded}
open={detailsExpanded}
>
<summary className={interactiveSummaryClassName}>
<div className="min-w-0 flex-1">
@@ -1523,7 +1592,17 @@ function TimelineBlockCard({
data-testid={`${dataTestId}:details`}
>
{detailEntries.map((entry) => (
<div key={entry.id}>{entry.content}</div>
<div
key={entry.id}
data-thread-item-id={entry.id}
ref={entry.id === focusedItemId ? focusedEntryRef : null}
className={cn(
entry.id === focusedItemId &&
"rounded-2xl ring-2 ring-sky-200 ring-offset-2 ring-offset-white",
)}
>
{entry.content}
</div>
))}
</div>
</details>
@@ -1594,8 +1673,12 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
actionRequests = [],
isCurrentTurn = false,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
focusedItemId = null,
focusRequestKey = 0,
}) => {
const visibleItems = useMemo(
() =>
@@ -1670,6 +1753,24 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
() => buildCompactReliabilityBadges(turn, threadRead),
[threadRead, turn],
);
const hasFocusedItem = useMemo(
() =>
Boolean(
focusedItemId &&
displayModel.orderedBlocks.some((block) =>
block.items.some((item) => item.id === focusedItemId),
),
),
[displayModel.orderedBlocks, focusedItemId],
);
useEffect(() => {
if (!hasFocusedItem || focusRequestKey <= 0) {
return;
}
setDetailsExpanded(true);
}, [focusRequestKey, hasFocusedItem]);
if (visibleItems.length === 0) {
return null;
@@ -1983,8 +2084,12 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
}
isExpanded={expandedBlockIndexes.has(index)}
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
focusedItemId={focusedItemId}
focusRequestKey={focusRequestKey}
/>
))}
</div>
@@ -26,6 +26,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import { Badge } from "@/components/ui/badge";
import { CompactRightDockButton } from "@/components/ui/compact-right-dock-button";
import {
@@ -299,22 +300,14 @@ function resolvePreviousArtifactContent(
artifact: Artifact,
artifacts: Artifact[],
): string | null {
const currentPath = normalizePath(
typeof artifact.meta.filePath === "string"
? artifact.meta.filePath
: artifact.meta.filename || artifact.title,
);
const currentPath = normalizePath(resolveArtifactProtocolFilePath(artifact));
for (let index = artifacts.length - 1; index >= 0; index -= 1) {
const candidate = artifacts[index];
if (candidate.id === artifact.id) {
continue;
}
const candidatePath = normalizePath(
typeof candidate.meta.filePath === "string"
? candidate.meta.filePath
: candidate.meta.filename || candidate.title,
);
const candidatePath = normalizePath(resolveArtifactProtocolFilePath(candidate));
if (candidatePath === currentPath && candidate.content.trim()) {
return candidate.content;
}
@@ -352,9 +345,7 @@ function resolveMappedPreviousContentForPath(
const matchedArtifact = artifacts.find((artifact) => {
const artifactPath = resolveAbsoluteWorkspacePath(
workspaceRoot,
typeof artifact.meta.filePath === "string"
? artifact.meta.filePath
: artifact.meta.filename || artifact.title,
resolveArtifactProtocolFilePath(artifact),
);
return artifactPath ? normalizePath(artifactPath) === normalizedTarget : false;
});
@@ -374,10 +365,7 @@ function buildEntries(
.slice()
.reverse()
.map((artifact) => {
const filePath =
typeof artifact.meta.filePath === "string"
? artifact.meta.filePath
: artifact.meta.filename || artifact.title;
const filePath = resolveArtifactProtocolFilePath(artifact);
const writePhase = resolveArtifactWritePhase(artifact);
return {
key: `artifact:${artifact.id}`,
@@ -9,7 +9,7 @@ const {
mockListAgentRuntimeSessions,
mockGetAgentRuntimeSession,
mockUpdateAgentRuntimeSession,
mockParseStreamEvent,
mockParseAgentEvent,
mockSafeListen,
mockToast,
mockUseConfiguredProviders,
@@ -23,7 +23,7 @@ const {
mockListAgentRuntimeSessions: vi.fn(),
mockGetAgentRuntimeSession: vi.fn(),
mockUpdateAgentRuntimeSession: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
mockSafeListen: vi.fn(),
mockToast: {
success: vi.fn(),
@@ -53,9 +53,16 @@ vi.mock("@/lib/api/agentRuntime", async () => {
};
});
vi.mock("@/lib/api/agentStream", () => ({
parseStreamEvent: mockParseStreamEvent,
}));
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
};
});
vi.mock("@/lib/dev-bridge", async () => {
const actual = await vi.importActual<typeof import("@/lib/dev-bridge")>(
@@ -1,4 +1,5 @@
import styled, { keyframes } from "styled-components";
import type { KeyboardEvent, ReactNode } from "react";
import {
EMPTY_STATE_META_PILL_CLASSNAME,
EMPTY_STATE_PANEL_CLASSNAME,
@@ -75,7 +76,7 @@ const PresetButton = styled.button.attrs({
}
`;
const RecommendationCard = styled.button.attrs({
const RecommendationCard = styled.div.attrs({
className: EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME,
})<{ $index: number }>`
animation: ${itemReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
@@ -108,6 +109,9 @@ export interface EmptyStateQuickActionItem {
statusLabel?: string;
statusTone?: "slate" | "sky" | "emerald" | "amber";
statusDescription?: string;
secondaryStatusLabel?: string;
secondaryStatusTone?: "slate" | "sky" | "emerald" | "amber";
secondaryStatusDescription?: string;
testId?: string;
solutionId?: string;
}
@@ -122,12 +126,14 @@ export interface EmptyStateQuickPresetItem {
interface EmptyStateQuickActionsProps {
title: string;
description: string;
headerAddon?: ReactNode;
selectedTextPreview?: string;
presets?: EmptyStateQuickPresetItem[];
items: EmptyStateQuickActionItem[];
embedded?: boolean;
loading?: boolean;
onPresetAction?: (item: EmptyStateQuickPresetItem) => void;
onSecondaryStatusAction?: (item: EmptyStateQuickActionItem) => void;
onAction: (item: EmptyStateQuickActionItem) => void;
}
@@ -141,15 +147,17 @@ const STATUS_TONE_CLASSNAMES = {
export function EmptyStateQuickActions({
title,
description,
headerAddon,
selectedTextPreview,
presets = [],
items,
embedded = false,
loading = false,
onPresetAction,
onSecondaryStatusAction,
onAction,
}: EmptyStateQuickActionsProps) {
if (!loading && items.length === 0 && presets.length === 0) {
if (!loading && items.length === 0 && presets.length === 0 && !headerAddon) {
return null;
}
@@ -168,6 +176,7 @@ export function EmptyStateQuickActions({
<p className="mt-1 text-xs leading-5 text-slate-500 md:text-sm">
{description}
</p>
{headerAddon ? <div className="mt-2.5">{headerAddon}</div> : null}
</div>
</div>
@@ -204,15 +213,27 @@ export function EmptyStateQuickActions({
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-xs leading-5 text-slate-500">
正在加载推荐方案…
</div>
) : items.length === 0 ? (
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-xs leading-5 text-slate-500">
当前目录暂无可用项。
</div>
) : (
<div className="mt-2.5 grid gap-2 md:grid-cols-2">
{items.map((item, index) => (
<RecommendationCard
key={item.key}
$index={index}
type="button"
data-testid={item.testId}
role="button"
tabIndex={0}
onClick={() => onAction(item)}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
onAction(item);
}}
>
<div className="flex w-full items-start justify-between gap-3">
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
@@ -234,6 +255,38 @@ export function EmptyStateQuickActions({
产出:{item.outputHint}
</p>
) : null}
{item.secondaryStatusLabel ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
{onSecondaryStatusAction ? (
<button
type="button"
data-testid={
item.testId ? `${item.testId}-secondary-status` : undefined
}
className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-medium transition-colors hover:brightness-95 ${STATUS_TONE_CLASSNAMES[item.secondaryStatusTone ?? "slate"]}`}
title={item.secondaryStatusDescription ?? "打开对应任务"}
onClick={(event) => {
event.stopPropagation();
onSecondaryStatusAction(item);
}}
>
{item.secondaryStatusLabel}
</button>
) : (
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-medium ${STATUS_TONE_CLASSNAMES[item.secondaryStatusTone ?? "slate"]}`}
title={item.secondaryStatusDescription}
>
{item.secondaryStatusLabel}
</span>
)}
{item.secondaryStatusDescription ? (
<span className="text-[11px] leading-5 text-slate-400">
{item.secondaryStatusDescription}
</span>
) : null}
</div>
) : null}
{item.statusLabel ? (
<div className="mt-2 flex items-center justify-start">
<span
@@ -626,6 +626,28 @@ describe("HarnessStatusPanel", () => {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it("待审批区块应通过 artifact protocol 展示嵌套参数里的路径", () => {
renderPanel({
harnessState: createHarnessState({
pendingApprovals: [
{
requestId: "approval-path-1",
actionType: "tool_confirmation",
prompt: "确认写入主稿",
toolName: "write_file",
arguments: {
payload: {
filePath: "workspace/approval-draft.md",
},
},
},
],
}),
});
expect(document.body.textContent).toContain("workspace/approval-draft.md");
});
it("应渲染最近文件活动区块", () => {
renderPanel({
harnessState: createHarnessState({
@@ -58,6 +58,7 @@ import {
openPathWithDefaultApp,
revealPathInFinder,
} from "@/lib/api/fileSystem";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import type {
ActionRequired,
@@ -547,29 +548,7 @@ function matchesOutputFilter(
function pickPathFromArguments(
argumentsValue?: Record<string, unknown>,
): string | undefined {
if (!argumentsValue) {
return undefined;
}
for (const key of [
"path",
"filePath",
"file_path",
"fileName",
"file_name",
"filename",
"targetPath",
"target_path",
"outputPath",
"output_path",
]) {
const value = argumentsValue[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
return extractArtifactProtocolPathsFromValue(argumentsValue)[0];
}
function describeApproval(item: ActionRequired): string | undefined {
@@ -2,7 +2,10 @@ import React, { useState } from "react";
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import type { MessageImage } from "../../../types";
import { CharacterMention } from "./CharacterMention";
import { InputbarCore } from "./InputbarCore";
@@ -64,6 +67,8 @@ interface InputbarComposerSectionProps {
isThemeWorkbenchVariant: boolean;
activeTheme?: string;
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
setExecutionStrategy?: (
strategy: "react" | "code_orchestrated" | "auto",
) => void;
@@ -113,6 +118,8 @@ export const InputbarComposerSection: React.FC<
isThemeWorkbenchVariant,
activeTheme,
onManageProviders,
executionRuntime,
isExecutionRuntimeActive,
setExecutionStrategy,
topExtra,
queuedTurns,
@@ -294,6 +301,8 @@ export const InputbarComposerSection: React.FC<
setModel={inputAdapter.actions.setModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
/>
</>
}
@@ -1,5 +1,11 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime";
import { ChatModelSelector } from "../../ChatModelSelector";
import {
getExecutionRuntimeDisplayLabel,
getOutputSchemaRuntimeLabel,
} from "../../../utils/sessionExecutionRuntime";
interface InputbarModelExtraProps {
isFullscreen?: boolean;
@@ -10,6 +16,8 @@ interface InputbarModelExtraProps {
setModel?: (model: string) => void;
activeTheme?: string;
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
}
const NOOP_SET_PROVIDER_TYPE = (_type: string) => {};
@@ -24,11 +32,24 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
setModel,
activeTheme,
onManageProviders,
executionRuntime = null,
isExecutionRuntimeActive = false,
}) => {
if (isFullscreen || isThemeWorkbenchVariant || !providerType || !model) {
return null;
}
const executionRuntimeLabel = getExecutionRuntimeDisplayLabel(
executionRuntime,
{ active: isExecutionRuntimeActive },
);
const outputSchemaLabel = getOutputSchemaRuntimeLabel(
executionRuntime?.output_schema_runtime,
);
const executionRuntimeBadgeClass = isExecutionRuntimeActive
? "max-w-[220px] truncate border-emerald-200 bg-emerald-50 text-emerald-900"
: "max-w-[220px] truncate text-muted-foreground";
return (
<div className="flex items-center gap-2">
<ChatModelSelector
@@ -41,6 +62,24 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
popoverSide="top"
onManageProviders={onManageProviders}
/>
{executionRuntimeLabel ? (
<Badge
variant="outline"
className={executionRuntimeBadgeClass}
title={executionRuntimeLabel}
>
{executionRuntimeLabel}
</Badge>
) : null}
{outputSchemaLabel ? (
<Badge
variant="outline"
className="max-w-[180px] truncate text-muted-foreground"
title={`结构化输出 ${outputSchemaLabel}`}
>
结构化输出 {outputSchemaLabel}
</Badge>
) : null}
</div>
);
};
@@ -228,6 +228,37 @@ describe("Inputbar", () => {
expect(latestCall.skills).toEqual([]);
});
it("存在 executionRuntime 时应展示最近执行模型与结构化输出提示", async () => {
const container = renderInputbar({
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-5.4-mini",
setModel: vi.fn(),
executionRuntime: {
session_id: "session-1",
provider_selector: "openai",
provider_name: "openai",
model_name: "gpt-5.4",
source: "turn_context",
output_schema_runtime: {
source: "turn",
strategy: "native",
providerName: "openai",
modelName: "gpt-5.4",
},
},
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("最近执行模型 OpenAI · gpt-5.4");
expect(container.textContent).toContain(
"结构化输出 Native schema · turn contract",
);
});
it("受控模式下点击联网搜索应透传状态变更", async () => {
const onToolStatesChange = vi.fn();
const container = document.createElement("div");
@@ -2,7 +2,10 @@ import React from "react";
import type { MessageImage } from "../../types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import type { TaskFile } from "../TaskFiles";
import { InputbarComposerSection } from "./components/InputbarComposerSection";
import { InputbarOverlayShell } from "./components/InputbarOverlayShell";
@@ -70,6 +73,8 @@ interface InputbarProps {
setProviderType?: (type: string) => void;
model?: string;
setModel?: (model: string) => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
workspaceId?: string | null;
executionStrategy?: "react" | "code_orchestrated" | "auto";
setExecutionStrategy?: (
@@ -126,6 +131,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
setProviderType,
model,
setModel,
executionRuntime,
isExecutionRuntimeActive,
workspaceId,
executionStrategy,
setExecutionStrategy,
@@ -271,6 +278,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
providerType={providerType}
model={model}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
setExecutionStrategy={setExecutionStrategy}
topExtra={topExtra}
queuedTurns={queuedTurns}
@@ -14,20 +14,40 @@ vi.mock("./MarkdownRenderer", () => ({
const mockStreamingRenderer = vi.fn(
({
content,
onOpenSavedSiteContent,
}: {
content: string;
renderA2UIInline?: boolean;
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => (
<div data-testid="streaming-renderer">{content || "<empty-assistant>"}</div>
<div
data-testid="streaming-renderer"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
>
{content || "<empty-assistant>"}
</div>
),
);
const mockAgentThreadTimeline = vi.fn(
({
actionRequests,
onOpenSavedSiteContent,
}: {
actionRequests?: Array<Record<string, unknown>>;
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => (
<div data-testid="agent-thread-timeline">
<div
data-testid="agent-thread-timeline"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
>
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
</div>
),
@@ -155,6 +175,25 @@ describe("MessageList", () => {
);
});
it("应向助手消息正文透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-site-open",
role: "assistant",
content: "已保存站点结果。",
timestamp: now,
},
];
render(messages, { onOpenSavedSiteContent });
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("应按回合分组展示同一轮用户与后续助手回复", () => {
const now = new Date();
const messages: Message[] = [
@@ -448,6 +487,54 @@ describe("MessageList", () => {
]);
});
it("应向执行轨迹透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-site-timeline",
role: "assistant",
content: "站点结果已沉淀。",
timestamp: now,
},
];
render(messages, {
onOpenSavedSiteContent,
turns: [
{
id: "turn-site-open",
thread_id: "thread-1",
prompt_text: "采集站点内容",
status: "completed",
started_at: "2026-03-25T09:00:00Z",
completed_at: "2026-03-25T09:00:05Z",
created_at: "2026-03-25T09:00:00Z",
updated_at: "2026-03-25T09:00:05Z",
},
],
threadItems: [
{
id: "item-site-open-1",
thread_id: "thread-1",
turn_id: "turn-site-open",
sequence: 1,
status: "completed",
started_at: "2026-03-25T09:00:01Z",
completed_at: "2026-03-25T09:00:02Z",
updated_at: "2026-03-25T09:00:02Z",
type: "tool_call",
tool_name: "lime_site_run",
arguments: { adapter_name: "github/search" },
},
],
});
expect(mockAgentThreadTimeline).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("当前 turn 映射错位时,应优先显示在最后一个助手消息上", () => {
const messages: Message[] = [
{
@@ -12,6 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import type { Artifact } from "@/lib/artifact/types";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import {
MessageListContainer,
MessageWrapper,
@@ -43,6 +44,7 @@ import {
type ActionRequired,
type AgentThreadItem,
type AgentThreadTurn,
type SiteSavedContentTarget,
type WriteArtifactContext,
} from "../types";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
@@ -54,6 +56,7 @@ import type {
import { buildMessageTurnTimeline } from "../utils/threadTimelineView";
import { buildMessageTurnGroups } from "../utils/messageTurnGrouping";
import logoImg from "/logo.png";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
interface MessageListProps {
messages: Message[];
@@ -84,6 +87,10 @@ interface MessageListProps {
) => void;
/** 文件点击回调 */
onFileClick?: (fileName: string, content: string) => void;
/** 时间线内 artifact 精确跳转 */
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
/** 打开站点能力已保存内容 */
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
/** Artifact 点击回调 */
onArtifactClick?: (artifact: Artifact) => void;
/** 打开子代理会话 */
@@ -111,6 +118,10 @@ interface MessageListProps {
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
/** 是否压缩左侧留白,适用于工作台右栏 */
compactLeadingSpacing?: boolean;
/** 需要高亮的 timeline item */
focusedTimelineItemId?: string | null;
/** 触发 timeline item 聚焦的请求序号 */
timelineFocusRequestKey?: number;
}
const MessageListInner: React.FC<MessageListProps> = ({
@@ -128,6 +139,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
onA2UIFormChange,
onWriteFile,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onArtifactClick,
onOpenSubagentSession,
onPermissionResponse,
@@ -136,6 +149,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
onCodeBlockClick,
promoteActionRequestsToA2UI = false,
compactLeadingSpacing = false,
focusedTimelineItemId = null,
timelineFocusRequestKey = 0,
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -404,6 +419,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
: undefined
}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onPermissionResponse={onPermissionResponse}
collapseCodeBlocks={collapseCodeBlocks}
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
@@ -448,8 +464,12 @@ const MessageListInner: React.FC<MessageListProps> = ({
actionRequests={msg.actionRequests}
isCurrentTurn={timeline.turn.id === currentTurnId}
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
focusedItemId={focusedTimelineItemId}
focusRequestKey={timelineFocusRequestKey}
/>
) : null}
@@ -529,10 +549,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
return (
<div className="mt-3 flex flex-col gap-2">
{artifacts.map((artifact) => {
const filePath =
typeof artifact.meta.filePath === "string"
? artifact.meta.filePath
: artifact.meta.filename || artifact.title;
const filePath = resolveArtifactProtocolFilePath(artifact);
const writePhase = resolveArtifactWritePhase(artifact);
const statusLabel = formatArtifactWritePhaseLabel(writePhase);
const previewText = resolveArtifactPreviewText(artifact, 180);
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StreamingRenderer } from "./StreamingRenderer";
import type { AgentToolCallState } from "@/lib/api/agentProtocol";
import type {
AgentRuntimeStatus,
ActionRequired,
@@ -11,6 +12,38 @@ import type {
} from "../types";
const parseAIResponseMock = vi.fn();
const mockToolCallList = vi.fn(
({
onOpenSavedSiteContent,
}: {
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => (
<div
data-testid="tool-call-list"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
/>
),
);
const mockToolCallItem = vi.fn(
({
onOpenSavedSiteContent,
}: {
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => (
<div
data-testid="tool-call-item"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
/>
),
);
vi.mock("@/components/content-creator/a2ui/parser", () => ({
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
@@ -32,8 +65,20 @@ vi.mock("./A2UITaskCard", () => ({
}));
vi.mock("./ToolCallDisplay", () => ({
ToolCallList: () => <div data-testid="tool-call-list" />,
ToolCallItem: () => <div data-testid="tool-call-item" />,
ToolCallList: (props: {
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => mockToolCallList(props),
ToolCallItem: (props: {
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) => mockToolCallItem(props),
}));
vi.mock("./DecisionPanel", () => ({
@@ -96,6 +141,7 @@ function renderHarness(props: {
renderA2UIInline?: boolean;
runtimeStatus?: AgentRuntimeStatus;
showRuntimeStatusInline?: boolean;
toolCalls?: AgentToolCallState[];
actionRequests?: ActionRequired[];
promoteActionRequestsToA2UI?: boolean;
onPermissionResponse?: (payload: unknown) => void;
@@ -104,6 +150,11 @@ function renderHarness(props: {
fileName: string,
context?: WriteArtifactContext,
) => void;
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
title?: string;
}) => void;
}) {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -180,6 +231,57 @@ describe("StreamingRenderer", () => {
expect(parseAIResponseMock).toHaveBeenCalledTimes(1);
});
it("普通工具列表应透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
renderHarness({
content: "工具执行完成",
toolCalls: [
{
id: "tool-site-run-streaming-list",
name: "lime_site_run",
arguments: JSON.stringify({ adapter_name: "github/search" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-03-25T10:00:00.000Z"),
endTime: new Date("2026-03-25T10:00:01.000Z"),
},
],
onOpenSavedSiteContent,
});
expect(mockToolCallList).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("交错工具片段应透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
renderHarness({
content: "",
contentParts: [
{
type: "tool_use",
toolCall: {
id: "tool-site-run-streaming-item",
name: "lime_site_run",
arguments: JSON.stringify({ adapter_name: "github/search" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-03-25T10:01:00.000Z"),
endTime: new Date("2026-03-25T10:01:01.000Z"),
},
},
],
onOpenSavedSiteContent,
});
expect(mockToolCallItem).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("关闭内联 A2UI 时应仅保留普通文本片段", () => {
parseAIResponseMock.mockReturnValue({
parts: [
@@ -30,12 +30,13 @@ import type {
ParsedMessageContent,
} from "@/components/content-creator/a2ui/types";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import type {
AgentRuntimeStatus,
ContentPart,
ActionRequired,
ConfirmResponse,
SiteSavedContentTarget,
WriteArtifactContext,
} from "../types";
import {
@@ -550,6 +551,7 @@ interface StreamingRendererProps {
) => void;
/** 文件点击回调 */
onFileClick?: (fileName: string, content: string) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
/** 权限确认响应回调 */
onPermissionResponse?: (response: ConfirmResponse) => void;
/** 是否折叠代码块(当画布打开时) */
@@ -686,6 +688,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
renderA2UIInline = true,
onWriteFile,
onFileClick,
onOpenSavedSiteContent,
onPermissionResponse,
collapseCodeBlocks,
shouldCollapseCodeBlock,
@@ -1011,6 +1014,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
toolCall={part.toolCall}
isMessageStreaming={isStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
);
} else if (part.type === "action_required") {
@@ -1191,6 +1195,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
toolCalls={toolCalls}
isMessageStreaming={isStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
)}
@@ -32,7 +32,7 @@ interface TeamWorkbenchSummaryPanelProps {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
}
function buildOperationSummary(params: {
@@ -75,8 +75,9 @@ export function TeamWorkbenchSummaryPanel({
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles = [],
runtimeTeamState = null,
teamDispatchPreviewState = null,
}: TeamWorkbenchSummaryPanelProps) {
const runtimeTeamState = teamDispatchPreviewState;
const executionSummary = summarizeTeamWorkspaceExecution({
currentSessionId,
currentSessionRuntimeStatus,
@@ -508,7 +508,7 @@ describe("TeamWorkspaceBoard", () => {
role_key: "explorer",
},
],
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-formed-ordered",
status: "formed",
label: "修复 Team",
@@ -2015,7 +2015,7 @@ describe("TeamWorkspaceBoard", () => {
it("本轮 Team 准备中时,应在空 shell 展示组建状态", async () => {
const container = await renderBoard({
shellVisible: true,
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-forming-1",
status: "forming",
label: "排障 Team",
@@ -2040,7 +2040,7 @@ describe("TeamWorkspaceBoard", () => {
const container = await renderBoard({
shellVisible: true,
defaultShellExpanded: true,
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-formed-1",
status: "formed",
label: "修复 Team",
@@ -2096,7 +2096,7 @@ describe("TeamWorkspaceBoard", () => {
const container = await renderBoard({
shellVisible: true,
defaultShellExpanded: true,
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-failed-1",
status: "failed",
label: "失败的 Team",
@@ -21,7 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getAgentRuntimeSession } from "@/lib/api/agentRuntime";
import type { AgentThreadItem } from "@/lib/api/agentStream";
import type { AgentThreadItem } from "@/lib/api/agentProtocol";
import type {
AsterSessionDetail,
AsterSubagentSkillInfo,
@@ -118,7 +118,7 @@ interface TeamWorkspaceBoardProps {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
}
interface TeamSessionCard {
@@ -380,9 +380,22 @@ function extractMessageActivityEntries(
}
for (const content of message.content) {
const title = content.error ? "错误" : content.output ? "输出" : "回复";
const title =
content.type === "tool_response"
? content.error
? "错误"
: content.output
? "输出"
: "回复"
: "回复";
const previewSource =
content.type === "tool_response"
? content.error || content.output
: content.type === "text" || content.type === "thinking"
? content.text
: undefined;
const detailText = normalizeActivityPreviewText(
content.error || content.output || content.text,
previewSource,
ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH,
);
@@ -787,16 +800,16 @@ function resolveSessionBlueprintRoleId(
function orderSessionsByRuntimeRoles(
sessions: TeamSessionCard[],
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null,
): TeamSessionCard[] {
if (sessions.length <= 1 || !runtimeTeamState) {
if (sessions.length <= 1 || !teamDispatchPreviewState) {
return sessions;
}
const runtimeRoles = (
runtimeTeamState.members.length > 0
? runtimeTeamState.members
: runtimeTeamState.blueprint?.roles ?? []
teamDispatchPreviewState.members.length > 0
? teamDispatchPreviewState.members
: teamDispatchPreviewState.blueprint?.roles ?? []
).map((role) => ({
id: role.id,
label: role.label,
@@ -902,9 +915,9 @@ function buildFallbackSummary(params: {
}
function buildRuntimeFormationHint(
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (runtimeTeamState?.status) {
switch (teamDispatchPreviewState?.status) {
case "forming":
return "系统正在准备当前任务的协作分工,成员接入后会自动开始处理。";
case "formed":
@@ -917,16 +930,16 @@ function buildRuntimeFormationHint(
}
function buildRuntimeFormationEmptyDetail(
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (runtimeTeamState?.status) {
switch (teamDispatchPreviewState?.status) {
case "forming":
return "系统正在根据当前任务准备协作分工。完成后,这里会先展示当前成员卡片,再接入真实处理进展。";
case "formed":
return "当前协作方案已经准备好。画布会先展示当前分工,等成员真正开始处理后,再自动切换为实时进展。";
case "failed":
return (
runtimeTeamState.errorMessage?.trim() ||
teamDispatchPreviewState.errorMessage?.trim() ||
"当前协作准备失败,暂时无法展示当前成员。"
);
default:
@@ -1063,26 +1076,31 @@ function buildCanvasStageHint(params: {
hasRealTeamGraph: boolean;
hasRuntimeFormation: boolean;
hasSelectedTeamPlan: boolean;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
}) {
const { hasRealTeamGraph, hasRuntimeFormation, hasSelectedTeamPlan, runtimeTeamState } =
const {
hasRealTeamGraph,
hasRuntimeFormation,
hasSelectedTeamPlan,
teamDispatchPreviewState,
} =
params;
if (hasRealTeamGraph) {
return "拖动画布空白处可平移,滚轮配合 Ctrl/Cmd 可缩放,拖动成员卡片可调整布局。";
}
if (runtimeTeamState?.status === "forming") {
if (teamDispatchPreviewState?.status === "forming") {
return "当前协作分工正在准备中,成员加入后会接手这些位置。";
}
if (runtimeTeamState?.status === "formed") {
if (teamDispatchPreviewState?.status === "formed") {
return "当前协作分工已经准备好,成员加入后会自动接手这些位置。";
}
if (runtimeTeamState?.status === "failed") {
if (teamDispatchPreviewState?.status === "failed") {
return (
runtimeTeamState.errorMessage?.trim() ||
teamDispatchPreviewState.errorMessage?.trim() ||
"当前协作准备失败,暂时无法生成成员画布。"
);
}
@@ -1374,8 +1392,9 @@ export function TeamWorkspaceBoard({
selectedTeamLabel = null,
selectedTeamSummary = null,
selectedTeamRoles = [],
runtimeTeamState = null,
teamDispatchPreviewState = null,
}: TeamWorkspaceBoardProps) {
const runtimeTeamState = teamDispatchPreviewState;
const isChildSession = Boolean(subagentParentContext);
const canvasStorageScopeId =
currentSessionId?.trim() ||
@@ -1822,9 +1841,14 @@ export function TeamWorkspaceBoard({
isChildSession
? dedupeSessions([currentChildSession, ...visibleSessions])
: visibleSessions,
runtimeTeamState,
teamDispatchPreviewState,
),
[currentChildSession, isChildSession, runtimeTeamState, visibleSessions],
[
currentChildSession,
isChildSession,
teamDispatchPreviewState,
visibleSessions,
],
);
const railSessions = useMemo(
() =>
@@ -2906,9 +2930,14 @@ export function TeamWorkspaceBoard({
hasRealTeamGraph,
hasRuntimeFormation,
hasSelectedTeamPlan,
runtimeTeamState,
teamDispatchPreviewState,
}),
[hasRealTeamGraph, hasRuntimeFormation, hasSelectedTeamPlan, runtimeTeamState],
[
hasRealTeamGraph,
hasRuntimeFormation,
hasSelectedTeamPlan,
teamDispatchPreviewState,
],
);
const statusSummary = useMemo(() => {
@@ -10,10 +10,10 @@ const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
}));
const {
mockSafeListen,
mockParseStreamEvent,
mockParseAgentEvent,
} = vi.hoisted(() => ({
mockSafeListen: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
}));
vi.mock("@/lib/api/agentRuntime", async () => {
@@ -27,14 +27,14 @@ vi.mock("@/lib/api/agentRuntime", async () => {
};
});
vi.mock("@/lib/api/agentStream", async () => {
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentStream")>(
"@/lib/api/agentStream",
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
return {
...actual,
parseStreamEvent: mockParseStreamEvent,
parseAgentEvent: mockParseAgentEvent,
};
});
@@ -82,7 +82,7 @@ beforeEach(() => {
createSessionDetail(sessionId),
);
mockSafeListen.mockResolvedValue(() => {});
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockParseAgentEvent.mockImplementation((payload: unknown) => payload);
});
afterEach(() => {
@@ -262,7 +262,7 @@ describe("TeamWorkspaceDock", () => {
it("仅处于协作准备中时,不应自动展开面板,但应显示提醒", async () => {
const { container } = await renderDock({
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-forming-1",
status: "forming",
label: "修复 Team",
@@ -288,7 +288,7 @@ describe("TeamWorkspaceDock", () => {
it("协作方案已就绪但真实成员未出现时,不应自动展开面板", async () => {
const { container } = await renderDock({
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-formed-2",
status: "formed",
label: "修复 Team",
@@ -324,7 +324,7 @@ describe("TeamWorkspaceDock", () => {
it("协作准备失败时,不应自动展开面板,但应保留提醒入口", async () => {
const { container } = await renderDock({
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-failed-1",
status: "failed",
label: "修复 Team",
@@ -354,7 +354,7 @@ describe("TeamWorkspaceDock", () => {
const { container } = await renderDock({
placement: "inline",
onActivateWorkbench,
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-formed-launcher-1",
status: "formed",
label: "修复 Team",
@@ -680,7 +680,7 @@ describe("TeamWorkspaceDock", () => {
it("本轮协作方案已就绪时,应在空态 Dock 展示成员摘要", async () => {
const { container } = await renderDock({
runtimeTeamState: {
teamDispatchPreviewState: {
requestId: "runtime-formed-1",
status: "formed",
label: "修复 Team",
@@ -470,7 +470,7 @@ interface TeamWorkspaceDockProps {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
}
interface InlinePanelLayout {
@@ -508,8 +508,9 @@ export function TeamWorkspaceDock({
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles = [],
runtimeTeamState = null,
teamDispatchPreviewState = null,
}: TeamWorkspaceDockProps) {
const runtimeTeamState = teamDispatchPreviewState;
const launcherOnly = typeof onActivateWorkbench === "function";
const hasRealTeamGraph =
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
@@ -786,7 +787,7 @@ export function TeamWorkspaceDock({
selectedTeamLabel={selectedTeamLabel}
selectedTeamSummary={selectedTeamSummary}
selectedTeamRoles={selectedTeamRoles}
runtimeTeamState={runtimeTeamState}
teamDispatchPreviewState={runtimeTeamState}
/>
) : (
<EmptyStateCard data-testid="team-workspace-empty-card" role="status">
@@ -8,7 +8,7 @@
import React from "react";
import styled from "styled-components";
import { Coins } from "lucide-react";
import type { TokenUsage } from "@/lib/api/agentStream";
import type { AgentTokenUsage as TokenUsage } from "@/lib/api/agentProtocol";
const UsageContainer = styled.div`
display: inline-flex;
@@ -2,7 +2,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToolCallDisplay, ToolCallList } from "./ToolCallDisplay";
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
vi.mock("@tauri-apps/plugin-shell", () => ({
open: vi.fn().mockResolvedValue(undefined),
@@ -251,6 +251,154 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("Copy");
});
it("站点能力工具结果应展示自动保存结果与脚本来源", () => {
const { container } = renderTool({
id: "tool-site-run-1",
name: "lime_site_run",
arguments: JSON.stringify({
adapter_name: "github/search",
args: { query: "mcp" },
}),
status: "completed",
result: {
success: true,
output: JSON.stringify({
ok: true,
adapter: "github/search",
data: { items: [{ title: "modelcontextprotocol/servers" }] },
}),
metadata: {
tool_family: "site",
adapter_name: "github/search",
saved_content: {
content_id: "content-1",
project_id: "project-1",
title: "GitHub MCP 搜索结果",
},
saved_project_id: "project-1",
saved_by: "context_project",
adapter_source_kind: "server_synced",
adapter_source_version: "2026-03-25",
},
},
startTime: new Date("2026-03-25T12:10:00.000Z"),
endTime: new Date("2026-03-25T12:10:01.000Z"),
});
expect(container.textContent).toContain("已执行 github/search");
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(container.textContent).toContain(
"结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文",
);
expect(container.textContent).toContain("脚本来源:服务端脚本 · 2026-03-25");
});
it("站点能力工具结果应支持直接打开已保存内容", () => {
const onOpenSavedSiteContent = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ToolCallDisplay
toolCall={{
id: "tool-site-run-open-1",
name: "lime_site_run",
arguments: JSON.stringify({
adapter_name: "github/search",
args: { query: "lime" },
}),
status: "completed",
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-open-1",
project_id: "project-open-1",
title: "Lime 搜索结果",
},
saved_by: "context_project",
},
},
startTime: new Date("2026-03-25T12:20:00.000Z"),
endTime: new Date("2026-03-25T12:20:01.000Z"),
}}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>,
);
});
mountedRoots.push({ container, root });
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
act(() => {
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("打开已保存内容"),
) as HTMLButtonElement | undefined;
openButton?.click();
});
expect(onOpenSavedSiteContent).toHaveBeenCalledWith({
projectId: "project-open-1",
contentId: "content-open-1",
title: "Lime 搜索结果",
});
});
it("站点能力工具失败时应展示未保存原因", () => {
const { container } = renderTool({
id: "tool-site-run-2",
name: "lime_site_run",
arguments: JSON.stringify({
adapter_name: "zhihu/search",
args: { query: "lime" },
}),
status: "failed",
result: {
success: false,
error: "执行失败",
output: "",
metadata: {
tool_family: "site",
adapter_name: "zhihu/search",
save_skipped_project_id: "project-2",
save_skipped_by: "context_project",
save_error_message: "数据库写入失败",
},
},
startTime: new Date("2026-03-25T12:12:00.000Z"),
endTime: new Date("2026-03-25T12:12:03.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(container.textContent).toContain(
"执行失败,未保存到项目 project-2 · 来自当前项目上下文",
);
expect(container.textContent).toContain("自动保存失败:数据库写入失败");
});
it("应为浏览器、委派、任务输出与交互类工具生成具体动作句", () => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -324,4 +472,25 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("已列出 src/**/*.tsx");
expect(container.textContent).toContain("等待输入 需要继续吗?");
});
it("写文件工具应通过 artifact protocol 解析嵌套产物路径", () => {
const { container } = renderTool({
id: "tool-write-nested-1",
name: "write_file",
arguments: JSON.stringify({
payload: {
artifact_paths: ["social-posts\\final.md"],
},
}),
status: "completed",
result: {
success: true,
output: "# 最终稿",
},
startTime: new Date("2026-03-25T09:00:00.000Z"),
endTime: new Date("2026-03-25T09:00:01.000Z"),
});
expect(container.textContent).toContain("已写入 final.md");
});
});
@@ -20,7 +20,11 @@ import {
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToolCallState, ToolResultImage } from "@/lib/api/agentStream";
import type {
AgentToolCallState as ToolCallState,
AgentToolResultImage as ToolResultImage,
} from "@/lib/api/agentProtocol";
import type { SiteSavedContentTarget } from "../types";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import {
@@ -233,6 +237,180 @@ const normalizeToolResultMetadata = (
return Object.fromEntries(Object.entries(rawMetadata));
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readFirstNonEmptyString(
candidates: Array<Record<string, unknown> | null | undefined>,
keys: string[],
): string | undefined {
for (const candidate of candidates) {
if (!candidate) continue;
for (const key of keys) {
const value = candidate[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
}
return undefined;
}
interface SiteToolResultSummary {
savedContent?: {
contentId?: string;
projectId?: string;
title?: string;
};
savedProjectId?: string;
savedBy?: string;
saveSkippedProjectId?: string;
saveSkippedBy?: string;
saveErrorMessage?: string;
adapterSourceKind?: string;
adapterSourceVersion?: string;
}
interface ToolResultNotice {
key: string;
text: string;
tone: "neutral" | "success" | "warning" | "error";
}
function resolveSiteSavedContentTarget(
summary: SiteToolResultSummary | null,
): SiteSavedContentTarget | null {
if (!summary?.savedContent?.contentId) {
return null;
}
const projectId =
summary.savedContent.projectId?.trim() || summary.savedProjectId?.trim();
if (!projectId) {
return null;
}
return {
projectId,
contentId: summary.savedContent.contentId,
title: summary.savedContent.title,
};
}
function normalizeSiteToolResultSummary(
rawMetadata: unknown,
): SiteToolResultSummary | null {
const metadata = normalizeToolResultMetadata(rawMetadata);
if (!metadata) {
return null;
}
const metadataResult = asRecord(metadata.result);
const savedContentRecord =
asRecord(metadata.saved_content) || asRecord(metadataResult?.saved_content);
const candidates = [metadata, metadataResult, savedContentRecord];
const toolFamily = readFirstNonEmptyString(candidates, [
"tool_family",
"toolFamily",
]);
const savedProjectId = readFirstNonEmptyString(candidates, [
"saved_project_id",
"savedProjectId",
]);
const saveSkippedProjectId = readFirstNonEmptyString(candidates, [
"save_skipped_project_id",
"saveSkippedProjectId",
]);
const saveErrorMessage = readFirstNonEmptyString(candidates, [
"save_error_message",
"saveErrorMessage",
]);
const adapterSourceKind = readFirstNonEmptyString(candidates, [
"adapter_source_kind",
"adapterSourceKind",
]);
const adapterSourceVersion = readFirstNonEmptyString(candidates, [
"adapter_source_version",
"adapterSourceVersion",
]);
const hasSavedContent =
!!savedContentRecord &&
[
savedContentRecord.content_id,
savedContentRecord.contentId,
savedContentRecord.project_id,
savedContentRecord.projectId,
savedContentRecord.title,
].some((value) => typeof value === "string" && value.trim());
const isSiteTool =
toolFamily === "site" ||
hasSavedContent ||
!!savedProjectId ||
!!saveSkippedProjectId ||
!!saveErrorMessage ||
!!adapterSourceKind;
if (!isSiteTool) {
return null;
}
return {
savedContent: hasSavedContent
? {
contentId: readFirstNonEmptyString([savedContentRecord], [
"content_id",
"contentId",
]),
projectId: readFirstNonEmptyString([savedContentRecord], [
"project_id",
"projectId",
]),
title: readFirstNonEmptyString([savedContentRecord], ["title"]),
}
: undefined,
savedProjectId,
savedBy: readFirstNonEmptyString(candidates, ["saved_by", "savedBy"]),
saveSkippedProjectId,
saveSkippedBy: readFirstNonEmptyString(candidates, [
"save_skipped_by",
"saveSkippedBy",
]),
saveErrorMessage,
adapterSourceKind,
adapterSourceVersion,
};
}
function resolveSiteProjectSourceLabel(source?: string): string | null {
if (source === "context_project") {
return "来自当前项目上下文";
}
if (source === "explicit_project") {
return "来自显式项目参数";
}
return null;
}
function resolveSiteAdapterSourceLabel(summary: SiteToolResultSummary): string | null {
if (summary.adapterSourceKind === "server_synced") {
return summary.adapterSourceVersion
? `服务端脚本 · ${summary.adapterSourceVersion}`
: "服务端脚本";
}
if (summary.adapterSourceKind === "bundled") {
return summary.adapterSourceVersion
? `内置脚本 · ${summary.adapterSourceVersion}`
: "内置脚本";
}
return null;
}
// ============ 可展开面板组件 ============
interface ExpandablePanelProps {
@@ -472,6 +650,7 @@ interface ToolCallDisplayProps {
isMessageStreaming?: boolean;
/** 文件点击回调 - 用于打开右边栏显示文件内容 */
onFileClick?: (fileName: string, content: string) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
grouped?: boolean;
groupMarker?: string;
}
@@ -481,6 +660,7 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
defaultExpanded = false,
isMessageStreaming = false,
onFileClick,
onOpenSavedSiteContent,
grouped = false,
groupMarker = "•",
}) => {
@@ -529,6 +709,14 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
() => normalizeToolResultMetadata(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const siteResultSummary = useMemo(
() => normalizeSiteToolResultSummary(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTarget(siteResultSummary),
[siteResultSummary],
);
const resultText = useMemo(() => {
const rawText = toolCall.result?.error || toolCall.result?.output || "";
const normalized = extractLimeToolMetadataBlock(rawText).text;
@@ -578,6 +766,65 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
return items;
}, [resultMetadata]);
const siteResultNotices = useMemo(() => {
if (!siteResultSummary) return [] as ToolResultNotice[];
const notices: ToolResultNotice[] = [];
const savedProjectId =
siteResultSummary.savedProjectId || siteResultSummary.savedContent?.projectId;
const savedSourceLabel = resolveSiteProjectSourceLabel(siteResultSummary.savedBy);
if (siteResultSummary.savedContent?.title) {
let text = `结果已自动保存${
savedProjectId ? `到项目 ${savedProjectId}` : ""
}:${siteResultSummary.savedContent.title}`;
if (savedSourceLabel) {
text = `${text} · ${savedSourceLabel}`;
}
notices.push({
key: "site-save-success",
text,
tone: "success",
});
}
if (siteResultSummary.saveSkippedProjectId) {
let text =
toolCall.status === "failed"
? `执行失败,未保存到项目 ${siteResultSummary.saveSkippedProjectId}`
: `本次结果未保存到项目 ${siteResultSummary.saveSkippedProjectId}`;
const skippedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.saveSkippedBy,
);
if (skippedSourceLabel) {
text = `${text} · ${skippedSourceLabel}`;
}
notices.push({
key: "site-save-skipped",
text,
tone: siteResultSummary.saveErrorMessage ? "warning" : "neutral",
});
}
if (siteResultSummary.saveErrorMessage) {
notices.push({
key: "site-save-error",
text: `自动保存失败:${siteResultSummary.saveErrorMessage}`,
tone: "error",
});
}
const adapterSourceLabel = resolveSiteAdapterSourceLabel(siteResultSummary);
if (adapterSourceLabel) {
notices.push({
key: "site-adapter-source",
text: `脚本来源:${adapterSourceLabel}`,
tone: "neutral",
});
}
return notices;
}, [siteResultSummary, toolCall.status]);
const resultPath = useMemo(() => {
if (!resultMetadata) return undefined;
if (
@@ -688,6 +935,12 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
}
}, [fileContent, onFileClick, openableFilePath]);
const handleOpenSavedSiteContent = useCallback(() => {
if (savedSiteContentTarget && onOpenSavedSiteContent) {
onOpenSavedSiteContent(savedSiteContentTarget);
}
}, [onOpenSavedSiteContent, savedSiteContentTarget]);
const handleToggleExpanded = useCallback(() => {
hasUserToggledExpandedRef.current = true;
setIsExpanded((prev) => !prev);
@@ -844,6 +1097,34 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
))}
</div>
) : null}
{siteResultNotices.length > 0 ? (
<div className="space-y-1 text-[11px]">
{siteResultNotices.map((notice) => (
<div
key={notice.key}
className={cn(
notice.tone === "success" && "text-emerald-700",
notice.tone === "warning" && "text-amber-700",
notice.tone === "error" && "text-rose-700",
notice.tone === "neutral" && "text-slate-500",
)}
>
{notice.text}
</div>
))}
</div>
) : null}
{savedSiteContentTarget && onOpenSavedSiteContent ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-2.5 py-1.5 text-xs font-medium text-emerald-800 transition-colors hover:bg-emerald-50"
onClick={handleOpenSavedSiteContent}
>
打开已保存内容
</button>
</div>
) : null}
{resultPath ? (
<div className="break-all text-[11px] text-slate-500">
{resultPath.label}: {resultPath.value}
@@ -886,12 +1167,14 @@ interface ToolCallListProps {
isMessageStreaming?: boolean;
/** 文件点击回调 - 用于打开右边栏显示文件内容 */
onFileClick?: (fileName: string, content: string) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
}
export const ToolCallList: React.FC<ToolCallListProps> = ({
toolCalls,
isMessageStreaming = false,
onFileClick,
onOpenSavedSiteContent,
}) => {
if (!toolCalls || toolCalls.length === 0) return null;
@@ -970,6 +1253,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
toolCall={group.item}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
);
}
@@ -982,6 +1266,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
toolCall={group.items[0]!}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
);
}
@@ -992,6 +1277,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
toolCalls={group.items}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
);
}
@@ -1002,6 +1288,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
toolCalls={group.items}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
/>
);
})}
@@ -1013,10 +1300,12 @@ function WorkToolCallGroup({
toolCalls,
isMessageStreaming,
onFileClick,
onOpenSavedSiteContent,
}: {
toolCalls: ToolCallState[];
isMessageStreaming: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
}) {
const hasRunning = toolCalls.some((item) => item.status === "running");
const hasFailed = toolCalls.some((item) => item.status === "failed");
@@ -1064,6 +1353,7 @@ function WorkToolCallGroup({
toolCall={toolCall}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
grouped={true}
groupMarker={index === 0 ? "└" : "·"}
/>
@@ -1078,10 +1368,12 @@ function SearchToolCallGroup({
toolCalls,
isMessageStreaming,
onFileClick,
onOpenSavedSiteContent,
}: {
toolCalls: ToolCallState[];
isMessageStreaming: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
}) {
const [expanded, setExpanded] = useState(true);
const semanticSummaries = summarizeSearchQuerySemantics(
@@ -1138,6 +1430,7 @@ function SearchToolCallGroup({
toolCall={toolCall}
isMessageStreaming={isMessageStreaming}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
grouped={true}
groupMarker={index === 0 ? "└" : "·"}
/>
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
buildThemeWorkbenchActivityLogGroups,
parseThemeWorkbenchRunMetadataSummary,
} from "./themeWorkbenchWorkflowData";
describe("themeWorkbenchWorkflowData", () => {
it("应通过 artifact protocol 统一解析运行元数据里的产物路径", () => {
expect(
parseThemeWorkbenchRunMetadataSummary(
JSON.stringify({
workflow: "social",
execution_id: "exec-1",
version_id: "version-1",
stages: ["write_mode"],
artifact_paths: [" social-posts/demo.md "],
artifactPath: "social-posts\\demo-cover.png",
filePath: "social-posts/summary.md",
}),
),
).toEqual({
workflow: "social",
executionId: "exec-1",
versionId: "version-1",
stages: ["write_mode"],
artifactPaths: [
"social-posts/summary.md",
"social-posts/demo-cover.png",
"social-posts/demo.md",
],
});
});
it("应合并同一运行日志中的产物路径并保持规范化", () => {
expect(
buildThemeWorkbenchActivityLogGroups([
{
id: "log-1",
name: "排版优化",
status: "running",
timeLabel: "10:30",
runId: "run-1",
artifactPaths: [" social-posts/demo.md "],
},
{
id: "log-2",
name: "排版优化",
status: "completed",
timeLabel: "10:31",
runId: "run-1",
artifactPaths: ["social-posts/demo.md", "social-posts/demo-cover.png"],
},
])[0]?.artifactPaths,
).toEqual(["social-posts/demo.md", "social-posts/demo-cover.png"]);
});
});
@@ -1,3 +1,4 @@
import { extractArtifactProtocolPathsFromRecord } from "@/lib/artifact-protocol";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
export interface ThemeWorkbenchCreationTaskEvent {
@@ -173,7 +174,7 @@ export function parseThemeWorkbenchRunMetadataSummary(
executionId: readString(parsed.execution_id),
versionId: readString(parsed.version_id),
stages: readStringArray(parsed.stages),
artifactPaths: readStringArray(parsed.artifact_paths),
artifactPaths: extractArtifactProtocolPathsFromRecord(parsed),
};
} catch {
return fallback;
@@ -6,8 +6,10 @@ import type { ChatToolPreferences } from "./utils/chatToolPreferences";
export interface AgentChatWorkspaceBootstrap {
projectId?: string;
contentId?: string;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
initialRequestMetadata?: Record<string, unknown>;
theme?: string;
initialCreationMode?: CreationMode;
openBrowserAssistOnMount?: boolean;
@@ -17,6 +19,8 @@ export interface AgentChatWorkspaceBootstrap {
export interface HomeShellEnterWorkspacePayload {
prompt?: string;
images?: MessageImage[];
contentId?: string;
initialRequestMetadata?: Record<string, unknown>;
openBrowserAssistOnMount?: boolean;
toolPreferences?: ChatToolPreferences;
themeOverride?: string;
@@ -84,10 +88,12 @@ export function resolveHomeShellWorkspaceEntry(
const nextNewChatAt = now();
const shared = {
projectId: projectId ?? undefined,
contentId: payload.contentId,
theme: targetTheme,
initialCreationMode: creationMode,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
initialRequestMetadata: payload.initialRequestMetadata,
openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
} satisfies AgentPageParams;
@@ -100,8 +106,10 @@ export function resolveHomeShellWorkspaceEntry(
navigationParams: buildClawAgentParams(shared),
workspaceBootstrap: {
projectId: projectId ?? undefined,
contentId: payload.contentId,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
initialRequestMetadata: payload.initialRequestMetadata,
theme: targetTheme,
initialCreationMode: creationMode,
openBrowserAssistOnMount,
@@ -18,17 +18,17 @@ describe("agentChatHistory", () => {
role: "user",
timestamp: 1710000200,
content: [
{ type: "input_text", text: "[Image #1]" },
{ type: "input_text", text: "[Image #1]" } as never,
{
type: "input_image",
image_url: "data:image/png;base64,aGVsbG8=",
},
} as never,
],
},
{
role: "assistant",
timestamp: 1710000201,
content: [{ type: "output_text", text: "已收到图片" }],
content: [{ type: "output_text", text: "已收到图片" } as never],
},
],
};
@@ -70,7 +70,7 @@ describe("agentChatHistory", () => {
content: [
{ type: "thinking", thinking: "先理解主题" } as never,
{ type: "thinking", thinking: ",再组织结构。\n" } as never,
{ type: "output_text", text: "下面是整理好的 Prompt。" },
{ type: "output_text", text: "下面是整理好的 Prompt。" } as never,
],
},
],
@@ -1,6 +1,7 @@
import type { ContextTraceStep } from "@/lib/api/agentStream";
import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol";
import type { Message, MessageImage, ContentPart } from "../types";
import type { AsterSessionDetail } from "@/lib/api/agentRuntime";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import { mergeArtifacts } from "../utils/messageArtifacts";
import {
extractLimeToolMetadataBlock,
@@ -370,10 +371,7 @@ const messageArtifactsSignature = (
if (!artifacts || artifacts.length === 0) return "";
return artifacts
.map((artifact) => {
const filePath =
typeof artifact.meta.filePath === "string"
? artifact.meta.filePath
: "";
const filePath = resolveArtifactProtocolFilePath(artifact);
return [
artifact.id,
artifact.type,
@@ -1,4 +1,4 @@
import type { ToolResultImage } from "@/lib/api/agentStream";
import type { AgentToolResultImage as ToolResultImage } from "@/lib/api/agentProtocol";
import type { MessageImage } from "../types";
import {
LIME_TOOL_METADATA_BEGIN,
@@ -1,5 +1,10 @@
import { safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
createSubmitTurnRequestFromAgentOp,
type AgentEvent,
type AgentOp,
} from "@/lib/api/agentProtocol";
import {
compactAgentRuntimeSession,
createAgentRuntimeSession,
@@ -18,34 +23,11 @@ import {
submitAgentRuntimeTurn,
updateAgentRuntimeSession,
type AsterExecutionStrategy,
type AsterProviderConfig,
type AgentSearchMode,
type AsterSessionDetail,
type AsterSessionInfo,
type AutoContinueRequestPayload,
type ImageInput,
} from "@/lib/api/agentRuntime";
import type { StreamEvent } from "@/lib/api/agentStream";
import type { ActionRequiredScope } from "../types";
export interface AgentRuntimeTurnRequest {
message: string;
sessionId: string;
eventName: string;
workspaceId: string;
turnId?: string;
images?: ImageInput[];
providerConfig?: AsterProviderConfig;
executionStrategy?: AsterExecutionStrategy;
webSearch?: boolean;
searchMode?: AgentSearchMode;
autoContinue?: AutoContinueRequestPayload;
systemPrompt?: string;
metadata?: Record<string, unknown>;
queueIfBusy?: boolean;
queuedTurnId?: string;
}
export interface AgentRuntimeActionResponse {
sessionId: string;
requestId: string;
@@ -78,7 +60,7 @@ export interface AgentRuntimeAdapter {
sessionId: string,
executionStrategy: AsterExecutionStrategy,
): Promise<void>;
submitTurn(request: AgentRuntimeTurnRequest): Promise<void>;
submitOp(op: AgentOp): Promise<void>;
compactSession(sessionId: string, eventName: string): Promise<void>;
interruptTurn(sessionId: string): Promise<boolean>;
resumeThread(sessionId: string): Promise<boolean>;
@@ -87,11 +69,11 @@ export interface AgentRuntimeAdapter {
respondToAction(request: AgentRuntimeActionResponse): Promise<void>;
listenToTurnEvents(
eventName: string,
handler: (event: { payload: StreamEvent | unknown }) => void,
handler: (event: { payload: AgentEvent | unknown }) => void,
): Promise<UnlistenFn>;
listenToTeamEvents(
eventName: string,
handler: (event: { payload: StreamEvent | unknown }) => void,
handler: (event: { payload: AgentEvent | unknown }) => void,
): Promise<UnlistenFn>;
}
@@ -132,26 +114,14 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
execution_strategy: executionStrategy,
});
},
async submitTurn(request) {
await submitAgentRuntimeTurn({
message: request.message,
session_id: request.sessionId,
event_name: request.eventName,
workspace_id: request.workspaceId,
turn_id: request.turnId,
images: request.images,
turn_config: {
provider_config: request.providerConfig,
execution_strategy: request.executionStrategy,
web_search: request.webSearch,
search_mode: request.searchMode,
auto_continue: request.autoContinue,
system_prompt: request.systemPrompt,
metadata: request.metadata,
},
queue_if_busy: request.queueIfBusy,
queued_turn_id: request.queuedTurnId,
});
async submitOp(op) {
switch (op.type) {
case "user_input":
await submitAgentRuntimeTurn(createSubmitTurnRequestFromAgentOp(op));
return;
default:
throw new Error(`当前 runtime adapter 尚不支持 AgentOp: ${op.type}`);
}
},
async compactSession(sessionId, eventName) {
await compactAgentRuntimeSession({
@@ -203,9 +173,9 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
});
},
async listenToTurnEvents(eventName, handler) {
return safeListen<StreamEvent>(eventName, handler);
return safeListen<AgentEvent>(eventName, handler);
},
async listenToTeamEvents(eventName, handler) {
return safeListen<StreamEvent>(eventName, handler);
return safeListen<AgentEvent>(eventName, handler);
},
};
@@ -1,14 +1,18 @@
import { toast } from "sonner";
import type { Dispatch, SetStateAction } from "react";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type {
StreamEventActionRequired,
StreamEventArtifactSnapshot,
StreamEventContextTrace,
StreamEventToolEnd,
StreamEventToolStart,
} from "@/lib/api/agentStream";
AgentEventActionRequired,
AgentEventArtifactSnapshot,
AgentEventContextTrace,
AgentEventToolEnd,
AgentEventToolStart,
} from "@/lib/api/agentProtocol";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type { Artifact } from "@/lib/artifact/types";
import {
extractArtifactProtocolPaths,
extractArtifactProtocolPathsFromValue,
} from "@/lib/artifact-protocol";
import type { ActionRequired, Message, WriteArtifactContext } from "../types";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import {
@@ -27,7 +31,6 @@ import {
} from "./agentChatToolResult";
import {
buildArtifactFromWrite,
extractArtifactPathsFromMetadata,
findMessageArtifact,
upsertMessageArtifact,
} from "../utils/messageArtifacts";
@@ -135,19 +138,9 @@ function extractToolArgPath(
return undefined;
}
for (const key of [
"path",
"file_path",
"filePath",
"target_path",
"targetPath",
"output_path",
"outputPath",
]) {
const value = toolArgs[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
const protocolPath = extractArtifactProtocolPathsFromValue(toolArgs)[0];
if (protocolPath) {
return protocolPath;
}
return extractPatchPath(extractPatchText(toolArgs));
@@ -267,7 +260,7 @@ export function handleToolStartEvent({
}: BaseProcessorContext &
ArtifactWriteOptions &
ToolTrackingContext & {
data: StreamEventToolStart;
data: AgentEventToolStart;
setPendingActions: Dispatch<SetStateAction<ActionRequired[]>>;
}) {
const startedAt = Date.now();
@@ -423,7 +416,7 @@ export function handleToolEndEvent({
}: BaseProcessorContext &
ArtifactWriteOptions &
ToolTrackingContext & {
data: StreamEventToolEnd;
data: AgentEventToolEnd;
}) {
const normalizedResult = normalizeIncomingToolResult(data.result) || data.result;
const isSuccess = isToolResultSuccessful(normalizedResult);
@@ -500,7 +493,7 @@ export function handleToolEndEvent({
}),
);
const artifactPaths = extractArtifactPathsFromMetadata(
const artifactPaths = extractArtifactProtocolPaths(
normalizedResult.metadata,
);
if (artifactPaths.length === 0) {
@@ -555,7 +548,7 @@ export function handleArtifactSnapshotEvent({
assistantMsgId,
}: BaseProcessorContext &
ArtifactWriteOptions & {
data: StreamEventArtifactSnapshot;
data: AgentEventArtifactSnapshot;
}) {
const artifactPath = data.artifact.filePath;
if (!artifactPath) {
@@ -616,7 +609,7 @@ export function handleActionRequiredEvent({
activeSessionId,
resolvedWorkspaceId,
}: BaseProcessorContext & {
data: StreamEventActionRequired;
data: AgentEventActionRequired;
actionLoggedKeys: Set<string>;
effectiveExecutionStrategy: AsterExecutionStrategy;
runtime: AgentRuntimeAdapter;
@@ -704,7 +697,7 @@ export function handleContextTraceEvent({
setMessages,
assistantMsgId,
}: BaseProcessorContext & {
data: StreamEventContextTrace;
data: AgentEventContextTrace;
}) {
if (!Array.isArray(data.steps) || data.steps.length === 0) {
return;
@@ -1,14 +1,15 @@
import { toast } from "sonner";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type {
AsterExecutionStrategy,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import type {
AgentEvent,
AgentThreadItem,
AgentThreadTurn,
StreamEvent,
} from "@/lib/api/agentStream";
} from "@/lib/api/agentProtocol";
import type {
AsterExecutionStrategy,
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import type { ActionRequired, Message } from "../types";
import { appendTextToParts } from "./agentChatHistory";
@@ -33,6 +34,10 @@ import {
buildFailedAgentRuntimeStatus,
formatAgentRuntimeStatusSummary,
} from "../utils/agentRuntimeStatus";
import {
applyModelChangeExecutionRuntime,
applyTurnContextExecutionRuntime,
} from "../utils/sessionExecutionRuntime";
type MessageParts = NonNullable<Message["contentParts"]>;
@@ -69,13 +74,13 @@ interface StreamLifecycleCallbacks {
}
interface HandleTurnStreamEventOptions {
data: StreamEvent;
data: AgentEvent;
requestState: StreamRequestState;
callbacks: StreamLifecycleCallbacks;
observer?: StreamObserver;
eventName: string;
optimisticTurnId: string;
optimisticItemId: string;
pendingTurnKey: string;
pendingItemKey: string;
assistantMsgId: string;
activeSessionId: string;
resolvedWorkspaceId: string;
@@ -96,6 +101,9 @@ interface HandleTurnStreamEventOptions {
setThreadItems: Dispatch<SetStateAction<AgentThreadItem[]>>;
setThreadTurns: Dispatch<SetStateAction<AgentThreadTurn[]>>;
setCurrentTurnId: Dispatch<SetStateAction<string | null>>;
setExecutionRuntime: Dispatch<
SetStateAction<AsterSessionExecutionRuntime | null>
>;
}
function finishRequestLog(
@@ -127,8 +135,8 @@ export function handleTurnStreamEvent({
callbacks,
observer,
eventName,
optimisticTurnId,
optimisticItemId,
pendingTurnKey,
pendingItemKey,
assistantMsgId,
activeSessionId,
resolvedWorkspaceId,
@@ -145,6 +153,7 @@ export function handleTurnStreamEvent({
setThreadItems,
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
}: HandleTurnStreamEventOptions): void {
const {
activateStream,
@@ -167,7 +176,7 @@ export function handleTurnStreamEvent({
setThreadTurns((prev) => {
const runningTurn =
prev.find((turn) => turn.id === optimisticTurnId) ||
prev.find((turn) => turn.id === pendingTurnKey) ||
[...prev]
.reverse()
.find(
@@ -189,15 +198,15 @@ export function handleTurnStreamEvent({
});
setThreadItems((prev) => {
const optimisticItem = prev.find((item) => item.id === optimisticItemId);
if (!optimisticItem || optimisticItem.type !== "turn_summary") {
const pendingItem = prev.find((item) => item.id === pendingItemKey);
if (!pendingItem || pendingItem.type !== "turn_summary") {
return prev;
}
return upsertThreadItemState(prev, {
...optimisticItem,
...pendingItem,
status: "failed",
completed_at: optimisticItem.completed_at || failedAt,
completed_at: pendingItem.completed_at || failedAt,
updated_at: failedAt,
text: formatAgentRuntimeStatusSummary(failedRuntimeStatus),
});
@@ -248,26 +257,26 @@ export function handleTurnStreamEvent({
setCurrentTurnId(data.turn.id);
setThreadTurns((prev) =>
upsertThreadTurnState(
removeThreadTurnState(prev, optimisticTurnId),
removeThreadTurnState(prev, pendingTurnKey),
data.turn,
),
);
setThreadItems((prev) => {
const optimisticItem = prev.find((item) => item.id === optimisticItemId);
if (!optimisticItem) {
const pendingItem = prev.find((item) => item.id === pendingItemKey);
if (!pendingItem) {
return prev;
}
return upsertThreadItemState(
removeThreadItemState(prev, optimisticItemId),
removeThreadItemState(prev, pendingItemKey),
{
...optimisticItem,
...pendingItem,
thread_id: data.turn.thread_id,
turn_id: data.turn.id,
updated_at:
data.turn.updated_at ||
data.turn.started_at ||
optimisticItem.updated_at,
pendingItem.updated_at,
},
);
});
@@ -279,7 +288,7 @@ export function handleTurnStreamEvent({
activateStream();
setThreadItems((prev) =>
upsertThreadItemState(
removeThreadItemState(prev, optimisticItemId),
removeThreadItemState(prev, pendingItemKey),
data.item,
),
);
@@ -291,7 +300,7 @@ export function handleTurnStreamEvent({
clearOptimisticItem();
setThreadTurns((prev) =>
upsertThreadTurnState(
removeThreadTurnState(prev, optimisticTurnId),
removeThreadTurnState(prev, pendingTurnKey),
data.turn,
),
);
@@ -312,6 +321,20 @@ export function handleTurnStreamEvent({
);
break;
case "turn_context":
activateStream();
setExecutionRuntime((current) =>
applyTurnContextExecutionRuntime(current, data),
);
break;
case "model_change":
activateStream();
setExecutionRuntime((current) =>
applyModelChangeExecutionRuntime(current, data),
);
break;
case "thinking_delta":
activateStream();
setMessages((prev) =>
@@ -5,12 +5,12 @@ import type { Message } from "../types";
const {
mockSafeListen,
mockParseStreamEvent,
mockParseAgentEvent,
mockListExecutableSkills,
mockExecuteSkill,
} = vi.hoisted(() => ({
mockSafeListen: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
mockListExecutableSkills: vi.fn(),
mockExecuteSkill: vi.fn(),
}));
@@ -19,9 +19,16 @@ vi.mock("@/lib/dev-bridge", () => ({
safeListen: mockSafeListen,
}));
vi.mock("@/lib/api/agentStream", () => ({
parseStreamEvent: mockParseStreamEvent,
}));
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
};
});
vi.mock("@/lib/api/skill-execution", () => ({
skillExecutionApi: {
@@ -59,7 +66,7 @@ function buildBaseMessage(id = "assistant-1"): Message {
beforeEach(() => {
vi.clearAllMocks();
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockParseAgentEvent.mockImplementation((payload: unknown) => payload);
mockListExecutableSkills.mockResolvedValue([
{
name: "social_post_with_cover",
@@ -1,7 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { safeListen } from "@/lib/dev-bridge";
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
import { parseAgentEvent, type AgentEvent } from "@/lib/api/agentProtocol";
import {
skillExecutionApi,
type ExecutableSkillInfo,
@@ -421,8 +421,8 @@ export async function tryExecuteSlashSkillCommand(
});
stepUnlisteners.push(stepStartUl);
skillUnlisten = await safeListen<StreamEvent>(eventName, ({ payload }) => {
const streamEvent = parseStreamEvent(payload as unknown);
skillUnlisten = await safeListen<AgentEvent>(eventName, ({ payload }) => {
const streamEvent = parseAgentEvent(payload as unknown);
if (!streamEvent) return;
switch (streamEvent.type) {
@@ -9,6 +9,7 @@ import {
import { toast } from "sonner";
import type {
AsterExecutionStrategy,
AsterSessionExecutionRuntime,
AsterSubagentParentContext,
AsterSubagentSessionInfo,
AgentRuntimeThreadReadModel,
@@ -47,6 +48,10 @@ import {
mergeThreadItems,
mergeThreadTurns,
} from "../utils/threadTimelineView";
import {
createExecutionRuntimeFromSessionDetail,
createSessionModelPreferenceFromExecutionRuntime,
} from "../utils/sessionExecutionRuntime";
interface UseAgentSessionOptions {
runtime: AgentRuntimeAdapter;
@@ -152,6 +157,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
const [queuedTurns, setQueuedTurns] = useState<QueuedTurnSnapshot[]>([]);
const [threadRead, setThreadRead] =
useState<AgentRuntimeThreadReadModel | null>(null);
const [executionRuntime, setExecutionRuntime] =
useState<AsterSessionExecutionRuntime | null>(null);
const [todoItems, setTodoItems] = useState<AsterTodoItem[]>([]);
const [childSubagentSessions, setChildSubagentSessions] = useState<
AsterSubagentSessionInfo[]
@@ -264,6 +271,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -300,6 +308,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(scopedCurrentTurnId);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -571,9 +580,22 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}, []);
const applyRuntimeReadModel = useCallback(
(detail: Awaited<ReturnType<AgentRuntimeAdapter["getSession"]>>) => {
(
detail: Awaited<ReturnType<AgentRuntimeAdapter["getSession"]>>,
options?: { preserveExecutionRuntimeOnMissingDetail?: boolean },
) => {
const nextExecutionRuntime = createExecutionRuntimeFromSessionDetail(detail);
setQueuedTurns(normalizeQueuedTurnSnapshots(detail.queued_turns));
setThreadRead(detail.thread_read ?? null);
setExecutionRuntime((current) => {
if (
options?.preserveExecutionRuntimeOnMissingDetail &&
!nextExecutionRuntime
) {
return current;
}
return nextExecutionRuntime;
});
setTodoItems(detail.todo_items ?? []);
setChildSubagentSessions(detail.child_subagent_sessions ?? []);
setSubagentParentContext(detail.subagent_parent_context ?? null);
@@ -603,11 +625,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
const nextItems = shouldPreserveExistingTimeline
? mergeThreadItems(threadItemsRef.current, incomingItems)
: incomingItems;
const shouldPreserveExecutionRuntimeOnMissingDetail =
shouldPreserveExistingTimeline && !options?.syncSessionId;
setMessages(nextMessages);
setThreadTurns(nextTurns);
setThreadItems(nextItems);
applyRuntimeReadModel(detail);
applyRuntimeReadModel(detail, {
preserveExecutionRuntimeOnMissingDetail:
shouldPreserveExecutionRuntimeOnMissingDetail,
});
setCurrentTurnId(
nextTurns.length > 0
? nextTurns[nextTurns.length - 1]?.id || null
@@ -651,13 +678,23 @@ export function useAgentSession(options: UseAgentSessionOptions) {
workspaceId,
});
const detail = await runtime.getSession(topicId);
const topicPreference = loadSessionModelPreference(topicId);
const runtimePreference =
createSessionModelPreferenceFromExecutionRuntime(
detail.execution_runtime,
);
const topicPreference =
runtimePreference || loadSessionModelPreference(topicId);
applySessionDetail(topicId, detail, { syncSessionId: true });
logAgentDebug("useAgentSession", "switchTopic.success", {
durationMs: Date.now() - startedAt,
itemsCount: detail.items?.length ?? 0,
messagesCount: detail.messages.length,
modelPreferenceSource: runtimePreference
? "execution_runtime"
: topicPreference
? "session_storage"
: null,
queuedTurnsCount: detail.queued_turns?.length ?? 0,
topicId,
turnsCount: detail.turns?.length ?? 0,
@@ -687,6 +724,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -702,6 +740,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -938,6 +977,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -1070,6 +1110,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
setCurrentTurnId(null);
setQueuedTurns([]);
setThreadRead(null);
setExecutionRuntime(null);
setTodoItems([]);
setChildSubagentSessions([]);
setSubagentParentContext(null);
@@ -1202,6 +1243,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
subagentParentContext,
queuedTurns,
threadRead,
executionRuntime,
setExecutionRuntime,
setQueuedTurns,
topics,
setTopics,
@@ -10,14 +10,15 @@ import {
import { toast } from "sonner";
import type {
AsterExecutionStrategy,
AsterSessionExecutionRuntime,
AutoContinueRequestPayload,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import {
parseStreamEvent,
parseAgentEvent,
type AgentThreadItem,
type AgentThreadTurn,
} from "@/lib/api/agentStream";
} from "@/lib/api/agentProtocol";
import type { ActionRequired, Message, MessageImage } from "../types";
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
import {
@@ -60,6 +61,18 @@ function buildQueuedMessagePreview(content: string): string {
return compact.length > preview.length ? `${preview}...` : preview;
}
function normalizeRuntimeIdentifier(value?: string | null): string {
return value?.trim().toLowerCase() || "";
}
function createPendingTurnKey() {
return `pending-turn:${crypto.randomUUID()}`;
}
function createPendingItemKey(pendingTurnKey: string) {
return `pending-item:${pendingTurnKey}`;
}
function appendThinkingToParts(
parts: NonNullable<Message["contentParts"]>,
textDelta: string,
@@ -107,10 +120,14 @@ interface UseAgentStreamOptions {
setThreadItems: Dispatch<SetStateAction<AgentThreadItem[]>>;
setThreadTurns: Dispatch<SetStateAction<AgentThreadTurn[]>>;
setCurrentTurnId: Dispatch<SetStateAction<string | null>>;
setExecutionRuntime: Dispatch<
SetStateAction<AsterSessionExecutionRuntime | null>
>;
queuedTurns: QueuedTurnSnapshot[];
setQueuedTurns: Dispatch<SetStateAction<QueuedTurnSnapshot[]>>;
setPendingActions: Dispatch<SetStateAction<ActionRequired[]>>;
refreshSessionReadModel: (targetSessionId?: string) => Promise<boolean>;
executionRuntime: AsterSessionExecutionRuntime | null;
}
export function useAgentStream(options: UseAgentStreamOptions) {
@@ -133,10 +150,12 @@ export function useAgentStream(options: UseAgentStreamOptions) {
setThreadItems,
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
queuedTurns,
setQueuedTurns,
setPendingActions,
refreshSessionReadModel,
executionRuntime,
} = options;
const [isSending, setIsSending] = useState(false);
@@ -145,8 +164,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId: string;
eventName: string;
sessionId: string;
optimisticTurnId?: string;
optimisticItemId?: string;
pendingTurnKey?: string;
pendingItemKey?: string;
} | null>(null);
useEffect(() => {
@@ -165,8 +184,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId: string;
eventName: string;
sessionId: string;
optimisticTurnId?: string;
optimisticItemId?: string;
pendingTurnKey?: string;
pendingItemKey?: string;
} | null,
) => {
activeStreamRef.current = nextActive;
@@ -231,6 +250,21 @@ export function useAgentStream(options: UseAgentStreamOptions) {
executionStrategyOverride || executionStrategy;
const effectiveProviderType = providerTypeRef.current;
const effectiveModel = modelOverride?.trim() || modelRef.current;
const runtimeProviderSelector =
executionRuntime?.provider_selector?.trim() ||
executionRuntime?.provider_name?.trim() ||
null;
const runtimeModelName = executionRuntime?.model_name?.trim() || null;
const shouldSubmitProviderPreference =
!runtimeProviderSelector ||
normalizeRuntimeIdentifier(runtimeProviderSelector) !==
normalizeRuntimeIdentifier(effectiveProviderType);
const shouldSubmitModelPreference =
Boolean(modelOverride?.trim()) ||
shouldSubmitProviderPreference ||
!runtimeModelName ||
normalizeRuntimeIdentifier(runtimeModelName) !==
normalizeRuntimeIdentifier(effectiveModel);
const observer = options?.observer;
const requestMetadata = options?.requestMetadata;
const messagePurpose = options?.purpose;
@@ -333,8 +367,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
activeStreamRef.current?.assistantMsgId || assistantMsgId,
eventName: skillEventName,
sessionId: sessionIdForStop,
optimisticTurnId: activeStreamRef.current?.optimisticTurnId,
optimisticItemId: activeStreamRef.current?.optimisticItemId,
pendingTurnKey: activeStreamRef.current?.pendingTurnKey,
pendingItemKey: activeStreamRef.current?.pendingItemKey,
});
},
isExecutionCancelled: () =>
@@ -362,8 +396,9 @@ export function useAgentStream(options: UseAgentStreamOptions) {
};
let streamActivated = false;
const optimisticStartedAt = assistantMsg.timestamp.toISOString();
const optimisticTurnId = crypto.randomUUID();
const optimisticItemId = `turn-summary:${optimisticTurnId}`;
const pendingTurnKey = createPendingTurnKey();
const pendingItemKey = createPendingItemKey(pendingTurnKey);
const requestTurnId = crypto.randomUUID();
const optimisticThreadId =
sessionIdRef.current || `local-thread:${assistantMsgId}`;
const toolLogIdByToolId = new Map<string, string>();
@@ -416,15 +451,15 @@ export function useAgentStream(options: UseAgentStreamOptions) {
if (expectingQueue) {
return;
}
setThreadItems((prev) => removeThreadItemState(prev, optimisticItemId));
setThreadItems((prev) => removeThreadItemState(prev, pendingItemKey));
};
const clearOptimisticTurn = () => {
if (expectingQueue) {
return;
}
setThreadTurns((prev) => removeThreadTurnState(prev, optimisticTurnId));
setCurrentTurnId((prev) => (prev === optimisticTurnId ? null : prev));
setThreadTurns((prev) => removeThreadTurnState(prev, pendingTurnKey));
setCurrentTurnId((prev) => (prev === pendingTurnKey ? null : prev));
};
const markOptimisticFailure = (errorMessage: string) => {
@@ -436,7 +471,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage);
setThreadTurns((prev) => {
const currentTurn = prev.find((turn) => turn.id === optimisticTurnId);
const currentTurn = prev.find((turn) => turn.id === pendingTurnKey);
if (!currentTurn) {
return prev;
}
@@ -451,7 +486,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
});
setThreadItems((prev) => {
const currentItem = prev.find((item) => item.id === optimisticItemId);
const currentItem = prev.find((item) => item.id === pendingItemKey);
if (!currentItem || currentItem.type !== "turn_summary") {
return prev;
}
@@ -480,7 +515,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
if (!expectingQueue) {
setThreadTurns((prev) =>
upsertThreadTurnState(prev, {
id: optimisticTurnId,
id: pendingTurnKey,
thread_id: optimisticThreadId,
prompt_text: content,
status: "running",
@@ -491,9 +526,9 @@ export function useAgentStream(options: UseAgentStreamOptions) {
);
setThreadItems((prev) =>
upsertThreadItemState(prev, {
id: optimisticItemId,
id: pendingItemKey,
thread_id: optimisticThreadId,
turn_id: optimisticTurnId,
turn_id: pendingTurnKey,
sequence: 0,
status: "in_progress",
started_at: optimisticStartedAt,
@@ -502,7 +537,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
text: formatAgentRuntimeStatusSummary(assistantMsg.runtimeStatus),
}),
);
setCurrentTurnId(optimisticTurnId);
setCurrentTurnId(pendingTurnKey);
}
const eventName = `aster_stream_${assistantMsgId}`;
@@ -528,8 +563,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId,
eventName,
sessionId: activeSessionId,
optimisticTurnId,
optimisticItemId,
pendingTurnKey,
pendingItemKey,
});
setMessages((prev) =>
prev.map((msg) =>
@@ -547,7 +582,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
activateStream();
setThreadTurns((prev) =>
upsertThreadTurnState(prev, {
id: optimisticTurnId,
id: pendingTurnKey,
thread_id: activeSessionId,
prompt_text: content,
status: "running",
@@ -558,9 +593,9 @@ export function useAgentStream(options: UseAgentStreamOptions) {
);
setThreadItems((prev) =>
upsertThreadItemState(prev, {
id: optimisticItemId,
id: pendingItemKey,
thread_id: activeSessionId,
turn_id: optimisticTurnId,
turn_id: pendingTurnKey,
sequence: 0,
status: "in_progress",
started_at: optimisticStartedAt,
@@ -597,7 +632,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
unlisten = await runtime.listenToTurnEvents(
eventName,
(event: { payload: unknown }) => {
const data = parseStreamEvent(event.payload);
const data = parseAgentEvent(event.payload);
if (!data) {
return;
}
@@ -621,8 +656,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
},
observer,
eventName,
optimisticTurnId,
optimisticItemId,
pendingTurnKey,
pendingItemKey,
assistantMsgId,
activeSessionId,
resolvedWorkspaceId,
@@ -639,6 +674,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
setThreadItems,
setThreadTurns,
setCurrentTurnId,
setExecutionRuntime,
});
},
);
@@ -653,24 +689,27 @@ export function useAgentStream(options: UseAgentStreamOptions) {
}))
: undefined;
const providerConfig = {
provider_id: effectiveProviderType,
provider_name: mapProviderName(effectiveProviderType),
model_name: effectiveModel,
};
await runtime.submitTurn({
message: content,
await runtime.submitOp({
type: "user_input",
text: content,
sessionId: activeSessionId,
eventName,
workspaceId: resolvedWorkspaceId,
turnId: optimisticTurnId,
turnId: requestTurnId,
images: imagesToSend,
providerConfig,
executionStrategy: effectiveExecutionStrategy,
webSearch,
searchMode: webSearch ? "allowed" : "disabled",
autoContinue,
preferences: {
providerPreference: shouldSubmitProviderPreference
? effectiveProviderType
: undefined,
modelPreference: shouldSubmitModelPreference
? effectiveModel
: undefined,
thinking: _thinking,
executionStrategy: effectiveExecutionStrategy,
webSearch,
searchMode: webSearch ? "allowed" : "disabled",
autoContinue,
},
systemPrompt,
metadata: requestMetadata,
queueIfBusy: true,
@@ -734,9 +773,11 @@ export function useAgentStream(options: UseAgentStreamOptions) {
providerTypeRef,
queuedTurns.length,
runtime,
executionRuntime,
sessionIdRef,
setActiveStream,
setCurrentTurnId,
setExecutionRuntime,
setMessages,
setPendingActions,
setQueuedTurns,
@@ -770,17 +811,17 @@ export function useAgentStream(options: UseAgentStreamOptions) {
setQueuedTurns([]);
if (activeStream?.assistantMsgId) {
if (activeStream.optimisticItemId) {
if (activeStream.pendingItemKey) {
setThreadItems((prev) =>
removeThreadItemState(prev, activeStream.optimisticItemId!),
removeThreadItemState(prev, activeStream.pendingItemKey!),
);
}
if (activeStream.optimisticTurnId) {
if (activeStream.pendingTurnKey) {
setThreadTurns((prev) =>
removeThreadTurnState(prev, activeStream.optimisticTurnId!),
removeThreadTurnState(prev, activeStream.pendingTurnKey!),
);
setCurrentTurnId((prev) =>
prev === activeStream.optimisticTurnId ? null : prev,
prev === activeStream.pendingTurnKey ? null : prev,
);
}
setMessages((prev) =>
@@ -884,7 +925,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
try {
unlisten = await runtime.listenToTurnEvents(eventName, (event) => {
const data = parseStreamEvent(event.payload);
const data = parseAgentEvent(event.payload);
if (!data) {
return;
}
@@ -1,12 +1,12 @@
import { useEffect } from "react";
import type { Artifact } from "@/lib/artifact/types";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import type {
ArtifactWriteMetadata,
WriteArtifactContext,
} from "../types";
import {
buildArtifactFromWrite,
resolveArtifactFilePath,
resolveArtifactWritePhase,
} from "../utils/messageArtifacts";
@@ -46,7 +46,7 @@ export function shouldAutoSyncArtifactPreview(artifact: Artifact | null): boolea
return false;
}
const artifactPath = resolveArtifactFilePath(artifact);
const artifactPath = resolveArtifactProtocolFilePath(artifact);
if (!artifactPath.trim()) {
return false;
}
@@ -77,7 +77,7 @@ export function mergePreviewContentIntoArtifact(
const nextContent =
typeof preview.content === "string" ? preview.content : artifact.content;
const nextPath = preview.path?.trim() || resolveArtifactFilePath(artifact);
const nextPath = preview.path?.trim() || resolveArtifactProtocolFilePath(artifact);
const currentContent = artifact.content;
if (!nextContent.trim() && currentContent.trim()) {
@@ -92,7 +92,10 @@ export function mergePreviewContentIntoArtifact(
return null;
}
if (nextContent === currentContent && nextPath === resolveArtifactFilePath(artifact)) {
if (
nextContent === currentContent &&
nextPath === resolveArtifactProtocolFilePath(artifact)
) {
return null;
}
@@ -160,7 +163,7 @@ export function useArtifactAutoPreviewSync({
return;
}
const artifactPath = resolveArtifactFilePath(artifact);
const artifactPath = resolveArtifactProtocolFilePath(artifact);
if (!artifactPath.trim()) {
return;
}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Artifact } from "@/lib/artifact/types";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import { resolveArtifactWritePhase } from "../utils/messageArtifacts";
export type ArtifactDisplayMode =
@@ -80,16 +81,6 @@ function hasRenderableArtifactContent(artifact: Artifact | null | undefined): bo
return Boolean(artifact?.content.trim());
}
function resolveArtifactPath(artifact: Pick<Artifact, "title" | "meta">): string {
if (typeof artifact.meta.filePath === "string" && artifact.meta.filePath.trim()) {
return artifact.meta.filePath.trim();
}
if (typeof artifact.meta.filename === "string" && artifact.meta.filename.trim()) {
return artifact.meta.filename.trim();
}
return artifact.title;
}
function resolveArtifactDisplayName(path: string): string {
const normalized = path.replace(/\\/g, "/");
const segments = normalized.split("/");
@@ -138,7 +129,7 @@ function buildOverlayState(
phase: ArtifactOverlayPhase,
options: { isSlowTransition: boolean },
): ArtifactDisplayOverlayState {
const filePath = resolveArtifactPath(artifact);
const filePath = resolveArtifactProtocolFilePath(artifact);
const displayName = resolveArtifactDisplayName(filePath);
switch (phase) {
@@ -19,7 +19,7 @@ const {
mockPromoteAgentRuntimeQueuedTurn,
mockRemoveAgentRuntimeQueuedTurn,
mockRespondAgentRuntimeAction,
mockParseStreamEvent,
mockParseAgentEvent,
mockSafeListen,
mockToast,
mockParseSkillSlashCommand,
@@ -41,7 +41,7 @@ const {
mockPromoteAgentRuntimeQueuedTurn: vi.fn(),
mockRemoveAgentRuntimeQueuedTurn: vi.fn(),
mockRespondAgentRuntimeAction: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
mockSafeListen: vi.fn(),
mockToast: {
success: vi.fn(),
@@ -74,9 +74,16 @@ vi.mock("@/lib/api/agentRuntime", () => ({
respondAgentRuntimeAction: mockRespondAgentRuntimeAction,
}));
vi.mock("@/lib/api/agentStream", () => ({
parseStreamEvent: mockParseStreamEvent,
}));
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
};
});
vi.mock("@/lib/dev-bridge", () => ({
safeListen: mockSafeListen,
@@ -243,7 +250,7 @@ beforeEach(() => {
mockPromoteAgentRuntimeQueuedTurn.mockReset();
mockRemoveAgentRuntimeQueuedTurn.mockReset();
mockRespondAgentRuntimeAction.mockReset();
mockParseStreamEvent.mockReset();
mockParseAgentEvent.mockReset();
mockSafeListen.mockReset();
mockParseSkillSlashCommand.mockReset();
mockTryExecuteSlashSkillCommand.mockReset();
@@ -272,7 +279,7 @@ beforeEach(() => {
mockPromoteAgentRuntimeQueuedTurn.mockResolvedValue(true);
mockRemoveAgentRuntimeQueuedTurn.mockResolvedValue(true);
mockRespondAgentRuntimeAction.mockResolvedValue(undefined);
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockParseAgentEvent.mockImplementation((payload: unknown) => payload);
mockSafeListen.mockResolvedValue(() => {});
mockParseSkillSlashCommand.mockReturnValue(null);
mockTryExecuteSlashSkillCommand.mockResolvedValue(false);
@@ -1527,14 +1534,14 @@ describe("useAsterAgentChat thread timeline", () => {
.sendMessage("帮我先开始处理", [], false, false, false, "react");
});
const optimisticTurnId = harness.getValue().currentTurnId;
expect(optimisticTurnId).toBeTruthy();
const pendingTurnKey = harness.getValue().currentTurnId;
expect(pendingTurnKey).toContain("pending-turn:");
expect(harness.getValue().turns).toHaveLength(1);
expect(harness.getValue().turns[0]?.id).toBe(optimisticTurnId);
expect(harness.getValue().turns[0]?.id).toBe(pendingTurnKey);
expect(harness.getValue().turns[0]?.status).toBe("running");
expect(harness.getValue().threadItems).toHaveLength(1);
expect(harness.getValue().threadItems[0]?.id).toBe(
`turn-summary:${optimisticTurnId}`,
`pending-item:${pendingTurnKey}`,
);
expect(harness.getValue().threadItems[0]?.type).toBe("turn_summary");
expect(harness.getValue().threadItems[0]?.status).toBe("in_progress");
@@ -1559,7 +1566,7 @@ describe("useAsterAgentChat thread timeline", () => {
expect(harness.getValue().turns[0]?.id).toBe("turn-real-1");
expect(harness.getValue().threadItems).toEqual([
expect.objectContaining({
id: `turn-summary:${optimisticTurnId}`,
id: `pending-item:${pendingTurnKey}`,
type: "turn_summary",
status: "in_progress",
turn_id: "turn-real-1",
@@ -1755,7 +1762,7 @@ describe("useAsterAgentChat thread timeline", () => {
]);
expect(harness.getValue().threadItems).toEqual([
expect.objectContaining({
id: expect.stringContaining("turn-summary:"),
id: expect.stringContaining("pending-item:"),
type: "turn_summary",
status: "failed",
turn_id: "turn-stream-error-1",
@@ -3303,6 +3310,63 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
}
});
it("write_file 工具启动时应递归识别嵌套参数中的协议路径", async () => {
const workspaceId = "ws-artifact-tool-start-nested";
seedSession(workspaceId, "session-artifact-tool-start-nested");
const onWriteFile = vi.fn();
const harness = mountHook(workspaceId, { onWriteFile });
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("生成嵌套文稿", [], false, false, false, "react");
});
act(() => {
stream.emit({
type: "tool_start",
tool_id: "tool-write-nested-1",
tool_name: "write_file",
arguments: JSON.stringify({
payload: {
filePath: "notes/nested.md",
content: "# Nested\n\nbody",
},
}),
});
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(assistantMessage?.artifacts?.[0]).toMatchObject({
title: "nested.md",
content: "",
status: "streaming",
meta: expect.objectContaining({
filePath: "notes/nested.md",
writePhase: "preparing",
source: "tool_start",
}),
});
expect(onWriteFile).toHaveBeenCalledWith(
"",
"notes/nested.md",
expect.objectContaining({
source: "tool_start",
status: "streaming",
}),
);
} finally {
harness.unmount();
}
});
it("apply_patch 工具启动时应立即暴露目标文件,避免工作台空白等待", async () => {
const workspaceId = "ws-artifact-apply-patch";
seedSession(workspaceId, "session-artifact-apply-patch");
@@ -3982,6 +4046,100 @@ describe("useAsterAgentChat 偏好持久化", () => {
}
});
it("切换话题时应优先从 execution_runtime 恢复 provider/model", async () => {
const workspaceId = "ws-topic-runtime-priority";
const topicId = "topic-runtime-priority";
localStorage.setItem(
`agent_pref_provider_${workspaceId}`,
JSON.stringify("deepseek"),
);
localStorage.setItem(
`agent_pref_model_${workspaceId}`,
JSON.stringify("deepseek-chat"),
);
mockGetAgentRuntimeSession.mockResolvedValue({
id: topicId,
messages: [],
execution_strategy: "react",
execution_runtime: {
session_id: topicId,
provider_selector: "openai",
provider_name: "openai",
model_name: "gpt-5.4-mini",
source: "session",
},
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().switchTopic(topicId);
});
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("openai");
expect(value.model).toBe("gpt-5.4-mini");
expect(
JSON.parse(
localStorage.getItem(
`agent_topic_model_pref_${workspaceId}_${topicId}`,
) || "null",
),
).toEqual({
providerType: "openai",
model: "gpt-5.4-mini",
});
} finally {
harness.unmount();
}
});
it("切换话题时 execution_runtime 缺失应回退本地 session preference", async () => {
const workspaceId = "ws-topic-runtime-fallback";
const topicId = "topic-runtime-fallback";
localStorage.setItem(
`agent_pref_provider_${workspaceId}`,
JSON.stringify("openai"),
);
localStorage.setItem(
`agent_pref_model_${workspaceId}`,
JSON.stringify("gpt-5.4-mini"),
);
localStorage.setItem(
`agent_topic_model_pref_${workspaceId}_${topicId}`,
JSON.stringify({
providerType: "gemini",
model: "gemini-2.5-pro",
}),
);
mockGetAgentRuntimeSession.mockResolvedValue({
id: topicId,
messages: [],
execution_strategy: "react",
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().switchTopic(topicId);
});
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
} finally {
harness.unmount();
}
});
it("切换话题时应保留工具调用历史并恢复 elicitation 回答文本", async () => {
const workspaceId = "ws-history-hydrate";
const now = Math.floor(Date.now() / 1000);
@@ -4515,7 +4673,7 @@ describe("useAsterAgentChat 兼容接口", () => {
}
});
it("发送请求时应透传 provider_id,避免 custom provider 类型丢失", async () => {
it("发送请求时应透传 provider 偏好,避免 custom provider 类型丢失", async () => {
const harness = mountHook("ws-provider-id");
const providerId = "custom-a32774c6-6fd0-433b-8b81-e95340e08793";
const model = "gpt-5.3-codex";
@@ -4535,12 +4693,12 @@ describe("useAsterAgentChat 兼容接口", () => {
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.provider_config,
).toMatchObject({
provider_id: providerId,
provider_name: providerId,
model_name: model,
});
?.provider_preference,
).toBe(providerId);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.model_preference,
).toBe(model);
} finally {
harness.unmount();
}
@@ -4570,11 +4728,199 @@ describe("useAsterAgentChat 兼容接口", () => {
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.provider_config,
).toMatchObject({
provider_id: selectedProvider,
?.provider_preference,
).toBe(selectedProvider);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.model_preference,
).toBe(selectedModel);
} finally {
harness.unmount();
}
});
it("已有 executionRuntime 且 provider/model 未变化时不应重复提交偏好", async () => {
const workspaceId = "ws-runtime-model-reuse";
const selectedProvider = "openai";
const selectedModel = "gpt-5.4-mini";
localStorage.setItem(
`agent_pref_provider_${workspaceId}`,
JSON.stringify(selectedProvider),
);
localStorage.setItem(
`agent_pref_model_${workspaceId}`,
JSON.stringify(selectedModel),
);
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-runtime-model-reuse",
created_at: Date.now(),
updated_at: Date.now(),
execution_strategy: "react",
execution_runtime: {
session_id: "topic-runtime-model-reuse",
provider_selector: selectedProvider,
provider_name: "openai",
model_name: selectedModel,
source: "session",
},
messages: [],
turns: [],
items: [],
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().switchTopic("topic-runtime-model-reuse");
});
await act(async () => {
await harness
.getValue()
.sendMessage("继续沿用当前模型处理", [], false, false, false, "react");
});
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.provider_preference,
).toBeUndefined();
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.model_preference,
).toBeUndefined();
} finally {
harness.unmount();
}
});
it("同 provider 切模型时应只提交 model 偏好", async () => {
const workspaceId = "ws-runtime-model-switch-same-provider";
const selectedProvider = "openai";
const currentModel = "gpt-5.4-mini";
const nextModel = "gpt-5.4";
localStorage.setItem(
`agent_pref_provider_${workspaceId}`,
JSON.stringify(selectedProvider),
);
localStorage.setItem(
`agent_pref_model_${workspaceId}`,
JSON.stringify(currentModel),
);
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-runtime-model-switch-same-provider",
created_at: Date.now(),
updated_at: Date.now(),
execution_strategy: "react",
execution_runtime: {
session_id: "topic-runtime-model-switch-same-provider",
provider_selector: selectedProvider,
provider_name: "openai",
model_name: currentModel,
source: "session",
},
messages: [],
turns: [],
items: [],
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().switchTopic("topic-runtime-model-switch-same-provider");
});
act(() => {
harness.getValue().setModel(nextModel);
});
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("切换到同 provider 的另一个模型", [], false, false, false, "react");
});
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.provider_preference,
).toBeUndefined();
expect(
mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config
?.model_preference,
).toBe(nextModel);
} finally {
harness.unmount();
}
});
it("流式 turn_context / model_change 应更新 executionRuntime,并在结束后仅保留 last runtime", async () => {
const stream = captureTurnStream();
const harness = mountHook("ws-execution-runtime");
try {
await flushEffects();
await act(async () => {
await harness.getValue().triggerAIGuide("请输出结构化结果");
});
await act(async () => {
stream.emit({
type: "turn_context",
session_id: "created-session",
thread_id: "created-session",
turn_id: "turn-runtime-1",
output_schema_runtime: {
source: "turn",
strategy: "native",
providerName: "openai",
modelName: "gpt-5.4",
},
});
});
expect(harness.getValue().executionRuntime).toMatchObject({
session_id: "created-session",
source: "turn_context",
provider_name: "openai",
model_name: "gpt-5.4",
});
expect(harness.getValue().activeExecutionRuntime).toMatchObject({
model_name: "gpt-5.4",
});
await act(async () => {
stream.emit({
type: "model_change",
model: "gpt-5.4-mini",
mode: "responses",
});
});
expect(harness.getValue().executionRuntime).toMatchObject({
source: "model_change",
model_name: "gpt-5.4-mini",
mode: "responses",
});
expect(harness.getValue().activeExecutionRuntime).toMatchObject({
model_name: "gpt-5.4-mini",
});
await act(async () => {
stream.emit({
type: "final_done",
});
});
expect(harness.getValue().executionRuntime).toMatchObject({
model_name: "gpt-5.4-mini",
});
expect(harness.getValue().activeExecutionRuntime).toBeNull();
} finally {
harness.unmount();
}
@@ -5,10 +5,10 @@
* useAsterAgentChat -> useAgentContext / useAgentSession / useAgentTools / useAgentStream
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { parseStreamEvent } from "@/lib/api/agentStream";
import { parseAgentEvent } from "@/lib/api/agentProtocol";
import { logAgentDebug } from "@/lib/agentDebug";
import {
executeCodexSlashCommand,
@@ -27,6 +27,7 @@ import {
type SendMessageFn,
type UseAsterAgentChatOptions,
} from "./agentChatShared";
import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime";
export type { Topic } from "./agentChatShared";
@@ -123,10 +124,12 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
setThreadItems: session.setThreadItems,
setThreadTurns: session.setThreadTurns,
setCurrentTurnId: session.setCurrentTurnId,
setExecutionRuntime: session.setExecutionRuntime,
queuedTurns: session.queuedTurns,
setQueuedTurns: session.setQueuedTurns,
setPendingActions: tools.setPendingActions,
refreshSessionReadModel: session.refreshSessionReadModel,
executionRuntime: session.executionRuntime,
});
const setChatMessages = session.setMessages;
const clearChatMessages = session.clearMessages;
@@ -244,6 +247,14 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
session.sessionId &&
session.topics.some((topic) => topic.id === session.sessionId),
);
const activeExecutionRuntime = useMemo<
AsterSessionExecutionRuntime | null
>(() => {
const threadStatus = session.threadRead?.status;
const shouldPreferRuntime =
stream.isSending || threadStatus === "running" || threadStatus === "queued";
return shouldPreferRuntime ? session.executionRuntime : null;
}, [session.executionRuntime, session.threadRead?.status, stream.isSending]);
const currentSessionId = session.sessionId;
const refreshActiveSessionDetail = session.refreshSessionDetail;
@@ -369,7 +380,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
const unlisten = await runtime.listenToTeamEvents(
eventName,
(event) => {
const data = parseStreamEvent(event.payload);
const data = parseAgentEvent(event.payload);
if (disposed || data?.type !== "subagent_status_changed") {
return;
}
@@ -522,6 +533,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
subagentParentContext: session.subagentParentContext,
queuedTurns: session.queuedTurns,
threadRead: session.threadRead,
executionRuntime: session.executionRuntime,
activeExecutionRuntime,
isSending: stream.isSending,
sendMessage,
compactSession: stream.compactSession,
@@ -36,29 +36,11 @@ function renderHook(props?: Partial<HookProps>) {
const root = createRoot(container);
const defaultProps: HookProps = {
activeTheme: "general",
projectId: "project-1",
sessionId: "session-1",
selectedTeam: createSelectedTeam(),
subagentEnabled: true,
hasRealTeamGraph: false,
generateRuntimeTeam: vi.fn(
async (
_options: Parameters<NonNullable<HookProps["generateRuntimeTeam"]>>[0],
): Promise<TeamDefinition> => ({
id: "ephemeral-1",
source: "ephemeral",
label: "临时 Team",
description: "自动生成",
roles: [
{
id: "member-1",
label: "执行者",
summary: "执行当前任务",
},
],
}),
),
createRequestId: () => "request-1",
now: () => 1_710_000_000_000,
};
@@ -117,77 +99,28 @@ describe("useRuntimeTeamFormation", () => {
vi.clearAllMocks();
});
it("发送前满足条件时应进入 forming 并在生成完成后切到 formed", async () => {
let resolveRuntimeTeam:
| ((team: TeamDefinition) => void)
| null = null;
const generateRuntimeTeam = vi.fn(
async (
_options: Parameters<NonNullable<HookProps["generateRuntimeTeam"]>>[0],
): Promise<TeamDefinition> =>
new Promise((resolve) => {
resolveRuntimeTeam = resolve;
}),
);
const { render, getValue } = renderHook({
generateRuntimeTeam,
});
it("发送前满足条件且已选 Team 时,应直接生成基于蓝图的 formed 状态", async () => {
const { render, getValue } = renderHook();
await render();
const resultRef: { current: RuntimeTeamPreparationResult } = {
current: null,
};
let pending:
| Promise<RuntimeTeamPreparationResult>
| null = null;
await act(async () => {
pending = getValue().prepareRuntimeTeamBeforeSend({
input: "请拆成两个子任务",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
resultRef.current = await getValue().prepareRuntimeTeamBeforeSend({
input: "请把这个需求从多个角度拆解成子任务,分别调研竞品方案和技术实现路径,然后给出综合分析报告",
});
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
expect(generateRuntimeTeam).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "project-1",
providerType: "openai",
model: "gpt-4.1",
input: "请拆成两个子任务",
activeTheme: "general",
}),
);
await act(async () => {
resolveRuntimeTeam?.({
id: "ephemeral-1",
source: "ephemeral",
label: "自动 Team",
description: "自动生成",
roles: [
{
id: "role-1",
label: "执行者",
summary: "负责执行",
},
],
});
resultRef.current = await pending;
});
expect(resultRef.current?.status).toBe("formed");
expect(getValue().runtimeTeamState?.status).toBe("formed");
expect(getValue().runtimeTeamState?.label).toBe("自动 Team");
expect(resultRef.current?.label).toBe("研究协作组");
expect("runtimeTeamState" in getValue()).toBe(false);
});
it("发送前 Team 生成失败时应返回 failed 状态", async () => {
it("未选择 Team 时,不应再触发发送前自动编队", async () => {
const { render, getValue } = renderHook({
generateRuntimeTeam: vi.fn(async () => {
throw new Error("模型规划失败");
}),
selectedTeam: null,
});
await render();
@@ -197,118 +130,102 @@ describe("useRuntimeTeamFormation", () => {
};
await act(async () => {
resultRef.current = await getValue().prepareRuntimeTeamBeforeSend({
input: "请拆解任务",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
input: "请从多个维度拆解这个复杂需求,先做竞品调研,再做技术方案评估,最后输出可行性分析报告",
});
});
expect(resultRef.current?.status).toBe("failed");
expect(resultRef.current?.errorMessage).toBe("模型规划失败");
expect(getValue().runtimeTeamState?.status).toBe("failed");
expect(resultRef.current).toBeNull();
expect("runtimeTeamState" in getValue()).toBe(false);
});
it("subagent 关闭且没有真实 team 图时,应清空 runtime team 状态", async () => {
it("hook 不再把 prepared team 挂成会话级本地状态", async () => {
const { render, getValue } = renderHook();
await render();
act(() => {
void getValue().triggerRuntimeTeamFormation({
input: "请先规划 team",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
const firstResultRef: { current: RuntimeTeamPreparationResult } = {
current: null,
};
await act(async () => {
firstResultRef.current = await getValue().prepareRuntimeTeamBeforeSend({
input: "请先规划一个多角色协作的 team 来处理这个跨部门需求,需要调研、开发、测试三个角色分别推进各自负责的子任务",
subagentEnabled: true,
});
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
await render({
subagentEnabled: false,
hasRealTeamGraph: false,
});
expect(getValue().runtimeTeamState).toBeNull();
expect(firstResultRef.current?.status).toBe("formed");
expect("runtimeTeamState" in getValue()).toBe(false);
});
it("本轮不走 Team 且没有真实 team 图时,应清空上一轮 runtime team 状态", async () => {
it("本轮不走 Team 时,应只返回 null 而不保留上一轮残留状态", async () => {
const { render, getValue } = renderHook();
await render();
await act(async () => {
await getValue().prepareRuntimeTeamBeforeSend({
input: "请拆分成多个角色执行",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
input: "请拆分成多个角色来协作执行这个跨领域的综合分析任务,需要数据分析师、行业研究员和报告撰写员分别负责各自模块",
});
});
expect(getValue().runtimeTeamState?.status).toBe("formed");
expect("runtimeTeamState" in getValue()).toBe(false);
const secondResultRef: { current: RuntimeTeamPreparationResult } = {
current: null,
};
await act(async () => {
await getValue().prepareRuntimeTeamBeforeSend({
secondResultRef.current = await getValue().prepareRuntimeTeamBeforeSend({
input: "请直接润色这段文案",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
purpose: "content_review",
});
});
expect(getValue().runtimeTeamState).toBeNull();
expect(secondResultRef.current).toBeNull();
expect("runtimeTeamState" in getValue()).toBe(false);
});
it("session 切换时应重置 runtime team 状态", async () => {
it("clearRuntimeTeamState 保留为空操作兼容壳", async () => {
const { render, getValue } = renderHook();
await render();
act(() => {
void getValue().triggerRuntimeTeamFormation({
input: "请先规划 team",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
});
await act(async () => {
getValue().clearRuntimeTeamState();
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
await render({
sessionId: "session-2",
});
expect(getValue().runtimeTeamState).toBeNull();
expect("runtimeTeamState" in getValue()).toBe(false);
});
});
describe("shouldPrepareRuntimeTeamBeforeSend", () => {
it("仅在 subagent 开启、存在项目、非 purpose 且输入非空时返回 true", () => {
it("输入足够长且非简单生成类请求时返回 true", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "project-1",
input: "请拆解任务",
input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案,最后做交叉验证",
}),
).toBe(true);
});
it("subagent 关闭时返回 false", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: false,
projectId: "project-1",
input: "请拆解任务",
input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案",
}),
).toBe(false);
});
it("无项目时返回 false", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "",
input: "请拆解任务",
input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案",
}),
).toBe(false);
});
it("空白输入时返回 false", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
@@ -316,13 +233,43 @@ describe("shouldPrepareRuntimeTeamBeforeSend", () => {
input: " ",
}),
).toBe(false);
});
it("带 purpose 时返回 false", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "project-1",
input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案",
purpose: "content_review",
}),
).toBe(false);
});
it("短输入时返回 false(少于 40 字符)", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "project-1",
input: "请拆解任务",
purpose: "content_review",
}),
).toBe(false);
});
it("简单内容生成类请求返回 false", () => {
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "project-1",
input: "生成一份关于人工智能在医疗领域应用的详细报告,需要包含市场分析、技术趋势、政策法规、典型案例和未来展望五个部分",
}),
).toBe(false);
expect(
shouldPrepareRuntimeTeamBeforeSend({
subagentEnabled: true,
projectId: "project-1",
input: "帮我写一篇关于可持续发展目标的深度分析报告,要求涵盖经济、环境和社会三个维度的详细分析以及各国实践案例",
}),
).toBe(false);
});
@@ -1,43 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import type { HandleSendOptions } from "./handleSendTypes";
import {
createRuntimeFormationStateFromTeam,
type TeamWorkspaceRuntimeFormationState,
} from "../teamWorkspaceRuntime";
import { generateEphemeralTeamWithModel } from "../utils/teamAutoGeneration";
import type { TeamDefinition } from "../utils/teamDefinitions";
interface TriggerRuntimeTeamFormationParams {
interface PrepareRuntimeTeamBeforeSendParams {
input: string;
providerType: string;
model: string;
executionStrategy?: "react" | "code_orchestrated" | "auto";
}
interface PrepareRuntimeTeamBeforeSendParams
extends TriggerRuntimeTeamFormationParams {
purpose?: HandleSendOptions["purpose"];
subagentEnabled?: boolean;
}
interface UseRuntimeTeamFormationOptions {
activeTheme: string;
projectId?: string | null;
sessionId?: string | null;
selectedTeam?: TeamDefinition | null;
subagentEnabled: boolean;
hasRealTeamGraph: boolean;
generateRuntimeTeam?: typeof generateEphemeralTeamWithModel;
createRequestId?: () => string;
now?: () => number;
}
export interface UseRuntimeTeamFormationResult {
runtimeTeamState: TeamWorkspaceRuntimeFormationState | null;
clearRuntimeTeamState: () => void;
triggerRuntimeTeamFormation: (
params: TriggerRuntimeTeamFormationParams,
) => Promise<TeamWorkspaceRuntimeFormationState | null>;
prepareRuntimeTeamBeforeSend: (
params: PrepareRuntimeTeamBeforeSendParams,
) => Promise<TeamWorkspaceRuntimeFormationState | null>;
@@ -53,114 +39,61 @@ export function shouldPrepareRuntimeTeamBeforeSend(params: {
input: string;
purpose?: HandleSendOptions["purpose"];
}): boolean {
return (
params.subagentEnabled &&
!params.purpose &&
Boolean(params.projectId) &&
params.input.trim().length > 0
);
if (!params.subagentEnabled || params.purpose || !params.projectId) {
return false;
}
const trimmed = params.input.trim();
if (trimmed.length === 0) {
return false;
}
// 短输入(少于 40 字符)大概率是简单请求,不需要团队协作
if (trimmed.length < 40) {
return false;
}
// 检测是否为简单内容生成类请求(生成提纲、写报告等),这类任务单 agent 即可完成
const simpleGenerationPatterns = [
/^(请|帮我|帮忙)?(生成|写|起草|草拟|撰写|创建|制作|输出)/,
/^(generate|write|create|draft|make)\b/i,
];
if (simpleGenerationPatterns.some((pattern) => pattern.test(trimmed))) {
return false;
}
return true;
}
export function useRuntimeTeamFormation({
activeTheme,
projectId,
sessionId,
selectedTeam,
subagentEnabled,
hasRealTeamGraph,
generateRuntimeTeam = generateEphemeralTeamWithModel,
createRequestId = defaultCreateRequestId,
now = () => Date.now(),
}: UseRuntimeTeamFormationOptions): UseRuntimeTeamFormationResult {
const [runtimeTeamState, setRuntimeTeamState] =
useState<TeamWorkspaceRuntimeFormationState | null>(null);
const runtimeTeamRequestIdRef = useRef<string | null>(null);
const clearRuntimeTeamState = useCallback(() => {
runtimeTeamRequestIdRef.current = null;
setRuntimeTeamState(null);
return;
}, []);
const triggerRuntimeTeamFormation = useCallback(
async ({
input,
providerType,
model,
executionStrategy,
}: TriggerRuntimeTeamFormationParams) => {
const normalizedInput = input.trim();
if (!projectId || !normalizedInput) {
return null;
}
const formRuntimeTeamState = useCallback(() => {
if (!selectedTeam) {
return null;
}
const requestId = createRequestId();
runtimeTeamRequestIdRef.current = requestId;
const formingState = createRuntimeFormationStateFromTeam({
requestId,
status: "forming",
blueprintTeam: selectedTeam ?? null,
updatedAt: now(),
});
setRuntimeTeamState(formingState);
try {
const runtimeTeam = await generateRuntimeTeam({
workspaceId: projectId,
providerType,
model,
executionStrategy,
activeTheme,
input: normalizedInput,
blueprintTeam: selectedTeam ?? null,
});
if (runtimeTeamRequestIdRef.current !== requestId) {
return null;
}
const formedState = createRuntimeFormationStateFromTeam({
requestId,
status: "formed",
runtimeTeam,
blueprintTeam: selectedTeam ?? null,
updatedAt: now(),
});
setRuntimeTeamState(formedState);
return formedState;
} catch (error) {
if (runtimeTeamRequestIdRef.current !== requestId) {
return null;
}
const errorMessage =
error instanceof Error ? error.message : "Team 生成失败";
const failedState = createRuntimeFormationStateFromTeam({
requestId,
status: "failed",
blueprintTeam: selectedTeam ?? null,
errorMessage,
updatedAt: now(),
});
setRuntimeTeamState(failedState);
return failedState;
}
},
[
activeTheme,
createRequestId,
generateRuntimeTeam,
now,
projectId,
selectedTeam,
],
);
const formedState = createRuntimeFormationStateFromTeam({
requestId: createRequestId(),
status: "formed",
runtimeTeam: selectedTeam,
blueprintTeam: selectedTeam,
updatedAt: now(),
});
return formedState;
}, [createRequestId, now, selectedTeam]);
const prepareRuntimeTeamBeforeSend = useCallback(
({
input,
providerType,
model,
executionStrategy,
purpose,
subagentEnabled: subagentEnabledOverride,
}: PrepareRuntimeTeamBeforeSendParams) => {
@@ -172,45 +105,19 @@ export function useRuntimeTeamFormation({
projectId,
input,
purpose,
})
}) &&
selectedTeam
) {
return triggerRuntimeTeamFormation({
input,
providerType,
model,
executionStrategy,
});
}
if (!hasRealTeamGraph) {
clearRuntimeTeamState();
return Promise.resolve(formRuntimeTeamState());
}
return Promise.resolve(null);
},
[
clearRuntimeTeamState,
hasRealTeamGraph,
projectId,
subagentEnabled,
triggerRuntimeTeamFormation,
],
[formRuntimeTeamState, projectId, selectedTeam, subagentEnabled],
);
useEffect(() => {
clearRuntimeTeamState();
}, [clearRuntimeTeamState, sessionId]);
useEffect(() => {
if (!subagentEnabled && !hasRealTeamGraph) {
clearRuntimeTeamState();
}
}, [clearRuntimeTeamState, hasRealTeamGraph, subagentEnabled]);
return {
runtimeTeamState,
clearRuntimeTeamState,
triggerRuntimeTeamFormation,
prepareRuntimeTeamBeforeSend,
};
}
@@ -6,20 +6,20 @@ import { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime";
const {
mockSafeListen,
mockParseStreamEvent,
mockParseAgentEvent,
} = vi.hoisted(() => ({
mockSafeListen: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
}));
vi.mock("@/lib/api/agentStream", async () => {
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentStream")>(
"@/lib/api/agentStream",
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
return {
...actual,
parseStreamEvent: mockParseStreamEvent,
parseAgentEvent: mockParseAgentEvent,
};
});
@@ -88,7 +88,7 @@ describe("useTeamWorkspaceRuntime", () => {
).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
latestValue = null;
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockParseAgentEvent.mockImplementation((payload: unknown) => payload);
mockSafeListen.mockImplementation(
async (_eventName: string, _handler: (event: { payload: unknown }) => void) =>
() => {},
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
parseStreamEvent,
type StreamEvent,
type StreamRuntimeStatusPayload,
type ToolExecutionResult,
} from "@/lib/api/agentStream";
type AgentRuntimeStatusPayload,
type AgentToolExecutionResult as ToolExecutionResult,
parseAgentEvent,
type AgentEvent,
} from "@/lib/api/agentProtocol";
import type {
AsterSubagentParentContext,
AsterSubagentSessionInfo,
@@ -147,7 +147,7 @@ function buildThinkingDraftEntry(sessionId: string, draft?: string) {
function buildRuntimeStatusEntry(
sessionId: string,
status: StreamRuntimeStatusPayload,
status: AgentRuntimeStatusPayload,
) {
const waiting =
status.metadata?.team_phase === "queued" ||
@@ -164,7 +164,7 @@ function buildRuntimeStatusEntry(
});
}
function buildRuntimeStatusMetadataPatch(status: StreamRuntimeStatusPayload) {
function buildRuntimeStatusMetadataPatch(status: AgentRuntimeStatusPayload) {
return {
teamPhase: status.metadata?.team_phase,
teamParallelBudget: status.metadata?.team_parallel_budget,
@@ -408,7 +408,7 @@ function resolveFinalRuntimeStatus(params: {
function projectRuntimeStreamEvent(params: {
sessionId: string;
session: TeamWorkspaceRuntimeSessionSnapshot;
event: StreamEvent;
event: AgentEvent;
currentRuntime?: TeamWorkspaceLiveRuntimeState;
streamState?: SessionLiveStreamState;
toolNameById?: Record<string, string>;
@@ -926,7 +926,7 @@ export function useTeamWorkspaceRuntime(
const subscribe = async () => {
for (const eventName of eventNames) {
const unlisten = await safeListen(eventName, (event) => {
const data = parseStreamEvent(event.payload);
const data = parseAgentEvent(event.payload);
if (disposed || data?.type !== "subagent_status_changed") {
return;
}
@@ -1039,7 +1039,7 @@ export function useTeamWorkspaceRuntime(
for (const sessionId of sessionIds) {
const eventName = `agent_subagent_stream:${sessionId}`;
const unlisten = await safeListen(eventName, (event) => {
const data = parseStreamEvent(event.payload);
const data = parseAgentEvent(event.payload);
if (disposed || !data) {
return;
}
@@ -412,4 +412,55 @@ describe("useThemeContextWorkspace", () => {
"social-posts/demo.publish-pack.json",
]);
});
it("应递归提取嵌套协议对象中的产物路径", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
const messages = [
{
id: "msg-4",
role: "assistant",
content: "",
timestamp: "2026-03-05T10:50:00.000Z",
toolCalls: [
{
id: "tool-4",
name: "typesetting",
status: "completed",
arguments: JSON.stringify({
payload: {
filePath: "social-posts/draft.md",
},
}),
result: {
success: true,
output: JSON.stringify({
result: {
absolute_path: "/tmp/social-posts/final.md",
},
}),
},
startTime: "2026-03-05T10:50:00.000Z",
endTime: "2026-03-05T10:50:01.000Z",
},
],
},
] as unknown as Message[];
let snapshot: ThemeContextWorkspaceState | null = null;
mountProbe({
projectId: "project-artifact-nested-path",
activeTheme: "social-media",
messages,
onSnapshot: (value) => {
snapshot = value;
},
});
await flushEffects(12);
expect(snapshot!.activityLogs[0]?.artifactPaths).toEqual([
"social-posts/draft.md",
"/tmp/social-posts/final.md",
]);
});
});
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getContent, listContents, type ContentListItem } from "@/lib/api/project";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import { normalizeProjectId } from "../utils/topicProjectResolution";
import { useMaterials } from "@/hooks/useMaterials";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
@@ -270,67 +271,6 @@ function formatDurationLabel(
)}s`;
}
const TOOL_ARTIFACT_KEYWORDS = [
"path",
"file",
"filename",
"artifact",
"output",
"target",
"destination",
];
function isLikelyArtifactPath(value: string): boolean {
const normalized = value.trim();
if (!normalized || normalized.length > 260) {
return false;
}
if (normalized.includes("\n")) {
return false;
}
const lower = normalized.toLowerCase();
if (
lower.startsWith("http://") ||
lower.startsWith("https://") ||
lower.startsWith("data:")
) {
return false;
}
return /[\\/]/.test(normalized) || /\.[a-z0-9]{1,10}$/i.test(normalized);
}
function collectArtifactPathFromValue(value: unknown, bucket: Set<string>): void {
if (typeof value === "string") {
const candidate = value.trim();
if (isLikelyArtifactPath(candidate)) {
bucket.add(candidate);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item) => collectArtifactPathFromValue(item, bucket));
return;
}
if (!value || typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
Object.entries(record).forEach(([key, nestedValue]) => {
const lowerKey = key.toLowerCase();
const shouldCollectDirectly = TOOL_ARTIFACT_KEYWORDS.some((keyword) =>
lowerKey.includes(keyword),
);
if (shouldCollectDirectly) {
collectArtifactPathFromValue(nestedValue, bucket);
return;
}
if (nestedValue && typeof nestedValue === "object") {
collectArtifactPathFromValue(nestedValue, bucket);
}
});
}
function tryParseJson(raw?: string): unknown {
if (!raw || typeof raw !== "string") {
return null;
@@ -354,11 +294,11 @@ function extractToolCallArtifactPaths(
const parsedArgs = tryParseJson(argumentsRaw);
const parsedOutput = tryParseJson(outputRaw);
if (parsedArgs) {
collectArtifactPathFromValue(parsedArgs, bucket);
for (const path of extractArtifactProtocolPathsFromValue(parsedArgs)) {
bucket.add(path);
}
if (parsedOutput) {
collectArtifactPathFromValue(parsedOutput, bucket);
for (const path of extractArtifactProtocolPathsFromValue(parsedOutput)) {
bucket.add(path);
}
return Array.from(bucket);
@@ -127,4 +127,84 @@ describe("useThemeScopedChatToolPreferences", () => {
harness.unmount();
}
});
it("收到 runtime 快照时应优先回灌工具偏好", () => {
saveChatToolPreferences(
{ webSearch: false, thinking: false, task: true, subagent: false },
"general",
);
const harness = mountHook("general");
try {
act(() => {
harness.getValue().syncChatToolPreferencesSource("general", {
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
});
expect(harness.getValue().chatToolPreferences).toEqual({
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
expect(
JSON.parse(
localStorage.getItem("lime.chat.tool_preferences.general.v3") ||
"null",
),
).toEqual({
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
} finally {
harness.unmount();
}
});
it("相同 runtime 快照不应覆盖用户手动切换的偏好", () => {
const harness = mountHook("general");
try {
act(() => {
harness.getValue().syncChatToolPreferencesSource("general", {
webSearch: true,
thinking: false,
task: false,
subagent: false,
});
});
act(() => {
harness.getValue().setChatToolPreferences({
webSearch: false,
thinking: true,
task: true,
subagent: true,
});
});
act(() => {
harness.getValue().syncChatToolPreferencesSource("general", {
webSearch: true,
thinking: false,
task: false,
subagent: false,
});
});
expect(harness.getValue().chatToolPreferences).toEqual({
webSearch: false,
thinking: true,
task: true,
subagent: true,
});
} finally {
harness.unmount();
}
});
});
@@ -1,35 +1,60 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadChatToolPreferences,
saveChatToolPreferences,
type ChatToolPreferences,
} from "../utils/chatToolPreferences";
function serializePreferenceSource(
preferences?: ChatToolPreferences | null,
): string {
if (!preferences) {
return "storage";
}
return JSON.stringify([
preferences.webSearch,
preferences.thinking,
preferences.task,
preferences.subagent,
]);
}
export function useThemeScopedChatToolPreferences(activeTheme: string) {
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() => loadChatToolPreferences(activeTheme));
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
useState<string>(activeTheme);
const lastHydratedSourceRef = useRef(
`${activeTheme}:${serializePreferenceSource(null)}`,
);
const syncChatToolPreferencesSource = useCallback(
(theme: string, runtimePreferences?: ChatToolPreferences | null) => {
const nextSourceKey = `${theme}:${serializePreferenceSource(
runtimePreferences,
)}`;
if (lastHydratedSourceRef.current === nextSourceKey) {
return;
}
setChatToolPreferences(
runtimePreferences ?? loadChatToolPreferences(theme),
);
lastHydratedSourceRef.current = nextSourceKey;
},
[],
);
useEffect(() => {
if (chatToolPreferencesTheme === activeTheme) {
return;
}
setChatToolPreferences(loadChatToolPreferences(activeTheme));
setChatToolPreferencesTheme(activeTheme);
}, [activeTheme, chatToolPreferencesTheme]);
syncChatToolPreferencesSource(activeTheme);
}, [activeTheme, syncChatToolPreferencesSource]);
useEffect(() => {
if (chatToolPreferencesTheme !== activeTheme) {
return;
}
saveChatToolPreferences(chatToolPreferences, activeTheme);
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
}, [activeTheme, chatToolPreferences]);
return {
chatToolPreferences,
setChatToolPreferences,
syncChatToolPreferencesSource,
};
}
@@ -20,7 +20,7 @@ import {
loadConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
import { buildProviderModelsFromRegistry } from "@/hooks/useProviderModels";
import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog";
import { filterModelsByTheme } from "../utils/modelThemePolicy";
import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility";
+201 -390
View File
@@ -13,7 +13,6 @@ const {
mockUseTopicBranchBoard,
mockUseTeamWorkspaceRuntime,
mockUseCompatSubagentRuntime,
mockGenerateEphemeralTeamWithModel,
mockGetProject,
mockGetDefaultProject,
mockGetOrCreateDefaultProject,
@@ -58,7 +57,6 @@ const {
mockUseTopicBranchBoard: vi.fn(),
mockUseTeamWorkspaceRuntime: vi.fn(),
mockUseCompatSubagentRuntime: vi.fn(),
mockGenerateEphemeralTeamWithModel: vi.fn(),
mockGetProject: vi.fn(),
mockGetDefaultProject: vi.fn(),
mockGetOrCreateDefaultProject: vi.fn(),
@@ -173,10 +171,6 @@ vi.mock("./hooks", () => ({
useCompatSubagentRuntime: mockUseCompatSubagentRuntime,
}));
vi.mock("./utils/teamAutoGeneration", () => ({
generateEphemeralTeamWithModel: mockGenerateEphemeralTeamWithModel,
}));
vi.mock("./hooks/useSessionFiles", () => ({
useSessionFiles: () => ({
saveFile: vi.fn(async () => undefined),
@@ -429,14 +423,14 @@ vi.mock("./components/TeamWorkspaceDock", () => ({
withBottomOverlay,
shellVisible,
childSubagentSessions,
runtimeTeamState,
teamDispatchPreviewState,
onActivateWorkbench,
}: {
placement?: "floating" | "inline";
withBottomOverlay?: boolean;
shellVisible?: boolean;
childSubagentSessions?: Array<{ id: string }>;
runtimeTeamState?: {
teamDispatchPreviewState?: {
status?: string;
members?: Array<{ id: string }>;
} | null;
@@ -448,8 +442,8 @@ vi.mock("./components/TeamWorkspaceDock", () => ({
data-with-bottom-overlay={withBottomOverlay ? "true" : "false"}
data-shell-visible={shellVisible ? "true" : "false"}
data-child-count={String(childSubagentSessions?.length ?? 0)}
data-runtime-status={runtimeTeamState?.status || ""}
data-runtime-member-count={String(runtimeTeamState?.members?.length ?? 0)}
data-runtime-status={teamDispatchPreviewState?.status || ""}
data-runtime-member-count={String(teamDispatchPreviewState?.members?.length ?? 0)}
>
{onActivateWorkbench ? (
<button
@@ -635,10 +629,17 @@ vi.mock("@/lib/api/skills", () => ({
},
}));
vi.mock("@/lib/webview-api", () => ({
launchBrowserSession: mockLaunchBrowserSession,
browserExecuteAction: mockBrowserExecuteAction,
}));
vi.mock("@/lib/webview-api", async () => {
const actual = await vi.importActual<typeof import("@/lib/webview-api")>(
"@/lib/webview-api",
);
return {
...actual,
launchBrowserSession: mockLaunchBrowserSession,
browserExecuteAction: mockBrowserExecuteAction,
};
});
vi.mock("@/lib/api/agentRuntime", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
@@ -823,6 +824,26 @@ async function flushEffects(times = 6) {
}
}
function getSendMessageCall(callIndex = 0) {
const call = sharedSendMessageMock.mock.calls[callIndex];
if (!call) {
throw new Error(`未找到第 ${callIndex + 1} 次 sendMessage 调用`);
}
return {
raw: call,
content: call[0],
images: call[1],
webSearch: call[2],
thinking: call[3],
skipUserMessage: call[4],
executionStrategy: call[5],
modelOverride: call[6],
autoContinue: call[7],
options: call[8],
};
}
function clickButton(container: HTMLElement, testId: string) {
const button = container.querySelector(
`[data-testid="${testId}"]`,
@@ -1741,29 +1762,13 @@ describe("AgentChatPage 通用工作台", () => {
).not.toBeNull();
});
it("Team 组建中先展示本地调度预览,编队完成后应携带 team_prepared metadata 再发送", async () => {
let resolveGeneratedTeam:
| ((value: {
id: string;
source: "ephemeral";
label: string;
description: string;
roles: Array<{
id: string;
label: string;
summary: string;
profileId: string;
roleKey: string;
skillIds: string[];
}>;
}) => void)
| null = null;
mockGenerateEphemeralTeamWithModel.mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneratedTeam = resolve;
}),
it("发送时不再先调用本地 Team 规划模型,而是直接发送并透传已选 Team 约束", async () => {
localStorage.setItem(
"lime.chat.team_selection.v1.general",
JSON.stringify({
id: "code-triage-team",
source: "builtin",
}),
);
const mounted = mountPage({
@@ -1800,128 +1805,38 @@ describe("AgentChatPage 通用工作台", () => {
});
await flushEffects(8);
expect(mockGenerateEphemeralTeamWithModel).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "project-team-runtime",
providerType: "kiro",
model: "mock-model",
input: "请帮我拆解并推进这个修复任务",
}),
);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as
| {
messages?: Array<{
role: string;
content: string;
runtimeStatus?: { title?: string };
}>;
}
| undefined;
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
expect(
latestMessageListProps?.messages?.map((message) => ({
role: message.role,
content: message.content,
runtimeTitle: message.runtimeStatus?.title || null,
})),
).toEqual([
{
role: "user",
content: "请帮我拆解并推进这个修复任务",
runtimeTitle: null,
},
{
role: "assistant",
content: "我会先安排协作分工,再把关键进展和结果汇总给你。",
runtimeTitle: "正在组建 Team",
},
]);
const runtimeDock = mounted.container.querySelector(
'[data-testid="team-workspace-dock"]',
);
expect(runtimeDock).not.toBeNull();
expect(runtimeDock?.getAttribute("data-runtime-status")).toBe("forming");
expect(
(
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
| {
teamView?: {
triggerState?: { label?: string | null };
} | null;
}
| undefined
)?.teamView?.triggerState?.label,
).toBe("组建中");
act(() => {
resolveGeneratedTeam?.({
id: "ephemeral-team",
source: "ephemeral",
label: "修复 Team",
description: "分析、执行、验证协作闭环。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责落地修复方案。",
profileId: "code-executor",
roleKey: "executor",
skillIds: ["bounded-implementation"],
},
],
});
});
await flushEffects(10);
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
"请帮我拆解并推进这个修复任务",
[],
false,
false,
false,
"auto",
"mock-model",
undefined,
expect.objectContaining({
assistantDraft: expect.objectContaining({
content: expect.stringContaining("修复 Team"),
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe("请帮我拆解并推进这个修复任务");
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBe("auto");
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
const sendOptions = sendCall.options;
expect(sendOptions?.requestMetadata?.harness).toMatchObject({
preferred_team_preset_id: "code-triage-team",
selected_team_id: "code-triage-team",
selected_team_source: "builtin",
selected_team_label: "代码排障团队",
selected_team_roles: expect.arrayContaining([
expect.objectContaining({
id: "explorer",
label: "分析",
profile_id: "code-explorer",
}),
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
turn_team_decision: "team_prepared",
turn_team_reason: "runtime_team_prepared",
turn_team_blueprint: expect.objectContaining({
label: "修复 Team",
roles: expect.arrayContaining([
expect.objectContaining({
id: "explorer",
label: "分析",
}),
expect.objectContaining({
id: "executor",
label: "执行",
}),
]),
}),
}),
expect.objectContaining({
id: "executor",
label: "执行",
profile_id: "code-executor",
}),
}),
);
]),
});
expect(sendOptions?.requestMetadata?.harness?.turn_team_decision).toBeUndefined();
expect(sendOptions?.requestMetadata?.harness?.turn_team_blueprint).toBeUndefined();
expect(sendOptions?.assistantDraft).toBeUndefined();
expect(
mounted.container
@@ -1931,58 +1846,14 @@ describe("AgentChatPage 通用工作台", () => {
const formedRuntimeDock = mounted.container.querySelector(
'[data-testid="team-workspace-dock"]',
);
expect(formedRuntimeDock).not.toBeNull();
expect(formedRuntimeDock?.getAttribute("data-runtime-status")).toBe(
"formed",
);
expect(formedRuntimeDock?.getAttribute("data-runtime-member-count")).toBe(
"2",
);
expect(
(
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
| {
teamView?: {
triggerState?: { label?: string | null };
} | null;
}
| undefined
)?.teamView?.triggerState?.label,
).toBe("已就绪");
const updatedMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as
| {
messages?: Array<{
role: string;
content: string;
runtimeStatus?: { title?: string };
}>;
}
| undefined;
expect(updatedMessageListProps?.messages?.at(-1)?.content).toContain(
"我已经为这项任务准备了「修复 Team」。",
);
expect(updatedMessageListProps?.messages?.at(-1)?.content).toContain(
"1. 分析:负责定位问题边界。",
);
if (formedRuntimeDock) {
expect(formedRuntimeDock.getAttribute("data-runtime-status")).not.toBe(
"forming",
);
}
});
it("用户手动关闭真实 Team 画布后,同一轮后续成员更新不应再次抢焦点", async () => {
let resolveGeneratedTeam:
| ((value: {
id: string;
source: "ephemeral";
label: string;
description: string;
roles: Array<{
id: string;
label: string;
summary: string;
profileId: string;
roleKey: string;
skillIds: string[];
}>;
}) => void)
| null = null;
const runtimeState = {
childSubagentSessions: [] as Array<{
id: string;
@@ -1996,11 +1867,12 @@ describe("AgentChatPage 通用工作台", () => {
}>,
};
mockGenerateEphemeralTeamWithModel.mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneratedTeam = resolve;
}),
localStorage.setItem(
"lime.chat.team_selection.v1.general",
JSON.stringify({
id: "code-triage-team",
source: "builtin",
}),
);
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
@@ -2090,34 +1962,6 @@ describe("AgentChatPage 通用工作台", () => {
?.getAttribute("data-mode"),
).toBe("chat");
act(() => {
resolveGeneratedTeam?.({
id: "ephemeral-team-manual",
source: "ephemeral",
label: "修复 Team",
description: "分析、执行、验证协作闭环。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责落地修复方案。",
profileId: "code-executor",
roleKey: "executor",
skillIds: ["bounded-implementation"],
},
],
});
});
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
@@ -2317,28 +2161,12 @@ describe("AgentChatPage 通用工作台", () => {
});
it("仅有 runtime Team 方案时,顶部展开与协作按钮都应能手动打开 Team 画布", async () => {
let resolveGeneratedTeam:
| ((value: {
id: string;
source: "ephemeral";
label: string;
description: string;
roles: Array<{
id: string;
label: string;
summary: string;
profileId: string;
roleKey: string;
skillIds: string[];
}>;
}) => void)
| null = null;
mockGenerateEphemeralTeamWithModel.mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneratedTeam = resolve;
}),
localStorage.setItem(
"lime.chat.team_selection.v1.general",
JSON.stringify({
id: "code-triage-team",
source: "builtin",
}),
);
const mounted = mountPage({
@@ -2375,26 +2203,6 @@ describe("AgentChatPage 通用工作台", () => {
});
await flushEffects(8);
act(() => {
resolveGeneratedTeam?.({
id: "ephemeral-team-manual-open",
source: "ephemeral",
label: "修复 Team",
description: "分析、执行、验证协作闭环。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
],
});
});
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
@@ -2420,7 +2228,7 @@ describe("AgentChatPage 通用工作台", () => {
}
| undefined
)?.teamView?.title,
).toBe("修复 Team");
).toBe("代码排障团队");
expect(
typeof (
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
@@ -2462,7 +2270,7 @@ describe("AgentChatPage 通用工作台", () => {
}
| undefined
)?.teamView?.title,
).toBe("修复 Team");
).toBe("代码排障团队");
});
it("已安装 skills 但未显式激活时,通用工作台不应展示技能区块", async () => {
@@ -2907,15 +2715,17 @@ describe("AgentChatPage 通用工作台", () => {
},
timeout_ms: 20000,
});
expect(sharedSendMessageMock).toHaveBeenCalledWith(
prompt,
[],
false,
false,
false,
undefined,
"mock-model",
undefined,
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(prompt);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3068,15 +2878,17 @@ describe("AgentChatPage 通用工作台", () => {
});
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
prompt,
[],
false,
false,
false,
"auto",
"mock-model",
undefined,
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(prompt);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBe("auto");
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
browserPreflightConfirmed: true,
requestMetadata: expect.objectContaining({
@@ -3275,15 +3087,17 @@ describe("AgentChatPage 自动引导", () => {
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
`/social_post_with_cover ${initialUserPrompt}`,
[],
false,
false,
false,
undefined,
"mock-model",
undefined,
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(`/social_post_with_cover ${initialUserPrompt}`);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3321,15 +3135,19 @@ describe("AgentChatPage 自动引导", () => {
clickButton(contextContainer, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(
`/social_post_with_cover [生效上下文]\n1. [素材] 品牌手册\n\n${initialUserPrompt}`,
[],
false,
false,
false,
undefined,
"mock-model",
undefined,
);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3399,18 +3217,24 @@ describe("AgentChatPage 自动引导", () => {
const selectedModelContainer = mountedRoots.at(-1)
?.container as HTMLDivElement;
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestSelectedModelProps = mockInputbar.mock.calls.at(-1)?.[0] as
| { model?: string }
| undefined;
expect(latestSelectedModelProps?.model).toBe(selectedModel);
clickButton(selectedModelContainer, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
`/social_post_with_cover ${initialUserPrompt}`,
[],
false,
false,
false,
undefined,
selectedModel,
undefined,
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(`/social_post_with_cover ${initialUserPrompt}`);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3480,15 +3304,19 @@ describe("AgentChatPage 自动引导", () => {
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(
"/social_post_with_cover 请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“撰写主稿”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。",
[],
false,
false,
false,
undefined,
expect.any(String),
undefined,
);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3742,15 +3570,19 @@ describe("AgentChatPage 自动引导", () => {
clickButton(mounted.container, "theme-workbench-entry-continue");
await flushEffects(10);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(
"/social_post_with_cover 请基于当前上下文直接开始生成首版社媒主稿。",
[],
false,
false,
false,
undefined,
expect.any(String),
undefined,
);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
@@ -3786,33 +3618,7 @@ describe("AgentChatPage 自动引导", () => {
expect(latestInputbarProps?.input || "").toBe("");
});
it("附图发送时若当前模型不支持多模态应提示并阻止发送", async () => {
vi.spyOn(providerModelsModule, "loadProviderModels").mockResolvedValueOnce([
buildMockProviderModel({
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
}),
buildMockProviderModel({
id: "mock-model-vision",
display_name: "Mock Model Vision",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
release_date: "2026-03-20",
}),
]);
it("附图发送时应透传图片,由 Inputbar 独立负责多模态模型提醒", async () => {
renderPage({
projectId: "project-image-vision-block",
theme: "general",
@@ -3832,20 +3638,23 @@ describe("AgentChatPage 自动引导", () => {
}
| undefined;
await act(async () => {
await latestInputbarProps?.onSend?.(
[{ data: "aGVsbG8=", mediaType: "image/png" }],
false,
false,
"请看图",
"auto",
);
const images = [{ data: "aGVsbG8=", mediaType: "image/png" }];
act(() => {
void latestInputbarProps?.onSend?.(images, false, false, "请看图", "auto");
});
await flushEffects(8);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
expect(mockToast.error).toHaveBeenCalledWith(
"当前模型 mock-model 不支持多模态图片理解,请切换到 mock-model-vision 或其他支持多模态的模型后再发送图片",
);
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe("请看图");
expect(sendCall.images).toEqual(images);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBe("auto");
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(mockToast.error).not.toHaveBeenCalled();
});
it("主题工作台空闲时应把 success 终态版本标记为 merged", async () => {
@@ -5251,15 +5060,17 @@ describe("AgentChatPage 小说主题工作台", () => {
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
initialUserPrompt,
[],
false,
false,
false,
undefined,
"mock-model",
undefined,
expect(sharedSendMessageMock).toHaveBeenCalledTimes(1);
const sendCall = getSendMessageCall();
expect(sendCall.content).toBe(initialUserPrompt);
expect(sendCall.images).toEqual([]);
expect(sendCall.webSearch).toBe(false);
expect(sendCall.thinking).toBe(false);
expect(sendCall.skipUserMessage).toBe(false);
expect(sendCall.executionStrategy).toBeUndefined();
expect(sendCall.modelOverride).toBeUndefined();
expect(sendCall.autoContinue).toBeUndefined();
expect(sendCall.options).toEqual(
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
+5
View File
@@ -160,6 +160,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) {
agentEntry={effectiveAgentEntry}
showChatPanel={effectiveShowChatPanel}
projectId={activeBootstrap?.projectId ?? projectId}
contentId={activeBootstrap?.contentId ?? contentId}
theme={activeBootstrap?.theme ?? theme}
initialCreationMode={
activeBootstrap?.initialCreationMode ?? initialCreationMode
@@ -170,6 +171,10 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) {
initialUserImages={
activeBootstrap?.initialUserImages ?? initialUserImages
}
initialRequestMetadata={
activeBootstrap?.initialRequestMetadata ??
props.initialRequestMetadata
}
newChatAt={activeBootstrap?.newChatAt ?? newChatAt}
openBrowserAssistOnMount={
activeBootstrap?.openBrowserAssistOnMount ?? openBrowserAssistOnMount
@@ -0,0 +1,120 @@
/* global process */
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const PROTOCOL_GUARD_DIRS = [
join(process.cwd(), "src/components/agent/chat"),
join(process.cwd(), "src/components/terminal/ai"),
join(process.cwd(), "src/components/smart-input"),
] as const;
const API_PROTOCOL_GUARD_DIR = join(process.cwd(), "src/lib/api");
function collectTsFiles(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...collectTsFiles(fullPath));
continue;
}
if (!fullPath.endsWith(".ts") && !fullPath.endsWith(".tsx")) {
continue;
}
if (fullPath.endsWith(".test.ts") || fullPath.endsWith(".test.tsx")) {
continue;
}
files.push(fullPath);
}
return files;
}
const FORBIDDEN_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
{
label: "artifact protocol 旧浅层 path fallback",
pattern: /args\.path\s*\|\|\s*args\.file_path\s*\|\|\s*args\.filePath/,
},
{
label: "artifact protocol 私有键名单",
pattern:
/\[\s*"path",\s*"file_path",\s*"filePath",[\s\S]{0,400}"artifact_paths",\s*"artifactPaths"[\s\S]{0,400}"source_file_name",\s*"sourceFileName"\s*\]/m,
},
{
label: "filesystem event protocol 私有键名单",
pattern:
/\[\s*"path",\s*"file_path",\s*"filePath",[\s\S]{0,400}"file_name",\s*"fileName",\s*"filename"[\s\S]{0,400}"new_path",\s*"newPath"[\s\S]{0,400}"files"\s*\]/m,
},
{
label: "filesystem event 目录位置 hint 旧 readString fallback",
pattern: /readString\(\s*(?:args|metadata)\s*,\s*\["directory",\s*"cwd"\]\s*\)/,
},
{
label: "filesystem event 输出位置 hint 旧 readString fallback",
pattern:
/readString\(\s*(?:args|metadata)\s*,\s*\["output_file",\s*"offload_file"\]\s*\)/,
},
];
describe("agent chat protocol fact source guard", () => {
it("不应在聊天工作台代码中重新引入私有协议键名单", () => {
const files = PROTOCOL_GUARD_DIRS.flatMap((dir) => collectTsFiles(dir));
const offenders: string[] = [];
for (const filePath of files) {
const content = readFileSync(filePath, "utf8");
for (const { label, pattern } of FORBIDDEN_PATTERNS) {
if (pattern.test(content)) {
offenders.push(`${relative(process.cwd(), filePath)} -> ${label}`);
}
}
}
expect(offenders).toEqual([]);
});
it("不应重新直接 import agentStream 旧协议出口", () => {
const files = PROTOCOL_GUARD_DIRS.flatMap((dir) => collectTsFiles(dir));
const offenders: string[] = [];
for (const filePath of files) {
const content = readFileSync(filePath, "utf8");
if (content.includes('@/lib/api/agentStream')) {
offenders.push(relative(process.cwd(), filePath));
}
}
expect(offenders).toEqual([]);
});
it("agentStream compat 壳不应回流到 src/lib/api", () => {
expect(existsSync(join(API_PROTOCOL_GUARD_DIR, "agentStream.ts"))).toBe(
false,
);
});
it("src/lib/api 不应重新 import agentStream compat 壳", () => {
const files = collectTsFiles(API_PROTOCOL_GUARD_DIR);
const offenders: string[] = [];
for (const filePath of files) {
const content = readFileSync(filePath, "utf8");
if (
content.includes('./agentStream') ||
content.includes("./agentStream")
) {
offenders.push(relative(process.cwd(), filePath));
}
}
expect(offenders).toEqual([]);
});
});
@@ -64,18 +64,42 @@ describe("ServiceSkillHomePanel", () => {
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
automationStatus: {
jobId: "automation-job-1",
jobName: "复制短视频脚本|定时执行",
statusLabel: "成功",
tone: "emerald",
detail: "下次 03/24 09:00",
},
},
];
const onSelect = vi.fn();
const onOpenAutomationJob = vi.fn();
const container = renderPanel({
skills,
catalogMeta: {
tenantId: "tenant-demo",
version: "tenant-2026-03-24",
syncedAt: "2026-03-24T12:00:00.000Z",
itemCount: 1,
sourceLabel: "租户云目录",
isSeeded: false,
},
onSelect,
onOpenAutomationJob,
});
expect(container.textContent).toContain("服务型技能");
expect(container.textContent).toContain("租户云目录");
expect(container.textContent).toContain("tenant-demo");
expect(container.textContent).toContain("tenant-2026-03-24");
expect(container.textContent).toContain("1 项");
expect(container.textContent).toContain("同步于");
expect(container.textContent).toContain("复制短视频脚本");
expect(container.textContent).toContain("产出:脚本大纲 + 镜头节奏");
expect(container.textContent).toContain("本地任务 · 成功");
expect(container.textContent).toContain("下次 03/24 09:00");
expect(container.textContent).toContain("本地即时执行");
expect(container.textContent).toContain("填写参数");
@@ -90,5 +114,17 @@ describe("ServiceSkillHomePanel", () => {
});
expect(onSelect).toHaveBeenCalledWith(skills[0]);
const statusButton = container.querySelector(
'[data-testid="service-skill-short-video-script-replication-secondary-status"]',
) as HTMLButtonElement | null;
expect(statusButton).toBeTruthy();
act(() => {
statusButton?.click();
});
expect(onOpenAutomationJob).toHaveBeenCalledWith(skills[0]);
});
});
@@ -1,18 +1,41 @@
import { useMemo } from "react";
import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions";
import type { ServiceSkillHomeItem } from "./types";
import type { ServiceSkillCatalogMeta, ServiceSkillHomeItem } from "./types";
interface ServiceSkillHomePanelProps {
skills: ServiceSkillHomeItem[];
catalogMeta?: ServiceSkillCatalogMeta | null;
loading?: boolean;
onSelect: (skill: ServiceSkillHomeItem) => void | Promise<void>;
onOpenAutomationJob?: (skill: ServiceSkillHomeItem) => void | Promise<void>;
}
export function ServiceSkillHomePanel({
skills,
catalogMeta = null,
loading = false,
onSelect,
onOpenAutomationJob,
}: ServiceSkillHomePanelProps) {
const syncedAtLabel = useMemo(() => {
if (!catalogMeta?.syncedAt) {
return null;
}
const parsed = new Date(catalogMeta.syncedAt);
if (Number.isNaN(parsed.getTime())) {
return catalogMeta.syncedAt;
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
}, [catalogMeta?.syncedAt]);
const items = useMemo(
() =>
skills.map((skill) => ({
@@ -23,6 +46,11 @@ export function ServiceSkillHomePanel({
prompt: "",
actionLabel: skill.actionLabel,
outputHint: skill.outputHint,
secondaryStatusLabel: skill.automationStatus
? `本地任务 · ${skill.automationStatus.statusLabel}`
: undefined,
secondaryStatusTone: skill.automationStatus?.tone,
secondaryStatusDescription: skill.automationStatus?.detail ?? undefined,
statusLabel: skill.runnerLabel,
statusTone: skill.runnerTone,
statusDescription: skill.runnerDescription,
@@ -35,9 +63,42 @@ export function ServiceSkillHomePanel({
<EmptyStateQuickActions
title="服务型技能"
description="先选一个结果导向入口,补齐关键参数后直接进入对应工作模式。"
headerAddon={
catalogMeta ? (
<div className="flex flex-wrap items-center gap-2 text-[11px] leading-5 text-slate-500">
<span className="rounded-full border border-slate-200 bg-white/92 px-2.5 py-1 font-medium text-slate-600">
{catalogMeta.sourceLabel}
</span>
<span className="rounded-full border border-slate-200/90 bg-slate-50/92 px-2.5 py-1">
Tenant · {catalogMeta.tenantId}
</span>
<span className="rounded-full border border-slate-200/90 bg-slate-50/92 px-2.5 py-1">
Version · {catalogMeta.version}
</span>
<span className="rounded-full border border-slate-200/90 bg-slate-50/92 px-2.5 py-1">
{catalogMeta.itemCount} 项
</span>
{syncedAtLabel ? (
<span className="rounded-full border border-slate-200/90 bg-slate-50/92 px-2.5 py-1">
同步于 {syncedAtLabel}
</span>
) : null}
</div>
) : null
}
items={items}
embedded
loading={loading}
onSecondaryStatusAction={
onOpenAutomationJob
? (item) => {
const skill = skills.find((candidate) => candidate.id === item.key);
if (skill?.automationStatus) {
void onOpenAutomationJob(skill);
}
}
: undefined
}
onAction={(item) => {
const skill = skills.find((candidate) => candidate.id === item.key);
if (skill) {
@@ -6,18 +6,23 @@ 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>,
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", () => ({
@@ -32,7 +37,9 @@ vi.mock("@/components/ui/button", () => ({
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input {...props} />
),
}));
vi.mock("@/components/ui/textarea", () => ({
@@ -45,7 +52,9 @@ vi.mock("@/components/ui/label", () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => <label {...props}>{children}</label>,
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label {...props}>{children}</label>
),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
@@ -87,6 +96,7 @@ const MOCK_SKILL: ServiceSkillHomeItem = {
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
automationStatus: null,
};
beforeEach(() => {
@@ -160,10 +170,9 @@ function setFormValue(
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
descriptor?.set?.call(element, value);
element.dispatchEvent(
new Event(
element instanceof HTMLSelectElement ? "change" : "input",
{ bubbles: true },
),
new Event(element instanceof HTMLSelectElement ? "change" : "input", {
bubbles: true,
}),
);
}
@@ -262,4 +271,73 @@ describe("ServiceSkillLaunchDialog", () => {
},
);
});
it("云端托管技能应显示云端运行文案且不暴露本地自动化入口", async () => {
const onLaunch = vi.fn();
const onCreateAutomation = vi.fn();
renderDialog({
skill: {
...MOCK_SKILL,
id: "cloud-video-dubbing",
title: "云端视频配音",
executionLocation: "cloud_required",
defaultExecutorBinding: "cloud_scene",
runnerLabel: "云端托管执行",
runnerTone: "slate",
runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。",
actionLabel: "提交云端",
},
onLaunch,
onCreateAutomation,
});
await flushEffects();
expect(document.body.textContent).toContain("提交云端运行");
expect(document.body.textContent).toContain(
"不会进入本地工作区,也不会创建本地自动化草稿",
);
expect(
document.body.querySelector(
'[data-testid="service-skill-enter-workspace"]',
),
).toBeNull();
expect(
document.body.querySelector(
'[data-testid="service-skill-create-automation"]',
),
).toBeNull();
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;
act(() => {
if (referenceInput) {
setFormValue(referenceInput, "https://example.com/cloud-video");
}
});
await flushEffects();
act(() => {
launchButton?.click();
});
expect(onLaunch).toHaveBeenCalledWith(
expect.objectContaining({
id: "cloud-video-dubbing",
executionLocation: "cloud_required",
}),
{
reference_video: "https://example.com/cloud-video",
platform: "douyin",
},
);
expect(onCreateAutomation).not.toHaveBeenCalled();
});
});
@@ -72,9 +72,7 @@ function renderFieldControl(params: {
"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>
<option value="">{slot.placeholder}</option>
{(slot.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -131,7 +129,14 @@ export function ServiceSkillLaunchDialog({
() => (skill ? supportsServiceSkillLocalAutomation(skill) : false),
[skill],
);
const canCreateAutomation = supportsAutomation && typeof onCreateAutomation === "function";
const canCreateAutomation =
supportsAutomation && typeof onCreateAutomation === "function";
const isCloudRequired = skill?.executionLocation === "cloud_required";
const primaryActionLabel = canCreateAutomation
? "创建任务并进入工作区"
: isCloudRequired
? "提交云端运行"
: "进入工作区";
if (!skill) {
return null;
@@ -157,6 +162,11 @@ export function ServiceSkillLaunchDialog({
<div className="mt-2 text-[11px] text-slate-500">
这类任务支持直接生成本地自动化草稿;你也可以只先进入工作区做首版结果。
</div>
) : skill.executionLocation === "cloud_required" ? (
<div className="mt-2 text-[11px] text-slate-500">
该任务会直接提交到 OEM
云端执行,不会进入本地工作区,也不会创建本地自动化草稿。
</div>
) : null}
</div>
@@ -193,7 +203,8 @@ export function ServiceSkillLaunchDialog({
{!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("、")}
还缺少必填参数:
{validation.missing.map((slot) => slot.label).join("、")}
</div>
) : null}
@@ -234,7 +245,7 @@ export function ServiceSkillLaunchDialog({
void onLaunch(skill, slotValues);
}}
>
{canCreateAutomation ? "创建任务并进入工作区" : "进入工作区"}
{primaryActionLabel}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AutomationJobRecord } from "@/lib/api/automation";
import {
buildServiceSkillAutomationStatusMap,
listServiceSkillAutomationLinks,
recordServiceSkillAutomationLink,
subscribeServiceSkillAutomationLinksChanged,
} from "./automationLinkStorage";
function buildJob(
overrides: Partial<AutomationJobRecord> = {},
): AutomationJobRecord {
return {
id: "automation-job-1",
name: "每日趋势摘要|定时执行",
description: "服务型技能创建的本地任务",
enabled: true,
workspace_id: "project-1",
execution_mode: "skill",
schedule: {
kind: "cron",
expr: "0 9 * * *",
tz: "Asia/Shanghai",
},
payload: {
kind: "agent_turn",
prompt: "prompt",
system_prompt: null,
web_search: false,
},
delivery: {
mode: "none",
channel: null,
target: null,
best_effort: true,
output_schema: "text",
output_format: "text",
},
timeout_secs: null,
max_retries: 2,
next_run_at: "2026-03-24T09:00:00.000Z",
last_status: "success",
last_error: null,
last_run_at: "2026-03-23T09:00:00.000Z",
last_finished_at: "2026-03-23T09:00:10.000Z",
running_started_at: null,
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: null,
last_delivery: null,
created_at: "2026-03-22T09:00:00.000Z",
updated_at: "2026-03-23T09:00:10.000Z",
...overrides,
};
}
describe("automationLinkStorage", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it("记录关联后应能读回最后一次 skill -> job 绑定", () => {
recordServiceSkillAutomationLink({
skillId: "daily-trend-briefing",
jobId: "automation-job-1",
jobName: "每日趋势摘要|定时执行",
linkedAt: 1,
});
recordServiceSkillAutomationLink({
skillId: "daily-trend-briefing",
jobId: "automation-job-2",
jobName: "每日趋势摘要|持续执行",
linkedAt: 2,
});
expect(listServiceSkillAutomationLinks()).toEqual([
{
skillId: "daily-trend-briefing",
jobId: "automation-job-2",
jobName: "每日趋势摘要|持续执行",
linkedAt: 2,
},
]);
});
it("变更关联时应广播事件", () => {
const callback = vi.fn();
const unsubscribe = subscribeServiceSkillAutomationLinksChanged(callback);
try {
recordServiceSkillAutomationLink({
skillId: "daily-trend-briefing",
jobId: "automation-job-1",
jobName: "每日趋势摘要|定时执行",
});
expect(callback).toHaveBeenCalledTimes(1);
} finally {
unsubscribe();
}
});
it("应把关联 job 汇总成首页可显示的状态摘要", () => {
recordServiceSkillAutomationLink({
skillId: "daily-trend-briefing",
jobId: "automation-job-1",
jobName: "每日趋势摘要|定时执行",
});
const statusMap = buildServiceSkillAutomationStatusMap([buildJob()]);
expect(statusMap["daily-trend-briefing"]).toEqual(
expect.objectContaining({
jobId: "automation-job-1",
statusLabel: "成功",
tone: "emerald",
}),
);
expect(statusMap["daily-trend-briefing"]?.detail).toContain("下次");
});
});
@@ -0,0 +1,296 @@
import type { AutomationJobRecord } from "@/lib/api/automation";
import type {
ServiceSkillAutomationLinkRecord,
ServiceSkillAutomationStatus,
} from "./types";
const SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY =
"lime:service-skill-automation-links:v1";
export const SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT =
"lime:service-skill-automation-links-changed";
function hasWindow(): boolean {
return typeof window !== "undefined";
}
function isValidAutomationLinkRecord(
value: unknown,
): value is ServiceSkillAutomationLinkRecord {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<ServiceSkillAutomationLinkRecord>;
return (
typeof record.skillId === "string" &&
record.skillId.length > 0 &&
typeof record.jobId === "string" &&
record.jobId.length > 0 &&
typeof record.jobName === "string" &&
record.jobName.length > 0 &&
typeof record.linkedAt === "number" &&
Number.isFinite(record.linkedAt)
);
}
function emitAutomationLinksChanged(): void {
if (!hasWindow()) {
return;
}
window.dispatchEvent(
new CustomEvent(SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT, {
detail: {
timestamp: Date.now(),
},
}),
);
}
function persistAutomationLinks(
records: ServiceSkillAutomationLinkRecord[],
): ServiceSkillAutomationLinkRecord[] {
if (!hasWindow()) {
return records;
}
try {
window.localStorage.setItem(
SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY,
JSON.stringify(records),
);
} catch {
// ignore write errors
}
emitAutomationLinksChanged();
return records;
}
function formatAutomationTime(value?: string | null): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
function resolveStatusLabel(status?: string | null): string {
switch (status) {
case "queued":
return "排队中";
case "success":
return "成功";
case "running":
return "运行中";
case "waiting_for_human":
return "等待人工处理";
case "human_controlling":
return "人工接管中";
case "agent_resuming":
return "恢复给 Agent";
case "error":
return "失败";
case "timeout":
return "超时";
default:
return "待执行";
}
}
function resolveStatusTone(
status?: string | null,
): ServiceSkillAutomationStatus["tone"] {
if (status === "success") {
return "emerald";
}
if (
status === "queued" ||
status === "running" ||
status === "agent_resuming"
) {
return "sky";
}
if (
status === "waiting_for_human" ||
status === "human_controlling" ||
status === "timeout"
) {
return "amber";
}
if (status === "error") {
return "amber";
}
return "slate";
}
function resolveStatusDetail(job: AutomationJobRecord): string | null {
if (job.running_started_at) {
const startedAt = formatAutomationTime(job.running_started_at);
return startedAt ? `开始于 ${startedAt}` : null;
}
if (job.auto_disabled_until) {
const resumeAt = formatAutomationTime(job.auto_disabled_until);
return resumeAt ? `冷却至 ${resumeAt}` : "当前处于冷却期";
}
if (job.last_status === "success") {
const nextRunAt = formatAutomationTime(job.next_run_at);
const finishedAt = formatAutomationTime(job.last_finished_at);
if (nextRunAt) {
return `下次 ${nextRunAt}`;
}
if (finishedAt) {
return `完成于 ${finishedAt}`;
}
return null;
}
if (job.last_status === "error" || job.last_status === "timeout") {
const finishedAt =
formatAutomationTime(job.last_finished_at) ??
formatAutomationTime(job.updated_at);
if (finishedAt) {
return `最近一次 ${finishedAt}`;
}
return null;
}
if (job.next_run_at) {
const nextRunAt = formatAutomationTime(job.next_run_at);
return nextRunAt ? `下次 ${nextRunAt}` : null;
}
if (!job.enabled) {
return "任务已停用";
}
return null;
}
export function listServiceSkillAutomationLinks(): ServiceSkillAutomationLinkRecord[] {
if (!hasWindow()) {
return [];
}
try {
const raw = window.localStorage.getItem(
SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY,
);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(isValidAutomationLinkRecord)
.sort((left, right) => right.linkedAt - left.linkedAt);
} catch {
return [];
}
}
export function recordServiceSkillAutomationLink(
input: Omit<ServiceSkillAutomationLinkRecord, "linkedAt"> & {
linkedAt?: number;
},
): ServiceSkillAutomationLinkRecord[] {
const nextRecord: ServiceSkillAutomationLinkRecord = {
skillId: input.skillId,
jobId: input.jobId,
jobName: input.jobName,
linkedAt: input.linkedAt ?? Date.now(),
};
const nextRecords = [
nextRecord,
...listServiceSkillAutomationLinks().filter(
(record) => record.skillId !== nextRecord.skillId,
),
];
return persistAutomationLinks(nextRecords);
}
export function subscribeServiceSkillAutomationLinksChanged(
callback: () => void,
): () => void {
if (!hasWindow()) {
return () => undefined;
}
const customEventHandler = () => {
callback();
};
const storageHandler = (event: StorageEvent) => {
if (event.key !== SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY) {
return;
}
callback();
};
window.addEventListener(
SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT,
customEventHandler,
);
window.addEventListener("storage", storageHandler);
return () => {
window.removeEventListener(
SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT,
customEventHandler,
);
window.removeEventListener("storage", storageHandler);
};
}
export function buildServiceSkillAutomationStatusMap(
jobs: AutomationJobRecord[],
): Record<string, ServiceSkillAutomationStatus> {
const jobsById = new Map(jobs.map((job) => [job.id, job]));
return listServiceSkillAutomationLinks().reduce<
Record<string, ServiceSkillAutomationStatus>
>((result, link) => {
const job = jobsById.get(link.jobId);
if (!job) {
return result;
}
const statusLabel = job.auto_disabled_until
? "冷却中"
: resolveStatusLabel(job.running_started_at ? "running" : job.last_status);
result[link.skillId] = {
jobId: job.id,
jobName: job.name || link.jobName,
statusLabel,
tone: resolveStatusTone(
job.auto_disabled_until
? "timeout"
: job.running_started_at
? "running"
: job.last_status,
),
detail: resolveStatusDetail(job),
};
return result;
}, {});
}
@@ -1,5 +1,6 @@
import type {
ServiceSkillCatalog,
ServiceSkillArtifactKind,
ServiceSkillExecutionLocation,
ServiceSkillExecutorBinding,
ServiceSkillItem,
@@ -13,6 +14,7 @@ import type {
export type {
ServiceSkillCatalog,
ServiceSkillArtifactKind,
ServiceSkillExecutionLocation,
ServiceSkillExecutorBinding,
ServiceSkillItem,
@@ -28,6 +30,21 @@ export type ServiceSkillTone = "slate" | "sky" | "emerald" | "amber";
export type ServiceSkillSlotValues = Record<string, string>;
export interface ServiceSkillAutomationStatus {
jobId: string;
jobName: string;
statusLabel: string;
tone: ServiceSkillTone;
detail: string | null;
}
export interface ServiceSkillAutomationLinkRecord {
skillId: string;
jobId: string;
jobName: string;
linkedAt: number;
}
export interface ServiceSkillHomeItem extends ServiceSkillItem {
badge: string;
recentUsedAt: number | null;
@@ -36,6 +53,16 @@ export interface ServiceSkillHomeItem extends ServiceSkillItem {
runnerTone: ServiceSkillTone;
runnerDescription: string;
actionLabel: string;
automationStatus: ServiceSkillAutomationStatus | null;
}
export interface ServiceSkillCatalogMeta {
tenantId: string;
version: string;
syncedAt: string;
itemCount: number;
sourceLabel: string;
isSeeded: boolean;
}
export interface ServiceSkillUsageRecord {
@@ -0,0 +1,256 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearServiceSkillCatalogCache,
getSeededServiceSkillCatalog,
saveServiceSkillCatalog,
type ServiceSkillCatalog,
} from "@/lib/api/serviceSkills";
import { recordServiceSkillAutomationLink } from "./automationLinkStorage";
import { useServiceSkills } from "./useServiceSkills";
interface HookHarness {
getValue: () => ReturnType<typeof useServiceSkills>;
unmount: () => void;
}
function buildRemoteCatalog(): ServiceSkillCatalog {
const seeded = getSeededServiceSkillCatalog();
return {
version: "tenant-2026-03-24",
tenantId: "tenant-demo",
syncedAt: "2026-03-24T12:00:00.000Z",
items: [
{
...seeded.items[0]!,
id: "tenant-daily-briefing",
title: "租户日报摘要",
summary: "远端同步后的目录项",
version: "tenant-2026-03-24",
},
],
};
}
function mountHook(): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useServiceSkills> | null = null;
function TestComponent() {
hookValue = useServiceSkills();
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
resolve,
reject,
};
}
async function flushEffects(times = 3) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
describe("useServiceSkills", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
delete window.__LIME_OEM_CLOUD__;
delete window.__LIME_SESSION_TOKEN__;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("目录更新事件后应刷新服务型技能列表", async () => {
const harness = mountHook();
try {
await flushEffects();
expect(harness.getValue().skills[0]?.id).toBe(
"carousel-post-replication",
);
expect(harness.getValue().catalogMeta).toEqual(
expect.objectContaining({
tenantId: "local-seeded",
sourceLabel: "本地 Seeded 目录",
isSeeded: true,
}),
);
act(() => {
recordServiceSkillAutomationLink({
skillId: "carousel-post-replication",
jobId: "automation-job-daily-brief",
jobName: "每日线索巡检",
});
});
await flushEffects();
expect(
harness
.getValue()
.skills.find((skill) => skill.id === "carousel-post-replication")
?.automationStatus,
).toEqual(
expect.objectContaining({
jobId: "automation-job-daily-brief",
jobName: "每日线索巡检",
statusLabel: "成功",
}),
);
expect(
harness
.getValue()
.skills.find((skill) => skill.id === "carousel-post-replication")
?.automationStatus?.detail,
).toContain("下次");
act(() => {
saveServiceSkillCatalog(buildRemoteCatalog(), "bootstrap_sync");
});
await flushEffects();
expect(harness.getValue().skills).toHaveLength(1);
expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing");
expect(harness.getValue().skills[0]?.badge).toBe("云目录");
expect(harness.getValue().catalogMeta).toEqual(
expect.objectContaining({
tenantId: "tenant-demo",
version: "tenant-2026-03-24",
sourceLabel: "租户云目录",
isSeeded: false,
}),
);
act(() => {
clearServiceSkillCatalogCache();
});
await flushEffects();
expect(harness.getValue().skills[0]?.id).toBe(
"carousel-post-replication",
);
expect(harness.getValue().catalogMeta).toEqual(
expect.objectContaining({
tenantId: "local-seeded",
sourceLabel: "本地 Seeded 目录",
isSeeded: true,
}),
);
} finally {
harness.unmount();
}
});
it("有 OEM 会话时应先显示本地目录再后台刷新远端目录", async () => {
window.__LIME_OEM_CLOUD__ = {
baseUrl: "https://oem.example.com",
tenantId: "tenant-demo",
};
window.__LIME_SESSION_TOKEN__ = "session-token-demo";
const responseDeferred = createDeferred<{
ok: boolean;
json: () => Promise<unknown>;
}>();
const fetchMock = vi.fn(() => responseDeferred.promise);
vi.stubGlobal("fetch", fetchMock);
const harness = mountHook();
try {
await flushEffects(4);
expect(fetchMock).toHaveBeenCalledWith(
"https://oem.example.com/api/v1/public/tenants/tenant-demo/client/service-skills",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
Authorization: "Bearer session-token-demo",
}),
}),
);
expect(harness.getValue().catalogMeta).toEqual(
expect.objectContaining({
tenantId: "local-seeded",
sourceLabel: "本地 Seeded 目录",
isSeeded: true,
}),
);
responseDeferred.resolve({
ok: true,
json: async () => ({
code: 200,
message: "success",
data: buildRemoteCatalog(),
}),
});
await flushEffects(4);
expect(harness.getValue().skills).toHaveLength(1);
expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing");
expect(harness.getValue().catalogMeta).toEqual(
expect.objectContaining({
tenantId: "tenant-demo",
version: "tenant-2026-03-24",
sourceLabel: "租户云目录",
isSeeded: false,
}),
);
} finally {
harness.unmount();
}
});
});
@@ -1,8 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listServiceSkills } from "@/lib/api/serviceSkills";
import { getAutomationJobs } from "@/lib/api/automation";
import {
getServiceSkillCatalog,
isSeededServiceSkillCatalog,
refreshServiceSkillCatalogFromRemote,
subscribeServiceSkillCatalogChanged,
} from "@/lib/api/serviceSkills";
import {
buildServiceSkillAutomationStatusMap,
listServiceSkillAutomationLinks,
subscribeServiceSkillAutomationLinksChanged,
} from "./automationLinkStorage";
import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage";
import type {
RecordServiceSkillUsageInput,
ServiceSkillAutomationStatus,
ServiceSkillCatalog,
ServiceSkillCatalogMeta,
ServiceSkillHomeItem,
ServiceSkillItem,
ServiceSkillRunnerType,
@@ -27,12 +41,40 @@ const RUNNER_DESCRIPTIONS: Record<ServiceSkillRunnerType, string> = {
managed: "当前先进入工作区生成首版跟踪方案,后续再接本地持续任务。",
};
const ACTION_LABELS: Record<ServiceSkillRunnerType, string> = {
const LOCAL_ACTION_LABELS: Record<ServiceSkillRunnerType, string> = {
instant: "填写参数",
scheduled: "先做方案",
managed: "先定指标",
};
function getRunnerLabel(item: ServiceSkillItem): string {
if (item.executionLocation === "cloud_required") {
return "云端托管执行";
}
return RUNNER_LABELS[item.runnerType];
}
function getRunnerTone(item: ServiceSkillItem): ServiceSkillTone {
if (item.executionLocation === "cloud_required") {
return "slate";
}
return RUNNER_TONES[item.runnerType];
}
function getRunnerDescription(item: ServiceSkillItem): string {
if (item.executionLocation === "cloud_required") {
return "提交到 OEM 云端执行,结果由服务端异步返回。";
}
return RUNNER_DESCRIPTIONS[item.runnerType];
}
function getActionLabel(item: ServiceSkillItem): string {
if (item.executionLocation === "cloud_required") {
return "提交云端";
}
return LOCAL_ACTION_LABELS[item.runnerType];
}
function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string {
if (isRecent) {
return "最近使用";
@@ -43,7 +85,10 @@ function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string {
return "本地技能";
}
function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] {
function buildHomeItems(
items: ServiceSkillItem[],
automationStatusMap: Record<string, ServiceSkillAutomationStatus>,
): ServiceSkillHomeItem[] {
const usageMap = getServiceSkillUsageMap();
const mapped: Array<ServiceSkillHomeItem & { _sortIndex: number }> = items.map(
(item, index) => {
@@ -56,10 +101,11 @@ function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] {
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],
runnerLabel: getRunnerLabel(item),
runnerTone: getRunnerTone(item),
runnerDescription: getRunnerDescription(item),
actionLabel: getActionLabel(item),
automationStatus: automationStatusMap[item.id] ?? null,
_sortIndex: index,
};
},
@@ -82,8 +128,24 @@ function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] {
.map(({ _sortIndex, ...item }) => item);
}
function buildCatalogMeta(
catalog: ServiceSkillCatalog,
): ServiceSkillCatalogMeta {
const isSeeded = isSeededServiceSkillCatalog(catalog);
return {
tenantId: catalog.tenantId,
version: catalog.version,
syncedAt: catalog.syncedAt,
itemCount: catalog.items.length,
sourceLabel: isSeeded ? "本地 Seeded 目录" : "租户云目录",
isSeeded,
};
}
interface UseServiceSkillsResult {
skills: ServiceSkillHomeItem[];
catalogMeta: ServiceSkillCatalogMeta | null;
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
@@ -92,35 +154,120 @@ interface UseServiceSkillsResult {
export function useServiceSkills(enabled = true): UseServiceSkillsResult {
const [items, setItems] = useState<ServiceSkillItem[]>([]);
const [automationStatusMap, setAutomationStatusMap] = useState<
Record<string, ServiceSkillAutomationStatus>
>({});
const [catalogMeta, setCatalogMeta] = useState<ServiceSkillCatalogMeta | null>(
null,
);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [usageVersion, setUsageVersion] = useState(0);
const [automationLinkCount, setAutomationLinkCount] = useState(0);
const applyCatalogSnapshot = useCallback(async (catalog: ServiceSkillCatalog) => {
const automationLinks = listServiceSkillAutomationLinks();
let automationStatuses: Record<string, ServiceSkillAutomationStatus> = {};
if (automationLinks.length > 0) {
try {
automationStatuses = buildServiceSkillAutomationStatusMap(
await getAutomationJobs(),
);
} catch {
automationStatuses = {};
}
}
setItems(catalog.items.filter((item) => item.source === "cloud_catalog"));
setAutomationLinkCount(automationLinks.length);
setAutomationStatusMap(automationStatuses);
setCatalogMeta(buildCatalogMeta(catalog));
}, []);
const loadCurrentCatalog = useCallback(async () => {
const catalog = await getServiceSkillCatalog();
await applyCatalogSnapshot(catalog);
return catalog;
}, [applyCatalogSnapshot]);
const refresh = useCallback(async () => {
if (!enabled) {
setItems([]);
setAutomationStatusMap({});
setAutomationLinkCount(0);
setCatalogMeta(null);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
let currentCatalog: ServiceSkillCatalog | null = null;
try {
const nextItems = await listServiceSkills();
setItems(nextItems);
currentCatalog = await loadCurrentCatalog();
setError(null);
setIsLoading(false);
try {
const remoteCatalog = await refreshServiceSkillCatalogFromRemote();
if (remoteCatalog) {
await applyCatalogSnapshot(remoteCatalog);
}
} catch (reason) {
if (!currentCatalog) {
throw reason;
}
}
} catch (reason) {
setItems([]);
setAutomationStatusMap({});
setAutomationLinkCount(0);
setCatalogMeta(null);
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setIsLoading(false);
}
}, [enabled]);
}, [applyCatalogSnapshot, enabled, loadCurrentCatalog]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!enabled) {
return;
}
const unsubscribeCatalog = subscribeServiceSkillCatalogChanged(() => {
void loadCurrentCatalog();
});
const unsubscribeAutomationLinks =
subscribeServiceSkillAutomationLinksChanged(() => {
void loadCurrentCatalog();
});
return () => {
unsubscribeCatalog();
unsubscribeAutomationLinks();
};
}, [enabled, loadCurrentCatalog]);
useEffect(() => {
if (!enabled || automationLinkCount === 0) {
return;
}
const timer = window.setInterval(() => {
void refresh();
}, 15_000);
return () => {
window.clearInterval(timer);
};
}, [automationLinkCount, enabled, refresh]);
const recordUsageAndRefresh = useCallback(
(input: RecordServiceSkillUsageInput) => {
recordServiceSkillUsage(input);
@@ -131,11 +278,12 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult {
const skills = useMemo(() => {
void usageVersion;
return buildHomeItems(items);
}, [items, usageVersion]);
return buildHomeItems(items, automationStatusMap);
}, [items, usageVersion, automationStatusMap]);
return {
skills,
catalogMeta,
isLoading,
error,
refresh,
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildServiceSkillWorkspaceSeed } from "./workspaceLaunch";
import type { ServiceSkillItem } from "@/lib/api/serviceSkills";
function createSkill(
overrides: Partial<ServiceSkillItem> = {},
): ServiceSkillItem {
return {
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: [],
...overrides,
};
}
describe("service skill workspace launch", () => {
it("内容创作类服务型技能应生成内容种子与 artifact metadata", () => {
const seed = buildServiceSkillWorkspaceSeed(
createSkill({
defaultArtifactKind: "analysis",
}),
);
expect(seed).toEqual({
title: "每日趋势摘要",
contentType: "post",
requestMetadata: {
artifact: {
artifact_mode: "draft",
artifact_kind: "analysis",
workbench_surface: "right_panel",
},
},
metadata: {
source: "service_skill",
serviceSkill: {
id: "daily-trend-briefing",
title: "每日趋势摘要",
runnerType: "scheduled",
executionLocation: "client_default",
themeTarget: "social-media",
artifactKind: "analysis",
},
},
});
});
it("非内容创作主题不应强制生成内容种子", () => {
expect(
buildServiceSkillWorkspaceSeed(
createSkill({
themeTarget: "general",
defaultArtifactKind: "brief",
}),
),
).toBeNull();
});
});
@@ -0,0 +1,56 @@
import type { ContentType, ProjectType } from "@/lib/api/project";
import { getDefaultContentTypeForProject } from "@/lib/api/project";
import type { ServiceSkillArtifactKind, ServiceSkillItem } from "@/lib/api/serviceSkills";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
export interface ServiceSkillWorkspaceSeed {
title: string;
contentType: ContentType;
requestMetadata?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
function resolveServiceSkillArtifactRequestMetadata(
artifactKind?: ServiceSkillArtifactKind,
): Record<string, unknown> | undefined {
if (!artifactKind) {
return undefined;
}
return {
artifact: {
artifact_mode: "draft",
artifact_kind: artifactKind,
workbench_surface: "right_panel",
},
};
}
export function buildServiceSkillWorkspaceSeed(
skill: ServiceSkillItem,
fallbackTheme?: string | null,
): ServiceSkillWorkspaceSeed | null {
const targetTheme = skill.themeTarget ?? fallbackTheme ?? null;
if (!targetTheme || !isContentCreationTheme(targetTheme)) {
return null;
}
return {
title: skill.title.trim() || "服务型技能交付物",
contentType: getDefaultContentTypeForProject(targetTheme as ProjectType),
requestMetadata: resolveServiceSkillArtifactRequestMetadata(
skill.defaultArtifactKind,
),
metadata: {
source: "service_skill",
serviceSkill: {
id: skill.id,
title: skill.title,
runnerType: skill.runnerType,
executionLocation: skill.executionLocation,
themeTarget: targetTheme,
artifactKind: skill.defaultArtifactKind ?? null,
},
},
};
}
@@ -1,4 +1,4 @@
import type { AgentThreadItem } from "@/lib/api/agentStream";
import type { AgentThreadItem } from "@/lib/api/agentProtocol";
import type {
AsterSubagentParentContext,
AsterSubagentSessionInfo,
+11 -5
View File
@@ -1,8 +1,8 @@
import type {
ContextTraceStep,
ToolCallState,
TokenUsage,
} from "@/lib/api/agentStream";
AgentContextTraceStep as ContextTraceStep,
AgentToolCallState as ToolCallState,
AgentTokenUsage as TokenUsage,
} from "@/lib/api/agentProtocol";
import type { Artifact, ArtifactStatus } from "@/lib/artifact/types";
import { safeInvoke } from "@/lib/dev-bridge";
@@ -10,7 +10,7 @@ export type {
AgentThreadItem,
AgentThreadItemStatus,
AgentThreadTurn,
} from "@/lib/api/agentStream";
} from "@/lib/api/agentProtocol";
export interface MessageImage {
data: string;
@@ -45,6 +45,12 @@ export type BrowserPreflightState =
| "failed"
| "degraded";
export interface SiteSavedContentTarget {
projectId: string;
contentId: string;
title?: string;
}
// ============ 权限确认相关类型 ============
export interface ActionRequiredScope {
@@ -1,5 +1,5 @@
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type { ContextTraceStep } from "@/lib/api/agentStream";
import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol";
import type { AgentRuntimeStatus } from "../types";
function buildExecutionLabel(strategy: AsterExecutionStrategy): string {
@@ -120,6 +120,52 @@ describe("agentThreadGrouping", () => {
]);
});
it("应通过 artifact protocol 识别嵌套参数中的文件路径", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("tool-file-1", 1),
type: "tool_call",
tool_name: "write_file",
arguments: {
payload: {
filePath: "articles/nested-draft.md",
},
},
},
];
const model = buildAgentThreadDisplayModel(items);
expect(model.groups.map((group) => group.kind)).toEqual(["file"]);
expect(model.groups[0]?.previewLines).toEqual(["nested-draft.md"]);
});
it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("tool-dir-1", 1),
type: "tool_call",
tool_name: "list_directory",
arguments: {
directory: "workspace\\reports",
},
},
{
...createBaseItem("tool-output-1", 2),
type: "tool_call",
tool_name: "bash",
metadata: {
output_file: "workspace\\logs\\run.log",
},
},
];
const model = buildAgentThreadDisplayModel(items);
expect(model.groups.map((group) => group.kind)).toEqual(["file"]);
expect(model.groups[0]?.previewLines).toEqual(["reports", "run.log"]);
});
it("思考块应保留在真实时序中,而不是整体前置", () => {
const items: AgentThreadItem[] = [
{
@@ -1,4 +1,9 @@
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import {
extractFilesystemEventLocationHintsFromValue,
extractFilesystemEventPathsFromValue,
} from "@/lib/filesystem-event-protocol";
import type { AgentThreadItem, AgentThreadItemStatus } from "../types";
import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
@@ -174,11 +179,19 @@ function resolvePathFromItem(item: AgentThreadItem): string | null {
return null;
}
const metadata = asRecord(item.metadata);
const args = asRecord(item.arguments);
const protocolPath =
extractArtifactProtocolPathsFromValue(item.arguments)[0] ||
extractArtifactProtocolPathsFromValue(item.metadata)[0];
if (protocolPath) {
return protocolPath;
}
return (
readString(args, ["path", "file_path", "filePath", "directory", "cwd"]) ||
readString(metadata, ["path", "file_path", "filePath", "output_file", "offload_file"])
extractFilesystemEventPathsFromValue(item.arguments)[0] ||
extractFilesystemEventPathsFromValue(item.metadata)[0] ||
extractFilesystemEventLocationHintsFromValue(item.arguments)[0] ||
extractFilesystemEventLocationHintsFromValue(item.metadata)[0] ||
null
);
}
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
buildArtifactTimelineLinkIndex,
resolveTimelineArtifactNavigation,
} from "./artifactTimelineNavigation";
import type { AgentThreadItem } from "../types";
import type { Artifact } from "@/lib/artifact/types";
function createFileArtifactItem(
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>> = {},
): Extract<AgentThreadItem, { type: "file_artifact" }> {
return {
id: "item-1",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 3,
status: "completed",
started_at: "2026-03-25T10:00:00Z",
completed_at: "2026-03-25T10:00:01Z",
updated_at: "2026-03-25T10:00:01Z",
type: "file_artifact",
path: ".lime/artifacts/thread-1/demo.artifact.json",
source: "artifact_snapshot",
content: JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis",
title: "季度总结",
status: "ready",
language: "zh-CN",
blocks: [
{ id: "hero-1", type: "hero_summary", summary: "摘要" },
{ id: "body-1", type: "rich_text", markdown: "正文" },
],
sources: [],
metadata: {
currentVersionDiff: {
changedBlocks: [
{
blockId: "body-1",
changeType: "updated",
},
],
},
},
}),
metadata: {
artifact_id: "artifact-document:demo",
},
...overrides,
};
}
function createArtifact(): Artifact {
const content = JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis",
title: "季度总结",
status: "ready",
language: "zh-CN",
blocks: [
{ id: "hero-1", type: "hero_summary", summary: "摘要" },
{ id: "body-1", type: "rich_text", markdown: "正文" },
],
sources: [],
metadata: {},
});
return {
id: "artifact-1",
type: "document",
title: "demo.artifact.json",
content,
status: "complete",
meta: {
filePath: ".lime/artifacts/thread-1/demo.artifact.json",
filename: "demo.artifact.json",
language: "json",
},
position: { start: 0, end: content.length },
createdAt: 1,
updatedAt: 1,
};
}
describe("artifactTimelineNavigation", () => {
it("应优先从 timeline file_artifact 的 diff 中提取 block 目标", () => {
const navigation = resolveTimelineArtifactNavigation(createFileArtifactItem());
expect(navigation?.rootTarget.filePath).toBe(
".lime/artifacts/thread-1/demo.artifact.json",
);
expect(navigation?.blockTargets).toEqual([
expect.objectContaining({
timelineItemId: "item-1",
blockId: "body-1",
}),
]);
});
it("构建 workbench 侧索引时应按 blockId 回灌 timeline 关联", () => {
const linkIndex = buildArtifactTimelineLinkIndex({
artifact: createArtifact(),
items: [
createFileArtifactItem(),
createFileArtifactItem({
id: "item-2",
sequence: 5,
updated_at: "2026-03-25T10:00:03Z",
metadata: {
artifact_id: "artifact-document:demo",
artifact_block_id: "hero-1",
},
}),
],
});
expect(linkIndex["body-1"]).toEqual([
expect.objectContaining({
itemId: "item-1",
blockId: "body-1",
}),
]);
expect(linkIndex["hero-1"]).toEqual([
expect.objectContaining({
itemId: "item-2",
blockId: "hero-1",
}),
]);
});
});
@@ -0,0 +1,249 @@
import {
resolveArtifactDocumentCurrentVersionDiff,
resolveArtifactDocumentSourceLinks,
type ArtifactDocumentV1,
} from "@/lib/artifact-document";
import {
resolveArtifactProtocolDocumentPayload,
resolveArtifactProtocolFilePath,
} from "@/lib/artifact-protocol";
import type { Artifact } from "@/lib/artifact/types";
import type { AgentThreadItem } from "../types";
export interface ArtifactTimelineOpenTarget {
artifactId?: string;
filePath: string;
content: string;
timelineItemId: string;
blockId?: string;
}
export interface ArtifactTimelineLink {
itemId: string;
blockId: string;
label: string;
filePath: string;
artifactId?: string;
updatedAt?: string;
sequence: number;
}
interface ResolvedTimelineArtifactNavigation {
rootTarget: ArtifactTimelineOpenTarget;
blockTargets: ArtifactTimelineOpenTarget[];
artifactDocumentId?: string;
title?: string;
updatedAt?: string;
sequence: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeText(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizePath(path: string): string {
return path.replace(/\\/g, "/").trim();
}
function readString(
record: Record<string, unknown> | null,
keys: string[],
): string | undefined {
for (const key of keys) {
const value = normalizeText(record?.[key]);
if (value) {
return value;
}
}
return undefined;
}
function readStringArray(
record: Record<string, unknown> | null,
keys: string[],
): string[] {
const values: string[] = [];
for (const key of keys) {
const rawValue = record?.[key];
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
const normalized = normalizeText(item);
if (normalized) {
values.push(normalized);
}
}
continue;
}
const normalized = normalizeText(rawValue);
if (normalized) {
values.push(normalized);
}
}
return values;
}
function dedupeStrings(values: string[]): string[] {
return Array.from(
new Set(values.map((value) => value.trim()).filter(Boolean)),
);
}
function buildStepLabel(sequence: number): string {
return `步骤 ${String(sequence).padStart(2, "0")}`;
}
function resolveDocumentBlockIds(
document: ArtifactDocumentV1 | null,
includeSourceLinks: boolean,
): string[] {
if (!document) {
return [];
}
const diffBlockIds =
resolveArtifactDocumentCurrentVersionDiff(document)?.changedBlocks
.map((block) => normalizeText(block.blockId))
.filter((blockId): blockId is string => Boolean(blockId)) || [];
if (diffBlockIds.length > 0) {
return dedupeStrings(diffBlockIds);
}
if (!includeSourceLinks) {
return [];
}
return dedupeStrings(
resolveArtifactDocumentSourceLinks(document)
.map((link) => normalizeText(link.blockId))
.filter((blockId): blockId is string => Boolean(blockId)),
);
}
export function resolveTimelineArtifactNavigation(
item: AgentThreadItem,
options?: {
includeSourceLinks?: boolean;
},
): ResolvedTimelineArtifactNavigation | null {
if (item.type !== "file_artifact") {
return null;
}
const metadata = asRecord(item.metadata);
const artifactId = readString(metadata, ["artifact_id", "artifactId"]);
const filePath = normalizePath(item.path);
const content = typeof item.content === "string" ? item.content : "";
const document = resolveArtifactProtocolDocumentPayload({
content,
metadata: metadata || undefined,
});
const explicitBlockIds = readStringArray(metadata, [
"artifact_block_id",
"artifactBlockId",
"block_id",
"blockId",
"target_block_id",
"targetBlockId",
"artifact_target_block_id",
"artifactTargetBlockId",
]);
const blockIds =
explicitBlockIds.length > 0
? dedupeStrings(explicitBlockIds)
: resolveDocumentBlockIds(document, options?.includeSourceLinks === true);
const rootTarget: ArtifactTimelineOpenTarget = {
artifactId,
filePath,
content,
timelineItemId: item.id,
};
return {
rootTarget,
blockTargets: blockIds.map((blockId) => ({
...rootTarget,
blockId,
})),
artifactDocumentId: document?.artifactId,
title: document?.title,
updatedAt: item.updated_at,
sequence: item.sequence,
};
}
export function buildArtifactTimelineLinkIndex(params: {
artifact: Artifact;
items: AgentThreadItem[];
}): Record<string, ArtifactTimelineLink[]> {
const artifactPath = normalizePath(resolveArtifactProtocolFilePath(params.artifact));
const document = resolveArtifactProtocolDocumentPayload({
content: params.artifact.content,
metadata: params.artifact.meta,
});
const artifactIds = new Set(
[
normalizeText(params.artifact.id),
normalizeText(document?.artifactId),
normalizeText(
asRecord(params.artifact.meta)?.artifactId,
),
].filter((value): value is string => Boolean(value)),
);
const result = new Map<string, ArtifactTimelineLink[]>();
for (const item of params.items) {
const navigation = resolveTimelineArtifactNavigation(item, {
includeSourceLinks: true,
});
if (!navigation) {
continue;
}
const matchesByPath =
navigation.rootTarget.filePath.length > 0 &&
navigation.rootTarget.filePath === artifactPath;
const matchesByArtifactId =
(navigation.rootTarget.artifactId &&
artifactIds.has(navigation.rootTarget.artifactId)) ||
(navigation.artifactDocumentId &&
artifactIds.has(navigation.artifactDocumentId));
if (!matchesByPath && !matchesByArtifactId) {
continue;
}
for (const target of navigation.blockTargets) {
if (!target.blockId) {
continue;
}
const current = result.get(target.blockId) || [];
current.push({
itemId: navigation.rootTarget.timelineItemId,
blockId: target.blockId,
label: navigation.title || buildStepLabel(navigation.sequence),
filePath: navigation.rootTarget.filePath,
artifactId: navigation.rootTarget.artifactId,
updatedAt: navigation.updatedAt,
sequence: navigation.sequence,
});
result.set(target.blockId, current);
}
}
return Object.fromEntries(
Array.from(result.entries()).map(([blockId, links]) => [
blockId,
[...links].sort((left, right) => right.sequence - left.sequence),
]),
);
}
@@ -1,4 +1,4 @@
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import type { Artifact } from "@/lib/artifact/types";
import type {
BrowserAssistSessionState,
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { resolveClawWorkspaceProviderSelection } from "./clawWorkspaceProviderSelection";
const {
mockLoadConfiguredProviders,
mockGetModelRegistry,
mockGetProviderAliasConfig,
mockFetchProviderModelsAuto,
mockFilterModelsByTheme,
} = vi.hoisted(() => ({
mockLoadConfiguredProviders: vi.fn(),
mockGetModelRegistry: vi.fn(),
mockGetProviderAliasConfig: vi.fn(),
mockFetchProviderModelsAuto: vi.fn(),
mockFilterModelsByTheme: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
}));
vi.mock("@/lib/api/modelRegistry", () => ({
modelRegistryApi: {
getModelRegistry: mockGetModelRegistry,
getProviderAliasConfig: mockGetProviderAliasConfig,
fetchProviderModelsAuto: mockFetchProviderModelsAuto,
},
}));
vi.mock("./modelThemePolicy", () => ({
filterModelsByTheme: mockFilterModelsByTheme,
}));
function createProvider(
overrides: Partial<ConfiguredProvider> = {},
): ConfiguredProvider {
return {
key: "custom-social-provider",
label: "Custom Social Provider",
registryId: "custom-social-provider",
fallbackRegistryId: "openai",
type: "openai",
...overrides,
};
}
function createModel(
id: string,
overrides: Partial<EnhancedModelMetadata> = {},
): EnhancedModelMetadata {
return {
id,
display_name: id,
provider_id: "custom-social-provider",
provider_name: "Custom Social Provider",
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: false,
description: null,
source: "local",
created_at: 0,
updated_at: 0,
...overrides,
};
}
describe("resolveClawWorkspaceProviderSelection", () => {
beforeEach(() => {
mockLoadConfiguredProviders.mockResolvedValue([]);
mockGetModelRegistry.mockResolvedValue([]);
mockGetProviderAliasConfig.mockResolvedValue(null);
mockFetchProviderModelsAuto.mockResolvedValue({
models: [],
source: "LocalFallback",
error: null,
});
mockFilterModelsByTheme.mockImplementation(
(_theme: string | undefined, models: EnhancedModelMetadata[]) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "mock",
}),
);
});
it("本地注册表存在模型时应直接选中,不再触发 provider API fallback", async () => {
mockLoadConfiguredProviders.mockResolvedValueOnce([
createProvider(),
createProvider({
key: "backup-provider",
label: "Backup Provider",
registryId: "backup-provider",
}),
]);
mockGetModelRegistry.mockResolvedValueOnce([
createModel("social-model-1", {
is_latest: true,
}),
]);
const result = await resolveClawWorkspaceProviderSelection({
currentProviderType: "custom-social-provider",
currentModel: "legacy-model",
theme: "social-media",
});
expect(result).toEqual({
providerType: "custom-social-provider",
model: "social-model-1",
});
expect(mockFetchProviderModelsAuto).not.toHaveBeenCalled();
});
it("本地注册表无模型时应回退到后端 provider API 结果", async () => {
mockLoadConfiguredProviders.mockResolvedValueOnce([
createProvider(),
]);
mockFetchProviderModelsAuto.mockResolvedValueOnce({
models: [
createModel("social-model-api", {
source: "custom",
is_latest: true,
}),
],
source: "Api",
error: null,
});
const result = await resolveClawWorkspaceProviderSelection({
currentProviderType: "custom-social-provider",
currentModel: null,
theme: "social-media",
});
expect(result).toEqual({
providerType: "custom-social-provider",
model: "social-model-api",
});
expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith(
"custom-social-provider",
);
});
});
@@ -2,7 +2,12 @@ import {
loadConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
import { loadProviderModels } from "@/hooks/useProviderModels";
import { modelRegistryApi } from "@/lib/api/modelRegistry";
import {
getAliasConfigKey,
isAliasProvider,
} from "@/lib/constants/providerMappings";
import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog";
import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { filterModelsByTheme } from "./modelThemePolicy";
import { resolveProviderModelCompatibility } from "./providerModelCompatibility";
@@ -83,6 +88,52 @@ function resolvePreferredModelId(
return candidateModels[0]?.id ?? null;
}
async function loadProviderModelsForSelection(
provider: ConfiguredProvider,
): Promise<EnhancedModelMetadata[]> {
const aliasConfigPromise = isAliasProvider(provider.key)
? modelRegistryApi.getProviderAliasConfig(getAliasConfigKey(provider.key))
: Promise.resolve(null);
const [registryModels, aliasConfig] = await Promise.all([
modelRegistryApi.getModelRegistry(),
aliasConfigPromise,
]);
const localResult = buildProviderModelsFromRegistry(
provider,
registryModels,
aliasConfig,
);
if (localResult.models.length > 0 || isAliasProvider(provider.key)) {
return localResult.models;
}
try {
const apiResult = await modelRegistryApi.fetchProviderModelsAuto(provider.key);
if (!apiResult.models.length) {
return localResult.models;
}
const existingModelIds = new Set(
localResult.models.map((model) => normalizeValue(model.id)),
);
return [
...localResult.models,
...apiResult.models.filter((model) => {
const normalizedModelId = normalizeValue(model.id);
if (existingModelIds.has(normalizedModelId)) {
return false;
}
existingModelIds.add(normalizedModelId);
return true;
}),
];
} catch {
return localResult.models;
}
}
export async function resolveClawWorkspaceProviderSelection(
input: ResolveClawWorkspaceProviderSelectionInput,
): Promise<ClawWorkspaceProviderSelection | null> {
@@ -106,7 +157,7 @@ export async function resolveClawWorkspaceProviderSelection(
: configuredProviders;
for (const provider of orderedProviders) {
const providerModels = await loadProviderModels(provider);
const providerModels = await loadProviderModelsForSelection(provider);
const preferredModel = resolvePreferredModelId(
provider,
providerModels,
@@ -13,12 +13,12 @@ describe("harnessRequestMetadata", () => {
legacy_flag: true,
},
theme: "general",
creationMode: "guided",
chatMode: "general",
webSearchEnabled: true,
thinkingEnabled: false,
taskModeEnabled: true,
subagentModeEnabled: false,
preferences: {
webSearch: true,
thinking: false,
task: true,
subagent: false,
},
sessionMode: "default",
browserAssistProfileKey: "general_browser_assist",
preferredTeamPresetId: "code-triage-team",
@@ -44,10 +44,12 @@ describe("harnessRequestMetadata", () => {
trace_id: "trace-1",
legacy_flag: true,
theme: "general",
creation_mode: "guided",
chat_mode: "general",
web_search_enabled: true,
task_mode_enabled: true,
preferences: {
web_search: true,
thinking: false,
task: true,
subagent: false,
},
preferred_team_preset_id: "code-triage-team",
selected_team_id: "custom-team-1",
selected_team_source: "custom",
@@ -69,17 +71,86 @@ describe("harnessRequestMetadata", () => {
profile_key: "general_browser_assist",
}),
});
expect(metadata.creation_mode).toBeUndefined();
expect(metadata.chat_mode).toBeUndefined();
expect(metadata.web_search_enabled).toBeUndefined();
expect(metadata.task_mode_enabled).toBeUndefined();
});
it("应清理 base 中遗留的平铺状态字段", () => {
const metadata = buildHarnessRequestMetadata({
base: {
creation_mode: "guided",
chat_mode: "general",
web_search_enabled: false,
thinking_enabled: false,
task_mode_enabled: false,
subagent_mode_enabled: false,
creationMode: "hybrid",
chatMode: "creator",
webSearchEnabled: true,
thinkingEnabled: true,
taskModeEnabled: true,
subagentModeEnabled: true,
turn_team_decision: "team_prepared",
turn_team_reason: "runtime_team_prepared",
turn_team_blueprint: {
label: "旧 Team 蓝图",
},
turnTeamDecision: "single_agent",
turnTeamReason: "single_agent_direct",
turnTeamBlueprint: {
label: "legacy team blueprint",
},
},
theme: "document",
preferences: {
webSearch: true,
thinking: true,
task: false,
subagent: false,
},
sessionMode: "default",
});
expect(metadata).toMatchObject({
theme: "document",
preferences: {
web_search: true,
thinking: true,
task: false,
subagent: false,
},
});
expect(metadata.creation_mode).toBeUndefined();
expect(metadata.chat_mode).toBeUndefined();
expect(metadata.web_search_enabled).toBeUndefined();
expect(metadata.thinking_enabled).toBeUndefined();
expect(metadata.task_mode_enabled).toBeUndefined();
expect(metadata.subagent_mode_enabled).toBeUndefined();
expect(metadata.creationMode).toBeUndefined();
expect(metadata.chatMode).toBeUndefined();
expect(metadata.webSearchEnabled).toBeUndefined();
expect(metadata.thinkingEnabled).toBeUndefined();
expect(metadata.taskModeEnabled).toBeUndefined();
expect(metadata.subagentModeEnabled).toBeUndefined();
expect(metadata.turn_team_decision).toBeUndefined();
expect(metadata.turn_team_reason).toBeUndefined();
expect(metadata.turn_team_blueprint).toBeUndefined();
expect(metadata.turnTeamDecision).toBeUndefined();
expect(metadata.turnTeamReason).toBeUndefined();
expect(metadata.turnTeamBlueprint).toBeUndefined();
});
it("默认会话模式不应写入 gate_key", () => {
const metadata = buildHarnessRequestMetadata({
theme: "document",
creationMode: "fast",
chatMode: "agent",
webSearchEnabled: false,
thinkingEnabled: true,
taskModeEnabled: false,
subagentModeEnabled: false,
preferences: {
webSearch: false,
thinking: true,
task: false,
subagent: false,
},
sessionMode: "default",
gateKey: "topic_select",
});
@@ -87,15 +158,31 @@ describe("harnessRequestMetadata", () => {
expect(metadata.gate_key).toBeUndefined();
});
it("应透传当前发送用途,供后端统一决策运行时行为", () => {
const metadata = buildHarnessRequestMetadata({
theme: "document",
turnPurpose: "content_review",
preferences: {
webSearch: false,
thinking: true,
task: false,
subagent: false,
},
sessionMode: "theme_workbench",
});
expect(metadata.turn_purpose).toBe("content_review");
});
it("应保留 Team 角色的 profileId、roleKey 与 skillIds", () => {
const metadata = buildHarnessRequestMetadata({
theme: "general",
creationMode: "guided",
chatMode: "agent",
webSearchEnabled: false,
thinkingEnabled: true,
taskModeEnabled: true,
subagentModeEnabled: true,
preferences: {
webSearch: false,
thinking: true,
task: true,
subagent: true,
},
sessionMode: "default",
selectedTeamRoles: [
{
@@ -121,73 +208,32 @@ describe("harnessRequestMetadata", () => {
]);
});
it("应写入当前 Team 决策与预编队蓝图", () => {
it("不应再写入旧 turn_team compat 字段", () => {
const metadata = buildHarnessRequestMetadata({
theme: "general",
creationMode: "guided",
chatMode: "agent",
webSearchEnabled: false,
thinkingEnabled: true,
taskModeEnabled: true,
subagentModeEnabled: true,
sessionMode: "default",
turnTeamDecision: "team_prepared",
turnTeamReason: "runtime_team_prepared",
turnTeamBlueprint: {
label: "当前调试 Team",
description: "先分析,再实现,最后验证。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责提交修复。",
},
],
preferences: {
webSearch: false,
thinking: true,
task: true,
subagent: true,
},
sessionMode: "default",
});
expect(metadata).toMatchObject({
turn_team_decision: "team_prepared",
turn_team_reason: "runtime_team_prepared",
turn_team_blueprint: {
label: "当前调试 Team",
description: "先分析,再实现,最后验证。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题。",
profile_id: "code-explorer",
role_key: "explorer",
skill_ids: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责提交修复。",
},
],
},
});
expect(metadata.turn_team_decision).toBeUndefined();
expect(metadata.turn_team_reason).toBeUndefined();
expect(metadata.turn_team_blueprint).toBeUndefined();
});
it("需要人工确认的浏览器任务应标记 user step", () => {
const metadata = buildHarnessRequestMetadata({
theme: "general",
creationMode: "hybrid",
chatMode: "general",
webSearchEnabled: true,
thinkingEnabled: true,
taskModeEnabled: true,
subagentModeEnabled: true,
preferences: {
webSearch: true,
thinking: true,
task: true,
subagent: true,
},
sessionMode: "theme_workbench",
gateKey: "publish_confirm",
browserRequirement: "required_with_user_step",
@@ -1,24 +1,16 @@
import type { CreationMode } from "../components/types";
import type { BrowserTaskRequirement } from "../types";
import type { BrowserTaskRequirement, Message } from "../types";
import type { TeamRoleDefinition, TeamDefinitionSource } from "./teamDefinitions";
export type HarnessTurnTeamDecision = "single_agent" | "team_prepared";
export interface HarnessTurnTeamBlueprint {
label?: string | null;
description?: string | null;
roles?: TeamRoleDefinition[] | null;
}
export interface BuildHarnessRequestMetadataOptions {
base?: Record<string, unknown>;
theme: string;
creationMode: CreationMode;
chatMode: "agent" | "general" | "creator";
webSearchEnabled: boolean;
thinkingEnabled: boolean;
taskModeEnabled: boolean;
subagentModeEnabled: boolean;
turnPurpose?: Message["purpose"] | null;
preferences: {
webSearch: boolean;
thinking: boolean;
task: boolean;
subagent: boolean;
};
sessionMode: "default" | "theme_workbench";
gateKey?: string | null;
runTitle?: string | null;
@@ -33,9 +25,6 @@ export interface BuildHarnessRequestMetadataOptions {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
turnTeamDecision?: HarnessTurnTeamDecision | null;
turnTeamReason?: string | null;
turnTeamBlueprint?: HarnessTurnTeamBlueprint | null;
}
export function extractExistingHarnessMetadata(
@@ -53,18 +42,41 @@ export function extractExistingHarnessMetadata(
return harnessValue as Record<string, unknown>;
}
const LEGACY_HARNESS_STATE_KEYS = [
"creation_mode",
"creationMode",
"chat_mode",
"chatMode",
"web_search_enabled",
"webSearchEnabled",
"thinking_enabled",
"thinkingEnabled",
"task_mode_enabled",
"taskModeEnabled",
"subagent_mode_enabled",
"subagentModeEnabled",
"turn_team_decision",
"turnTeamDecision",
"turn_team_reason",
"turnTeamReason",
"turn_team_blueprint",
"turnTeamBlueprint",
] as const;
function clearLegacyHarnessStateFields(metadata: Record<string, unknown>): void {
LEGACY_HARNESS_STATE_KEYS.forEach((key) => {
delete metadata[key];
});
}
export function buildHarnessRequestMetadata(
options: BuildHarnessRequestMetadataOptions,
): Record<string, unknown> {
const {
base,
theme,
creationMode,
chatMode,
webSearchEnabled,
thinkingEnabled,
taskModeEnabled,
subagentModeEnabled,
turnPurpose,
preferences,
sessionMode,
gateKey,
runTitle,
@@ -79,9 +91,6 @@ export function buildHarnessRequestMetadata(
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles,
turnTeamDecision,
turnTeamReason,
turnTeamBlueprint,
} = options;
const serializeTeamRoles = (roles?: TeamRoleDefinition[] | null) =>
@@ -99,15 +108,16 @@ export function buildHarnessRequestMetadata(
}))
: undefined;
return {
const metadata: Record<string, unknown> = {
...(base || {}),
theme,
creation_mode: creationMode,
chat_mode: chatMode,
web_search_enabled: webSearchEnabled,
thinking_enabled: thinkingEnabled,
task_mode_enabled: taskModeEnabled,
subagent_mode_enabled: subagentModeEnabled,
turn_purpose: turnPurpose || undefined,
preferences: {
web_search: preferences.webSearch,
thinking: preferences.thinking,
task: preferences.task,
subagent: preferences.subagent,
},
session_mode: sessionMode,
gate_key:
sessionMode === "theme_workbench" ? gateKey || undefined : undefined,
@@ -119,19 +129,6 @@ export function buildHarnessRequestMetadata(
selected_team_label: selectedTeamLabel || undefined,
selected_team_summary: selectedTeamSummary || undefined,
selected_team_roles: serializeTeamRoles(selectedTeamRoles),
turn_team_decision: turnTeamDecision || undefined,
turn_team_reason: turnTeamReason || undefined,
turn_team_blueprint:
turnTeamBlueprint &&
(turnTeamBlueprint.label?.trim() ||
turnTeamBlueprint.description?.trim() ||
(turnTeamBlueprint.roles?.length || 0) > 0)
? {
label: turnTeamBlueprint.label?.trim() || undefined,
description: turnTeamBlueprint.description?.trim() || undefined,
roles: serializeTeamRoles(turnTeamBlueprint.roles),
}
: undefined,
browser_requirement: browserRequirement || undefined,
browser_requirement_reason: browserRequirementReason || undefined,
browser_launch_url: browserLaunchUrl || undefined,
@@ -147,4 +144,7 @@ export function buildHarnessRequestMetadata(
}
: undefined,
};
clearLegacyHarnessStateFields(metadata);
return metadata;
}
@@ -151,6 +151,51 @@ describe("deriveHarnessSessionState", () => {
expect(state.outputSignals[0]?.content).toContain("https://example.com/xinhua");
});
it("应通过 artifact protocol 提取工具输出中的嵌套产物路径", () => {
const messages = [
createMessage({
toolCalls: [
{
id: "tool-artifact-1",
name: "write_file",
arguments: JSON.stringify({
payload: {
filePath: "workspace/draft.md",
},
}),
status: "completed",
result: {
success: true,
metadata: {
payload: {
artifactPath: "workspace\\cover.png",
},
},
output: JSON.stringify({
result: {
absolute_path: "/tmp/workspace/final.md",
},
}),
},
startTime: new Date("2026-03-13T12:00:00.000Z"),
endTime: new Date("2026-03-13T12:00:03.000Z"),
},
],
}),
];
const state = deriveHarnessSessionState(messages, []);
expect(state.outputSignals[0]).toMatchObject({
title: "产物已写入",
artifactPath: "workspace/cover.png",
});
expect(state.recentFileEvents[0]).toMatchObject({
path: "workspace/draft.md",
action: "write",
});
});
it("仅有 turn_summary 时也应为 harness 提供计划摘要兜底", () => {
const messages = [createMessage()];
const items: AgentThreadItem[] = [
+18 -71
View File
@@ -1,9 +1,14 @@
import type {
AgentContextTraceStep as ContextTraceStep,
AgentThreadItem,
ContextTraceStep,
ToolCallState,
} from "@/lib/api/agentStream";
AgentToolCallState as ToolCallState,
} from "@/lib/api/agentProtocol";
import type { ArtifactStatus } from "@/lib/artifact/types";
import {
extractArtifactProtocolPathsFromValue,
resolveArtifactProtocolFilePath,
} from "@/lib/artifact-protocol";
import { extractFilesystemEventPathsFromValue } from "@/lib/filesystem-event-protocol";
import type { ActionRequired, AgentRuntimeStatus, Message } from "../types";
import { resolveToolDisplayLabel } from "./toolDisplayInfo";
import {
@@ -446,14 +451,7 @@ function extractActiveFileWrites(
continue;
}
const path =
typeof artifact.meta.filePath === "string" &&
artifact.meta.filePath.trim()
? artifact.meta.filePath.trim()
: typeof artifact.meta.filename === "string" &&
artifact.meta.filename.trim()
? artifact.meta.filename.trim()
: artifact.title;
const path = resolveArtifactProtocolFilePath(artifact);
if (!path) {
continue;
}
@@ -580,56 +578,6 @@ function fileNameFromPath(path: string): string {
return segments[segments.length - 1] || path;
}
function pickFirstPath(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string" && item.trim()) {
return item.trim();
}
}
}
return undefined;
}
function extractPathFromRecord(
record: Record<string, unknown> | null,
): string | undefined {
if (!record) {
return undefined;
}
for (const key of [
"path",
"file_path",
"filePath",
"file_name",
"fileName",
"filename",
"target_path",
"targetPath",
"output_path",
"outputPath",
"absolute_path",
"absolutePath",
"new_path",
"newPath",
"paths",
"files",
]) {
const value = pickFirstPath(record[key]);
if (value) {
return value;
}
}
return undefined;
}
function extractContentFromRecord(
record: Record<string, unknown> | null,
): string | undefined {
@@ -739,12 +687,7 @@ function resolveFileKind(
function extractArtifactPath(toolCall: ToolCallState): string | undefined {
const output = toolCall.result?.output;
const parsed = asRecord(parseJsonValue(output));
return (
normalizeString(parsed?.absolute_path) ||
normalizeString(parsed?.path) ||
normalizeString(parsed?.output_path)
);
return extractArtifactProtocolPathsFromValue(parseJsonValue(output))[0];
}
function extractOutputSignal(
@@ -766,7 +709,7 @@ function extractOutputSignal(
output,
);
const artifactPath =
normalizeString(metadata?.path) || extractArtifactPath(toolCall);
extractArtifactProtocolPathsFromValue(metadata)[0] || extractArtifactPath(toolCall);
const exitCode =
normalizeNumber(metadata?.exit_code) ||
parseNumberFromText(/^退出码:\s*(-?\d+)$/m, output) ||
@@ -939,7 +882,12 @@ function extractFileEventFromToolCall(
const args = asRecord(parseJsonValue(toolCall.arguments));
const metadata = extractMetadata(toolCall);
const path = extractPathFromRecord(args) || extractPathFromRecord(metadata);
const path =
extractArtifactProtocolPathsFromValue(args)[0] ||
extractArtifactProtocolPathsFromValue(metadata)[0] ||
// 非 artifact 协议文件工具仍可能只暴露通用文件参数,这里保留事件级 fallback。
extractFilesystemEventPathsFromValue(args)[0] ||
extractFilesystemEventPathsFromValue(metadata)[0];
if (!path) {
return null;
}
@@ -1147,8 +1095,7 @@ function pickItemPath(item: AgentThreadItem): string | undefined {
}
if (item.type === "tool_call") {
const metadata = asRecord(item.metadata);
return extractPathFromRecord(metadata);
return extractArtifactProtocolPathsFromValue(item.metadata)[0];
}
return undefined;
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import type { Artifact } from "@/lib/artifact/types";
import { ARTIFACT_DOCUMENT_SCHEMA_VERSION } from "@/lib/artifact-document";
import { buildArtifactFromWrite } from "./messageArtifacts";
function createArtifact(overrides: Partial<Artifact> = {}): Artifact {
const content = overrides.content ?? "";
return {
id: overrides.id ?? "artifact-1",
type: overrides.type ?? "document",
title: overrides.title ?? "report.artifact.json",
content,
status: overrides.status ?? "streaming",
meta: {
filePath:
overrides.meta?.filePath ??
".lime/artifacts/thread-1/report.artifact.json",
filename: overrides.meta?.filename ?? "report.artifact.json",
...overrides.meta,
},
position: overrides.position ?? { start: 0, end: content.length },
createdAt: overrides.createdAt ?? 1,
updatedAt: overrides.updatedAt ?? 1,
error: overrides.error,
};
}
describe("messageArtifacts.buildArtifactFromWrite", () => {
it("应从 artifactDocument metadata 直接构建结构化文档 artifact", () => {
const artifactDocument = {
schemaVersion: ARTIFACT_DOCUMENT_SCHEMA_VERSION,
artifactId: "artifact-doc-1",
kind: "analysis",
title: "自动落盘报告",
status: "ready",
language: "zh-CN",
summary: "这是结构化摘要。",
blocks: [
{
id: "hero-1",
type: "hero_summary",
summary: "这是结构化摘要。",
},
],
sources: [],
metadata: {
theme: "knowledge",
},
};
const artifact = buildArtifactFromWrite({
filePath: ".lime/artifacts/thread-1/report.artifact.json",
content: "",
context: {
artifactId: "artifact-snapshot-1",
source: "artifact_snapshot",
sourceMessageId: "assistant-1",
status: "streaming",
metadata: {
complete: true,
artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION,
artifactDocument,
},
},
});
expect(artifact.type).toBe("document");
expect(artifact.meta.language).toBe("json");
expect(artifact.meta.previewText).toBe("这是结构化摘要。");
expect(artifact.meta.artifactTitle).toBe("自动落盘报告");
expect(artifact.meta.artifactKind).toBe("analysis");
expect(artifact.meta.artifactDocument).toMatchObject({
artifactId: "artifact-doc-1",
kind: "analysis",
title: "自动落盘报告",
summary: "这是结构化摘要。",
});
});
it("metadata 仅保留 schema 时应复用已有 artifactDocument", () => {
const previousArtifact = createArtifact({
meta: {
filePath: ".lime/artifacts/thread-1/report.artifact.json",
filename: "report.artifact.json",
artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION,
artifactDocument: {
schemaVersion: ARTIFACT_DOCUMENT_SCHEMA_VERSION,
artifactId: "artifact-doc-2",
kind: "report",
title: "已有结构化文档",
status: "ready",
language: "zh-CN",
summary: "沿用已有结构摘要",
blocks: [
{
id: "hero-1",
type: "hero_summary",
summary: "沿用已有结构摘要",
},
],
sources: [],
metadata: {},
},
},
});
const artifact = buildArtifactFromWrite({
filePath: ".lime/artifacts/thread-1/report.artifact.json",
content: "",
context: {
artifact: previousArtifact,
source: "artifact_snapshot",
sourceMessageId: "assistant-2",
status: "complete",
metadata: {
artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION,
complete: true,
},
},
});
expect(artifact.meta.artifactTitle).toBe("已有结构化文档");
expect(artifact.meta.previewText).toBe("沿用已有结构摘要");
expect(artifact.meta.artifactDocument).toMatchObject({
artifactId: "artifact-doc-2",
kind: "report",
title: "已有结构化文档",
summary: "沿用已有结构摘要",
});
});
});
@@ -4,6 +4,12 @@ import type {
ArtifactStatus,
ArtifactType,
} from "@/lib/artifact/types";
import {
hasArtifactProtocolDocumentMetadata,
resolveArtifactProtocolDocumentPayload,
resolveArtifactProtocolFilePath,
resolveArtifactProtocolPreviewText,
} from "@/lib/artifact-protocol";
import type {
ArtifactWriteMetadata,
ArtifactWritePhase,
@@ -52,6 +58,8 @@ const CODE_LANGUAGE_BY_EXTENSION: Record<string, string> = {
};
const ARTIFACT_TYPE_ALIASES: Record<string, ArtifactType> = {
artifact_document: "document",
artifact_document_v1: "document",
code: "code",
document: "document",
draft: "document",
@@ -184,10 +192,7 @@ export function findMessageArtifact(
return false;
}
const artifactPath =
typeof artifact.meta.filePath === "string"
? normalizePath(artifact.meta.filePath)
: null;
const artifactPath = normalizePath(resolveArtifactProtocolFilePath(artifact));
return artifactPath === normalizedPath;
});
}
@@ -195,7 +200,18 @@ export function findMessageArtifact(
export function resolveArtifactTypeFromFile(
filePath: string,
metadata?: Record<string, unknown>,
content?: string,
): ArtifactType {
if (
resolveArtifactProtocolDocumentPayload({
content,
metadata,
}) ||
hasArtifactProtocolDocumentMetadata(metadata)
) {
return "document";
}
const explicitType =
readStringValue(metadata, "artifact_type") ||
readStringValue(metadata, "type") ||
@@ -259,24 +275,6 @@ export function resolveDefaultArtifactViewMode(
return "source";
}
export function resolveArtifactFilePath(
artifact: Pick<Artifact, "title" | "meta">,
): string {
if (
typeof artifact.meta.filePath === "string" &&
artifact.meta.filePath.trim()
) {
return artifact.meta.filePath.trim();
}
if (
typeof artifact.meta.filename === "string" &&
artifact.meta.filename.trim()
) {
return artifact.meta.filename.trim();
}
return artifact.title;
}
export interface BuildArtifactInput {
filePath: string;
content: string;
@@ -292,31 +290,59 @@ export function buildArtifactFromWrite({
const title = fileNameFromPath(normalizedPath);
const now = Date.now();
const metadata = context?.metadata;
const type = resolveArtifactTypeFromFile(normalizedPath, metadata);
const language =
type === "code" || type === "document"
? resolveArtifactLanguageFromFile(normalizedPath)
: undefined;
const previewText =
typeof content === "string" && content.trim()
? normalizePreviewText(content)
: undefined;
const existingMeta =
context?.artifact?.meta && typeof context.artifact.meta === "object"
? (context.artifact.meta as ArtifactWriteMetadata)
: undefined;
const existingArtifactDocument = resolveArtifactProtocolDocumentPayload({
metadata: existingMeta,
});
const artifactDocument = resolveArtifactProtocolDocumentPayload({
content,
metadata,
previous: existingArtifactDocument,
});
const type = resolveArtifactTypeFromFile(normalizedPath, metadata, content);
const language =
artifactDocument
? "json"
: type === "code" || type === "document"
? resolveArtifactLanguageFromFile(normalizedPath)
: undefined;
const previewCandidate =
artifactDocument
? resolveArtifactProtocolPreviewText(artifactDocument)
: typeof metadata?.previewText === "string" && metadata.previewText.trim()
? metadata.previewText
: typeof content === "string" && content.trim()
? content
: undefined;
const previewText = previewCandidate
? normalizePreviewText(previewCandidate)
: undefined;
const baseMeta: ArtifactMeta = {
...(existingMeta || {}),
...(metadata || {}),
...(language ? { language } : {}),
...(previewText &&
!(typeof metadata?.previewText === "string" && metadata.previewText.trim())
(!(
typeof metadata?.previewText === "string" && metadata.previewText.trim()
) ||
Boolean(artifactDocument))
? { previewText }
: {}),
filePath: normalizedPath,
filename: title,
source: context?.source,
sourceMessageId: context?.sourceMessageId,
...(artifactDocument
? {
artifactSchema: artifactDocument.schemaVersion,
artifactKind: artifactDocument.kind,
artifactDocument,
artifactTitle: artifactDocument.title,
}
: {}),
};
return {
@@ -397,42 +423,6 @@ export function updateMessageArtifactsStatus(
};
}
export function extractArtifactPathsFromMetadata(
metadata?: Record<string, unknown>,
): string[] {
if (!metadata) {
return [];
}
const collected = new Set<string>();
const appendPath = (value: unknown) => {
if (typeof value !== "string") {
return;
}
const normalized = normalizePath(value);
if (normalized) {
collected.add(normalized);
}
};
const appendArray = (value: unknown) => {
if (!Array.isArray(value)) {
return;
}
for (const item of value) {
appendPath(item);
}
};
appendArray(metadata.artifact_paths);
appendArray(metadata.paths);
appendPath(metadata.artifact_path);
appendPath(metadata.path);
appendPath(metadata.source_file_name);
return Array.from(collected);
}
export function mergeArtifacts(artifacts: Artifact[]): Artifact[] {
const merged = new Map<string, Artifact>();
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
applyModelChangeExecutionRuntime,
getExecutionRuntimeDisplayLabel,
applyTurnContextExecutionRuntime,
createChatToolPreferencesFromExecutionRuntime,
createSessionModelPreferenceFromExecutionRuntime,
getExecutionRuntimeProviderLabel,
getExecutionRuntimeSummaryLabel,
getOutputSchemaRuntimeLabel,
} from "./sessionExecutionRuntime";
describe("sessionExecutionRuntime", () => {
it("应根据 turn_context 事件同步 output schema runtime", () => {
const runtime = applyTurnContextExecutionRuntime(null, {
type: "turn_context",
session_id: "session-1",
thread_id: "thread-1",
turn_id: "turn-1",
output_schema_runtime: {
source: "turn",
strategy: "native",
providerName: "openai",
modelName: "gpt-5.4",
},
});
expect(runtime).toMatchObject({
session_id: "session-1",
source: "turn_context",
provider_name: "openai",
model_name: "gpt-5.4",
latest_turn_id: "turn-1",
latest_turn_status: "running",
});
});
it("应在 model_change 后保留 provider 与 output schema,并更新模型", () => {
const fromTurnContext = applyTurnContextExecutionRuntime(null, {
type: "turn_context",
session_id: "session-1",
thread_id: "thread-1",
turn_id: "turn-1",
output_schema_runtime: {
source: "session",
strategy: "final_output_tool",
providerName: "openai",
modelName: "gpt-5.4",
},
});
const runtime = applyModelChangeExecutionRuntime(fromTurnContext, {
type: "model_change",
model: "gpt-5.4-mini",
mode: "responses",
});
expect(runtime).toMatchObject({
session_id: "session-1",
source: "model_change",
provider_name: "openai",
model_name: "gpt-5.4-mini",
mode: "responses",
});
expect(runtime?.output_schema_runtime?.strategy).toBe("final_output_tool");
});
it("应产出可读的 provider 与 schema 标签", () => {
const runtime = {
session_id: "session-2",
provider_selector: "openai",
provider_name: "openai",
model_name: "gpt-5.4",
source: "runtime_snapshot" as const,
output_schema_runtime: {
source: "turn" as const,
strategy: "native" as const,
providerName: "openai",
modelName: "gpt-5.4",
},
};
expect(getExecutionRuntimeProviderLabel(runtime)).toBe("OpenAI");
expect(getExecutionRuntimeSummaryLabel(runtime)).toBe(
"执行模型 OpenAI · gpt-5.4",
);
expect(getExecutionRuntimeDisplayLabel(runtime)).toBe(
"最近执行模型 OpenAI · gpt-5.4",
);
expect(getExecutionRuntimeDisplayLabel(runtime, { active: true })).toBe(
"实际执行模型 OpenAI · gpt-5.4",
);
expect(getOutputSchemaRuntimeLabel(runtime.output_schema_runtime)).toBe(
"Native schema · turn contract",
);
});
it("应优先使用 provider_selector 还原会话模型偏好", () => {
expect(
createSessionModelPreferenceFromExecutionRuntime({
provider_selector: "custom-provider-id",
provider_name: "openai",
model_name: "gpt-5.4",
}),
).toEqual({
providerType: "custom-provider-id",
model: "gpt-5.4",
});
});
it("缺少 provider 或 model 时不应生成会话模型偏好", () => {
expect(
createSessionModelPreferenceFromExecutionRuntime({
provider_selector: "openai",
model_name: null,
}),
).toBeNull();
expect(
createSessionModelPreferenceFromExecutionRuntime({
provider_selector: null,
provider_name: null,
model_name: "gpt-5.4",
}),
).toBeNull();
});
it("应从 execution runtime 提取最近工具偏好", () => {
expect(
createChatToolPreferencesFromExecutionRuntime({
recent_preferences: {
webSearch: true,
thinking: true,
task: false,
subagent: true,
},
}),
).toEqual({
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
});
});
@@ -0,0 +1,223 @@
import { getProviderLabel } from "@/lib/constants/providerMappings";
import type {
AsterSessionExecutionRuntime,
AsterSessionExecutionRuntimePreferences,
AsterSessionExecutionRuntimeSource,
AsterTurnOutputSchemaRuntime,
} from "@/lib/api/agentExecutionRuntime";
import type { AsterSessionDetail } from "@/lib/api/agentRuntime";
import type {
AgentEventModelChange,
AgentEventTurnContext,
} from "@/lib/api/agentProtocol";
import type { SessionModelPreference } from "../hooks/agentChatShared";
import type { ChatToolPreferences } from "./chatToolPreferences";
function mergeExecutionRuntime(
current: AsterSessionExecutionRuntime | null,
updates: Partial<AsterSessionExecutionRuntime>,
source: AsterSessionExecutionRuntimeSource,
): AsterSessionExecutionRuntime | null {
const sessionId = updates.session_id || current?.session_id;
const providerSelector =
updates.provider_selector ?? current?.provider_selector ?? null;
const providerName = updates.provider_name ?? current?.provider_name ?? null;
const modelName = updates.model_name ?? current?.model_name ?? null;
const executionStrategy =
updates.execution_strategy ?? current?.execution_strategy ?? null;
const outputSchemaRuntime =
updates.output_schema_runtime ?? current?.output_schema_runtime ?? null;
const recentPreferences =
updates.recent_preferences ?? current?.recent_preferences ?? null;
const mode = updates.mode ?? current?.mode ?? null;
const latestTurnId = updates.latest_turn_id ?? current?.latest_turn_id ?? null;
const latestTurnStatus =
updates.latest_turn_status ?? current?.latest_turn_status ?? null;
if (!sessionId) {
return null;
}
if (
!providerSelector &&
!providerName &&
!modelName &&
!outputSchemaRuntime &&
!executionStrategy &&
!recentPreferences
) {
return null;
}
return {
session_id: sessionId,
provider_selector: providerSelector,
provider_name: providerName,
model_name: modelName,
execution_strategy: executionStrategy,
output_schema_runtime: outputSchemaRuntime,
recent_preferences: recentPreferences,
source,
mode,
latest_turn_id: latestTurnId,
latest_turn_status: latestTurnStatus,
};
}
export function createExecutionRuntimeFromSessionDetail(
detail?: Pick<AsterSessionDetail, "execution_runtime"> | null,
): AsterSessionExecutionRuntime | null {
return detail?.execution_runtime || null;
}
export function createSessionModelPreferenceFromExecutionRuntime(
runtime?: Pick<
AsterSessionExecutionRuntime,
"provider_selector" | "provider_name" | "model_name"
> | null,
): SessionModelPreference | null {
const providerType =
runtime?.provider_selector?.trim() ||
runtime?.provider_name?.trim() ||
null;
const model = runtime?.model_name?.trim() || null;
if (!providerType || !model) {
return null;
}
return {
providerType,
model,
};
}
function normalizeRecentPreferenceBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function createChatToolPreferencesFromExecutionRuntime(
runtime?: Pick<AsterSessionExecutionRuntime, "recent_preferences"> | null,
): ChatToolPreferences | null {
const preferences = runtime?.recent_preferences as
| AsterSessionExecutionRuntimePreferences
| null
| undefined;
if (!preferences) {
return null;
}
const webSearch = normalizeRecentPreferenceBoolean(preferences.webSearch);
const thinking = normalizeRecentPreferenceBoolean(preferences.thinking);
const task = normalizeRecentPreferenceBoolean(preferences.task);
const subagent = normalizeRecentPreferenceBoolean(preferences.subagent);
if (
webSearch === null &&
thinking === null &&
task === null &&
subagent === null
) {
return null;
}
return {
webSearch: webSearch ?? false,
thinking: thinking ?? false,
task: task ?? false,
subagent: subagent ?? false,
};
}
export function applyTurnContextExecutionRuntime(
current: AsterSessionExecutionRuntime | null,
event: AgentEventTurnContext,
): AsterSessionExecutionRuntime | null {
const outputSchemaRuntime = event.output_schema_runtime || null;
return mergeExecutionRuntime(
current,
{
session_id: event.session_id,
output_schema_runtime: outputSchemaRuntime,
provider_name: outputSchemaRuntime?.providerName ?? undefined,
model_name: outputSchemaRuntime?.modelName ?? undefined,
latest_turn_id: event.turn_id,
latest_turn_status: "running",
},
"turn_context",
);
}
export function applyModelChangeExecutionRuntime(
current: AsterSessionExecutionRuntime | null,
event: AgentEventModelChange,
): AsterSessionExecutionRuntime | null {
return mergeExecutionRuntime(
current,
{
model_name: event.model,
mode: event.mode,
latest_turn_status: current?.latest_turn_status || "running",
},
"model_change",
);
}
export function getExecutionRuntimeProviderLabel(
runtime?: AsterSessionExecutionRuntime | null,
): string | null {
const providerKey =
runtime?.provider_selector?.trim() ||
runtime?.provider_name?.trim() ||
null;
if (!providerKey) {
return null;
}
return getProviderLabel(providerKey);
}
export function getOutputSchemaRuntimeLabel(
runtime?: AsterTurnOutputSchemaRuntime | null,
): string | null {
if (!runtime) {
return null;
}
const strategyLabel =
runtime.strategy === "native" ? "Native schema" : "Final output tool";
const sourceLabel =
runtime.source === "turn" ? "turn contract" : "session contract";
return `${strategyLabel} · ${sourceLabel}`;
}
export function getExecutionRuntimeSummaryLabel(
runtime?: AsterSessionExecutionRuntime | null,
): string | null {
const providerLabel = getExecutionRuntimeProviderLabel(runtime);
const modelLabel = runtime?.model_name?.trim() || null;
if (providerLabel && modelLabel) {
return `执行模型 ${providerLabel} · ${modelLabel}`;
}
if (modelLabel) {
return `执行模型 ${modelLabel}`;
}
if (providerLabel) {
return `执行提供方 ${providerLabel}`;
}
return null;
}
export function getExecutionRuntimeDisplayLabel(
runtime?: AsterSessionExecutionRuntime | null,
options?: { active?: boolean },
): string | null {
const summaryLabel = getExecutionRuntimeSummaryLabel(runtime);
if (!summaryLabel) {
return null;
}
return summaryLabel.replace(
/^执行/,
options?.active ? "实际执行" : "最近执行",
);
}
@@ -1,4 +1,4 @@
import type { StreamEvent } from "@/lib/api/agentStream";
import type { AgentEvent } from "@/lib/api/agentProtocol";
import { updateCrashContext } from "@/lib/crashReporting";
const EVENT_PUBLISH_INTERVAL = 20;
@@ -119,7 +119,7 @@ export function createStreamDiagnosticsReporter(componentName: string) {
publish(true);
},
record(event: StreamEvent) {
record(event: AgentEvent) {
if (!snapshot) return;
snapshot.totalEvents += 1;
snapshot.lastEventAt = nowIso();
@@ -1,317 +0,0 @@
import { safeListen } from "@/lib/dev-bridge";
import {
createAgentRuntimeSession,
deleteAgentRuntimeSession,
getAgentRuntimeSession,
initAsterAgent,
submitAgentRuntimeTurn,
type AsterExecutionStrategy,
} from "@/lib/api/agentRuntime";
import { parseStreamEvent } from "@/lib/api/agentStream";
import { mapProviderName } from "../hooks/agentChatCoreUtils";
import {
createTeamDefinitionId,
normalizeTeamDefinition,
type TeamDefinition,
type TeamRoleDefinition,
} from "./teamDefinitions";
interface GenerateTeamWithModelOptions {
workspaceId: string;
providerType: string;
model: string;
executionStrategy?: AsterExecutionStrategy;
activeTheme?: string;
input: string;
blueprintTeam?: TeamDefinition | null;
timeoutMs?: number;
}
interface GeneratedTeamPayload {
label?: string;
description?: string;
roles?: Array<{
id?: string;
label?: string;
summary?: string;
profileId?: string;
roleKey?: string;
skillIds?: string[];
}>;
team?: GeneratedTeamPayload;
}
function buildTeamGenerationPrompt(
input: string,
activeTheme?: string,
blueprintTeam?: TeamDefinition | null,
): string {
const blueprintPayload = blueprintTeam
? {
label: blueprintTeam.label,
description: blueprintTeam.description,
roles: blueprintTeam.roles.map((role) => ({
id: role.id,
label: role.label,
summary: role.summary,
profileId: role.profileId,
roleKey: role.roleKey,
skillIds: role.skillIds ?? [],
})),
}
: null;
return [
"请根据下面的任务,为 GUI Team 模式生成一份“当前协作方案”。",
"要求:",
"1. 仅输出 JSON,不要 Markdown,不要代码块,不要额外解释。",
"2. 生成 2~4 个角色,角色名称与职责用中文。",
"3. label 要像产品里可直接展示的团队名称,不要出现“本轮 Team”这类内部说法。",
"4. roleKey 优先从以下集合中选择:explorer、executor、verifier、researcher、planner、reviewer、writer。",
"5. profileId 仅可使用以下值:code-explorer、code-executor、code-verifier、research-analyst、doc-writer、content-ideator、content-reviewer。",
"6. skillIds 仅可使用以下值:repo-exploration、bounded-implementation、verification-report、source-grounding、structured-writing。",
"7. 输出结构必须满足:",
JSON.stringify(
{
label: "当前协作名称",
description: "一句话描述适用场景",
roles: [
{
id: "role-id",
label: "角色名称",
summary: "角色职责",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration", "source-grounding"],
},
],
},
null,
2,
),
`当前主题:${activeTheme?.trim() || "general"}`,
blueprintPayload
? "8. 如果下面提供了“参考蓝图 Team”,请把它视为偏好与约束来源;你可以按当前任务动态调整角色结构,但不要完全脱离参考蓝图。"
: "8. 如果没有参考蓝图,请直接围绕当前任务组织最合适的当前协作方案。",
blueprintPayload
? `参考蓝图 Team:${JSON.stringify(blueprintPayload, null, 2)}`
: "参考蓝图 Team:无",
"如果任务本身不复杂,也仍然请输出一个最轻量可用的两角色 Team。",
`任务描述:${input.trim()}`,
].join("\n");
}
function extractAssistantText(detail: Awaited<ReturnType<typeof getAgentRuntimeSession>>): string {
const assistantMessages = [...detail.messages]
.filter((message) => message.role === "assistant")
.sort((left, right) => right.timestamp - left.timestamp);
for (const message of assistantMessages) {
const text = message.content
.map((item) => item.text || item.output || item.error || "")
.join("\n")
.trim();
if (text) {
return text;
}
}
return "";
}
function extractJsonObject(raw: string): string {
const trimmed = raw.trim();
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = codeFenceMatch?.[1]?.trim() || trimmed;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
throw new Error("模型未返回可解析的 JSON");
}
return candidate.slice(start, end + 1);
}
function parseGeneratedTeam(
raw: string,
activeTheme?: string,
): TeamDefinition {
const json = extractJsonObject(raw);
const parsed = JSON.parse(json) as GeneratedTeamPayload;
const payload = parsed.team && typeof parsed.team === "object" ? parsed.team : parsed;
const normalizedRoles: TeamRoleDefinition[] | undefined = payload.roles?.map(
(role) => ({
id: role.id?.trim() || "",
label: role.label?.trim() || "",
summary: role.summary?.trim() || "",
profileId: role.profileId?.trim() || undefined,
roleKey: role.roleKey?.trim() || undefined,
skillIds: role.skillIds?.map((skillId) => skillId.trim()).filter(Boolean) || [],
}),
);
const normalized = normalizeTeamDefinition({
id: createTeamDefinitionId("ephemeral-team"),
source: "ephemeral",
label: payload.label,
description: payload.description,
theme: activeTheme?.trim() || undefined,
roles: normalizedRoles,
});
if (!normalized) {
throw new Error("模型返回的 Team 结构不完整");
}
return {
...normalized,
source: "ephemeral",
};
}
export async function generateEphemeralTeamWithModel(
options: GenerateTeamWithModelOptions,
): Promise<TeamDefinition> {
const {
workspaceId,
providerType,
model,
executionStrategy = "react",
activeTheme,
input,
blueprintTeam,
timeoutMs = 45_000,
} = options;
const trimmedInput = input.trim();
if (!workspaceId.trim()) {
throw new Error("缺少 workspace,暂时无法生成 Team");
}
if (!providerType.trim() || !model.trim()) {
throw new Error("请先选择可用模型,再生成 Team");
}
if (!trimmedInput) {
throw new Error("请先输入任务描述,再生成 Team");
}
await initAsterAgent();
const resolvedExecutionStrategy =
executionStrategy === "code_orchestrated" ? "react" : executionStrategy;
const sessionId = await createAgentRuntimeSession(
workspaceId,
"Team 规划",
resolvedExecutionStrategy,
);
const eventName = `agent_team_draft:${sessionId}:${Date.now()}`;
const unlistenRef: { current: (() => void) | null } = { current: null };
try {
const completion = new Promise<TeamDefinition>((resolve, reject) => {
let settled = false;
const timer = globalThis.setTimeout(() => {
settle(() => reject(new Error("生成 Team 超时,请稍后重试")));
}, timeoutMs);
const settle = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
globalThis.clearTimeout(timer);
callback();
};
void (async () => {
try {
unlistenRef.current = await safeListen(eventName, async (event) => {
const parsed = parseStreamEvent(event.payload);
if (!parsed) {
return;
}
if (parsed.type === "turn_failed" || parsed.type === "error") {
settle(() =>
reject(
new Error(
parsed.type === "turn_failed"
? parsed.turn.error_message?.trim() ||
"Team 生成失败"
: parsed.message.trim() || "Team 生成失败",
),
),
);
return;
}
if (
parsed.type !== "turn_completed" &&
parsed.type !== "final_done"
) {
return;
}
try {
const detail = await getAgentRuntimeSession(sessionId);
const responseText = extractAssistantText(detail);
const generatedTeam = parseGeneratedTeam(responseText, activeTheme);
settle(() => resolve(generatedTeam));
} catch (error) {
settle(() =>
reject(
error instanceof Error
? error
: new Error("解析 Team 结果失败"),
),
);
}
});
await submitAgentRuntimeTurn({
message: buildTeamGenerationPrompt(
trimmedInput,
activeTheme,
blueprintTeam,
),
session_id: sessionId,
event_name: eventName,
workspace_id: workspaceId,
turn_config: {
provider_config: {
provider_id: providerType,
provider_name: mapProviderName(providerType),
model_name: model,
},
execution_strategy: resolvedExecutionStrategy,
web_search: false,
search_mode: "disabled",
system_prompt:
"你是 GUI Team 规划器。当前唯一任务是输出结构化 JSON Team 定义,不要调用工具,不要输出解释。",
metadata: {
source: "team_auto_generation",
auto_team: true,
theme: activeTheme || "general",
},
},
});
} catch (error) {
settle(() =>
reject(
error instanceof Error
? error
: new Error("提交 Team 生成任务失败"),
),
);
}
})();
});
return await completion;
} finally {
try {
unlistenRef.current?.();
} catch {
// ignore cleanup failure
}
try {
await deleteAgentRuntimeSession(sessionId);
} catch {
// ignore cleanup failure
}
}
}
@@ -12,7 +12,8 @@ import {
Wrench,
type LucideIcon,
} from "lucide-react";
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
export type ToolCallStatus = ToolCallState["status"];
export type ToolCallFamily =
@@ -866,6 +867,70 @@ const EXACT_TOOL_CONFIGS = new Map<string, ToolDisplayConfig>([
actionKey: "task",
},
],
[
"limesitelist",
{
family: "list",
label: "站点能力目录",
verb: "浏览",
icon: Globe,
groupTitle: "站点",
actionKey: "list",
actions: {
failed: "浏览失败",
completed: "已浏览",
running: "浏览中",
},
},
],
[
"limesitesearch",
{
family: "search",
label: "站点能力搜索",
verb: "搜索",
icon: Search,
groupTitle: "站点",
actionKey: "search",
actions: {
failed: "搜索失败",
completed: "已搜索",
running: "搜索中",
},
},
],
[
"limesiteinfo",
{
family: "read",
label: "站点能力详情",
verb: "查看",
icon: Globe,
groupTitle: "站点",
actionKey: "read",
actions: {
failed: "查看失败",
completed: "已查看",
running: "查看中",
},
},
],
[
"limesiterun",
{
family: "generic",
label: "站点能力执行",
verb: "执行",
icon: Globe,
groupTitle: "站点",
actionKey: "generic",
actions: {
failed: "执行失败",
completed: "已执行",
running: "执行中",
},
},
],
]);
const BROWSER_TOOL_MATCHERS: Array<{
@@ -1173,7 +1238,7 @@ const resolveToolArgumentPreview = (
};
const getFileName = (filePath: string): string => {
const parts = filePath.split("/");
const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath;
};
@@ -1206,8 +1271,7 @@ export const parseToolCallArguments = (
export const resolveToolFilePath = (
args: Record<string, ToolCallArgumentValue>,
): string | null => {
const path = args.path || args.file_path || args.filePath;
return path ? String(path) : null;
return extractArtifactProtocolPathsFromValue(args)[0] ?? null;
};
export const isBrowserToolName = (name: string): boolean =>
@@ -1355,6 +1419,25 @@ export const resolveToolPrimarySubject = (
]);
}
if (
normalizedName === "limesiterun" ||
normalizedName === "limesiteinfo"
) {
return (
resolveToolArgumentPreview(args, [
"adapter_name",
"name",
"query",
"repo",
"url",
]) || "站点适配器"
);
}
if (normalizedName === "limesitesearch") {
return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力";
}
if (normalizedName === "ask" || normalizedName === "requestuserinput") {
return resolveToolArgumentPreview(args, [
"question",
@@ -1561,10 +1644,16 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => {
const running = toolCalls.some((item) => item.status === "running");
if (info.family === "search") {
if (info.groupTitle === "站点") {
return running ? "站点搜索中" : failed ? "站点搜索失败" : "已搜索站点能力";
}
return running ? "搜索中" : failed ? "搜索失败" : "已搜索";
}
if (["read", "list"].includes(info.family)) {
if (info.groupTitle === "站点") {
return running ? "站点浏览中" : failed ? "站点浏览失败" : "已浏览站点能力";
}
return running ? "探索中" : failed ? "探索失败" : "已探索";
}
@@ -1645,7 +1734,15 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => {
? `图像分析失败 ${toolCalls.length} 项`
: running
? `图像分析中 ${toolCalls.length} 项`
: `已分析 ${toolCalls.length} 项图像`;
: `已分析 ${toolCalls.length} 项图像`;
}
if (info.groupTitle === "站点") {
return failed
? `站点操作失败 ${toolCalls.length} 项`
: running
? `站点操作中 ${toolCalls.length} 项`
: `已完成 ${toolCalls.length} 项站点操作`;
}
return failed
@@ -0,0 +1,669 @@
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 { ArtifactWorkbenchShell } from "./ArtifactWorkbenchShell";
import {
areLightweightRenderersRegistered,
registerLightweightRenderers,
} from "@/components/artifact/renderers";
import type { Artifact } from "@/lib/artifact/types";
import type { AgentThreadItem } from "../types";
vi.mock("@/components/content-creator/canvas/document/editor", async () => {
const ReactModule = await import("react");
const MockNotionEditor = ReactModule.forwardRef<
{ flushContent: () => string },
{
content: string;
onCommit: (content: string) => void;
onSave: (latestContent?: string) => void;
onCancel: () => void;
}
>(({ content, onCommit, onSave, onCancel }, ref) => {
const [value, setValue] = ReactModule.useState(content);
ReactModule.useEffect(() => {
setValue(content);
}, [content]);
ReactModule.useImperativeHandle(
ref,
() => ({
flushContent: () => value,
}),
[value],
);
return (
<div data-testid="mock-notion-editor">
<textarea
data-testid="mock-notion-editor-input"
value={value}
onChange={(event) => {
setValue(event.target.value);
onCommit(event.target.value);
}}
/>
<button type="button" onClick={() => onSave(value)}>
保存编辑器
</button>
<button type="button" onClick={onCancel}>
取消编辑器
</button>
</div>
);
});
MockNotionEditor.displayName = "MockNotionEditor";
return {
NotionEditor: MockNotionEditor,
};
});
interface MountedShell {
container: HTMLDivElement;
root: Root;
}
const mountedShells: MountedShell[] = [];
function createArtifactDocumentArtifact(): Artifact {
const content = JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis",
title: "董事会季度复盘",
status: "ready",
language: "zh-CN",
summary: "需要优先补齐来源与版本线索。",
blocks: [
{
id: "hero-1",
type: "hero_summary",
summary: "核心摘要",
sourceIds: ["source-1"],
},
{
id: "body-1",
type: "rich_text",
markdown: "正文内容",
sourceIds: ["source-1"],
},
],
sources: [
{
id: "source-1",
title: "OpenAI Blog",
url: "https://openai.com",
},
],
metadata: {
currentVersionId: "artifact-document:demo:v2",
currentVersionNo: 2,
currentVersionDiff: {
baseVersionId: "artifact-document:demo:v1",
baseVersionNo: 1,
targetVersionId: "artifact-document:demo:v2",
targetVersionNo: 2,
updatedCount: 1,
changedBlocks: [
{
blockId: "body-1",
changeType: "updated",
beforeText: "旧正文",
afterText: "正文内容",
summary: "更新 block 内容",
},
],
},
versionHistory: [
{
id: "artifact-document:demo:v1",
artifactId: "artifact-document:demo",
versionNo: 1,
title: "董事会季度复盘",
summary: "第一版摘要",
status: "ready",
},
{
id: "artifact-document:demo:v2",
artifactId: "artifact-document:demo",
versionNo: 2,
title: "董事会季度复盘",
summary: "补齐来源与版本信息",
status: "ready",
},
],
},
});
return {
id: "artifact-1",
type: "document",
title: "board-review.artifact.json",
content,
status: "complete",
meta: {
filePath: ".lime/artifacts/thread-1/board-review.artifact.json",
filename: "board-review.artifact.json",
language: "json",
},
position: { start: 0, end: content.length },
createdAt: 1,
updatedAt: 1,
};
}
function createStructuredEditableArtifact(): Artifact {
const content = JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:editable",
kind: "report",
title: "结构化编辑演示",
status: "ready",
language: "zh-CN",
summary: "用于验证章节头、摘要卡与提示块编辑。",
blocks: [
{
id: "section-1",
type: "section_header",
title: "执行摘要",
description: "先看结论,再看展开分析。",
},
{
id: "hero-structured",
type: "hero_summary",
eyebrow: "董事会视角",
title: "季度经营摘要",
summary: "收入增长稳定,但需要关注交付效率。",
highlights: ["收入增长 18%", "交付时延仍偏高"],
},
{
id: "body-structured",
type: "rich_text",
markdown: "这里是详细分析。",
},
{
id: "callout-1",
type: "callout",
title: "风险提示",
content: "第二季度需重点压缩项目交付周期。",
tone: "warning",
},
],
sources: [],
metadata: {},
});
return {
id: "artifact-structured",
type: "document",
title: "structured-edit.artifact.json",
content,
status: "complete",
meta: {
filePath: ".lime/artifacts/thread-1/structured-edit.artifact.json",
filename: "structured-edit.artifact.json",
language: "json",
},
position: { start: 0, end: content.length },
createdAt: 1,
updatedAt: 1,
};
}
function createArtifactTimelineItems(): AgentThreadItem[] {
return [
{
id: "thread-item-body",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 4,
status: "completed",
started_at: "2026-03-25T10:00:00Z",
completed_at: "2026-03-25T10:00:01Z",
updated_at: "2026-03-25T10:00:01Z",
type: "file_artifact",
path: ".lime/artifacts/thread-1/board-review.artifact.json",
source: "artifact_snapshot",
content: createArtifactDocumentArtifact().content,
metadata: {
artifact_id: "artifact-document:demo",
artifact_block_id: "body-1",
},
},
];
}
function renderShell(
artifact: Artifact,
overrides: Partial<React.ComponentProps<typeof ArtifactWorkbenchShell>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ArtifactWorkbenchShell
artifact={artifact}
artifactOverlay={null}
isStreaming={false}
showPreviousVersionBadge={false}
viewMode="preview"
onViewModeChange={() => {}}
previewSize="desktop"
onPreviewSizeChange={() => {}}
onCloseCanvas={() => {}}
{...overrides}
/>,
);
});
mountedShells.push({ container, root });
return container;
}
function setTextControlValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement
? HTMLInputElement.prototype
: HTMLTextAreaElement.prototype,
"value",
);
descriptor?.set?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
HTMLElement.prototype.scrollIntoView = vi.fn();
if (!areLightweightRenderersRegistered()) {
registerLightweightRenderers();
}
});
afterEach(() => {
vi.restoreAllMocks();
while (mountedShells.length > 0) {
const mounted = mountedShells.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ArtifactWorkbenchShell", () => {
it("命中文档协议时应展示概览、来源、版本 inspector", async () => {
const container = renderShell(createArtifactDocumentArtifact());
await act(async () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="artifact-workbench-shell"]'),
).not.toBeNull();
expect(container.textContent).toContain("概览");
expect(container.textContent).toContain("来源");
expect(container.textContent).toContain("版本");
expect(container.textContent).toContain("差异");
expect(container.textContent).toContain("更新 block 内容");
const sourcesTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("来源"),
);
expect(sourcesTrigger).not.toBeUndefined();
await act(async () => {
sourcesTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(container.textContent).toContain("OpenAI Blog");
expect(container.textContent).toContain("block hero-1");
});
it("应支持从来源项与差异项跳转到对应 block", async () => {
const container = renderShell(createArtifactDocumentArtifact());
await act(async () => {
await Promise.resolve();
});
const sourcesTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("来源"),
);
expect(sourcesTrigger).not.toBeUndefined();
await act(async () => {
sourcesTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const sourceButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("OpenAI Blog"),
);
expect(sourceButton).not.toBeUndefined();
await act(async () => {
sourceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const heroBlock = container.querySelector("#artifact-block-hero-1");
expect(heroBlock?.classList.contains("ring-2")).toBe(true);
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
const diffTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("差异"),
);
expect(diffTrigger).not.toBeUndefined();
await act(async () => {
diffTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const diffJumpButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("跳到 block"),
);
expect(diffJumpButton).not.toBeUndefined();
await act(async () => {
diffJumpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const bodyBlock = container.querySelector("#artifact-block-body-1");
expect(bodyBlock?.classList.contains("ring-2")).toBe(true);
});
it("提供保存回调时应展示编辑页签,并把更新后的文档回传主链", async () => {
const handleSaveArtifactDocument = vi.fn().mockResolvedValue(undefined);
const container = renderShell(createArtifactDocumentArtifact(), {
onSaveArtifactDocument: handleSaveArtifactDocument,
});
await act(async () => {
await Promise.resolve();
});
const editTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("编辑"),
);
expect(editTrigger).not.toBeUndefined();
await act(async () => {
editTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const bodyBlockTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("正文块 1"),
);
expect(bodyBlockTrigger).not.toBeUndefined();
await act(async () => {
bodyBlockTrigger?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
});
const editorInput = container.querySelector(
'[data-testid="mock-notion-editor-input"]',
) as HTMLTextAreaElement | null;
expect(editorInput).not.toBeNull();
await act(async () => {
if (editorInput) {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(editorInput, "更新后的正文");
editorInput.dispatchEvent(new Event("input", { bubbles: true }));
}
await Promise.resolve();
});
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("保存编辑器"),
);
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(handleSaveArtifactDocument).toHaveBeenCalledTimes(1);
expect(handleSaveArtifactDocument).toHaveBeenCalledWith(
expect.objectContaining({ id: "artifact-1" }),
expect.objectContaining({
schemaVersion: "artifact_document.v1",
blocks: expect.arrayContaining([
expect.objectContaining({
id: "body-1",
type: "rich_text",
markdown: "更新后的正文",
}),
]),
}),
);
});
it("应支持在 workbench 中编辑结构化摘要块并回写 highlights", async () => {
const handleSaveArtifactDocument = vi.fn().mockResolvedValue(undefined);
const container = renderShell(createStructuredEditableArtifact(), {
onSaveArtifactDocument: handleSaveArtifactDocument,
});
await act(async () => {
await Promise.resolve();
});
const editTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("编辑"),
);
expect(editTrigger).not.toBeUndefined();
await act(async () => {
editTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const heroTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("季度经营摘要"),
);
expect(heroTrigger).not.toBeUndefined();
await act(async () => {
heroTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const summaryInput = container.querySelector(
'[data-testid="artifact-structured-edit-summary"]',
) as HTMLTextAreaElement | null;
const highlightsInput = container.querySelector(
'[data-testid="artifact-structured-edit-highlights"]',
) as HTMLTextAreaElement | null;
expect(summaryInput).not.toBeNull();
expect(highlightsInput).not.toBeNull();
await act(async () => {
if (summaryInput) {
setTextControlValue(summaryInput, "更新后的摘要正文");
}
if (highlightsInput) {
setTextControlValue(highlightsInput, "保留现金流优势\n压缩交付周期");
}
await Promise.resolve();
});
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "保存",
);
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(handleSaveArtifactDocument).toHaveBeenCalledTimes(1);
expect(handleSaveArtifactDocument).toHaveBeenCalledWith(
expect.objectContaining({ id: "artifact-structured" }),
expect.objectContaining({
blocks: expect.arrayContaining([
expect.objectContaining({
id: "hero-structured",
type: "hero_summary",
summary: "更新后的摘要正文",
highlights: ["保留现金流优势", "压缩交付周期"],
}),
]),
}),
);
});
it("编辑态命中关联 timeline 时应支持跳回执行过程", async () => {
const onJumpToTimelineItem = vi.fn();
const container = renderShell(createArtifactDocumentArtifact(), {
onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined),
threadItems: createArtifactTimelineItems(),
onJumpToTimelineItem,
});
await act(async () => {
await Promise.resolve();
});
const editTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("编辑"),
);
expect(editTrigger).not.toBeUndefined();
await act(async () => {
editTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const bodyBlockTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("正文块 1"),
);
expect(bodyBlockTrigger).not.toBeUndefined();
await act(async () => {
bodyBlockTrigger?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
});
const jumpButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("跳到过程"),
);
expect(jumpButton).not.toBeUndefined();
await act(async () => {
jumpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(onJumpToTimelineItem).toHaveBeenCalledWith("thread-item-body");
});
it("应支持在 workbench 中编辑提示块并回写 tone 与正文", async () => {
const handleSaveArtifactDocument = vi.fn().mockResolvedValue(undefined);
const container = renderShell(createStructuredEditableArtifact(), {
onSaveArtifactDocument: handleSaveArtifactDocument,
});
await act(async () => {
await Promise.resolve();
});
const editTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("编辑"),
);
expect(editTrigger).not.toBeUndefined();
await act(async () => {
editTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const calloutTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("风险提示"),
);
expect(calloutTrigger).not.toBeUndefined();
await act(async () => {
calloutTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const toneInput = container.querySelector(
'[data-testid="artifact-structured-edit-tone"]',
) as HTMLInputElement | null;
const bodyInput = container.querySelector(
'[data-testid="artifact-structured-edit-body"]',
) as HTMLTextAreaElement | null;
expect(toneInput).not.toBeNull();
expect(bodyInput).not.toBeNull();
await act(async () => {
if (toneInput) {
setTextControlValue(toneInput, "critical");
}
if (bodyInput) {
setTextControlValue(bodyInput, "交付周期偏长,需要立即治理。");
}
await Promise.resolve();
});
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "保存",
);
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(handleSaveArtifactDocument).toHaveBeenCalledTimes(1);
expect(handleSaveArtifactDocument).toHaveBeenCalledWith(
expect.objectContaining({ id: "artifact-structured" }),
expect.objectContaining({
blocks: expect.arrayContaining([
expect.objectContaining({
id: "callout-1",
type: "callout",
tone: "critical",
variant: "critical",
content: "交付周期偏长,需要立即治理。",
text: "交付周期偏长,需要立即治理。",
}),
]),
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -71,6 +71,10 @@ interface GeneralWorkbenchDialogSectionProps extends HarnessPanelBaseProps {
activeTheme: ComponentProps<typeof AgentRuntimeStrip>["activeTheme"];
toolPreferences: ComponentProps<typeof AgentRuntimeStrip>["toolPreferences"];
isSending: ComponentProps<typeof AgentRuntimeStrip>["isSending"];
executionRuntime: ComponentProps<typeof AgentRuntimeStrip>["executionRuntime"];
isExecutionRuntimeActive: ComponentProps<
typeof AgentRuntimeStrip
>["isExecutionRuntimeActive"];
runtimeStatusTitle: ComponentProps<typeof AgentRuntimeStrip>["runtimeStatusTitle"];
selectedTeamRoleCount: ComponentProps<
typeof AgentRuntimeStrip
@@ -84,6 +88,8 @@ export function GeneralWorkbenchDialogSection({
activeTheme,
toolPreferences,
isSending,
executionRuntime,
isExecutionRuntimeActive,
runtimeStatusTitle,
selectedTeamRoleCount,
...panelBaseProps
@@ -112,6 +118,8 @@ export function GeneralWorkbenchDialogSection({
compatSubagentRuntime={panelBaseProps.compatSubagentRuntime}
variant="embedded"
isSending={isSending}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
runtimeStatusTitle={runtimeStatusTitle}
selectedTeamLabel={panelBaseProps.selectedTeamLabel}
selectedTeamSummary={panelBaseProps.selectedTeamSummary}
@@ -12,6 +12,34 @@ export interface RuntimeTeamDispatchPreviewSnapshot {
failureMessage?: string | null;
}
export function resolveRuntimeTeamDispatchPreviewState(
snapshot: RuntimeTeamDispatchPreviewSnapshot | null | undefined,
): TeamWorkspaceRuntimeFormationState | null {
const formationState = snapshot?.formationState ?? null;
if (!snapshot || !formationState) {
return null;
}
const normalizedFailureMessage = snapshot.failureMessage?.trim() || null;
if (
snapshot.status === formationState.status &&
!(snapshot.status === "failed" && normalizedFailureMessage)
) {
return formationState;
}
return {
...formationState,
status: snapshot.status,
errorMessage:
snapshot.status === "failed"
? normalizedFailureMessage ||
formationState.errorMessage?.trim() ||
null
: null,
};
}
function buildRuntimeTeamMemberPlanLines(
state: TeamWorkspaceRuntimeFormationState,
): string[] {
@@ -27,7 +27,7 @@ export interface UseTeamWorkbenchPresentationParams {
surfaceProps: TeamWorkbenchSurfaceProps;
hasRealTeamGraph: boolean;
autoFocusToken?: string | number | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
@@ -36,21 +36,22 @@ export interface UseTeamWorkbenchPresentationParams {
function resolveTeamWorkbenchTriggerState(params: {
enabled: boolean;
hasRealTeamGraph: boolean;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
executionSummary: ReturnType<typeof summarizeTeamWorkspaceExecution>;
}): CanvasWorkbenchTeamView["triggerState"] {
const runtimeTeamState = params.teamDispatchPreviewState;
if (!params.enabled) {
return null;
}
if (params.runtimeTeamState?.status === "failed") {
if (runtimeTeamState?.status === "failed") {
return { tone: "error", label: "失败" };
}
if (params.runtimeTeamState?.status === "forming") {
if (runtimeTeamState?.status === "forming") {
return { tone: "active", label: "组建中" };
}
@@ -74,7 +75,7 @@ function resolveTeamWorkbenchTriggerState(params: {
};
}
if (params.runtimeTeamState?.status === "formed" && !params.hasRealTeamGraph) {
if (runtimeTeamState?.status === "formed" && !params.hasRealTeamGraph) {
return { tone: "active", label: "已就绪" };
}
@@ -96,11 +97,12 @@ export function useTeamWorkbenchPresentation({
surfaceProps,
hasRealTeamGraph,
autoFocusToken,
runtimeTeamState = null,
teamDispatchPreviewState = null,
liveActivityBySessionId = {},
teamWaitSummary = null,
teamControlSummary = null,
}: UseTeamWorkbenchPresentationParams) {
const runtimeTeamState = teamDispatchPreviewState;
const boardProps = useMemo<ComponentProps<typeof TeamWorkspaceBoard>>(
() => ({
...surfaceProps,
@@ -135,7 +137,7 @@ export function useTeamWorkbenchPresentation({
selectedTeamLabel: surfaceProps.selectedTeamLabel,
selectedTeamSummary: surfaceProps.selectedTeamSummary,
selectedTeamRoles: surfaceProps.selectedTeamRoles,
runtimeTeamState: surfaceProps.runtimeTeamState,
teamDispatchPreviewState: surfaceProps.teamDispatchPreviewState,
}),
[liveActivityBySessionId, surfaceProps, teamControlSummary, teamWaitSummary],
);
@@ -167,7 +169,7 @@ export function useTeamWorkbenchPresentation({
resolveTeamWorkbenchTriggerState({
enabled,
hasRealTeamGraph,
runtimeTeamState,
teamDispatchPreviewState,
liveActivityBySessionId,
teamWaitSummary,
teamControlSummary,
@@ -178,7 +180,7 @@ export function useTeamWorkbenchPresentation({
executionSummary,
hasRealTeamGraph,
liveActivityBySessionId,
runtimeTeamState,
teamDispatchPreviewState,
teamControlSummary,
teamWaitSummary,
],
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Message } from "../types";
import { buildThemeWorkbenchWorkflowSteps } from "./themeWorkbenchHelpers";
describe("themeWorkbenchHelpers", () => {
it("应通过 artifact protocol 解析嵌套参数中的写文件路径标题", () => {
const messages: Message[] = [
{
id: "user-1",
role: "user",
content: "/social_post_with_cover 请继续生成社媒稿",
timestamp: new Date("2026-03-24T15:00:00.000Z"),
},
{
id: "assistant-1",
role: "assistant",
content: "",
timestamp: new Date("2026-03-24T15:00:01.000Z"),
isThinking: true,
toolCalls: [
{
id: "tool-write-1",
name: "write_file",
arguments: JSON.stringify({
payload: {
artifact_paths: ["social-posts\\final.md"],
},
}),
status: "completed",
startTime: new Date("2026-03-24T15:00:01.500Z"),
endTime: new Date("2026-03-24T15:00:02.000Z"),
},
],
},
];
const workflowSteps = buildThemeWorkbenchWorkflowSteps(
messages,
null,
true,
{},
);
expect(workflowSteps).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "写入 social-posts/final.md",
status: "completed",
}),
]),
);
});
});
@@ -7,8 +7,9 @@ import type {
ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
} from "@/lib/api/executionRun";
import type { ThemeWorkbenchDocumentState } from "@/lib/api/project";
import type { ToolCallState } from "@/lib/api/agentStream";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
import type { SkillDetailInfo } from "@/lib/api/skill-execution";
import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard";
import { parseSkillSlashCommand } from "../hooks/skillCommand";
@@ -115,11 +116,7 @@ function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string {
return size ? `生成封面图(${size})` : "生成封面图";
}
if (normalized.includes("write_file") || normalized.includes("create_file")) {
const pathValue = resolveThemeWorkbenchTextArg(args, [
"path",
"file_path",
"filePath",
]);
const pathValue = extractArtifactProtocolPathsFromValue(args)[0] ?? "";
return pathValue
? `写入 ${getThemeWorkbenchFileLabel(pathValue)}`
: "写入主稿文件";
@@ -13,12 +13,12 @@ import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import { readFilePreview } from "@/lib/api/fileBrowser";
import type { SessionFile } from "@/lib/api/session-files";
import type { Artifact } from "@/lib/artifact/types";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import type { TaskFile } from "../components/TaskFiles";
import type { HarnessFilePreviewResult } from "../components/HarnessStatusPanel";
import { useArtifactAutoPreviewSync } from "../hooks/useArtifactAutoPreviewSync";
import {
buildArtifactFromWrite,
resolveArtifactFilePath,
resolveDefaultArtifactViewMode,
} from "../utils/messageArtifacts";
import {
@@ -199,7 +199,7 @@ export function useWorkspaceArtifactPreviewActions({
const openArtifactInWorkbench = useCallback(
async (artifact: Artifact) => {
let nextArtifact = artifact;
const artifactPath = resolveArtifactFilePath(artifact);
const artifactPath = resolveArtifactProtocolFilePath(artifact);
const shouldLoadPreview = artifact.content.length === 0 && artifactPath;
if (shouldLoadPreview) {
@@ -285,7 +285,7 @@ export function useWorkspaceArtifactPreviewActions({
if (activeTheme === "general") {
const matchingArtifact = artifacts.find((artifact) => {
const artifactPath = resolveArtifactFilePath(artifact);
const artifactPath = resolveArtifactProtocolFilePath(artifact);
return (
artifactPath === fileName ||
artifact.title === extractFileNameFromPath(fileName) ||
@@ -25,6 +25,7 @@ import type {
CanvasWorkbenchTeamView,
} from "../components/CanvasWorkbenchLayout";
import type { TaskFile } from "../components/TaskFiles";
import type { AgentThreadItem } from "../types";
import {
ArtifactWorkbenchPreview,
WorkspaceLiveCanvasPreview,
@@ -68,6 +69,13 @@ interface WorkspaceCanvasPreviewArtifactParams {
onArtifactPreviewSizeChange: NonNullable<
ComponentProps<typeof ArtifactToolbar>["onPreviewSizeChange"]
>;
onSaveArtifactDocument?: ComponentProps<
typeof ArtifactWorkbenchPreview
>["onSaveArtifactDocument"];
threadItems?: AgentThreadItem[];
focusedBlockId?: string | null;
blockFocusRequestKey?: number;
onJumpToTimelineItem?: (itemId: string) => void;
onCloseCanvas: () => void;
}
@@ -239,18 +247,28 @@ export function useWorkspaceCanvasPreviewPresentation({
onArtifactViewModeChange: artifactPreview.onArtifactViewModeChange,
artifactPreviewSize: artifactPreview.artifactPreviewSize,
onArtifactPreviewSizeChange: artifactPreview.onArtifactPreviewSizeChange,
onSaveArtifactDocument: artifactPreview.onSaveArtifactDocument,
threadItems: artifactPreview.threadItems,
focusedBlockId: artifactPreview.focusedBlockId,
blockFocusRequestKey: artifactPreview.blockFocusRequestKey,
onJumpToTimelineItem: artifactPreview.onJumpToTimelineItem,
onCloseCanvas: artifactPreview.onCloseCanvas,
}),
[
artifactPreview.artifactOverlay,
artifactPreview.artifactPreviewSize,
artifactPreview.artifactViewMode,
artifactPreview.blockFocusRequestKey,
artifactPreview.currentCanvasArtifact,
artifactPreview.displayedCanvasArtifact,
artifactPreview.focusedBlockId,
artifactPreview.onArtifactPreviewSizeChange,
artifactPreview.onSaveArtifactDocument,
artifactPreview.onArtifactViewModeChange,
artifactPreview.onJumpToTimelineItem,
artifactPreview.onCloseCanvas,
artifactPreview.showPreviousVersionBadge,
artifactPreview.threadItems,
],
);
@@ -477,7 +495,7 @@ export function useWorkspaceCanvasPreviewPresentation({
surfaceProps: teamWorkbench.surfaceProps,
hasRealTeamGraph: teamWorkbench.hasRealTeamGraph,
autoFocusToken: teamWorkbench.autoFocusToken,
runtimeTeamState: teamWorkbench.runtimeTeamState,
teamDispatchPreviewState: teamWorkbench.teamDispatchPreviewState,
liveActivityBySessionId: teamWorkbench.liveActivityBySessionId,
teamWaitSummary: teamWorkbench.teamWaitSummary,
teamControlSummary: teamWorkbench.teamControlSummary,

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