mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.93.0
This commit is contained in:
@@ -3,19 +3,99 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentChatHomeShell } from "./AgentChatHomeShell";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
|
||||
const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({
|
||||
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
|
||||
agentEntry: "claw",
|
||||
...(overrides || {}),
|
||||
})),
|
||||
mockSaveChatToolPreferences: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockBuildClawAgentParams,
|
||||
mockHomeShellExecutionStrategy,
|
||||
mockHomeShellModel,
|
||||
mockHomeShellProviderType,
|
||||
mockSetExecutionStrategy,
|
||||
mockSetModel,
|
||||
mockSetProviderType,
|
||||
mockLoadConfiguredProviders,
|
||||
mockLoadProviderModels,
|
||||
mockFilterModelsByTheme,
|
||||
mockSaveChatToolPreferences,
|
||||
mockPrepareClawSolution,
|
||||
mockUseClawSolutions,
|
||||
mockRecordClawSolutionUsage,
|
||||
mockClawSolutions,
|
||||
} = vi.hoisted(() => {
|
||||
const mockClawSolutions = [
|
||||
{
|
||||
id: "social-post-starter",
|
||||
title: "社媒主稿生成",
|
||||
summary: "进入社媒专项工作台并生成一版首稿。",
|
||||
outputHint: "社媒首稿 + 平台结构",
|
||||
recommendedCapabilities: ["模型", "社媒主题"],
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始",
|
||||
badge: "社媒方案",
|
||||
recentUsedAt: null,
|
||||
isRecent: false,
|
||||
readinessLabel: "可直接开始",
|
||||
readinessTone: "emerald",
|
||||
},
|
||||
{
|
||||
id: "team-breakdown",
|
||||
title: "多代理拆任务",
|
||||
summary: "默认启用多代理偏好,按 team runtime 方式展开任务。",
|
||||
outputHint: "任务拆解 + 分工执行",
|
||||
recommendedCapabilities: ["模型", "多代理"],
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始,进入后会启用多代理偏好",
|
||||
reasonCode: "team_recommended",
|
||||
badge: "多代理",
|
||||
recentUsedAt: null,
|
||||
isRecent: false,
|
||||
readinessLabel: "可直接开始",
|
||||
readinessTone: "emerald",
|
||||
},
|
||||
];
|
||||
|
||||
const mockRecordClawSolutionUsage = vi.fn();
|
||||
|
||||
return {
|
||||
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
|
||||
agentEntry: "claw",
|
||||
...(overrides || {}),
|
||||
})),
|
||||
mockHomeShellProviderType: { current: "mock-provider" },
|
||||
mockHomeShellModel: { current: "mock-model" },
|
||||
mockHomeShellExecutionStrategy: { current: "react" },
|
||||
mockSetProviderType: vi.fn(),
|
||||
mockSetModel: vi.fn(),
|
||||
mockSetExecutionStrategy: vi.fn(),
|
||||
mockLoadConfiguredProviders: vi.fn(async () => []),
|
||||
mockLoadProviderModels: vi.fn(async () => []),
|
||||
mockFilterModelsByTheme: vi.fn(
|
||||
(_theme: string | undefined, models: unknown[]) => ({
|
||||
models,
|
||||
usedFallback: false,
|
||||
filteredOutCount: 0,
|
||||
policyName: "mock",
|
||||
}),
|
||||
),
|
||||
mockSaveChatToolPreferences: vi.fn(),
|
||||
mockPrepareClawSolution: vi.fn(),
|
||||
mockUseClawSolutions: vi.fn(() => ({
|
||||
solutions: mockClawSolutions,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
recordUsage: mockRecordClawSolutionUsage,
|
||||
})),
|
||||
mockRecordClawSolutionUsage,
|
||||
mockClawSolutions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./components/EmptyState", () => ({
|
||||
EmptyState: ({
|
||||
onSend,
|
||||
onRecommendationClick,
|
||||
supportingSlotOverride,
|
||||
}: {
|
||||
onSend: (
|
||||
value: string,
|
||||
@@ -23,6 +103,7 @@ vi.mock("./components/EmptyState", () => ({
|
||||
images?: Array<{ data: string; mediaType: string }>,
|
||||
) => void;
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
supportingSlotOverride?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
@@ -44,6 +125,7 @@ vi.mock("./components/EmptyState", () => ({
|
||||
>
|
||||
Team 推荐
|
||||
</button>
|
||||
{supportingSlotOverride}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
@@ -72,6 +154,10 @@ vi.mock("./hooks/agentChatStorage", () => ({
|
||||
})),
|
||||
loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback),
|
||||
loadPersistedString: vi.fn(() => ""),
|
||||
resolveWorkspaceAgentPreferences: vi.fn(() => ({
|
||||
providerType: "mock-provider",
|
||||
model: "mock-model",
|
||||
})),
|
||||
savePersisted: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -79,6 +165,17 @@ vi.mock("./hooks/agentChatCoreUtils", () => ({
|
||||
normalizeExecutionStrategy: vi.fn((value: string) => value || "react"),
|
||||
}));
|
||||
|
||||
vi.mock("./hooks/useHomeShellAgentPreferences", () => ({
|
||||
useHomeShellAgentPreferences: vi.fn(() => ({
|
||||
providerType: mockHomeShellProviderType.current,
|
||||
setProviderType: mockSetProviderType,
|
||||
model: mockHomeShellModel.current,
|
||||
setModel: mockSetModel,
|
||||
executionStrategy: mockHomeShellExecutionStrategy.current,
|
||||
setExecutionStrategy: mockSetExecutionStrategy,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/chatToolPreferences", () => ({
|
||||
loadChatToolPreferences: vi.fn(() => ({
|
||||
webSearch: false,
|
||||
@@ -93,6 +190,49 @@ vi.mock("@/lib/workspace/navigation", () => ({
|
||||
buildClawAgentParams: mockBuildClawAgentParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/clawSolutions", () => ({
|
||||
prepareClawSolution: mockPrepareClawSolution,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
loadConfiguredProviders: mockLoadConfiguredProviders,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useProviderModels", () => ({
|
||||
loadProviderModels: mockLoadProviderModels,
|
||||
}));
|
||||
|
||||
vi.mock("./utils/modelThemePolicy", () => ({
|
||||
filterModelsByTheme: mockFilterModelsByTheme,
|
||||
}));
|
||||
|
||||
vi.mock("./claw-solutions/useClawSolutions", () => ({
|
||||
useClawSolutions: mockUseClawSolutions,
|
||||
}));
|
||||
|
||||
vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({
|
||||
ClawHomeSolutionsPanel: ({
|
||||
solutions,
|
||||
onSelect,
|
||||
}: {
|
||||
solutions: Array<{ id: string; title: string }>;
|
||||
onSelect: (solution: { id: string; title: string }) => void;
|
||||
}) => (
|
||||
<>
|
||||
{solutions.map((solution) => (
|
||||
<button
|
||||
key={solution.id}
|
||||
type="button"
|
||||
data-testid={`home-shell-solution-${solution.id}`}
|
||||
onClick={() => onSelect(solution)}
|
||||
>
|
||||
{solution.title}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -101,6 +241,26 @@ beforeEach(() => {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockHomeShellProviderType.current = "mock-provider";
|
||||
mockHomeShellModel.current = "mock-model";
|
||||
mockHomeShellExecutionStrategy.current = "react";
|
||||
mockUseClawSolutions.mockImplementation(() => ({
|
||||
solutions: mockClawSolutions,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
recordUsage: mockRecordClawSolutionUsage,
|
||||
}));
|
||||
mockLoadConfiguredProviders.mockResolvedValue([]);
|
||||
mockLoadProviderModels.mockResolvedValue([]);
|
||||
mockFilterModelsByTheme.mockImplementation(
|
||||
(_theme: string | undefined, models: unknown[]) => ({
|
||||
models,
|
||||
usedFallback: false,
|
||||
filteredOutCount: 0,
|
||||
policyName: "mock",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -241,4 +401,304 @@ describe("AgentChatHomeShell", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("点击社媒方案时应切换到 social-media 工作区", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
mockLoadConfiguredProviders.mockResolvedValueOnce([
|
||||
{
|
||||
key: "custom-social-provider",
|
||||
label: "Custom Social Provider",
|
||||
registryId: "custom-social-provider",
|
||||
fallbackRegistryId: "openai",
|
||||
type: "openai",
|
||||
},
|
||||
]);
|
||||
mockLoadProviderModels.mockResolvedValueOnce([
|
||||
{
|
||||
id: "social-model-1",
|
||||
display_name: "Social Model 1",
|
||||
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: true,
|
||||
description: "social",
|
||||
source: "custom",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
]);
|
||||
mockPrepareClawSolution.mockResolvedValueOnce({
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
prompt: "请先帮我起草一版社媒内容首稿",
|
||||
themeTarget: "social-media",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: false,
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始",
|
||||
});
|
||||
|
||||
const { container } = renderShell({
|
||||
onNavigate,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const socialSolutionButton = container.querySelector(
|
||||
'[data-testid="home-shell-solution-social-post-starter"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(socialSolutionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
socialSolutionButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(mockSetProviderType).toHaveBeenCalledWith("custom-social-provider");
|
||||
expect(mockSetModel).toHaveBeenCalledWith("social-model-1");
|
||||
expect(mockPrepareClawSolution).toHaveBeenCalledWith(
|
||||
"social-post-starter",
|
||||
{
|
||||
projectId: "project-1",
|
||||
userInput: undefined,
|
||||
},
|
||||
);
|
||||
expect(mockBuildClawAgentParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
theme: "social-media",
|
||||
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
|
||||
}),
|
||||
);
|
||||
expect(onNavigate).toHaveBeenCalledWith(
|
||||
"agent",
|
||||
expect.objectContaining({
|
||||
agentEntry: "claw",
|
||||
theme: "social-media",
|
||||
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
|
||||
}),
|
||||
);
|
||||
expect(mockSetProviderType.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onNavigate.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mockSetModel.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onNavigate.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
themeTarget: "social-media",
|
||||
});
|
||||
});
|
||||
|
||||
it("当前 provider 已可用时应保留 custom provider id 并仅切换模型", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
mockHomeShellProviderType.current = "custom-social-provider";
|
||||
mockHomeShellModel.current = "legacy-model";
|
||||
mockLoadConfiguredProviders.mockResolvedValueOnce([
|
||||
{
|
||||
key: "custom-social-provider",
|
||||
label: "Custom Social Provider",
|
||||
registryId: "custom-social-provider",
|
||||
fallbackRegistryId: "openai",
|
||||
type: "openai",
|
||||
},
|
||||
{
|
||||
key: "other-provider",
|
||||
label: "Other Provider",
|
||||
registryId: "other-provider",
|
||||
type: "openai",
|
||||
},
|
||||
]);
|
||||
mockLoadProviderModels.mockResolvedValueOnce([
|
||||
{
|
||||
id: "custom-social-model",
|
||||
display_name: "Custom Social Model",
|
||||
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: true,
|
||||
description: "custom-social-model",
|
||||
source: "custom",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
]);
|
||||
mockPrepareClawSolution.mockResolvedValueOnce({
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
prompt: "请先帮我起草一版社媒内容首稿",
|
||||
themeTarget: "social-media",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: false,
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始",
|
||||
});
|
||||
|
||||
const { container } = renderShell({
|
||||
onNavigate,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const socialSolutionButton = container.querySelector(
|
||||
'[data-testid="home-shell-solution-social-post-starter"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(socialSolutionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
socialSolutionButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(mockSetProviderType).not.toHaveBeenCalled();
|
||||
expect(mockSetModel).toHaveBeenCalledWith("custom-social-model");
|
||||
expect(mockLoadProviderModels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "custom-social-provider",
|
||||
}),
|
||||
);
|
||||
expect(onNavigate).toHaveBeenCalledWith(
|
||||
"agent",
|
||||
expect.objectContaining({
|
||||
theme: "social-media",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("点击多代理方案时应开启多代理偏好并进入工作区", async () => {
|
||||
const onEnterWorkspace = vi.fn();
|
||||
mockPrepareClawSolution.mockResolvedValueOnce({
|
||||
solutionId: "team-breakdown",
|
||||
actionType: "enable_team_mode",
|
||||
prompt: "请把这个任务按多代理方式拆解",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: true,
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始,进入后会启用多代理偏好",
|
||||
});
|
||||
|
||||
const { container } = renderShell({
|
||||
onNavigate: undefined,
|
||||
onEnterWorkspace,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const teamSolutionButton = container.querySelector(
|
||||
'[data-testid="home-shell-solution-team-breakdown"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(teamSolutionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
teamSolutionButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: true,
|
||||
}),
|
||||
"general",
|
||||
);
|
||||
expect(onEnterWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
theme: "general",
|
||||
initialCreationMode: "guided",
|
||||
initialUserPrompt: "请把这个任务按多代理方式拆解",
|
||||
}),
|
||||
);
|
||||
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
|
||||
solutionId: "team-breakdown",
|
||||
actionType: "enable_team_mode",
|
||||
themeTarget: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const onEnterWorkspace = vi.fn();
|
||||
mockPrepareClawSolution.mockResolvedValueOnce({
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
prompt: "请先帮我起草一版社媒内容首稿",
|
||||
themeTarget: "social-media",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: false,
|
||||
readiness: "needs_setup",
|
||||
readinessMessage: "请先配置至少一个可用模型",
|
||||
reasonCode: "missing_model",
|
||||
});
|
||||
|
||||
const { container } = renderShell({
|
||||
onNavigate,
|
||||
onEnterWorkspace,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const socialSolutionButton = container.querySelector(
|
||||
'[data-testid="home-shell-solution-social-post-starter"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(socialSolutionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
socialSolutionButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
});
|
||||
expect(onEnterWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockRecordClawSolutionUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { toast } from "sonner";
|
||||
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
|
||||
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
|
||||
import { logAgentDebug } from "@/lib/agentDebug";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
import { prepareClawSolution } from "@/lib/api/clawSolutions";
|
||||
import type { Page, PageParams } from "@/types/page";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
import type { ThemeType } from "@/components/content-creator/types";
|
||||
import { EmptyState } from "./components/EmptyState";
|
||||
import type { CreationMode } from "./components/types";
|
||||
import { buildClawAgentParams } from "@/lib/workspace/navigation";
|
||||
import {
|
||||
DEFAULT_AGENT_MODEL,
|
||||
DEFAULT_AGENT_PROVIDER,
|
||||
GLOBAL_MODEL_PREF_KEY,
|
||||
GLOBAL_PROVIDER_PREF_KEY,
|
||||
getAgentPreferenceKeys,
|
||||
loadPersisted,
|
||||
loadPersistedString,
|
||||
savePersisted,
|
||||
} from "./hooks/agentChatStorage";
|
||||
import { normalizeExecutionStrategy } from "./hooks/agentChatCoreUtils";
|
||||
import type { MessageImage } from "./types";
|
||||
import {
|
||||
loadChatToolPreferences,
|
||||
saveChatToolPreferences,
|
||||
type ChatToolPreferences,
|
||||
} from "./utils/chatToolPreferences";
|
||||
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
|
||||
import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection";
|
||||
import { normalizeProjectId } from "./utils/topicProjectResolution";
|
||||
import {
|
||||
LAST_PROJECT_ID_KEY,
|
||||
usePersistedProjectId,
|
||||
} from "./hooks/agentProjectStorage";
|
||||
import { useHomeShellAgentPreferences } from "./hooks/useHomeShellAgentPreferences";
|
||||
import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory";
|
||||
import { useHomeShellSkills } from "./hooks/useHomeShellSkills";
|
||||
import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences";
|
||||
import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference";
|
||||
import {
|
||||
enableSubagentPreference,
|
||||
resolveClawSolutionLaunch,
|
||||
resolveClawSolutionSetupTarget,
|
||||
} from "./claw-solutions/actionDispatcher";
|
||||
import { useClawSolutions } from "./claw-solutions/useClawSolutions";
|
||||
import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel";
|
||||
import type { ClawSolutionHomeItem } from "./claw-solutions/types";
|
||||
import {
|
||||
type AgentChatWorkspaceBootstrap,
|
||||
resolveHomeShellWorkspaceEntry,
|
||||
type HomeShellEnterWorkspacePayload,
|
||||
} from "./homeShellEntry";
|
||||
|
||||
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
|
||||
"general",
|
||||
@@ -44,10 +48,6 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
|
||||
"novel",
|
||||
];
|
||||
|
||||
const HOME_ENHANCEMENT_IDLE_TIMEOUT_MS = 1_500;
|
||||
const HOME_ENHANCEMENT_FALLBACK_DELAY_MS = 180;
|
||||
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
|
||||
|
||||
const PageContainer = styled.div<{ $compact?: boolean }>`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -152,107 +152,17 @@ function normalizeInitialTheme(value?: string): ThemeType {
|
||||
return "general";
|
||||
}
|
||||
|
||||
function scheduleDeferredHomeEnhancement(task: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => undefined;
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const idleId = window.requestIdleCallback(() => task(), {
|
||||
timeout: HOME_ENHANCEMENT_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return () => {
|
||||
if (typeof window.cancelIdleCallback === "function") {
|
||||
window.cancelIdleCallback(idleId);
|
||||
}
|
||||
};
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
return "请稍后重试";
|
||||
}
|
||||
|
||||
function loadPersistedProjectId(key: string): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
|
||||
} catch {
|
||||
return normalizeProjectId(stored);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedProjectId(key: string, projectId: string) {
|
||||
const normalized = normalizeProjectId(projectId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(normalized));
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutionStrategyStorageKey(
|
||||
projectId?: string | null,
|
||||
): string | null {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
if (!normalizedProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `aster_execution_strategy_${normalizedProjectId}`;
|
||||
}
|
||||
|
||||
function resolvePersistedProviderModel(projectId?: string | null): {
|
||||
providerType: string;
|
||||
model: string;
|
||||
} {
|
||||
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
|
||||
return {
|
||||
providerType:
|
||||
loadPersistedString(providerKey) ||
|
||||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) ||
|
||||
DEFAULT_AGENT_PROVIDER,
|
||||
model:
|
||||
loadPersistedString(modelKey) ||
|
||||
loadPersistedString(GLOBAL_MODEL_PREF_KEY) ||
|
||||
DEFAULT_AGENT_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePersistedExecutionStrategy(
|
||||
projectId?: string | null,
|
||||
): AsterExecutionStrategy {
|
||||
const storageKey = resolveExecutionStrategyStorageKey(projectId);
|
||||
if (!storageKey) {
|
||||
return "react";
|
||||
}
|
||||
|
||||
return normalizeExecutionStrategy(loadPersisted<string | null>(storageKey, "react"));
|
||||
}
|
||||
|
||||
export interface AgentChatWorkspaceBootstrap {
|
||||
projectId?: string;
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: MessageImage[];
|
||||
theme?: string;
|
||||
initialCreationMode?: CreationMode;
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
newChatAt?: number;
|
||||
}
|
||||
export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry";
|
||||
|
||||
interface AgentChatHomeShellProps {
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
@@ -277,35 +187,35 @@ export function AgentChatHomeShell({
|
||||
const [creationMode, setCreationMode] = useState<CreationMode>(
|
||||
initialCreationMode ?? "guided",
|
||||
);
|
||||
const [chatToolPreferences, setChatToolPreferences] =
|
||||
useState<ChatToolPreferences>(() =>
|
||||
loadChatToolPreferences(normalizedEntryTheme),
|
||||
);
|
||||
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
|
||||
useState<string>(normalizedEntryTheme);
|
||||
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
|
||||
() =>
|
||||
normalizeProjectId(externalProjectId) ??
|
||||
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
|
||||
);
|
||||
const initialProviderModel = resolvePersistedProviderModel(currentProjectId);
|
||||
const [providerType, setProviderTypeState] = useState(
|
||||
initialProviderModel.providerType,
|
||||
);
|
||||
const [model, setModelState] = useState(initialProviderModel.model);
|
||||
const [executionStrategy, setExecutionStrategyState] =
|
||||
useState<AsterExecutionStrategy>(() =>
|
||||
resolvePersistedExecutionStrategy(currentProjectId),
|
||||
);
|
||||
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(null);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||||
const { chatToolPreferences, setChatToolPreferences } =
|
||||
useThemeScopedChatToolPreferences(activeTheme);
|
||||
const {
|
||||
projectId: currentProjectId,
|
||||
setProjectId: setCurrentProjectId,
|
||||
rememberProjectId,
|
||||
} = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY);
|
||||
const {
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
} = useHomeShellAgentPreferences(currentProjectId);
|
||||
const projectMemory = useHomeShellProjectMemory(currentProjectId);
|
||||
const { skills, skillsLoading, refreshSkills } = useHomeShellSkills();
|
||||
const [browserAssistLoading, setBrowserAssistLoading] = useState(false);
|
||||
const {
|
||||
selectedTeam,
|
||||
setSelectedTeam: handleSelectTeam,
|
||||
enableSuggestedTeam: handleEnableSuggestedTeam,
|
||||
} = useSelectedTeamPreference(activeTheme);
|
||||
const {
|
||||
solutions: clawSolutions,
|
||||
isLoading: clawSolutionsLoading,
|
||||
error: clawSolutionsError,
|
||||
recordUsage: recordClawSolutionUsage,
|
||||
} = useClawSolutions(activeTheme === "general");
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTheme(normalizeInitialTheme(initialTheme));
|
||||
@@ -319,161 +229,16 @@ export function AgentChatHomeShell({
|
||||
}, [initialCreationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentProjectId(
|
||||
normalizeProjectId(externalProjectId) ??
|
||||
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
|
||||
);
|
||||
}, [externalProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme === activeTheme) {
|
||||
if (activeTheme !== "general" || !clawSolutionsError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatToolPreferences(loadChatToolPreferences(activeTheme));
|
||||
setChatToolPreferencesTheme(activeTheme);
|
||||
}, [activeTheme, chatToolPreferencesTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme !== activeTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveChatToolPreferences(chatToolPreferences, activeTheme);
|
||||
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPreferences = resolvePersistedProviderModel(currentProjectId);
|
||||
setProviderTypeState(nextPreferences.providerType);
|
||||
setModelState(nextPreferences.model);
|
||||
setExecutionStrategyState(resolvePersistedExecutionStrategy(currentProjectId));
|
||||
}, [currentProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedProjectId = normalizeProjectId(currentProjectId);
|
||||
if (!normalizedProjectId) {
|
||||
setProjectMemory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const startedAt = Date.now();
|
||||
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.start", {
|
||||
projectId: normalizedProjectId,
|
||||
});
|
||||
|
||||
void getProjectMemory(normalizedProjectId)
|
||||
.then((memory) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectMemory(memory);
|
||||
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
projectId: normalizedProjectId,
|
||||
charactersCount: memory.characters.length,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectMemory(null);
|
||||
logAgentDebug(
|
||||
"AgentChatHomeShell",
|
||||
"loadProjectMemory.error",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
projectId: normalizedProjectId,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentProjectId]);
|
||||
|
||||
const loadSkills = useCallback(
|
||||
async (includeRemote = false): Promise<Skill[]> => {
|
||||
const startedAt = Date.now();
|
||||
logAgentDebug("AgentChatHomeShell", "loadSkills.start", {
|
||||
includeRemote,
|
||||
});
|
||||
setSkillsLoading(true);
|
||||
try {
|
||||
const loadedSkills = includeRemote
|
||||
? await skillsApi.getAll("lime")
|
||||
: await skillsApi.getLocal("lime");
|
||||
setSkills(loadedSkills);
|
||||
logAgentDebug("AgentChatHomeShell", "loadSkills.success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
includeRemote,
|
||||
skillsCount: loadedSkills.length,
|
||||
});
|
||||
return loadedSkills;
|
||||
} catch (error) {
|
||||
setSkills([]);
|
||||
logAgentDebug(
|
||||
"AgentChatHomeShell",
|
||||
"loadSkills.error",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
includeRemote,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleDeferredHomeEnhancement(() => {
|
||||
void loadSkills(false);
|
||||
});
|
||||
}, [loadSkills]);
|
||||
|
||||
const setProviderType = useCallback(
|
||||
(nextProviderType: string) => {
|
||||
setProviderTypeState(nextProviderType);
|
||||
const { providerKey } = getAgentPreferenceKeys(currentProjectId);
|
||||
savePersisted(providerKey, nextProviderType);
|
||||
},
|
||||
[currentProjectId],
|
||||
);
|
||||
|
||||
const setModel = useCallback(
|
||||
(nextModel: string) => {
|
||||
setModelState(nextModel);
|
||||
const { modelKey } = getAgentPreferenceKeys(currentProjectId);
|
||||
savePersisted(modelKey, nextModel);
|
||||
},
|
||||
[currentProjectId],
|
||||
);
|
||||
|
||||
const setExecutionStrategy = useCallback(
|
||||
(nextExecutionStrategy: AsterExecutionStrategy) => {
|
||||
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
|
||||
setExecutionStrategyState(normalized);
|
||||
const storageKey = resolveExecutionStrategyStorageKey(currentProjectId);
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
savePersisted(storageKey, normalized);
|
||||
},
|
||||
[currentProjectId],
|
||||
);
|
||||
toast.error(`加载 Claw 方案失败:${clawSolutionsError}`);
|
||||
}, [activeTheme, clawSolutionsError]);
|
||||
|
||||
const handleRefreshSkills = useCallback(async () => {
|
||||
await loadSkills(true);
|
||||
}, [loadSkills]);
|
||||
await refreshSkills(true);
|
||||
}, [refreshSkills]);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(nextProjectId: string) => {
|
||||
@@ -484,76 +249,143 @@ export function AgentChatHomeShell({
|
||||
const normalizedProjectId = normalizeProjectId(nextProjectId);
|
||||
setCurrentProjectId(normalizedProjectId);
|
||||
if (normalizedProjectId) {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
|
||||
rememberProjectId(normalizedProjectId);
|
||||
}
|
||||
},
|
||||
[externalProjectId],
|
||||
[externalProjectId, rememberProjectId, setCurrentProjectId],
|
||||
);
|
||||
|
||||
const handleEnterWorkspace = useCallback(
|
||||
(payload: {
|
||||
prompt?: string;
|
||||
images?: MessageImage[];
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
toolPreferences?: ChatToolPreferences;
|
||||
}) => {
|
||||
(payload: HomeShellEnterWorkspacePayload) => {
|
||||
const normalizedProjectId = normalizeProjectId(currentProjectId);
|
||||
const hasPrompt = Boolean(payload.prompt?.trim());
|
||||
const hasImages = Boolean(payload.images?.length);
|
||||
const effectiveToolPreferences =
|
||||
payload.toolPreferences ?? chatToolPreferences;
|
||||
const resolved = resolveHomeShellWorkspaceEntry({
|
||||
projectId: normalizedProjectId,
|
||||
activeTheme,
|
||||
creationMode,
|
||||
defaultToolPreferences: chatToolPreferences,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!payload.openBrowserAssistOnMount && !normalizedProjectId) {
|
||||
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) {
|
||||
return;
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "missing_project") {
|
||||
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedProjectId) {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
|
||||
rememberProjectId(normalizedProjectId);
|
||||
}
|
||||
saveChatToolPreferences(effectiveToolPreferences, activeTheme);
|
||||
const nextNewChatAt = Date.now();
|
||||
saveChatToolPreferences(resolved.toolPreferences, resolved.targetTheme);
|
||||
|
||||
if (onNavigate) {
|
||||
onNavigate(
|
||||
"agent",
|
||||
buildClawAgentParams({
|
||||
projectId: normalizedProjectId ?? undefined,
|
||||
theme: activeTheme,
|
||||
initialCreationMode: creationMode,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
onNavigate("agent", resolved.navigationParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
onEnterWorkspace({
|
||||
projectId: normalizedProjectId ?? undefined,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
theme: activeTheme,
|
||||
initialCreationMode: creationMode,
|
||||
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
});
|
||||
onEnterWorkspace(resolved.workspaceBootstrap);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
activeTheme,
|
||||
chatToolPreferences,
|
||||
creationMode,
|
||||
currentProjectId,
|
||||
rememberProjectId,
|
||||
onEnterWorkspace,
|
||||
onNavigate,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClawSolutionSelect = useCallback(
|
||||
async (solution: ClawSolutionHomeItem) => {
|
||||
try {
|
||||
const preparation = await prepareClawSolution(solution.id, {
|
||||
projectId: normalizeProjectId(currentProjectId) ?? undefined,
|
||||
userInput: input.trim() || undefined,
|
||||
});
|
||||
|
||||
if (preparation.readiness !== "ready") {
|
||||
const setupTab = resolveClawSolutionSetupTarget(
|
||||
preparation.readiness,
|
||||
preparation.reasonCode,
|
||||
);
|
||||
if (setupTab && onNavigate) {
|
||||
onNavigate("settings", { tab: setupTab });
|
||||
return;
|
||||
}
|
||||
toast.error(preparation.readinessMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const launch = resolveClawSolutionLaunch(
|
||||
preparation,
|
||||
chatToolPreferences,
|
||||
);
|
||||
const targetTheme =
|
||||
launch.enterWorkspacePayload.themeOverride ?? activeTheme;
|
||||
|
||||
try {
|
||||
const providerSelection = await resolveClawWorkspaceProviderSelection({
|
||||
currentProviderType: providerType,
|
||||
currentModel: model,
|
||||
theme: targetTheme,
|
||||
});
|
||||
|
||||
if (providerSelection) {
|
||||
if (providerSelection.providerType !== providerType) {
|
||||
setProviderType(providerSelection.providerType);
|
||||
}
|
||||
if (providerSelection.model !== model) {
|
||||
setModel(providerSelection.model);
|
||||
}
|
||||
}
|
||||
} catch (selectionError) {
|
||||
console.warn(
|
||||
"[AgentChatHomeShell] 解析 Claw 工作区默认 provider/model 失败,继续沿用当前选择:",
|
||||
selectionError,
|
||||
);
|
||||
}
|
||||
|
||||
if (launch.preferencesChanged) {
|
||||
setChatToolPreferences(launch.nextToolPreferences);
|
||||
}
|
||||
|
||||
if (launch.shouldStartBrowserAssistLoading) {
|
||||
setBrowserAssistLoading(true);
|
||||
}
|
||||
|
||||
const entered = handleEnterWorkspace(launch.enterWorkspacePayload);
|
||||
|
||||
if (!entered) {
|
||||
if (launch.shouldStartBrowserAssistLoading) {
|
||||
setBrowserAssistLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
recordClawSolutionUsage(launch.usageRecord);
|
||||
} catch (error) {
|
||||
setBrowserAssistLoading(false);
|
||||
toast.error(`启动方案失败:${getErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
[
|
||||
chatToolPreferences,
|
||||
currentProjectId,
|
||||
handleEnterWorkspace,
|
||||
input,
|
||||
model,
|
||||
onNavigate,
|
||||
providerType,
|
||||
recordClawSolutionUsage,
|
||||
setChatToolPreferences,
|
||||
setModel,
|
||||
setProviderType,
|
||||
activeTheme,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRecommendationClick = useCallback(
|
||||
(shortLabel: string, fullPrompt: string) => {
|
||||
setInput(fullPrompt);
|
||||
@@ -565,14 +397,10 @@ export function AgentChatHomeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextToolPreferences = chatToolPreferences.subagent
|
||||
? chatToolPreferences
|
||||
: {
|
||||
...chatToolPreferences,
|
||||
subagent: true,
|
||||
};
|
||||
const { nextToolPreferences, changed } =
|
||||
enableSubagentPreference(chatToolPreferences);
|
||||
|
||||
if (!chatToolPreferences.subagent) {
|
||||
if (changed) {
|
||||
setChatToolPreferences(nextToolPreferences);
|
||||
}
|
||||
saveChatToolPreferences(nextToolPreferences, activeTheme);
|
||||
@@ -581,7 +409,12 @@ export function AgentChatHomeShell({
|
||||
toolPreferences: nextToolPreferences,
|
||||
});
|
||||
},
|
||||
[activeTheme, chatToolPreferences, handleEnterWorkspace],
|
||||
[
|
||||
activeTheme,
|
||||
chatToolPreferences,
|
||||
handleEnterWorkspace,
|
||||
setChatToolPreferences,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -658,6 +491,15 @@ export function AgentChatHomeShell({
|
||||
hasContentId={false}
|
||||
selectedText=""
|
||||
onRecommendationClick={handleRecommendationClick}
|
||||
supportingSlotOverride={
|
||||
activeTheme === "general" ? (
|
||||
<ClawHomeSolutionsPanel
|
||||
solutions={clawSolutions}
|
||||
loading={clawSolutionsLoading}
|
||||
onSelect={handleClawSolutionSelect}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
characters={projectMemory?.characters || []}
|
||||
skills={skills}
|
||||
isSkillsLoading={skillsLoading}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
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 { ClawHomeSolutionsPanel } from "./ClawHomeSolutionsPanel";
|
||||
import type { ClawSolutionHomeItem } from "./types";
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPanel(
|
||||
props: React.ComponentProps<typeof ClawHomeSolutionsPanel>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<ClawHomeSolutionsPanel {...props} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClawHomeSolutionsPanel", () => {
|
||||
it("应渲染方案信息并透传选择回调", () => {
|
||||
const solutions: ClawSolutionHomeItem[] = [
|
||||
{
|
||||
id: "social-post-starter",
|
||||
title: "社媒主稿生成",
|
||||
summary: "进入社媒专项工作台并生成一版首稿。",
|
||||
outputHint: "社媒首稿 + 平台结构",
|
||||
recommendedCapabilities: ["模型", "社媒主题"],
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始",
|
||||
badge: "社媒方案",
|
||||
recentUsedAt: null,
|
||||
isRecent: false,
|
||||
readinessLabel: "可直接开始",
|
||||
readinessTone: "emerald",
|
||||
},
|
||||
];
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const container = renderPanel({
|
||||
solutions,
|
||||
onSelect,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("推荐方案");
|
||||
expect(container.textContent).toContain("社媒主稿生成");
|
||||
expect(container.textContent).toContain("产出:社媒首稿 + 平台结构");
|
||||
expect(container.textContent).toContain("可直接开始");
|
||||
expect(container.textContent).toContain("立即开始");
|
||||
|
||||
const solutionButton = container.querySelector(
|
||||
'[data-testid="claw-solution-social-post-starter"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(solutionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
solutionButton?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(solutions[0]);
|
||||
});
|
||||
|
||||
it("加载中且无方案时应展示加载状态", () => {
|
||||
const container = renderPanel({
|
||||
solutions: [],
|
||||
loading: true,
|
||||
onSelect: vi.fn(),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("正在加载推荐方案");
|
||||
});
|
||||
|
||||
it("未就绪方案应展示去配置动作文案", () => {
|
||||
const solutions: ClawSolutionHomeItem[] = [
|
||||
{
|
||||
id: "web-research-brief",
|
||||
title: "网页研究简报",
|
||||
summary: "先配置模型后再开始。",
|
||||
outputHint: "研究提纲 + 结论摘要",
|
||||
recommendedCapabilities: ["模型"],
|
||||
readiness: "needs_setup",
|
||||
readinessMessage: "请先配置模型",
|
||||
reasonCode: "missing_model",
|
||||
badge: "Claw 方案",
|
||||
recentUsedAt: null,
|
||||
isRecent: false,
|
||||
readinessLabel: "先配置模型",
|
||||
readinessTone: "amber",
|
||||
},
|
||||
];
|
||||
|
||||
const container = renderPanel({
|
||||
solutions,
|
||||
onSelect: vi.fn(),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("先配置模型");
|
||||
expect(container.textContent).toContain("去配置");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions";
|
||||
import type { ClawSolutionHomeItem } from "./types";
|
||||
|
||||
interface ClawHomeSolutionsPanelProps {
|
||||
solutions: ClawSolutionHomeItem[];
|
||||
loading?: boolean;
|
||||
onSelect: (solution: ClawSolutionHomeItem) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ClawHomeSolutionsPanel({
|
||||
solutions,
|
||||
loading = false,
|
||||
onSelect,
|
||||
}: ClawHomeSolutionsPanelProps) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
solutions.map((solution) => ({
|
||||
key: solution.id,
|
||||
title: solution.title,
|
||||
description: solution.summary,
|
||||
badge: solution.badge,
|
||||
prompt: "",
|
||||
actionLabel: solution.readiness === "ready" ? "立即开始" : "去配置",
|
||||
outputHint: solution.outputHint,
|
||||
statusLabel: solution.readinessLabel,
|
||||
statusTone: solution.readinessTone,
|
||||
statusDescription: solution.readinessMessage,
|
||||
solutionId: solution.id,
|
||||
testId: `claw-solution-${solution.id}`,
|
||||
})),
|
||||
[solutions],
|
||||
);
|
||||
|
||||
return (
|
||||
<EmptyStateQuickActions
|
||||
title="推荐方案"
|
||||
description="先选一个方案,Claw 会自动进入对应工作模式并带好起始动作。"
|
||||
items={items}
|
||||
embedded
|
||||
loading={loading}
|
||||
onAction={(item) => {
|
||||
const solution = solutions.find(
|
||||
(candidate) => candidate.id === item.solutionId,
|
||||
);
|
||||
if (solution) {
|
||||
void onSelect(solution);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClawHomeSolutionsPanel;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
enableSubagentPreference,
|
||||
resolveClawSolutionLaunch,
|
||||
resolveClawSolutionSetupTarget,
|
||||
} from "./actionDispatcher";
|
||||
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
|
||||
import type { ClawSolutionPreparation } from "./types";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
|
||||
const defaultPreferences: ChatToolPreferences = {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: false,
|
||||
};
|
||||
|
||||
describe("claw solution action dispatcher", () => {
|
||||
it("应在未开启时启用多代理偏好", () => {
|
||||
expect(enableSubagentPreference(defaultPreferences)).toEqual({
|
||||
nextToolPreferences: {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: true,
|
||||
},
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("已开启多代理时应保持原偏好", () => {
|
||||
const current = {
|
||||
...defaultPreferences,
|
||||
subagent: true,
|
||||
};
|
||||
|
||||
expect(enableSubagentPreference(current)).toEqual({
|
||||
nextToolPreferences: current,
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("应把社媒方案转换为工作区进入载荷", () => {
|
||||
const preparation: ClawSolutionPreparation = {
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
prompt: "请先帮我起草一版社媒内容首稿",
|
||||
themeTarget: "social-media",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: false,
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始",
|
||||
};
|
||||
|
||||
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
|
||||
nextToolPreferences: defaultPreferences,
|
||||
preferencesChanged: false,
|
||||
shouldStartBrowserAssistLoading: false,
|
||||
enterWorkspacePayload: {
|
||||
prompt: "请先帮我起草一版社媒内容首稿",
|
||||
openBrowserAssistOnMount: false,
|
||||
toolPreferences: defaultPreferences,
|
||||
themeOverride: "social-media",
|
||||
},
|
||||
usageRecord: {
|
||||
solutionId: "social-post-starter",
|
||||
actionType: "navigate_theme",
|
||||
themeTarget: "social-media",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应把多代理方案转换为开启 team 模式的载荷", () => {
|
||||
const preparation: ClawSolutionPreparation = {
|
||||
solutionId: "team-breakdown",
|
||||
actionType: "enable_team_mode",
|
||||
prompt: "请把这个任务按多代理方式拆解",
|
||||
shouldLaunchBrowserAssist: false,
|
||||
shouldEnableTeamMode: true,
|
||||
readiness: "ready",
|
||||
readinessMessage: "可直接开始,进入后会启用多代理偏好",
|
||||
};
|
||||
|
||||
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
|
||||
nextToolPreferences: {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: true,
|
||||
},
|
||||
preferencesChanged: true,
|
||||
shouldStartBrowserAssistLoading: false,
|
||||
enterWorkspacePayload: {
|
||||
prompt: "请把这个任务按多代理方式拆解",
|
||||
openBrowserAssistOnMount: false,
|
||||
toolPreferences: {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: true,
|
||||
},
|
||||
themeOverride: undefined,
|
||||
},
|
||||
usageRecord: {
|
||||
solutionId: "team-breakdown",
|
||||
actionType: "enable_team_mode",
|
||||
themeTarget: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应把未就绪方案映射到对应配置入口", () => {
|
||||
expect(
|
||||
resolveClawSolutionSetupTarget("needs_setup", "missing_model"),
|
||||
).toBe(SettingsTabs.Providers);
|
||||
expect(
|
||||
resolveClawSolutionSetupTarget(
|
||||
"needs_capability",
|
||||
"missing_skill_dependency",
|
||||
),
|
||||
).toBe(SettingsTabs.Skills);
|
||||
expect(
|
||||
resolveClawSolutionSetupTarget(
|
||||
"needs_capability",
|
||||
"missing_browser_capability",
|
||||
),
|
||||
).toBe(SettingsTabs.ChromeRelay);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
|
||||
import type {
|
||||
ClawSolutionPreparation,
|
||||
ClawSolutionReadiness,
|
||||
ClawSolutionReasonCode,
|
||||
RecordClawSolutionUsageInput,
|
||||
} from "./types";
|
||||
|
||||
export interface ClawSolutionEnterWorkspacePayload {
|
||||
prompt: string;
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
toolPreferences: ChatToolPreferences;
|
||||
themeOverride?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedClawSolutionLaunch {
|
||||
nextToolPreferences: ChatToolPreferences;
|
||||
preferencesChanged: boolean;
|
||||
shouldStartBrowserAssistLoading: boolean;
|
||||
enterWorkspacePayload: ClawSolutionEnterWorkspacePayload;
|
||||
usageRecord: RecordClawSolutionUsageInput;
|
||||
}
|
||||
|
||||
export function resolveClawSolutionSetupTarget(
|
||||
readiness: ClawSolutionReadiness,
|
||||
reasonCode?: ClawSolutionReasonCode,
|
||||
): SettingsTabs | null {
|
||||
if (readiness === "needs_setup") {
|
||||
return SettingsTabs.Providers;
|
||||
}
|
||||
|
||||
if (readiness !== "needs_capability") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (reasonCode === "missing_skill_dependency") {
|
||||
return SettingsTabs.Skills;
|
||||
}
|
||||
|
||||
if (reasonCode === "missing_browser_capability") {
|
||||
return SettingsTabs.ChromeRelay;
|
||||
}
|
||||
|
||||
return SettingsTabs.Providers;
|
||||
}
|
||||
|
||||
export function enableSubagentPreference(preferences: ChatToolPreferences): {
|
||||
nextToolPreferences: ChatToolPreferences;
|
||||
changed: boolean;
|
||||
} {
|
||||
if (preferences.subagent) {
|
||||
return {
|
||||
nextToolPreferences: preferences,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextToolPreferences: {
|
||||
...preferences,
|
||||
subagent: true,
|
||||
},
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClawSolutionLaunch(
|
||||
preparation: ClawSolutionPreparation,
|
||||
currentToolPreferences: ChatToolPreferences,
|
||||
): ResolvedClawSolutionLaunch {
|
||||
const { nextToolPreferences, changed } = preparation.shouldEnableTeamMode
|
||||
? enableSubagentPreference(currentToolPreferences)
|
||||
: {
|
||||
nextToolPreferences: currentToolPreferences,
|
||||
changed: false,
|
||||
};
|
||||
|
||||
return {
|
||||
nextToolPreferences,
|
||||
preferencesChanged: changed,
|
||||
shouldStartBrowserAssistLoading: preparation.shouldLaunchBrowserAssist,
|
||||
enterWorkspacePayload: {
|
||||
prompt: preparation.prompt,
|
||||
openBrowserAssistOnMount: preparation.shouldLaunchBrowserAssist,
|
||||
toolPreferences: nextToolPreferences,
|
||||
themeOverride: preparation.themeTarget,
|
||||
},
|
||||
usageRecord: {
|
||||
solutionId: preparation.solutionId,
|
||||
actionType: preparation.actionType,
|
||||
themeTarget: preparation.themeTarget ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClawSolutionUsageMap,
|
||||
listClawSolutionUsage,
|
||||
recordClawSolutionUsage,
|
||||
} from "./storage";
|
||||
|
||||
describe("claw solution storage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("应按最近时间记录方案使用", () => {
|
||||
recordClawSolutionUsage({
|
||||
solutionId: "web-research-brief",
|
||||
usedAt: 100,
|
||||
actionType: "fill_input",
|
||||
});
|
||||
recordClawSolutionUsage({
|
||||
solutionId: "team-breakdown",
|
||||
usedAt: 300,
|
||||
actionType: "enable_team_mode",
|
||||
});
|
||||
recordClawSolutionUsage({
|
||||
solutionId: "web-research-brief",
|
||||
usedAt: 500,
|
||||
actionType: "fill_input",
|
||||
themeTarget: "general",
|
||||
});
|
||||
|
||||
expect(listClawSolutionUsage()).toEqual([
|
||||
{
|
||||
solutionId: "web-research-brief",
|
||||
usedAt: 500,
|
||||
actionType: "fill_input",
|
||||
themeTarget: "general",
|
||||
},
|
||||
{
|
||||
solutionId: "team-breakdown",
|
||||
usedAt: 300,
|
||||
actionType: "enable_team_mode",
|
||||
themeTarget: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("应提供按方案 ID 查询的映射", () => {
|
||||
recordClawSolutionUsage({
|
||||
solutionId: "social-post-starter",
|
||||
usedAt: 200,
|
||||
actionType: "navigate_theme",
|
||||
themeTarget: "social-media",
|
||||
});
|
||||
|
||||
const usageMap = getClawSolutionUsageMap();
|
||||
expect(usageMap.get("social-post-starter")).toEqual({
|
||||
solutionId: "social-post-starter",
|
||||
usedAt: 200,
|
||||
actionType: "navigate_theme",
|
||||
themeTarget: "social-media",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
ClawSolutionUsageRecord,
|
||||
RecordClawSolutionUsageInput,
|
||||
} from "./types";
|
||||
|
||||
const CLAW_SOLUTION_USAGE_STORAGE_KEY = "lime:claw-solution-usage:v1";
|
||||
const MAX_CLAW_SOLUTION_USAGE_RECORDS = 12;
|
||||
|
||||
function isValidUsageRecord(value: unknown): value is ClawSolutionUsageRecord {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = value as Partial<ClawSolutionUsageRecord>;
|
||||
return (
|
||||
typeof record.solutionId === "string" &&
|
||||
record.solutionId.length > 0 &&
|
||||
typeof record.usedAt === "number" &&
|
||||
Number.isFinite(record.usedAt)
|
||||
);
|
||||
}
|
||||
|
||||
export function listClawSolutionUsage(): ClawSolutionUsageRecord[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CLAW_SOLUTION_USAGE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter(isValidUsageRecord)
|
||||
.sort((left, right) => right.usedAt - left.usedAt)
|
||||
.slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getClawSolutionUsageMap(): Map<
|
||||
string,
|
||||
ClawSolutionUsageRecord
|
||||
> {
|
||||
return new Map(
|
||||
listClawSolutionUsage().map((record) => [record.solutionId, record]),
|
||||
);
|
||||
}
|
||||
|
||||
export function recordClawSolutionUsage(
|
||||
input: RecordClawSolutionUsageInput,
|
||||
): ClawSolutionUsageRecord[] {
|
||||
const nextRecord: ClawSolutionUsageRecord = {
|
||||
solutionId: input.solutionId,
|
||||
usedAt: input.usedAt ?? Date.now(),
|
||||
actionType: input.actionType,
|
||||
themeTarget: input.themeTarget ?? null,
|
||||
};
|
||||
|
||||
const nextRecords = [
|
||||
nextRecord,
|
||||
...listClawSolutionUsage().filter(
|
||||
(record) => record.solutionId !== nextRecord.solutionId,
|
||||
),
|
||||
].slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return nextRecords;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
CLAW_SOLUTION_USAGE_STORAGE_KEY,
|
||||
JSON.stringify(nextRecords),
|
||||
);
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
|
||||
return nextRecords;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
ClawSolutionActionType,
|
||||
ClawSolutionDetail,
|
||||
ClawSolutionPreparation,
|
||||
ClawSolutionReadiness,
|
||||
ClawSolutionReadinessResult,
|
||||
ClawSolutionReasonCode,
|
||||
ClawSolutionSummary,
|
||||
} from "@/lib/api/clawSolutions";
|
||||
|
||||
export type {
|
||||
ClawSolutionActionType,
|
||||
ClawSolutionDetail,
|
||||
ClawSolutionPreparation,
|
||||
ClawSolutionReadiness,
|
||||
ClawSolutionReadinessResult,
|
||||
ClawSolutionReasonCode,
|
||||
ClawSolutionSummary,
|
||||
};
|
||||
|
||||
export type ClawSolutionTone = "slate" | "sky" | "emerald" | "amber";
|
||||
|
||||
export interface ClawSolutionHomeItem extends ClawSolutionSummary {
|
||||
badge: string;
|
||||
recentUsedAt: number | null;
|
||||
isRecent: boolean;
|
||||
readinessLabel: string;
|
||||
readinessTone: ClawSolutionTone;
|
||||
}
|
||||
|
||||
export interface ClawSolutionUsageRecord {
|
||||
solutionId: string;
|
||||
usedAt: number;
|
||||
actionType?: ClawSolutionActionType;
|
||||
themeTarget?: string | null;
|
||||
}
|
||||
|
||||
export interface RecordClawSolutionUsageInput {
|
||||
solutionId: string;
|
||||
usedAt?: number;
|
||||
actionType?: ClawSolutionActionType;
|
||||
themeTarget?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { listClawSolutions } from "@/lib/api/clawSolutions";
|
||||
import { getClawSolutionUsageMap, recordClawSolutionUsage } from "./storage";
|
||||
import type {
|
||||
ClawSolutionHomeItem,
|
||||
ClawSolutionReadiness,
|
||||
ClawSolutionTone,
|
||||
ClawSolutionSummary,
|
||||
RecordClawSolutionUsageInput,
|
||||
} from "./types";
|
||||
|
||||
function getReadinessLabel(readiness: ClawSolutionReadiness): string {
|
||||
if (readiness === "needs_setup") {
|
||||
return "先配置模型";
|
||||
}
|
||||
if (readiness === "needs_capability") {
|
||||
return "补齐能力后开始";
|
||||
}
|
||||
return "可直接开始";
|
||||
}
|
||||
|
||||
function getReadinessTone(readiness: ClawSolutionReadiness): ClawSolutionTone {
|
||||
if (readiness === "needs_setup") {
|
||||
return "amber";
|
||||
}
|
||||
if (readiness === "needs_capability") {
|
||||
return "sky";
|
||||
}
|
||||
return "emerald";
|
||||
}
|
||||
|
||||
function getSolutionBadge(
|
||||
summary: ClawSolutionSummary,
|
||||
isRecent: boolean,
|
||||
): string {
|
||||
if (isRecent) {
|
||||
return "最近使用";
|
||||
}
|
||||
if (summary.id === "social-post-starter") {
|
||||
return "社媒方案";
|
||||
}
|
||||
if (summary.id === "browser-assist-task") {
|
||||
return "浏览器协助";
|
||||
}
|
||||
if (summary.id === "team-breakdown") {
|
||||
return "多代理";
|
||||
}
|
||||
return "Claw 方案";
|
||||
}
|
||||
|
||||
function buildHomeItems(
|
||||
summaries: ClawSolutionSummary[],
|
||||
): ClawSolutionHomeItem[] {
|
||||
const usageMap = getClawSolutionUsageMap();
|
||||
const items: InternalClawSolutionHomeItem[] = summaries.map(
|
||||
(summary, index) => {
|
||||
const recent = usageMap.get(summary.id);
|
||||
const recentUsedAt = recent?.usedAt ?? null;
|
||||
const isRecent = typeof recentUsedAt === "number";
|
||||
|
||||
return {
|
||||
...summary,
|
||||
badge: getSolutionBadge(summary, isRecent),
|
||||
recentUsedAt,
|
||||
isRecent,
|
||||
readinessLabel: getReadinessLabel(summary.readiness),
|
||||
readinessTone: getReadinessTone(summary.readiness),
|
||||
_sortIndex: index,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return items
|
||||
.sort((left, right) => {
|
||||
if (left.recentUsedAt && right.recentUsedAt) {
|
||||
if (left.recentUsedAt !== right.recentUsedAt) {
|
||||
return right.recentUsedAt - left.recentUsedAt;
|
||||
}
|
||||
} else if (left.recentUsedAt) {
|
||||
return -1;
|
||||
} else if (right.recentUsedAt) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left._sortIndex - right._sortIndex;
|
||||
})
|
||||
.map(({ _sortIndex, ...item }) => item);
|
||||
}
|
||||
|
||||
type InternalClawSolutionHomeItem = ClawSolutionHomeItem & {
|
||||
_sortIndex: number;
|
||||
};
|
||||
|
||||
interface UseClawSolutionsResult {
|
||||
solutions: ClawSolutionHomeItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
recordUsage: (input: RecordClawSolutionUsageInput) => void;
|
||||
}
|
||||
|
||||
export function useClawSolutions(enabled = true): UseClawSolutionsResult {
|
||||
const [summaries, setSummaries] = useState<ClawSolutionSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [usageVersion, setUsageVersion] = useState(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setSummaries([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextSummaries = await listClawSolutions();
|
||||
setSummaries(nextSummaries);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setSummaries([]);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const recordUsageAndRefresh = useCallback(
|
||||
(input: RecordClawSolutionUsageInput) => {
|
||||
recordClawSolutionUsage(input);
|
||||
setUsageVersion((previous) => previous + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const solutions = useMemo(() => {
|
||||
void usageVersion;
|
||||
return buildHomeItems(summaries);
|
||||
}, [summaries, usageVersion]);
|
||||
|
||||
return {
|
||||
solutions,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
recordUsage: recordUsageAndRefresh,
|
||||
};
|
||||
}
|
||||
@@ -758,6 +758,9 @@ describe("AgentThreadTimeline", () => {
|
||||
|
||||
clickTimelineToggle(container);
|
||||
|
||||
expect(container.textContent).toContain("图片任务 1");
|
||||
expect(container.textContent).not.toContain("Image #1");
|
||||
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((element) => element.textContent?.includes("打开子会话"));
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type AgentThreadSummaryChip,
|
||||
} from "../utils/agentThreadGrouping";
|
||||
import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI";
|
||||
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
|
||||
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
@@ -725,11 +726,13 @@ function renderGroupItemDetails(
|
||||
|
||||
if (item.type === "subagent_activity") {
|
||||
const subagentSessionId = item.session_id?.trim();
|
||||
const displayTitle =
|
||||
resolveInternalImageTaskDisplayName(item.title) || "子代理协作";
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
icon={Bot}
|
||||
title={item.title || "子代理协作"}
|
||||
title={displayTitle}
|
||||
badge={
|
||||
<Badge variant={resolveStatusBadgeVariant(item.status)}>
|
||||
{item.status_label}
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/api/fileBrowser", () => ({
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
rerender: (props: React.ComponentProps<typeof CanvasWorkbenchLayout>) => void;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
@@ -95,16 +96,34 @@ function createTaskFile(
|
||||
function mount(
|
||||
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
|
||||
): HTMLDivElement {
|
||||
return mountHarness(props).container;
|
||||
}
|
||||
|
||||
function mountHarness(
|
||||
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
|
||||
): MountedHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let currentProps = props;
|
||||
|
||||
act(() => {
|
||||
root.render(<CanvasWorkbenchLayout {...props} />);
|
||||
root.render(<CanvasWorkbenchLayout {...currentProps} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return container;
|
||||
const harness: MountedHarness = {
|
||||
container,
|
||||
root,
|
||||
rerender: (nextProps) => {
|
||||
currentProps = nextProps;
|
||||
act(() => {
|
||||
root.render(<CanvasWorkbenchLayout {...currentProps} />);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
mountedRoots.push(harness);
|
||||
return harness;
|
||||
}
|
||||
|
||||
async function flushEffects(times = 6) {
|
||||
@@ -115,7 +134,7 @@ async function flushEffects(times = 6) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeWorkbench(width: number) {
|
||||
async function resizeWorkbench(width: number, height = 720) {
|
||||
resizeObserverState.width = width;
|
||||
await act(async () => {
|
||||
resizeObserverState.observers.forEach((observer) => {
|
||||
@@ -128,7 +147,7 @@ async function resizeWorkbench(width: number) {
|
||||
target: observer.target,
|
||||
contentRect: {
|
||||
width,
|
||||
height: 720,
|
||||
height,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -445,7 +464,187 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
expect(container.textContent).toContain("该文件为二进制内容");
|
||||
});
|
||||
|
||||
it("容器变窄时应切换为右侧抽屉布局并保持工作台可展开收起", async () => {
|
||||
it("启用 teamView 且没有默认预览时应优先展示 Team Workbench", async () => {
|
||||
const renderPreview = vi.fn((_target: CanvasWorkbenchPreviewTarget) => (
|
||||
<div data-testid="fallback-preview">fallback</div>
|
||||
));
|
||||
const renderTeamPreview = vi.fn(
|
||||
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
|
||||
<div data-testid="team-preview">team-preview</div>
|
||||
),
|
||||
);
|
||||
const renderTeamPanel = vi.fn(() => (
|
||||
<div data-testid="team-panel">team-panel</div>
|
||||
));
|
||||
|
||||
const container = mount({
|
||||
artifacts: [],
|
||||
canvasState: null,
|
||||
taskFiles: [],
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: null,
|
||||
loadFilePreview: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
content: "",
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
error: null,
|
||||
})),
|
||||
onOpenPath: vi.fn(async () => undefined),
|
||||
onRevealPath: vi.fn(async () => undefined),
|
||||
renderPreview,
|
||||
teamView: {
|
||||
enabled: true,
|
||||
title: "Team Workbench",
|
||||
subtitle: "多 agent 实时协作",
|
||||
renderPreview: renderTeamPreview,
|
||||
renderPanel: renderTeamPanel,
|
||||
},
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("多 agent 实时协作");
|
||||
expect(renderTeamPreview).toHaveBeenCalled();
|
||||
expect(renderTeamPanel).toHaveBeenCalled();
|
||||
expect(renderPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => {
|
||||
const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => (
|
||||
<div data-testid="fallback-preview">
|
||||
fallback:{target.kind}
|
||||
</div>
|
||||
));
|
||||
const renderTeamPreview = vi.fn(
|
||||
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
|
||||
<div data-testid="team-preview">team-preview</div>
|
||||
),
|
||||
);
|
||||
const renderTeamPanel = vi.fn(() => (
|
||||
<div data-testid="team-panel">team-panel</div>
|
||||
));
|
||||
|
||||
const baseProps: React.ComponentProps<typeof CanvasWorkbenchLayout> = {
|
||||
artifacts: [
|
||||
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
|
||||
],
|
||||
canvasState: null,
|
||||
taskFiles: [],
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: {
|
||||
selectionKey: "artifact:artifact-1",
|
||||
title: "draft.md",
|
||||
content: "标题\n当前内容",
|
||||
filePath: "draft.md",
|
||||
absolutePath: "/workspace/draft.md",
|
||||
previousContent: null,
|
||||
},
|
||||
loadFilePreview: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
content: "",
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
error: null,
|
||||
})),
|
||||
onOpenPath: vi.fn(async () => undefined),
|
||||
onRevealPath: vi.fn(async () => undefined),
|
||||
renderPreview,
|
||||
teamView: {
|
||||
enabled: true,
|
||||
title: "Team Workbench",
|
||||
subtitle: "多成员实时协作",
|
||||
autoFocusToken: 1,
|
||||
renderPreview: renderTeamPreview,
|
||||
renderPanel: renderTeamPanel,
|
||||
},
|
||||
};
|
||||
|
||||
const harness = mountHarness(baseProps);
|
||||
await flushEffects();
|
||||
|
||||
expect(harness.container.querySelector('[data-testid="team-preview"]')).toBeNull();
|
||||
expect(harness.container.querySelector('[data-testid="team-panel"]')).toBeNull();
|
||||
expect(harness.container.querySelector('[data-testid="fallback-preview"]')).not.toBeNull();
|
||||
|
||||
harness.rerender({
|
||||
...baseProps,
|
||||
teamView: {
|
||||
...baseProps.teamView!,
|
||||
autoFocusToken: 2,
|
||||
},
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(harness.container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
|
||||
expect(harness.container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
|
||||
expect(harness.container.textContent).toContain("多成员实时协作");
|
||||
});
|
||||
|
||||
it("teamView 存在活动态提示时,应在窄屏悬浮入口显示状态标签", async () => {
|
||||
const container = mount({
|
||||
artifacts: [
|
||||
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
|
||||
],
|
||||
canvasState: null,
|
||||
taskFiles: [],
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: {
|
||||
selectionKey: "artifact:artifact-1",
|
||||
title: "draft.md",
|
||||
content: "标题\n当前内容",
|
||||
filePath: "draft.md",
|
||||
absolutePath: "/workspace/draft.md",
|
||||
previousContent: null,
|
||||
},
|
||||
loadFilePreview: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
content: "",
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
error: null,
|
||||
})),
|
||||
onOpenPath: vi.fn(async () => undefined),
|
||||
onRevealPath: vi.fn(async () => undefined),
|
||||
renderPreview: (target, options) => (
|
||||
<div data-testid="preview-panel">
|
||||
{options?.stackedWorkbenchTrigger}
|
||||
{target.kind}:{target.title}
|
||||
</div>
|
||||
),
|
||||
teamView: {
|
||||
enabled: true,
|
||||
title: "Team Workbench",
|
||||
subtitle: "多成员实时协作",
|
||||
triggerState: {
|
||||
tone: "active",
|
||||
label: "组建中",
|
||||
},
|
||||
renderPreview: () => <div data-testid="team-preview">team-preview</div>,
|
||||
renderPanel: () => <div data-testid="team-panel">team-panel</div>,
|
||||
},
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
await resizeWorkbench(820);
|
||||
await flushEffects();
|
||||
|
||||
const trigger = container.querySelector<HTMLElement>(
|
||||
'[data-testid="canvas-workbench-trigger"]',
|
||||
);
|
||||
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(trigger?.textContent).toContain("工作台");
|
||||
expect(trigger?.textContent).toContain("组建中");
|
||||
expect(trigger?.className).toContain("bg-sky-50");
|
||||
});
|
||||
|
||||
it("容器变窄时应切换为底部工作台布局并保持工作台可展开收起", async () => {
|
||||
const container = mount({
|
||||
artifacts: [
|
||||
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
|
||||
@@ -506,13 +705,17 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
expect(
|
||||
container.querySelector('button[aria-label="展开画布工作台"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-trigger"]')
|
||||
?.textContent,
|
||||
).toContain("工作台");
|
||||
|
||||
clickButtonByLabel(container, "展开画布工作台");
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="canvas-workbench-layout"]')
|
||||
?.getAttribute("data-panel-placement"),
|
||||
).toBe("overlay-right");
|
||||
).toBe("overlay-bottom");
|
||||
expect(
|
||||
container.querySelector('button[aria-label="折叠画布工作台"]'),
|
||||
).not.toBeNull();
|
||||
@@ -526,4 +729,110 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
clickButtonByLabel(container, "切换画布标签-预览");
|
||||
expect(container.textContent).toContain("当前画布正文");
|
||||
});
|
||||
|
||||
it("窄屏底部工作台应支持拖拽调整高度", async () => {
|
||||
const container = mount({
|
||||
artifacts: [
|
||||
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
|
||||
],
|
||||
canvasState: null,
|
||||
taskFiles: [],
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: {
|
||||
selectionKey: "artifact:artifact-new",
|
||||
title: "draft.md",
|
||||
content: "标题\n产物版本",
|
||||
filePath: "draft.md",
|
||||
absolutePath: "/workspace/draft.md",
|
||||
previousContent: "标题\n上一版本",
|
||||
} satisfies CanvasWorkbenchDefaultPreview,
|
||||
loadFilePreview: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
content: "README 内容",
|
||||
isBinary: false,
|
||||
size: 12,
|
||||
error: null,
|
||||
})),
|
||||
onOpenPath: vi.fn(async () => undefined),
|
||||
onRevealPath: vi.fn(async () => undefined),
|
||||
renderPreview: (target, options) => (
|
||||
<div data-testid="preview-panel">
|
||||
{options?.stackedWorkbenchTrigger}
|
||||
{target.kind}:{target.title}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
await resizeWorkbench(820, 640);
|
||||
await flushEffects();
|
||||
|
||||
clickButtonByLabel(container, "展开画布工作台");
|
||||
await flushEffects();
|
||||
|
||||
const layout = container.querySelector<HTMLElement>(
|
||||
'[data-testid="canvas-workbench-layout"]',
|
||||
);
|
||||
const resizeHandle = container.querySelector<HTMLElement>(
|
||||
'[data-testid="canvas-workbench-resize-handle"]',
|
||||
);
|
||||
|
||||
expect(layout).toBeTruthy();
|
||||
expect(resizeHandle).toBeTruthy();
|
||||
|
||||
const initialHeight = Number.parseFloat(layout?.style.height || "0");
|
||||
expect(initialHeight).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
resizeHandle?.dispatchEvent(
|
||||
new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
clientY: 520,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mousemove", {
|
||||
bubbles: true,
|
||||
clientY: 420,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mouseup", {
|
||||
bubbles: true,
|
||||
clientY: 420,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const expandedHeight = Number.parseFloat(layout?.style.height || "0");
|
||||
expect(expandedHeight).toBeGreaterThan(initialHeight);
|
||||
|
||||
await act(async () => {
|
||||
resizeHandle?.dispatchEvent(
|
||||
new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
clientY: 420,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mousemove", {
|
||||
bubbles: true,
|
||||
clientY: 560,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mouseup", {
|
||||
bubbles: true,
|
||||
clientY: 560,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reducedHeight = Number.parseFloat(layout?.style.height || "0");
|
||||
expect(reducedHeight).toBeLessThan(expandedHeight);
|
||||
expect(reducedHeight).toBeGreaterThanOrEqual(220);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -43,7 +44,12 @@ import {
|
||||
type CanvasWorkbenchDiffLine,
|
||||
} from "../utils/canvasWorkbenchDiff";
|
||||
|
||||
type CanvasWorkbenchTab = "artifacts" | "files" | "changes" | "preview";
|
||||
type CanvasWorkbenchTab =
|
||||
| "artifacts"
|
||||
| "files"
|
||||
| "changes"
|
||||
| "preview"
|
||||
| "team";
|
||||
export type CanvasWorkbenchLayoutMode = "split" | "stacked";
|
||||
|
||||
interface CanvasWorkbenchEntryBase {
|
||||
@@ -126,8 +132,26 @@ export type CanvasWorkbenchPreviewTarget =
|
||||
| {
|
||||
kind: "empty";
|
||||
title: string;
|
||||
}
|
||||
| {
|
||||
kind: "team-workbench";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface CanvasWorkbenchTeamView {
|
||||
enabled: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
autoFocusToken?: string | number | null;
|
||||
triggerState?: {
|
||||
tone: "idle" | "active" | "error";
|
||||
label?: string | null;
|
||||
} | null;
|
||||
renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => ReactNode;
|
||||
renderPanel?: () => ReactNode;
|
||||
renderFooter?: () => ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceFileSelection {
|
||||
path: string;
|
||||
title: string;
|
||||
@@ -155,20 +179,14 @@ export interface CanvasWorkbenchLayoutProps {
|
||||
},
|
||||
) => ReactNode;
|
||||
onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void;
|
||||
teamView?: CanvasWorkbenchTeamView | null;
|
||||
}
|
||||
|
||||
const TAB_META: Array<{ key: CanvasWorkbenchTab; label: string }> = [
|
||||
{ key: "artifacts", label: "产物" },
|
||||
{ key: "files", label: "全部文件" },
|
||||
{ key: "changes", label: "变更" },
|
||||
{ key: "preview", label: "预览" },
|
||||
];
|
||||
|
||||
const WORKBENCH_PANEL_CLASSNAME =
|
||||
"rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5";
|
||||
"rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5";
|
||||
|
||||
const WORKBENCH_MUTED_PANEL_CLASSNAME =
|
||||
"rounded-[24px] border border-dashed border-slate-200/90 bg-slate-50/82 px-4 py-6 text-sm text-slate-500";
|
||||
"rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500";
|
||||
|
||||
const WORKBENCH_BUTTON_CLASSNAME =
|
||||
"border-slate-200/80 bg-white/90 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900";
|
||||
@@ -181,6 +199,47 @@ const WORKBENCH_GHOST_BUTTON_CLASSNAME =
|
||||
|
||||
const STACKED_LAYOUT_BREAKPOINT = 1040;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function resolveStackedWorkbenchMetrics(shellHeight: number): {
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
defaultHeight: number;
|
||||
} {
|
||||
const safeShellHeight = shellHeight > 0 ? shellHeight : 720;
|
||||
const minHeight = Math.max(220, Math.min(300, Math.round(safeShellHeight * 0.32)));
|
||||
const maxHeight = Math.max(
|
||||
minHeight + 80,
|
||||
Math.min(560, safeShellHeight - 56),
|
||||
);
|
||||
const defaultHeight = clamp(
|
||||
Math.round(safeShellHeight * 0.48),
|
||||
minHeight,
|
||||
maxHeight,
|
||||
);
|
||||
return {
|
||||
minHeight,
|
||||
maxHeight,
|
||||
defaultHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStackedTriggerClassName(
|
||||
tone: "idle" | "active" | "error",
|
||||
): string {
|
||||
switch (tone) {
|
||||
case "active":
|
||||
return "border-sky-200 bg-sky-50 text-sky-700 shadow-sky-950/10 hover:bg-sky-100 hover:text-sky-800";
|
||||
case "error":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-950/10 hover:bg-rose-100 hover:text-rose-800";
|
||||
case "idle":
|
||||
default:
|
||||
return "border-slate-200 bg-white text-slate-600 shadow-slate-950/10 hover:bg-slate-50 hover:text-slate-900";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -469,12 +528,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
onRevealPath,
|
||||
renderPreview,
|
||||
onLayoutModeChange,
|
||||
teamView = null,
|
||||
}: CanvasWorkbenchLayoutProps) {
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>("artifacts");
|
||||
const stackedResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>(() =>
|
||||
teamView?.enabled && !defaultPreview ? "team" : "artifacts",
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [isStackedLayout, setIsStackedLayout] = useState(false);
|
||||
const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false);
|
||||
const [shellHeight, setShellHeight] = useState(720);
|
||||
const [stackedWorkbenchHeight, setStackedWorkbenchHeight] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
|
||||
{},
|
||||
@@ -488,6 +555,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
const [workspaceFileSelections, setWorkspaceFileSelections] = useState<
|
||||
Record<string, WorkspaceFileSelection>
|
||||
>({});
|
||||
const availableTabs = useMemo<Array<{ key: CanvasWorkbenchTab; label: string }>>(
|
||||
() => [
|
||||
...(teamView?.enabled ? [{ key: "team" as const, label: "Team" }] : []),
|
||||
{ key: "artifacts" as const, label: "产物" },
|
||||
{ key: "files" as const, label: "全部文件" },
|
||||
{ key: "changes" as const, label: "变更" },
|
||||
{ key: "preview" as const, label: "预览" },
|
||||
],
|
||||
[teamView?.enabled],
|
||||
);
|
||||
|
||||
const entries = useMemo(
|
||||
() => buildEntries(artifacts, canvasState, taskFiles, workspaceRoot),
|
||||
@@ -568,27 +645,37 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateLayout = (width: number) => {
|
||||
const updateLayout = (width: number, height: number) => {
|
||||
if (width <= 0) {
|
||||
return;
|
||||
}
|
||||
setIsStackedLayout(width < STACKED_LAYOUT_BREAKPOINT);
|
||||
if (height > 0) {
|
||||
setShellHeight(height);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackWidth =
|
||||
node.getBoundingClientRect().width || node.clientWidth || window.innerWidth;
|
||||
updateLayout(fallbackWidth);
|
||||
const fallbackHeight =
|
||||
node.getBoundingClientRect().height || node.clientHeight || window.innerHeight;
|
||||
updateLayout(fallbackWidth, fallbackHeight);
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const contentRect = entries[0]?.contentRect;
|
||||
const nextWidth =
|
||||
entries[0]?.contentRect.width ||
|
||||
contentRect?.width ||
|
||||
node.getBoundingClientRect().width ||
|
||||
node.clientWidth;
|
||||
updateLayout(nextWidth);
|
||||
const nextHeight =
|
||||
contentRect?.height ||
|
||||
node.getBoundingClientRect().height ||
|
||||
node.clientHeight;
|
||||
updateLayout(nextWidth, nextHeight);
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
@@ -607,6 +694,57 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
}
|
||||
}, [isStackedLayout]);
|
||||
|
||||
const stackedWorkbenchMetrics = useMemo(
|
||||
() => resolveStackedWorkbenchMetrics(shellHeight),
|
||||
[shellHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStackedWorkbenchHeight((previous) =>
|
||||
previous == null
|
||||
? stackedWorkbenchMetrics.defaultHeight
|
||||
: clamp(
|
||||
previous,
|
||||
stackedWorkbenchMetrics.minHeight,
|
||||
stackedWorkbenchMetrics.maxHeight,
|
||||
),
|
||||
);
|
||||
}, [stackedWorkbenchMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stackedResizeCleanupRef.current?.();
|
||||
stackedResizeCleanupRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTabs.some((tab) => tab.key === activeTab)) {
|
||||
setActiveTab(availableTabs[0]?.key || "artifacts");
|
||||
}
|
||||
}, [activeTab, availableTabs]);
|
||||
|
||||
const teamAutoFocusTokenRef = useRef<string | number | null | undefined>(
|
||||
teamView?.autoFocusToken,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamView?.enabled || teamView.autoFocusToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamAutoFocusTokenRef.current === teamView.autoFocusToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
teamAutoFocusTokenRef.current = teamView.autoFocusToken;
|
||||
setActiveTab("team");
|
||||
setCollapsed(false);
|
||||
if (isStackedLayout) {
|
||||
setStackedWorkbenchOpen(true);
|
||||
}
|
||||
}, [isStackedLayout, teamView?.autoFocusToken, teamView?.enabled]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(path: string) => {
|
||||
setExpandedDirectories((previous) => {
|
||||
@@ -687,6 +825,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
: null;
|
||||
|
||||
const currentTarget = useMemo<CanvasWorkbenchPreviewTarget>(() => {
|
||||
if (activeTab === "team" && teamView?.enabled) {
|
||||
return {
|
||||
kind: "team-workbench",
|
||||
title: teamView.title || "Team Workbench",
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedWorkspaceFile) {
|
||||
if (selectedWorkspaceFile.status === "loading") {
|
||||
return {
|
||||
@@ -796,7 +941,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
kind: "empty",
|
||||
title: "暂无可预览内容",
|
||||
};
|
||||
}, [defaultPreview, selectedEntry, selectedWorkspaceFile]);
|
||||
}, [activeTab, defaultPreview, selectedEntry, selectedWorkspaceFile, teamView]);
|
||||
|
||||
const currentContent = useMemo(() => {
|
||||
if (currentTarget.kind === "default-canvas") {
|
||||
@@ -1146,30 +1291,72 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
);
|
||||
};
|
||||
|
||||
const renderTeamTab = () => {
|
||||
if (!teamView?.enabled) {
|
||||
return (
|
||||
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
|
||||
当前没有可展示的 Team 工作台。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return teamView.renderPanel ? (
|
||||
teamView.renderPanel()
|
||||
) : (
|
||||
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
|
||||
Team 工作台已启用。
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTabButtons = (stacked: boolean) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid gap-2",
|
||||
stacked ? "grid-cols-2" : "grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{TAB_META.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
aria-label={`切换画布标签-${tab.label}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
|
||||
: WORKBENCH_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
stacked ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
aria-label={`切换画布标签-${tab.label}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
|
||||
: WORKBENCH_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(
|
||||
Math.max(availableTabs.length, 1),
|
||||
5,
|
||||
)}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
aria-label={`切换画布标签-${tab.label}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
|
||||
: WORKBENCH_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const renderWorkbenchHeader = (
|
||||
@@ -1184,33 +1371,62 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
stacked ? "px-3 py-3" : "px-4 py-4",
|
||||
)}
|
||||
>
|
||||
{stacked ? (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
|
||||
Canvas Workbench
|
||||
{activeTab === "team" ? "Team Workbench" : "Canvas Workbench"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-foreground">
|
||||
{currentTarget.title}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-sm font-semibold text-foreground",
|
||||
stacked ? "line-clamp-2" : "truncate",
|
||||
)}
|
||||
>
|
||||
{activeTab === "team"
|
||||
? teamView?.title || currentTarget.title
|
||||
: currentTarget.title}
|
||||
</div>
|
||||
{selectionPath ? (
|
||||
<div className="mt-1 truncate text-xs text-slate-500">
|
||||
{activeTab === "team" && teamView?.subtitle ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-xs text-slate-500",
|
||||
stacked ? "line-clamp-2" : "truncate",
|
||||
)}
|
||||
>
|
||||
{teamView.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab !== "team" && selectionPath ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-xs text-slate-500",
|
||||
stacked ? "line-clamp-2 break-all" : "truncate",
|
||||
)}
|
||||
>
|
||||
{selectionPath}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下载当前画布项"
|
||||
disabled={!currentContent.trim()}
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
WORKBENCH_GHOST_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
{activeTab !== "team" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下载当前画布项"
|
||||
disabled={!currentContent.trim()}
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
WORKBENCH_GHOST_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{stacked || options?.showCollapseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1237,6 +1453,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
);
|
||||
|
||||
const renderWorkbenchFooter = (stacked: boolean) => (
|
||||
activeTab === "team" && teamView?.renderFooter ? (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-slate-200/80",
|
||||
stacked ? "px-3 py-3" : "px-4 py-3",
|
||||
)}
|
||||
>
|
||||
{teamView.renderFooter()}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-slate-200/80",
|
||||
@@ -1301,10 +1527,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const renderActiveTab = () =>
|
||||
activeTab === "artifacts"
|
||||
activeTab === "team"
|
||||
? renderTeamTab()
|
||||
: activeTab === "artifacts"
|
||||
? renderEntriesTab()
|
||||
: activeTab === "files"
|
||||
? renderFilesTab()
|
||||
@@ -1312,6 +1541,52 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
? renderChangesTab()
|
||||
: renderPreviewTab();
|
||||
|
||||
const handleStartStackedResize = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isStackedLayout || !stackedWorkbenchOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
stackedResizeCleanupRef.current?.();
|
||||
|
||||
const startY = event.clientY;
|
||||
const startHeight =
|
||||
stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const nextHeight = clamp(
|
||||
startHeight + (startY - moveEvent.clientY),
|
||||
stackedWorkbenchMetrics.minHeight,
|
||||
stackedWorkbenchMetrics.maxHeight,
|
||||
);
|
||||
setStackedWorkbenchHeight(nextHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
stackedResizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
stackedResizeCleanupRef.current = () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[
|
||||
isStackedLayout,
|
||||
stackedWorkbenchHeight,
|
||||
stackedWorkbenchMetrics.defaultHeight,
|
||||
stackedWorkbenchMetrics.maxHeight,
|
||||
stackedWorkbenchMetrics.minHeight,
|
||||
stackedWorkbenchOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const stackedWorkbenchTrigger =
|
||||
isStackedLayout && !stackedWorkbenchOpen ? (
|
||||
<button
|
||||
@@ -1319,9 +1594,28 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
aria-label="展开画布工作台"
|
||||
title="工作台"
|
||||
onClick={() => setStackedWorkbenchOpen(true)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200/80 bg-white/88 text-slate-500 shadow-sm shadow-slate-950/5 transition-all hover:bg-white hover:text-slate-900"
|
||||
data-testid="canvas-workbench-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center gap-2 rounded-full border px-3.5 text-sm font-medium shadow-lg transition-all hover:-translate-y-0.5",
|
||||
resolveStackedTriggerClassName(teamView?.triggerState?.tone || "idle"),
|
||||
)}
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
<span>{activeTab === "team" ? "Team 工作台" : "工作台"}</span>
|
||||
{teamView?.triggerState?.label ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-[10px] font-semibold leading-none",
|
||||
teamView.triggerState.tone === "error"
|
||||
? "border-rose-200 bg-white/90 text-rose-700"
|
||||
: teamView.triggerState.tone === "active"
|
||||
? "border-sky-200 bg-white/90 text-sky-700"
|
||||
: "border-slate-200 bg-white/90 text-slate-600",
|
||||
)}
|
||||
>
|
||||
{teamView.triggerState.label}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : undefined;
|
||||
|
||||
@@ -1331,7 +1625,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
data-testid="canvas-workbench-shell"
|
||||
data-layout-mode={isStackedLayout ? "stacked" : "split"}
|
||||
className={cn(
|
||||
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5",
|
||||
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
|
||||
isStackedLayout ? "block" : "flex flex-row",
|
||||
)}
|
||||
>
|
||||
@@ -1341,29 +1635,58 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
isStackedLayout ? "h-full" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{renderPreview(currentTarget, {
|
||||
stackedWorkbenchTrigger,
|
||||
})}
|
||||
{activeTab === "team" && teamView?.enabled
|
||||
? teamView.renderPreview({
|
||||
stackedWorkbenchTrigger,
|
||||
})
|
||||
: renderPreview(currentTarget, {
|
||||
stackedWorkbenchTrigger,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isStackedLayout ? (
|
||||
stackedWorkbenchOpen ? (
|
||||
<section
|
||||
data-testid="canvas-workbench-layout"
|
||||
data-panel-placement="overlay-right"
|
||||
className="absolute inset-y-3 right-3 z-10 flex w-[min(25rem,calc(100%-1.5rem))] max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200/80 bg-white/95 shadow-[0_24px_80px_rgba(15,23,42,0.14)] backdrop-blur-md"
|
||||
>
|
||||
{renderWorkbenchHeader(true)}
|
||||
<div className="flex-1 overflow-auto px-3 py-3">{renderActiveTab()}</div>
|
||||
{renderWorkbenchFooter(true)}
|
||||
</section>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠画布工作台遮罩"
|
||||
onClick={() => setStackedWorkbenchOpen(false)}
|
||||
className="absolute inset-0 z-10 bg-slate-950/5"
|
||||
/>
|
||||
<section
|
||||
data-testid="canvas-workbench-layout"
|
||||
data-panel-placement="overlay-bottom"
|
||||
className="absolute inset-x-3 bottom-3 z-20 flex max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-[0_24px_80px_rgba(15,23,42,0.16)]"
|
||||
style={{
|
||||
height: `${stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight}px`,
|
||||
minHeight: `${stackedWorkbenchMetrics.minHeight}px`,
|
||||
maxHeight: `${stackedWorkbenchMetrics.maxHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="调整画布工作台高度"
|
||||
data-testid="canvas-workbench-resize-handle"
|
||||
onMouseDown={handleStartStackedResize}
|
||||
className="flex h-5 shrink-0 cursor-row-resize items-center justify-center border-b border-slate-200/80 bg-white"
|
||||
>
|
||||
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
{renderWorkbenchHeader(true)}
|
||||
<div className="flex-1 overflow-auto px-3 py-3">
|
||||
{renderActiveTab()}
|
||||
</div>
|
||||
{renderWorkbenchFooter(true)}
|
||||
</section>
|
||||
</>
|
||||
) : null
|
||||
) : (
|
||||
<aside
|
||||
data-testid="canvas-workbench-layout"
|
||||
data-panel-placement="side"
|
||||
className={cn(
|
||||
"relative flex h-full flex-col border-l border-slate-200/80 bg-white/82 backdrop-blur-sm transition-[width] duration-200",
|
||||
"relative flex h-full flex-col border-l border-slate-200/80 bg-white transition-[width] duration-200",
|
||||
collapsed ? "w-12" : "w-[360px]",
|
||||
)}
|
||||
>
|
||||
@@ -1380,7 +1703,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</button>
|
||||
{TAB_META.map((tab) => (
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
|
||||
@@ -865,4 +865,42 @@ describe("ChatSidebar", () => {
|
||||
|
||||
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2");
|
||||
});
|
||||
|
||||
it("内部图片子代理标题应显示为用户文案", () => {
|
||||
const container = renderSidebar({
|
||||
topics: [
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "child-image",
|
||||
title: "Image #1",
|
||||
sourceSessionId: "child-image",
|
||||
},
|
||||
],
|
||||
currentTopicId: "child-image",
|
||||
subagentParentContext: {
|
||||
parent_session_id: "parent-1",
|
||||
parent_session_name: "主线程",
|
||||
role_hint: "image_editor",
|
||||
task_summary: "处理图片细节。",
|
||||
created_from_turn_id: "turn-42",
|
||||
sibling_subagent_sessions: [
|
||||
{
|
||||
id: "child-2",
|
||||
name: "Image #2",
|
||||
created_at: 1_742_288_430,
|
||||
updated_at: 1_742_288_530,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查图片导出尺寸。",
|
||||
role_hint: "image_reviewer",
|
||||
runtime_status: "queued",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("图片任务 1");
|
||||
expect(container.textContent).toContain("图片任务 2");
|
||||
expect(container.textContent).not.toContain("Image #1");
|
||||
expect(container.textContent).not.toContain("Image #2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type TaskStatusReason,
|
||||
} from "../hooks/agentChatShared";
|
||||
import type { Message } from "../types";
|
||||
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
|
||||
|
||||
const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
|
||||
const OLDER_TASKS_INITIAL_COUNT = 8;
|
||||
@@ -169,6 +170,13 @@ function normalizePreviewText(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").slice(0, 72);
|
||||
}
|
||||
|
||||
function resolveSidebarDisplayTitle(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
return resolveInternalImageTaskDisplayName(value) || fallback;
|
||||
}
|
||||
|
||||
function resolveCurrentTaskPreview(messages: Message[]) {
|
||||
return extractTaskPreviewFromMessages(messages);
|
||||
}
|
||||
@@ -540,7 +548,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
||||
return {
|
||||
id: topic.id,
|
||||
title: topic.title || "未命名任务",
|
||||
title: resolveSidebarDisplayTitle(topic.title, "未命名任务"),
|
||||
updatedAt: topic.updatedAt || topic.createdAt,
|
||||
messagesCount: topic.messagesCount,
|
||||
status,
|
||||
@@ -834,7 +842,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{session.name || "未命名子代理"}
|
||||
{resolveSidebarDisplayTitle(session.name, "未命名子代理")}
|
||||
</div>
|
||||
<Badge className={statusMeta.badgeClassName}>
|
||||
{statusMeta.label}
|
||||
@@ -1086,7 +1094,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
<div className="rounded-[20px] border border-slate-200/80 bg-white/86 px-3.5 py-3 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{currentTaskItem?.title || "当前子代理"}
|
||||
{resolveSidebarDisplayTitle(
|
||||
currentTaskItem?.title,
|
||||
"当前子代理",
|
||||
)}
|
||||
</div>
|
||||
<Badge className="border border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900">
|
||||
当前子代理
|
||||
|
||||
@@ -529,4 +529,20 @@ describe("EmptyState", () => {
|
||||
|
||||
expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应允许外部覆盖 supportingSlot", async () => {
|
||||
const container = renderEmptyState({
|
||||
supportingSlotOverride: (
|
||||
<div data-testid="custom-supporting-slot">自定义入口层</div>
|
||||
),
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="custom-supporting-slot"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("自定义入口层");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import type { MessageImage } from "../types";
|
||||
import type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
|
||||
@@ -202,6 +203,8 @@ interface EmptyStateProps {
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
|
||||
teamWorkspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
hasCanvasContent?: boolean;
|
||||
hasContentId?: boolean;
|
||||
selectedText?: string;
|
||||
@@ -235,6 +238,8 @@ interface EmptyStateProps {
|
||||
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
|
||||
/** 配置读取策略 */
|
||||
configLoadStrategy?: "immediate" | "idle";
|
||||
/** 覆盖默认支持面板 */
|
||||
supportingSlotOverride?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ENTRY_THEME_ID = "social-media";
|
||||
@@ -390,6 +395,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
selectedTeam = null,
|
||||
onSelectTeam,
|
||||
onEnableSuggestedTeam,
|
||||
teamWorkspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
hasCanvasContent = false,
|
||||
hasContentId = false,
|
||||
selectedText = "",
|
||||
@@ -408,6 +415,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
deferProjectSelectorListLoad = false,
|
||||
modelSelectorBackgroundPreload = "immediate",
|
||||
configLoadStrategy = "immediate",
|
||||
supportingSlotOverride,
|
||||
}) => {
|
||||
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
|
||||
useActiveSkill();
|
||||
@@ -1101,6 +1109,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
workspaceId={projectId}
|
||||
executionStrategy={executionStrategy}
|
||||
executionStrategyLabel={executionStrategyLabel}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
@@ -1148,6 +1157,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onSubagentEnabledChange={onSubagentEnabledChange}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={onSelectTeam}
|
||||
teamWorkspaceSettings={teamWorkspaceSettings}
|
||||
onPersistCustomTeams={onPersistCustomTeams}
|
||||
onEnableSuggestedTeam={onEnableSuggestedTeam}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onWebSearchEnabledChange={onWebSearchEnabledChange}
|
||||
@@ -1158,7 +1169,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const quickActionsPanel = (
|
||||
const defaultQuickActionsPanel = (
|
||||
<EmptyStateQuickActions
|
||||
title="快速启动"
|
||||
description="先选一个任务模板,再在当前会话里继续补充和追问。"
|
||||
@@ -1233,7 +1244,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
cards={workspaceCards}
|
||||
features={workspaceFeatures}
|
||||
prioritySlot={composerPanel}
|
||||
supportingSlot={quickActionsPanel}
|
||||
supportingSlot={supportingSlotOverride ?? defaultQuickActionsPanel}
|
||||
themeTabs={themeTabs}
|
||||
headerControls={headerControls}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,12 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./Inputbar/components/TeamSelector", () => ({
|
||||
TeamSelector: () => <div data-testid="empty-state-team-selector" />,
|
||||
TeamSelector: (props: { autoOpenToken?: number | null }) => (
|
||||
<div
|
||||
data-testid="empty-state-team-selector"
|
||||
data-auto-open-token={String(props.autoOpenToken ?? "")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
@@ -129,6 +134,95 @@ function renderPanel(
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderStatefulPanel(
|
||||
props?: Partial<React.ComponentProps<typeof EmptyStateComposerPanel>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const StatefulPanel = () => {
|
||||
const [subagentEnabled, setSubagentEnabled] = React.useState(false);
|
||||
return (
|
||||
<EmptyStateComposerPanel
|
||||
input=""
|
||||
setInput={vi.fn()}
|
||||
placeholder="输入内容"
|
||||
onSend={vi.fn()}
|
||||
activeTheme="general"
|
||||
providerType="openai"
|
||||
setProviderType={vi.fn()}
|
||||
model="gpt-4.1"
|
||||
setModel={vi.fn()}
|
||||
executionStrategy="react"
|
||||
executionStrategyLabel="ReAct"
|
||||
setExecutionStrategy={vi.fn()}
|
||||
onManageProviders={vi.fn()}
|
||||
isGeneralTheme
|
||||
isEntryTheme={false}
|
||||
entryTaskType="direct"
|
||||
entryTaskTypes={[]}
|
||||
getEntryTaskTemplate={vi.fn()}
|
||||
entryTemplate={{
|
||||
type: "direct",
|
||||
label: "直接写作",
|
||||
description: "直接按需求写作",
|
||||
pattern: "{input}",
|
||||
slots: [],
|
||||
}}
|
||||
entryPreview=""
|
||||
entrySlotValues={{}}
|
||||
onEntryTaskTypeChange={vi.fn()}
|
||||
onEntrySlotChange={vi.fn()}
|
||||
characters={[]}
|
||||
skills={[]}
|
||||
activeSkill={null}
|
||||
setActiveSkill={vi.fn()}
|
||||
clearActiveSkill={vi.fn()}
|
||||
isSkillsLoading={false}
|
||||
onNavigateToSettings={vi.fn()}
|
||||
onImportSkill={vi.fn()}
|
||||
onRefreshSkills={vi.fn()}
|
||||
showCreationModeSelector={false}
|
||||
creationMode="guided"
|
||||
onCreationModeChange={vi.fn()}
|
||||
platform="xiaohongshu"
|
||||
setPlatform={vi.fn()}
|
||||
depth="deep"
|
||||
setDepth={vi.fn()}
|
||||
ratio="3:4"
|
||||
setRatio={vi.fn()}
|
||||
style="minimal"
|
||||
setStyle={vi.fn()}
|
||||
ratioPopoverOpen={false}
|
||||
setRatioPopoverOpen={vi.fn()}
|
||||
stylePopoverOpen={false}
|
||||
setStylePopoverOpen={vi.fn()}
|
||||
thinkingEnabled={false}
|
||||
onThinkingEnabledChange={vi.fn()}
|
||||
taskEnabled={false}
|
||||
onTaskEnabledChange={vi.fn()}
|
||||
subagentEnabled={subagentEnabled}
|
||||
onSubagentEnabledChange={setSubagentEnabled}
|
||||
webSearchEnabled={false}
|
||||
onWebSearchEnabledChange={vi.fn()}
|
||||
pendingImages={[]}
|
||||
onFileSelect={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onRemoveImage={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<StatefulPanel />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EmptyStateComposerPanel", () => {
|
||||
it("应将 onPaste 绑定到输入框", () => {
|
||||
const onPaste = vi.fn();
|
||||
@@ -229,4 +323,68 @@ describe("EmptyStateComposerPanel", () => {
|
||||
container.querySelector('[data-testid="empty-state-team-selector"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", () => {
|
||||
const onSubagentEnabledChange = vi.fn();
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
subagentEnabled: false,
|
||||
onSubagentEnabledChange,
|
||||
});
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="empty-state-team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
expect(enableButton?.textContent).toContain("开启 Team");
|
||||
|
||||
act(() => {
|
||||
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
|
||||
const container = renderStatefulPanel();
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="empty-state-team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const teamSelector = container.querySelector(
|
||||
'[data-testid="empty-state-team-selector"]',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(teamSelector).toBeTruthy();
|
||||
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
|
||||
});
|
||||
|
||||
it("复杂任务但未开启 Team 时,首页开启按钮应显示推荐态", () => {
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
subagentEnabled: false,
|
||||
input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收",
|
||||
});
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="empty-state-team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
expect(enableButton?.textContent).toContain("开启 Team");
|
||||
expect(enableButton?.textContent).toContain("推荐");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { CharacterMention } from "./Inputbar/components/CharacterMention";
|
||||
import { SkillBadge } from "./Inputbar/components/SkillBadge";
|
||||
import { SkillSelector } from "./Inputbar/components/SkillSelector";
|
||||
import { TeamSelector } from "./Inputbar/components/TeamSelector";
|
||||
import { TeamModeEntryButton } from "./Inputbar/components/TeamModeEntryButton";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import { CREATION_MODE_CONFIG } from "./constants";
|
||||
import type {
|
||||
CreationMode,
|
||||
@@ -478,6 +480,7 @@ interface EmptyStateComposerPanelProps {
|
||||
setProviderType: (type: string) => void;
|
||||
model: string;
|
||||
setModel: (model: string) => void;
|
||||
workspaceId?: string | null;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
executionStrategyLabel: string;
|
||||
setExecutionStrategy?: (
|
||||
@@ -527,6 +530,8 @@ interface EmptyStateComposerPanelProps {
|
||||
onSubagentEnabledChange?: (enabled: boolean) => void;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
teamWorkspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
|
||||
webSearchEnabled: boolean;
|
||||
onWebSearchEnabledChange?: (enabled: boolean) => void;
|
||||
@@ -546,6 +551,7 @@ export function EmptyStateComposerPanel({
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
workspaceId,
|
||||
executionStrategy = "react",
|
||||
executionStrategyLabel,
|
||||
setExecutionStrategy,
|
||||
@@ -593,6 +599,8 @@ export function EmptyStateComposerPanel({
|
||||
onSubagentEnabledChange,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
teamWorkspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
onEnableSuggestedTeam,
|
||||
webSearchEnabled,
|
||||
onWebSearchEnabledChange,
|
||||
@@ -606,6 +614,9 @@ export function EmptyStateComposerPanel({
|
||||
const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
@@ -643,6 +654,20 @@ export function EmptyStateComposerPanel({
|
||||
setDismissedSuggestionKey(suggestionKey);
|
||||
};
|
||||
|
||||
const handleEnableTeamMode = () => {
|
||||
if (!subagentEnabled && !selectedTeam) {
|
||||
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
|
||||
}
|
||||
onSubagentEnabledChange?.(true);
|
||||
};
|
||||
|
||||
const handleToggleSubagentMode = () => {
|
||||
if (!subagentEnabled && !selectedTeam) {
|
||||
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
|
||||
}
|
||||
onSubagentEnabledChange?.(!subagentEnabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputCard>
|
||||
{isEntryTheme && (
|
||||
@@ -784,9 +809,24 @@ export function EmptyStateComposerPanel({
|
||||
<TeamSelector
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
workspaceId={workspaceId}
|
||||
providerType={providerType}
|
||||
model={model}
|
||||
executionStrategy={executionStrategy}
|
||||
autoOpenToken={teamSelectorAutoOpenToken}
|
||||
selectedTeam={selectedTeam}
|
||||
workspaceSettings={teamWorkspaceSettings}
|
||||
onPersistCustomTeams={onPersistCustomTeams}
|
||||
onSelectTeam={(team) => onSelectTeam?.(team)}
|
||||
/>
|
||||
) : isGeneralTheme && onSubagentEnabledChange ? (
|
||||
<TeamModeEntryButton
|
||||
selectedTeamLabel={selectedTeam?.label}
|
||||
dataTestId="empty-state-team-mode-enable-button"
|
||||
recommended={teamSuggestion.shouldSuggest}
|
||||
hint={teamSuggestion.reasons[0]}
|
||||
onClick={handleEnableTeamMode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ChatModelSelector
|
||||
@@ -1058,7 +1098,7 @@ export function EmptyStateComposerPanel({
|
||||
subagentEnabled,
|
||||
"slate",
|
||||
)}
|
||||
onClick={() => onSubagentEnabledChange?.(!subagentEnabled)}
|
||||
onClick={handleToggleSubagentMode}
|
||||
aria-pressed={subagentEnabled}
|
||||
title={subagentEnabled ? "关闭多代理偏好" : "开启多代理偏好"}
|
||||
>
|
||||
|
||||
@@ -103,6 +103,13 @@ export interface EmptyStateQuickActionItem {
|
||||
description: string;
|
||||
badge: string;
|
||||
prompt: string;
|
||||
actionLabel?: string;
|
||||
outputHint?: string;
|
||||
statusLabel?: string;
|
||||
statusTone?: "slate" | "sky" | "emerald" | "amber";
|
||||
statusDescription?: string;
|
||||
testId?: string;
|
||||
solutionId?: string;
|
||||
}
|
||||
|
||||
export interface EmptyStateQuickPresetItem {
|
||||
@@ -119,10 +126,18 @@ interface EmptyStateQuickActionsProps {
|
||||
presets?: EmptyStateQuickPresetItem[];
|
||||
items: EmptyStateQuickActionItem[];
|
||||
embedded?: boolean;
|
||||
loading?: boolean;
|
||||
onPresetAction?: (item: EmptyStateQuickPresetItem) => void;
|
||||
onAction: (item: EmptyStateQuickActionItem) => void;
|
||||
}
|
||||
|
||||
const STATUS_TONE_CLASSNAMES = {
|
||||
slate: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
sky: "border-sky-200 bg-sky-50 text-sky-700",
|
||||
emerald: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
amber: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
} as const;
|
||||
|
||||
export function EmptyStateQuickActions({
|
||||
title,
|
||||
description,
|
||||
@@ -130,10 +145,11 @@ export function EmptyStateQuickActions({
|
||||
presets = [],
|
||||
items,
|
||||
embedded = false,
|
||||
loading = false,
|
||||
onPresetAction,
|
||||
onAction,
|
||||
}: EmptyStateQuickActionsProps) {
|
||||
if (items.length === 0 && presets.length === 0) {
|
||||
if (!loading && items.length === 0 && presets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -141,7 +157,9 @@ export function EmptyStateQuickActions({
|
||||
<QuickActionsPanel
|
||||
$embedded={embedded}
|
||||
className={
|
||||
embedded ? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME : EMPTY_STATE_PANEL_CLASSNAME
|
||||
embedded
|
||||
? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME
|
||||
: EMPTY_STATE_PANEL_CLASSNAME
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
@@ -182,33 +200,55 @@ export function EmptyStateQuickActions({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2.5 grid gap-2 md:grid-cols-2">
|
||||
{items.map((item, index) => (
|
||||
<RecommendationCard
|
||||
key={item.key}
|
||||
$index={index}
|
||||
type="button"
|
||||
onClick={() => onAction(item)}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
|
||||
{item.badge}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-slate-400 transition-colors group-hover:text-slate-500">
|
||||
立即开始
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{item.title}
|
||||
{loading && 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}
|
||||
onClick={() => onAction(item)}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
|
||||
{item.badge}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-slate-400 transition-colors group-hover:text-slate-500">
|
||||
{item.actionLabel || "立即开始"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</RecommendationCard>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
|
||||
{item.description}
|
||||
</p>
|
||||
{item.outputHint ? (
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-400">
|
||||
产出:{item.outputHint}
|
||||
</p>
|
||||
) : null}
|
||||
{item.statusLabel ? (
|
||||
<div className="mt-2 flex items-center justify-start">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-medium ${STATUS_TONE_CLASSNAMES[item.statusTone ?? "slate"]}`}
|
||||
title={item.statusDescription}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</RecommendationCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QuickActionsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
+56
-8
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
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";
|
||||
@@ -8,12 +8,15 @@ import { CharacterMention } from "./CharacterMention";
|
||||
import { InputbarCore } from "./InputbarCore";
|
||||
import { SkillSelector } from "./SkillSelector";
|
||||
import { TeamSelector } from "./TeamSelector";
|
||||
import { TeamModeEntryButton } from "./TeamModeEntryButton";
|
||||
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
|
||||
import { InputbarModelExtra } from "./InputbarModelExtra";
|
||||
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
|
||||
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
|
||||
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
|
||||
import type { TeamDefinition } from "../../../utils/teamDefinitions";
|
||||
import { getTeamSuggestion } from "../../../utils/teamSuggestion";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import type {
|
||||
ThemeWorkbenchGateState,
|
||||
ThemeWorkbenchQuickAction,
|
||||
@@ -40,6 +43,11 @@ interface InputbarComposerSectionProps {
|
||||
onRefreshSkills?: () => void | Promise<void>;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
teamWorkspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
workspaceId?: string | null;
|
||||
providerType?: string;
|
||||
model?: string;
|
||||
onSend: () => void;
|
||||
onToolClick: (tool: string) => void;
|
||||
activeTools: Record<string, boolean>;
|
||||
@@ -83,6 +91,11 @@ export const InputbarComposerSection: React.FC<
|
||||
onRefreshSkills,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
teamWorkspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
workspaceId,
|
||||
providerType,
|
||||
model,
|
||||
onSend,
|
||||
onToolClick,
|
||||
activeTools,
|
||||
@@ -101,11 +114,29 @@ export const InputbarComposerSection: React.FC<
|
||||
onPromoteQueuedTurn,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const showSkillSelector =
|
||||
!isThemeWorkbenchVariant && isGeneralResearchTheme(activeTheme);
|
||||
const teamSuggestion =
|
||||
!activeTools["subagent_mode"] && isGeneralResearchTheme(activeTheme)
|
||||
? getTeamSuggestion({
|
||||
input,
|
||||
activeTheme,
|
||||
subagentEnabled: false,
|
||||
})
|
||||
: null;
|
||||
const shouldRecommendTeamEntry = Boolean(teamSuggestion?.shouldSuggest);
|
||||
const currentPendingImages =
|
||||
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
|
||||
pendingImages;
|
||||
const handleEnableTeamMode = () => {
|
||||
if (!selectedTeam) {
|
||||
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
|
||||
}
|
||||
onToolClick("subagent_mode");
|
||||
};
|
||||
|
||||
if (renderThemeWorkbenchGeneratingPanel) {
|
||||
return (
|
||||
@@ -198,13 +229,30 @@ export const InputbarComposerSection: React.FC<
|
||||
onRefreshSkills={onRefreshSkills}
|
||||
/>
|
||||
) : null}
|
||||
{activeTools["subagent_mode"] ? (
|
||||
<TeamSelector
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={(team) => onSelectTeam?.(team)}
|
||||
/>
|
||||
{isGeneralResearchTheme(activeTheme) ? (
|
||||
activeTools["subagent_mode"] ? (
|
||||
<TeamSelector
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
workspaceId={workspaceId}
|
||||
providerType={providerType}
|
||||
model={model}
|
||||
executionStrategy={executionStrategy}
|
||||
autoOpenToken={teamSelectorAutoOpenToken}
|
||||
selectedTeam={selectedTeam}
|
||||
workspaceSettings={teamWorkspaceSettings}
|
||||
onPersistCustomTeams={onPersistCustomTeams}
|
||||
onSelectTeam={(team) => onSelectTeam?.(team)}
|
||||
/>
|
||||
) : (
|
||||
<TeamModeEntryButton
|
||||
selectedTeamLabel={selectedTeam?.label}
|
||||
dataTestId="team-mode-enable-button"
|
||||
recommended={shouldRecommendTeamEntry}
|
||||
hint={teamSuggestion?.reasons?.[0]}
|
||||
onClick={handleEnableTeamMode}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<InputbarModelExtra
|
||||
isFullscreen={isFullscreen}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { Workflow } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TeamModeEntryButtonProps {
|
||||
selectedTeamLabel?: string | null;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
recommended?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const TeamModeEntryButton: React.FC<TeamModeEntryButtonProps> = ({
|
||||
selectedTeamLabel,
|
||||
onClick,
|
||||
className,
|
||||
dataTestId,
|
||||
recommended = false,
|
||||
hint,
|
||||
}) => {
|
||||
const trimmedLabel = selectedTeamLabel?.trim() || "";
|
||||
const buttonLabel = trimmedLabel ? `开启 Team · ${trimmedLabel}` : "开启 Team";
|
||||
const title =
|
||||
hint?.trim() ||
|
||||
(recommended
|
||||
? "当前任务更适合拆分协作,但仍由你手动决定是否启用 Team"
|
||||
: "仅在当前任务适合拆分协作时启用 Team");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={dataTestId}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-none transition-colors",
|
||||
recommended
|
||||
? "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-400 hover:bg-sky-100"
|
||||
: "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700 hover:border-fuchsia-300 hover:bg-fuchsia-100",
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
aria-label={buttonLabel}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[180px] truncate">{buttonLabel}</span>
|
||||
{recommended ? (
|
||||
<span className="rounded-full border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-semibold leading-none text-sky-700">
|
||||
推荐
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamModeEntryButton;
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
import { Users } from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
|
||||
import type { TeamDefinition } from "../../../utils/teamDefinitions";
|
||||
|
||||
@@ -21,8 +22,15 @@ const TeamSelectorPanel = lazy(async () => {
|
||||
interface TeamSelectorProps {
|
||||
activeTheme?: string;
|
||||
input?: string;
|
||||
workspaceId?: string | null;
|
||||
providerType?: string;
|
||||
model?: string;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
autoOpenToken?: number | null;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam: (team: TeamDefinition | null) => void;
|
||||
workspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
triggerLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
@@ -30,9 +38,16 @@ interface TeamSelectorProps {
|
||||
export const TeamSelector: React.FC<TeamSelectorProps> = ({
|
||||
activeTheme,
|
||||
input,
|
||||
workspaceId,
|
||||
providerType,
|
||||
model,
|
||||
executionStrategy,
|
||||
autoOpenToken = null,
|
||||
selectedTeam = null,
|
||||
onSelectTeam,
|
||||
triggerLabel = "Team",
|
||||
workspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
triggerLabel = "配置 Team",
|
||||
className,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -43,6 +58,13 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpenToken === null || autoOpenToken === undefined) {
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
}, [autoOpenToken]);
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (!selectedTeam?.label?.trim()) {
|
||||
return triggerLabel;
|
||||
@@ -90,7 +112,13 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
|
||||
<TeamSelectorPanel
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
workspaceId={workspaceId}
|
||||
providerType={providerType}
|
||||
model={model}
|
||||
executionStrategy={executionStrategy}
|
||||
selectedTeam={selectedTeam}
|
||||
workspaceSettings={workspaceSettings}
|
||||
onPersistCustomTeams={onPersistCustomTeams}
|
||||
onSelectTeam={(team) => {
|
||||
onSelectTeam(team);
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamSelectorPanel } from "./TeamSelectorPanel";
|
||||
import {
|
||||
@@ -93,6 +94,14 @@ describe("TeamSelectorPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("不应再展示模型生成 Team 入口", async () => {
|
||||
const { container } = renderPanel();
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).not.toContain("模型生成 Team");
|
||||
});
|
||||
|
||||
it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => {
|
||||
const onSelectTeam = vi.fn();
|
||||
const selectedTeam = createTeamDefinitionFromPreset(
|
||||
@@ -146,6 +155,8 @@ describe("TeamSelectorPanel", () => {
|
||||
saveButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined;
|
||||
|
||||
expect(savedTeam).toBeTruthy();
|
||||
@@ -158,4 +169,50 @@ describe("TeamSelectorPanel", () => {
|
||||
]);
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("项目级自定义 Team 应通过回调持久化", async () => {
|
||||
const onPersistCustomTeams = vi.fn().mockResolvedValue(undefined);
|
||||
const workspaceSettings: WorkspaceSettings = {
|
||||
agentTeam: {
|
||||
customTeams: [
|
||||
{
|
||||
id: "custom-team-project-1",
|
||||
label: "项目联调 Team",
|
||||
description: "面向当前项目的联调与修复。",
|
||||
roles: [
|
||||
{
|
||||
id: "planner",
|
||||
label: "分析",
|
||||
summary: "先定位问题再拆解实施。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const { container } = renderPanel({
|
||||
workspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).toContain("项目联调 Team");
|
||||
expect(container.textContent).toContain("当前项目 Team");
|
||||
|
||||
const deleteButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("删除这个 Team"),
|
||||
);
|
||||
|
||||
expect(deleteButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
deleteButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(onPersistCustomTeams).toHaveBeenCalledWith([]);
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BUILTIN_TEAM_PROFILE_OPTIONS,
|
||||
@@ -32,13 +33,22 @@ import {
|
||||
type TeamRoleDefinition,
|
||||
} from "../../../utils/teamDefinitions";
|
||||
import { getTeamSuggestion } from "../../../utils/teamSuggestion";
|
||||
import { loadCustomTeams, saveCustomTeams } from "../../../utils/teamStorage";
|
||||
import {
|
||||
resolveCustomTeams,
|
||||
saveCustomTeams,
|
||||
} from "../../../utils/teamStorage";
|
||||
|
||||
interface TeamSelectorPanelProps {
|
||||
activeTheme?: string;
|
||||
input?: string;
|
||||
workspaceId?: string | null;
|
||||
providerType?: string;
|
||||
model?: string;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam: (team: TeamDefinition | null) => void;
|
||||
workspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
@@ -288,18 +298,27 @@ function TeamCard({
|
||||
export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
activeTheme,
|
||||
input,
|
||||
workspaceId: _workspaceId,
|
||||
providerType: _providerType,
|
||||
model: _model,
|
||||
executionStrategy: _executionStrategy,
|
||||
selectedTeam = null,
|
||||
onSelectTeam,
|
||||
workspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
onClose,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [customTeams, setCustomTeams] = useState<TeamDefinition[]>([]);
|
||||
const [draft, setDraft] = useState<TeamDraft | null>(null);
|
||||
const [expandedTeamId, setExpandedTeamId] = useState<string | null>(null);
|
||||
const isProjectScopedCustomTeam = Boolean(
|
||||
workspaceSettings && onPersistCustomTeams,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomTeams(loadCustomTeams());
|
||||
}, []);
|
||||
setCustomTeams(resolveCustomTeams(workspaceSettings));
|
||||
}, [workspaceSettings]);
|
||||
|
||||
const suggestion = useMemo(
|
||||
() =>
|
||||
@@ -370,7 +389,16 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
setDraft(buildDraftFromTeam(team));
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
const persistCustomTeams = async (nextCustomTeams: TeamDefinition[]) => {
|
||||
if (onPersistCustomTeams) {
|
||||
await Promise.resolve(onPersistCustomTeams(nextCustomTeams));
|
||||
return;
|
||||
}
|
||||
|
||||
saveCustomTeams(nextCustomTeams);
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const normalized = normalizeTeamDefinition({
|
||||
id: draft?.id,
|
||||
source: "custom",
|
||||
@@ -400,23 +428,46 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
const nextCustomTeams = [...customTeams.filter((team) => team.id !== nextTeam.id), nextTeam].sort(
|
||||
(left, right) => (right.updatedAt || 0) - (left.updatedAt || 0),
|
||||
);
|
||||
setCustomTeams(nextCustomTeams);
|
||||
saveCustomTeams(nextCustomTeams);
|
||||
setDraft(null);
|
||||
onSelectTeam(nextTeam);
|
||||
onClose?.();
|
||||
toast.success(`已保存 Team「${nextTeam.label}」`);
|
||||
try {
|
||||
await persistCustomTeams(nextCustomTeams);
|
||||
setCustomTeams(nextCustomTeams);
|
||||
setDraft(null);
|
||||
onSelectTeam(nextTeam);
|
||||
onClose?.();
|
||||
toast.success(
|
||||
isProjectScopedCustomTeam
|
||||
? `已将 Team「${nextTeam.label}」保存到当前项目`
|
||||
: `已保存 Team「${nextTeam.label}」`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`保存 Team 失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCustom = (team: TeamDefinition) => {
|
||||
const handleDeleteCustom = async (team: TeamDefinition) => {
|
||||
const nextCustomTeams = customTeams.filter((item) => item.id !== team.id);
|
||||
setCustomTeams(nextCustomTeams);
|
||||
saveCustomTeams(nextCustomTeams);
|
||||
setDraft((currentDraft) => (currentDraft?.id === team.id ? null : currentDraft));
|
||||
if (selectedTeam?.id === team.id) {
|
||||
onSelectTeam(null);
|
||||
|
||||
try {
|
||||
await persistCustomTeams(nextCustomTeams);
|
||||
setCustomTeams(nextCustomTeams);
|
||||
setDraft((currentDraft) =>
|
||||
currentDraft?.id === team.id ? null : currentDraft,
|
||||
);
|
||||
if (selectedTeam?.id === team.id) {
|
||||
onSelectTeam(null);
|
||||
}
|
||||
toast.success(
|
||||
isProjectScopedCustomTeam
|
||||
? `已从当前项目删除 Team「${team.label}」`
|
||||
: `已删除 Team「${team.label}」`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`删除 Team 失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
toast.success(`已删除 Team「${team.label}」`);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
@@ -606,7 +657,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-rose-200 bg-white text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => handleDeleteCustom(inspectorTeam)}
|
||||
onClick={() => {
|
||||
void handleDeleteCustom(inspectorTeam);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
删除这个 Team
|
||||
@@ -930,7 +983,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||||
onClick={handleSaveDraft}
|
||||
onClick={() => {
|
||||
void handleSaveDraft();
|
||||
}}
|
||||
>
|
||||
保存 Team
|
||||
</Button>
|
||||
@@ -953,6 +1008,11 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<div className="mt-1 text-sm text-slate-700">
|
||||
只在当前任务适合拆分协作时,为主代理提供团队结构参考。
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{isProjectScopedCustomTeam
|
||||
? "自定义 Team 会保存到当前项目,便于项目级复用与评审。"
|
||||
: "自定义 Team 当前保存在本地设备,用于快速测试与个人偏好。"}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTeam ? (
|
||||
<button
|
||||
@@ -1002,7 +1062,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||||
onClick={() =>
|
||||
handleStartCreate(
|
||||
selectedTeam || recommendedTeam || builtinTeams[0] || null,
|
||||
selectedTeam ||
|
||||
recommendedTeam ||
|
||||
builtinTeams[0] ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -1049,7 +1112,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
|
||||
我的 Team
|
||||
{isProjectScopedCustomTeam ? "当前项目 Team" : "我的 Team"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1058,7 +1121,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
className="h-8 border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
onClick={() =>
|
||||
handleStartCreate(
|
||||
selectedTeam || recommendedTeam || builtinTeams[0] || null,
|
||||
selectedTeam ||
|
||||
recommendedTeam ||
|
||||
builtinTeams[0] ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -1083,14 +1149,18 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
}
|
||||
onCopy={() => handleStartCreate(team)}
|
||||
onEdit={() => handleStartEdit(team)}
|
||||
onDelete={() => handleDeleteCustom(team)}
|
||||
onDelete={() => {
|
||||
void handleDeleteCustom(team);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||||
<div>
|
||||
还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。
|
||||
{isProjectScopedCustomTeam
|
||||
? "当前项目还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"
|
||||
: "还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1099,7 +1169,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
className="mt-3 border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
onClick={() =>
|
||||
handleStartCreate(
|
||||
selectedTeam || recommendedTeam || builtinTeams[0] || null,
|
||||
selectedTeam ||
|
||||
recommendedTeam ||
|
||||
builtinTeams[0] ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const IDLE_PRELOAD_TIMEOUT_MS = 1_500;
|
||||
const IDLE_PRELOAD_FALLBACK_DELAY_MS = 180;
|
||||
|
||||
function shouldSkipIdleModulePreload(): boolean {
|
||||
return Boolean(import.meta.env?.MODE === "test" || import.meta.env?.VITEST);
|
||||
}
|
||||
|
||||
export function scheduleIdleModulePreload(task: () => void): () => void {
|
||||
if (shouldSkipIdleModulePreload()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
task();
|
||||
return () => {};
|
||||
|
||||
@@ -85,7 +85,12 @@ vi.mock("./components/SkillBadge", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./components/TeamSelector", () => ({
|
||||
TeamSelector: () => <div data-testid="team-selector-stub" />,
|
||||
TeamSelector: (props: { autoOpenToken?: number | null }) => (
|
||||
<div
|
||||
data-testid="team-selector-stub"
|
||||
data-auto-open-token={String(props.autoOpenToken ?? "")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../ChatModelSelector", () => ({
|
||||
@@ -322,6 +327,9 @@ describe("Inputbar", () => {
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-selector-stub"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-mode-enable-button"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
const enabledContainer = renderInputbar({
|
||||
activeTheme: "general",
|
||||
@@ -340,6 +348,103 @@ describe("Inputbar", () => {
|
||||
expect(
|
||||
enabledContainer.querySelector('[data-testid="team-selector-stub"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
enabledContainer.querySelector('[data-testid="team-mode-enable-button"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", async () => {
|
||||
const onToolStatesChange = vi.fn();
|
||||
const container = renderInputbar({
|
||||
activeTheme: "general",
|
||||
toolStates: {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: false,
|
||||
},
|
||||
onToolStatesChange,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
expect(enableButton?.textContent).toContain("开启 Team");
|
||||
|
||||
act(() => {
|
||||
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onToolStatesChange).toHaveBeenCalledWith({
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
|
||||
const container = renderInputbar({
|
||||
activeTheme: "general",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const teamSelector = container.querySelector(
|
||||
'[data-testid="team-selector-stub"]',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(teamSelector).toBeTruthy();
|
||||
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
|
||||
});
|
||||
|
||||
it("复杂任务但未开启 Team 时,开启按钮应显示推荐态", async () => {
|
||||
const container = renderInputbar({
|
||||
activeTheme: "general",
|
||||
input: "请把这个跨模块问题拆分成分析、实现、验证三个并行子任务再汇总",
|
||||
toolStates: {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: false,
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const enableButton = container.querySelector(
|
||||
'[data-testid="team-mode-enable-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(enableButton).toBeTruthy();
|
||||
expect(enableButton?.textContent).toContain("开启 Team");
|
||||
expect(enableButton?.textContent).toContain("推荐");
|
||||
});
|
||||
|
||||
it("社媒主题默认应自动注入 social_post_with_cover skill", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { type InputbarToolStates } from "./hooks/useInputbarToolState";
|
||||
import { useInputbarController } from "./hooks/useInputbarController";
|
||||
import type { TeamDefinition } from "../../utils/teamDefinitions";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
|
||||
interface InputbarProps {
|
||||
input: string;
|
||||
@@ -69,6 +70,7 @@ interface InputbarProps {
|
||||
setProviderType?: (type: string) => void;
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
workspaceId?: string | null;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
@@ -93,6 +95,8 @@ interface InputbarProps {
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
|
||||
teamWorkspaceSettings?: WorkspaceSettings | null;
|
||||
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const Inputbar: React.FC<InputbarProps> = ({
|
||||
@@ -122,6 +126,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
workspaceId,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
toolStates,
|
||||
@@ -141,6 +146,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
onEnableSuggestedTeam,
|
||||
teamWorkspaceSettings,
|
||||
onPersistCustomTeams,
|
||||
}) => {
|
||||
const {
|
||||
textareaRef,
|
||||
@@ -245,6 +252,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
onRefreshSkills={onRefreshSkills}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={onSelectTeam}
|
||||
teamWorkspaceSettings={teamWorkspaceSettings}
|
||||
onPersistCustomTeams={onPersistCustomTeams}
|
||||
workspaceId={workspaceId}
|
||||
onSend={handleSend}
|
||||
onToolClick={handleToolClick}
|
||||
activeTools={activeTools}
|
||||
@@ -256,6 +266,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
isCanvasOpen={isCanvasOpen}
|
||||
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
|
||||
activeTheme={activeTheme}
|
||||
providerType={providerType}
|
||||
model={model}
|
||||
onManageProviders={onManageProviders}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
topExtra={topExtra}
|
||||
|
||||
@@ -204,6 +204,51 @@ describe("MessageList", () => {
|
||||
expect(groups[1]?.textContent).toContain("已继续执行发布流程。");
|
||||
});
|
||||
|
||||
it("用户图片消息不应渲染内部图片占位文本", () => {
|
||||
const now = new Date();
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "msg-user-image",
|
||||
role: "user",
|
||||
content: "[Image #1]",
|
||||
images: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
const container = render(messages);
|
||||
|
||||
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
|
||||
const image = container.querySelector('img[alt="attachment"]');
|
||||
expect(image).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("[Image #1]");
|
||||
});
|
||||
|
||||
it("助手内部图片标签应在主消息里隐藏", () => {
|
||||
const now = new Date();
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "msg-assistant-image",
|
||||
role: "assistant",
|
||||
content: "[Image #1]",
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
render(messages);
|
||||
|
||||
expect(mockStreamingRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("助手消息包含 artifacts 时应渲染产物卡片并响应点击", () => {
|
||||
const now = new Date();
|
||||
const onArtifactClick = vi.fn();
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
resolveArtifactPreviewText,
|
||||
resolveArtifactWritePhase,
|
||||
} from "../utils/messageArtifacts";
|
||||
import {
|
||||
sanitizeContentPartsForDisplay,
|
||||
sanitizeMessageTextForDisplay,
|
||||
sanitizeMessageTextForPreview,
|
||||
} from "../utils/internalImagePlaceholder";
|
||||
import {
|
||||
Message,
|
||||
type AgentThreadItem,
|
||||
@@ -255,6 +260,18 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
showIdentity?: boolean;
|
||||
},
|
||||
) => {
|
||||
const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
|
||||
const displayContent = sanitizeMessageTextForDisplay(msg.content || "", {
|
||||
role: msg.role,
|
||||
hasImages,
|
||||
});
|
||||
const displayContentParts = sanitizeContentPartsForDisplay(
|
||||
msg.contentParts,
|
||||
{
|
||||
role: msg.role,
|
||||
hasImages,
|
||||
},
|
||||
);
|
||||
const mappedTimeline = timelineByMessageId.get(msg.id);
|
||||
const timeline =
|
||||
msg.role !== "assistant"
|
||||
@@ -330,13 +347,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
</div>
|
||||
) : msg.role === "assistant" ? (
|
||||
<StreamingRenderer
|
||||
content={msg.content}
|
||||
content={displayContent}
|
||||
isStreaming={msg.isThinking}
|
||||
toolCalls={msg.toolCalls}
|
||||
showCursor={msg.isThinking && !msg.content}
|
||||
showCursor={msg.isThinking && !displayContent}
|
||||
thinkingContent={msg.thinkingContent}
|
||||
runtimeStatus={msg.runtimeStatus}
|
||||
contentParts={msg.contentParts}
|
||||
contentParts={displayContentParts}
|
||||
actionRequests={msg.actionRequests}
|
||||
onA2UISubmit={
|
||||
onA2UISubmit
|
||||
@@ -366,15 +383,17 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
renderProposedPlanBlocks={!timeline}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderer
|
||||
content={msg.content}
|
||||
onA2UISubmit={
|
||||
onA2UISubmit
|
||||
? (formData) => onA2UISubmit(formData, msg.id)
|
||||
: undefined
|
||||
}
|
||||
renderA2UIInline={renderA2UIInline}
|
||||
/>
|
||||
displayContent ? (
|
||||
<MarkdownRenderer
|
||||
content={displayContent}
|
||||
onA2UISubmit={
|
||||
onA2UISubmit
|
||||
? (formData) => onA2UISubmit(formData, msg.id)
|
||||
: undefined
|
||||
}
|
||||
renderA2UIInline={renderA2UIInline}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
@@ -549,10 +568,24 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
)}
|
||||
|
||||
{messageGroups.map((group, groupIndex) => {
|
||||
const previewSource =
|
||||
group.userMessage?.content ||
|
||||
group.assistantMessages[0]?.content ||
|
||||
"继续当前任务";
|
||||
const previewSource = group.userMessage
|
||||
? sanitizeMessageTextForPreview(group.userMessage.content || "", {
|
||||
role: group.userMessage.role,
|
||||
hasImages:
|
||||
Array.isArray(group.userMessage.images) &&
|
||||
group.userMessage.images.length > 0,
|
||||
}) || "继续当前任务"
|
||||
: group.assistantMessages[0]
|
||||
? sanitizeMessageTextForPreview(
|
||||
group.assistantMessages[0].content || "",
|
||||
{
|
||||
role: group.assistantMessages[0].role,
|
||||
hasImages:
|
||||
Array.isArray(group.assistantMessages[0].images) &&
|
||||
group.assistantMessages[0].images.length > 0,
|
||||
},
|
||||
) || "继续当前任务"
|
||||
: "继续当前任务";
|
||||
const hasTimeline = group.messages.some((message) =>
|
||||
timelineByMessageId.has(message.id),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/lib/api/project";
|
||||
import { toast } from "sonner";
|
||||
import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog";
|
||||
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
/** 当前激活的主题(用于过滤项目) */
|
||||
@@ -110,7 +111,7 @@ export function ProjectSelector({
|
||||
workspaceType: type,
|
||||
});
|
||||
|
||||
toast.success("项目创建成功");
|
||||
notifyProjectCreatedWithRuntimeAgentsGuide(newProject, "项目创建成功");
|
||||
await loadProjects();
|
||||
onSelectProject(newProject.id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Activity, Bot, Clock3, Sparkles, Workflow } from "lucide-react";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
TeamWorkspaceActivityEntry,
|
||||
TeamWorkspaceControlSummary,
|
||||
TeamWorkspaceLiveRuntimeState,
|
||||
TeamWorkspaceRuntimeFormationState,
|
||||
TeamWorkspaceWaitSummary,
|
||||
} from "../teamWorkspaceRuntime";
|
||||
import {
|
||||
resolveRuntimeFormationStatusMeta,
|
||||
resolveRuntimeMemberStatusMeta,
|
||||
summarizeTeamWorkspaceExecution,
|
||||
} from "../teamWorkspaceRuntime";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
|
||||
interface TeamWorkbenchSummaryPanelProps {
|
||||
currentSessionId?: string | null;
|
||||
currentSessionRuntimeStatus?: AsterSubagentSessionInfo["runtime_status"];
|
||||
currentSessionLatestTurnStatus?: AsterSubagentSessionInfo["runtime_status"];
|
||||
currentSessionQueuedTurnCount?: number;
|
||||
childSubagentSessions?: AsterSubagentSessionInfo[];
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
liveRuntimeBySessionId?: Record<string, TeamWorkspaceLiveRuntimeState>;
|
||||
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
|
||||
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
|
||||
teamControlSummary?: TeamWorkspaceControlSummary | null;
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
selectedTeamRoles?: TeamRoleDefinition[] | null;
|
||||
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
function buildOperationSummary(params: {
|
||||
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
|
||||
teamControlSummary?: TeamWorkspaceControlSummary | null;
|
||||
}) {
|
||||
if (params.teamControlSummary) {
|
||||
const affectedCount = params.teamControlSummary.affectedSessionIds.length;
|
||||
switch (params.teamControlSummary.action) {
|
||||
case "resume":
|
||||
return `最近一次恢复操作影响 ${affectedCount} 个 agent。`;
|
||||
case "close_completed":
|
||||
return `最近一次收尾操作关闭了 ${affectedCount} 位已完成成员。`;
|
||||
case "close":
|
||||
default:
|
||||
return `最近一次关闭操作影响 ${affectedCount} 个 agent。`;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.teamWaitSummary) {
|
||||
return params.teamWaitSummary.timedOut
|
||||
? `最近一次等待超时,仍有 ${params.teamWaitSummary.awaitedSessionIds.length} 位成员在推进。`
|
||||
: "最近一次等待已收到成员结果。";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TeamWorkbenchSummaryPanel({
|
||||
currentSessionId,
|
||||
currentSessionRuntimeStatus,
|
||||
currentSessionLatestTurnStatus,
|
||||
currentSessionQueuedTurnCount = 0,
|
||||
childSubagentSessions = [],
|
||||
subagentParentContext = null,
|
||||
liveRuntimeBySessionId = {},
|
||||
liveActivityBySessionId = {},
|
||||
teamWaitSummary = null,
|
||||
teamControlSummary = null,
|
||||
selectedTeamLabel,
|
||||
selectedTeamSummary,
|
||||
selectedTeamRoles = [],
|
||||
runtimeTeamState = null,
|
||||
}: TeamWorkbenchSummaryPanelProps) {
|
||||
const executionSummary = summarizeTeamWorkspaceExecution({
|
||||
currentSessionId,
|
||||
currentSessionRuntimeStatus,
|
||||
currentSessionLatestTurnStatus,
|
||||
currentSessionQueuedTurnCount,
|
||||
childSubagentSessions,
|
||||
subagentParentContext,
|
||||
liveRuntimeBySessionId,
|
||||
});
|
||||
const hasRealTeamGraph =
|
||||
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
|
||||
const runtimeFormationMeta = runtimeTeamState
|
||||
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
|
||||
: null;
|
||||
const runtimeTeamLabel =
|
||||
runtimeTeamState?.label?.trim() ||
|
||||
runtimeTeamState?.blueprint?.label?.trim() ||
|
||||
selectedTeamLabel?.trim() ||
|
||||
null;
|
||||
const runtimeSummaryText =
|
||||
runtimeTeamState?.summary?.trim() ||
|
||||
runtimeTeamState?.blueprint?.summary?.trim() ||
|
||||
selectedTeamSummary ||
|
||||
null;
|
||||
const operationSummary = buildOperationSummary({
|
||||
teamWaitSummary,
|
||||
teamControlSummary,
|
||||
});
|
||||
const latestActivity = Object.values(liveActivityBySessionId)
|
||||
.flat()
|
||||
.slice(0, 4);
|
||||
const displayRoleCount = hasRealTeamGraph
|
||||
? executionSummary.totalSessionCount
|
||||
: runtimeTeamState?.members.length ?? 0;
|
||||
const roleCards =
|
||||
runtimeTeamState?.members.length
|
||||
? runtimeTeamState.members.map((member) => ({
|
||||
id: member.id,
|
||||
label: member.label,
|
||||
roleKey: member.roleKey,
|
||||
profileId: member.profileId,
|
||||
summary: member.summary,
|
||||
skillIds: member.skillIds,
|
||||
statusMeta: resolveRuntimeMemberStatusMeta(member.status),
|
||||
}))
|
||||
: (selectedTeamRoles ?? []).map((role) => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
roleKey: role.roleKey,
|
||||
profileId: role.profileId,
|
||||
summary: role.summary,
|
||||
skillIds: role.skillIds ?? [],
|
||||
statusMeta: null,
|
||||
}));
|
||||
const summaryCards = [
|
||||
{
|
||||
label: "活跃 Agent",
|
||||
value: String(executionSummary.activeSessionCount),
|
||||
hint: executionSummary.hasActiveRuntime ? "正在协作" : "尚未运行",
|
||||
},
|
||||
{
|
||||
label: "运行中",
|
||||
value: String(executionSummary.runningSessionCount),
|
||||
hint: executionSummary.runningSessionCount > 0 ? "并发执行" : "暂无",
|
||||
},
|
||||
{
|
||||
label: "排队中",
|
||||
value: String(executionSummary.queuedSessionCount),
|
||||
hint: executionSummary.queuedSessionCount > 0 ? "等待调度" : "暂无",
|
||||
},
|
||||
{
|
||||
label: hasRealTeamGraph
|
||||
? "总会话"
|
||||
: runtimeTeamState
|
||||
? "当前成员"
|
||||
: "总会话",
|
||||
value: String(
|
||||
hasRealTeamGraph ? executionSummary.totalSessionCount : displayRoleCount,
|
||||
),
|
||||
hint: hasRealTeamGraph
|
||||
? "已进入团队图谱"
|
||||
: runtimeTeamState
|
||||
? "本轮编队"
|
||||
: "等待创建",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
<span>Team Workbench</span>
|
||||
{runtimeTeamLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
{runtimeTeamLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFormationMeta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-normal normal-case",
|
||||
runtimeFormationMeta.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{runtimeFormationMeta.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{executionSummary.statusTitle ||
|
||||
runtimeFormationMeta?.title ||
|
||||
"团队已就绪,等待主代理开始编排"}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">
|
||||
{runtimeTeamState?.status === "failed"
|
||||
? runtimeTeamState.errorMessage?.trim() ||
|
||||
"本轮 Team 准备失败,可继续在当前对话中推进。"
|
||||
: runtimeSummaryText ||
|
||||
"这里展示团队总览、运行密度和最近动态;详细协作过程在左侧主画布查看。"}
|
||||
</p>
|
||||
{!hasRealTeamGraph && runtimeTeamState ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 rounded-2xl border px-3 py-2 text-xs leading-5",
|
||||
runtimeFormationMeta?.badgeClassName ||
|
||||
"border border-slate-200 bg-slate-50 text-slate-700",
|
||||
)}
|
||||
>
|
||||
{runtimeTeamState.status === "forming"
|
||||
? "模型正在依据本轮任务准备 Team,真实成员加入后会自动切换到实时协作轨道。"
|
||||
: runtimeTeamState.status === "formed"
|
||||
? `已准备 ${runtimeTeamState.members.length} 个成员,当前等待系统分派真实成员进入协作。`
|
||||
: runtimeTeamState.errorMessage?.trim() ||
|
||||
"暂未成功准备本轮 Team。"}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasRealTeamGraph && !runtimeTeamState ? (
|
||||
<div className="mt-3 rounded-2xl border border-sky-100 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-700">
|
||||
尚未出现真实团队成员。开始分派成员后,左侧 Team 画布会自动切换为实时协作视图。
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-[20px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5"
|
||||
>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
{card.label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-slate-900">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{card.hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{roleCards.length > 0 ? (
|
||||
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>{runtimeTeamState ? "当前成员" : "角色分工"}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{roleCards.map((role) => (
|
||||
<div
|
||||
key={`team-workbench-role-${role.id}`}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{role.label}
|
||||
</span>
|
||||
{role.statusMeta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
role.statusMeta.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{role.statusMeta.label}
|
||||
</span>
|
||||
) : null}
|
||||
{role.roleKey ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{role.roleKey}
|
||||
</span>
|
||||
) : null}
|
||||
{role.profileId ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{role.profileId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
{role.summary}
|
||||
</div>
|
||||
{role.skillIds.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{role.skillIds.map((skillId) => (
|
||||
<span
|
||||
key={`${role.id}-${skillId}`}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
|
||||
>
|
||||
{skillId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{runtimeTeamState?.blueprint?.label ? (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
参考蓝图 Team:{runtimeTeamState.blueprint.label}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(operationSummary || latestActivity.length > 0) && (
|
||||
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>最近动态</span>
|
||||
</div>
|
||||
{operationSummary ? (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm leading-6 text-slate-700">
|
||||
{operationSummary}
|
||||
</div>
|
||||
) : null}
|
||||
{latestActivity.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestActivity.map((entry) => (
|
||||
<div
|
||||
key={`team-workbench-activity-${entry.id}`}
|
||||
className="rounded-2xl border border-slate-200 bg-white px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{entry.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
entry.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{entry.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
{entry.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">
|
||||
<div className="flex items-center gap-2 font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>交互说明</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
左侧主画布负责展示成员轨道、实时过程与细节;右侧用于总览、角色结构与快速理解当前协作状态。
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sky-700">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>这套布局会复用到图片、多文件和其他专用画布。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamWorkbenchSummaryPanel;
|
||||
@@ -125,7 +125,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team Workspace");
|
||||
expect(container.textContent).toContain("尚未创建真实 child session");
|
||||
expect(container.textContent).toContain("尚未出现真实团队成员");
|
||||
expect(container.textContent).toContain("查看详情");
|
||||
expect(container.textContent).not.toContain("spawn_agent");
|
||||
expect(container.textContent).not.toContain("Explorer 槽位");
|
||||
@@ -147,12 +147,58 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("等待真实子代理");
|
||||
expect(container.textContent).toContain("spawn_agent");
|
||||
expect(container.textContent).toContain("等待团队成员加入");
|
||||
expect(container.textContent).toContain("分派成员");
|
||||
expect(container.textContent).toContain("收起");
|
||||
});
|
||||
|
||||
it("父线程视角应展示 child session、最近过程并支持打开焦点会话", async () => {
|
||||
it("已选 Team 但尚无真实子会话时,应在主画布展示计划角色", async () => {
|
||||
const container = await renderBoard({
|
||||
shellVisible: true,
|
||||
childSubagentSessions: [],
|
||||
selectedTeamLabel: "临时修复 Team",
|
||||
selectedTeamSummary: "分析、执行、验证三段式推进。",
|
||||
selectedTeamRoles: [
|
||||
{
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题与影响范围。",
|
||||
profileId: "code-explorer",
|
||||
roleKey: "explorer",
|
||||
skillIds: ["repo-exploration"],
|
||||
},
|
||||
{
|
||||
id: "executor",
|
||||
label: "执行",
|
||||
summary: "负责完成改动并给出结果。",
|
||||
profileId: "code-executor",
|
||||
roleKey: "executor",
|
||||
skillIds: ["bounded-implementation"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("临时修复 Team");
|
||||
expect(container.textContent).toContain("2 个计划角色");
|
||||
|
||||
const expandButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("查看详情"),
|
||||
);
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("计划中的 Team 角色");
|
||||
expect(container.textContent).toContain("分析");
|
||||
expect(container.textContent).toContain("执行");
|
||||
expect(container.textContent).toContain("Role · explorer");
|
||||
expect(container.textContent).toContain("Profile · code-explorer");
|
||||
expect(container.textContent).toContain("Skill · repo-exploration");
|
||||
});
|
||||
|
||||
it("主会话视角应展示团队成员、最近过程并支持打开焦点会话", async () => {
|
||||
const onOpenSubagentSession = vi.fn();
|
||||
mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "child-2") {
|
||||
@@ -249,7 +295,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(container.textContent).toContain("Team Workspace");
|
||||
expect(container.textContent).toContain("研究员");
|
||||
expect(container.textContent).toContain("执行器");
|
||||
expect(container.textContent).toContain("2 个 child session 已加入");
|
||||
expect(container.textContent).toContain("2 位成员已加入");
|
||||
expect(container.textContent).toContain("代码分析员");
|
||||
expect(container.textContent).toContain("代码排障团队");
|
||||
expect(container.textContent).toContain("仓库探索");
|
||||
@@ -268,7 +314,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(container.textContent).toContain("已对 3 个来源完成去重校验。");
|
||||
|
||||
const openButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("打开会话"),
|
||||
(element) => element.textContent?.includes("查看对话"),
|
||||
);
|
||||
expect(openButton).toBeTruthy();
|
||||
|
||||
@@ -480,6 +526,60 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(summary?.textContent).toContain("整理竞品与数据来源");
|
||||
});
|
||||
|
||||
it("嵌入态真实 team 应支持收起并重新展开详情", async () => {
|
||||
const container = await renderBoard({
|
||||
embedded: true,
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-collapse-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
task_summary: "整理竞品与数据来源,补齐关键差异点与证据链。",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-detail-section"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
const collapseButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("收起详情"),
|
||||
);
|
||||
expect(collapseButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-detail-section"]'),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-compact-summary"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("紧凑视图");
|
||||
expect(container.textContent).toContain("展开详情");
|
||||
|
||||
const expandButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("展开详情"),
|
||||
);
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-detail-section"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("注入 live runtime props 后应立即投影状态与最近轨迹", async () => {
|
||||
mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) =>
|
||||
createSessionDetail(sessionId),
|
||||
@@ -600,7 +700,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(container.textContent).toContain("页面结构差异已提取完成。");
|
||||
});
|
||||
|
||||
it("选中运行中的子代理时应展示关闭操作,关闭后触发回调", async () => {
|
||||
it("选中运行中的子代理时应展示停止操作,停止后触发回调", async () => {
|
||||
const onCloseSubagentSession = vi.fn();
|
||||
const container = await renderBoard({
|
||||
childSubagentSessions: [
|
||||
@@ -619,8 +719,12 @@ describe("TeamWorkspaceBoard", () => {
|
||||
onCloseSubagentSession,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"停止会中断当前执行并保留会话,可稍后恢复。",
|
||||
);
|
||||
|
||||
const closeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("关闭子代理"),
|
||||
(element) => element.textContent?.includes("停止成员"),
|
||||
);
|
||||
expect(closeButton).toBeTruthy();
|
||||
|
||||
@@ -652,9 +756,12 @@ describe("TeamWorkspaceBoard", () => {
|
||||
});
|
||||
|
||||
const resumeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("恢复子代理"),
|
||||
(element) => element.textContent?.includes("恢复成员"),
|
||||
);
|
||||
expect(resumeButton).toBeTruthy();
|
||||
expect(container.textContent).toContain(
|
||||
"停止会中断当前执行并保留会话,可稍后恢复。",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
@@ -684,7 +791,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
});
|
||||
|
||||
const waitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("等待 30 秒"),
|
||||
(element) => element.textContent?.includes("等待结果 30 秒"),
|
||||
);
|
||||
expect(waitButton).toBeTruthy();
|
||||
|
||||
@@ -737,11 +844,11 @@ describe("TeamWorkspaceBoard", () => {
|
||||
onWaitActiveTeamSessions,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("等待任一活跃 agent");
|
||||
expect(container.textContent).toContain("2 个活跃 agent 可聚合 wait");
|
||||
expect(container.textContent).toContain("等待任一活跃成员");
|
||||
expect(container.textContent).toContain("2 位活跃成员可统一等待");
|
||||
|
||||
const waitAnyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("等待任一活跃 agent"),
|
||||
(element) => element.textContent?.includes("等待任一活跃成员"),
|
||||
);
|
||||
expect(waitAnyButton).toBeTruthy();
|
||||
|
||||
@@ -796,8 +903,8 @@ describe("TeamWorkspaceBoard", () => {
|
||||
);
|
||||
|
||||
expect(operations).toBeTruthy();
|
||||
expect(operations?.textContent).toContain("Team wait 命中");
|
||||
expect(operations?.textContent).toContain("最近一次 team wait 命中 执行器");
|
||||
expect(operations?.textContent).toContain("等待命中");
|
||||
expect(operations?.textContent).toContain("最近一次统一等待命中 执行器");
|
||||
expect(operations?.textContent).toContain("已进入已完成状态");
|
||||
expect(container.textContent).toContain("输出落地方案");
|
||||
});
|
||||
@@ -885,9 +992,9 @@ describe("TeamWorkspaceBoard", () => {
|
||||
);
|
||||
|
||||
expect(operations).toBeTruthy();
|
||||
expect(operations?.textContent).toContain("级联关闭");
|
||||
expect(operations?.textContent).toContain("级联停止");
|
||||
expect(operations?.textContent).toContain(
|
||||
"最近一次 close_agent 已级联关闭 2 个会话",
|
||||
"最近一次停止操作已级联停止 2 位成员",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -940,7 +1047,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(summary?.textContent).toContain("输出落地方案");
|
||||
|
||||
const controlEntry = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("级联关闭"),
|
||||
(element) => element.textContent?.includes("级联停止"),
|
||||
);
|
||||
expect(controlEntry).toBeTruthy();
|
||||
|
||||
@@ -997,12 +1104,12 @@ describe("TeamWorkspaceBoard", () => {
|
||||
onCloseCompletedTeamSessions,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("关闭已完成 agent");
|
||||
expect(container.textContent).toContain("2 个已完成 agent 可释放 slot");
|
||||
expect(container.textContent).toContain("清理已完成成员");
|
||||
expect(container.textContent).toContain("2 位已完成成员可清理");
|
||||
|
||||
const closeCompletedButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((element) => element.textContent?.includes("关闭已完成 agent"));
|
||||
).find((element) => element.textContent?.includes("清理已完成成员"));
|
||||
expect(closeCompletedButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -1058,11 +1165,11 @@ describe("TeamWorkspaceBoard", () => {
|
||||
});
|
||||
|
||||
const sendButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("发送补充任务"),
|
||||
(element) => element.textContent?.includes("发送补充说明"),
|
||||
);
|
||||
const interruptButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((element) => element.textContent?.includes("中断后发送"));
|
||||
).find((element) => element.textContent?.includes("中断并发送"));
|
||||
expect(sendButton).toBeTruthy();
|
||||
expect(interruptButton).toBeTruthy();
|
||||
|
||||
@@ -1127,4 +1234,107 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(header?.className).toContain("sticky");
|
||||
expect(header?.className).toContain("top-0");
|
||||
});
|
||||
|
||||
it("本轮 Team 准备中时,应在空 shell 展示组建状态", async () => {
|
||||
const container = await renderBoard({
|
||||
shellVisible: true,
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-forming-1",
|
||||
status: "forming",
|
||||
label: "排障 Team",
|
||||
summary: "围绕当前任务组织最轻量可用的运行时团队。",
|
||||
members: [],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [],
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("正在准备本轮 Team");
|
||||
expect(container.textContent).toContain("组建中");
|
||||
expect(container.textContent).toContain("Team · 排障 Team");
|
||||
expect(container.textContent).toContain("参考蓝图 · 代码排障团队");
|
||||
});
|
||||
|
||||
it("本轮 Team 已就绪时,应在无真实子会话下展示当前成员", async () => {
|
||||
const container = await renderBoard({
|
||||
shellVisible: true,
|
||||
defaultShellExpanded: true,
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-formed-1",
|
||||
status: "formed",
|
||||
label: "修复 Team",
|
||||
summary: "分析、执行、验证协作闭环。",
|
||||
members: [
|
||||
{
|
||||
id: "runtime-explorer",
|
||||
label: "分析",
|
||||
summary: "收敛问题边界并整理影响范围。",
|
||||
roleKey: "explorer",
|
||||
profileId: "code-explorer",
|
||||
skillIds: ["repo-exploration"],
|
||||
status: "planned",
|
||||
},
|
||||
{
|
||||
id: "runtime-executor",
|
||||
label: "执行",
|
||||
summary: "在边界内落地修复并汇报结果。",
|
||||
roleKey: "executor",
|
||||
profileId: "code-executor",
|
||||
skillIds: ["bounded-implementation"],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [
|
||||
{
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
summary: "先定位问题与影响面。",
|
||||
},
|
||||
],
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-runtime-formation"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-runtime-members"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("本轮 Team 已就绪");
|
||||
expect(container.textContent).toContain("分析");
|
||||
expect(container.textContent).toContain("执行");
|
||||
expect(container.textContent).toContain("Skill · repo-exploration");
|
||||
expect(container.textContent).toContain("参考蓝图角色");
|
||||
});
|
||||
|
||||
it("本轮 Team 准备失败时,应展示失败原因", async () => {
|
||||
const container = await renderBoard({
|
||||
shellVisible: true,
|
||||
defaultShellExpanded: true,
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-failed-1",
|
||||
status: "failed",
|
||||
label: "失败的 Team",
|
||||
summary: null,
|
||||
members: [],
|
||||
blueprint: null,
|
||||
errorMessage: "Provider 认证失败,无法生成 Team。",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team 生成失败");
|
||||
expect(container.textContent).toContain(
|
||||
"Provider 认证失败,无法生成 Team。",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronUp,
|
||||
Clock3,
|
||||
Loader2,
|
||||
PanelTop,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -23,17 +24,22 @@ import type {
|
||||
import { formatRelativeTime } from "@/lib/api/project";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getTeamPresetOption } from "../utils/teamPresets";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
import {
|
||||
applyLiveRuntimeState,
|
||||
buildTeamWorkspaceActivityEntryFromThreadItem,
|
||||
buildTeamWorkspaceSessionFingerprint,
|
||||
isTeamWorkspaceActiveStatus,
|
||||
isTeamWorkspaceTerminalStatus,
|
||||
mergeSessionActivityEntries,
|
||||
normalizeTeamWorkspaceRuntimeStatus,
|
||||
resolveRuntimeFormationStatusMeta,
|
||||
resolveRuntimeMemberStatusMeta,
|
||||
resolveTeamWorkspaceRuntimeStatusLabel,
|
||||
type TeamWorkspaceActivityEntry,
|
||||
type TeamWorkspaceControlSummary,
|
||||
type TeamWorkspaceLiveRuntimeState,
|
||||
type TeamWorkspaceRuntimeFormationState,
|
||||
type TeamWorkspaceWaitSummary,
|
||||
} from "../teamWorkspaceRuntime";
|
||||
|
||||
@@ -76,6 +82,10 @@ interface TeamWorkspaceBoardProps {
|
||||
onReturnToParentSession?: () => void | Promise<void>;
|
||||
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
|
||||
teamControlSummary?: TeamWorkspaceControlSummary | null;
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
selectedTeamRoles?: TeamRoleDefinition[] | null;
|
||||
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
interface TeamSessionCard {
|
||||
@@ -176,7 +186,7 @@ const STATUS_META: Record<
|
||||
dotClassName: "bg-rose-500",
|
||||
},
|
||||
closed: {
|
||||
label: "已关闭",
|
||||
label: "已停止",
|
||||
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600",
|
||||
cardClassName: "border-slate-200 bg-slate-50",
|
||||
dotClassName: "bg-slate-400",
|
||||
@@ -190,7 +200,7 @@ function resolveStatusMeta(status?: RuntimeStatus) {
|
||||
function resolveSessionTypeLabel(value?: string) {
|
||||
switch (value) {
|
||||
case "sub_agent":
|
||||
return "子代理";
|
||||
return "协作成员";
|
||||
case "fork":
|
||||
return "分支会话";
|
||||
case "user":
|
||||
@@ -443,21 +453,21 @@ function buildTeamWaitSummaryDisplay(
|
||||
} {
|
||||
if (summary.timedOut) {
|
||||
return {
|
||||
text: `最近一次 team wait 已超时,${summary.awaitedSessionIds.length} 个活跃 agent 仍在推进。`,
|
||||
text: `最近一次统一等待已超时,${summary.awaitedSessionIds.length} 位活跃成员仍在推进。`,
|
||||
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedName = summary.resolvedSessionId
|
||||
? sessionNameById.get(summary.resolvedSessionId) ?? summary.resolvedSessionId
|
||||
: "子代理";
|
||||
: "成员";
|
||||
const normalizedStatus = summary.resolvedStatus
|
||||
? normalizeTeamWorkspaceRuntimeStatus(summary.resolvedStatus)
|
||||
: undefined;
|
||||
const statusMeta = resolveStatusMeta(normalizedStatus);
|
||||
|
||||
return {
|
||||
text: `最近一次 team wait 命中 ${resolvedName},已进入${resolveTeamWorkspaceRuntimeStatusLabel(summary.resolvedStatus)}状态。`,
|
||||
text: `最近一次统一等待命中 ${resolvedName},已进入${resolveTeamWorkspaceRuntimeStatusLabel(summary.resolvedStatus)}状态。`,
|
||||
badgeClassName: statusMeta.badgeClassName,
|
||||
};
|
||||
}
|
||||
@@ -473,20 +483,20 @@ function buildTeamControlSummaryDisplay(
|
||||
const firstAffectedId = summary.affectedSessionIds[0];
|
||||
const firstAffectedName = firstAffectedId
|
||||
? sessionNameById.get(firstAffectedId) ?? firstAffectedId
|
||||
: "子代理";
|
||||
: "成员";
|
||||
|
||||
switch (summary.action) {
|
||||
case "resume":
|
||||
return {
|
||||
text:
|
||||
affectedCount > 1
|
||||
? `最近一次 resume_agent 已级联恢复 ${affectedCount} 个会话。`
|
||||
: `最近一次 resume_agent 已恢复 ${firstAffectedName}。`,
|
||||
? `最近一次恢复操作已级联恢复 ${affectedCount} 位成员。`
|
||||
: `最近一次恢复操作已恢复 ${firstAffectedName}。`,
|
||||
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
|
||||
};
|
||||
case "close_completed":
|
||||
return {
|
||||
text: `最近一次批量 close_agent 已关闭 ${affectedCount} 个会话。`,
|
||||
text: `最近一次批量清理已关闭 ${affectedCount} 位成员。`,
|
||||
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
|
||||
};
|
||||
case "close":
|
||||
@@ -494,8 +504,8 @@ function buildTeamControlSummaryDisplay(
|
||||
return {
|
||||
text:
|
||||
affectedCount > 1
|
||||
? `最近一次 close_agent 已级联关闭 ${affectedCount} 个会话。`
|
||||
: `最近一次 close_agent 已关闭 ${firstAffectedName}。`,
|
||||
? `最近一次停止操作已级联停止 ${affectedCount} 位成员。`
|
||||
: `最近一次停止操作已停止 ${firstAffectedName}。`,
|
||||
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
|
||||
};
|
||||
}
|
||||
@@ -515,7 +525,7 @@ function buildTeamOperationDisplayEntries(params: {
|
||||
);
|
||||
entries.push({
|
||||
id: `wait-${params.teamWaitSummary.updatedAt}`,
|
||||
title: params.teamWaitSummary.timedOut ? "Team wait 超时" : "Team wait 命中",
|
||||
title: params.teamWaitSummary.timedOut ? "等待超时" : "等待命中",
|
||||
detail: display.text,
|
||||
badgeClassName: display.badgeClassName,
|
||||
updatedAt: params.teamWaitSummary.updatedAt,
|
||||
@@ -538,7 +548,7 @@ function buildTeamOperationDisplayEntries(params: {
|
||||
return "批量关闭";
|
||||
case "close":
|
||||
default:
|
||||
return "级联关闭";
|
||||
return "级联停止";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -571,7 +581,7 @@ function buildCurrentChildSession(
|
||||
|
||||
return {
|
||||
id: currentSessionId,
|
||||
name: currentSessionName?.trim() || "当前子代理",
|
||||
name: currentSessionName?.trim() || "当前成员",
|
||||
runtimeStatus: currentSessionRuntimeStatus,
|
||||
taskSummary: subagentParentContext.task_summary,
|
||||
roleHint: subagentParentContext.role_hint,
|
||||
@@ -603,10 +613,10 @@ function buildOrchestratorSession(
|
||||
|
||||
return {
|
||||
id: currentSessionId,
|
||||
name: currentSessionName?.trim() || "主线程编排器",
|
||||
name: currentSessionName?.trim() || "主会话编排器",
|
||||
runtimeStatus: currentSessionRuntimeStatus,
|
||||
taskSummary:
|
||||
"当前主线程负责拆分任务、启动子代理、等待结果并在同一团队面板中汇总结论。",
|
||||
"当前主会话负责拆分任务、启动成员协作、等待结果并在同一团队面板中汇总结论。",
|
||||
roleHint: "orchestrator",
|
||||
sessionType: "user",
|
||||
isCurrent: true,
|
||||
@@ -647,9 +657,9 @@ function buildBoardHeadline(params: {
|
||||
return subagentParentContext?.parent_session_name?.trim() || "父会话团队";
|
||||
}
|
||||
if (!hasRealTeamGraph) {
|
||||
return "等待真实子代理";
|
||||
return "等待团队成员加入";
|
||||
}
|
||||
return totalTeamSessions > 0 ? `团队中有 ${totalTeamSessions} 个 agent` : "团队协作";
|
||||
return totalTeamSessions > 0 ? `团队中有 ${totalTeamSessions} 位成员` : "团队协作";
|
||||
}
|
||||
|
||||
function buildBoardHint(params: {
|
||||
@@ -660,10 +670,10 @@ function buildBoardHint(params: {
|
||||
const { hasRealTeamGraph, isChildSession, siblingCount } = params;
|
||||
|
||||
if (isChildSession) {
|
||||
return siblingCount > 0 ? `当前与 ${siblingCount} 个 sibling 同组协作` : "当前为唯一子代理";
|
||||
return siblingCount > 0 ? `当前与 ${siblingCount} 位同组成员协作` : "当前为唯一成员";
|
||||
}
|
||||
if (!hasRealTeamGraph) {
|
||||
return "只有出现真实 child session 才会展开团队视图";
|
||||
return "只有出现真实团队成员时才会展开团队视图";
|
||||
}
|
||||
return "仅在任务需要拆分时进入 team,不默认使用多代理";
|
||||
}
|
||||
@@ -676,15 +686,48 @@ function buildFallbackSummary(params: {
|
||||
const { hasRealTeamGraph, isChildSession, selectedSession } = params;
|
||||
|
||||
if (!hasRealTeamGraph) {
|
||||
return "尚未创建真实 child session。下一次 spawn_agent 成功后,这里会展示团队成员与最新状态。";
|
||||
return "尚未出现真实团队成员。开始分派成员后,这里会展示团队成员与最新状态。";
|
||||
}
|
||||
if (selectedSession?.sessionType === "user") {
|
||||
return "主线程负责拆分任务、分派团队成员,并在必要时等待结果后汇总。";
|
||||
return "主会话负责拆分任务、分派团队成员,并在必要时等待结果后汇总。";
|
||||
}
|
||||
if (isChildSession) {
|
||||
return "当前 agent 正在执行父线程分派的子任务,可在这里快速切换到 siblings 或返回父会话。";
|
||||
return "当前成员正在执行主会话分派的子任务,可在这里快速切换到同组成员或返回主会话。";
|
||||
}
|
||||
return "选择一位团队成员后,这里会展示它的任务摘要、运行状态与模型信息。";
|
||||
}
|
||||
|
||||
function buildRuntimeFormationHint(
|
||||
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
|
||||
) {
|
||||
switch (runtimeTeamState?.status) {
|
||||
case "forming":
|
||||
return "先准备本轮 Team,再等待真实团队成员接入实时轨道。";
|
||||
case "formed":
|
||||
return "本轮 Team 已就绪,真实团队成员加入后会自动接管实时协作。";
|
||||
case "failed":
|
||||
return "Team 准备失败,但主会话仍可继续执行或稍后重试。";
|
||||
default:
|
||||
return "只有出现真实团队成员时才会展开团队视图";
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuntimeFormationEmptyDetail(
|
||||
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
|
||||
) {
|
||||
switch (runtimeTeamState?.status) {
|
||||
case "forming":
|
||||
return "当前正在根据本轮任务准备 Team。完成后,这里会先展示本轮成员卡片,随后等待真实团队成员接入。";
|
||||
case "formed":
|
||||
return "本轮 Team 已就绪。当前画布先展示本轮成员蓝图,待系统真正分派成员并开始协作后,会自动切换到真实协作轨道。";
|
||||
case "failed":
|
||||
return (
|
||||
runtimeTeamState.errorMessage?.trim() ||
|
||||
"本轮 Team 准备失败,暂时无法展示本轮成员。"
|
||||
);
|
||||
default:
|
||||
return "当前还没有真实团队成员。系统开始分派成员后,详情区会切换为选中成员的摘要视图。";
|
||||
}
|
||||
return "选择一位 team agent 后,这里会展示它的任务摘要、运行状态与模型信息。";
|
||||
}
|
||||
|
||||
export function TeamWorkspaceBoard({
|
||||
@@ -712,6 +755,10 @@ export function TeamWorkspaceBoard({
|
||||
onReturnToParentSession,
|
||||
teamWaitSummary = null,
|
||||
teamControlSummary = null,
|
||||
selectedTeamLabel = null,
|
||||
selectedTeamSummary = null,
|
||||
selectedTeamRoles = [],
|
||||
runtimeTeamState = null,
|
||||
}: TeamWorkspaceBoardProps) {
|
||||
const isChildSession = Boolean(subagentParentContext);
|
||||
const [shellExpanded, setShellExpanded] = useState(defaultShellExpanded);
|
||||
@@ -857,6 +904,260 @@ export function TeamWorkspaceBoard({
|
||||
const hasRealTeamGraph = isChildSession || visibleSessions.length > 0;
|
||||
const isEmptyShellState =
|
||||
!isChildSession && shellVisible && visibleSessions.length === 0;
|
||||
const normalizedSelectedTeamLabel = selectedTeamLabel?.trim() || null;
|
||||
const normalizedSelectedTeamSummary = selectedTeamSummary?.trim() || null;
|
||||
const normalizedSelectedTeamRoles = (selectedTeamRoles ?? []).filter((role) =>
|
||||
role.label.trim(),
|
||||
);
|
||||
const runtimeFormationMeta = runtimeTeamState
|
||||
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
|
||||
: null;
|
||||
const runtimeFormationLabel =
|
||||
runtimeTeamState?.label?.trim() ||
|
||||
runtimeTeamState?.blueprint?.label?.trim() ||
|
||||
normalizedSelectedTeamLabel;
|
||||
const runtimeFormationSummary =
|
||||
runtimeTeamState?.summary?.trim() ||
|
||||
runtimeTeamState?.blueprint?.summary?.trim() ||
|
||||
normalizedSelectedTeamSummary;
|
||||
const runtimeMembers = runtimeTeamState?.members ?? [];
|
||||
const runtimeBlueprintRoles = runtimeTeamState?.blueprint?.roles ?? [];
|
||||
const hasRuntimeFormation = Boolean(runtimeTeamState);
|
||||
const hasSelectedTeamPlan =
|
||||
Boolean(normalizedSelectedTeamLabel) ||
|
||||
Boolean(normalizedSelectedTeamSummary) ||
|
||||
normalizedSelectedTeamRoles.length > 0;
|
||||
|
||||
const renderSelectedTeamPlanSummary = () => {
|
||||
if (!hasSelectedTeamPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
{normalizedSelectedTeamLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700">
|
||||
Team · {normalizedSelectedTeamLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{normalizedSelectedTeamRoles.length > 0 ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
{normalizedSelectedTeamRoles.length} 个计划角色
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedTeamPlanPanel = () => {
|
||||
if (!hasSelectedTeamPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[18px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>计划中的 Team 角色</span>
|
||||
{normalizedSelectedTeamLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
{normalizedSelectedTeamLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{normalizedSelectedTeamSummary ? (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{normalizedSelectedTeamSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{normalizedSelectedTeamRoles.length > 0 ? (
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
{normalizedSelectedTeamRoles.map((role) => (
|
||||
<div
|
||||
key={`planned-team-role-${role.id}`}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50 px-3.5 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{role.label}
|
||||
</span>
|
||||
{role.roleKey ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
Role · {role.roleKey}
|
||||
</span>
|
||||
) : null}
|
||||
{role.profileId ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
Profile · {role.profileId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{role.summary}
|
||||
</p>
|
||||
{role.skillIds?.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{role.skillIds.map((skillId) => (
|
||||
<span
|
||||
key={`${role.id}-${skillId}`}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
|
||||
>
|
||||
Skill · {skillId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRuntimeFormationSummary = () => {
|
||||
if (!hasRuntimeFormation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
{runtimeFormationLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700">
|
||||
Team · {runtimeFormationLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFormationMeta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 font-medium",
|
||||
runtimeFormationMeta.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{runtimeFormationMeta.label}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeMembers.length > 0 ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
{runtimeMembers.length} 个当前成员
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeTeamState?.blueprint?.label ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
参考蓝图 · {runtimeTeamState.blueprint.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRuntimeFormationPanel = () => {
|
||||
if (!runtimeTeamState || !runtimeFormationMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="team-workspace-runtime-formation"
|
||||
className="rounded-[18px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
<span>本轮编队</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-normal",
|
||||
runtimeFormationMeta.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{runtimeFormationMeta.label}
|
||||
</span>
|
||||
{runtimeFormationLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
{runtimeFormationLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{runtimeFormationMeta.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{runtimeTeamState.status === "failed"
|
||||
? runtimeTeamState.errorMessage?.trim() ||
|
||||
"本轮 Team 准备失败,暂时无法展示更多内容。"
|
||||
: runtimeFormationSummary ||
|
||||
"这里展示本轮 Team 规划结果,真实成员加入后会接管实时协作视图。"}
|
||||
</p>
|
||||
{runtimeTeamState.blueprint?.label ? (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
参考蓝图 Team:{runtimeTeamState.blueprint.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRuntimeMemberPanel = () => {
|
||||
if (runtimeMembers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 grid gap-3 xl:grid-cols-2"
|
||||
data-testid="team-workspace-runtime-members"
|
||||
>
|
||||
{runtimeMembers.map((member) => {
|
||||
const memberMeta = resolveRuntimeMemberStatusMeta(member.status);
|
||||
return (
|
||||
<div
|
||||
key={`runtime-team-member-${member.id}`}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50 px-3.5 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{member.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
memberMeta.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{memberMeta.label}
|
||||
</span>
|
||||
{member.roleKey ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
Role · {member.roleKey}
|
||||
</span>
|
||||
) : null}
|
||||
{member.profileId ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
Profile · {member.profileId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{member.summary}
|
||||
</p>
|
||||
{member.skillIds.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{member.skillIds.map((skillId) => (
|
||||
<span
|
||||
key={`${member.id}-${skillId}`}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
|
||||
>
|
||||
Skill · {skillId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const railSessions = useMemo(
|
||||
() =>
|
||||
@@ -1258,10 +1559,12 @@ export function TeamWorkspaceBoard({
|
||||
onSendSubagentInput &&
|
||||
selectedSession.id !== currentSessionId,
|
||||
);
|
||||
const canCloseSelectedSession = Boolean(
|
||||
const canStopSelectedSession = Boolean(
|
||||
selectedSession &&
|
||||
selectedSession.sessionType !== "user" &&
|
||||
selectedSession.runtimeStatus !== "closed" &&
|
||||
isTeamWorkspaceActiveStatus(
|
||||
selectedSession.runtimeStatus ?? selectedSession.latestTurnStatus,
|
||||
) &&
|
||||
onCloseSubagentSession,
|
||||
);
|
||||
const canResumeSelectedSession = Boolean(
|
||||
@@ -1456,15 +1759,20 @@ export function TeamWorkspaceBoard({
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
Team 运行时已就绪
|
||||
{runtimeFormationMeta?.title || "Team 运行时已就绪"}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
|
||||
尚未创建真实 child session
|
||||
{runtimeFormationMeta?.label || "尚未出现真实团队成员"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
这里先保持轻量状态条,避免遮挡消息区。只有真正创建子代理后,团队面板才需要展开。
|
||||
{hasRuntimeFormation
|
||||
? buildRuntimeFormationHint(runtimeTeamState)
|
||||
: "这里先保持轻量状态条,避免遮挡消息区。只有真正出现团队成员后,团队面板才需要展开。"}
|
||||
</p>
|
||||
{hasRuntimeFormation
|
||||
? renderRuntimeFormationSummary()
|
||||
: renderSelectedTeamPlanSummary()}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1482,17 +1790,30 @@ export function TeamWorkspaceBoard({
|
||||
|
||||
const selectedStatusMeta = resolveStatusMeta(selectedSession?.runtimeStatus);
|
||||
const detailVisible = isEmptyShellState ? shellExpanded : detailExpanded;
|
||||
const boardHeadline = buildBoardHeadline({
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
subagentParentContext,
|
||||
totalTeamSessions,
|
||||
});
|
||||
const boardHint = buildBoardHint({
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
siblingCount,
|
||||
});
|
||||
const detailToggleLabel = isEmptyShellState
|
||||
? detailVisible
|
||||
? "收起面板"
|
||||
: "展开详情"
|
||||
: detailVisible
|
||||
? "收起详情"
|
||||
: "展开详情";
|
||||
const boardHeadline =
|
||||
!hasRealTeamGraph && runtimeFormationMeta
|
||||
? runtimeFormationMeta.title
|
||||
: buildBoardHeadline({
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
subagentParentContext,
|
||||
totalTeamSessions,
|
||||
});
|
||||
const boardHint =
|
||||
!hasRealTeamGraph && hasRuntimeFormation
|
||||
? buildRuntimeFormationHint(runtimeTeamState)
|
||||
: buildBoardHint({
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
siblingCount,
|
||||
});
|
||||
const detailSummary =
|
||||
selectedSession?.taskSummary ||
|
||||
buildFallbackSummary({
|
||||
@@ -1528,7 +1849,7 @@ export function TeamWorkspaceBoard({
|
||||
const stackedRail = embedded;
|
||||
const boardShellClassName = cn(
|
||||
embedded
|
||||
? "pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-36px_rgba(15,23,42,0.18)]"
|
||||
? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-36px_rgba(15,23,42,0.18)]"
|
||||
: "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]",
|
||||
embedded ? "mx-0 mt-0" : "mx-3 mt-2",
|
||||
className,
|
||||
@@ -1540,7 +1861,7 @@ export function TeamWorkspaceBoard({
|
||||
: "border-b border-slate-200 bg-slate-50",
|
||||
);
|
||||
const boardBodyClassName = embedded
|
||||
? "min-h-0 flex-1 overflow-y-auto p-3 sm:p-4 space-y-3"
|
||||
? "min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4 space-y-3"
|
||||
: "p-3 sm:p-4";
|
||||
const railCardClassName = embedded
|
||||
? "pointer-events-auto space-y-4"
|
||||
@@ -1563,14 +1884,20 @@ export function TeamWorkspaceBoard({
|
||||
const timelineEntryClassName = embedded
|
||||
? "border-l-2 border-slate-200 pl-3"
|
||||
: "rounded-[14px] border border-slate-200 bg-white p-3";
|
||||
const railTitle = isChildSession ? "同组 Agent 轨道" : "Team Agent 轨道";
|
||||
const railTitle = isChildSession ? "同组成员轨道" : "团队成员轨道";
|
||||
const railSubtitle = isChildSession
|
||||
? siblingCount > 0
|
||||
? `当前 agent 与 ${siblingCount} 个 sibling 同组`
|
||||
: "当前只有一个真实子线程"
|
||||
? `当前成员与 ${siblingCount} 位同组成员协作`
|
||||
: "当前只有一位成员"
|
||||
: hasRealTeamGraph
|
||||
? `${visibleSessions.length} 个 child session 已加入`
|
||||
: "等待真实 child session";
|
||||
? `${visibleSessions.length} 位成员已加入`
|
||||
: runtimeTeamState?.status === "forming"
|
||||
? "正在准备本轮成员"
|
||||
: runtimeTeamState?.status === "formed"
|
||||
? `${runtimeMembers.length} 个成员待启动`
|
||||
: runtimeTeamState?.status === "failed"
|
||||
? "Team 准备失败"
|
||||
: "等待成员加入";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -1602,7 +1929,7 @@ export function TeamWorkspaceBoard({
|
||||
) : null}
|
||||
{!isChildSession && totalTeamSessions > 0 ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] text-sky-700">
|
||||
{totalTeamSessions} 个活跃 agent
|
||||
{totalTeamSessions} 位活跃成员
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1629,13 +1956,13 @@ export function TeamWorkspaceBoard({
|
||||
) : (
|
||||
<ChevronDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{detailVisible ? "收起" : "展开详情"}
|
||||
{detailToggleLabel}
|
||||
</Button>
|
||||
)}
|
||||
{isEmptyShellState
|
||||
? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
|
||||
尚未创建真实 child session
|
||||
{runtimeFormationMeta?.label || "尚未出现真实团队成员"}
|
||||
</span>
|
||||
)
|
||||
: Object.entries(statusSummary)
|
||||
@@ -1712,14 +2039,14 @@ export function TeamWorkspaceBoard({
|
||||
{pendingTeamAction === "wait_any" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{pendingTeamAction === "wait_any"
|
||||
? "等待中..."
|
||||
: "等待任一活跃 agent"}
|
||||
{pendingTeamAction === "wait_any"
|
||||
? "等待中..."
|
||||
: "等待任一活跃成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canWaitAnyActiveTeamSession ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
|
||||
{waitableTeamSessionIds.length} 个活跃 agent 可聚合 wait
|
||||
{waitableTeamSessionIds.length} 位活跃成员可统一等待
|
||||
</span>
|
||||
) : null}
|
||||
{canCloseCompletedTeamSessions ? (
|
||||
@@ -1734,14 +2061,14 @@ export function TeamWorkspaceBoard({
|
||||
{pendingTeamAction === "close_completed" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{pendingTeamAction === "close_completed"
|
||||
? "关闭中..."
|
||||
: "关闭已完成 agent"}
|
||||
{pendingTeamAction === "close_completed"
|
||||
? "关闭中..."
|
||||
: "清理已完成成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCloseCompletedTeamSessions ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
|
||||
{completedTeamSessionIds.length} 个已完成 agent 可释放 slot
|
||||
{completedTeamSessionIds.length} 位已完成成员可清理
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1959,15 +2286,39 @@ export function TeamWorkspaceBoard({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500">
|
||||
还没有真实 child session。下一次{" "}
|
||||
<span className="font-mono text-slate-700">spawn_agent</span>{" "}
|
||||
成功后,这里会生成可纵向浏览的 team 轨道。
|
||||
</div>
|
||||
<>
|
||||
{hasRuntimeFormation ? (
|
||||
<>
|
||||
{renderRuntimeFormationPanel()}
|
||||
{renderRuntimeMemberPanel()}
|
||||
</>
|
||||
) : (
|
||||
renderSelectedTeamPlanPanel()
|
||||
)}
|
||||
<div className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500">
|
||||
{runtimeTeamState?.status === "forming" ? (
|
||||
"模型正在准备本轮 Team,完成后这里会先展示本轮成员;后续真实成员加入时,再切换为可纵向浏览的团队轨道。"
|
||||
) : runtimeTeamState?.status === "formed" ? (
|
||||
<>
|
||||
本轮 Team 已就绪。系统开始分派成员后,这里会从当前编队过渡到真实团队轨道。
|
||||
</>
|
||||
) : runtimeTeamState?.status === "failed" ? (
|
||||
runtimeTeamState.errorMessage?.trim() ||
|
||||
"Team 准备失败,暂时还没有真实团队成员。"
|
||||
) : (
|
||||
<>
|
||||
还没有真实团队成员。系统开始分派成员后,这里会生成可纵向浏览的团队轨道。
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailVisible ? (
|
||||
<div className={cn("mt-3", detailCardClassName)}>
|
||||
<div
|
||||
className={cn("mt-3", detailCardClassName)}
|
||||
data-testid="team-workspace-detail-section"
|
||||
>
|
||||
{selectedSession ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -2014,10 +2365,10 @@ export function TeamWorkspaceBoard({
|
||||
{selectedActionPending &&
|
||||
pendingSessionAction?.action === "resume"
|
||||
? "恢复中..."
|
||||
: "恢复子代理"}
|
||||
: "恢复成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCloseSelectedSession ? (
|
||||
{canStopSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -2033,10 +2384,15 @@ export function TeamWorkspaceBoard({
|
||||
) : null}
|
||||
{selectedActionPending &&
|
||||
pendingSessionAction?.action === "close"
|
||||
? "关闭中..."
|
||||
: "关闭子代理"}
|
||||
? "停止中..."
|
||||
: "停止成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canStopSelectedSession || canResumeSelectedSession ? (
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
停止会中断当前执行并保留会话,可稍后恢复。
|
||||
</span>
|
||||
) : null}
|
||||
{canOpenSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2046,7 +2402,7 @@ export function TeamWorkspaceBoard({
|
||||
void onOpenSubagentSession?.(selectedSession.id)
|
||||
}
|
||||
>
|
||||
{isChildSession ? "切换" : "打开会话"}
|
||||
{isChildSession ? "切换" : "查看对话"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2125,10 +2481,10 @@ export function TeamWorkspaceBoard({
|
||||
<div className={secondarySectionClassName}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>Team 控制</span>
|
||||
<span>协作控制</span>
|
||||
{canWaitSelectedSession ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
可直接 wait_agent
|
||||
可直接等待结果
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2150,10 +2506,10 @@ export function TeamWorkspaceBoard({
|
||||
{selectedActionPending &&
|
||||
pendingSessionAction?.action === "wait"
|
||||
? "等待中..."
|
||||
: "等待 30 秒"}
|
||||
: "等待结果 30 秒"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
仅在主线程确实被该子代理结果阻塞时使用。
|
||||
仅在当前对话确实依赖该成员结果时使用。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -2166,7 +2522,7 @@ export function TeamWorkspaceBoard({
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="向该子代理补充新的指令、澄清约束,或要求它继续推进下一步。"
|
||||
placeholder="给该成员补充新的说明、澄清约束,或要求它继续推进下一步。"
|
||||
className="min-h-[96px] resize-y border-slate-200 bg-white text-sm text-slate-700 placeholder:text-slate-400"
|
||||
data-testid="team-workspace-send-input-textarea"
|
||||
/>
|
||||
@@ -2189,7 +2545,7 @@ export function TeamWorkspaceBoard({
|
||||
{selectedActionPending &&
|
||||
pendingSessionAction?.action === "send"
|
||||
? "发送中..."
|
||||
: "发送补充任务"}
|
||||
: "发送补充说明"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2212,10 +2568,10 @@ export function TeamWorkspaceBoard({
|
||||
pendingSessionAction?.action ===
|
||||
"interrupt_send"
|
||||
? "中断中..."
|
||||
: "中断后发送"}
|
||||
: "中断并发送"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
`send_input` 只针对该子代理,不影响其他团队成员。
|
||||
这条补充说明只会发送给当前成员,不影响其他团队成员。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2246,11 +2602,11 @@ export function TeamWorkspaceBoard({
|
||||
</p>
|
||||
) : selectedSessionActivityPreview?.status === "ready" ? (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
该子代理暂未产出可展示的最近过程。
|
||||
该成员暂未产出可展示的最近过程。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
正在同步该子代理的最近过程...
|
||||
正在同步该成员的最近过程...
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2317,21 +2673,71 @@ export function TeamWorkspaceBoard({
|
||||
<span>焦点详情</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-slate-900">
|
||||
等待真实子代理加入
|
||||
{runtimeFormationMeta?.title || "等待团队成员加入"}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
现在只有 team shell,没有真实 child session。主线程下一步应显式调用{" "}
|
||||
<span className="font-mono text-slate-700">spawn_agent</span>{" "}
|
||||
创建团队成员;创建成功后,详情区会切换为选中 agent 的摘要视图。
|
||||
{buildRuntimeFormationEmptyDetail(runtimeTeamState)}
|
||||
</p>
|
||||
{hasRuntimeFormation ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
{renderRuntimeFormationPanel()}
|
||||
{renderRuntimeMemberPanel()}
|
||||
{runtimeBlueprintRoles.length > 0 ? (
|
||||
<div className="rounded-[18px] border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
参考蓝图角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
{runtimeBlueprintRoles.map((role) => (
|
||||
<div
|
||||
key={`runtime-blueprint-role-${role.id}`}
|
||||
className="rounded-2xl border border-slate-200 bg-white px-3.5 py-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{role.label}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{role.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : hasSelectedTeamPlan ? (
|
||||
<div className="mt-4">{renderSelectedTeamPlanPanel()}</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
推荐链路:spawn_agent → wait_agent → send_input
|
||||
推荐流程:分派成员 → 等待结果 → 补充说明
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : railSessions.length > 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 rounded-[18px] border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-500",
|
||||
embedded ? "shadow-none" : "shadow-sm shadow-slate-950/5",
|
||||
)}
|
||||
data-testid="team-workspace-compact-summary"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<PanelTop className="h-3.5 w-3.5" />
|
||||
<span>紧凑视图</span>
|
||||
{selectedSession ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
焦点成员 · {selectedSession.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
已收起焦点详情、执行配置、协作控制与最近过程,当前仅保留团队轨道与 Team
|
||||
轨迹,便于在小屏幕下先浏览整体进度。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,8 +174,8 @@ describe("TeamWorkspaceDock", () => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("等待真实子代理");
|
||||
expect(document.body.textContent).toContain("spawn_agent");
|
||||
expect(document.body.textContent).toContain("等待团队成员加入");
|
||||
expect(document.body.textContent).toContain("分派团队成员");
|
||||
expect(document.body.textContent).toContain("不遮挡画布");
|
||||
expect(document.body.textContent).toContain("当前 Team:前端联调团队");
|
||||
expect(document.body.textContent).toContain("分析、实现、验证三段式推进。");
|
||||
@@ -260,6 +260,95 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(document.body.textContent).toContain("研究员");
|
||||
});
|
||||
|
||||
it("仅处于 Team 组建中时,不应自动展开面板,但应显示提醒", async () => {
|
||||
const { container } = await renderDock({
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-forming-1",
|
||||
status: "forming",
|
||||
label: "修复 Team",
|
||||
summary: "正在根据任务准备协作成员。",
|
||||
members: [],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [],
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看 Team · 组建中");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Team 已就绪但真实成员未出现时,不应自动展开面板", async () => {
|
||||
const { container } = await renderDock({
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-formed-2",
|
||||
status: "formed",
|
||||
label: "修复 Team",
|
||||
summary: "分析、执行、验证协作闭环。",
|
||||
members: [
|
||||
{
|
||||
id: "runtime-explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题边界。",
|
||||
roleKey: "explorer",
|
||||
profileId: "code-explorer",
|
||||
skillIds: ["repo-exploration"],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [],
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看 Team · 1");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Team 准备失败时,不应自动展开面板,但应保留提醒入口", async () => {
|
||||
const { container } = await renderDock({
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-failed-1",
|
||||
status: "failed",
|
||||
label: "修复 Team",
|
||||
summary: "分析、执行、验证协作闭环。",
|
||||
members: [],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [],
|
||||
},
|
||||
errorMessage: "Team 生成失败",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看 Team · 失败");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("真实 team 图谱折叠时,应显示查看入口和动态提示", async () => {
|
||||
const { container } = await renderDock({
|
||||
childSubagentSessions: [
|
||||
@@ -332,4 +421,183 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(getComputedStyle(panelInBody as HTMLElement).position).toBe("fixed");
|
||||
expect(getComputedStyle(panelInBody as HTMLElement).zIndex).toBe("10010");
|
||||
});
|
||||
|
||||
it("用户手动收起后,同一轮不应再次自动展开", async () => {
|
||||
const { container, render } = await renderDock({
|
||||
placement: "inline",
|
||||
});
|
||||
|
||||
await render({
|
||||
placement: "inline",
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toggleButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
);
|
||||
expect(toggleButton?.textContent).toContain("收起 Team");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
|
||||
await render({
|
||||
placement: "inline",
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "执行者",
|
||||
created_at: 1_710_000_010,
|
||||
updated_at: 1_710_000_120,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "提交修复方案",
|
||||
role_hint: "executor",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(toggleButton?.textContent).toContain("查看 Team · 2");
|
||||
});
|
||||
|
||||
it("切换会话后,新的真实成员出现应再次自动展开", async () => {
|
||||
const { container, render } = await renderDock({
|
||||
placement: "inline",
|
||||
});
|
||||
|
||||
await render({
|
||||
placement: "inline",
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toggleButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
await render({
|
||||
placement: "inline",
|
||||
currentSessionId: "parent-2",
|
||||
childSubagentSessions: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
|
||||
await render({
|
||||
placement: "inline",
|
||||
currentSessionId: "parent-2",
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-2",
|
||||
name: "执行者",
|
||||
created_at: 1_710_000_010,
|
||||
updated_at: 1_710_000_120,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "提交修复方案",
|
||||
role_hint: "executor",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("收起 Team");
|
||||
});
|
||||
|
||||
it("本轮 Team 已就绪时,应在空态 Dock 展示成员摘要", async () => {
|
||||
const { container } = await renderDock({
|
||||
runtimeTeamState: {
|
||||
requestId: "runtime-formed-1",
|
||||
status: "formed",
|
||||
label: "修复 Team",
|
||||
summary: "分析、执行、验证协作闭环。",
|
||||
members: [
|
||||
{
|
||||
id: "runtime-explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题边界。",
|
||||
roleKey: "explorer",
|
||||
profileId: "code-explorer",
|
||||
skillIds: ["repo-exploration"],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
blueprint: {
|
||||
label: "代码排障团队",
|
||||
summary: "分析、执行、验证三段式推进。",
|
||||
roles: [],
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const toggleButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
);
|
||||
expect(toggleButton?.textContent).toContain("查看 Team · 1");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
const detailToggle = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-selected-team-toggle"]',
|
||||
);
|
||||
expect(detailToggle).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
detailToggle?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("本轮 Team 已就绪");
|
||||
expect(document.body.textContent).toContain("修复 Team");
|
||||
expect(document.body.textContent).toContain("分析");
|
||||
expect(document.body.textContent).toContain("repo-exploration");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,13 @@ import type {
|
||||
TeamWorkspaceActivityEntry,
|
||||
TeamWorkspaceControlSummary,
|
||||
TeamWorkspaceLiveRuntimeState,
|
||||
TeamWorkspaceRuntimeFormationState,
|
||||
TeamWorkspaceWaitSummary,
|
||||
} from "../teamWorkspaceRuntime";
|
||||
import {
|
||||
resolveRuntimeFormationStatusMeta,
|
||||
resolveRuntimeMemberStatusMeta,
|
||||
} from "../teamWorkspaceRuntime";
|
||||
import { TeamWorkspaceBoard } from "./TeamWorkspaceBoard";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
|
||||
@@ -352,6 +357,7 @@ interface TeamWorkspaceDockProps {
|
||||
shellVisible?: boolean;
|
||||
withBottomOverlay?: boolean;
|
||||
placement?: "floating" | "inline";
|
||||
onActivateWorkbench?: () => void;
|
||||
currentSessionId?: string | null;
|
||||
currentSessionName?: string | null;
|
||||
currentSessionRuntimeStatus?: AsterSubagentSessionInfo["runtime_status"];
|
||||
@@ -387,6 +393,7 @@ interface TeamWorkspaceDockProps {
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
selectedTeamRoles?: TeamRoleDefinition[] | null;
|
||||
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
interface InlinePanelLayout {
|
||||
@@ -400,6 +407,7 @@ export function TeamWorkspaceDock({
|
||||
shellVisible = false,
|
||||
withBottomOverlay = false,
|
||||
placement = "floating",
|
||||
onActivateWorkbench,
|
||||
currentSessionId,
|
||||
currentSessionName,
|
||||
currentSessionRuntimeStatus,
|
||||
@@ -423,27 +431,54 @@ export function TeamWorkspaceDock({
|
||||
selectedTeamLabel,
|
||||
selectedTeamSummary,
|
||||
selectedTeamRoles = [],
|
||||
runtimeTeamState = null,
|
||||
}: TeamWorkspaceDockProps) {
|
||||
const hasRealTeamGraph =
|
||||
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
|
||||
const hasRuntimeFormation = Boolean(runtimeTeamState);
|
||||
const runtimeFormationMeta = runtimeTeamState
|
||||
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
|
||||
: null;
|
||||
const runtimeTeamLabel =
|
||||
runtimeTeamState?.label?.trim() ||
|
||||
runtimeTeamState?.blueprint?.label?.trim() ||
|
||||
selectedTeamLabel?.trim() ||
|
||||
null;
|
||||
const runtimeTeamSummary =
|
||||
runtimeTeamState?.summary?.trim() ||
|
||||
runtimeTeamState?.blueprint?.summary?.trim() ||
|
||||
selectedTeamSummary?.trim() ||
|
||||
null;
|
||||
const [expanded, setExpanded] = useState(
|
||||
() => placement === "inline" && hasRealTeamGraph,
|
||||
);
|
||||
const [userDismissedAutoExpand, setUserDismissedAutoExpand] = useState(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const previousSessionIdRef = useRef<string | null>(currentSessionId ?? null);
|
||||
const previousHasRealGraphRef = useRef(false);
|
||||
const previousPlacementRef = useRef(placement);
|
||||
const toggleRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [inlinePanelLayout, setInlinePanelLayout] =
|
||||
useState<InlinePanelLayout | null>(null);
|
||||
const isCompact = !hasRealTeamGraph;
|
||||
const showAttentionCue = hasRealTeamGraph && !expanded;
|
||||
const isCompact = !hasRealTeamGraph && !hasRuntimeFormation;
|
||||
const showAttentionCue =
|
||||
!expanded &&
|
||||
(hasRealTeamGraph ||
|
||||
runtimeTeamState?.status === "forming" ||
|
||||
runtimeTeamState?.status === "formed" ||
|
||||
runtimeTeamState?.status === "failed");
|
||||
const dockCount = subagentParentContext
|
||||
? (subagentParentContext.sibling_subagent_sessions?.length ?? 0) + 1
|
||||
: childSubagentSessions.length;
|
||||
const shouldPortalPanel = expanded;
|
||||
const [teamDetailExpanded, setTeamDetailExpanded] = useState(false);
|
||||
const hasSelectedTeamDetails =
|
||||
Boolean(selectedTeamSummary?.trim()) || (selectedTeamRoles?.length ?? 0) > 0;
|
||||
Boolean(runtimeTeamSummary) ||
|
||||
(runtimeTeamState?.members.length ?? 0) > 0 ||
|
||||
Boolean(runtimeTeamState?.blueprint?.summary?.trim()) ||
|
||||
(runtimeTeamState?.blueprint?.roles.length ?? 0) > 0 ||
|
||||
Boolean(selectedTeamSummary?.trim()) ||
|
||||
(selectedTeamRoles?.length ?? 0) > 0;
|
||||
const toggleLabel = useMemo(() => {
|
||||
if (expanded) {
|
||||
return "收起 Team";
|
||||
@@ -451,38 +486,70 @@ export function TeamWorkspaceDock({
|
||||
if (hasRealTeamGraph) {
|
||||
return `查看 Team · ${dockCount}`;
|
||||
}
|
||||
if (runtimeTeamState?.status === "forming") {
|
||||
return "查看 Team · 组建中";
|
||||
}
|
||||
if (runtimeTeamState?.status === "formed") {
|
||||
return `查看 Team · ${runtimeTeamState.members.length}`;
|
||||
}
|
||||
if (runtimeTeamState?.status === "failed") {
|
||||
return "查看 Team · 失败";
|
||||
}
|
||||
return "Team";
|
||||
}, [dockCount, expanded, hasRealTeamGraph]);
|
||||
}, [dockCount, expanded, hasRealTeamGraph, runtimeTeamState]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSessionId = currentSessionId ?? null;
|
||||
|
||||
if (!hasInitializedRef.current) {
|
||||
if (placement === "inline" && hasRealTeamGraph) {
|
||||
setExpanded(true);
|
||||
}
|
||||
previousSessionIdRef.current = normalizedSessionId;
|
||||
previousHasRealGraphRef.current = hasRealTeamGraph;
|
||||
previousPlacementRef.current = placement;
|
||||
hasInitializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousSessionIdRef.current !== normalizedSessionId) {
|
||||
previousSessionIdRef.current = normalizedSessionId;
|
||||
previousHasRealGraphRef.current = hasRealTeamGraph;
|
||||
previousPlacementRef.current = placement;
|
||||
setUserDismissedAutoExpand(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const graphAppeared =
|
||||
!previousHasRealGraphRef.current && hasRealTeamGraph;
|
||||
const graphCleared =
|
||||
previousHasRealGraphRef.current && !hasRealTeamGraph;
|
||||
|
||||
if (
|
||||
previousPlacementRef.current !== placement &&
|
||||
placement === "inline" &&
|
||||
hasRealTeamGraph
|
||||
hasRealTeamGraph &&
|
||||
!userDismissedAutoExpand
|
||||
) {
|
||||
setExpanded(true);
|
||||
previousSessionIdRef.current = normalizedSessionId;
|
||||
previousHasRealGraphRef.current = hasRealTeamGraph;
|
||||
previousPlacementRef.current = placement;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previousHasRealGraphRef.current && hasRealTeamGraph) {
|
||||
if (graphCleared) {
|
||||
setUserDismissedAutoExpand(false);
|
||||
}
|
||||
|
||||
if (graphAppeared && !userDismissedAutoExpand) {
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = normalizedSessionId;
|
||||
previousHasRealGraphRef.current = hasRealTeamGraph;
|
||||
previousPlacementRef.current = placement;
|
||||
}, [hasRealTeamGraph, placement]);
|
||||
}, [currentSessionId, hasRealTeamGraph, placement, userDismissedAutoExpand]);
|
||||
|
||||
const updateInlinePanelLayout = useCallback(() => {
|
||||
if (
|
||||
@@ -536,6 +603,8 @@ export function TeamWorkspaceDock({
|
||||
return null;
|
||||
}
|
||||
|
||||
const launcherOnly = typeof onActivateWorkbench === "function";
|
||||
|
||||
const panelContent = hasRealTeamGraph ? (
|
||||
<TeamWorkspaceBoard
|
||||
embedded={true}
|
||||
@@ -557,11 +626,15 @@ export function TeamWorkspaceDock({
|
||||
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
|
||||
onCloseSubagentSession={onCloseSubagentSession}
|
||||
onResumeSubagentSession={onResumeSubagentSession}
|
||||
onOpenSubagentSession={onOpenSubagentSession}
|
||||
onReturnToParentSession={onReturnToParentSession}
|
||||
teamWaitSummary={teamWaitSummary}
|
||||
teamControlSummary={teamControlSummary}
|
||||
/>
|
||||
onOpenSubagentSession={onOpenSubagentSession}
|
||||
onReturnToParentSession={onReturnToParentSession}
|
||||
teamWaitSummary={teamWaitSummary}
|
||||
teamControlSummary={teamControlSummary}
|
||||
selectedTeamLabel={selectedTeamLabel}
|
||||
selectedTeamSummary={selectedTeamSummary}
|
||||
selectedTeamRoles={selectedTeamRoles}
|
||||
runtimeTeamState={runtimeTeamState}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateCard data-testid="team-workspace-empty-card" role="status">
|
||||
<EmptyStateBody>
|
||||
@@ -571,13 +644,28 @@ export function TeamWorkspaceDock({
|
||||
<span>Team 已启用</span>
|
||||
</span>
|
||||
<EmptyStateBadge $tone="success">实时订阅</EmptyStateBadge>
|
||||
{runtimeFormationMeta ? (
|
||||
<EmptyStateBadge>{runtimeFormationMeta.label}</EmptyStateBadge>
|
||||
) : null}
|
||||
</EmptyStateEyebrow>
|
||||
<EmptyStateTitle>等待真实子代理</EmptyStateTitle>
|
||||
<EmptyStateTitle>
|
||||
{runtimeFormationMeta?.title || "等待团队成员加入"}
|
||||
</EmptyStateTitle>
|
||||
<EmptyStateDescription>
|
||||
模型首次成功调用 <span className="font-mono text-slate-700">spawn_agent</span>{" "}
|
||||
后,这里会自动切换到真实团队工作台。
|
||||
{runtimeTeamState?.status === "forming"
|
||||
? "系统正在根据本轮任务准备成员,真实团队成员加入后会自动切换到完整团队工作台。"
|
||||
: runtimeTeamState?.status === "formed"
|
||||
? `已准备 ${runtimeTeamState.members.length} 个成员,后续真实成员会接管实时协作轨道。`
|
||||
: runtimeTeamState?.status === "failed"
|
||||
? runtimeTeamState.errorMessage?.trim() ||
|
||||
"本轮 Team 准备失败,你仍然可以继续在当前对话中执行。"
|
||||
: (
|
||||
<>
|
||||
当系统开始真正分派团队成员后,这里会自动切换到真实团队工作台。
|
||||
</>
|
||||
)}
|
||||
</EmptyStateDescription>
|
||||
{selectedTeamLabel && hasSelectedTeamDetails ? (
|
||||
{runtimeTeamLabel && hasSelectedTeamDetails ? (
|
||||
<EmptyStateDetailCard>
|
||||
<EmptyStateDetailToggle
|
||||
type="button"
|
||||
@@ -586,9 +674,11 @@ export function TeamWorkspaceDock({
|
||||
data-testid="team-workspace-selected-team-toggle"
|
||||
>
|
||||
<div>
|
||||
<EmptyStateDetailTitle>{selectedTeamLabel}</EmptyStateDetailTitle>
|
||||
<EmptyStateDetailTitle>{runtimeTeamLabel}</EmptyStateDetailTitle>
|
||||
<EmptyStateDetailHint>
|
||||
查看当前 Team 配置与角色分工
|
||||
{runtimeTeamState
|
||||
? "查看本轮成员与参考蓝图"
|
||||
: "查看当前 Team 配置与角色分工"}
|
||||
</EmptyStateDetailHint>
|
||||
</div>
|
||||
{teamDetailExpanded ? (
|
||||
@@ -599,30 +689,88 @@ export function TeamWorkspaceDock({
|
||||
</EmptyStateDetailToggle>
|
||||
{teamDetailExpanded ? (
|
||||
<EmptyStateRoleList data-testid="team-workspace-selected-team-detail">
|
||||
{selectedTeamSummary ? (
|
||||
{runtimeTeamSummary ? (
|
||||
<EmptyStateRoleItem>
|
||||
<EmptyStateRoleName>Team 摘要</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>{selectedTeamSummary}</EmptyStateRoleSummary>
|
||||
<EmptyStateRoleName>
|
||||
{runtimeTeamState ? "本轮摘要" : "Team 摘要"}
|
||||
</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>{runtimeTeamSummary}</EmptyStateRoleSummary>
|
||||
</EmptyStateRoleItem>
|
||||
) : null}
|
||||
{selectedTeamRoles?.map((role) => (
|
||||
<EmptyStateRoleItem key={`dock-role-${role.id}`}>
|
||||
<EmptyStateRoleName>{role.label}</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>{role.summary}</EmptyStateRoleSummary>
|
||||
{runtimeTeamState?.members.map((member) => {
|
||||
const memberStatusMeta = resolveRuntimeMemberStatusMeta(
|
||||
member.status,
|
||||
);
|
||||
return (
|
||||
<EmptyStateRoleItem key={`dock-runtime-role-${member.id}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EmptyStateRoleName>{member.label}</EmptyStateRoleName>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${memberStatusMeta.badgeClassName}`}
|
||||
>
|
||||
{memberStatusMeta.label}
|
||||
</span>
|
||||
{member.roleKey ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{member.roleKey}
|
||||
</span>
|
||||
) : null}
|
||||
{member.profileId ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{member.profileId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<EmptyStateRoleSummary>{member.summary}</EmptyStateRoleSummary>
|
||||
{member.skillIds.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{member.skillIds.map((skillId) => (
|
||||
<span
|
||||
key={`${member.id}-${skillId}`}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
|
||||
>
|
||||
{skillId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</EmptyStateRoleItem>
|
||||
);
|
||||
})}
|
||||
{!runtimeTeamState
|
||||
? selectedTeamRoles?.map((role) => (
|
||||
<EmptyStateRoleItem key={`dock-role-${role.id}`}>
|
||||
<EmptyStateRoleName>{role.label}</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>{role.summary}</EmptyStateRoleSummary>
|
||||
</EmptyStateRoleItem>
|
||||
))
|
||||
: null}
|
||||
{runtimeTeamState?.blueprint?.label ? (
|
||||
<EmptyStateRoleItem>
|
||||
<EmptyStateRoleName>
|
||||
参考蓝图 · {runtimeTeamState.blueprint.label}
|
||||
</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>
|
||||
{runtimeTeamState.blueprint.summary ||
|
||||
"本轮 Team 参考了当前蓝图 Team 的角色偏好。"}
|
||||
</EmptyStateRoleSummary>
|
||||
</EmptyStateRoleItem>
|
||||
))}
|
||||
) : null}
|
||||
</EmptyStateRoleList>
|
||||
) : null}
|
||||
</EmptyStateDetailCard>
|
||||
) : null}
|
||||
<EmptyStateFooter>
|
||||
{selectedTeamLabel ? (
|
||||
{runtimeTeamLabel ? (
|
||||
<EmptyStateBadge data-testid="team-workspace-selected-team">
|
||||
当前 Team:{selectedTeamLabel}
|
||||
当前 Team:{runtimeTeamLabel}
|
||||
</EmptyStateBadge>
|
||||
) : null}
|
||||
{selectedTeamSummary ? (
|
||||
<EmptyStateBadge>{selectedTeamSummary}</EmptyStateBadge>
|
||||
{runtimeTeamSummary ? (
|
||||
<EmptyStateBadge>{runtimeTeamSummary}</EmptyStateBadge>
|
||||
) : null}
|
||||
{runtimeTeamState?.members.length ? (
|
||||
<EmptyStateBadge>{runtimeTeamState.members.length} 个成员</EmptyStateBadge>
|
||||
) : null}
|
||||
<EmptyStateBadge>不遮挡画布</EmptyStateBadge>
|
||||
</EmptyStateFooter>
|
||||
@@ -656,17 +804,38 @@ export function TeamWorkspaceDock({
|
||||
$withBottomOverlay={withBottomOverlay}
|
||||
data-testid="team-workspace-dock"
|
||||
>
|
||||
{expanded && !shouldPortalPanel ? panelNode : null}
|
||||
{!launcherOnly && expanded && !shouldPortalPanel ? panelNode : null}
|
||||
<DockToggle
|
||||
type="button"
|
||||
data-testid="team-workspace-dock-toggle"
|
||||
ref={toggleRef}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "收起 Team Workspace" : "展开 Team Workspace"}
|
||||
aria-expanded={launcherOnly ? false : expanded}
|
||||
aria-label={
|
||||
launcherOnly
|
||||
? "打开 Team Workbench"
|
||||
: expanded
|
||||
? "收起 Team Workspace"
|
||||
: "展开 Team Workspace"
|
||||
}
|
||||
$active={hasRealTeamGraph}
|
||||
$expanded={expanded}
|
||||
$attention={showAttentionCue}
|
||||
onClick={() => setExpanded((previous) => !previous)}
|
||||
onClick={() => {
|
||||
if (launcherOnly) {
|
||||
onActivateWorkbench?.();
|
||||
return;
|
||||
}
|
||||
setExpanded((previous) => {
|
||||
const nextExpanded = !previous;
|
||||
if (previous && !nextExpanded) {
|
||||
setUserDismissedAutoExpand(true);
|
||||
}
|
||||
if (!previous && nextExpanded) {
|
||||
setUserDismissedAutoExpand(false);
|
||||
}
|
||||
return nextExpanded;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DockIconShell $attention={showAttentionCue}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
@@ -681,7 +850,8 @@ export function TeamWorkspaceDock({
|
||||
{toggleLabel}
|
||||
</span>
|
||||
</DockToggle>
|
||||
{expanded &&
|
||||
{!launcherOnly &&
|
||||
expanded &&
|
||||
shouldPortalPanel &&
|
||||
inlinePanelLayout &&
|
||||
typeof document !== "undefined"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeWorkbenchEntryPromptAccessory } from "./ThemeWorkbenchEntryPromptAccessory";
|
||||
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
|
||||
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderAccessory(
|
||||
props?: Partial<ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const defaultPrompt: ThemeWorkbenchEntryPromptState = {
|
||||
kind: "initial_prompt",
|
||||
signature: "dispatch-1",
|
||||
title: "已恢复待执行创作意图",
|
||||
description: "进入页面后不会自动开始生成,确认后再继续。",
|
||||
actionLabel: "继续生成",
|
||||
prompt: "请先生成主稿",
|
||||
};
|
||||
const defaultProps: ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory> =
|
||||
{
|
||||
prompt: defaultPrompt,
|
||||
onRestart: vi.fn(),
|
||||
onContinue: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ThemeWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return {
|
||||
container,
|
||||
props: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ThemeWorkbenchEntryPromptAccessory", () => {
|
||||
it("应渲染提示文案与操作按钮", () => {
|
||||
const { container } = renderAccessory();
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-entry-prompt"]')
|
||||
?.textContent,
|
||||
).toContain("已恢复待执行创作意图");
|
||||
expect(container.textContent).toContain("进入页面后不会自动开始生成");
|
||||
expect(container.textContent).toContain("继续生成");
|
||||
expect(container.textContent).toContain("重新开始");
|
||||
});
|
||||
|
||||
it("应分发继续与重启动作", async () => {
|
||||
const onRestart = vi.fn();
|
||||
const onContinue = vi.fn(async () => undefined);
|
||||
const { container } = renderAccessory({
|
||||
onRestart,
|
||||
onContinue,
|
||||
});
|
||||
|
||||
const restartButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="theme-workbench-entry-restart"]',
|
||||
);
|
||||
const continueButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="theme-workbench-entry-continue"]',
|
||||
);
|
||||
|
||||
if (!restartButton || !continueButton) {
|
||||
throw new Error("未找到主题工作台入口提示操作按钮");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
restartButton.click();
|
||||
});
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
continueButton.click();
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { memo } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import styled from "styled-components";
|
||||
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
|
||||
|
||||
interface ThemeWorkbenchEntryPromptAccessoryProps {
|
||||
prompt: ThemeWorkbenchEntryPromptState;
|
||||
onRestart: () => void;
|
||||
onContinue: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const ThemeWorkbenchEntryPromptCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: min(360px, calc(100vw - 48px));
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.92);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.98) 0%,
|
||||
rgba(239, 246, 255, 0.96) 100%
|
||||
);
|
||||
color: #0f172a;
|
||||
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptTitleWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptTitle = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptDescription = styled.span`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchEntryPromptButton = styled.button<{
|
||||
$variant?: "primary" | "ghost";
|
||||
}>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 88px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid
|
||||
${({ $variant }) =>
|
||||
$variant === "ghost"
|
||||
? "rgba(191, 219, 254, 0.92)"
|
||||
: "rgba(59, 130, 246, 0.94)"};
|
||||
background: ${({ $variant }) =>
|
||||
$variant === "ghost"
|
||||
? "rgba(255, 255, 255, 0.92)"
|
||||
: "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"};
|
||||
color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46);
|
||||
background: ${({ $variant }) =>
|
||||
$variant === "ghost"
|
||||
? "rgba(239, 246, 255, 0.98)"
|
||||
: "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ThemeWorkbenchEntryPromptAccessory = memo(
|
||||
function ThemeWorkbenchEntryPromptAccessory({
|
||||
prompt,
|
||||
onRestart,
|
||||
onContinue,
|
||||
}: ThemeWorkbenchEntryPromptAccessoryProps) {
|
||||
return (
|
||||
<ThemeWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
|
||||
<ThemeWorkbenchEntryPromptHeader>
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
|
||||
<ThemeWorkbenchEntryPromptTitleWrap>
|
||||
<ThemeWorkbenchEntryPromptTitle>
|
||||
{prompt.title}
|
||||
</ThemeWorkbenchEntryPromptTitle>
|
||||
<ThemeWorkbenchEntryPromptDescription>
|
||||
{prompt.description}
|
||||
</ThemeWorkbenchEntryPromptDescription>
|
||||
</ThemeWorkbenchEntryPromptTitleWrap>
|
||||
</ThemeWorkbenchEntryPromptHeader>
|
||||
<ThemeWorkbenchEntryPromptActions>
|
||||
<ThemeWorkbenchEntryPromptButton
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
data-testid="theme-workbench-entry-restart"
|
||||
onClick={onRestart}
|
||||
>
|
||||
重新开始
|
||||
</ThemeWorkbenchEntryPromptButton>
|
||||
<ThemeWorkbenchEntryPromptButton
|
||||
type="button"
|
||||
data-testid="theme-workbench-entry-continue"
|
||||
onClick={() => {
|
||||
void onContinue();
|
||||
}}
|
||||
>
|
||||
{prompt.actionLabel}
|
||||
</ThemeWorkbenchEntryPromptButton>
|
||||
</ThemeWorkbenchEntryPromptActions>
|
||||
</ThemeWorkbenchEntryPromptCard>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatToolPreferences } from "./utils/chatToolPreferences";
|
||||
import { resolveHomeShellWorkspaceEntry } from "./homeShellEntry";
|
||||
|
||||
const defaultToolPreferences: ChatToolPreferences = {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: false,
|
||||
};
|
||||
|
||||
describe("homeShellEntry", () => {
|
||||
it("缺少项目且不是浏览器协助时应拒绝进入", () => {
|
||||
expect(
|
||||
resolveHomeShellWorkspaceEntry({
|
||||
projectId: null,
|
||||
activeTheme: "general",
|
||||
creationMode: "guided",
|
||||
defaultToolPreferences,
|
||||
payload: {
|
||||
prompt: "帮我整理方案",
|
||||
},
|
||||
now: () => 123,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "missing_project",
|
||||
});
|
||||
});
|
||||
|
||||
it("没有 prompt 和图片时应拒绝空进入", () => {
|
||||
expect(
|
||||
resolveHomeShellWorkspaceEntry({
|
||||
projectId: "project-1",
|
||||
activeTheme: "general",
|
||||
creationMode: "guided",
|
||||
defaultToolPreferences,
|
||||
payload: {},
|
||||
now: () => 123,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "empty_payload",
|
||||
});
|
||||
});
|
||||
|
||||
it("应生成导航参数与工作区 bootstrap", () => {
|
||||
expect(
|
||||
resolveHomeShellWorkspaceEntry({
|
||||
projectId: "project-1",
|
||||
activeTheme: "general",
|
||||
creationMode: "guided",
|
||||
defaultToolPreferences,
|
||||
payload: {
|
||||
prompt: "请起草一版首稿",
|
||||
themeOverride: "social-media",
|
||||
},
|
||||
now: () => 456,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
toolPreferences: defaultToolPreferences,
|
||||
targetTheme: "social-media",
|
||||
nextNewChatAt: 456,
|
||||
navigationParams: {
|
||||
agentEntry: "claw",
|
||||
immersiveHome: false,
|
||||
projectId: "project-1",
|
||||
theme: "social-media",
|
||||
initialCreationMode: "guided",
|
||||
initialUserPrompt: "请起草一版首稿",
|
||||
initialUserImages: undefined,
|
||||
openBrowserAssistOnMount: undefined,
|
||||
newChatAt: 456,
|
||||
lockTheme: false,
|
||||
},
|
||||
workspaceBootstrap: {
|
||||
projectId: "project-1",
|
||||
initialUserPrompt: "请起草一版首稿",
|
||||
initialUserImages: undefined,
|
||||
theme: "social-media",
|
||||
initialCreationMode: "guided",
|
||||
openBrowserAssistOnMount: undefined,
|
||||
newChatAt: 456,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("浏览器协助入口允许无项目直接进入", () => {
|
||||
expect(
|
||||
resolveHomeShellWorkspaceEntry({
|
||||
projectId: null,
|
||||
activeTheme: "general",
|
||||
creationMode: "guided",
|
||||
defaultToolPreferences,
|
||||
payload: {
|
||||
prompt: "",
|
||||
openBrowserAssistOnMount: true,
|
||||
},
|
||||
now: () => 789,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
toolPreferences: defaultToolPreferences,
|
||||
targetTheme: "general",
|
||||
nextNewChatAt: 789,
|
||||
navigationParams: {
|
||||
agentEntry: "claw",
|
||||
immersiveHome: false,
|
||||
projectId: undefined,
|
||||
theme: "general",
|
||||
initialCreationMode: "guided",
|
||||
initialUserPrompt: "",
|
||||
initialUserImages: undefined,
|
||||
openBrowserAssistOnMount: true,
|
||||
newChatAt: 789,
|
||||
lockTheme: false,
|
||||
},
|
||||
workspaceBootstrap: {
|
||||
projectId: undefined,
|
||||
initialUserPrompt: "",
|
||||
initialUserImages: undefined,
|
||||
theme: "general",
|
||||
initialCreationMode: "guided",
|
||||
openBrowserAssistOnMount: true,
|
||||
newChatAt: 789,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { buildClawAgentParams } from "@/lib/workspace/navigation";
|
||||
import type { AgentPageParams } from "@/types/page";
|
||||
import type { CreationMode } from "./components/types";
|
||||
import type { MessageImage } from "./types";
|
||||
import type { ChatToolPreferences } from "./utils/chatToolPreferences";
|
||||
|
||||
export interface AgentChatWorkspaceBootstrap {
|
||||
projectId?: string;
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: MessageImage[];
|
||||
theme?: string;
|
||||
initialCreationMode?: CreationMode;
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
newChatAt?: number;
|
||||
}
|
||||
|
||||
export interface HomeShellEnterWorkspacePayload {
|
||||
prompt?: string;
|
||||
images?: MessageImage[];
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
toolPreferences?: ChatToolPreferences;
|
||||
themeOverride?: string;
|
||||
}
|
||||
|
||||
export interface ResolveHomeShellWorkspaceEntryInput {
|
||||
projectId?: string | null;
|
||||
activeTheme: string;
|
||||
creationMode: CreationMode;
|
||||
defaultToolPreferences: ChatToolPreferences;
|
||||
payload: HomeShellEnterWorkspacePayload;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export type HomeShellEnterWorkspaceRejectedReason =
|
||||
| "missing_project"
|
||||
| "empty_payload";
|
||||
|
||||
export type ResolvedHomeShellWorkspaceEntry =
|
||||
| {
|
||||
ok: false;
|
||||
reason: HomeShellEnterWorkspaceRejectedReason;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
toolPreferences: ChatToolPreferences;
|
||||
targetTheme: string;
|
||||
nextNewChatAt: number;
|
||||
navigationParams: AgentPageParams;
|
||||
workspaceBootstrap: AgentChatWorkspaceBootstrap;
|
||||
};
|
||||
|
||||
export function resolveHomeShellWorkspaceEntry(
|
||||
input: ResolveHomeShellWorkspaceEntryInput,
|
||||
): ResolvedHomeShellWorkspaceEntry {
|
||||
const {
|
||||
projectId,
|
||||
activeTheme,
|
||||
creationMode,
|
||||
defaultToolPreferences,
|
||||
payload,
|
||||
now = () => Date.now(),
|
||||
} = input;
|
||||
|
||||
const hasPrompt = Boolean(payload.prompt?.trim());
|
||||
const hasImages = Boolean(payload.images?.length);
|
||||
const toolPreferences = payload.toolPreferences ?? defaultToolPreferences;
|
||||
const targetTheme = payload.themeOverride ?? activeTheme;
|
||||
const openBrowserAssistOnMount = payload.openBrowserAssistOnMount;
|
||||
|
||||
if (!openBrowserAssistOnMount && !projectId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing_project",
|
||||
};
|
||||
}
|
||||
|
||||
if (!openBrowserAssistOnMount && !hasPrompt && !hasImages) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "empty_payload",
|
||||
};
|
||||
}
|
||||
|
||||
const nextNewChatAt = now();
|
||||
const shared = {
|
||||
projectId: projectId ?? undefined,
|
||||
theme: targetTheme,
|
||||
initialCreationMode: creationMode,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
} satisfies AgentPageParams;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
toolPreferences,
|
||||
targetTheme,
|
||||
nextNewChatAt,
|
||||
navigationParams: buildClawAgentParams(shared),
|
||||
workspaceBootstrap: {
|
||||
projectId: projectId ?? undefined,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
theme: targetTheme,
|
||||
initialCreationMode: creationMode,
|
||||
openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,49 @@ import {
|
||||
} from "./agentChatHistory";
|
||||
|
||||
describe("agentChatHistory", () => {
|
||||
it("应清理仅用于内部展示的图片占位文本", () => {
|
||||
const detail: AsterSessionDetail = {
|
||||
id: "session-image-placeholder",
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
timestamp: 1710000200,
|
||||
content: [
|
||||
{ type: "input_text", text: "[Image #1]" },
|
||||
{
|
||||
type: "input_image",
|
||||
image_url: "data:image/png;base64,aGVsbG8=",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: 1710000201,
|
||||
content: [{ type: "output_text", text: "已收到图片" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = hydrateSessionDetailMessages(
|
||||
detail,
|
||||
"session-image-placeholder",
|
||||
);
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "",
|
||||
images: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[1]?.content).toBe("已收到图片");
|
||||
});
|
||||
|
||||
it("应从历史消息的 thinking 字段恢复完整思考过程", () => {
|
||||
const detail: AsterSessionDetail = {
|
||||
id: "session-1",
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
resolveHistoryUserDataText,
|
||||
stringifyToolArguments,
|
||||
} from "./agentChatToolResult";
|
||||
import {
|
||||
sanitizeContentPartsForDisplay,
|
||||
sanitizeMessageTextForDisplay,
|
||||
} from "../utils/internalImagePlaceholder";
|
||||
|
||||
export const normalizeHistoryPartType = (value: unknown): string => {
|
||||
if (typeof value !== "string") return "";
|
||||
@@ -569,12 +573,21 @@ export const hydrateSessionDetailMessages = (
|
||||
contentParts.push({ type: "text", text: resolved });
|
||||
}
|
||||
|
||||
const content = textParts.join("\n").trim();
|
||||
const rawContent = textParts.join("\n").trim();
|
||||
let normalizedRole =
|
||||
msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant");
|
||||
const content = sanitizeMessageTextForDisplay(rawContent, {
|
||||
role: normalizedRole,
|
||||
hasImages: images.length > 0,
|
||||
});
|
||||
const sanitizedContentParts =
|
||||
sanitizeContentPartsForDisplay(contentParts, {
|
||||
role: normalizedRole,
|
||||
hasImages: images.length > 0,
|
||||
}) || [];
|
||||
const hasToolMetadata =
|
||||
toolCalls.length > 0 ||
|
||||
contentParts.some((part) => part.type === "tool_use");
|
||||
sanitizedContentParts.some((part) => part.type === "tool_use");
|
||||
|
||||
if (normalizedRole === "user" && !content && images.length === 0) {
|
||||
if (hasToolMetadata) {
|
||||
@@ -587,7 +600,7 @@ export const hydrateSessionDetailMessages = (
|
||||
if (
|
||||
!content &&
|
||||
images.length === 0 &&
|
||||
contentParts.length === 0 &&
|
||||
sanitizedContentParts.length === 0 &&
|
||||
toolCalls.length === 0
|
||||
) {
|
||||
return [];
|
||||
@@ -599,11 +612,16 @@ export const hydrateSessionDetailMessages = (
|
||||
role: normalizedRole,
|
||||
content,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
contentParts: contentParts.length > 0 ? contentParts : undefined,
|
||||
contentParts:
|
||||
sanitizedContentParts.length > 0
|
||||
? sanitizedContentParts
|
||||
: undefined,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
timestamp: messageTimestamp,
|
||||
isThinking: false,
|
||||
thinkingContent: extractThinkingContentFromParts(contentParts),
|
||||
thinkingContent: extractThinkingContentFromParts(
|
||||
sanitizedContentParts,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -134,6 +134,40 @@ describe("agentChatShared", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("图片占位符不应直接出现在任务摘要里", () => {
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "msg-user-image",
|
||||
role: "user",
|
||||
content: "[Image #1]",
|
||||
images: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
expect(extractTaskPreviewFromMessages(messages)).toBe("已附加图片");
|
||||
});
|
||||
|
||||
it("助手内部图片标签应转换为自然语言摘要", () => {
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "msg-assistant-image",
|
||||
role: "assistant",
|
||||
content: "[Image #1]",
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
expect(extractTaskPreviewFromMessages(messages)).toBe("图片处理中");
|
||||
});
|
||||
|
||||
it("应按浏览器预检阶段映射不同状态原因", () => {
|
||||
expect(
|
||||
deriveTaskLiveState({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import type { Message, MessageImage, WriteArtifactContext } from "../types";
|
||||
import { normalizeExecutionStrategy } from "./agentChatCoreUtils";
|
||||
import { sanitizeMessageTextForPreview } from "../utils/internalImagePlaceholder";
|
||||
|
||||
export type TaskStatus = "draft" | "running" | "waiting" | "done" | "failed";
|
||||
export type TaskStatusReason =
|
||||
@@ -120,8 +121,12 @@ function normalizeTaskPreviewText(value: string) {
|
||||
}
|
||||
|
||||
function extractMessageTextContent(message: Message): string {
|
||||
if (message.content?.trim()) {
|
||||
return normalizeTaskPreviewText(message.content);
|
||||
const sanitizedContent = sanitizeMessageTextForPreview(message.content || "", {
|
||||
role: message.role,
|
||||
hasImages: Array.isArray(message.images) && message.images.length > 0,
|
||||
});
|
||||
if (sanitizedContent) {
|
||||
return normalizeTaskPreviewText(sanitizedContent);
|
||||
}
|
||||
|
||||
const partText = message.contentParts
|
||||
@@ -129,7 +134,15 @@ function extractMessageTextContent(message: Message): string {
|
||||
(part): part is Extract<(typeof message.contentParts)[number], { type: "text" | "thinking" }> =>
|
||||
part.type === "text" || part.type === "thinking",
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.map((part) =>
|
||||
part.type === "text"
|
||||
? sanitizeMessageTextForPreview(part.text, {
|
||||
role: message.role,
|
||||
hasImages: Array.isArray(message.images) && message.images.length > 0,
|
||||
})
|
||||
: part.text,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return normalizeTaskPreviewText(partText || "");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LAST_PROJECT_ID_KEY,
|
||||
loadPersistedProjectId,
|
||||
savePersistedProjectId,
|
||||
usePersistedProjectId,
|
||||
} from "./agentProjectStorage";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof usePersistedProjectId>;
|
||||
rerender: (externalProjectId?: string | null) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(initialExternalProjectId?: string | null): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof usePersistedProjectId> | null = null;
|
||||
|
||||
function TestComponent({
|
||||
externalProjectId,
|
||||
}: {
|
||||
externalProjectId?: string | null;
|
||||
}) {
|
||||
hookValue = usePersistedProjectId(externalProjectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (externalProjectId?: string | null) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent externalProjectId={externalProjectId} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialExternalProjectId);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("agentProjectStorage", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("应保存并读取最近项目偏好", () => {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-a");
|
||||
|
||||
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-a");
|
||||
});
|
||||
|
||||
it("hook 应优先使用 externalProjectId,否则回退最近项目", () => {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-local");
|
||||
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
expect(harness.getValue().projectId).toBe("project-local");
|
||||
|
||||
harness.rerender("project-external");
|
||||
expect(harness.getValue().projectId).toBe("project-external");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setProjectId("project-updated");
|
||||
harness.getValue().rememberProjectId("project-updated");
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBe("project-updated");
|
||||
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-updated");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { normalizeProjectId } from "../utils/topicProjectResolution";
|
||||
|
||||
export const LAST_PROJECT_ID_KEY = "agent_last_project_id";
|
||||
|
||||
export function loadPersistedProjectId(key: string): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
|
||||
} catch {
|
||||
return normalizeProjectId(stored);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedProjectId(key: string, projectId: string): void {
|
||||
const normalized = normalizeProjectId(projectId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(normalized));
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
export function usePersistedProjectId(
|
||||
externalProjectId?: string | null,
|
||||
storageKey = LAST_PROJECT_ID_KEY,
|
||||
) {
|
||||
const resolveProjectId = useCallback(
|
||||
() =>
|
||||
normalizeProjectId(externalProjectId) ??
|
||||
loadPersistedProjectId(storageKey),
|
||||
[externalProjectId, storageKey],
|
||||
);
|
||||
const [projectId, setProjectIdState] = useState<string | null>(() =>
|
||||
resolveProjectId(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setProjectIdState(resolveProjectId());
|
||||
}, [resolveProjectId]);
|
||||
|
||||
const setProjectId = useCallback((nextProjectId?: string | null) => {
|
||||
setProjectIdState(normalizeProjectId(nextProjectId));
|
||||
}, []);
|
||||
|
||||
const rememberProjectId = useCallback(
|
||||
(nextProjectId?: string | null) => {
|
||||
const normalized = normalizeProjectId(nextProjectId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
savePersistedProjectId(storageKey, normalized);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
setProjectId,
|
||||
rememberProjectId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
|
||||
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
|
||||
import type { BrowserPreflightState, BrowserTaskRequirement, MessageImage } from "../types";
|
||||
|
||||
export interface HandleSendObserver {
|
||||
onComplete?: (content: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface HandleSendOptions {
|
||||
skipThemeSkillPrefix?: boolean;
|
||||
purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit";
|
||||
observer?: HandleSendObserver;
|
||||
requestMetadata?: Record<string, unknown>;
|
||||
browserPreflightConfirmed?: boolean;
|
||||
toolPreferencesOverride?: ChatToolPreferences;
|
||||
}
|
||||
|
||||
export interface BrowserTaskPreflight {
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
sourceText: string;
|
||||
images: MessageImage[];
|
||||
webSearch?: boolean;
|
||||
thinking?: boolean;
|
||||
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
autoContinuePayload?: AutoContinueRequestPayload;
|
||||
sendOptions?: HandleSendOptions;
|
||||
requirement: BrowserTaskRequirement;
|
||||
reason: string;
|
||||
phase: BrowserPreflightState;
|
||||
launchUrl: string;
|
||||
platformLabel?: string;
|
||||
detail?: string;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
|
||||
|
||||
// 旧 useAgentChat 已删除,避免新代码继续沿 compat 路径扩展。
|
||||
export { useAsterAgentChat } from "./useAsterAgentChat";
|
||||
export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation";
|
||||
export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime";
|
||||
export { useCompatSubagentRuntime } from "./useCompatSubagentRuntime";
|
||||
export { useThemeContextWorkspace } from "./useThemeContextWorkspace";
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockNotifyProjectRuntimeAgentsGuide,
|
||||
mockSetSessionExecutionStrategy,
|
||||
mockToastError,
|
||||
mockUpdateProject,
|
||||
} = vi.hoisted(() => ({
|
||||
mockNotifyProjectRuntimeAgentsGuide: vi.fn(),
|
||||
mockSetSessionExecutionStrategy: vi.fn(async () => undefined),
|
||||
mockToastError: vi.fn(),
|
||||
mockUpdateProject: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/project", () => ({
|
||||
updateProject: mockUpdateProject,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/workspace/services/runtimeAgentsGuideService", () => ({
|
||||
notifyProjectRuntimeAgentsGuide: mockNotifyProjectRuntimeAgentsGuide,
|
||||
}));
|
||||
|
||||
import { useAgentContext } from "./useAgentContext";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useAgentContext>;
|
||||
unmount: () => void;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function mountHook(workspaceId = "workspace-1"): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const sendMessage = vi.fn(async () => undefined);
|
||||
let hookValue: ReturnType<typeof useAgentContext> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useAgentContext({
|
||||
workspaceId,
|
||||
sessionIdRef: { current: null },
|
||||
topicsUpdaterRef: { current: null },
|
||||
sendMessageRef: { current: sendMessage },
|
||||
runtime: {
|
||||
setSessionExecutionStrategy: mockSetSessionExecutionStrategy,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useAgentContext", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockNotifyProjectRuntimeAgentsGuide.mockReset();
|
||||
mockSetSessionExecutionStrategy.mockClear();
|
||||
mockToastError.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("修复目录并重试时应触发运行时 AGENTS 引导", async () => {
|
||||
const harness = mountHook();
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setWorkspacePathMissing({
|
||||
content: "继续上次对话",
|
||||
images: [],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.fixWorkspacePathAndRetry("/tmp/workspace-linked");
|
||||
});
|
||||
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith("workspace-1", {
|
||||
rootPath: "/tmp/workspace-linked",
|
||||
});
|
||||
expect(mockNotifyProjectRuntimeAgentsGuide).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "workspace-1",
|
||||
rootPath: "/tmp/workspace-linked",
|
||||
},
|
||||
{
|
||||
successMessage: "工作区目录已重新关联",
|
||||
showSuccessWhenGuideAlreadySeen: false,
|
||||
},
|
||||
);
|
||||
expect(harness.sendMessage).toHaveBeenCalledWith(
|
||||
"继续上次对话",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { updateProject } from "@/lib/api/project";
|
||||
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
|
||||
import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
|
||||
import type {
|
||||
SendMessageFn,
|
||||
SessionModelPreference,
|
||||
@@ -286,6 +287,16 @@ export function useAgentContext(options: UseAgentContextOptions) {
|
||||
setWorkspacePathMissing(null);
|
||||
try {
|
||||
await updateProject(workspaceId, { rootPath: newPath });
|
||||
notifyProjectRuntimeAgentsGuide(
|
||||
{
|
||||
id: workspaceId,
|
||||
rootPath: newPath,
|
||||
},
|
||||
{
|
||||
successMessage: "工作区目录已重新关联",
|
||||
showSuccessWhenGuideAlreadySeen: false,
|
||||
},
|
||||
);
|
||||
await sendMessage(retryContent, retryImages, false, false, true);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildInitialDispatchKey,
|
||||
useBootstrapDispatchPreview,
|
||||
} from "./useBootstrapDispatchPreview";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useBootstrapDispatchPreview>;
|
||||
rerender: (
|
||||
props?: Partial<{
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: Array<{ data: string; mediaType: string }>;
|
||||
messagesCount: number;
|
||||
isSending: boolean;
|
||||
queuedTurnCount: number;
|
||||
consumedInitialPromptKey?: string | null;
|
||||
shouldUseCompactThemeWorkbench?: boolean;
|
||||
}>,
|
||||
) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(
|
||||
initialProps?: Partial<{
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: Array<{ data: string; mediaType: string }>;
|
||||
messagesCount: number;
|
||||
isSending: boolean;
|
||||
queuedTurnCount: number;
|
||||
consumedInitialPromptKey?: string | null;
|
||||
shouldUseCompactThemeWorkbench?: boolean;
|
||||
}>,
|
||||
): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useBootstrapDispatchPreview> | null = null;
|
||||
let currentProps = {
|
||||
initialUserPrompt: "",
|
||||
initialUserImages: [],
|
||||
messagesCount: 0,
|
||||
isSending: false,
|
||||
queuedTurnCount: 0,
|
||||
consumedInitialPromptKey: null,
|
||||
shouldUseCompactThemeWorkbench: false,
|
||||
...initialProps,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useBootstrapDispatchPreview(currentProps);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (
|
||||
nextProps?: Partial<{
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: Array<{ data: string; mediaType: string }>;
|
||||
messagesCount: number;
|
||||
isSending: boolean;
|
||||
queuedTurnCount: number;
|
||||
consumedInitialPromptKey?: string | null;
|
||||
shouldUseCompactThemeWorkbench?: boolean;
|
||||
}>,
|
||||
) => {
|
||||
currentProps = {
|
||||
...currentProps,
|
||||
...nextProps,
|
||||
};
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useBootstrapDispatchPreview", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
it("应生成稳定的 initialDispatchKey", () => {
|
||||
expect(
|
||||
buildInitialDispatchKey("写一篇文章", [
|
||||
{ data: "abcdef1234567890", mediaType: "image/png" },
|
||||
]),
|
||||
).toContain("写一篇文章");
|
||||
});
|
||||
|
||||
it("发送中且无消息时应展示 bootstrap 预览消息", () => {
|
||||
const harness = mountHook({
|
||||
initialUserPrompt: "请开始处理这个任务",
|
||||
isSending: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const value = harness.getValue();
|
||||
expect(value.initialDispatchKey).toBeTruthy();
|
||||
expect(value.shouldShowBootstrapDispatchPreview).toBe(true);
|
||||
expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2);
|
||||
expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(
|
||||
"请开始处理这个任务",
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("有真实消息后应清空 bootstrap 预览", () => {
|
||||
const harness = mountHook({
|
||||
initialUserPrompt: "请开始处理这个任务",
|
||||
isSending: true,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(2);
|
||||
|
||||
harness.rerender({
|
||||
messagesCount: 1,
|
||||
isSending: false,
|
||||
});
|
||||
|
||||
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(0);
|
||||
expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Message, MessageImage } from "../types";
|
||||
|
||||
export interface InitialDispatchPreviewSnapshot {
|
||||
key: string;
|
||||
prompt?: string;
|
||||
images: MessageImage[];
|
||||
}
|
||||
|
||||
interface UseBootstrapDispatchPreviewOptions {
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: MessageImage[];
|
||||
messagesCount: number;
|
||||
isSending: boolean;
|
||||
queuedTurnCount: number;
|
||||
consumedInitialPromptKey?: string | null;
|
||||
shouldUseCompactThemeWorkbench?: boolean;
|
||||
}
|
||||
|
||||
export function buildInitialDispatchKey(
|
||||
prompt?: string,
|
||||
images?: MessageImage[],
|
||||
): string | null {
|
||||
const normalizedPrompt = (prompt || "").trim();
|
||||
const normalizedImages = images || [];
|
||||
|
||||
if (!normalizedPrompt && normalizedImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageSignature = normalizedImages
|
||||
.map(
|
||||
(image, index) =>
|
||||
`${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`,
|
||||
)
|
||||
.join("|");
|
||||
|
||||
return `${normalizedPrompt}::${imageSignature}`;
|
||||
}
|
||||
|
||||
export function buildInitialDispatchPreviewMessages(
|
||||
dispatchKey: string,
|
||||
prompt?: string,
|
||||
images?: MessageImage[],
|
||||
): Message[] {
|
||||
const normalizedPrompt = (prompt || "").trim();
|
||||
const normalizedImages = images || [];
|
||||
|
||||
if (!normalizedPrompt && normalizedImages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timestamp = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
id: `initial-dispatch:${dispatchKey}:user`,
|
||||
role: "user",
|
||||
content: normalizedPrompt,
|
||||
images: normalizedImages.length > 0 ? normalizedImages : undefined,
|
||||
timestamp,
|
||||
},
|
||||
{
|
||||
id: `initial-dispatch:${dispatchKey}:assistant`,
|
||||
role: "assistant",
|
||||
content: "正在开始处理任务…",
|
||||
timestamp: new Date(timestamp.getTime() + 1),
|
||||
isThinking: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useBootstrapDispatchPreview({
|
||||
initialUserPrompt,
|
||||
initialUserImages,
|
||||
messagesCount,
|
||||
isSending,
|
||||
queuedTurnCount,
|
||||
consumedInitialPromptKey,
|
||||
shouldUseCompactThemeWorkbench = false,
|
||||
}: UseBootstrapDispatchPreviewOptions) {
|
||||
const initialDispatchKey = useMemo(
|
||||
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
|
||||
[initialUserImages, initialUserPrompt],
|
||||
);
|
||||
const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] =
|
||||
useState<InitialDispatchPreviewSnapshot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialDispatchKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBootstrapDispatchSnapshot({
|
||||
key: initialDispatchKey,
|
||||
prompt: initialUserPrompt,
|
||||
images: initialUserImages || [],
|
||||
});
|
||||
}, [initialDispatchKey, initialUserImages, initialUserPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesCount > 0) {
|
||||
setBootstrapDispatchSnapshot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialDispatchKey && !isSending && queuedTurnCount === 0) {
|
||||
setBootstrapDispatchSnapshot(null);
|
||||
}
|
||||
}, [initialDispatchKey, isSending, messagesCount, queuedTurnCount]);
|
||||
|
||||
const activeBootstrapDispatch = useMemo(() => {
|
||||
if (
|
||||
initialDispatchKey &&
|
||||
((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0)
|
||||
) {
|
||||
return {
|
||||
key: initialDispatchKey,
|
||||
prompt: initialUserPrompt,
|
||||
images: initialUserImages || [],
|
||||
};
|
||||
}
|
||||
|
||||
return bootstrapDispatchSnapshot;
|
||||
}, [
|
||||
bootstrapDispatchSnapshot,
|
||||
initialDispatchKey,
|
||||
initialUserImages,
|
||||
initialUserPrompt,
|
||||
]);
|
||||
|
||||
const isBootstrapDispatchPending =
|
||||
activeBootstrapDispatch !== null &&
|
||||
consumedInitialPromptKey !== activeBootstrapDispatch.key;
|
||||
const shouldShowBootstrapDispatchPreview =
|
||||
!shouldUseCompactThemeWorkbench &&
|
||||
Boolean(activeBootstrapDispatch) &&
|
||||
messagesCount === 0 &&
|
||||
(isSending || queuedTurnCount > 0);
|
||||
const bootstrapDispatchPreviewMessages = useMemo(() => {
|
||||
if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) {
|
||||
return [] as Message[];
|
||||
}
|
||||
|
||||
return buildInitialDispatchPreviewMessages(
|
||||
activeBootstrapDispatch.key,
|
||||
activeBootstrapDispatch.prompt,
|
||||
activeBootstrapDispatch.images,
|
||||
);
|
||||
}, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]);
|
||||
|
||||
return {
|
||||
initialDispatchKey,
|
||||
activeBootstrapDispatch,
|
||||
isBootstrapDispatchPending,
|
||||
shouldShowBootstrapDispatchPreview,
|
||||
bootstrapDispatchPreviewMessages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useHomeShellAgentPreferences } from "./useHomeShellAgentPreferences";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useHomeShellAgentPreferences>;
|
||||
rerender: (projectId?: string | null) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(initialProjectId?: string | null): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useHomeShellAgentPreferences> | null = null;
|
||||
|
||||
function TestComponent({ projectId }: { projectId?: string | null }) {
|
||||
hookValue = useHomeShellAgentPreferences(projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (projectId?: string | null) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent projectId={projectId} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialProjectId);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useHomeShellAgentPreferences", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("应按项目作用域读取 provider、model 与执行策略", () => {
|
||||
localStorage.setItem(
|
||||
"agent_pref_provider_project-home-a",
|
||||
JSON.stringify("openai"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"agent_pref_model_project-home-a",
|
||||
JSON.stringify("gpt-5-mini"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"aster_execution_strategy_project-home-a",
|
||||
JSON.stringify("auto"),
|
||||
);
|
||||
|
||||
const harness = mountHook("project-home-a");
|
||||
|
||||
try {
|
||||
expect(harness.getValue().providerType).toBe("openai");
|
||||
expect(harness.getValue().model).toBe("gpt-5-mini");
|
||||
expect(harness.getValue().executionStrategy).toBe("auto");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("切换项目时应重载对应偏好,并把变更写回当前作用域", () => {
|
||||
localStorage.setItem(
|
||||
"agent_pref_provider_project-home-a",
|
||||
JSON.stringify("openai"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"agent_pref_model_project-home-a",
|
||||
JSON.stringify("gpt-5-mini"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"aster_execution_strategy_project-home-a",
|
||||
JSON.stringify("auto"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"agent_pref_provider_project-home-b",
|
||||
JSON.stringify("claude"),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"agent_pref_model_project-home-b",
|
||||
JSON.stringify("claude-sonnet-4-5"),
|
||||
);
|
||||
|
||||
const harness = mountHook("project-home-a");
|
||||
|
||||
try {
|
||||
harness.rerender("project-home-b");
|
||||
|
||||
expect(harness.getValue().providerType).toBe("claude");
|
||||
expect(harness.getValue().model).toBe("claude-sonnet-4-5");
|
||||
expect(harness.getValue().executionStrategy).toBe("react");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setProviderType("gemini");
|
||||
harness.getValue().setModel("gemini-2.5-pro");
|
||||
harness.getValue().setExecutionStrategy("code_orchestrated");
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("agent_pref_provider_project-home-b") || "null",
|
||||
),
|
||||
).toBe("gemini");
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("agent_pref_model_project-home-b") || "null",
|
||||
),
|
||||
).toBe("gemini-2.5-pro");
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
"aster_execution_strategy_project-home-b",
|
||||
) || "null",
|
||||
),
|
||||
).toBe("code_orchestrated");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
|
||||
import { normalizeProjectId } from "../utils/topicProjectResolution";
|
||||
import { normalizeExecutionStrategy } from "./agentChatCoreUtils";
|
||||
import {
|
||||
getAgentPreferenceKeys,
|
||||
loadPersisted,
|
||||
resolveWorkspaceAgentPreferences,
|
||||
savePersisted,
|
||||
} from "./agentChatStorage";
|
||||
|
||||
function resolveExecutionStrategyStorageKey(
|
||||
projectId?: string | null,
|
||||
): string | null {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
if (!normalizedProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `aster_execution_strategy_${normalizedProjectId}`;
|
||||
}
|
||||
|
||||
function resolvePersistedExecutionStrategy(
|
||||
projectId?: string | null,
|
||||
): AsterExecutionStrategy {
|
||||
const storageKey = resolveExecutionStrategyStorageKey(projectId);
|
||||
if (!storageKey) {
|
||||
return "react";
|
||||
}
|
||||
|
||||
return normalizeExecutionStrategy(
|
||||
loadPersisted<string | null>(storageKey, "react"),
|
||||
);
|
||||
}
|
||||
|
||||
export function useHomeShellAgentPreferences(projectId?: string | null) {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
const initialPreferences =
|
||||
resolveWorkspaceAgentPreferences(normalizedProjectId);
|
||||
const [providerType, setProviderTypeState] = useState(
|
||||
initialPreferences.providerType,
|
||||
);
|
||||
const [model, setModelState] = useState(initialPreferences.model);
|
||||
const [executionStrategy, setExecutionStrategyState] =
|
||||
useState<AsterExecutionStrategy>(() =>
|
||||
resolvePersistedExecutionStrategy(normalizedProjectId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scopedPreferences =
|
||||
resolveWorkspaceAgentPreferences(normalizedProjectId);
|
||||
setProviderTypeState(scopedPreferences.providerType);
|
||||
setModelState(scopedPreferences.model);
|
||||
setExecutionStrategyState(
|
||||
resolvePersistedExecutionStrategy(normalizedProjectId),
|
||||
);
|
||||
}, [normalizedProjectId]);
|
||||
|
||||
const setProviderType = useCallback(
|
||||
(nextProviderType: string) => {
|
||||
setProviderTypeState(nextProviderType);
|
||||
const { providerKey } = getAgentPreferenceKeys(normalizedProjectId);
|
||||
savePersisted(providerKey, nextProviderType);
|
||||
},
|
||||
[normalizedProjectId],
|
||||
);
|
||||
|
||||
const setModel = useCallback(
|
||||
(nextModel: string) => {
|
||||
setModelState(nextModel);
|
||||
const { modelKey } = getAgentPreferenceKeys(normalizedProjectId);
|
||||
savePersisted(modelKey, nextModel);
|
||||
},
|
||||
[normalizedProjectId],
|
||||
);
|
||||
|
||||
const setExecutionStrategy = useCallback(
|
||||
(nextExecutionStrategy: AsterExecutionStrategy) => {
|
||||
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
|
||||
setExecutionStrategyState(normalized);
|
||||
const storageKey = resolveExecutionStrategyStorageKey(normalizedProjectId);
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
savePersisted(storageKey, normalized);
|
||||
},
|
||||
[normalizedProjectId],
|
||||
);
|
||||
|
||||
return {
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useHomeShellProjectMemory } from "./useHomeShellProjectMemory";
|
||||
|
||||
const mockGetProjectMemory = vi.hoisted(() => vi.fn());
|
||||
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/memory", () => ({
|
||||
getProjectMemory: mockGetProjectMemory,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/agentDebug", () => ({
|
||||
logAgentDebug: mockLogAgentDebug,
|
||||
}));
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useHomeShellProjectMemory>;
|
||||
rerender: (projectId?: string | null) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(initialProjectId?: string | null): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useHomeShellProjectMemory> | null = null;
|
||||
|
||||
function TestComponent({ projectId }: { projectId?: string | null }) {
|
||||
hookValue = useHomeShellProjectMemory(projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (projectId?: string | null) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent projectId={projectId} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialProjectId);
|
||||
|
||||
return {
|
||||
getValue: () => hookValue,
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects(times = 4) {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("useHomeShellProjectMemory", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
mockGetProjectMemory.mockResolvedValue({
|
||||
characters: [{ id: "char-1", name: "主角" }],
|
||||
outline: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("无项目时应直接返回空记忆且不触发加载", async () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(harness.getValue()).toBeNull();
|
||||
expect(mockGetProjectMemory).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("切换项目时应加载对应项目记忆", async () => {
|
||||
const harness = mountHook("project-memory-a");
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(mockGetProjectMemory).toHaveBeenCalledWith("project-memory-a");
|
||||
expect(harness.getValue()?.characters[0]?.name).toBe("主角");
|
||||
|
||||
mockGetProjectMemory.mockResolvedValueOnce({
|
||||
characters: [{ id: "char-2", name: "配角" }],
|
||||
outline: [],
|
||||
});
|
||||
|
||||
harness.rerender("project-memory-b");
|
||||
await flushEffects();
|
||||
|
||||
expect(mockGetProjectMemory).toHaveBeenLastCalledWith("project-memory-b");
|
||||
expect(harness.getValue()?.characters[0]?.name).toBe("配角");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
|
||||
import { logAgentDebug } from "@/lib/agentDebug";
|
||||
import { normalizeProjectId } from "../utils/topicProjectResolution";
|
||||
|
||||
export function useHomeShellProjectMemory(projectId?: string | null) {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedProjectId) {
|
||||
setProjectMemory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const startedAt = Date.now();
|
||||
logAgentDebug("useHomeShellProjectMemory", "load.start", {
|
||||
projectId: normalizedProjectId,
|
||||
});
|
||||
|
||||
void getProjectMemory(normalizedProjectId)
|
||||
.then((memory) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectMemory(memory);
|
||||
logAgentDebug("useHomeShellProjectMemory", "load.success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
projectId: normalizedProjectId,
|
||||
charactersCount: memory.characters.length,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectMemory(null);
|
||||
logAgentDebug(
|
||||
"useHomeShellProjectMemory",
|
||||
"load.error",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
projectId: normalizedProjectId,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedProjectId]);
|
||||
|
||||
return projectMemory;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useHomeShellSkills } from "./useHomeShellSkills";
|
||||
|
||||
const mockGetLocal = vi.hoisted(() => vi.fn());
|
||||
const mockGetAll = vi.hoisted(() => vi.fn());
|
||||
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: {
|
||||
getLocal: mockGetLocal,
|
||||
getAll: mockGetAll,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/agentDebug", () => ({
|
||||
logAgentDebug: mockLogAgentDebug,
|
||||
}));
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useHomeShellSkills>;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useHomeShellSkills> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useHomeShellSkills();
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useHomeShellSkills", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockGetLocal.mockResolvedValue([
|
||||
{
|
||||
key: "local-skill",
|
||||
name: "本地技能",
|
||||
description: "默认加载",
|
||||
directory: "local-skill",
|
||||
installed: true,
|
||||
sourceKind: "builtin",
|
||||
},
|
||||
]);
|
||||
mockGetAll.mockResolvedValue([
|
||||
{
|
||||
key: "remote-skill",
|
||||
name: "远端技能",
|
||||
description: "刷新加载",
|
||||
directory: "remote-skill",
|
||||
installed: true,
|
||||
sourceKind: "builtin",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应在 idle/fallback 阶段加载本地 skills", async () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(mockGetLocal).toHaveBeenCalledWith("lime");
|
||||
expect(harness.getValue().skills).toHaveLength(1);
|
||||
expect(harness.getValue().skills[0]?.key).toBe("local-skill");
|
||||
expect(harness.getValue().skillsLoading).toBe(false);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("手动刷新时应走远端聚合入口", async () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness.getValue().refreshSkills(true);
|
||||
});
|
||||
|
||||
expect(mockGetAll).toHaveBeenCalledWith("lime");
|
||||
expect(harness.getValue().skills[0]?.key).toBe("remote-skill");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLimeSkills } from "./useLimeSkills";
|
||||
|
||||
export function useHomeShellSkills() {
|
||||
return useLimeSkills({
|
||||
autoLoad: "deferred",
|
||||
logScope: "useHomeShellSkills",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLimeSkills } from "./useLimeSkills";
|
||||
|
||||
const mockGetLocal = vi.hoisted(() => vi.fn());
|
||||
const mockGetAll = vi.hoisted(() => vi.fn());
|
||||
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: {
|
||||
getLocal: mockGetLocal,
|
||||
getAll: mockGetAll,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/agentDebug", () => ({
|
||||
logAgentDebug: mockLogAgentDebug,
|
||||
}));
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useLimeSkills>;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(
|
||||
options: Parameters<typeof useLimeSkills>[0] = {
|
||||
autoLoad: "deferred",
|
||||
},
|
||||
): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useLimeSkills> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useLimeSkills(options);
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useLimeSkills", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockGetLocal.mockResolvedValue([
|
||||
{
|
||||
key: "local-skill",
|
||||
name: "本地技能",
|
||||
description: "默认加载",
|
||||
directory: "local-skill",
|
||||
installed: true,
|
||||
sourceKind: "builtin",
|
||||
},
|
||||
]);
|
||||
mockGetAll.mockResolvedValue([
|
||||
{
|
||||
key: "remote-skill",
|
||||
name: "远端技能",
|
||||
description: "刷新加载",
|
||||
directory: "remote-skill",
|
||||
installed: true,
|
||||
sourceKind: "builtin",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("deferred 模式应在 idle/fallback 阶段加载本地 skills", async () => {
|
||||
const harness = mountHook({ autoLoad: "deferred", logScope: "TestScope" });
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(mockGetLocal).toHaveBeenCalledWith("lime");
|
||||
expect(harness.getValue().skills[0]?.key).toBe("local-skill");
|
||||
expect(mockLogAgentDebug).toHaveBeenCalledWith(
|
||||
"TestScope",
|
||||
"loadSkills.start",
|
||||
{ includeRemote: false },
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("immediate 模式应立即加载,并支持手动刷新远端", async () => {
|
||||
const harness = mountHook({ autoLoad: "immediate" });
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
expect(mockGetLocal).toHaveBeenCalledWith("lime");
|
||||
|
||||
await act(async () => {
|
||||
await harness.getValue().refreshSkills(true);
|
||||
});
|
||||
|
||||
expect(mockGetAll).toHaveBeenCalledWith("lime");
|
||||
expect(harness.getValue().skills[0]?.key).toBe("remote-skill");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { logAgentDebug } from "@/lib/agentDebug";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
|
||||
const SKILLS_IDLE_TIMEOUT_MS = 1_500;
|
||||
const SKILLS_FALLBACK_DELAY_MS = 180;
|
||||
|
||||
function scheduleDeferredSkillsLoad(task: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
task();
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const idleId = window.requestIdleCallback(() => task(), {
|
||||
timeout: SKILLS_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return () => {
|
||||
if (typeof window.cancelIdleCallback === "function") {
|
||||
window.cancelIdleCallback(idleId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(task, SKILLS_FALLBACK_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
interface UseLimeSkillsOptions {
|
||||
autoLoad?: "immediate" | "deferred" | false;
|
||||
logScope?: string;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function useLimeSkills(options: UseLimeSkillsOptions = {}) {
|
||||
const {
|
||||
autoLoad = "immediate",
|
||||
logScope = "useLimeSkills",
|
||||
onError,
|
||||
} = options;
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
const refreshSkills = useCallback(
|
||||
async (includeRemote = false): Promise<Skill[]> => {
|
||||
const requestId = latestRequestIdRef.current + 1;
|
||||
latestRequestIdRef.current = requestId;
|
||||
|
||||
const startedAt = Date.now();
|
||||
logAgentDebug(logScope, "loadSkills.start", {
|
||||
includeRemote,
|
||||
});
|
||||
setSkillsLoading(true);
|
||||
|
||||
try {
|
||||
const loadedSkills = includeRemote
|
||||
? await skillsApi.getAll("lime")
|
||||
: await skillsApi.getLocal("lime");
|
||||
|
||||
if (latestRequestIdRef.current !== requestId) {
|
||||
return loadedSkills;
|
||||
}
|
||||
|
||||
setSkills(loadedSkills);
|
||||
logAgentDebug(logScope, "loadSkills.success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
includeRemote,
|
||||
skillsCount: loadedSkills.length,
|
||||
});
|
||||
return loadedSkills;
|
||||
} catch (error) {
|
||||
if (latestRequestIdRef.current !== requestId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setSkills([]);
|
||||
onError?.(error);
|
||||
logAgentDebug(
|
||||
logScope,
|
||||
"loadSkills.error",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
includeRemote,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
if (latestRequestIdRef.current === requestId) {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[logScope, onError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoLoad === "deferred") {
|
||||
return scheduleDeferredSkillsLoad(() => {
|
||||
void refreshSkills(false);
|
||||
});
|
||||
}
|
||||
|
||||
void refreshSkills(false);
|
||||
return;
|
||||
}, [autoLoad, refreshSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
latestRequestIdRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
skills,
|
||||
skillsLoading,
|
||||
refreshSkills,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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 type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
import {
|
||||
shouldGenerateRuntimeTeamAfterSend,
|
||||
useRuntimeTeamFormation,
|
||||
} from "./useRuntimeTeamFormation";
|
||||
|
||||
type HookProps = Parameters<typeof useRuntimeTeamFormation>[0];
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
function createSelectedTeam(): TeamDefinition {
|
||||
return {
|
||||
id: "team-1",
|
||||
source: "builtin",
|
||||
label: "研究协作组",
|
||||
description: "负责拆解调研任务",
|
||||
roles: [
|
||||
{
|
||||
id: "role-1",
|
||||
label: "研究员",
|
||||
summary: "收集资料",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderHook(props?: Partial<HookProps>) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
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 () => ({
|
||||
id: "ephemeral-1",
|
||||
source: "ephemeral",
|
||||
label: "临时 Team",
|
||||
description: "自动生成",
|
||||
roles: [
|
||||
{
|
||||
id: "member-1",
|
||||
label: "执行者",
|
||||
summary: "执行当前任务",
|
||||
},
|
||||
],
|
||||
})),
|
||||
createRequestId: () => "request-1",
|
||||
now: () => 1_710_000_000_000,
|
||||
};
|
||||
|
||||
let latestValue: ReturnType<typeof useRuntimeTeamFormation> | null = null;
|
||||
|
||||
function Probe(currentProps: HookProps) {
|
||||
latestValue = useRuntimeTeamFormation(currentProps);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = async (nextProps?: Partial<HookProps>) => {
|
||||
await act(async () => {
|
||||
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
|
||||
return {
|
||||
render,
|
||||
getValue: () => {
|
||||
if (!latestValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return latestValue;
|
||||
},
|
||||
defaultProps: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRuntimeTeamFormation", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("发送后满足条件时应进入 forming 并在生成完成后切到 formed", async () => {
|
||||
const generateRuntimeTeam = vi.fn(async () => ({
|
||||
id: "ephemeral-1",
|
||||
source: "ephemeral" as const,
|
||||
label: "自动 Team",
|
||||
description: "自动生成",
|
||||
roles: [
|
||||
{
|
||||
id: "role-1",
|
||||
label: "执行者",
|
||||
summary: "负责执行",
|
||||
},
|
||||
],
|
||||
}));
|
||||
const { render, getValue } = renderHook({
|
||||
generateRuntimeTeam,
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
act(() => {
|
||||
getValue().handleRuntimeTeamAfterSend({
|
||||
input: "请拆成两个子任务",
|
||||
providerType: "openai",
|
||||
model: "gpt-4.1",
|
||||
executionStrategy: "react",
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getValue().runtimeTeamState?.status).toBe("formed");
|
||||
expect(getValue().runtimeTeamState?.label).toBe("自动 Team");
|
||||
});
|
||||
|
||||
it("subagent 关闭且没有真实 team 图时,应清空 runtime team 状态", async () => {
|
||||
const { render, getValue } = renderHook();
|
||||
await render();
|
||||
|
||||
act(() => {
|
||||
getValue().triggerRuntimeTeamFormation({
|
||||
input: "请先规划 team",
|
||||
providerType: "openai",
|
||||
model: "gpt-4.1",
|
||||
executionStrategy: "react",
|
||||
});
|
||||
});
|
||||
|
||||
expect(getValue().runtimeTeamState?.status).toBe("forming");
|
||||
|
||||
await render({
|
||||
subagentEnabled: false,
|
||||
hasRealTeamGraph: false,
|
||||
});
|
||||
|
||||
expect(getValue().runtimeTeamState).toBeNull();
|
||||
});
|
||||
|
||||
it("session 切换时应重置 runtime team 状态", async () => {
|
||||
const { render, getValue } = renderHook();
|
||||
await render();
|
||||
|
||||
act(() => {
|
||||
getValue().triggerRuntimeTeamFormation({
|
||||
input: "请先规划 team",
|
||||
providerType: "openai",
|
||||
model: "gpt-4.1",
|
||||
executionStrategy: "react",
|
||||
});
|
||||
});
|
||||
|
||||
expect(getValue().runtimeTeamState?.status).toBe("forming");
|
||||
|
||||
await render({
|
||||
sessionId: "session-2",
|
||||
});
|
||||
|
||||
expect(getValue().runtimeTeamState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldGenerateRuntimeTeamAfterSend", () => {
|
||||
it("仅在 subagent 开启、存在项目、非 purpose 且输入非空时返回 true", () => {
|
||||
expect(
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled: true,
|
||||
projectId: "project-1",
|
||||
input: "请拆解任务",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled: false,
|
||||
projectId: "project-1",
|
||||
input: "请拆解任务",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled: true,
|
||||
projectId: "",
|
||||
input: "请拆解任务",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled: true,
|
||||
projectId: "project-1",
|
||||
input: " ",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled: true,
|
||||
projectId: "project-1",
|
||||
input: "请拆解任务",
|
||||
purpose: "content_review",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useRef, useState } 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 {
|
||||
input: string;
|
||||
providerType: string;
|
||||
model: string;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
}
|
||||
|
||||
interface HandleRuntimeTeamAfterSendParams
|
||||
extends TriggerRuntimeTeamFormationParams {
|
||||
purpose?: HandleSendOptions["purpose"];
|
||||
}
|
||||
|
||||
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,
|
||||
) => void;
|
||||
handleRuntimeTeamAfterSend: (params: HandleRuntimeTeamAfterSendParams) => void;
|
||||
}
|
||||
|
||||
function defaultCreateRequestId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function shouldGenerateRuntimeTeamAfterSend(params: {
|
||||
subagentEnabled: boolean;
|
||||
projectId?: string | null;
|
||||
input: string;
|
||||
purpose?: HandleSendOptions["purpose"];
|
||||
}): boolean {
|
||||
return (
|
||||
params.subagentEnabled &&
|
||||
!params.purpose &&
|
||||
Boolean(params.projectId) &&
|
||||
params.input.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const triggerRuntimeTeamFormation = useCallback(
|
||||
({
|
||||
input,
|
||||
providerType,
|
||||
model,
|
||||
executionStrategy,
|
||||
}: TriggerRuntimeTeamFormationParams) => {
|
||||
const normalizedInput = input.trim();
|
||||
if (!projectId || !normalizedInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = createRequestId();
|
||||
runtimeTeamRequestIdRef.current = requestId;
|
||||
setRuntimeTeamState(
|
||||
createRuntimeFormationStateFromTeam({
|
||||
requestId,
|
||||
status: "forming",
|
||||
blueprintTeam: selectedTeam ?? null,
|
||||
updatedAt: now(),
|
||||
}),
|
||||
);
|
||||
|
||||
void generateRuntimeTeam({
|
||||
workspaceId: projectId,
|
||||
providerType,
|
||||
model,
|
||||
executionStrategy,
|
||||
activeTheme,
|
||||
input: normalizedInput,
|
||||
blueprintTeam: selectedTeam ?? null,
|
||||
})
|
||||
.then((runtimeTeam) => {
|
||||
if (runtimeTeamRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeTeamState(
|
||||
createRuntimeFormationStateFromTeam({
|
||||
requestId,
|
||||
status: "formed",
|
||||
runtimeTeam,
|
||||
blueprintTeam: selectedTeam ?? null,
|
||||
updatedAt: now(),
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (runtimeTeamRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Team 生成失败";
|
||||
setRuntimeTeamState(
|
||||
createRuntimeFormationStateFromTeam({
|
||||
requestId,
|
||||
status: "failed",
|
||||
blueprintTeam: selectedTeam ?? null,
|
||||
errorMessage,
|
||||
updatedAt: now(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
[
|
||||
activeTheme,
|
||||
createRequestId,
|
||||
generateRuntimeTeam,
|
||||
now,
|
||||
projectId,
|
||||
selectedTeam,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRuntimeTeamAfterSend = useCallback(
|
||||
({
|
||||
input,
|
||||
providerType,
|
||||
model,
|
||||
executionStrategy,
|
||||
purpose,
|
||||
}: HandleRuntimeTeamAfterSendParams) => {
|
||||
if (
|
||||
shouldGenerateRuntimeTeamAfterSend({
|
||||
subagentEnabled,
|
||||
projectId,
|
||||
input,
|
||||
purpose,
|
||||
})
|
||||
) {
|
||||
triggerRuntimeTeamFormation({
|
||||
input,
|
||||
providerType,
|
||||
model,
|
||||
executionStrategy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subagentEnabled && !hasRealTeamGraph) {
|
||||
clearRuntimeTeamState();
|
||||
}
|
||||
},
|
||||
[
|
||||
clearRuntimeTeamState,
|
||||
hasRealTeamGraph,
|
||||
projectId,
|
||||
subagentEnabled,
|
||||
triggerRuntimeTeamFormation,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
clearRuntimeTeamState();
|
||||
}, [clearRuntimeTeamState, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subagentEnabled && !hasRealTeamGraph) {
|
||||
clearRuntimeTeamState();
|
||||
}
|
||||
}, [clearRuntimeTeamState, hasRealTeamGraph, subagentEnabled]);
|
||||
|
||||
return {
|
||||
runtimeTeamState,
|
||||
clearRuntimeTeamState,
|
||||
triggerRuntimeTeamFormation,
|
||||
handleRuntimeTeamAfterSend,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
import { createTeamDefinitionFromPreset } from "../utils/teamDefinitions";
|
||||
import { useSelectedTeamPreference } from "./useSelectedTeamPreference";
|
||||
@@ -9,31 +10,42 @@ import {
|
||||
persistSelectedTeam,
|
||||
} from "../utils/teamStorage";
|
||||
|
||||
type HookOptions = Parameters<typeof useSelectedTeamPreference>[1];
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useSelectedTeamPreference>;
|
||||
rerender: (theme?: string | null) => void;
|
||||
rerender: (theme?: string | null, options?: HookOptions) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(initialTheme?: string | null): HookHarness {
|
||||
function mountHook(
|
||||
initialTheme?: string | null,
|
||||
initialOptions?: HookOptions,
|
||||
): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useSelectedTeamPreference> | null = null;
|
||||
|
||||
function TestComponent({ theme }: { theme?: string | null }) {
|
||||
hookValue = useSelectedTeamPreference(theme);
|
||||
function TestComponent({
|
||||
theme,
|
||||
options,
|
||||
}: {
|
||||
theme?: string | null;
|
||||
options?: HookOptions;
|
||||
}) {
|
||||
hookValue = useSelectedTeamPreference(theme, options);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (theme?: string | null) => {
|
||||
const render = (theme?: string | null, options?: HookOptions) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent theme={theme} />);
|
||||
root.render(<TestComponent theme={theme} options={options} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialTheme);
|
||||
render(initialTheme, initialOptions);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
@@ -116,4 +128,170 @@ describe("useSelectedTeamPreference", () => {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("项目级 Team 偏好应优先于 localStorage,并通过回调持久化", async () => {
|
||||
const engineeringTeam = createTeamDefinitionFromPreset(
|
||||
"code-triage-team",
|
||||
) as TeamDefinition;
|
||||
const researchTeam = createTeamDefinitionFromPreset(
|
||||
"research-team",
|
||||
) as TeamDefinition;
|
||||
const projectSettings: WorkspaceSettings = {
|
||||
agentTeam: {
|
||||
selectedTeam: {
|
||||
id: engineeringTeam.id,
|
||||
source: engineeringTeam.source,
|
||||
},
|
||||
},
|
||||
};
|
||||
const persistSpy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
persistSelectedTeam(researchTeam, "general");
|
||||
|
||||
const harness = mountHook("general", {
|
||||
projectSettings,
|
||||
onPersistSelectedTeam: persistSpy,
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setSelectedTeam(null);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(persistSpy).toHaveBeenCalledWith(null);
|
||||
expect(loadSelectedTeamReference("general")).toEqual({
|
||||
id: "research-team",
|
||||
source: "builtin",
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("项目级自定义 Team 应能直接解析为当前选择", async () => {
|
||||
const harness = mountHook("general", {
|
||||
projectSettings: {
|
||||
agentTeam: {
|
||||
selectedTeam: {
|
||||
id: "custom-team-project-1",
|
||||
source: "custom",
|
||||
},
|
||||
customTeams: [
|
||||
{
|
||||
id: "custom-team-project-1",
|
||||
label: "前端联调团队",
|
||||
description: "分析、实现、验证三段式推进。",
|
||||
roles: [
|
||||
{
|
||||
id: "planner",
|
||||
label: "分析",
|
||||
summary: "负责拆解问题与确认边界。",
|
||||
profileId: "code-explorer",
|
||||
roleKey: "explorer",
|
||||
skillIds: ["source-grounding"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(harness.getValue().selectedTeam?.id).toBe("custom-team-project-1");
|
||||
expect(harness.getValue().selectedTeam?.label).toBe("前端联调团队");
|
||||
expect(harness.getValue().selectedTeam?.source).toBe("custom");
|
||||
expect(harness.getValue().selectedTeam?.roles[0]?.profileId).toBe(
|
||||
"code-explorer",
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("项目级持久化失败时应回滚到当前项目 Team", async () => {
|
||||
const engineeringTeam = createTeamDefinitionFromPreset(
|
||||
"code-triage-team",
|
||||
) as TeamDefinition;
|
||||
const researchTeam = createTeamDefinitionFromPreset(
|
||||
"research-team",
|
||||
) as TeamDefinition;
|
||||
const persistSpy = vi.fn().mockRejectedValue(new Error("save failed"));
|
||||
|
||||
const harness = mountHook("general", {
|
||||
projectSettings: {
|
||||
agentTeam: {
|
||||
selectedTeam: {
|
||||
id: engineeringTeam.id,
|
||||
source: engineeringTeam.source,
|
||||
},
|
||||
},
|
||||
},
|
||||
onPersistSelectedTeam: persistSpy,
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setSelectedTeam(researchTeam);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(persistSpy).toHaveBeenCalledWith(researchTeam);
|
||||
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("ephemeral Team 不应写入项目级或本地持久化", async () => {
|
||||
const persistSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const ephemeralTeam: TeamDefinition = {
|
||||
id: "ephemeral-team-generated",
|
||||
source: "ephemeral",
|
||||
label: "本轮 Team",
|
||||
description: "仅本轮会话使用",
|
||||
roles: [
|
||||
{
|
||||
id: "planner",
|
||||
label: "分析",
|
||||
summary: "负责拆解任务。",
|
||||
},
|
||||
],
|
||||
};
|
||||
const harness = mountHook("general", {
|
||||
projectSettings: {
|
||||
agentTeam: {
|
||||
selectedTeam: {
|
||||
id: "code-triage-team",
|
||||
source: "builtin",
|
||||
},
|
||||
},
|
||||
},
|
||||
onPersistSelectedTeam: persistSpy,
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
act(() => {
|
||||
harness.getValue().setSelectedTeam(ephemeralTeam);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(harness.getValue().selectedTeam?.id).toBe(
|
||||
"ephemeral-team-generated",
|
||||
);
|
||||
expect(persistSpy).not.toHaveBeenCalled();
|
||||
expect(loadSelectedTeamReference("general")).toBeNull();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkspaceSettings } from "@/types/workspace";
|
||||
import type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
import {
|
||||
buildTeamDefinitionLabel,
|
||||
@@ -7,24 +8,55 @@ import {
|
||||
} from "../utils/teamDefinitions";
|
||||
import {
|
||||
persistSelectedTeam,
|
||||
resolvePersistedSelectedTeam,
|
||||
resolveSelectedTeamPreference,
|
||||
} from "../utils/teamStorage";
|
||||
|
||||
export function useSelectedTeamPreference(theme?: string | null) {
|
||||
interface UseSelectedTeamPreferenceOptions {
|
||||
projectSettings?: WorkspaceSettings | null;
|
||||
onPersistSelectedTeam?: (team: TeamDefinition | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useSelectedTeamPreference(
|
||||
theme?: string | null,
|
||||
options: UseSelectedTeamPreferenceOptions = {},
|
||||
) {
|
||||
const { projectSettings, onPersistSelectedTeam } = options;
|
||||
const resolveCurrentSelection = useCallback(
|
||||
() =>
|
||||
resolveSelectedTeamPreference({
|
||||
theme,
|
||||
workspaceSettings: projectSettings,
|
||||
}),
|
||||
[projectSettings, theme],
|
||||
);
|
||||
const [selectedTeam, setSelectedTeamState] = useState<TeamDefinition | null>(
|
||||
() => resolvePersistedSelectedTeam(theme),
|
||||
() => resolveCurrentSelection(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTeamState(resolvePersistedSelectedTeam(theme));
|
||||
}, [theme]);
|
||||
setSelectedTeamState(resolveCurrentSelection());
|
||||
}, [resolveCurrentSelection]);
|
||||
|
||||
const setSelectedTeam = useCallback(
|
||||
(team: TeamDefinition | null) => {
|
||||
persistSelectedTeam(team, theme);
|
||||
setSelectedTeamState(team);
|
||||
|
||||
if (onPersistSelectedTeam && team?.source !== "ephemeral") {
|
||||
const fallbackTeam = resolveCurrentSelection();
|
||||
void Promise.resolve(onPersistSelectedTeam(team)).catch((error) => {
|
||||
console.warn("[Team] 持久化项目级 Team 偏好失败:", error);
|
||||
setSelectedTeamState(fallbackTeam);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (onPersistSelectedTeam && team?.source === "ephemeral") {
|
||||
return;
|
||||
}
|
||||
|
||||
persistSelectedTeam(team, theme);
|
||||
},
|
||||
[theme],
|
||||
[onPersistSelectedTeam, resolveCurrentSelection, theme],
|
||||
);
|
||||
|
||||
const enableSuggestedTeam = useCallback(
|
||||
|
||||
@@ -244,6 +244,148 @@ describe("useTeamWorkspaceRuntime", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("收到 turn_completed 后,应立即把 live runtime 从 running 回落到 completed", async () => {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
(event: { payload: unknown }) => void
|
||||
>();
|
||||
mockSafeListen.mockImplementation(
|
||||
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
|
||||
listeners.set(eventName, handler);
|
||||
return () => {
|
||||
listeners.delete(eventName);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await renderHookProbe({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
listeners.get("agent_subagent_stream:child-1")?.({
|
||||
payload: {
|
||||
type: "turn_completed",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(
|
||||
latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus,
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("收到 final_done 且没有后续状态事件时,也应结束 running 状态", async () => {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
(event: { payload: unknown }) => void
|
||||
>();
|
||||
mockSafeListen.mockImplementation(
|
||||
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
|
||||
listeners.set(eventName, handler);
|
||||
return () => {
|
||||
listeners.delete(eventName);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await renderHookProbe({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
listeners.get("agent_subagent_stream:child-1")?.({
|
||||
payload: {
|
||||
type: "final_done",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
|
||||
"completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("收到 error 后,应立即把 live runtime 从 running 回落到 failed", async () => {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
(event: { payload: unknown }) => void
|
||||
>();
|
||||
mockSafeListen.mockImplementation(
|
||||
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
|
||||
listeners.set(eventName, handler);
|
||||
return () => {
|
||||
listeners.delete(eventName);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await renderHookProbe({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
task_summary: "整理竞品与数据来源",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
listeners.get("agent_subagent_stream:child-1")?.({
|
||||
payload: {
|
||||
type: "error",
|
||||
message: "工具调用失败",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
|
||||
"failed",
|
||||
);
|
||||
expect(
|
||||
latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus,
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("base snapshot 追平或 session 移除后,应自动清理过期 live 状态", async () => {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
|
||||
@@ -50,6 +50,7 @@ interface TeamWorkspaceRuntimeStreamProjection {
|
||||
forgetToolId?: string;
|
||||
runtimeStatus?: TeamWorkspaceRuntimeStatus;
|
||||
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
|
||||
queuedTurnCount?: number;
|
||||
refreshPreview?: boolean;
|
||||
}
|
||||
|
||||
@@ -272,7 +273,12 @@ function buildLiveRuntimeState(
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot,
|
||||
baseFingerprint: string,
|
||||
current: TeamWorkspaceLiveRuntimeState | undefined,
|
||||
patch: Pick<TeamWorkspaceLiveRuntimeState, "runtimeStatus" | "latestTurnStatus">,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
TeamWorkspaceLiveRuntimeState,
|
||||
"runtimeStatus" | "latestTurnStatus" | "queuedTurnCount"
|
||||
>
|
||||
>,
|
||||
): TeamWorkspaceLiveRuntimeState {
|
||||
return {
|
||||
runtimeStatus:
|
||||
@@ -285,17 +291,71 @@ function buildLiveRuntimeState(
|
||||
current?.latestTurnStatus ??
|
||||
session.latestTurnStatus ??
|
||||
session.runtimeStatus,
|
||||
queuedTurnCount:
|
||||
patch.queuedTurnCount ??
|
||||
current?.queuedTurnCount ??
|
||||
session.queuedTurnCount,
|
||||
baseFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function getQueuedTurnCount(
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot,
|
||||
current?: TeamWorkspaceLiveRuntimeState,
|
||||
): number {
|
||||
return current?.queuedTurnCount ?? session.queuedTurnCount ?? 0;
|
||||
}
|
||||
|
||||
function resolveQueueDrainedRuntimeStatus(
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot,
|
||||
current?: TeamWorkspaceLiveRuntimeState,
|
||||
): TeamWorkspaceRuntimeStatus {
|
||||
const candidate = [
|
||||
current?.latestTurnStatus,
|
||||
session.latestTurnStatus,
|
||||
current?.runtimeStatus,
|
||||
session.runtimeStatus,
|
||||
].find((status) => status && status !== "queued" && status !== "running");
|
||||
|
||||
return candidate ?? "idle";
|
||||
}
|
||||
|
||||
function resolveFinalRuntimeStatus(params: {
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot;
|
||||
current?: TeamWorkspaceLiveRuntimeState;
|
||||
terminalStatus?: Extract<TeamWorkspaceRuntimeStatus, "completed" | "failed">;
|
||||
queuedTurnCount?: number;
|
||||
}): TeamWorkspaceRuntimeStatus {
|
||||
const queuedTurnCount =
|
||||
params.queuedTurnCount ?? getQueuedTurnCount(params.session, params.current);
|
||||
if (queuedTurnCount > 0) {
|
||||
return "queued";
|
||||
}
|
||||
|
||||
if (params.terminalStatus) {
|
||||
return params.terminalStatus;
|
||||
}
|
||||
|
||||
const candidate = [
|
||||
params.current?.latestTurnStatus,
|
||||
params.session.latestTurnStatus,
|
||||
params.current?.runtimeStatus,
|
||||
params.session.runtimeStatus,
|
||||
].find((status) => status && status !== "queued" && status !== "running");
|
||||
|
||||
return candidate ?? "completed";
|
||||
}
|
||||
|
||||
function projectRuntimeStreamEvent(params: {
|
||||
sessionId: string;
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot;
|
||||
event: StreamEvent;
|
||||
currentRuntime?: TeamWorkspaceLiveRuntimeState;
|
||||
streamState?: SessionLiveStreamState;
|
||||
toolNameById?: Record<string, string>;
|
||||
}): TeamWorkspaceRuntimeStreamProjection | null {
|
||||
const { sessionId, event, streamState, toolNameById } = params;
|
||||
const { sessionId, session, event, currentRuntime, streamState, toolNameById } =
|
||||
params;
|
||||
|
||||
switch (event.type) {
|
||||
case "item_started":
|
||||
@@ -385,6 +445,7 @@ function projectRuntimeStreamEvent(params: {
|
||||
}),
|
||||
runtimeStatus: "queued",
|
||||
latestTurnStatus: "queued",
|
||||
queuedTurnCount: getQueuedTurnCount(session, currentRuntime) + 1,
|
||||
};
|
||||
case "queue_started":
|
||||
return {
|
||||
@@ -398,10 +459,29 @@ function projectRuntimeStreamEvent(params: {
|
||||
}),
|
||||
runtimeStatus: "running",
|
||||
latestTurnStatus: "running",
|
||||
queuedTurnCount: Math.max(getQueuedTurnCount(session, currentRuntime) - 1, 0),
|
||||
};
|
||||
case "queue_removed":
|
||||
case "queue_removed": {
|
||||
const queuedTurnCount = Math.max(
|
||||
getQueuedTurnCount(session, currentRuntime) - 1,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
queuedTurnCount,
|
||||
runtimeStatus:
|
||||
queuedTurnCount === 0 && currentRuntime?.runtimeStatus === "queued"
|
||||
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
|
||||
: undefined,
|
||||
refreshPreview: true,
|
||||
};
|
||||
}
|
||||
case "queue_cleared":
|
||||
return {
|
||||
queuedTurnCount: 0,
|
||||
runtimeStatus:
|
||||
currentRuntime?.runtimeStatus === "queued"
|
||||
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
|
||||
: undefined,
|
||||
refreshPreview: true,
|
||||
};
|
||||
case "turn_started":
|
||||
@@ -418,26 +498,37 @@ function projectRuntimeStreamEvent(params: {
|
||||
latestTurnStatus: "running",
|
||||
};
|
||||
case "turn_completed":
|
||||
return {
|
||||
entry: buildLifecycleActivityEntry({
|
||||
sessionId,
|
||||
key: "turn",
|
||||
title: "回合完成",
|
||||
detail: "当前回合已完成,正在等待快照同步。",
|
||||
statusLabel: "完成",
|
||||
badgeClassName: COMPLETED_BADGE_CLASS_NAME,
|
||||
}),
|
||||
latestTurnStatus: "completed",
|
||||
clearEntryIds: [
|
||||
`stream-text:${sessionId}`,
|
||||
`stream-thinking:${sessionId}`,
|
||||
`runtime-status:${sessionId}`,
|
||||
],
|
||||
clearTextDraft: true,
|
||||
clearThinkingDraft: true,
|
||||
refreshPreview: true,
|
||||
};
|
||||
case "turn_failed":
|
||||
{
|
||||
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
|
||||
return {
|
||||
entry: buildLifecycleActivityEntry({
|
||||
sessionId,
|
||||
key: "turn",
|
||||
title: "回合完成",
|
||||
detail: "当前回合已完成,正在等待快照同步。",
|
||||
statusLabel: "完成",
|
||||
badgeClassName: COMPLETED_BADGE_CLASS_NAME,
|
||||
}),
|
||||
runtimeStatus: resolveFinalRuntimeStatus({
|
||||
session,
|
||||
current: currentRuntime,
|
||||
terminalStatus: "completed",
|
||||
queuedTurnCount,
|
||||
}),
|
||||
latestTurnStatus: "completed",
|
||||
queuedTurnCount,
|
||||
clearEntryIds: [
|
||||
`stream-text:${sessionId}`,
|
||||
`stream-thinking:${sessionId}`,
|
||||
`runtime-status:${sessionId}`,
|
||||
],
|
||||
clearTextDraft: true,
|
||||
clearThinkingDraft: true,
|
||||
refreshPreview: true,
|
||||
};
|
||||
}
|
||||
case "turn_failed": {
|
||||
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
|
||||
return {
|
||||
entry: buildLifecycleActivityEntry({
|
||||
sessionId,
|
||||
@@ -448,7 +539,14 @@ function projectRuntimeStreamEvent(params: {
|
||||
statusLabel: "失败",
|
||||
badgeClassName: FAILED_BADGE_CLASS_NAME,
|
||||
}),
|
||||
runtimeStatus: resolveFinalRuntimeStatus({
|
||||
session,
|
||||
current: currentRuntime,
|
||||
terminalStatus: "failed",
|
||||
queuedTurnCount,
|
||||
}),
|
||||
latestTurnStatus: "failed",
|
||||
queuedTurnCount,
|
||||
clearEntryIds: [
|
||||
`stream-text:${sessionId}`,
|
||||
`stream-thinking:${sessionId}`,
|
||||
@@ -458,15 +556,24 @@ function projectRuntimeStreamEvent(params: {
|
||||
clearThinkingDraft: true,
|
||||
refreshPreview: true,
|
||||
};
|
||||
}
|
||||
case "warning":
|
||||
return {
|
||||
entry: buildWarningActivityEntry(sessionId, event.message, event.code),
|
||||
refreshPreview: true,
|
||||
};
|
||||
case "error":
|
||||
case "error": {
|
||||
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
|
||||
return {
|
||||
entry: buildErrorActivityEntry(sessionId, event.message),
|
||||
runtimeStatus: resolveFinalRuntimeStatus({
|
||||
session,
|
||||
current: currentRuntime,
|
||||
terminalStatus: "failed",
|
||||
queuedTurnCount,
|
||||
}),
|
||||
latestTurnStatus: "failed",
|
||||
queuedTurnCount,
|
||||
clearEntryIds: [
|
||||
`stream-text:${sessionId}`,
|
||||
`stream-thinking:${sessionId}`,
|
||||
@@ -476,10 +583,18 @@ function projectRuntimeStreamEvent(params: {
|
||||
clearThinkingDraft: true,
|
||||
refreshPreview: true,
|
||||
};
|
||||
}
|
||||
case "done":
|
||||
return null;
|
||||
case "final_done":
|
||||
case "final_done": {
|
||||
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
|
||||
return {
|
||||
runtimeStatus: resolveFinalRuntimeStatus({
|
||||
session,
|
||||
current: currentRuntime,
|
||||
queuedTurnCount,
|
||||
}),
|
||||
queuedTurnCount,
|
||||
clearEntryIds: [
|
||||
`stream-text:${sessionId}`,
|
||||
`stream-thinking:${sessionId}`,
|
||||
@@ -489,6 +604,7 @@ function projectRuntimeStreamEvent(params: {
|
||||
clearThinkingDraft: true,
|
||||
refreshPreview: true,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -551,6 +667,9 @@ export function useTeamWorkspaceRuntime(
|
||||
const [liveRuntimeBySessionId, setLiveRuntimeBySessionId] = useState<
|
||||
Record<string, TeamWorkspaceLiveRuntimeState>
|
||||
>({});
|
||||
const liveRuntimeBySessionIdRef = useRef<
|
||||
Record<string, TeamWorkspaceLiveRuntimeState>
|
||||
>({});
|
||||
const [liveActivityBySessionId, setLiveActivityBySessionId] = useState<
|
||||
Record<string, TeamWorkspaceActivityEntry[]>
|
||||
>({});
|
||||
@@ -614,6 +733,10 @@ export function useTeamWorkspaceRuntime(
|
||||
baseFingerprintByIdRef.current = baseFingerprintById;
|
||||
}, [activeSnapshotById, baseFingerprintById]);
|
||||
|
||||
useEffect(() => {
|
||||
liveRuntimeBySessionIdRef.current = liveRuntimeBySessionId;
|
||||
}, [liveRuntimeBySessionId]);
|
||||
|
||||
const scheduleActivityRefresh = useCallback((sessionId: string) => {
|
||||
if (refreshTimersRef.current[sessionId] !== undefined) {
|
||||
return;
|
||||
@@ -742,9 +865,18 @@ export function useTeamWorkspaceRuntime(
|
||||
|
||||
setLiveRuntimeBySessionId((previous) => {
|
||||
const current = previous[data.session_id];
|
||||
const nextQueuedTurnCount =
|
||||
normalizedStatus === "completed" ||
|
||||
normalizedStatus === "failed" ||
|
||||
normalizedStatus === "aborted" ||
|
||||
normalizedStatus === "closed" ||
|
||||
normalizedStatus === "idle"
|
||||
? 0
|
||||
: current?.queuedTurnCount ?? matchingSession.queuedTurnCount;
|
||||
if (
|
||||
current?.runtimeStatus === normalizedStatus &&
|
||||
current.latestTurnStatus === normalizedStatus &&
|
||||
current.queuedTurnCount === nextQueuedTurnCount &&
|
||||
current.baseFingerprint === baseFingerprint
|
||||
) {
|
||||
return previous;
|
||||
@@ -752,11 +884,16 @@ export function useTeamWorkspaceRuntime(
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[data.session_id]: {
|
||||
runtimeStatus: normalizedStatus,
|
||||
latestTurnStatus: normalizedStatus,
|
||||
[data.session_id]: buildLiveRuntimeState(
|
||||
matchingSession,
|
||||
baseFingerprint,
|
||||
},
|
||||
current,
|
||||
{
|
||||
runtimeStatus: normalizedStatus,
|
||||
latestTurnStatus: normalizedStatus,
|
||||
queuedTurnCount: nextQueuedTurnCount,
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -830,7 +967,9 @@ export function useTeamWorkspaceRuntime(
|
||||
|
||||
const projection = projectRuntimeStreamEvent({
|
||||
sessionId,
|
||||
session: matchingSession,
|
||||
event: data,
|
||||
currentRuntime: liveRuntimeBySessionIdRef.current[sessionId],
|
||||
streamState: liveStreamStateBySessionIdRef.current[sessionId],
|
||||
toolNameById: toolNameBySessionIdRef.current[sessionId],
|
||||
});
|
||||
@@ -891,7 +1030,8 @@ export function useTeamWorkspaceRuntime(
|
||||
|
||||
if (
|
||||
projection.runtimeStatus !== undefined ||
|
||||
projection.latestTurnStatus !== undefined
|
||||
projection.latestTurnStatus !== undefined ||
|
||||
projection.queuedTurnCount !== undefined
|
||||
) {
|
||||
setLiveRuntimeBySessionId((previous) => {
|
||||
const current = previous[sessionId];
|
||||
@@ -902,6 +1042,7 @@ export function useTeamWorkspaceRuntime(
|
||||
{
|
||||
runtimeStatus: projection.runtimeStatus,
|
||||
latestTurnStatus: projection.latestTurnStatus,
|
||||
queuedTurnCount: projection.queuedTurnCount,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -909,6 +1050,7 @@ export function useTeamWorkspaceRuntime(
|
||||
current &&
|
||||
current.runtimeStatus === nextState.runtimeStatus &&
|
||||
current.latestTurnStatus === nextState.latestTurnStatus &&
|
||||
current.queuedTurnCount === nextState.queuedTurnCount &&
|
||||
current.baseFingerprint === nextState.baseFingerprint
|
||||
) {
|
||||
return previous;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { saveChatToolPreferences } from "../utils/chatToolPreferences";
|
||||
import { useThemeScopedChatToolPreferences } from "./useThemeScopedChatToolPreferences";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useThemeScopedChatToolPreferences>;
|
||||
rerender: (activeTheme: string) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(initialTheme: string): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useThemeScopedChatToolPreferences> | null =
|
||||
null;
|
||||
|
||||
function TestComponent({ activeTheme }: { activeTheme: string }) {
|
||||
hookValue = useThemeScopedChatToolPreferences(activeTheme);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (activeTheme: string) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent activeTheme={activeTheme} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialTheme);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useThemeScopedChatToolPreferences", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("应按当前主题读取工具偏好", () => {
|
||||
saveChatToolPreferences(
|
||||
{ webSearch: true, thinking: false, task: true, subagent: false },
|
||||
"general",
|
||||
);
|
||||
|
||||
const harness = mountHook("general");
|
||||
|
||||
try {
|
||||
expect(harness.getValue().chatToolPreferences).toEqual({
|
||||
webSearch: true,
|
||||
thinking: false,
|
||||
task: true,
|
||||
subagent: false,
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("切换主题时应切换作用域,并在更新后持久化当前主题", () => {
|
||||
saveChatToolPreferences(
|
||||
{ webSearch: true, thinking: false, task: false, subagent: false },
|
||||
"general",
|
||||
);
|
||||
saveChatToolPreferences(
|
||||
{ webSearch: false, thinking: true, task: true, subagent: true },
|
||||
"social-media",
|
||||
);
|
||||
|
||||
const harness = mountHook("general");
|
||||
|
||||
try {
|
||||
harness.rerender("social-media");
|
||||
expect(harness.getValue().chatToolPreferences).toEqual({
|
||||
webSearch: false,
|
||||
thinking: true,
|
||||
task: true,
|
||||
subagent: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
harness.getValue().setChatToolPreferences({
|
||||
webSearch: true,
|
||||
thinking: true,
|
||||
task: false,
|
||||
subagent: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("lime.chat.tool_preferences.social-media.v3") ||
|
||||
"null",
|
||||
),
|
||||
).toEqual({
|
||||
webSearch: true,
|
||||
thinking: true,
|
||||
task: false,
|
||||
subagent: true,
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
loadChatToolPreferences,
|
||||
saveChatToolPreferences,
|
||||
type ChatToolPreferences,
|
||||
} from "../utils/chatToolPreferences";
|
||||
|
||||
export function useThemeScopedChatToolPreferences(activeTheme: string) {
|
||||
const [chatToolPreferences, setChatToolPreferences] =
|
||||
useState<ChatToolPreferences>(() => loadChatToolPreferences(activeTheme));
|
||||
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
|
||||
useState<string>(activeTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme === activeTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatToolPreferences(loadChatToolPreferences(activeTheme));
|
||||
setChatToolPreferencesTheme(activeTheme);
|
||||
}, [activeTheme, chatToolPreferencesTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme !== activeTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveChatToolPreferences(chatToolPreferences, activeTheme);
|
||||
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
|
||||
|
||||
return {
|
||||
chatToolPreferences,
|
||||
setChatToolPreferences,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useThemeWorkbenchEntryPrompt } from "./useThemeWorkbenchEntryPrompt";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPrompt>;
|
||||
rerender: (props?: Partial<HookProps>) => void;
|
||||
unmount: () => void;
|
||||
onHydrateInitialPrompt: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface HookProps {
|
||||
activeTheme: string;
|
||||
contentId?: string;
|
||||
sessionId?: string;
|
||||
isThemeWorkbench: boolean;
|
||||
shouldUseCompactThemeWorkbench: boolean;
|
||||
messagesCount: number;
|
||||
initialDispatchKey: string | null;
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: Array<{ data: string; mediaType: string }>;
|
||||
consumedInitialPromptKey?: string | null;
|
||||
}
|
||||
|
||||
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onHydrateInitialPrompt = vi.fn();
|
||||
|
||||
const loadWorkflow = vi.fn(async () => null);
|
||||
const loadRunState = vi.fn(async () => null);
|
||||
|
||||
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null = null;
|
||||
let currentProps: HookProps = {
|
||||
activeTheme: "social-media",
|
||||
contentId: "content-1",
|
||||
sessionId: "session-1",
|
||||
isThemeWorkbench: true,
|
||||
shouldUseCompactThemeWorkbench: false,
|
||||
messagesCount: 0,
|
||||
initialDispatchKey: null,
|
||||
initialUserPrompt: "",
|
||||
initialUserImages: [],
|
||||
consumedInitialPromptKey: null,
|
||||
...initialProps,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useThemeWorkbenchEntryPrompt({
|
||||
...currentProps,
|
||||
onHydrateInitialPrompt,
|
||||
loadWorkflow,
|
||||
loadRunState,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (nextProps?: Partial<HookProps>) => {
|
||||
currentProps = {
|
||||
...currentProps,
|
||||
...nextProps,
|
||||
};
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
onHydrateInitialPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects(times = 4) {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("useThemeWorkbenchEntryPrompt", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("主题工作台初始意图应先进入预填提示态", async () => {
|
||||
const harness = mountHook({
|
||||
initialDispatchKey: "initial-dispatch",
|
||||
initialUserPrompt: "请先生成社媒主稿",
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(harness.onHydrateInitialPrompt).toHaveBeenCalledWith(
|
||||
"请先生成社媒主稿",
|
||||
"initial-dispatch",
|
||||
);
|
||||
expect(harness.getValue().themeWorkbenchEntryPrompt).toMatchObject({
|
||||
kind: "initial_prompt",
|
||||
prompt: "请先生成社媒主稿",
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("无初始意图时应查询 resume prompt", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onHydrateInitialPrompt = vi.fn();
|
||||
const loadWorkflow = vi.fn(async () => ({
|
||||
id: "wf-1",
|
||||
content_id: "content-1",
|
||||
theme: "social-media",
|
||||
mode: "guided",
|
||||
current_step_index: 1,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
steps: [
|
||||
{
|
||||
id: "step-1",
|
||||
title: "撰写主稿",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
title: "润色结尾",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
}));
|
||||
const loadRunState = vi.fn(async () => null);
|
||||
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useThemeWorkbenchEntryPrompt({
|
||||
activeTheme: "social-media",
|
||||
contentId: "content-1",
|
||||
sessionId: "session-1",
|
||||
isThemeWorkbench: true,
|
||||
shouldUseCompactThemeWorkbench: false,
|
||||
messagesCount: 0,
|
||||
initialDispatchKey: null,
|
||||
initialUserPrompt: "",
|
||||
initialUserImages: [],
|
||||
consumedInitialPromptKey: null,
|
||||
onHydrateInitialPrompt,
|
||||
loadWorkflow,
|
||||
loadRunState,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
expect(loadWorkflow).toHaveBeenCalledWith("content-1");
|
||||
expect(hookValue?.themeWorkbenchEntryPrompt).toMatchObject({
|
||||
kind: "resume",
|
||||
title: "发现上次未完成任务",
|
||||
});
|
||||
expect(hookValue?.themeWorkbenchEntryCheckPending).toBe(false);
|
||||
} finally {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
contentWorkflowApi,
|
||||
type WorkflowState as ContentWorkflowState,
|
||||
} from "@/lib/api/content-workflow";
|
||||
import {
|
||||
executionRunGetThemeWorkbenchState,
|
||||
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
|
||||
type ThemeWorkbenchRunTerminalItem,
|
||||
type ThemeWorkbenchRunTodoItem,
|
||||
} from "@/lib/api/executionRun";
|
||||
import type { MessageImage } from "../types";
|
||||
|
||||
export interface ThemeWorkbenchEntryPromptState {
|
||||
kind: "initial_prompt" | "resume";
|
||||
signature: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface UseThemeWorkbenchEntryPromptOptions {
|
||||
activeTheme: string;
|
||||
contentId?: string;
|
||||
sessionId?: string;
|
||||
isThemeWorkbench: boolean;
|
||||
shouldUseCompactThemeWorkbench: boolean;
|
||||
messagesCount: number;
|
||||
initialDispatchKey: string | null;
|
||||
initialUserPrompt?: string;
|
||||
initialUserImages?: MessageImage[];
|
||||
consumedInitialPromptKey?: string | null;
|
||||
onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void;
|
||||
loadWorkflow?: (contentId: string) => Promise<ContentWorkflowState | null>;
|
||||
loadRunState?: (
|
||||
sessionId: string,
|
||||
) => Promise<BackendThemeWorkbenchRunState | null>;
|
||||
}
|
||||
|
||||
const defaultLoadThemeWorkbenchWorkflow = (contentId: string) =>
|
||||
contentWorkflowApi.getByContent(contentId);
|
||||
|
||||
const defaultLoadThemeWorkbenchRunState = (sessionId: string) =>
|
||||
executionRunGetThemeWorkbenchState(sessionId, 3);
|
||||
|
||||
function resolveThemeWorkbenchGateLabel(
|
||||
gateKey?: ThemeWorkbenchRunTodoItem["gate_key"],
|
||||
): string | null {
|
||||
switch (gateKey) {
|
||||
case "topic_select":
|
||||
return "选题确认";
|
||||
case "write_mode":
|
||||
return "写作推进";
|
||||
case "publish_confirm":
|
||||
return "发布确认";
|
||||
case "idle":
|
||||
case null:
|
||||
case undefined:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasWorkflowMeaningfulProgress(
|
||||
workflow: ContentWorkflowState | null,
|
||||
): boolean {
|
||||
if (!workflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (workflow.current_step_index > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return workflow.steps.some(
|
||||
(step) =>
|
||||
step.status === "completed" ||
|
||||
step.status === "skipped" ||
|
||||
step.status === "error" ||
|
||||
Boolean(step.result),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildThemeWorkbenchResumePromptFromWorkflow(
|
||||
workflow: ContentWorkflowState | null,
|
||||
): ThemeWorkbenchEntryPromptState | null {
|
||||
if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPendingStep = workflow.steps.some(
|
||||
(step) => step.status !== "completed" && step.status !== "skipped",
|
||||
);
|
||||
if (!hasPendingStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeStep =
|
||||
workflow.steps.find(
|
||||
(step) =>
|
||||
step.status === "active" ||
|
||||
step.status === "pending" ||
|
||||
step.status === "error",
|
||||
) || workflow.steps[workflow.current_step_index];
|
||||
const stepTitle = activeStep?.title?.trim() || "当前创作阶段";
|
||||
|
||||
return {
|
||||
kind: "resume",
|
||||
signature: `workflow:${workflow.id}:${workflow.updated_at}:${activeStep?.id || ""}`,
|
||||
title: "发现上次未完成任务",
|
||||
description: `检测到当前文稿上次停留在“${stepTitle}”,可以直接衔接已有进度继续。`,
|
||||
actionLabel: "继续上次任务",
|
||||
prompt: `请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“${stepTitle}”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThemeWorkbenchPendingRunCandidate(
|
||||
state: BackendThemeWorkbenchRunState | null,
|
||||
): ThemeWorkbenchRunTodoItem | ThemeWorkbenchRunTerminalItem | null {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeQueueItem = (state.queue_items || []).find((item) =>
|
||||
["queued", "running", "error", "timeout"].includes(item.status),
|
||||
);
|
||||
if (activeQueueItem) {
|
||||
return activeQueueItem;
|
||||
}
|
||||
|
||||
if (
|
||||
state.latest_terminal &&
|
||||
["queued", "running", "error", "timeout"].includes(
|
||||
state.latest_terminal.status,
|
||||
)
|
||||
) {
|
||||
return state.latest_terminal;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildThemeWorkbenchResumePromptFromRunState(
|
||||
state: BackendThemeWorkbenchRunState | null,
|
||||
): ThemeWorkbenchEntryPromptState | null {
|
||||
const pendingRun = resolveThemeWorkbenchPendingRunCandidate(state);
|
||||
if (!pendingRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runTitle = pendingRun.title?.trim() || "最近一次创作任务";
|
||||
const gateLabel = resolveThemeWorkbenchGateLabel(pendingRun.gate_key);
|
||||
const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : "";
|
||||
|
||||
return {
|
||||
kind: "resume",
|
||||
signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`,
|
||||
title: "发现上次未完成任务",
|
||||
description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`,
|
||||
actionLabel: "继续上次任务",
|
||||
prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemeWorkbenchEntryPrompt({
|
||||
activeTheme,
|
||||
contentId,
|
||||
sessionId,
|
||||
isThemeWorkbench,
|
||||
shouldUseCompactThemeWorkbench,
|
||||
messagesCount,
|
||||
initialDispatchKey,
|
||||
initialUserPrompt,
|
||||
initialUserImages,
|
||||
consumedInitialPromptKey,
|
||||
onHydrateInitialPrompt,
|
||||
loadWorkflow = defaultLoadThemeWorkbenchWorkflow,
|
||||
loadRunState = defaultLoadThemeWorkbenchRunState,
|
||||
}: UseThemeWorkbenchEntryPromptOptions) {
|
||||
const [themeWorkbenchEntryPrompt, setThemeWorkbenchEntryPrompt] =
|
||||
useState<ThemeWorkbenchEntryPromptState | null>(null);
|
||||
const [themeWorkbenchEntryCheckPending, setThemeWorkbenchEntryCheckPending] =
|
||||
useState(false);
|
||||
const hydratedPromptSignatureRef = useRef<string | null>(null);
|
||||
const dismissedPromptSignatureRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hydratedPromptSignatureRef.current = null;
|
||||
dismissedPromptSignatureRef.current = null;
|
||||
setThemeWorkbenchEntryPrompt(null);
|
||||
setThemeWorkbenchEntryCheckPending(false);
|
||||
}, [activeTheme, contentId, initialDispatchKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUseCompactThemeWorkbench) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingInitialPrompt = (initialUserPrompt || "").trim();
|
||||
const pendingInitialImages = initialUserImages || [];
|
||||
if (
|
||||
!isThemeWorkbench ||
|
||||
!contentId ||
|
||||
!initialDispatchKey ||
|
||||
!pendingInitialPrompt ||
|
||||
pendingInitialImages.length > 0 ||
|
||||
messagesCount > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
consumedInitialPromptKey === initialDispatchKey ||
|
||||
hydratedPromptSignatureRef.current === initialDispatchKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hydratedPromptSignatureRef.current = initialDispatchKey;
|
||||
onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey);
|
||||
setThemeWorkbenchEntryPrompt({
|
||||
kind: "initial_prompt",
|
||||
signature: initialDispatchKey,
|
||||
title: "已恢复待执行创作意图",
|
||||
description: "进入页面后不会自动开始生成,确认后再继续。",
|
||||
actionLabel: "继续生成",
|
||||
prompt: pendingInitialPrompt,
|
||||
});
|
||||
}, [
|
||||
consumedInitialPromptKey,
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
initialUserImages,
|
||||
initialUserPrompt,
|
||||
isThemeWorkbench,
|
||||
messagesCount,
|
||||
onHydrateInitialPrompt,
|
||||
shouldUseCompactThemeWorkbench,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUseCompactThemeWorkbench) {
|
||||
setThemeWorkbenchEntryCheckPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isThemeWorkbench ||
|
||||
!contentId ||
|
||||
!sessionId ||
|
||||
messagesCount > 0 ||
|
||||
Boolean(initialDispatchKey)
|
||||
) {
|
||||
setThemeWorkbenchEntryCheckPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setThemeWorkbenchEntryCheckPending(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [workflow, backendState] = await Promise.all([
|
||||
loadWorkflow(contentId).catch(() => null),
|
||||
loadRunState(sessionId).catch(() => null),
|
||||
]);
|
||||
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrompt =
|
||||
buildThemeWorkbenchResumePromptFromWorkflow(workflow) ??
|
||||
buildThemeWorkbenchResumePromptFromRunState(backendState);
|
||||
|
||||
if (!nextPrompt) {
|
||||
setThemeWorkbenchEntryPrompt((current) =>
|
||||
current?.kind === "resume" ? null : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dismissedPromptSignatureRef.current === nextPrompt.signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThemeWorkbenchEntryPrompt((current) =>
|
||||
current?.kind === "initial_prompt" ? current : nextPrompt,
|
||||
);
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setThemeWorkbenchEntryCheckPending(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
isThemeWorkbench,
|
||||
loadRunState,
|
||||
loadWorkflow,
|
||||
messagesCount,
|
||||
sessionId,
|
||||
shouldUseCompactThemeWorkbench,
|
||||
]);
|
||||
|
||||
const clearThemeWorkbenchEntryPrompt = useCallback(() => {
|
||||
setThemeWorkbenchEntryPrompt(null);
|
||||
}, []);
|
||||
|
||||
const dismissThemeWorkbenchEntryPrompt = useCallback(
|
||||
(options?: {
|
||||
consumeInitialPrompt?: boolean;
|
||||
onConsumeInitialPrompt?: () => void;
|
||||
}) => {
|
||||
setThemeWorkbenchEntryPrompt((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (
|
||||
current.kind === "initial_prompt" &&
|
||||
options?.consumeInitialPrompt &&
|
||||
initialDispatchKey
|
||||
) {
|
||||
options.onConsumeInitialPrompt?.();
|
||||
} else {
|
||||
dismissedPromptSignatureRef.current = current.signature;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
},
|
||||
[initialDispatchKey],
|
||||
);
|
||||
|
||||
return {
|
||||
themeWorkbenchEntryPrompt,
|
||||
themeWorkbenchEntryCheckPending,
|
||||
clearThemeWorkbenchEntryPrompt,
|
||||
dismissThemeWorkbenchEntryPrompt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useThemeWorkbenchEntryPromptActions } from "./useThemeWorkbenchEntryPromptActions";
|
||||
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
|
||||
|
||||
interface HookProps {
|
||||
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
|
||||
input: string;
|
||||
initialDispatchKey: string | null;
|
||||
}
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPromptActions>;
|
||||
rerender: (props?: Partial<HookProps>) => void;
|
||||
unmount: () => void;
|
||||
onContinuePrompt: ReturnType<typeof vi.fn>;
|
||||
dismissThemeWorkbenchEntryPrompt: ReturnType<typeof vi.fn>;
|
||||
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
|
||||
onInputChange: ReturnType<typeof vi.fn>;
|
||||
onRequirePrompt: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const onContinuePrompt = vi.fn(async () => undefined);
|
||||
const dismissThemeWorkbenchEntryPrompt = vi.fn();
|
||||
const onConsumeInitialPrompt = vi.fn();
|
||||
const onInputChange = vi.fn();
|
||||
const onRequirePrompt = vi.fn();
|
||||
|
||||
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPromptActions> | null =
|
||||
null;
|
||||
let currentProps: HookProps = {
|
||||
themeWorkbenchEntryPrompt: null,
|
||||
input: "",
|
||||
initialDispatchKey: null,
|
||||
...initialProps,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useThemeWorkbenchEntryPromptActions({
|
||||
...currentProps,
|
||||
onContinuePrompt,
|
||||
dismissThemeWorkbenchEntryPrompt,
|
||||
onConsumeInitialPrompt,
|
||||
onInputChange,
|
||||
onRequirePrompt,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (nextProps?: Partial<HookProps>) => {
|
||||
currentProps = {
|
||||
...currentProps,
|
||||
...nextProps,
|
||||
};
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
onContinuePrompt,
|
||||
dismissThemeWorkbenchEntryPrompt,
|
||||
onConsumeInitialPrompt,
|
||||
onInputChange,
|
||||
onRequirePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useThemeWorkbenchEntryPromptActions", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => {
|
||||
const harness = mountHook({
|
||||
themeWorkbenchEntryPrompt: {
|
||||
kind: "initial_prompt",
|
||||
signature: "dispatch-1",
|
||||
title: "已恢复待执行创作意图",
|
||||
description: "desc",
|
||||
actionLabel: "继续生成",
|
||||
prompt: "请先生成主稿",
|
||||
},
|
||||
input: "",
|
||||
initialDispatchKey: "dispatch-1",
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.handleContinueThemeWorkbenchEntryPrompt();
|
||||
});
|
||||
expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿");
|
||||
|
||||
harness.rerender({
|
||||
input: "我已经补充了额外要求",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.handleContinueThemeWorkbenchEntryPrompt();
|
||||
});
|
||||
expect(harness.onContinuePrompt).toHaveBeenLastCalledWith(
|
||||
"我已经补充了额外要求",
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("继续时没有任何可发送内容应提示补充", async () => {
|
||||
const harness = mountHook({
|
||||
themeWorkbenchEntryPrompt: {
|
||||
kind: "resume",
|
||||
signature: "resume-1",
|
||||
title: "发现上次未完成任务",
|
||||
description: "desc",
|
||||
actionLabel: "继续任务",
|
||||
prompt: " ",
|
||||
},
|
||||
input: " ",
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.handleContinueThemeWorkbenchEntryPrompt();
|
||||
});
|
||||
expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onContinuePrompt).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("重新开始初始提示时应消费意图并清空输入", () => {
|
||||
const harness = mountHook({
|
||||
themeWorkbenchEntryPrompt: {
|
||||
kind: "initial_prompt",
|
||||
signature: "dispatch-1",
|
||||
title: "已恢复待执行创作意图",
|
||||
description: "desc",
|
||||
actionLabel: "继续生成",
|
||||
prompt: "请先生成主稿",
|
||||
},
|
||||
initialDispatchKey: "dispatch-1",
|
||||
input: "已有内容",
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
harness.getValue().handleRestartThemeWorkbenchEntryPrompt();
|
||||
});
|
||||
|
||||
expect(harness.dismissThemeWorkbenchEntryPrompt).toHaveBeenCalledTimes(1);
|
||||
const options =
|
||||
harness.dismissThemeWorkbenchEntryPrompt.mock.calls[0]?.[0];
|
||||
expect(options?.consumeInitialPrompt).toBe(true);
|
||||
options?.onConsumeInitialPrompt?.();
|
||||
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
|
||||
expect(harness.onInputChange).toHaveBeenCalledWith("");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
|
||||
|
||||
interface DismissThemeWorkbenchEntryPromptOptions {
|
||||
consumeInitialPrompt?: boolean;
|
||||
onConsumeInitialPrompt?: () => void;
|
||||
}
|
||||
|
||||
interface UseThemeWorkbenchEntryPromptActionsOptions {
|
||||
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
|
||||
input: string;
|
||||
initialDispatchKey: string | null;
|
||||
onContinuePrompt: (prompt: string) => Promise<void> | void;
|
||||
dismissThemeWorkbenchEntryPrompt: (
|
||||
options?: DismissThemeWorkbenchEntryPromptOptions,
|
||||
) => void;
|
||||
onConsumeInitialPrompt?: (dispatchKey: string | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
onRequirePrompt?: () => void;
|
||||
}
|
||||
|
||||
export function useThemeWorkbenchEntryPromptActions({
|
||||
themeWorkbenchEntryPrompt,
|
||||
input,
|
||||
initialDispatchKey,
|
||||
onContinuePrompt,
|
||||
dismissThemeWorkbenchEntryPrompt,
|
||||
onConsumeInitialPrompt,
|
||||
onInputChange,
|
||||
onRequirePrompt,
|
||||
}: UseThemeWorkbenchEntryPromptActionsOptions) {
|
||||
const handleContinueThemeWorkbenchEntryPrompt = useCallback(async () => {
|
||||
if (!themeWorkbenchEntryPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptToSend =
|
||||
input.trim() || themeWorkbenchEntryPrompt.prompt.trim();
|
||||
if (!promptToSend) {
|
||||
onRequirePrompt?.();
|
||||
return;
|
||||
}
|
||||
|
||||
await onContinuePrompt(promptToSend);
|
||||
}, [input, onContinuePrompt, onRequirePrompt, themeWorkbenchEntryPrompt]);
|
||||
|
||||
const handleRestartThemeWorkbenchEntryPrompt = useCallback(() => {
|
||||
if (!themeWorkbenchEntryPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismissThemeWorkbenchEntryPrompt({
|
||||
consumeInitialPrompt: themeWorkbenchEntryPrompt.kind === "initial_prompt",
|
||||
onConsumeInitialPrompt: () => {
|
||||
onConsumeInitialPrompt?.(initialDispatchKey);
|
||||
},
|
||||
});
|
||||
onInputChange("");
|
||||
}, [
|
||||
dismissThemeWorkbenchEntryPrompt,
|
||||
initialDispatchKey,
|
||||
onConsumeInitialPrompt,
|
||||
onInputChange,
|
||||
themeWorkbenchEntryPrompt,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleContinueThemeWorkbenchEntryPrompt,
|
||||
handleRestartThemeWorkbenchEntryPrompt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useThemeWorkbenchSendBoundary,
|
||||
} from "./useThemeWorkbenchSendBoundary";
|
||||
import type { BrowserTaskPreflight } from "./handleSendTypes";
|
||||
|
||||
interface HookHarnessProps {
|
||||
isThemeWorkbench: boolean;
|
||||
contentId?: string;
|
||||
initialDispatchKey: string | null;
|
||||
consumedInitialPromptKey: string | null;
|
||||
mappedTheme: string;
|
||||
browserTaskPreflight: BrowserTaskPreflight | null;
|
||||
isBrowserAssistReady: boolean;
|
||||
}
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useThemeWorkbenchSendBoundary>;
|
||||
rerender: (props?: Partial<HookHarnessProps>) => void;
|
||||
unmount: () => void;
|
||||
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
|
||||
onResetConsumedInitialPrompt: ReturnType<typeof vi.fn>;
|
||||
onClearEntryPrompt: ReturnType<typeof vi.fn>;
|
||||
onPrepareBrowserTaskPreflight: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function createPreflight(): BrowserTaskPreflight {
|
||||
return {
|
||||
requestId: "browser-preflight:existing",
|
||||
createdAt: 123,
|
||||
sourceText: "旧任务",
|
||||
images: [],
|
||||
requirement: "required_with_user_step",
|
||||
reason: "需要浏览器",
|
||||
phase: "awaiting_user",
|
||||
launchUrl: "https://mp.weixin.qq.com/",
|
||||
};
|
||||
}
|
||||
|
||||
function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const onConsumeInitialPrompt = vi.fn();
|
||||
const onResetConsumedInitialPrompt = vi.fn();
|
||||
const onClearEntryPrompt = vi.fn();
|
||||
const onPrepareBrowserTaskPreflight = vi.fn();
|
||||
|
||||
let hookValue: ReturnType<typeof useThemeWorkbenchSendBoundary> | null = null;
|
||||
let currentProps: HookHarnessProps = {
|
||||
isThemeWorkbench: true,
|
||||
contentId: "content-1",
|
||||
initialDispatchKey: "dispatch-1",
|
||||
consumedInitialPromptKey: null,
|
||||
mappedTheme: "social-media",
|
||||
browserTaskPreflight: null,
|
||||
isBrowserAssistReady: true,
|
||||
...initialProps,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useThemeWorkbenchSendBoundary({
|
||||
...currentProps,
|
||||
initialUserImages: [],
|
||||
socialArticleSkillKey: "social_post_with_cover",
|
||||
onConsumeInitialPrompt,
|
||||
onResetConsumedInitialPrompt,
|
||||
onClearEntryPrompt,
|
||||
onPrepareBrowserTaskPreflight,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (nextProps?: Partial<HookHarnessProps>) => {
|
||||
currentProps = {
|
||||
...currentProps,
|
||||
...nextProps,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
onConsumeInitialPrompt,
|
||||
onResetConsumedInitialPrompt,
|
||||
onClearEntryPrompt,
|
||||
onPrepareBrowserTaskPreflight,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useThemeWorkbenchSendBoundary", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应识别社媒主题工作台的首条意图消费与技能前缀", () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
const boundary = harness.getValue().resolveSendBoundary({
|
||||
sourceText: "请生成今天的社媒主稿",
|
||||
});
|
||||
|
||||
expect(boundary.sourceText).toBe(
|
||||
"/social_post_with_cover 请生成今天的社媒主稿",
|
||||
);
|
||||
expect(boundary.shouldConsumePendingThemeWorkbenchInitialPrompt).toBe(
|
||||
true,
|
||||
);
|
||||
expect(boundary.shouldDismissThemeWorkbenchEntryPrompt).toBe(true);
|
||||
expect(boundary.browserRequirementMatch).toBeNull();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("已有浏览器前置引导时应阻止未确认发送", () => {
|
||||
const harness = mountHook({
|
||||
mappedTheme: "general",
|
||||
browserTaskPreflight: createPreflight(),
|
||||
});
|
||||
|
||||
try {
|
||||
expect(harness.getValue().isBlockedByBrowserPreflight()).toBe(true);
|
||||
expect(
|
||||
harness.getValue().isBlockedByBrowserPreflight({
|
||||
browserPreflightConfirmed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("需要真实浏览器时应创建前置引导并同步消费初始意图", () => {
|
||||
const harness = mountHook({
|
||||
mappedTheme: "general",
|
||||
isBrowserAssistReady: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const boundary = harness.getValue().resolveSendBoundary({
|
||||
sourceText: "帮我把这篇文章发布到微信公众号后台",
|
||||
});
|
||||
|
||||
const started = harness.getValue().maybeStartBrowserTaskPreflight({
|
||||
boundary,
|
||||
images: [],
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
sendExecutionStrategy: "auto",
|
||||
});
|
||||
|
||||
expect(started).toBe(true);
|
||||
expect(harness.onPrepareBrowserTaskPreflight).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceText: "帮我把这篇文章发布到微信公众号后台",
|
||||
requirement: "required_with_user_step",
|
||||
launchUrl: "https://mp.weixin.qq.com/",
|
||||
phase: "launching",
|
||||
}),
|
||||
);
|
||||
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
|
||||
expect(harness.onClearEntryPrompt).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("发送失败时应回滚已消费的首条意图", () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
const boundary = harness.getValue().resolveSendBoundary({
|
||||
sourceText: "请生成今天的社媒主稿",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
harness.getValue().rollbackAfterSendFailure(boundary);
|
||||
});
|
||||
|
||||
expect(harness.onResetConsumedInitialPrompt).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback } from "react";
|
||||
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
|
||||
import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement";
|
||||
import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement";
|
||||
import type { MessageImage } from "../types";
|
||||
import type { BrowserTaskPreflight, HandleSendOptions } from "./handleSendTypes";
|
||||
|
||||
interface BuildThemeWorkbenchSendBoundaryStateOptions {
|
||||
isThemeWorkbench: boolean;
|
||||
contentId?: string;
|
||||
initialDispatchKey: string | null;
|
||||
consumedInitialPromptKey: string | null;
|
||||
initialUserImages?: MessageImage[];
|
||||
mappedTheme: string;
|
||||
socialArticleSkillKey: string;
|
||||
sourceText: string;
|
||||
sendOptions?: HandleSendOptions;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchSendBoundaryState {
|
||||
sourceText: string;
|
||||
browserRequirementMatch: BrowserTaskRequirementMatch | null;
|
||||
shouldConsumePendingThemeWorkbenchInitialPrompt: boolean;
|
||||
shouldDismissThemeWorkbenchEntryPrompt: boolean;
|
||||
}
|
||||
|
||||
interface CreateBrowserTaskPreflightOptions {
|
||||
sourceText: string;
|
||||
images?: MessageImage[];
|
||||
webSearch?: boolean;
|
||||
thinking?: boolean;
|
||||
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
autoContinuePayload?: AutoContinueRequestPayload;
|
||||
sendOptions?: HandleSendOptions;
|
||||
browserRequirementMatch: BrowserTaskRequirementMatch;
|
||||
createRequestId?: () => string;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface UseThemeWorkbenchSendBoundaryOptions {
|
||||
isThemeWorkbench: boolean;
|
||||
contentId?: string;
|
||||
initialDispatchKey: string | null;
|
||||
consumedInitialPromptKey: string | null;
|
||||
initialUserImages?: MessageImage[];
|
||||
mappedTheme: string;
|
||||
socialArticleSkillKey: string;
|
||||
isBrowserAssistReady: boolean;
|
||||
browserTaskPreflight: BrowserTaskPreflight | null;
|
||||
onConsumeInitialPrompt: (dispatchKey: string) => void;
|
||||
onResetConsumedInitialPrompt: () => void;
|
||||
onClearEntryPrompt: () => void;
|
||||
onPrepareBrowserTaskPreflight: (preflight: BrowserTaskPreflight) => void;
|
||||
}
|
||||
|
||||
interface StartBrowserTaskPreflightOptions {
|
||||
boundary: ThemeWorkbenchSendBoundaryState;
|
||||
images?: MessageImage[];
|
||||
webSearch?: boolean;
|
||||
thinking?: boolean;
|
||||
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
autoContinuePayload?: AutoContinueRequestPayload;
|
||||
sendOptions?: HandleSendOptions;
|
||||
}
|
||||
|
||||
function defaultCreateBrowserTaskPreflightRequestId() {
|
||||
return `browser-preflight:${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export function buildThemeWorkbenchSendBoundaryState({
|
||||
isThemeWorkbench,
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
consumedInitialPromptKey,
|
||||
initialUserImages,
|
||||
mappedTheme,
|
||||
socialArticleSkillKey,
|
||||
sourceText,
|
||||
sendOptions,
|
||||
}: BuildThemeWorkbenchSendBoundaryStateOptions): ThemeWorkbenchSendBoundaryState {
|
||||
const shouldConsumePendingThemeWorkbenchInitialPrompt =
|
||||
isThemeWorkbench &&
|
||||
Boolean(contentId) &&
|
||||
Boolean(initialDispatchKey) &&
|
||||
consumedInitialPromptKey !== initialDispatchKey &&
|
||||
(initialUserImages || []).length === 0 &&
|
||||
!sendOptions?.purpose;
|
||||
const shouldDismissThemeWorkbenchEntryPrompt =
|
||||
isThemeWorkbench && !sendOptions?.purpose;
|
||||
|
||||
let nextSourceText = sourceText;
|
||||
|
||||
if (
|
||||
isThemeWorkbench &&
|
||||
mappedTheme === "social-media" &&
|
||||
nextSourceText.trim() &&
|
||||
!nextSourceText.trimStart().startsWith("/") &&
|
||||
!sendOptions?.skipThemeSkillPrefix
|
||||
) {
|
||||
nextSourceText = `/${socialArticleSkillKey} ${nextSourceText}`.trim();
|
||||
}
|
||||
|
||||
const browserRequirementMatch =
|
||||
mappedTheme === "general" && !sendOptions?.purpose
|
||||
? detectBrowserTaskRequirement(nextSourceText)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sourceText: nextSourceText,
|
||||
browserRequirementMatch,
|
||||
shouldConsumePendingThemeWorkbenchInitialPrompt,
|
||||
shouldDismissThemeWorkbenchEntryPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserTaskPreflight({
|
||||
sourceText,
|
||||
images,
|
||||
webSearch,
|
||||
thinking,
|
||||
sendExecutionStrategy,
|
||||
autoContinuePayload,
|
||||
sendOptions,
|
||||
browserRequirementMatch,
|
||||
createRequestId = defaultCreateBrowserTaskPreflightRequestId,
|
||||
now = () => Date.now(),
|
||||
}: CreateBrowserTaskPreflightOptions): BrowserTaskPreflight {
|
||||
return {
|
||||
requestId: createRequestId(),
|
||||
createdAt: now(),
|
||||
sourceText,
|
||||
images: images || [],
|
||||
webSearch,
|
||||
thinking,
|
||||
sendExecutionStrategy,
|
||||
autoContinuePayload,
|
||||
sendOptions,
|
||||
requirement: browserRequirementMatch.requirement,
|
||||
reason: browserRequirementMatch.reason,
|
||||
phase: "launching",
|
||||
launchUrl: browserRequirementMatch.launchUrl,
|
||||
platformLabel: browserRequirementMatch.platformLabel,
|
||||
detail: "正在尝试建立浏览器会话,请稍候...",
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemeWorkbenchSendBoundary({
|
||||
isThemeWorkbench,
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
consumedInitialPromptKey,
|
||||
initialUserImages,
|
||||
mappedTheme,
|
||||
socialArticleSkillKey,
|
||||
isBrowserAssistReady,
|
||||
browserTaskPreflight,
|
||||
onConsumeInitialPrompt,
|
||||
onResetConsumedInitialPrompt,
|
||||
onClearEntryPrompt,
|
||||
onPrepareBrowserTaskPreflight,
|
||||
}: UseThemeWorkbenchSendBoundaryOptions) {
|
||||
const resolveSendBoundary = useCallback(
|
||||
({ sourceText, sendOptions }: { sourceText: string; sendOptions?: HandleSendOptions }) =>
|
||||
buildThemeWorkbenchSendBoundaryState({
|
||||
isThemeWorkbench,
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
consumedInitialPromptKey,
|
||||
initialUserImages,
|
||||
mappedTheme,
|
||||
socialArticleSkillKey,
|
||||
sourceText,
|
||||
sendOptions,
|
||||
}),
|
||||
[
|
||||
consumedInitialPromptKey,
|
||||
contentId,
|
||||
initialDispatchKey,
|
||||
initialUserImages,
|
||||
isThemeWorkbench,
|
||||
mappedTheme,
|
||||
socialArticleSkillKey,
|
||||
],
|
||||
);
|
||||
|
||||
const isBlockedByBrowserPreflight = useCallback(
|
||||
(sendOptions?: HandleSendOptions) =>
|
||||
Boolean(browserTaskPreflight) && !sendOptions?.browserPreflightConfirmed,
|
||||
[browserTaskPreflight],
|
||||
);
|
||||
|
||||
const maybeStartBrowserTaskPreflight = useCallback(
|
||||
({
|
||||
boundary,
|
||||
images,
|
||||
webSearch,
|
||||
thinking,
|
||||
sendExecutionStrategy,
|
||||
autoContinuePayload,
|
||||
sendOptions,
|
||||
}: StartBrowserTaskPreflightOptions) => {
|
||||
if (
|
||||
!boundary.browserRequirementMatch ||
|
||||
sendOptions?.browserPreflightConfirmed ||
|
||||
isBrowserAssistReady
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const preflight = createBrowserTaskPreflight({
|
||||
sourceText: boundary.sourceText,
|
||||
images,
|
||||
webSearch,
|
||||
thinking,
|
||||
sendExecutionStrategy,
|
||||
autoContinuePayload,
|
||||
sendOptions,
|
||||
browserRequirementMatch: boundary.browserRequirementMatch,
|
||||
});
|
||||
|
||||
onPrepareBrowserTaskPreflight(preflight);
|
||||
|
||||
if (
|
||||
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
|
||||
initialDispatchKey
|
||||
) {
|
||||
onConsumeInitialPrompt(initialDispatchKey);
|
||||
}
|
||||
|
||||
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
|
||||
onClearEntryPrompt();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[
|
||||
initialDispatchKey,
|
||||
isBrowserAssistReady,
|
||||
onClearEntryPrompt,
|
||||
onConsumeInitialPrompt,
|
||||
onPrepareBrowserTaskPreflight,
|
||||
],
|
||||
);
|
||||
|
||||
const finalizeAfterSendSuccess = useCallback(
|
||||
(boundary: ThemeWorkbenchSendBoundaryState) => {
|
||||
if (
|
||||
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
|
||||
initialDispatchKey
|
||||
) {
|
||||
onConsumeInitialPrompt(initialDispatchKey);
|
||||
}
|
||||
|
||||
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
|
||||
onClearEntryPrompt();
|
||||
}
|
||||
},
|
||||
[initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt],
|
||||
);
|
||||
|
||||
const rollbackAfterSendFailure = useCallback(
|
||||
(boundary: ThemeWorkbenchSendBoundaryState) => {
|
||||
if (boundary.shouldConsumePendingThemeWorkbenchInitialPrompt) {
|
||||
onResetConsumedInitialPrompt();
|
||||
}
|
||||
},
|
||||
[onResetConsumedInitialPrompt],
|
||||
);
|
||||
|
||||
return {
|
||||
resolveSendBoundary,
|
||||
isBlockedByBrowserPreflight,
|
||||
maybeStartBrowserTaskPreflight,
|
||||
finalizeAfterSendSuccess,
|
||||
rollbackAfterSendFailure,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getOverview,
|
||||
getProviders,
|
||||
loadConfiguredProviders,
|
||||
getModelRegistry,
|
||||
getAllAliasConfigs,
|
||||
syncTrayModelShortcuts,
|
||||
} = vi.hoisted(() => ({
|
||||
getOverview: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
loadConfiguredProviders: vi.fn(),
|
||||
getModelRegistry: vi.fn(),
|
||||
getAllAliasConfigs: vi.fn(),
|
||||
syncTrayModelShortcuts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/providerPool", () => ({
|
||||
providerPoolApi: {
|
||||
getOverview,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/apiKeyProvider", () => ({
|
||||
apiKeyProviderApi: {
|
||||
getProviders,
|
||||
},
|
||||
vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
loadConfiguredProviders,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/modelRegistry", () => ({
|
||||
@@ -68,20 +58,12 @@ describe("buildTrayPayload", () => {
|
||||
vi.clearAllMocks();
|
||||
invalidateTrayPayloadCache();
|
||||
|
||||
getOverview.mockResolvedValue([
|
||||
loadConfiguredProviders.mockResolvedValue([
|
||||
{
|
||||
provider_type: "deepseek",
|
||||
credentials: [{ credential_type: "deepseek" }],
|
||||
},
|
||||
]);
|
||||
getProviders.mockResolvedValue([
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
key: "deepseek",
|
||||
label: "DeepSeek",
|
||||
registryId: "deepseek",
|
||||
type: "deepseek",
|
||||
enabled: true,
|
||||
api_key_count: 1,
|
||||
custom_models: [],
|
||||
},
|
||||
]);
|
||||
getModelRegistry.mockResolvedValue([
|
||||
@@ -107,8 +89,7 @@ describe("buildTrayPayload", () => {
|
||||
);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(getOverview).toHaveBeenCalledTimes(1);
|
||||
expect(getProviders).toHaveBeenCalledTimes(1);
|
||||
expect(loadConfiguredProviders).toHaveBeenCalledTimes(1);
|
||||
expect(getModelRegistry).toHaveBeenCalledTimes(1);
|
||||
expect(getAllAliasConfigs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -119,8 +100,7 @@ describe("buildTrayPayload", () => {
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
expect(getOverview).toHaveBeenCalledTimes(2);
|
||||
expect(getProviders).toHaveBeenCalledTimes(2);
|
||||
expect(loadConfiguredProviders).toHaveBeenCalledTimes(2);
|
||||
expect(getModelRegistry).toHaveBeenCalledTimes(2);
|
||||
expect(getAllAliasConfigs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -132,6 +112,49 @@ describe("buildTrayPayload", () => {
|
||||
expect(syncTrayModelShortcuts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("当 provider 仅命中 fallbackRegistryId 时仍应保留托盘候选模型", async () => {
|
||||
loadConfiguredProviders.mockResolvedValueOnce([
|
||||
{
|
||||
key: "custom-openai",
|
||||
label: "Custom OpenAI",
|
||||
registryId: "custom-openai",
|
||||
fallbackRegistryId: "openai",
|
||||
type: "openai",
|
||||
},
|
||||
]);
|
||||
getModelRegistry.mockResolvedValueOnce([
|
||||
{
|
||||
id: "gpt-4.1",
|
||||
display_name: "GPT-4.1",
|
||||
provider_id: "openai",
|
||||
provider_name: "OpenAI",
|
||||
is_latest: true,
|
||||
release_date: "2026-02-01",
|
||||
},
|
||||
]);
|
||||
|
||||
const payload = await buildTrayPayload(
|
||||
"custom-openai",
|
||||
"gpt-4.1",
|
||||
"general",
|
||||
{ forceRefresh: true },
|
||||
);
|
||||
|
||||
expect(payload.quick_model_groups).toEqual([
|
||||
{
|
||||
provider_type: "custom-openai",
|
||||
provider_label: "Custom OpenAI",
|
||||
models: [
|
||||
{
|
||||
provider_type: "custom-openai",
|
||||
provider_label: "Custom OpenAI",
|
||||
model: "gpt-4.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("首次同步失败时不应缓存成功指纹,后续重试仍应继续同步", async () => {
|
||||
syncTrayModelShortcuts
|
||||
.mockRejectedValueOnce(new Error("tray unavailable"))
|
||||
|
||||
@@ -6,26 +6,21 @@ import {
|
||||
type TrayModelSelectedPayload,
|
||||
type TrayQuickModelGroup,
|
||||
} from "@/lib/api/tray";
|
||||
import {
|
||||
apiKeyProviderApi,
|
||||
type ProviderWithKeysDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
import {
|
||||
providerPoolApi,
|
||||
type ProviderPoolOverview,
|
||||
} from "@/lib/api/providerPool";
|
||||
import { modelRegistryApi } from "@/lib/api/modelRegistry";
|
||||
import { trayApi } from "@/lib/api/tray";
|
||||
import {
|
||||
getAliasConfigKey,
|
||||
getProviderLabel,
|
||||
getRegistryIdFromType,
|
||||
isAliasProvider,
|
||||
} from "@/lib/constants/providerMappings";
|
||||
import type {
|
||||
EnhancedModelMetadata,
|
||||
ProviderAliasConfig,
|
||||
} from "@/lib/types/modelRegistry";
|
||||
import {
|
||||
loadConfiguredProviders,
|
||||
type ConfiguredProvider,
|
||||
} from "@/hooks/useConfiguredProviders";
|
||||
import { buildProviderModelsFromRegistry } from "@/hooks/useProviderModels";
|
||||
import { filterModelsByTheme } from "../utils/modelThemePolicy";
|
||||
import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility";
|
||||
|
||||
@@ -38,16 +33,6 @@ interface UseTrayModelShortcutsOptions {
|
||||
deferInitialSync?: boolean;
|
||||
}
|
||||
|
||||
interface ConfiguredProvider {
|
||||
key: string;
|
||||
label: string;
|
||||
registryId: string;
|
||||
fallbackRegistryId?: string;
|
||||
type: string;
|
||||
providerId?: string;
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
const MAX_TRAY_MODELS_PER_PROVIDER = 8;
|
||||
const TRAY_PAYLOAD_CACHE_TTL_MS = 3_000;
|
||||
const TRAY_SYNC_IDLE_TIMEOUT_MS = 1_500;
|
||||
@@ -80,172 +65,6 @@ const THEME_LABEL_MAP: Record<string, string> = {
|
||||
novel: "小说创作",
|
||||
};
|
||||
|
||||
function sortModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] {
|
||||
return [...models].sort((a, b) => {
|
||||
if (a.is_latest && !b.is_latest) return -1;
|
||||
if (!a.is_latest && b.is_latest) return 1;
|
||||
|
||||
if (a.release_date && b.release_date) {
|
||||
return b.release_date.localeCompare(a.release_date);
|
||||
}
|
||||
if (a.release_date && !b.release_date) return -1;
|
||||
if (!a.release_date && b.release_date) return 1;
|
||||
|
||||
return a.display_name.localeCompare(b.display_name);
|
||||
});
|
||||
}
|
||||
|
||||
function convertCustomModelsToMetadata(
|
||||
models: string[],
|
||||
providerId: string,
|
||||
providerName: string,
|
||||
): EnhancedModelMetadata[] {
|
||||
const now = Date.now() / 1000;
|
||||
return models.map((modelName) => ({
|
||||
id: modelName,
|
||||
display_name: modelName,
|
||||
provider_id: providerId,
|
||||
provider_name: providerName,
|
||||
family: null,
|
||||
tier: "pro",
|
||||
capabilities: {
|
||||
vision: false,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: modelName.includes("thinking"),
|
||||
},
|
||||
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: `自定义模型: ${modelName}`,
|
||||
source: "custom",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}));
|
||||
}
|
||||
|
||||
function convertAliasModelsToMetadata(
|
||||
models: string[],
|
||||
aliasConfig: ProviderAliasConfig,
|
||||
providerId: string,
|
||||
providerName: string,
|
||||
): EnhancedModelMetadata[] {
|
||||
const now = Date.now() / 1000;
|
||||
return models.map((modelName) => {
|
||||
const aliasInfo = aliasConfig.aliases[modelName];
|
||||
return {
|
||||
id: modelName,
|
||||
display_name: modelName,
|
||||
provider_id: providerId,
|
||||
provider_name: providerName,
|
||||
family: aliasInfo?.provider || null,
|
||||
tier: "pro" as const,
|
||||
capabilities: {
|
||||
vision: false,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: modelName.includes("thinking"),
|
||||
},
|
||||
pricing: null,
|
||||
limits: {
|
||||
context_length: null,
|
||||
max_output_tokens: null,
|
||||
requests_per_minute: null,
|
||||
tokens_per_minute: null,
|
||||
},
|
||||
status: "active" as const,
|
||||
release_date: null,
|
||||
is_latest: false,
|
||||
description: aliasInfo?.description || aliasInfo?.actual || modelName,
|
||||
source: "custom" as const,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildConfiguredProviders(
|
||||
oauthCredentials: ProviderPoolOverview[],
|
||||
apiKeyProviders: ProviderWithKeysDisplay[],
|
||||
): ConfiguredProvider[] {
|
||||
const providerMap = new Map<string, ConfiguredProvider>();
|
||||
|
||||
oauthCredentials.forEach((overview) => {
|
||||
if (overview.credentials.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = overview.provider_type;
|
||||
if (providerMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
providerMap.set(key, {
|
||||
key,
|
||||
label: getProviderLabel(key),
|
||||
registryId: getRegistryIdFromType(key),
|
||||
type: key,
|
||||
});
|
||||
});
|
||||
|
||||
apiKeyProviders
|
||||
.filter((provider) => provider.api_key_count > 0 && provider.enabled)
|
||||
.forEach((provider) => {
|
||||
let key = provider.id;
|
||||
let label = provider.name;
|
||||
|
||||
if (providerMap.has(key)) {
|
||||
key = `${provider.id}_api_key`;
|
||||
label = `${provider.name} API Key`;
|
||||
}
|
||||
|
||||
if (providerMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
providerMap.set(key, {
|
||||
key,
|
||||
label,
|
||||
registryId: provider.id,
|
||||
fallbackRegistryId: getRegistryIdFromType(provider.type),
|
||||
type: provider.type,
|
||||
providerId: provider.id,
|
||||
customModels: provider.custom_models,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(providerMap.values());
|
||||
}
|
||||
|
||||
function dedupeModels(
|
||||
models: EnhancedModelMetadata[],
|
||||
): EnhancedModelMetadata[] {
|
||||
const seen = new Set<string>();
|
||||
const result: EnhancedModelMetadata[] = [];
|
||||
|
||||
models.forEach((model) => {
|
||||
const normalized = model.id.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(model);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTrayPayloadSignature(
|
||||
providerType: string,
|
||||
model: string,
|
||||
@@ -292,55 +111,14 @@ function scheduleTrayModelSync(task: () => void): () => void {
|
||||
|
||||
function resolveProviderModels(
|
||||
provider: ConfiguredProvider,
|
||||
registryModels: EnhancedModelMetadata[],
|
||||
registryModels: Parameters<typeof buildProviderModelsFromRegistry>[1],
|
||||
aliasConfigs: Record<string, ProviderAliasConfig>,
|
||||
): EnhancedModelMetadata[] {
|
||||
const combined: EnhancedModelMetadata[] = [];
|
||||
|
||||
if (provider.customModels?.length) {
|
||||
combined.push(
|
||||
...convertCustomModelsToMetadata(
|
||||
provider.customModels,
|
||||
provider.key,
|
||||
provider.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isAliasProvider(provider.key)) {
|
||||
const aliasConfig = aliasConfigs[getAliasConfigKey(provider.key)];
|
||||
if (aliasConfig) {
|
||||
combined.push(
|
||||
...convertAliasModelsToMetadata(
|
||||
aliasConfig.models,
|
||||
aliasConfig,
|
||||
provider.key,
|
||||
provider.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
return dedupeModels(combined);
|
||||
}
|
||||
|
||||
const registryMatches = sortModels(
|
||||
registryModels.filter((item) => item.provider_id === provider.registryId),
|
||||
);
|
||||
if (registryMatches.length > 0) {
|
||||
combined.push(...registryMatches);
|
||||
return dedupeModels(combined);
|
||||
}
|
||||
|
||||
if (provider.fallbackRegistryId) {
|
||||
combined.push(
|
||||
...sortModels(
|
||||
registryModels.filter(
|
||||
(item) => item.provider_id === provider.fallbackRegistryId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return dedupeModels(combined);
|
||||
) {
|
||||
return buildProviderModelsFromRegistry(
|
||||
provider,
|
||||
registryModels,
|
||||
aliasConfigs[getAliasConfigKey(provider.key)] ?? null,
|
||||
).models;
|
||||
}
|
||||
|
||||
function resolveThemeLabel(theme?: string): string {
|
||||
@@ -476,34 +254,23 @@ export async function buildTrayPayload(
|
||||
|
||||
const payloadPromise = (async () => {
|
||||
const sourceOptions = forceRefresh ? { forceRefresh: true } : undefined;
|
||||
const [oauthCredentials, apiKeyProviders, registryModels, aliasConfigs] =
|
||||
await Promise.all([
|
||||
loadTraySource(
|
||||
() => providerPoolApi.getOverview(sourceOptions),
|
||||
[] as ProviderPoolOverview[],
|
||||
"OAuth Provider 概览",
|
||||
),
|
||||
loadTraySource(
|
||||
() => apiKeyProviderApi.getProviders(sourceOptions),
|
||||
[] as ProviderWithKeysDisplay[],
|
||||
"API Key Provider 列表",
|
||||
),
|
||||
loadTraySource(
|
||||
() => modelRegistryApi.getModelRegistry(sourceOptions),
|
||||
[] as EnhancedModelMetadata[],
|
||||
"模型注册表",
|
||||
),
|
||||
loadTraySource(
|
||||
() => modelRegistryApi.getAllAliasConfigs(sourceOptions),
|
||||
{} as Record<string, ProviderAliasConfig>,
|
||||
"别名模型配置",
|
||||
),
|
||||
]);
|
||||
|
||||
const providers = buildConfiguredProviders(
|
||||
oauthCredentials,
|
||||
apiKeyProviders,
|
||||
);
|
||||
const [providers, registryModels, aliasConfigs] = await Promise.all([
|
||||
loadTraySource(
|
||||
() => loadConfiguredProviders({ forceRefresh }),
|
||||
[] as ConfiguredProvider[],
|
||||
"已配置 Provider 列表",
|
||||
),
|
||||
loadTraySource(
|
||||
() => modelRegistryApi.getModelRegistry(sourceOptions),
|
||||
[] as EnhancedModelMetadata[],
|
||||
"模型注册表",
|
||||
),
|
||||
loadTraySource(
|
||||
() => modelRegistryApi.getAllAliasConfigs(sourceOptions),
|
||||
{} as Record<string, ProviderAliasConfig>,
|
||||
"别名模型配置",
|
||||
),
|
||||
]);
|
||||
const currentProvider =
|
||||
providers.find((item) => item.key === providerType) || null;
|
||||
|
||||
@@ -585,6 +352,25 @@ export function useTrayModelShortcuts({
|
||||
}: UseTrayModelShortcutsOptions) {
|
||||
const lastSyncedSignatureRef = useRef<string>("");
|
||||
const initialSyncHandledRef = useRef(false);
|
||||
const latestSelectionRef = useRef({
|
||||
providerType,
|
||||
model,
|
||||
});
|
||||
const latestMutatorsRef = useRef({
|
||||
setProviderType,
|
||||
setModel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestSelectionRef.current = {
|
||||
providerType,
|
||||
model,
|
||||
};
|
||||
latestMutatorsRef.current = {
|
||||
setProviderType,
|
||||
setModel,
|
||||
};
|
||||
}, [model, providerType, setModel, setProviderType]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedProviderType = providerType.trim();
|
||||
@@ -648,17 +434,22 @@ export function useTrayModelShortcuts({
|
||||
|
||||
const nextProviderType = event.payload?.providerType?.trim() || "";
|
||||
const nextModel = event.payload?.model?.trim() || "";
|
||||
const currentSelection = latestSelectionRef.current;
|
||||
const currentMutators = latestMutatorsRef.current;
|
||||
|
||||
if (!nextModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextProviderType && nextProviderType !== providerType) {
|
||||
setProviderType(nextProviderType);
|
||||
if (
|
||||
nextProviderType &&
|
||||
nextProviderType !== currentSelection.providerType
|
||||
) {
|
||||
currentMutators.setProviderType(nextProviderType);
|
||||
}
|
||||
|
||||
if (nextModel !== model) {
|
||||
setModel(nextModel);
|
||||
if (nextModel !== currentSelection.model) {
|
||||
currentMutators.setModel(nextModel);
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
@@ -678,5 +469,5 @@ export function useTrayModelShortcuts({
|
||||
dispose();
|
||||
}
|
||||
};
|
||||
}, [model, providerType, setModel, setProviderType]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LAST_PROJECT_ID_KEY } from "./agentProjectStorage";
|
||||
import { useWorkspaceProjectSelection } from "./useWorkspaceProjectSelection";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useWorkspaceProjectSelection>;
|
||||
rerender: (props?: {
|
||||
externalProjectId?: string | null;
|
||||
newChatAt?: number;
|
||||
}) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(
|
||||
initialProps: {
|
||||
externalProjectId?: string | null;
|
||||
newChatAt?: number;
|
||||
} = {},
|
||||
): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useWorkspaceProjectSelection> | null = null;
|
||||
|
||||
function TestComponent(props: {
|
||||
externalProjectId?: string | null;
|
||||
newChatAt?: number;
|
||||
}) {
|
||||
hookValue = useWorkspaceProjectSelection(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = (props?: {
|
||||
externalProjectId?: string | null;
|
||||
newChatAt?: number;
|
||||
}) => {
|
||||
act(() => {
|
||||
root.render(<TestComponent {...props} />);
|
||||
});
|
||||
};
|
||||
|
||||
render(initialProps);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
rerender: render,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWorkspaceProjectSelection", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("应在新会话请求未处理前屏蔽最近项目恢复", () => {
|
||||
localStorage.setItem(LAST_PROJECT_ID_KEY, JSON.stringify("project-local"));
|
||||
const harness = mountHook({
|
||||
newChatAt: 123,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(harness.getValue().projectId).toBeUndefined();
|
||||
expect(harness.getValue().shouldDisableSessionRestore).toBe(true);
|
||||
|
||||
act(() => {
|
||||
harness.getValue().markNewChatRequestHandled("123");
|
||||
harness.getValue().applyProjectSelection(null);
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBeUndefined();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("应统一处理项目选择与最近项目记忆", () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
harness.getValue().applyProjectSelection("project-a");
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBe("project-a");
|
||||
expect(harness.getValue().getRememberedProjectId()).toBe("project-a");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().resetProjectSelection();
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBeUndefined();
|
||||
expect(harness.getValue().getRememberedProjectId()).toBe("project-a");
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("应在项目切换完成后恢复待执行的任务切换", () => {
|
||||
const harness = mountHook();
|
||||
|
||||
try {
|
||||
expect(harness.getValue().startTopicProjectResolution()).toBe(true);
|
||||
expect(harness.getValue().startTopicProjectResolution()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
harness.getValue().deferTopicSwitch("topic-1", "project-target");
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBe("project-target");
|
||||
expect(
|
||||
harness.getValue().consumePendingTopicSwitch("project-other"),
|
||||
).toBeNull();
|
||||
expect(harness.getValue().consumePendingTopicSwitch("project-target")).toEqual({
|
||||
topicId: "topic-1",
|
||||
targetProjectId: "project-target",
|
||||
});
|
||||
|
||||
harness.getValue().finishTopicProjectResolution();
|
||||
expect(harness.getValue().startTopicProjectResolution()).toBe(true);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("有外部项目锁定时不应覆盖当前项目选择", () => {
|
||||
const harness = mountHook({
|
||||
externalProjectId: "project-external",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(harness.getValue().projectId).toBe("project-external");
|
||||
|
||||
act(() => {
|
||||
harness.getValue().applyProjectSelection("project-local");
|
||||
});
|
||||
|
||||
expect(harness.getValue().projectId).toBe("project-external");
|
||||
expect(harness.getValue().getRememberedProjectId()).toBeNull();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { normalizeProjectId } from "../utils/topicProjectResolution";
|
||||
import {
|
||||
LAST_PROJECT_ID_KEY,
|
||||
loadPersistedProjectId,
|
||||
savePersistedProjectId,
|
||||
} from "./agentProjectStorage";
|
||||
|
||||
interface PendingTopicSwitchState {
|
||||
topicId: string;
|
||||
targetProjectId: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceProjectSelectionOptions {
|
||||
externalProjectId?: string | null;
|
||||
newChatAt?: number;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function useWorkspaceProjectSelection(
|
||||
options: UseWorkspaceProjectSelectionOptions = {},
|
||||
) {
|
||||
const {
|
||||
externalProjectId,
|
||||
newChatAt,
|
||||
storageKey = LAST_PROJECT_ID_KEY,
|
||||
} = options;
|
||||
const normalizedExternalProjectId = normalizeProjectId(externalProjectId);
|
||||
const [internalProjectId, setInternalProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const handledNewChatRequestRef = useRef<string | null>(null);
|
||||
const pendingTopicSwitchRef = useRef<PendingTopicSwitchState | null>(null);
|
||||
const isResolvingTopicProjectRef = useRef(false);
|
||||
|
||||
const incomingNewChatRequestKey =
|
||||
typeof newChatAt === "number" ? String(newChatAt) : null;
|
||||
const shouldDisableSessionRestore = incomingNewChatRequestKey !== null;
|
||||
const shouldResetToFreshHomeContext =
|
||||
!normalizedExternalProjectId &&
|
||||
incomingNewChatRequestKey !== null &&
|
||||
handledNewChatRequestRef.current !== incomingNewChatRequestKey;
|
||||
|
||||
const projectId =
|
||||
normalizedExternalProjectId ??
|
||||
(shouldResetToFreshHomeContext ? undefined : internalProjectId) ??
|
||||
undefined;
|
||||
|
||||
const hasHandledNewChatRequest = useCallback(
|
||||
(requestKey: string) => handledNewChatRequestRef.current === requestKey,
|
||||
[],
|
||||
);
|
||||
|
||||
const markNewChatRequestHandled = useCallback((requestKey: string) => {
|
||||
handledNewChatRequestRef.current = requestKey;
|
||||
}, []);
|
||||
|
||||
const clearProjectSelectionRuntime = useCallback(() => {
|
||||
pendingTopicSwitchRef.current = null;
|
||||
isResolvingTopicProjectRef.current = false;
|
||||
}, []);
|
||||
|
||||
const rememberProjectId = useCallback(
|
||||
(nextProjectId?: string | null) => {
|
||||
const normalizedProjectId = normalizeProjectId(nextProjectId);
|
||||
if (!normalizedProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePersistedProjectId(storageKey, normalizedProjectId);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const resetProjectSelection = useCallback(() => {
|
||||
clearProjectSelectionRuntime();
|
||||
setInternalProjectId(null);
|
||||
}, [clearProjectSelectionRuntime]);
|
||||
|
||||
const applyProjectSelection = useCallback(
|
||||
(nextProjectId?: string | null) => {
|
||||
if (normalizedExternalProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedProjectId = normalizeProjectId(nextProjectId);
|
||||
clearProjectSelectionRuntime();
|
||||
rememberProjectId(normalizedProjectId);
|
||||
setInternalProjectId(normalizedProjectId);
|
||||
},
|
||||
[clearProjectSelectionRuntime, normalizedExternalProjectId, rememberProjectId],
|
||||
);
|
||||
|
||||
const startTopicProjectResolution = useCallback(() => {
|
||||
if (isResolvingTopicProjectRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isResolvingTopicProjectRef.current = true;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const finishTopicProjectResolution = useCallback(() => {
|
||||
isResolvingTopicProjectRef.current = false;
|
||||
}, []);
|
||||
|
||||
const deferTopicSwitch = useCallback(
|
||||
(topicId: string, targetProjectId: string) => {
|
||||
const normalizedTargetProjectId = normalizeProjectId(targetProjectId);
|
||||
if (!normalizedTargetProjectId) {
|
||||
pendingTopicSwitchRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
rememberProjectId(normalizedTargetProjectId);
|
||||
pendingTopicSwitchRef.current = {
|
||||
topicId,
|
||||
targetProjectId: normalizedTargetProjectId,
|
||||
};
|
||||
setInternalProjectId(normalizedTargetProjectId);
|
||||
},
|
||||
[rememberProjectId],
|
||||
);
|
||||
|
||||
const consumePendingTopicSwitch = useCallback(
|
||||
(currentProjectId?: string | null) => {
|
||||
const pending = pendingTopicSwitchRef.current;
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCurrentProjectId = normalizeProjectId(currentProjectId);
|
||||
if (normalizedCurrentProjectId !== pending.targetProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingTopicSwitchRef.current = null;
|
||||
return pending;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getRememberedProjectId = useCallback(
|
||||
() => loadPersistedProjectId(storageKey),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
shouldDisableSessionRestore,
|
||||
hasHandledNewChatRequest,
|
||||
markNewChatRequestHandled,
|
||||
rememberProjectId,
|
||||
getRememberedProjectId,
|
||||
applyProjectSelection,
|
||||
resetProjectSelection,
|
||||
clearProjectSelectionRuntime,
|
||||
startTopicProjectResolution,
|
||||
finishTopicProjectResolution,
|
||||
deferTopicSwitch,
|
||||
consumePendingTopicSwitch,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
mockUseTopicBranchBoard,
|
||||
mockUseTeamWorkspaceRuntime,
|
||||
mockUseCompatSubagentRuntime,
|
||||
mockGenerateEphemeralTeamWithModel,
|
||||
mockGetProject,
|
||||
mockGetDefaultProject,
|
||||
mockGetOrCreateDefaultProject,
|
||||
@@ -36,6 +37,8 @@ const {
|
||||
mockExecutionRunGetThemeWorkbenchState,
|
||||
mockExecutionRunListThemeWorkbenchHistory,
|
||||
mockExecutionRunGet,
|
||||
mockContentWorkflowCreate,
|
||||
mockContentWorkflowGetByContent,
|
||||
mockSkillExecutionGetDetail,
|
||||
mockSkillsGetAll,
|
||||
mockSkillsGetLocal,
|
||||
@@ -50,6 +53,7 @@ const {
|
||||
mockUseTopicBranchBoard: vi.fn(),
|
||||
mockUseTeamWorkspaceRuntime: vi.fn(),
|
||||
mockUseCompatSubagentRuntime: vi.fn(),
|
||||
mockGenerateEphemeralTeamWithModel: vi.fn(),
|
||||
mockGetProject: vi.fn(),
|
||||
mockGetDefaultProject: vi.fn(),
|
||||
mockGetOrCreateDefaultProject: vi.fn(),
|
||||
@@ -79,15 +83,21 @@ const {
|
||||
mockEmptyState: vi.fn((props?: { input?: string }) => (
|
||||
<div data-testid="empty-state">{props?.input || ""}</div>
|
||||
)),
|
||||
mockInputbar: vi.fn((_props?: Record<string, unknown>) => (
|
||||
<div data-testid="inputbar" />
|
||||
)),
|
||||
mockInputbar: vi.fn(
|
||||
(props?: { overlayAccessory?: ReactNode; input?: string }) => (
|
||||
<div data-testid="inputbar" data-input={props?.input || ""}>
|
||||
{props?.overlayAccessory}
|
||||
</div>
|
||||
),
|
||||
),
|
||||
mockMessageList: vi.fn((_props?: Record<string, unknown>) => (
|
||||
<div data-testid="message-list" />
|
||||
)),
|
||||
mockExecutionRunGetThemeWorkbenchState: vi.fn(),
|
||||
mockExecutionRunListThemeWorkbenchHistory: vi.fn(),
|
||||
mockExecutionRunGet: vi.fn(),
|
||||
mockContentWorkflowCreate: vi.fn(),
|
||||
mockContentWorkflowGetByContent: vi.fn(),
|
||||
mockSkillExecutionGetDetail: vi.fn(),
|
||||
mockSkillsGetAll: vi.fn(),
|
||||
mockSkillsGetLocal: vi.fn(),
|
||||
@@ -153,6 +163,10 @@ vi.mock("./hooks", () => ({
|
||||
useCompatSubagentRuntime: mockUseCompatSubagentRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("./utils/teamAutoGeneration", () => ({
|
||||
generateEphemeralTeamWithModel: mockGenerateEphemeralTeamWithModel,
|
||||
}));
|
||||
|
||||
vi.mock("./hooks/useSessionFiles", () => ({
|
||||
useSessionFiles: () => ({
|
||||
saveFile: vi.fn(async () => undefined),
|
||||
@@ -405,11 +419,18 @@ vi.mock("./components/TeamWorkspaceDock", () => ({
|
||||
withBottomOverlay,
|
||||
shellVisible,
|
||||
childSubagentSessions,
|
||||
runtimeTeamState,
|
||||
onActivateWorkbench,
|
||||
}: {
|
||||
placement?: "floating" | "inline";
|
||||
withBottomOverlay?: boolean;
|
||||
shellVisible?: boolean;
|
||||
childSubagentSessions?: Array<{ id: string }>;
|
||||
runtimeTeamState?: {
|
||||
status?: string;
|
||||
members?: Array<{ id: string }>;
|
||||
} | null;
|
||||
onActivateWorkbench?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="team-workspace-dock"
|
||||
@@ -417,7 +438,21 @@ 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)}
|
||||
>
|
||||
{onActivateWorkbench ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="team-workspace-dock-activate"
|
||||
onClick={() => {
|
||||
onActivateWorkbench();
|
||||
}}
|
||||
>
|
||||
打开 Team Workbench
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -459,7 +494,19 @@ vi.mock("@/components/general-chat/bridge", () => ({
|
||||
vi.mock("@/components/artifact", () => ({
|
||||
ArtifactList: () => <div data-testid="artifact-list" />,
|
||||
ArtifactRenderer: () => <div data-testid="artifact-renderer" />,
|
||||
ArtifactToolbar: () => <div data-testid="artifact-toolbar" />,
|
||||
ArtifactToolbar: ({ onClose }: { onClose?: () => void }) => (
|
||||
<div data-testid="artifact-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-toolbar-close"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/artifact/store", () => ({
|
||||
@@ -558,6 +605,13 @@ vi.mock("@/lib/api/executionRun", () => ({
|
||||
mockExecutionRunListThemeWorkbenchHistory,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/content-workflow", () => ({
|
||||
contentWorkflowApi: {
|
||||
create: mockContentWorkflowCreate,
|
||||
getByContent: mockContentWorkflowGetByContent,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/skill-execution", () => ({
|
||||
skillExecutionApi: {
|
||||
getSkillDetail: mockSkillExecutionGetDetail,
|
||||
@@ -721,10 +775,10 @@ function createMockThemeContextWorkspaceState(
|
||||
|
||||
async function flushEffects(times = 6) {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.dynamicImportSettled();
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, 20);
|
||||
});
|
||||
act(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,7 +886,7 @@ function mockBrowserAssistCompletedSession() {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
@@ -849,6 +903,17 @@ beforeEach(() => {
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const message = String(args[0] ?? "");
|
||||
if (
|
||||
message.includes("not wrapped in act") ||
|
||||
message.includes("A suspended resource finished loading inside a test")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
observedWorkspaceIds.length = 0;
|
||||
@@ -888,6 +953,17 @@ beforeEach(() => {
|
||||
next_offset: null,
|
||||
});
|
||||
mockExecutionRunGet.mockResolvedValue(null);
|
||||
mockContentWorkflowCreate.mockResolvedValue({
|
||||
id: "wf-default",
|
||||
content_id: "content-default",
|
||||
theme: "social-media",
|
||||
mode: "guided",
|
||||
steps: [],
|
||||
current_step_index: 0,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
mockContentWorkflowGetByContent.mockResolvedValue(null);
|
||||
mockSkillExecutionGetDetail.mockResolvedValue({
|
||||
name: "social_post_with_cover",
|
||||
display_name: "社媒主稿与封面",
|
||||
@@ -1063,6 +1139,8 @@ beforeEach(() => {
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await import("./AgentChatWorkspace");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1078,6 +1156,7 @@ afterEach(() => {
|
||||
}
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -1470,6 +1549,366 @@ describe("AgentChatPage 通用工作台", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("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;
|
||||
}),
|
||||
);
|
||||
|
||||
const mounted = mountPage({
|
||||
projectId: "project-team-runtime",
|
||||
theme: "general",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
onToolStatesChange?: (
|
||||
next:
|
||||
| Record<string, unknown>
|
||||
| ((prev: Record<string, unknown>) => Record<string, unknown>),
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
act(() => {
|
||||
latestInputbarProps?.onToolStatesChange?.((previous) => ({
|
||||
...previous,
|
||||
subagent: true,
|
||||
}));
|
||||
});
|
||||
await flushEffects(8);
|
||||
|
||||
latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
onSend?: (
|
||||
images?: unknown[],
|
||||
webSearch?: boolean,
|
||||
thinking?: boolean,
|
||||
textOverride?: string,
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto",
|
||||
) => Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
await act(async () => {
|
||||
await latestInputbarProps?.onSend?.(
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
"请帮我拆解并推进这个修复任务",
|
||||
"auto",
|
||||
);
|
||||
});
|
||||
await flushEffects(8);
|
||||
|
||||
expect(mockGenerateEphemeralTeamWithModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "project-team-runtime",
|
||||
providerType: "kiro",
|
||||
model: "mock-model",
|
||||
input: "请帮我拆解并推进这个修复任务",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="team-workspace-dock"]')
|
||||
?.getAttribute("data-runtime-status"),
|
||||
).toBe("forming");
|
||||
|
||||
await act(async () => {
|
||||
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 Promise.resolve();
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="team-workspace-dock"]')
|
||||
?.getAttribute("data-runtime-status"),
|
||||
).toBe("formed");
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="team-workspace-dock"]')
|
||||
?.getAttribute("data-child-count"),
|
||||
).toBe("0");
|
||||
});
|
||||
|
||||
it("用户手动点击 Team 入口时,组建中的 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;
|
||||
}),
|
||||
);
|
||||
|
||||
const mounted = mountPage({
|
||||
projectId: "project-team-manual-open",
|
||||
theme: "general",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
onToolStatesChange?: (
|
||||
next:
|
||||
| Record<string, unknown>
|
||||
| ((prev: Record<string, unknown>) => Record<string, unknown>),
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
act(() => {
|
||||
latestInputbarProps?.onToolStatesChange?.((previous) => ({
|
||||
...previous,
|
||||
subagent: true,
|
||||
}));
|
||||
});
|
||||
await flushEffects(8);
|
||||
|
||||
latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
onSend?: (
|
||||
images?: unknown[],
|
||||
webSearch?: boolean,
|
||||
thinking?: boolean,
|
||||
textOverride?: string,
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto",
|
||||
) => Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
await act(async () => {
|
||||
await latestInputbarProps?.onSend?.(
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
"请组织一个协作团队推进这项修复",
|
||||
"auto",
|
||||
);
|
||||
});
|
||||
await flushEffects(8);
|
||||
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="team-workspace-dock"]')
|
||||
?.getAttribute("data-runtime-status"),
|
||||
).toBe("forming");
|
||||
|
||||
clickButton(mounted.container, "team-workspace-dock-activate");
|
||||
await flushEffects(8);
|
||||
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat-canvas");
|
||||
|
||||
await act(async () => {
|
||||
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 Promise.resolve();
|
||||
});
|
||||
await flushEffects(8);
|
||||
});
|
||||
|
||||
it("同一会话首次出现真实 Team 成员时,应自动切到 Team 画布", async () => {
|
||||
const runtimeState = {
|
||||
childSubagentSessions: [] as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
session_type: "sub_agent";
|
||||
runtime_status: "running";
|
||||
task_summary: string;
|
||||
role_hint: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
mockUseAgentChatUnified.mockImplementation(
|
||||
({ workspaceId }: { workspaceId: string }) => {
|
||||
observedWorkspaceIds.push(workspaceId);
|
||||
return {
|
||||
providerType: "kiro",
|
||||
setProviderType: vi.fn(),
|
||||
model: "mock-model",
|
||||
setModel: vi.fn(),
|
||||
executionStrategy: "auto",
|
||||
setExecutionStrategy: vi.fn(),
|
||||
messages: [],
|
||||
currentTurnId: null,
|
||||
turns: [],
|
||||
threadItems: [],
|
||||
todoItems: [],
|
||||
childSubagentSessions: runtimeState.childSubagentSessions,
|
||||
subagentParentContext: null,
|
||||
queuedTurns: [],
|
||||
isSending: false,
|
||||
sendMessage: sharedSendMessageMock,
|
||||
stopSending: vi.fn(async () => undefined),
|
||||
promoteQueuedTurn: vi.fn(async () => false),
|
||||
removeQueuedTurn: vi.fn(async () => false),
|
||||
clearMessages: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
editMessage: vi.fn(),
|
||||
handlePermissionResponse: vi.fn(),
|
||||
pendingActions: [],
|
||||
triggerAIGuide: sharedTriggerAIGuideMock,
|
||||
topics: [
|
||||
{
|
||||
id: "topic-a",
|
||||
title: "话题 A",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
sessionId: "session-1",
|
||||
createFreshSession: vi.fn(async () => undefined),
|
||||
switchTopic: sharedSwitchTopicMock,
|
||||
deleteTopic: vi.fn(),
|
||||
renameTopic: vi.fn(),
|
||||
updateTopicSnapshot: vi.fn(),
|
||||
workspacePathMissing: false,
|
||||
fixWorkspacePathAndRetry: vi.fn(),
|
||||
dismissWorkspacePathError: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const mounted = mountPage({
|
||||
projectId: "project-team-real-graph",
|
||||
theme: "general",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
|
||||
runtimeState.childSubagentSessions = [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "分析成员",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
task_summary: "分析问题边界",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
];
|
||||
|
||||
mounted.rerender();
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
mounted.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat-canvas");
|
||||
});
|
||||
|
||||
it("已安装 skills 但未显式激活时,通用工作台不应展示技能区块", async () => {
|
||||
mockSkillsGetAll.mockResolvedValue([
|
||||
{
|
||||
@@ -1746,6 +2185,117 @@ describe("AgentChatPage 通用工作台", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("手动关闭浏览器协助画布后,同 scope 会话更新不应自动重新打开", async () => {
|
||||
mockBrowserAssistCompletedSession();
|
||||
|
||||
const harness = mountPage({
|
||||
theme: "general",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
harness.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat-canvas");
|
||||
|
||||
clickButton(harness.container, "toggle-canvas");
|
||||
await flushEffects(6);
|
||||
|
||||
expect(
|
||||
harness.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
|
||||
mockUseAgentChatUnified.mockImplementation(
|
||||
({ workspaceId }: { workspaceId: string }) => {
|
||||
observedWorkspaceIds.push(workspaceId);
|
||||
return {
|
||||
providerType: "kiro",
|
||||
setProviderType: vi.fn(),
|
||||
model: "mock-model",
|
||||
setModel: vi.fn(),
|
||||
executionStrategy: "auto",
|
||||
setExecutionStrategy: vi.fn(),
|
||||
messages: [
|
||||
{
|
||||
id: "msg-browser-user-refresh",
|
||||
role: "user",
|
||||
content: "继续浏览器流程",
|
||||
timestamp: new Date("2026-03-14T03:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "msg-browser-assistant-refresh",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date("2026-03-14T03:00:01.000Z"),
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool-browser-open-refresh",
|
||||
name: "mcp__lime-browser__browser_navigate",
|
||||
arguments: JSON.stringify({
|
||||
url: "https://www.rokid.com/news",
|
||||
profile_key: "general_browser_assist",
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: new Date("2026-03-14T03:00:01.100Z"),
|
||||
endTime: new Date("2026-03-14T03:00:02.000Z"),
|
||||
result: {
|
||||
success: true,
|
||||
output: "已刷新页面",
|
||||
metadata: {
|
||||
result: {
|
||||
session_id: "browser-session-1",
|
||||
profile_key: "general_browser_assist",
|
||||
page_info: {
|
||||
title: "Rokid News",
|
||||
url: "https://www.rokid.com/news",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
isSending: false,
|
||||
sendMessage: sharedSendMessageMock,
|
||||
stopSending: vi.fn(async () => undefined),
|
||||
clearMessages: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
editMessage: vi.fn(),
|
||||
handlePermissionResponse: vi.fn(),
|
||||
triggerAIGuide: sharedTriggerAIGuideMock,
|
||||
topics: [
|
||||
{
|
||||
id: "topic-a",
|
||||
title: "话题 A",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
sessionId: "session-1",
|
||||
switchTopic: sharedSwitchTopicMock,
|
||||
deleteTopic: vi.fn(),
|
||||
renameTopic: vi.fn(),
|
||||
workspacePathMissing: false,
|
||||
fixWorkspacePathAndRetry: vi.fn(),
|
||||
dismissWorkspacePathError: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
harness.rerender({});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
harness.container
|
||||
.querySelector('[data-testid="layout-transition"]')
|
||||
?.getAttribute("data-mode"),
|
||||
).toBe("chat");
|
||||
});
|
||||
|
||||
it("显式新 URL 的浏览器请求应复用现有会话并导航到新页面", async () => {
|
||||
mockBrowserAssistCompletedSession();
|
||||
mockBrowserExecuteAction.mockResolvedValueOnce({
|
||||
@@ -2137,12 +2687,17 @@ describe("AgentChatPage 自动引导", () => {
|
||||
expect(sharedTriggerAIGuideMock).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("存在 initialUserPrompt 时应优先发送首条意图", async () => {
|
||||
it("存在 initialUserPrompt 时应先预填并等待确认", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
const onInitialUserPromptConsumed = vi.fn();
|
||||
const initialUserPrompt = "请先帮我写一篇社媒文案提纲。";
|
||||
|
||||
renderPage({
|
||||
const container = renderPage({
|
||||
projectId: "project-social-intent",
|
||||
contentId: "content-social-intent",
|
||||
theme: "social-media",
|
||||
@@ -2152,8 +2707,20 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| { input?: string }
|
||||
| undefined;
|
||||
expect(latestInputbarProps?.input || "").toBe(initialUserPrompt);
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-entry-prompt"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
clickButton(container, "theme-workbench-entry-continue");
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
initialUserPrompt,
|
||||
`/social_post_with_cover ${initialUserPrompt}`,
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
@@ -2193,6 +2760,11 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
const contextContainer = mountedRoots.at(-1)?.container as HTMLDivElement;
|
||||
clickButton(contextContainer, "theme-workbench-entry-continue");
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
`/social_post_with_cover [生效上下文]\n1. [素材] 品牌手册\n\n${initialUserPrompt}`,
|
||||
[],
|
||||
@@ -2215,6 +2787,11 @@ describe("AgentChatPage 自动引导", () => {
|
||||
|
||||
it("存在 initialUserPrompt 时应使用当前选中模型发送", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
const selectedModel = "gemini-2.5-pro";
|
||||
const onInitialUserPromptConsumed = vi.fn();
|
||||
const initialUserPrompt = "请生成面向 CTO 的社媒提纲";
|
||||
@@ -2263,8 +2840,13 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
const selectedModelContainer = mountedRoots.at(-1)?.container as HTMLDivElement;
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
clickButton(selectedModelContainer, "theme-workbench-entry-continue");
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
initialUserPrompt,
|
||||
`/social_post_with_cover ${initialUserPrompt}`,
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
@@ -2283,6 +2865,84 @@ describe("AgentChatPage 自动引导", () => {
|
||||
expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("检测到未完成工作流时应提示继续或重新开始", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
mockContentWorkflowGetByContent.mockResolvedValue({
|
||||
id: "wf-resume-1",
|
||||
content_id: "content-social-resume",
|
||||
theme: "social-media",
|
||||
mode: "guided",
|
||||
current_step_index: 1,
|
||||
created_at: Date.now() - 10_000,
|
||||
updated_at: Date.now(),
|
||||
steps: [
|
||||
{
|
||||
id: "clarify",
|
||||
type: "clarify",
|
||||
title: "明确选题",
|
||||
description: "",
|
||||
behavior: {
|
||||
skippable: true,
|
||||
redoable: true,
|
||||
auto_advance: false,
|
||||
},
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
type: "write",
|
||||
title: "撰写主稿",
|
||||
description: "",
|
||||
behavior: {
|
||||
skippable: false,
|
||||
redoable: true,
|
||||
auto_advance: false,
|
||||
},
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const container = renderPage({
|
||||
projectId: "project-social-resume",
|
||||
contentId: "content-social-resume",
|
||||
theme: "social-media",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(14);
|
||||
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("发现上次未完成任务");
|
||||
expect(container.textContent).toContain("撰写主稿");
|
||||
|
||||
clickButton(container, "theme-workbench-entry-continue");
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
"/social_post_with_cover 请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“撰写主稿”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
expect.any(String),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
requestMetadata: expect.objectContaining({
|
||||
harness: expect.objectContaining({
|
||||
theme: "social-media",
|
||||
session_mode: "theme_workbench",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("首条意图被父层消费后,发送中仍应保留 bootstrap 预览,避免空白对话框", async () => {
|
||||
mockUseAgentChatUnified.mockImplementation(
|
||||
({ workspaceId }: { workspaceId: string }) => {
|
||||
@@ -2473,7 +3133,7 @@ describe("AgentChatPage 自动引导", () => {
|
||||
expect(container.querySelector('[data-testid="inputbar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("主题工作台在初始意图稍后注入时应自动发送首条创作请求", async () => {
|
||||
it("主题工作台在初始意图稍后注入时应改为预填并等待确认", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
@@ -2511,6 +3171,17 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
|
||||
| { input?: string }
|
||||
| undefined;
|
||||
expect(latestInputbarProps?.input || "").toBe(
|
||||
"请基于当前上下文直接开始生成首版社媒主稿。",
|
||||
);
|
||||
|
||||
clickButton(mounted.container, "theme-workbench-entry-continue");
|
||||
await flushEffects(10);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
"/social_post_with_cover 请基于当前上下文直接开始生成首版社媒主稿。",
|
||||
[],
|
||||
@@ -3994,7 +4665,7 @@ describe("AgentChatPage 小说主题工作台", () => {
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("小说主题工作台带初始意图时仍应自动发送首条请求", async () => {
|
||||
it("小说主题工作台带初始意图时应改为预填并等待确认", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
@@ -4015,6 +4686,11 @@ describe("AgentChatPage 小说主题工作台", () => {
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
const container = mountedRoots.at(-1)?.container as HTMLDivElement;
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
clickButton(container, "theme-workbench-entry-continue");
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
initialUserPrompt,
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { summarizeTeamWorkspaceExecution } from "./teamWorkspaceRuntime";
|
||||
|
||||
describe("summarizeTeamWorkspaceExecution", () => {
|
||||
it("主线程流结束后,只要 live team 仍在运行就应保持活跃", () => {
|
||||
const summary = summarizeTeamWorkspaceExecution({
|
||||
currentSessionId: "parent-1",
|
||||
currentSessionRuntimeStatus: "completed",
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "completed",
|
||||
latest_turn_status: "completed",
|
||||
},
|
||||
],
|
||||
liveRuntimeBySessionId: {
|
||||
"child-1": {
|
||||
runtimeStatus: "running",
|
||||
latestTurnStatus: "running",
|
||||
baseFingerprint: "child-1:2:completed:completed:0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.hasActiveRuntime).toBe(true);
|
||||
expect(summary.runningSessionCount).toBe(1);
|
||||
expect(summary.statusTitle).toContain("Team 运行中");
|
||||
});
|
||||
|
||||
it("某个子代理失败但仍有其他子代理运行时,不应提前收敛", () => {
|
||||
const summary = summarizeTeamWorkspaceExecution({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "failed",
|
||||
latest_turn_status: "failed",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "执行者",
|
||||
created_at: 3,
|
||||
updated_at: 4,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(summary.hasActiveRuntime).toBe(true);
|
||||
expect(summary.activeSessionCount).toBe(1);
|
||||
expect(summary.runningSessionCount).toBe(1);
|
||||
expect(summary.statusTitle).toContain("Team 运行中");
|
||||
});
|
||||
|
||||
it("所有 team 会话进入终态后,应返回非活跃状态", () => {
|
||||
const summary = summarizeTeamWorkspaceExecution({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究员",
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "completed",
|
||||
latest_turn_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "执行者",
|
||||
created_at: 3,
|
||||
updated_at: 4,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "failed",
|
||||
latest_turn_status: "failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(summary.hasActiveRuntime).toBe(false);
|
||||
expect(summary.activeSessionCount).toBe(0);
|
||||
expect(summary.statusTitle).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { AgentThreadItem } from "@/lib/api/agentStream";
|
||||
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import {
|
||||
buildTeamDefinitionSummary,
|
||||
type TeamDefinition,
|
||||
type TeamRoleDefinition,
|
||||
} from "./utils/teamDefinitions";
|
||||
|
||||
export type TeamWorkspaceRuntimeStatus =
|
||||
AsterSubagentSessionInfo["runtime_status"];
|
||||
@@ -18,6 +26,7 @@ export interface TeamWorkspaceActivityEntry {
|
||||
export interface TeamWorkspaceLiveRuntimeState {
|
||||
runtimeStatus: TeamWorkspaceRuntimeStatus;
|
||||
latestTurnStatus: TeamWorkspaceRuntimeStatus;
|
||||
queuedTurnCount?: number;
|
||||
baseFingerprint: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +42,16 @@ export interface TeamWorkspaceRuntimeCard {
|
||||
id: string;
|
||||
runtimeStatus?: TeamWorkspaceRuntimeStatus;
|
||||
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
|
||||
queuedTurnCount?: number;
|
||||
}
|
||||
|
||||
export interface TeamWorkspaceExecutionSummary {
|
||||
totalSessionCount: number;
|
||||
runningSessionCount: number;
|
||||
queuedSessionCount: number;
|
||||
activeSessionCount: number;
|
||||
hasActiveRuntime: boolean;
|
||||
statusTitle: string | null;
|
||||
}
|
||||
|
||||
export interface TeamWorkspaceWaitSummary {
|
||||
@@ -51,6 +70,106 @@ export interface TeamWorkspaceControlSummary {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type TeamWorkspaceRuntimeFormationStatus =
|
||||
| "forming"
|
||||
| "formed"
|
||||
| "failed";
|
||||
|
||||
export type TeamWorkspaceRuntimeMemberStatus =
|
||||
| "planned"
|
||||
| "spawning"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface TeamWorkspaceBlueprintSnapshot {
|
||||
label?: string | null;
|
||||
summary?: string | null;
|
||||
roles: TeamRoleDefinition[];
|
||||
}
|
||||
|
||||
export interface TeamWorkspaceRuntimeMember {
|
||||
id: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
profileId?: string;
|
||||
roleKey?: string;
|
||||
skillIds: string[];
|
||||
status: TeamWorkspaceRuntimeMemberStatus;
|
||||
sessionId?: string;
|
||||
latestSnippet?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamWorkspaceRuntimeFormationState {
|
||||
requestId: string;
|
||||
status: TeamWorkspaceRuntimeFormationStatus;
|
||||
label?: string | null;
|
||||
summary?: string | null;
|
||||
members: TeamWorkspaceRuntimeMember[];
|
||||
blueprint?: TeamWorkspaceBlueprintSnapshot | null;
|
||||
errorMessage?: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const FORMATION_STATUS_META = {
|
||||
forming: {
|
||||
label: "组建中",
|
||||
title: "正在准备本轮 Team",
|
||||
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
formed: {
|
||||
label: "已形成",
|
||||
title: "本轮 Team 已就绪",
|
||||
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
title: "Team 生成失败",
|
||||
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
|
||||
},
|
||||
} satisfies Record<
|
||||
TeamWorkspaceRuntimeFormationStatus,
|
||||
{
|
||||
label: string;
|
||||
title: string;
|
||||
badgeClassName: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const MEMBER_STATUS_META = {
|
||||
planned: {
|
||||
label: "计划中",
|
||||
badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600",
|
||||
},
|
||||
spawning: {
|
||||
label: "拉起中",
|
||||
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
running: {
|
||||
label: "运行中",
|
||||
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
waiting: {
|
||||
label: "等待中",
|
||||
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
|
||||
},
|
||||
} satisfies Record<
|
||||
TeamWorkspaceRuntimeMemberStatus,
|
||||
{
|
||||
label: string;
|
||||
badgeClassName: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const STATUS_META = {
|
||||
idle: {
|
||||
label: "待开始",
|
||||
@@ -77,7 +196,7 @@ const STATUS_META = {
|
||||
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
|
||||
},
|
||||
closed: {
|
||||
label: "已关闭",
|
||||
label: "已停止",
|
||||
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600",
|
||||
},
|
||||
} satisfies Record<
|
||||
@@ -145,7 +264,8 @@ function resolveActivityEntryStatusMeta(
|
||||
case "completed":
|
||||
return {
|
||||
label: "完成",
|
||||
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
badgeClassName:
|
||||
"border border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -198,7 +318,8 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): {
|
||||
return {
|
||||
title: item.output ? "检索结果" : "检索查询",
|
||||
detail:
|
||||
normalizeActivityText(item.output) || normalizeActivityText(item.query),
|
||||
normalizeActivityText(item.output) ||
|
||||
normalizeActivityText(item.query),
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
@@ -237,7 +358,7 @@ export function resolveTeamWorkspaceRuntimeStatusLabel(
|
||||
case "aborted":
|
||||
return "已中止";
|
||||
case "closed":
|
||||
return "已关闭";
|
||||
return "已停止";
|
||||
case "not_found":
|
||||
return "未找到";
|
||||
default:
|
||||
@@ -263,6 +384,147 @@ export function normalizeTeamWorkspaceRuntimeStatus(
|
||||
return status === "not_found" ? "closed" : status;
|
||||
}
|
||||
|
||||
export function isTeamWorkspaceActiveStatus(
|
||||
status?: TeamWorkspaceResolvedRuntimeStatus,
|
||||
): boolean {
|
||||
return status === "running" || status === "queued";
|
||||
}
|
||||
|
||||
function resolveExecutionSummaryStatusTitle(params: {
|
||||
totalSessionCount: number;
|
||||
runningSessionCount: number;
|
||||
queuedSessionCount: number;
|
||||
}) {
|
||||
const { totalSessionCount, runningSessionCount, queuedSessionCount } = params;
|
||||
if (runningSessionCount > 0) {
|
||||
if (queuedSessionCount > 0) {
|
||||
return totalSessionCount > 1
|
||||
? `Team 运行中 · ${runningSessionCount} 运行 / ${queuedSessionCount} 排队`
|
||||
: "Team 运行中";
|
||||
}
|
||||
return totalSessionCount > 1
|
||||
? `Team 运行中 · ${runningSessionCount}/${totalSessionCount}`
|
||||
: "Team 运行中";
|
||||
}
|
||||
|
||||
if (queuedSessionCount > 0) {
|
||||
return totalSessionCount > 1
|
||||
? `Team 排队中 · ${queuedSessionCount}/${totalSessionCount}`
|
||||
: "Team 排队中";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExecutionSummarySnapshots(params: {
|
||||
currentSessionId?: string | null;
|
||||
currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus;
|
||||
currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus;
|
||||
currentSessionQueuedTurnCount?: number;
|
||||
childSubagentSessions?: AsterSubagentSessionInfo[];
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
}) {
|
||||
const snapshots = new Map<string, TeamWorkspaceRuntimeSessionSnapshot>();
|
||||
const currentSessionId = params.currentSessionId?.trim();
|
||||
|
||||
if (params.subagentParentContext && currentSessionId) {
|
||||
snapshots.set(currentSessionId, {
|
||||
id: currentSessionId,
|
||||
runtimeStatus: params.currentSessionRuntimeStatus,
|
||||
latestTurnStatus: params.currentSessionLatestTurnStatus,
|
||||
queuedTurnCount: params.currentSessionQueuedTurnCount,
|
||||
});
|
||||
}
|
||||
|
||||
const relatedSessions = params.subagentParentContext
|
||||
? (params.subagentParentContext.sibling_subagent_sessions ?? [])
|
||||
: (params.childSubagentSessions ?? []);
|
||||
|
||||
relatedSessions.forEach((session) => {
|
||||
if (snapshots.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
snapshots.set(session.id, {
|
||||
id: session.id,
|
||||
runtimeStatus: session.runtime_status,
|
||||
latestTurnStatus: session.latest_turn_status,
|
||||
queuedTurnCount: session.queued_turn_count,
|
||||
updatedAt: session.updated_at,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(snapshots.values());
|
||||
}
|
||||
|
||||
function resolveExecutionSummarySessionStatus(params: {
|
||||
session: TeamWorkspaceRuntimeSessionSnapshot;
|
||||
liveState?: TeamWorkspaceLiveRuntimeState;
|
||||
}): TeamWorkspaceRuntimeStatus | undefined {
|
||||
if (params.liveState?.runtimeStatus) {
|
||||
return params.liveState.runtimeStatus;
|
||||
}
|
||||
|
||||
if (params.session.runtimeStatus) {
|
||||
return params.session.runtimeStatus;
|
||||
}
|
||||
|
||||
if (params.session.latestTurnStatus) {
|
||||
return params.session.latestTurnStatus;
|
||||
}
|
||||
|
||||
if ((params.session.queuedTurnCount ?? 0) > 0) {
|
||||
return "queued";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function summarizeTeamWorkspaceExecution(params: {
|
||||
currentSessionId?: string | null;
|
||||
currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus;
|
||||
currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus;
|
||||
currentSessionQueuedTurnCount?: number;
|
||||
childSubagentSessions?: AsterSubagentSessionInfo[];
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
liveRuntimeBySessionId?: Record<string, TeamWorkspaceLiveRuntimeState>;
|
||||
}): TeamWorkspaceExecutionSummary {
|
||||
const snapshots = buildExecutionSummarySnapshots(params);
|
||||
let runningSessionCount = 0;
|
||||
let queuedSessionCount = 0;
|
||||
|
||||
snapshots.forEach((session) => {
|
||||
const status = resolveExecutionSummarySessionStatus({
|
||||
session,
|
||||
liveState: params.liveRuntimeBySessionId?.[session.id],
|
||||
});
|
||||
|
||||
if (status === "running") {
|
||||
runningSessionCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "queued") {
|
||||
queuedSessionCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const activeSessionCount = runningSessionCount + queuedSessionCount;
|
||||
const totalSessionCount = snapshots.length;
|
||||
|
||||
return {
|
||||
totalSessionCount,
|
||||
runningSessionCount,
|
||||
queuedSessionCount,
|
||||
activeSessionCount,
|
||||
hasActiveRuntime: activeSessionCount > 0,
|
||||
statusTitle: resolveExecutionSummaryStatusTitle({
|
||||
totalSessionCount,
|
||||
runningSessionCount,
|
||||
queuedSessionCount,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTeamWorkspaceSessionFingerprint(
|
||||
session?: TeamWorkspaceRuntimeSessionSnapshot | null,
|
||||
) {
|
||||
@@ -347,5 +609,67 @@ export function applyLiveRuntimeState<T extends TeamWorkspaceRuntimeCard>(
|
||||
...session,
|
||||
runtimeStatus: liveState.runtimeStatus,
|
||||
latestTurnStatus: liveState.latestTurnStatus,
|
||||
queuedTurnCount: liveState.queuedTurnCount ?? session.queuedTurnCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTeamWorkspaceBlueprintSnapshot(
|
||||
team?: TeamDefinition | null,
|
||||
): TeamWorkspaceBlueprintSnapshot | null {
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: team.label?.trim() || null,
|
||||
summary: buildTeamDefinitionSummary(team) || null,
|
||||
roles: team.roles.map((role) => ({
|
||||
...role,
|
||||
skillIds: role.skillIds ? [...role.skillIds] : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeFormationStateFromTeam(params: {
|
||||
requestId: string;
|
||||
status: TeamWorkspaceRuntimeFormationStatus;
|
||||
runtimeTeam?: TeamDefinition | null;
|
||||
blueprintTeam?: TeamDefinition | null;
|
||||
errorMessage?: string | null;
|
||||
updatedAt?: number;
|
||||
}): TeamWorkspaceRuntimeFormationState {
|
||||
const runtimeTeam = params.runtimeTeam || null;
|
||||
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: params.status,
|
||||
label: runtimeTeam?.label?.trim() || null,
|
||||
summary: runtimeTeam ? buildTeamDefinitionSummary(runtimeTeam) || null : null,
|
||||
members: (runtimeTeam?.roles ?? []).map((role, index) => ({
|
||||
id: role.id?.trim() || `runtime-member-${index + 1}`,
|
||||
label: role.label?.trim() || `角色 ${index + 1}`,
|
||||
summary: role.summary?.trim() || `${role.label || `角色 ${index + 1}`}负责当前子任务。`,
|
||||
profileId: role.profileId?.trim() || undefined,
|
||||
roleKey: role.roleKey?.trim() || undefined,
|
||||
skillIds: role.skillIds ? [...role.skillIds] : [],
|
||||
status: "planned",
|
||||
sessionId: undefined,
|
||||
latestSnippet: null,
|
||||
})),
|
||||
blueprint: createTeamWorkspaceBlueprintSnapshot(params.blueprintTeam),
|
||||
errorMessage: params.errorMessage?.trim() || null,
|
||||
updatedAt: params.updatedAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeFormationStatusMeta(
|
||||
status: TeamWorkspaceRuntimeFormationStatus,
|
||||
) {
|
||||
return FORMATION_STATUS_META[status];
|
||||
}
|
||||
|
||||
export function resolveRuntimeMemberStatusMeta(
|
||||
status: TeamWorkspaceRuntimeMemberStatus,
|
||||
) {
|
||||
return MEMBER_STATUS_META[status];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import type { AgentThreadItem, AgentThreadItemStatus } from "../types";
|
||||
import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder";
|
||||
|
||||
export type AgentThreadGroupKind =
|
||||
| "thinking"
|
||||
@@ -445,7 +446,11 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null {
|
||||
if (item.type !== "subagent_activity") {
|
||||
return null;
|
||||
}
|
||||
return shortenText(item.title || item.summary || item.status_label);
|
||||
return shortenText(
|
||||
resolveInternalImageTaskDisplayName(item.title) ||
|
||||
item.summary ||
|
||||
item.status_label,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeAlertItem(item: AgentThreadItem): string | null {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
loadConfiguredProviders,
|
||||
type ConfiguredProvider,
|
||||
} from "@/hooks/useConfiguredProviders";
|
||||
import { loadProviderModels } from "@/hooks/useProviderModels";
|
||||
import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry";
|
||||
import { filterModelsByTheme } from "./modelThemePolicy";
|
||||
import { resolveProviderModelCompatibility } from "./providerModelCompatibility";
|
||||
|
||||
export interface ResolveClawWorkspaceProviderSelectionInput {
|
||||
currentProviderType?: string | null;
|
||||
currentModel?: string | null;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface ClawWorkspaceProviderSelection {
|
||||
providerType: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function normalizeValue(value?: string | null): string {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveExactModelId(
|
||||
models: EnhancedModelMetadata[],
|
||||
targetModelId?: string | null,
|
||||
): string | null {
|
||||
const normalizedTarget = normalizeValue(targetModelId);
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchedModel = models.find(
|
||||
(model) => normalizeValue(model.id) === normalizedTarget,
|
||||
);
|
||||
return matchedModel?.id ?? null;
|
||||
}
|
||||
|
||||
function resolvePreferredModelId(
|
||||
provider: ConfiguredProvider,
|
||||
models: EnhancedModelMetadata[],
|
||||
currentModel?: string | null,
|
||||
theme?: string,
|
||||
): string | null {
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themedModels = filterModelsByTheme(theme, models).models;
|
||||
const candidateModels = themedModels.length > 0 ? themedModels : models;
|
||||
|
||||
if (candidateModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveCompatibleModelId = (modelId?: string | null): string | null => {
|
||||
const exactModelId = resolveExactModelId(candidateModels, modelId);
|
||||
const compatibilityResult = resolveProviderModelCompatibility({
|
||||
providerType: provider.key,
|
||||
configuredProviderType: provider.type,
|
||||
model: exactModelId ?? modelId ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
resolveExactModelId(candidateModels, compatibilityResult.model) ??
|
||||
exactModelId
|
||||
);
|
||||
};
|
||||
|
||||
const retainedCurrentModel = resolveCompatibleModelId(currentModel);
|
||||
if (retainedCurrentModel) {
|
||||
return retainedCurrentModel;
|
||||
}
|
||||
|
||||
for (const candidateModel of candidateModels) {
|
||||
const compatibleModelId = resolveCompatibleModelId(candidateModel.id);
|
||||
if (compatibleModelId) {
|
||||
return compatibleModelId;
|
||||
}
|
||||
}
|
||||
|
||||
return candidateModels[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export async function resolveClawWorkspaceProviderSelection(
|
||||
input: ResolveClawWorkspaceProviderSelectionInput,
|
||||
): Promise<ClawWorkspaceProviderSelection | null> {
|
||||
const { currentProviderType, currentModel, theme } = input;
|
||||
const configuredProviders = await loadConfiguredProviders();
|
||||
|
||||
if (configuredProviders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentProvider = configuredProviders.find(
|
||||
(provider) => provider.key === currentProviderType,
|
||||
);
|
||||
const orderedProviders = currentProvider
|
||||
? [
|
||||
currentProvider,
|
||||
...configuredProviders.filter(
|
||||
(provider) => provider.key !== currentProvider.key,
|
||||
),
|
||||
]
|
||||
: configuredProviders;
|
||||
|
||||
for (const provider of orderedProviders) {
|
||||
const providerModels = await loadProviderModels(provider);
|
||||
const preferredModel = resolvePreferredModelId(
|
||||
provider,
|
||||
providerModels,
|
||||
provider.key === currentProvider?.key ? currentModel : null,
|
||||
theme,
|
||||
);
|
||||
|
||||
if (!preferredModel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
providerType: provider.key,
|
||||
model: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ describe("harnessRequestMetadata", () => {
|
||||
expect.objectContaining({
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
role_key: undefined,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "executor",
|
||||
@@ -86,6 +87,40 @@ describe("harnessRequestMetadata", () => {
|
||||
expect(metadata.gate_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("应保留 Team 角色的 profileId、roleKey 与 skillIds", () => {
|
||||
const metadata = buildHarnessRequestMetadata({
|
||||
theme: "general",
|
||||
creationMode: "guided",
|
||||
chatMode: "agent",
|
||||
webSearchEnabled: false,
|
||||
thinkingEnabled: true,
|
||||
taskModeEnabled: true,
|
||||
subagentModeEnabled: true,
|
||||
sessionMode: "default",
|
||||
selectedTeamRoles: [
|
||||
{
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题。",
|
||||
profileId: "code-explorer",
|
||||
roleKey: "explorer",
|
||||
skillIds: ["repo-exploration", "source-grounding"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(metadata.selected_team_roles).toEqual([
|
||||
{
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题。",
|
||||
profile_id: "code-explorer",
|
||||
role_key: "explorer",
|
||||
skill_ids: ["repo-exploration", "source-grounding"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("需要人工确认的浏览器任务应标记 user step", () => {
|
||||
const metadata = buildHarnessRequestMetadata({
|
||||
theme: "general",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ContentPart, Message } from "../types";
|
||||
|
||||
const BRACKET_IMAGE_PLACEHOLDER_RE = /\[\s*Image\s*#\d+\s*\]/gi;
|
||||
const BARE_IMAGE_PLACEHOLDER_RE = /(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/gi;
|
||||
const ONLY_IMAGE_PLACEHOLDERS_RE =
|
||||
/^\s*(?:(?:\[\s*Image\s*#\d+\s*\]|Image\s*#\d+)\s*[,,;;]?\s*)+$/i;
|
||||
const BRACKET_IMAGE_PLACEHOLDER_TEST_RE = /\[\s*Image\s*#\d+\s*\]/i;
|
||||
const BARE_IMAGE_PLACEHOLDER_TEST_RE =
|
||||
/(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/i;
|
||||
const EXACT_IMAGE_TASK_LABEL_RE = /^\[?\s*Image\s*#(\d+)\s*\]?$/i;
|
||||
|
||||
function collapseDisplayWhitespace(value: string): string {
|
||||
return value
|
||||
.replace(/\s+([,。!?、;:,.!?;:])/g, "$1")
|
||||
.replace(/([(【《“‘([<])\s+/g, "$1")
|
||||
.replace(/\s+([)】》”’)\]>])/g, "$1")
|
||||
.replace(/([\u4e00-\u9fff])\s+([\u4e00-\u9fff])/g, "$1$2")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function replaceImagePlaceholders(text: string, replacement: string): string {
|
||||
const withBracketPlaceholders = text.replace(
|
||||
BRACKET_IMAGE_PLACEHOLDER_RE,
|
||||
` ${replacement} `,
|
||||
);
|
||||
return withBracketPlaceholders.replace(
|
||||
BARE_IMAGE_PLACEHOLDER_RE,
|
||||
(_match, prefix: string) => `${prefix}${replacement}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function containsInternalImagePlaceholder(text: string): boolean {
|
||||
return (
|
||||
BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) ||
|
||||
BARE_IMAGE_PLACEHOLDER_TEST_RE.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOnlyInternalImagePlaceholderText(text: string): boolean {
|
||||
return ONLY_IMAGE_PLACEHOLDERS_RE.test(text.trim());
|
||||
}
|
||||
|
||||
export function resolveInternalImageTaskDisplayName(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = normalized.match(EXACT_IMAGE_TASK_LABEL_RE);
|
||||
if (!match) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const index = match[1]?.trim();
|
||||
return index ? `图片任务 ${index}` : "图片任务";
|
||||
}
|
||||
|
||||
interface SanitizeMessageTextOptions {
|
||||
role: Message["role"];
|
||||
hasImages?: boolean;
|
||||
}
|
||||
|
||||
export function sanitizeMessageTextForDisplay(
|
||||
text: string,
|
||||
options: SanitizeMessageTextOptions,
|
||||
): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!containsInternalImagePlaceholder(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (
|
||||
isOnlyInternalImagePlaceholderText(normalized) &&
|
||||
((options.role === "user" && options.hasImages) ||
|
||||
options.role === "assistant")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return collapseDisplayWhitespace(replaceImagePlaceholders(normalized, "图片"));
|
||||
}
|
||||
|
||||
export function sanitizeMessageTextForPreview(
|
||||
text: string,
|
||||
options: SanitizeMessageTextOptions,
|
||||
): string {
|
||||
const sanitized = sanitizeMessageTextForDisplay(text, options);
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
if (options.role === "user" && options.hasImages) {
|
||||
return "已附加图片";
|
||||
}
|
||||
|
||||
if (
|
||||
options.role === "assistant" &&
|
||||
isOnlyInternalImagePlaceholderText(text)
|
||||
) {
|
||||
return "图片处理中";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function sanitizeContentPartsForDisplay(
|
||||
parts: ContentPart[] | undefined,
|
||||
options: SanitizeMessageTextOptions,
|
||||
): ContentPart[] | undefined {
|
||||
if (!parts || parts.length === 0) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
const sanitizedParts = parts.flatMap((part) => {
|
||||
if (part.type !== "text") {
|
||||
return [part];
|
||||
}
|
||||
|
||||
const sanitizedText = sanitizeMessageTextForDisplay(part.text, options);
|
||||
if (!sanitizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...part,
|
||||
text: sanitizedText,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return sanitizedParts.length > 0 ? sanitizedParts : undefined;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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,
|
||||
} 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 模式生成一个“本轮 Team 配置”。",
|
||||
"要求:",
|
||||
"1. 仅输出 JSON,不要 Markdown,不要代码块,不要额外解释。",
|
||||
"2. 生成 2~4 个角色,角色名称与职责用中文。",
|
||||
"3. roleKey 优先从以下集合中选择:explorer、executor、verifier、researcher、planner、reviewer、writer。",
|
||||
"4. profileId 仅可使用以下值:code-explorer、code-executor、code-verifier、research-analyst、doc-writer、content-ideator、content-reviewer。",
|
||||
"5. skillIds 仅可使用以下值:repo-exploration、bounded-implementation、verification-report、source-grounding、structured-writing。",
|
||||
"6. 输出结构必须满足:",
|
||||
JSON.stringify(
|
||||
{
|
||||
label: "本轮 Team 名称",
|
||||
description: "一句话描述适用场景",
|
||||
roles: [
|
||||
{
|
||||
id: "role-id",
|
||||
label: "角色名称",
|
||||
summary: "角色职责",
|
||||
profileId: "code-explorer",
|
||||
roleKey: "explorer",
|
||||
skillIds: ["repo-exploration", "source-grounding"],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
`当前主题:${activeTheme?.trim() || "general"}`,
|
||||
blueprintPayload
|
||||
? "7. 如果下面提供了“参考蓝图 Team”,请把它视为偏好与约束来源;你可以按当前任务动态调整角色结构,但不要完全脱离参考蓝图。"
|
||||
: "7. 如果没有参考蓝图,请直接围绕当前任务组织最合适的本轮 Team。",
|
||||
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 normalized = normalizeTeamDefinition({
|
||||
id: createTeamDefinitionId("ephemeral-team"),
|
||||
source: "ephemeral",
|
||||
label: payload.label,
|
||||
description: payload.description,
|
||||
theme: activeTheme?.trim() || undefined,
|
||||
roles: payload.roles,
|
||||
});
|
||||
|
||||
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()}`;
|
||||
let unlisten: (() => void) | null = 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 {
|
||||
unlisten = 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 {
|
||||
unlisten?.();
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
try {
|
||||
await deleteAgentRuntimeSession(sessionId);
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
type TeamPresetOption,
|
||||
} from "./teamPresets";
|
||||
|
||||
export type TeamDefinitionSource = "builtin" | "custom";
|
||||
export type TeamDefinitionSource = "builtin" | "custom" | "ephemeral";
|
||||
export type PersistableTeamDefinitionSource = Exclude<
|
||||
TeamDefinitionSource,
|
||||
"ephemeral"
|
||||
>;
|
||||
|
||||
export interface TeamRoleDefinition {
|
||||
id: string;
|
||||
@@ -29,7 +33,7 @@ export interface TeamDefinition {
|
||||
|
||||
export interface TeamSelectionReference {
|
||||
id: string;
|
||||
source: TeamDefinitionSource;
|
||||
source: PersistableTeamDefinitionSource;
|
||||
}
|
||||
|
||||
function normalizeText(value?: string | null): string {
|
||||
@@ -148,7 +152,12 @@ export function normalizeTeamDefinition(
|
||||
|
||||
return {
|
||||
id: normalizeText(value.id) || createTeamDefinitionId("custom-team"),
|
||||
source: value.source === "builtin" ? "builtin" : "custom",
|
||||
source:
|
||||
value.source === "builtin"
|
||||
? "builtin"
|
||||
: value.source === "ephemeral"
|
||||
? "ephemeral"
|
||||
: "custom",
|
||||
label,
|
||||
description: normalizeText(value.description),
|
||||
theme: normalizeText(value.theme) || undefined,
|
||||
@@ -185,7 +194,7 @@ export function buildTeamDefinitionLabel(team?: TeamDefinition | null): string {
|
||||
export function buildTeamSelectionReference(
|
||||
team?: TeamDefinition | null,
|
||||
): TeamSelectionReference | null {
|
||||
if (!team) {
|
||||
if (!team || team.source === "ephemeral") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
WorkspaceAgentCustomTeamSettings,
|
||||
WorkspaceSettings,
|
||||
WorkspaceTeamSelectionReference,
|
||||
} from "@/types/workspace";
|
||||
import type { TeamDefinition } from "./teamDefinitions";
|
||||
import {
|
||||
createTeamDefinitionFromPreset,
|
||||
@@ -9,6 +14,18 @@ import {
|
||||
const CUSTOM_TEAM_STORAGE_KEY = "lime.chat.custom_teams.v1";
|
||||
const TEAM_SELECTION_STORAGE_KEY_PREFIX = "lime.chat.team_selection.v1";
|
||||
|
||||
type TeamSelectionLike = Pick<TeamSelectionReference, "id" | "source">;
|
||||
|
||||
interface ResolveSelectedTeamPreferenceOptions {
|
||||
theme?: string | null;
|
||||
workspaceSettings?: WorkspaceSettings | null;
|
||||
}
|
||||
|
||||
type WorkspaceTeamPreferenceState =
|
||||
| { kind: "unset" }
|
||||
| { kind: "disabled" }
|
||||
| { kind: "selected"; selection: TeamSelectionReference };
|
||||
|
||||
function normalizeCustomTeamList(
|
||||
teams: Array<Partial<TeamDefinition>> | TeamDefinition[],
|
||||
): TeamDefinition[] {
|
||||
@@ -43,6 +60,62 @@ function getTeamSelectionStorageKey(theme?: string | null): string {
|
||||
return `${TEAM_SELECTION_STORAGE_KEY_PREFIX}.${normalizeThemeScope(theme)}`;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTeamSelectionReference(
|
||||
value?: Partial<WorkspaceTeamSelectionReference> | null,
|
||||
): TeamSelectionReference | null {
|
||||
if (
|
||||
typeof value?.id !== "string" ||
|
||||
!value.id.trim() ||
|
||||
(value.source !== "builtin" && value.source !== "custom")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id.trim(),
|
||||
source: value.source,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTeamFromSelection(
|
||||
selection?: TeamSelectionReference | null,
|
||||
customTeams?: TeamDefinition[],
|
||||
): TeamDefinition | null {
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selection.source === "builtin") {
|
||||
return createTeamDefinitionFromPreset(selection.id);
|
||||
}
|
||||
|
||||
return (
|
||||
(customTeams || loadCustomTeams()).find((team) => team.id === selection.id) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceCustomTeamList(
|
||||
teams?: Array<Partial<WorkspaceAgentCustomTeamSettings>> | null,
|
||||
): TeamDefinition[] | null {
|
||||
if (!Array.isArray(teams)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeCustomTeamList(
|
||||
teams.map((team) => ({
|
||||
...team,
|
||||
source: "custom" as const,
|
||||
roles: Array.isArray(team.roles)
|
||||
? team.roles.map((role) => ({
|
||||
...role,
|
||||
skillIds: Array.isArray(role.skillIds) ? [...role.skillIds] : [],
|
||||
}))
|
||||
: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadCustomTeams(): TeamDefinition[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_TEAM_STORAGE_KEY);
|
||||
@@ -73,14 +146,12 @@ export function persistSelectedTeam(
|
||||
): void {
|
||||
try {
|
||||
const key = getTeamSelectionStorageKey(theme);
|
||||
if (!team) {
|
||||
const selection = buildTeamSelectionReference(team);
|
||||
if (!team || !selection) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify(buildTeamSelectionReference(team)),
|
||||
);
|
||||
localStorage.setItem(key, JSON.stringify(selection));
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
@@ -111,17 +182,135 @@ export function loadSelectedTeamReference(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTeamPreferenceState(
|
||||
settings?: WorkspaceSettings | null,
|
||||
): WorkspaceTeamPreferenceState {
|
||||
const agentTeam = settings?.agentTeam;
|
||||
if (!agentTeam) {
|
||||
return { kind: "unset" };
|
||||
}
|
||||
|
||||
if (agentTeam.disabled) {
|
||||
return { kind: "disabled" };
|
||||
}
|
||||
|
||||
const selection = normalizeWorkspaceTeamSelectionReference(
|
||||
agentTeam.selectedTeam,
|
||||
);
|
||||
return selection ? { kind: "selected", selection } : { kind: "unset" };
|
||||
}
|
||||
|
||||
export function loadCustomTeamsFromWorkspaceSettings(
|
||||
settings?: WorkspaceSettings | null,
|
||||
): TeamDefinition[] | null {
|
||||
return normalizeWorkspaceCustomTeamList(settings?.agentTeam?.customTeams);
|
||||
}
|
||||
|
||||
export function resolveCustomTeams(
|
||||
workspaceSettings?: WorkspaceSettings | null,
|
||||
): TeamDefinition[] {
|
||||
return loadCustomTeamsFromWorkspaceSettings(workspaceSettings) || loadCustomTeams();
|
||||
}
|
||||
|
||||
export function loadSelectedTeamReferenceFromWorkspaceSettings(
|
||||
settings?: WorkspaceSettings | null,
|
||||
): TeamSelectionReference | null {
|
||||
const state = resolveWorkspaceTeamPreferenceState(settings);
|
||||
return state.kind === "selected" ? state.selection : null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceSettingsWithSelectedTeam(
|
||||
currentSettings: WorkspaceSettings | null | undefined,
|
||||
team: TeamDefinition | null,
|
||||
): WorkspaceSettings {
|
||||
const selection = buildTeamSelectionReference(team);
|
||||
if (team?.source === "ephemeral") {
|
||||
return {
|
||||
...(currentSettings || {}),
|
||||
agentTeam: {
|
||||
...(currentSettings?.agentTeam || {}),
|
||||
disabled: false,
|
||||
selectedTeam: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...(currentSettings || {}),
|
||||
agentTeam: selection
|
||||
? {
|
||||
...(currentSettings?.agentTeam || {}),
|
||||
disabled: false,
|
||||
selectedTeam: selection,
|
||||
}
|
||||
: {
|
||||
...(currentSettings?.agentTeam || {}),
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceSettingsWithCustomTeams(
|
||||
currentSettings: WorkspaceSettings | null | undefined,
|
||||
teams: TeamDefinition[],
|
||||
): WorkspaceSettings {
|
||||
return {
|
||||
...(currentSettings || {}),
|
||||
agentTeam: {
|
||||
...(currentSettings?.agentTeam || {}),
|
||||
customTeams: normalizeCustomTeamList(teams).map((team) => ({
|
||||
id: team.id,
|
||||
label: team.label,
|
||||
description: team.description,
|
||||
theme: team.theme,
|
||||
presetId: team.presetId,
|
||||
roles: team.roles.map((role) => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
summary: role.summary,
|
||||
profileId: role.profileId,
|
||||
roleKey: role.roleKey,
|
||||
skillIds: role.skillIds ? [...role.skillIds] : [],
|
||||
})),
|
||||
createdAt: team.createdAt,
|
||||
updatedAt: team.updatedAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isSameTeamSelectionReference(
|
||||
left?: TeamSelectionLike | null,
|
||||
right?: TeamSelectionLike | null,
|
||||
): boolean {
|
||||
return (
|
||||
(left?.id || "") === (right?.id || "") &&
|
||||
(left?.source || "") === (right?.source || "")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePersistedSelectedTeam(
|
||||
theme?: string | null,
|
||||
): TeamDefinition | null {
|
||||
const selection = loadSelectedTeamReference(theme);
|
||||
if (!selection) {
|
||||
return resolveTeamFromSelection(loadSelectedTeamReference(theme));
|
||||
}
|
||||
|
||||
export function resolveSelectedTeamPreference({
|
||||
theme,
|
||||
workspaceSettings,
|
||||
}: ResolveSelectedTeamPreferenceOptions): TeamDefinition | null {
|
||||
const workspaceState = resolveWorkspaceTeamPreferenceState(workspaceSettings);
|
||||
const workspaceCustomTeams = loadCustomTeamsFromWorkspaceSettings(workspaceSettings);
|
||||
if (workspaceState.kind === "disabled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selection.source === "builtin") {
|
||||
return createTeamDefinitionFromPreset(selection.id);
|
||||
if (workspaceState.kind === "selected") {
|
||||
return resolveTeamFromSelection(
|
||||
workspaceState.selection,
|
||||
workspaceCustomTeams || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return loadCustomTeams().find((team) => team.id === selection.id) || null;
|
||||
return resolvePersistedSelectedTeam(theme);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,12 @@ const PanelSection = styled.div`
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const SectionHint = styled.span`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const PanelDivider = styled.div`
|
||||
height: 1px;
|
||||
background: hsl(var(--border));
|
||||
@@ -576,6 +582,9 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
|
||||
<Bot size={14} />
|
||||
模型切换
|
||||
</SectionTitle>
|
||||
<SectionHint>
|
||||
这里沿用当前 Claw 会话的 Provider、模型与思考过程设置。
|
||||
</SectionHint>
|
||||
<ModelSelector
|
||||
className="w-full"
|
||||
providerType={autoContinueProviderType}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { LayoutMode } from "../../types";
|
||||
import { LayoutTransition } from "./LayoutTransition";
|
||||
import {
|
||||
@@ -54,9 +54,34 @@ function PlainChatLayoutHarness({ mode }: { mode: LayoutMode }) {
|
||||
|
||||
describe("LayoutTransition", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 1440,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 900,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalInnerWidth,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalInnerHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it("chat-canvas 模式应为画布与对话保留分栏间距", () => {
|
||||
@@ -80,6 +105,38 @@ describe("LayoutTransition", () => {
|
||||
);
|
||||
|
||||
expect(hasGapRule).toBe(true);
|
||||
expect(root?.getAttribute("data-layout-axis")).toBe("horizontal");
|
||||
});
|
||||
|
||||
it("小屏 chat-canvas 模式应改为上下堆叠,避免右侧区域挤压不可见", () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 1080,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 720,
|
||||
});
|
||||
|
||||
const { container } = mountHarness(
|
||||
LayoutHarness,
|
||||
{ mode: "chat-canvas" },
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const root = container.querySelector<HTMLElement>(
|
||||
'[data-testid="layout-transition-root"]',
|
||||
);
|
||||
|
||||
expect(root?.getAttribute("data-layout-axis")).toBe("vertical");
|
||||
expect(
|
||||
container.querySelector('[data-testid="layout-chat-content"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="layout-canvas-content"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("画布内容为空时应退回聊天布局,避免保留空白画布列", () => {
|
||||
|
||||
@@ -4,36 +4,76 @@
|
||||
* @module components/content-creator/core/LayoutTransition/LayoutTransition
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { LayoutMode } from "../../types";
|
||||
import { useLayoutTransition, TransitionConfig } from "./useLayoutTransition";
|
||||
|
||||
const Container = styled.div`
|
||||
const STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH = 1320;
|
||||
const STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT = 820;
|
||||
const STACKED_CHAT_CANVAS_PANEL_HEIGHT = "clamp(260px, 36%, 360px)";
|
||||
|
||||
function shouldUseStackedChatCanvasLayout(mode: LayoutMode): boolean {
|
||||
if (mode !== "chat-canvas" || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.innerWidth <= STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH ||
|
||||
window.innerHeight <= STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $stacked: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: ${({ $stacked }) => ($stacked ? "column" : "row")};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
gap: ${({ $stacked }) => ($stacked ? "10px" : "12px")};
|
||||
`;
|
||||
|
||||
const ChatPanel = styled.div<{
|
||||
$width: string;
|
||||
$duration: number;
|
||||
$minWidth: string;
|
||||
$stacked: boolean;
|
||||
$hidden: boolean;
|
||||
$chrome: "panel" | "plain";
|
||||
}>`
|
||||
height: 100%;
|
||||
height: ${({ $stacked, $hidden }) =>
|
||||
$hidden
|
||||
? "0"
|
||||
: $stacked
|
||||
? STACKED_CHAT_CANVAS_PANEL_HEIGHT
|
||||
: "100%"};
|
||||
max-height: ${({ $stacked, $hidden }) =>
|
||||
$hidden
|
||||
? "0"
|
||||
: $stacked
|
||||
? STACKED_CHAT_CANVAS_PANEL_HEIGHT
|
||||
: "100%"};
|
||||
overflow: hidden;
|
||||
transition: width ${({ $duration }) => $duration}ms ease-out;
|
||||
width: ${({ $width }) => $width};
|
||||
min-width: ${({ $minWidth }) => $minWidth};
|
||||
will-change: width;
|
||||
transition:
|
||||
width ${({ $duration }) => $duration}ms ease-out,
|
||||
height ${({ $duration }) => $duration}ms ease-out;
|
||||
width: ${({ $stacked, $width, $hidden }) =>
|
||||
$hidden ? "0" : $stacked ? "100%" : $width};
|
||||
min-width: ${({ $stacked, $minWidth }) => ($stacked ? "0px" : $minWidth)};
|
||||
min-height: ${({ $stacked, $hidden }) =>
|
||||
$hidden ? "0" : $stacked ? "220px" : "100%"};
|
||||
flex: ${({ $stacked, $hidden }) =>
|
||||
$hidden
|
||||
? "0 0 0"
|
||||
: $stacked
|
||||
? `0 0 ${STACKED_CHAT_CANVAS_PANEL_HEIGHT}`
|
||||
: "0 0 auto"};
|
||||
will-change: width, height;
|
||||
display: ${({ $hidden }) => ($hidden ? "none" : "flex")};
|
||||
flex-direction: column;
|
||||
padding: ${({ $chrome }) =>
|
||||
$chrome === "plain" ? "0" : "16px 16px 16px 0"};
|
||||
padding: ${({ $stacked, $chrome }) =>
|
||||
$stacked || $chrome === "plain" ? "0" : "16px 16px 16px 0"};
|
||||
`;
|
||||
|
||||
const ChatPanelInner = styled.div`
|
||||
@@ -56,14 +96,16 @@ const PlainChatPanelInner = styled.div`
|
||||
|
||||
const CanvasPanel = styled.div<{
|
||||
$visible: boolean;
|
||||
$stacked: boolean;
|
||||
$transform: string;
|
||||
$opacity: number;
|
||||
$duration: number;
|
||||
}>`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
height: ${({ $stacked }) => ($stacked ? "auto" : "100%")};
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform ${({ $duration }) => $duration}ms ease-out,
|
||||
@@ -106,19 +148,43 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
|
||||
effectiveMode,
|
||||
transitionConfig,
|
||||
);
|
||||
const [stackedChatCanvas, setStackedChatCanvas] = useState(() =>
|
||||
shouldUseStackedChatCanvasLayout(effectiveMode),
|
||||
);
|
||||
|
||||
const chatStyles = getTransitionStyles("chat");
|
||||
const canvasStyles = getTransitionStyles("canvas");
|
||||
const shouldRenderCanvas = hasCanvasContent && isCanvasVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
setStackedChatCanvas(
|
||||
shouldUseStackedChatCanvasLayout(effectiveMode),
|
||||
);
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateLayout);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
};
|
||||
}, [effectiveMode]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
$stacked={stackedChatCanvas}
|
||||
data-testid="layout-transition-root"
|
||||
data-effective-mode={effectiveMode}
|
||||
data-has-canvas={shouldRenderCanvas ? "true" : "false"}
|
||||
data-layout-axis={stackedChatCanvas ? "vertical" : "horizontal"}
|
||||
>
|
||||
<CanvasPanel
|
||||
$visible={shouldRenderCanvas}
|
||||
$stacked={stackedChatCanvas}
|
||||
$transform={canvasStyles.transform as string}
|
||||
$opacity={canvasStyles.opacity as number}
|
||||
$duration={parseInt(
|
||||
@@ -133,7 +199,8 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
|
||||
$duration={parseInt(
|
||||
chatStyles.transition?.match(/\d+/)?.[0] || "300",
|
||||
)}
|
||||
$minWidth={effectiveMode === "canvas" ? "0px" : "460px"}
|
||||
$minWidth={effectiveMode === "chat-canvas" ? "360px" : "0px"}
|
||||
$stacked={stackedChatCanvas}
|
||||
$hidden={effectiveMode === "canvas"}
|
||||
$chrome={chatPanelChrome}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ function HookHarness({ mode }: { mode: LayoutMode }) {
|
||||
const { transitionState, isCanvasVisible, getTransitionStyles } =
|
||||
useLayoutTransition(mode);
|
||||
const canvasStyles = getTransitionStyles("canvas");
|
||||
const chatStyles = getTransitionStyles("chat");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -23,6 +24,7 @@ function HookHarness({ mode }: { mode: LayoutMode }) {
|
||||
data-canvas-visible={String(isCanvasVisible)}
|
||||
data-canvas-transform={String(canvasStyles.transform)}
|
||||
data-canvas-opacity={String(canvasStyles.opacity)}
|
||||
data-chat-width={String(chatStyles.width)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -66,5 +68,8 @@ describe("useLayoutTransition", () => {
|
||||
expect(root?.getAttribute("data-canvas-visible")).toBe("true");
|
||||
expect(root?.getAttribute("data-canvas-transform")).toBe("translateX(0)");
|
||||
expect(root?.getAttribute("data-canvas-opacity")).toBe("1");
|
||||
expect(root?.getAttribute("data-chat-width")).toBe(
|
||||
"min(100%, clamp(480px, 40%, 600px))",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ const DEFAULT_CONFIG: Required<TransitionConfig> = {
|
||||
exitDelay: 0,
|
||||
};
|
||||
|
||||
const CHAT_CANVAS_PANEL_WIDTH = "min(100%, clamp(480px, 40%, 600px))";
|
||||
|
||||
/**
|
||||
* 布局过渡 Hook
|
||||
*
|
||||
@@ -122,7 +124,11 @@ export function useLayoutTransition(
|
||||
return {
|
||||
transition: `width ${duration}ms ease-out`,
|
||||
width:
|
||||
mode === "chat-canvas" ? "35%" : mode === "canvas" ? "0%" : "100%",
|
||||
mode === "chat-canvas"
|
||||
? CHAT_CANVAS_PANEL_WIDTH
|
||||
: mode === "canvas"
|
||||
? "0%"
|
||||
: "100%",
|
||||
};
|
||||
},
|
||||
[transitionState, mergedConfig, mode],
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getProjectByRootPath, updateProject } from "@/lib/api/project";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
|
||||
|
||||
interface CrashRecoveryPanelProps {
|
||||
error: Error | null;
|
||||
@@ -207,6 +208,16 @@ export function CrashRecoveryPanel({
|
||||
return;
|
||||
}
|
||||
await updateProject(workspace.id, { rootPath: newPath });
|
||||
notifyProjectRuntimeAgentsGuide(
|
||||
{
|
||||
id: workspace.id,
|
||||
rootPath: newPath,
|
||||
},
|
||||
{
|
||||
successMessage: "Workspace 路径已更新",
|
||||
showSuccessWhenGuideAlreadySeen: false,
|
||||
},
|
||||
);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `Workspace 路径已更新为:${newPath}`,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DeleteProjectDialog } from "./DeleteProjectDialog";
|
||||
import { ContentListPage } from "./ContentListPage";
|
||||
import { toast } from "sonner";
|
||||
import { Page, PageParams } from "@/types/page";
|
||||
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
|
||||
|
||||
interface ProjectsPageProps {
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
@@ -166,13 +167,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
|
||||
try {
|
||||
const projectPath = await resolveProjectRootPath(name);
|
||||
|
||||
await createProject({
|
||||
const createdProject = await createProject({
|
||||
name,
|
||||
rootPath: projectPath,
|
||||
workspaceType: type,
|
||||
});
|
||||
|
||||
toast.success("项目创建成功");
|
||||
notifyProjectCreatedWithRuntimeAgentsGuide(createdProject, "项目创建成功");
|
||||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error("创建项目失败:", error);
|
||||
|
||||
@@ -8,6 +8,8 @@ const {
|
||||
mockGetMemoryOverview,
|
||||
mockGetMemoryEffectiveSources,
|
||||
mockGetMemoryAutoIndex,
|
||||
mockEnsureWorkspaceLocalAgentsGitignore,
|
||||
mockScaffoldRuntimeAgentsTemplate,
|
||||
mockToggleMemoryAuto,
|
||||
mockUpdateMemoryAutoNote,
|
||||
mockGetUnifiedMemoryStats,
|
||||
@@ -18,6 +20,8 @@ const {
|
||||
mockGetMemoryOverview: vi.fn(),
|
||||
mockGetMemoryEffectiveSources: vi.fn(),
|
||||
mockGetMemoryAutoIndex: vi.fn(),
|
||||
mockEnsureWorkspaceLocalAgentsGitignore: vi.fn(),
|
||||
mockScaffoldRuntimeAgentsTemplate: vi.fn(),
|
||||
mockToggleMemoryAuto: vi.fn(),
|
||||
mockUpdateMemoryAutoNote: vi.fn(),
|
||||
mockGetUnifiedMemoryStats: vi.fn(),
|
||||
@@ -33,6 +37,8 @@ vi.mock("@/lib/api/memoryRuntime", () => ({
|
||||
getContextMemoryOverview: mockGetMemoryOverview,
|
||||
getContextMemoryEffectiveSources: mockGetMemoryEffectiveSources,
|
||||
getContextMemoryAutoIndex: mockGetMemoryAutoIndex,
|
||||
ensureWorkspaceLocalAgentsGitignore: mockEnsureWorkspaceLocalAgentsGitignore,
|
||||
scaffoldRuntimeAgentsTemplate: mockScaffoldRuntimeAgentsTemplate,
|
||||
toggleContextMemoryAuto: mockToggleMemoryAuto,
|
||||
updateContextMemoryAutoNote: mockUpdateMemoryAutoNote,
|
||||
}));
|
||||
@@ -183,6 +189,17 @@ beforeEach(() => {
|
||||
items: [],
|
||||
});
|
||||
mockToggleMemoryAuto.mockResolvedValue({ enabled: false });
|
||||
mockScaffoldRuntimeAgentsTemplate.mockResolvedValue({
|
||||
target: "workspace",
|
||||
path: "/tmp/.lime/AGENTS.md",
|
||||
status: "created",
|
||||
createdParentDir: true,
|
||||
});
|
||||
mockEnsureWorkspaceLocalAgentsGitignore.mockResolvedValue({
|
||||
path: "/tmp/.gitignore",
|
||||
entry: ".lime/AGENTS.local.md",
|
||||
status: "added",
|
||||
});
|
||||
mockUpdateMemoryAutoNote.mockResolvedValue({
|
||||
enabled: true,
|
||||
root_dir: "/tmp/memory",
|
||||
@@ -255,4 +272,34 @@ describe("MemorySettings", () => {
|
||||
expect(mockUpdateMemoryAutoNote).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("请先输入要保存的自动记忆内容");
|
||||
});
|
||||
|
||||
it("点击生成 Workspace 模板应调用模板生成 API", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "生成 Workspace 模板").click();
|
||||
});
|
||||
|
||||
expect(mockScaffoldRuntimeAgentsTemplate).toHaveBeenCalledWith(
|
||||
"workspace",
|
||||
"/tmp",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("点击加入 .gitignore 应调用 gitignore API", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "将本机模板加入 .gitignore").click();
|
||||
});
|
||||
|
||||
expect(mockEnsureWorkspaceLocalAgentsGitignore).toHaveBeenCalledWith(
|
||||
"/tmp",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
getContextMemoryAutoIndex,
|
||||
getContextMemoryEffectiveSources,
|
||||
getContextMemoryOverview,
|
||||
ensureWorkspaceLocalAgentsGitignore,
|
||||
scaffoldRuntimeAgentsTemplate,
|
||||
toggleContextMemoryAuto,
|
||||
updateContextMemoryAutoNote,
|
||||
type AutoMemoryIndexResponse,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
type MemoryProfileConfig,
|
||||
type MemoryResolveConfig,
|
||||
type MemorySourcesConfig,
|
||||
type RuntimeAgentsTemplateTarget,
|
||||
} from "@/lib/api/memoryRuntime";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import { getUnifiedMemoryStats } from "@/lib/api/unifiedMemory";
|
||||
@@ -95,7 +98,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig {
|
||||
sources?.project_memory_paths?.length &&
|
||||
sources.project_memory_paths.filter((item) => item.trim().length > 0)
|
||||
? sources.project_memory_paths
|
||||
: ["AGENTS.md", ".agents/AGENTS.md"],
|
||||
: [".lime/AGENTS.md"],
|
||||
project_rule_dirs:
|
||||
sources?.project_rule_dirs?.length &&
|
||||
sources.project_rule_dirs.filter((item) => item.trim().length > 0)
|
||||
@@ -103,7 +106,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig {
|
||||
: [".agents/rules"],
|
||||
user_memory_path: sources?.user_memory_path ?? undefined,
|
||||
project_local_memory_path:
|
||||
sources?.project_local_memory_path ?? "AGENTS.local.md",
|
||||
sources?.project_local_memory_path ?? ".lime/AGENTS.local.md",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,6 +343,9 @@ export function MemorySettings() {
|
||||
const [loadingLayerMetrics, setLoadingLayerMetrics] = useState(false);
|
||||
const [loadingSourceState, setLoadingSourceState] = useState(false);
|
||||
const [savingAutoNote, setSavingAutoNote] = useState(false);
|
||||
const [scaffoldingTarget, setScaffoldingTarget] =
|
||||
useState<RuntimeAgentsTemplateTarget | null>(null);
|
||||
const [ensuringGitignore, setEnsuringGitignore] = useState(false);
|
||||
const [projectId, setProjectId] = useState<string | null>(() =>
|
||||
getStoredResourceProjectId({ includeLegacy: true }),
|
||||
);
|
||||
@@ -551,6 +557,65 @@ export function MemorySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScaffoldRuntimeAgentsTemplate = async (
|
||||
target: RuntimeAgentsTemplateTarget,
|
||||
) => {
|
||||
const workingDir = effectiveSources?.working_dir?.trim() || undefined;
|
||||
const targetLabelMap: Record<RuntimeAgentsTemplateTarget, string> = {
|
||||
global: "全局",
|
||||
workspace: "Workspace",
|
||||
workspace_local: "本机私有",
|
||||
};
|
||||
|
||||
if (target !== "global" && !workingDir) {
|
||||
setMessage(`当前未获取到 workspace 路径,暂无法生成${targetLabelMap[target]}模板`);
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
setScaffoldingTarget(target);
|
||||
try {
|
||||
const result = await scaffoldRuntimeAgentsTemplate(target, workingDir, false);
|
||||
if (result.status === "exists") {
|
||||
setMessage(`${targetLabelMap[target]}模板已存在,未覆盖:${result.path}`);
|
||||
} else {
|
||||
setMessage(`已生成${targetLabelMap[target]}模板:${result.path}`);
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
await Promise.all([loadSourceState(), loadLayerMetrics()]);
|
||||
} catch (error) {
|
||||
console.error("生成运行时 AGENTS 模板失败:", error);
|
||||
setMessage(`生成${targetLabelMap[target]}模板失败`);
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} finally {
|
||||
setScaffoldingTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnsureWorkspaceLocalGitignore = async () => {
|
||||
const workingDir = effectiveSources?.working_dir?.trim() || undefined;
|
||||
if (!workingDir) {
|
||||
setMessage("当前未获取到 workspace 路径,暂无法更新 .gitignore");
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
setEnsuringGitignore(true);
|
||||
try {
|
||||
const result = await ensureWorkspaceLocalAgentsGitignore(workingDir);
|
||||
const actionText =
|
||||
result.status === "exists" ? "已存在,无需重复添加" : "已写入";
|
||||
setMessage(`${actionText} .gitignore:${result.path}`);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
console.error("更新 .gitignore 失败:", error);
|
||||
setMessage("更新 .gitignore 失败");
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} finally {
|
||||
setEnsuringGitignore(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
@@ -916,7 +981,7 @@ export function MemorySettings() {
|
||||
}))
|
||||
}
|
||||
className={INPUT_CLASS_NAME}
|
||||
placeholder="例如 /Library/Application Support/Lime/AGENTS.md"
|
||||
placeholder="例如 ~/.lime/AGENTS.md"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -937,7 +1002,7 @@ export function MemorySettings() {
|
||||
}))
|
||||
}
|
||||
className={INPUT_CLASS_NAME}
|
||||
placeholder="留空时使用应用默认 AGENTS.md 路径"
|
||||
placeholder="留空时使用应用默认 ~/.lime/AGENTS.md 路径"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -959,10 +1024,81 @@ export function MemorySettings() {
|
||||
}))
|
||||
}
|
||||
className={INPUT_CLASS_NAME}
|
||||
placeholder="例如 AGENTS.local.md"
|
||||
placeholder="例如 .lime/AGENTS.local.md"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[20px] border border-slate-200/80 bg-white/80 p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
显式生成模板
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
只在你点击时创建模板文件,不会静默生成,也不会默认覆盖已有内容。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-500">
|
||||
当前 Workspace:{effectiveSources?.working_dir || "未解析"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleScaffoldRuntimeAgentsTemplate("global")
|
||||
}
|
||||
disabled={scaffoldingTarget !== null}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
|
||||
>
|
||||
{scaffoldingTarget === "global"
|
||||
? "生成中..."
|
||||
: "生成全局模板"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleScaffoldRuntimeAgentsTemplate("workspace")
|
||||
}
|
||||
disabled={
|
||||
scaffoldingTarget !== null || !effectiveSources?.working_dir
|
||||
}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
|
||||
>
|
||||
{scaffoldingTarget === "workspace"
|
||||
? "生成中..."
|
||||
: "生成 Workspace 模板"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleScaffoldRuntimeAgentsTemplate(
|
||||
"workspace_local",
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
scaffoldingTarget !== null || !effectiveSources?.working_dir
|
||||
}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
|
||||
>
|
||||
{scaffoldingTarget === "workspace_local"
|
||||
? "生成中..."
|
||||
: "生成本机模板"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnsureWorkspaceLocalGitignore()}
|
||||
disabled={ensuringGitignore || !effectiveSources?.working_dir}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
|
||||
>
|
||||
{ensuringGitignore
|
||||
? "写入中..."
|
||||
: "将本机模板加入 .gitignore"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4">
|
||||
@@ -1047,7 +1183,7 @@ export function MemorySettings() {
|
||||
项目记忆文件
|
||||
</span>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
每行一个相对路径,例如 `AGENTS.md`。
|
||||
每行一个相对路径,例如 `.lime/AGENTS.md`。
|
||||
</span>
|
||||
<textarea
|
||||
value={(sourcesConfig.project_memory_paths || []).join("\n")}
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
} from "@/lib/api/appUpdate";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_RELEASES_URL =
|
||||
"https://github.com/aiclientproxy/lime/releases";
|
||||
const FALLBACK_RELEASES_URL = "https://github.com/aiclientproxy/lime/releases";
|
||||
|
||||
const CREATIVE_THEMES = [
|
||||
"通用对话",
|
||||
@@ -74,6 +73,23 @@ const RELATED_LINKS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatUpdateDate(value?: string) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
interface AboutPanelProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -150,11 +166,10 @@ export function AboutSection() {
|
||||
const loadCurrentVersion = async () => {
|
||||
try {
|
||||
const result = await checkForUpdates();
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
current: result.current,
|
||||
setVersionInfo({
|
||||
...result,
|
||||
downloadUrl: result.downloadUrl || FALLBACK_RELEASES_URL,
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load version:", error);
|
||||
setVersionInfo((prev) => ({
|
||||
@@ -200,8 +215,8 @@ export function AboutSection() {
|
||||
setDownloadResult({
|
||||
...result,
|
||||
message: t(
|
||||
"安装程序已启动,应用将自动关闭以完成更新",
|
||||
"安装程序已启动,应用将自动关闭以完成更新",
|
||||
"更新已安装,应用将自动重启以完成升级",
|
||||
"更新已安装,应用将自动重启以完成升级",
|
||||
),
|
||||
});
|
||||
}, 1000);
|
||||
@@ -251,6 +266,20 @@ export function AboutSection() {
|
||||
};
|
||||
}, [t, versionInfo.error, versionInfo.hasUpdate, versionInfo.latest]);
|
||||
|
||||
const releaseNotesPreview = useMemo(() => {
|
||||
const notes = versionInfo.releaseNotes?.trim();
|
||||
if (!notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return notes.length > 220 ? `${notes.slice(0, 220)}...` : notes;
|
||||
}, [versionInfo.releaseNotes]);
|
||||
|
||||
const updatePublishedAt = useMemo(
|
||||
() => formatUpdateDate(versionInfo.pubDate),
|
||||
[versionInfo.pubDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
<section className="relative overflow-hidden rounded-[30px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(245,250,248,0.98)_0%,rgba(255,255,255,0.98)_52%,rgba(242,247,255,0.96)_100%)] shadow-sm shadow-slate-950/5">
|
||||
@@ -278,13 +307,22 @@ export function AboutSection() {
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
|
||||
{t("一句话:从“想到”直接走到“可发布”", "一句话:从“想到”直接走到“可发布”")}
|
||||
{t(
|
||||
"一句话:从“想到”直接走到“可发布”",
|
||||
"一句话:从“想到”直接走到“可发布”",
|
||||
)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
|
||||
{t("支持多主题创作与跨媒介工作流", "支持多主题创作与跨媒介工作流")}
|
||||
{t(
|
||||
"支持多主题创作与跨媒介工作流",
|
||||
"支持多主题创作与跨媒介工作流",
|
||||
)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
|
||||
{t("适合个人创作者与小团队长期沉淀资产", "适合个人创作者与小团队长期沉淀资产")}
|
||||
{t(
|
||||
"适合个人创作者与小团队长期沉淀资产",
|
||||
"适合个人创作者与小团队长期沉淀资产",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,8 +355,7 @@ export function AboutSection() {
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{t("当前版本 {{version}}", {
|
||||
version:
|
||||
versionInfo.current || t("读取中", "读取中"),
|
||||
version: versionInfo.current || t("读取中", "读取中"),
|
||||
defaultValue: "当前版本 {{version}}",
|
||||
})}
|
||||
</p>
|
||||
@@ -375,6 +412,29 @@ export function AboutSection() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{versionInfo.hasUpdate &&
|
||||
(releaseNotesPreview || updatePublishedAt) ? (
|
||||
<div className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50/85 p-4 text-sm text-slate-600 shadow-sm shadow-slate-950/5">
|
||||
<p className="font-medium text-slate-700">
|
||||
{t("最新版本 {{version}}", {
|
||||
version: versionInfo.latest ?? "",
|
||||
defaultValue: "最新版本 {{version}}",
|
||||
})}
|
||||
{updatePublishedAt
|
||||
? ` · ${t("发布时间 {{date}}", {
|
||||
date: updatePublishedAt,
|
||||
defaultValue: "发布时间 {{date}}",
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
{releaseNotesPreview ? (
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500">
|
||||
{releaseNotesPreview}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{downloadResult ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -410,22 +470,34 @@ export function AboutSection() {
|
||||
<AboutStat
|
||||
label={t("创作主题", "创作主题")}
|
||||
value={CREATIVE_THEMES.length.toString()}
|
||||
description={t("覆盖对话、海报、短视频、小说等常见创作场景。", "覆盖对话、海报、短视频、小说等常见创作场景。")}
|
||||
description={t(
|
||||
"覆盖对话、海报、短视频、小说等常见创作场景。",
|
||||
"覆盖对话、海报、短视频、小说等常见创作场景。",
|
||||
)}
|
||||
/>
|
||||
<AboutStat
|
||||
label={t("起步步骤", "起步步骤")}
|
||||
value={QUICK_START_STEPS.length.toString()}
|
||||
description={t("从选主题到持续迭代,压缩成清晰的三步流程。", "从选主题到持续迭代,压缩成清晰的三步流程。")}
|
||||
description={t(
|
||||
"从选主题到持续迭代,压缩成清晰的三步流程。",
|
||||
"从选主题到持续迭代,压缩成清晰的三步流程。",
|
||||
)}
|
||||
/>
|
||||
<AboutStat
|
||||
label={t("目标人群", "目标人群")}
|
||||
value={TARGET_USERS.length.toString()}
|
||||
description={t("既适合单人创作,也适合需要共用策略的小团队。", "既适合单人创作,也适合需要共用策略的小团队。")}
|
||||
description={t(
|
||||
"既适合单人创作,也适合需要共用策略的小团队。",
|
||||
"既适合单人创作,也适合需要共用策略的小团队。",
|
||||
)}
|
||||
/>
|
||||
<AboutStat
|
||||
label={t("官方入口", "官方入口")}
|
||||
value={RELATED_LINKS.length.toString()}
|
||||
description={t("源码、文档和 issue 入口集中保留,方便反馈和跟进。", "源码、文档和 issue 入口集中保留,方便反馈和跟进。")}
|
||||
description={t(
|
||||
"源码、文档和 issue 入口集中保留,方便反馈和跟进。",
|
||||
"源码、文档和 issue 入口集中保留,方便反馈和跟进。",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +508,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={Compass}
|
||||
title={t("产品定位", "产品定位")}
|
||||
description={t("先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。", "先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。")}
|
||||
description={t(
|
||||
"先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。",
|
||||
"先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4 text-sm leading-7 text-slate-600">
|
||||
@@ -450,7 +525,10 @@ export function AboutSection() {
|
||||
{t("一句话总结", "一句话总结")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
{t("从“想到”直接走到“可发布”。", "从“想到”直接走到“可发布”。")}
|
||||
{t(
|
||||
"从“想到”直接走到“可发布”。",
|
||||
"从“想到”直接走到“可发布”。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,7 +537,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={Rocket}
|
||||
title={t("3 步开始创作", "3 步开始创作")}
|
||||
description={t("把首次上手流程压缩成三步,避免用户在设置里迷路。", "把首次上手流程压缩成三步,避免用户在设置里迷路。")}
|
||||
description={t(
|
||||
"把首次上手流程压缩成三步,避免用户在设置里迷路。",
|
||||
"把首次上手流程压缩成三步,避免用户在设置里迷路。",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{QUICK_START_STEPS.map((step, index) => (
|
||||
@@ -481,7 +562,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={Users}
|
||||
title={t("适合谁", "适合谁")}
|
||||
description={t("更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。", "更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。")}
|
||||
description={t(
|
||||
"更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。",
|
||||
"更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{TARGET_USERS.map((user) => (
|
||||
@@ -505,7 +589,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={Layers3}
|
||||
title={t("支持的创作主题", "支持的创作主题")}
|
||||
description={t("主题不是简单分类,而是预设好的工作上下文与内容目标。", "主题不是简单分类,而是预设好的工作上下文与内容目标。")}
|
||||
description={t(
|
||||
"主题不是简单分类,而是预设好的工作上下文与内容目标。",
|
||||
"主题不是简单分类,而是预设好的工作上下文与内容目标。",
|
||||
)}
|
||||
aside={
|
||||
<span className="rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">
|
||||
{CREATIVE_THEMES.length} {t("项", "项")}
|
||||
@@ -527,7 +614,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={BookOpen}
|
||||
title={t("相关链接", "相关链接")}
|
||||
description={t("源码、文档和问题反馈保持直达,减少寻找成本。", "源码、文档和问题反馈保持直达,减少寻找成本。")}
|
||||
description={t(
|
||||
"源码、文档和问题反馈保持直达,减少寻找成本。",
|
||||
"源码、文档和问题反馈保持直达,减少寻找成本。",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{RELATED_LINKS.map((link) => (
|
||||
@@ -555,7 +645,10 @@ export function AboutSection() {
|
||||
<AboutPanel
|
||||
icon={Wand2}
|
||||
title={t("可选能力", "可选能力")}
|
||||
description={t("这两类说明更偏进阶使用场景,不应和主叙事混在一起。", "这两类说明更偏进阶使用场景,不应和主叙事混在一起。")}
|
||||
description={t(
|
||||
"这两类说明更偏进阶使用场景,不应和主叙事混在一起。",
|
||||
"这两类说明更偏进阶使用场景,不应和主叙事混在一起。",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{PRODUCT_CAPABILITIES.map((item) => (
|
||||
@@ -576,9 +669,7 @@ export function AboutSection() {
|
||||
<Sparkles className="h-4 w-4 text-emerald-600" />
|
||||
{t("Made for creators & builders", "Made for creators & builders")}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
2025-2026 Lime
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">2025-2026 Lime</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user