mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.96.0
This commit is contained in:
+11
-2
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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";
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user