feat: refresh latest v0.97.0 release payload

This commit is contained in:
coso
2026-03-28 08:33:54 +08:00
parent 71a225cfa2
commit dfbbb3d204
69 changed files with 6291 additions and 5190 deletions
+19 -1
View File
@@ -54,6 +54,7 @@ import { toast } from "sonner";
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { hasTauriInvokeCapability } from "@/lib/tauri-runtime";
import { SettingsTabs } from "./types/settings";
const AppContainer = styled.div`
display: flex;
@@ -424,6 +425,21 @@ function AppContent() {
toast.success("项目创建成功");
};
const handleOpenBrowserConnectorSettings = useCallback(
({ enable }: { enable: boolean }) => {
handleNavigate("settings", {
tab: SettingsTabs.ChromeRelay,
});
if (enable) {
toast.info("已打开连接器设置", {
description: "在“连接器”页中开启浏览器连接器或重新同步扩展。",
});
}
},
[handleNavigate],
);
const {
connectPayload,
relayInfo,
@@ -433,7 +449,9 @@ function AppContent() {
error,
handleConfirm,
handleCancel,
} = useDeepLink();
} = useDeepLink({
onOpenBrowserConnectorSettings: handleOpenBrowserConnectorSettings,
});
const { error: registryError, refresh: _refreshRegistry } =
useRelayRegistry();
@@ -15,12 +15,13 @@ import {
useRef,
} from "react";
import { toast } from "sonner";
import { useAgentChatUnified, useCompatSubagentRuntime } from "./hooks";
import { useAgentChatUnified } from "./hooks";
import { type TaskStatusReason } from "./hooks/agentChatShared";
import {
settleLiveArtifactAfterStreamStops,
useArtifactDisplayState,
} from "./hooks/useArtifactDisplayState";
import { useCompatSubagentRuntime } from "./hooks/useCompatSubagentRuntime";
import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard";
import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync } from "./hooks/useContentSync";
@@ -1991,13 +1992,18 @@ export function AgentChatWorkspace({
contentId,
onRunImageWorkbenchCommand: handleImageWorkbenchCommand,
});
const { handleA2UISubmit, handleInputbarA2UISubmit } =
useWorkspaceA2UISubmitActions({
handlePermissionResponseWithBrowserPreflight,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
sendMessage,
});
const { handleInputbarA2UISubmit } = useWorkspaceA2UISubmitActions({
handlePermissionResponseWithBrowserPreflight,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
sendMessage,
});
const handleMessageA2UISubmit = useCallback(
(formData: Parameters<typeof handleInputbarA2UISubmit>[0], _messageId: string) => {
handleInputbarA2UISubmit(formData);
},
[handleInputbarA2UISubmit],
);
// 监听主题工作台技能触发
useEffect(() => {
@@ -2643,7 +2649,7 @@ export function AgentChatWorkspace({
promoteQueuedTurn,
deleteMessage,
editMessage,
handleA2UISubmit,
handleA2UISubmit: handleMessageA2UISubmit,
handleWriteFile,
handleFileClick: handleWorkspaceFileClick,
handleOpenArtifactFromTimeline,
@@ -49,7 +49,7 @@ describe("A2UITaskCard", () => {
expect(container.querySelector("[data-testid='agent-a2ui-task-card']")).not.toBeNull();
expect(container.textContent).toContain("补充信息");
expect(container.textContent).toContain("待完成 1 / 1");
expect(container.textContent).toContain("等你确认");
clickButtonByText(container, "新写一篇内容");
await flushEffects();
@@ -77,6 +77,6 @@ describe("A2UITaskCard", () => {
container.querySelector("[data-testid='agent-a2ui-task-loading-card']"),
).not.toBeNull();
expect(container.textContent).toContain("正在解析结构化问题,请稍等。");
expect(container.textContent).toContain("表单加载中...");
expect(container.textContent).toContain("这一步加载中...");
});
});
@@ -21,18 +21,18 @@ interface ActionRequestA2UIPreviewCardProps {
function resolveStatusLabel(request: ActionRequired): string {
switch (request.status) {
case "queued":
return "已记录";
return "已记下";
case "submitted":
return "已确认";
default:
return "待补充";
return "等你补充";
}
}
function resolveTitle(request: ActionRequired): string {
return request.status === "submitted" || request.status === "queued"
? "已确认的补充信息"
: "补充信息";
? "你补充的信息"
: "等你补充信息";
}
function resolveSubtitle(
@@ -40,18 +40,18 @@ function resolveSubtitle(
context: "chat" | "timeline",
): string {
if (request.status === "queued") {
return "答案已记录,等待系统请求就绪后会自动继续执行。";
return "已经记下了,系统就绪后会继续。";
}
if (request.status === "submitted") {
return context === "timeline"
? "该阶段的问答已完成,阶段记录已改为结构化回显。"
: "已收到你的补充信息,助手会继续执行后续流程。";
? "这一步已经确认,继续往下做。"
: "收到这一步了,继续往下做。";
}
return context === "timeline"
? "该阶段需要补充信息,请在输入区表单中完成确认后继续。"
: "请先完成这一步,我再继续当前对话。";
? "去输入区把这一步补完。"
: "先补这一步,我再继续当前对话。";
}
export function ActionRequestA2UIPreviewCard({
@@ -7,7 +7,7 @@ import type {
} from "@/lib/api/agentRuntime";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
import type { CompatSubagentRuntimeStatus } from "../utils/compatSubagentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
import {
getExecutionRuntimeDisplayLabel,
@@ -19,10 +19,7 @@ interface AgentRuntimeStripProps {
toolPreferences: ChatToolPreferences;
harnessState: HarnessSessionState;
childSubagentSessions?: AsterSubagentSessionInfo[];
compatSubagentRuntime: Pick<
CompatSubagentRuntimeSnapshot,
"isRunning" | "progress"
>;
compatSubagentRuntime: CompatSubagentRuntimeStatus;
variant?: "standalone" | "embedded";
isSending?: boolean;
executionRuntime?: AsterSessionExecutionRuntime | null;
@@ -13,12 +13,58 @@ import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
const parseAIResponseMock = vi.fn();
function resolveMockToolText(toolCall: {
name: string;
arguments?: string;
status?: string;
}) {
let parsedArguments: Record<string, unknown> | null = null;
if (toolCall.arguments) {
try {
parsedArguments = JSON.parse(toolCall.arguments) as Record<string, unknown>;
} catch {
parsedArguments = null;
}
}
if (
toolCall.name === "browser_navigate" &&
typeof parsedArguments?.url === "string"
) {
return `打开 ${parsedArguments.url}`;
}
if (
(toolCall.name === "web_search" || toolCall.name === "search_query") &&
typeof parsedArguments?.query === "string"
) {
return `搜索 ${parsedArguments.query}`;
}
if (
toolCall.name === "exec_command" &&
typeof parsedArguments?.command === "string"
) {
return `执行 ${parsedArguments.command}`;
}
if (
toolCall.name === "lime_site_run" &&
typeof parsedArguments?.adapter_name === "string"
) {
return `执行 ${parsedArguments.adapter_name}`;
}
return toolCall.name;
}
const mockToolCallItem = vi.fn(
({
toolCall,
onOpenSavedSiteContent,
}: {
toolCall: { name: string };
toolCall: { name: string; arguments?: string; status?: string };
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
@@ -29,7 +75,8 @@ const mockToolCallItem = vi.fn(
data-testid="tool-call-item"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
>
{toolCall.name}
{resolveMockToolText(toolCall)}
{toolCall.status === "running" ? " 进行中" : ""}
</div>
),
);
@@ -51,7 +98,7 @@ vi.mock("./A2UITaskCard", () => ({
vi.mock("./ToolCallDisplay", () => ({
ToolCallItem: (props: {
toolCall: { name: string };
toolCall: { name: string; arguments?: string; status?: string };
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
@@ -196,19 +243,6 @@ function renderTimeline(
return container;
}
function clickTimelineToggle(container: HTMLElement) {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="agent-thread-details-toggle"]',
);
if (!button) {
throw new Error("未找到执行细节切换按钮");
}
act(() => {
button.click();
});
}
function createFileArtifactItem(
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>> = {},
): Extract<AgentThreadItem, { type: "file_artifact" }> {
@@ -240,14 +274,60 @@ function createFileArtifactItem(
}
describe("AgentThreadTimeline", () => {
it("默认直接渲染内联时间线,不再显示旧摘要壳", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "已完成页面检查\n可以继续执行发布。",
},
{
...createBaseItem("browser-1", 2),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://mp.weixin.qq.com" },
},
{
...createBaseItem("approval-1", 3),
type: "approval_request",
request_id: "req-1",
action_type: "tool_confirmation",
prompt: "请确认是否发布文章",
tool_name: "browser_click",
},
];
const container = renderTimeline(items, { isCurrentTurn: true });
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-summary-shell"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-goal"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-focus"]'),
).toBeNull();
expect(container.textContent).toContain("已完成页面检查");
expect(container.textContent).toContain("打开 https://mp.weixin.qq.com");
expect(container.textContent).toContain("请确认是否发布文章");
});
it("file_artifact 命中多个 block 时应提供精确跳转按钮", () => {
const onOpenArtifactFromTimeline = vi.fn();
const container = renderTimeline([createFileArtifactItem()], {
onOpenArtifactFromTimeline,
});
clickTimelineToggle(container);
const heroJumpButton = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.includes("跳到 block hero-1"));
@@ -285,23 +365,21 @@ describe("AgentThreadTimeline", () => {
},
);
expect(
container.querySelector('[data-testid="agent-thread-details"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).toBeNull();
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:browser"]',
);
const focusedEntry = container.querySelector<HTMLElement>(
'[data-thread-item-id="browser-1"]',
);
expect(block).not.toBeNull();
expect(focusedEntry?.className).toContain("ring-2");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});
it("应向时间线内的工具项透传已保存站点内容打开回调", () => {
it("应向时间线内的工具明细透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
const container = renderTimeline(
renderTimeline(
[
{
...createBaseItem("site-tool-1", 1),
@@ -322,227 +400,12 @@ describe("AgentThreadTimeline", () => {
{ onOpenSavedSiteContent },
);
clickTimelineToggle(container);
expect(mockToolCallItem).toHaveBeenCalledWith(
expect.objectContaining({ onOpenSavedSiteContent }),
);
});
it("应在时间线头部展示当前 turn 的 compact outcome 与 incident 徽标", () => {
const container = renderTimeline(
[
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "最近一次 Provider 调用失败,等待人工处理。",
},
],
{
threadRead: {
thread_id: "thread-1",
status: "failed",
active_turn_id: "turn-1",
pending_requests: [],
last_outcome: {
thread_id: "thread-1",
turn_id: "turn-1",
outcome_type: "failed_provider",
summary: "Provider 请求失败",
primary_cause: "429 rate limited",
retryable: true,
ended_at: at(9),
},
incidents: [
{
id: "incident-1",
thread_id: "thread-1",
turn_id: "turn-1",
incident_type: "provider_failure",
severity: "high",
status: "active",
title: "Provider 连续失败",
},
],
},
},
);
expect(
container.querySelector('[data-testid="agent-thread-compact-outcome"]')
?.textContent,
).toContain("Provider 失败");
expect(
container.querySelector('[data-testid="agent-thread-compact-incident"]')
?.textContent,
).toContain("1 个 incident");
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-summary-outcome"]')
?.textContent,
).toContain("Provider 失败");
expect(
container.querySelector('[data-testid="agent-thread-summary-incident"]')
?.textContent,
).toContain("1 个 incident");
});
it("应渲染当前阶段概览与按时序组织的分组块", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("plan-1", 1),
type: "plan",
text: "1. 打开 CDP 页面\n2. 检查登录态",
},
{
...createBaseItem("summary-1", 2),
type: "turn_summary",
text: "已完成页面检查\n可以继续执行发布。",
},
{
...createBaseItem("browser-1", 3),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://mp.weixin.qq.com" },
},
{
...createBaseItem("browser-2", 4),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#publish" },
},
{
...createBaseItem("approval-1", 5),
type: "approval_request",
request_id: "req-1",
action_type: "tool_confirmation",
prompt: "请确认是否发布文章",
tool_name: "browser_click",
},
{
...createBaseItem("other-1", 6),
type: "tool_call",
tool_name: "workspace_sync",
},
];
const container = renderTimeline(items, { isCurrentTurn: true });
expect(
container.querySelector('[data-testid="agent-thread-overview"]')
?.textContent,
).toContain("已完成页面检查");
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
?.textContent,
).toContain("思考与计划");
const overviewNode = container.querySelector('[data-testid="agent-thread-overview"]');
const toggleNode = container.querySelector('[data-testid="agent-thread-details-toggle"]');
expect(
Boolean(
overviewNode &&
toggleNode &&
overviewNode.compareDocumentPosition(toggleNode) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).toBeNull();
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-summary"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-summary-collapse"]'),
).not.toBeNull();
expect(container.textContent).toContain("当前任务摘要");
expect(
container.querySelector('[data-testid="agent-thread-summary-header"]')
?.textContent,
).not.toContain("段流程");
expect(
container.querySelector('[data-testid="agent-thread-summary-header"]')
?.textContent,
).not.toContain("已完成");
expect(
container.querySelector('[data-testid="agent-thread-summary-shell"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-goal"]')?.textContent,
).toContain("请检查并发布文章");
expect(
container.querySelector('[data-testid="agent-thread-focus"]'),
).not.toBeNull();
expect(container.textContent).toContain("已完成页面检查");
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).not.toBeNull();
expect(container.textContent).toContain("思考与计划");
expect(container.textContent).toContain("浏览器操作");
expect(container.textContent).toContain("需要你处理");
expect(container.textContent).toContain("执行过程");
});
it("展开后应在摘要头提供收起入口,并恢复折叠态头部", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "已整理出下一步执行顺序。",
},
{
...createBaseItem("browser-1", 2),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#publish" },
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
clickTimelineToggle(container);
const collapseButton = container.querySelector<HTMLButtonElement>(
'[data-testid="agent-thread-summary-collapse"]',
);
expect(collapseButton).not.toBeNull();
act(() => {
collapseButton?.click();
});
expect(
container.querySelector('[data-testid="agent-thread-summary"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).not.toBeNull();
});
it("审批块应默认展开,处理记录块默认折叠", () => {
it("审批项与技术项都应直接落在消息流中", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("approval-1", 1),
@@ -560,13 +423,6 @@ describe("AgentThreadTimeline", () => {
];
const container = renderTimeline(items);
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).toBeNull();
clickTimelineToggle(container);
const approvalGroup = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:1:approval"]',
);
@@ -574,22 +430,10 @@ describe("AgentThreadTimeline", () => {
'[data-testid="agent-thread-block:2:other"]',
);
expect(approvalGroup?.hasAttribute("open")).toBe(true);
expect(otherGroup?.hasAttribute("open")).toBe(false);
expect(
container.querySelector('[data-testid="agent-thread-block:1:approval:rail"]'),
).not.toBeNull();
expect(
container.querySelector(
'[data-testid="agent-thread-block:1:approval:details"]',
),
).not.toBeNull();
expect(
container.querySelector(
'[data-testid="agent-thread-block:2:other:details"]',
),
).not.toBeNull();
expect(container.textContent).toContain("次要执行记录");
expect(approvalGroup).not.toBeNull();
expect(otherGroup).not.toBeNull();
expect(container.textContent).toContain("请确认是否继续");
expect(container.textContent).toContain("workspace_sync");
});
it("应按真实发生顺序渲染思考与工具块", () => {
@@ -614,14 +458,18 @@ describe("AgentThreadTimeline", () => {
];
const container = renderTimeline(items);
clickTimelineToggle(container);
const blockIds = Array.from(
container.querySelectorAll<HTMLElement>(
"details[data-testid^='agent-thread-block:']",
"[data-testid^='agent-thread-block:']",
),
)
.map((node) => node.dataset.testid)
.filter((value): value is string => Boolean(value));
.filter((value): value is string => Boolean(value))
.filter(
(value) =>
!value.endsWith(":shell") &&
!value.endsWith(":details"),
);
expect(blockIds).toEqual([
"agent-thread-block:1:browser",
@@ -630,49 +478,7 @@ describe("AgentThreadTimeline", () => {
]);
});
it("完成后折叠条仍应保留最近的思考过程", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://example.com" },
},
{
...createBaseItem("plan-1", 2),
type: "plan",
text: "先梳理问题背景,再给出三套方案。",
},
{
...createBaseItem("browser-2", 3),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#submit" },
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "completed",
},
});
expect(
container.querySelector('[data-testid="agent-thread-overview"]')
?.textContent,
).toContain("先梳理问题背景");
expect(
container.querySelector('[data-testid="agent-thread-details-stage"]')
?.textContent,
).toContain("步骤 02");
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
?.textContent,
).toContain("思考与计划");
});
it("运行中的块应被高亮,已完成块应降噪", () => {
it("运行中的块应高亮,已完成块应降噪", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
@@ -697,17 +503,6 @@ describe("AgentThreadTimeline", () => {
];
const container = renderTimeline(items, { isCurrentTurn: true });
expect(
container.querySelector('[data-testid="agent-thread-details-inline-icon"]')
?.getAttribute("data-state"),
).toBe("running");
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
?.textContent,
).toContain("Mac mini 最新价格");
clickTimelineToggle(container);
const browserBlock = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:1:browser"]',
);
@@ -721,44 +516,10 @@ describe("AgentThreadTimeline", () => {
expect(browserBlock?.dataset.emphasis).toBe("quiet");
expect(searchBlock?.dataset.emphasis).toBe("active");
expect(otherBlock?.dataset.emphasis).toBe("quiet");
expect(browserBlock?.hasAttribute("open")).toBe(true);
expect(searchBlock?.hasAttribute("open")).toBe(true);
expect(otherBlock?.hasAttribute("open")).toBe(false);
expect(container.textContent).toContain("执行中");
expect(container.textContent).toContain("Mac mini 最新价格");
});
it("流程展开后不应重复显示顶部当前进展卡片", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("search-1", 1),
status: "in_progress",
completed_at: undefined,
updated_at: at(1),
type: "web_search",
action: "web_search",
query: "team runtime 侧栏高度",
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-details"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).toBeNull();
expect(container.textContent).toContain("当前任务摘要");
});
it("浏览器前置等待时不应显示已中断,而应显示待继续", () => {
it("浏览器前置等待时应显示轻量待继续提示", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
@@ -785,20 +546,15 @@ describe("AgentThreadTimeline", () => {
],
});
expect(container.textContent).toContain("待继续");
expect(
container.querySelector('[data-testid="agent-thread-inline-status"]')
?.textContent,
).toContain("待继续");
expect(container.textContent).toContain("完成登录");
expect(container.textContent).not.toContain("已中断");
clickTimelineToggle(container);
expect(
container
.querySelector<HTMLElement>('[data-testid="agent-thread-block:1:browser"]')
?.hasAttribute("open"),
).toBe(true);
});
it("普通 aborted 回合应显示已暂停,而不是已中断", () => {
it("普通 aborted 回合应显示已暂停提示", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("other-1", 1),
@@ -813,39 +569,13 @@ describe("AgentThreadTimeline", () => {
},
});
expect(container.textContent).toContain("已暂停");
expect(
container.querySelector('[data-testid="agent-thread-inline-status"]')
?.textContent,
).toContain("已暂停");
expect(container.textContent).not.toContain("已中断");
});
it("单个已完成阶段不应再默认展开", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "已整理为 notebook 工作方式。",
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "completed",
},
});
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).toBeNull();
clickTimelineToggle(container);
expect(
container
.querySelector<HTMLElement>('[data-testid="agent-thread-block:1:thinking"]')
?.hasAttribute("open"),
).toBe(false);
});
it("思考摘要中的 A2UI 代码块应切换为结构化预览", () => {
parseAIResponseMock.mockReturnValue({
parts: [
@@ -886,12 +616,6 @@ describe("AgentThreadTimeline", () => {
},
});
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).toBeNull();
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
@@ -899,7 +623,7 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).not.toContain("```a2ui");
});
it("纯 reasoning 阶段展开后不应重复渲染思考摘要卡", () => {
it("纯 reasoning 阶段仅在时间线中出现一次", () => {
const reasoningText = "先核对执行链路,再立即恢复当前运行。";
const items: AgentThreadItem[] = [
{
@@ -916,8 +640,6 @@ describe("AgentThreadTimeline", () => {
},
});
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'),
).toBeNull();
@@ -925,6 +647,40 @@ describe("AgentThreadTimeline", () => {
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1);
});
it("已完成的思考应默认折叠,只保留摘要行", () => {
const reasoningText = "先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。";
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: reasoningText,
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:thinking"]',
);
const summary = block?.querySelector("summary");
expect(block?.open).toBe(false);
expect(summary?.textContent).toContain("已完成思考");
expect(summary?.textContent).toContain("先核对执行链路,再立即恢复当前运行。");
expect(container.textContent).not.toContain("随后补齐自动续提。");
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(block?.open).toBe(true);
expect(container.textContent).toContain("随后补齐自动续提。");
});
it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => {
const items: AgentThreadItem[] = [
{
@@ -949,8 +705,6 @@ describe("AgentThreadTimeline", () => {
},
});
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
@@ -977,8 +731,6 @@ describe("AgentThreadTimeline", () => {
onOpenSubagentSession,
});
clickTimelineToggle(container);
expect(container.textContent).toContain("图片任务 1");
expect(container.textContent).not.toContain("Image #1");
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ 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 { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { Artifact } from "@/lib/artifact/types";
import { emitCompactRightPanelOpen } from "@/lib/compactRightPanelEvents";
import type { TaskFile } from "./TaskFiles";
@@ -10,6 +11,7 @@ import {
type CanvasWorkbenchDefaultPreview,
type CanvasWorkbenchPreviewTarget,
} from "./CanvasWorkbenchLayout";
import type { ArtifactWorkbenchDocumentController } from "../workspace/artifactWorkbenchDocument";
type MockResizeObserverCallback = (
entries: Array<{
@@ -94,6 +96,170 @@ function createTaskFile(
};
}
function createMockArtifactDocumentController(
overrides: Partial<ArtifactWorkbenchDocumentController> = {},
): ArtifactWorkbenchDocumentController {
const versionHistory = [
{
id: "artifact-document:demo:v1",
artifactId: "artifact-document:demo",
versionNo: 1,
title: "董事会季度复盘",
summary: "第一版摘要",
status: "ready" as const,
createdAt: "2026-03-25T10:00:00Z",
},
{
id: "artifact-document:demo:v2",
artifactId: "artifact-document:demo",
versionNo: 2,
title: "董事会季度复盘",
summary: "补齐来源与版本信息",
status: "ready" as const,
createdAt: "2026-03-26T10:00:00Z",
},
];
const currentVersionDiff = {
baseVersionId: "artifact-document:demo:v1",
baseVersionNo: 1,
targetVersionId: "artifact-document:demo:v2",
targetVersionNo: 2,
updatedCount: 1,
addedCount: 0,
removedCount: 0,
movedCount: 0,
changedBlocks: [
{
blockId: "body-1",
changeType: "updated" as const,
beforeText: "旧正文",
afterText: "正文内容",
summary: "更新 block 内容",
},
],
};
const editableDraft = {
editorKind: "rich_text" as const,
markdown: "正文内容",
};
const selectedEditableBlock = {
blockId: "body-1",
label: "正文块 1",
detail: "正文",
editorKind: "rich_text" as const,
draft: editableDraft,
};
const document: ArtifactDocumentV1 = {
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis" as const,
title: "董事会季度复盘",
status: "ready" as const,
language: "zh-CN",
summary: "需要优先补齐来源与版本线索。",
blocks: [
{
id: "body-1",
type: "rich_text" as const,
markdown: "正文内容",
},
],
sources: [
{
id: "source-1",
title: "OpenAI Blog",
url: "https://openai.com",
},
],
metadata: {
currentVersionId: "artifact-document:demo:v2",
currentVersionNo: 2,
currentVersionDiff,
versionHistory,
},
};
return {
artifact: createArtifact(
"artifact-doc",
".lime/artifacts/thread-1/board-review.artifact.json",
JSON.stringify(document),
40,
),
document,
currentVersion: versionHistory[1],
currentVersionDiff,
versionHistory,
sourceLinks: [
{
artifactId: "artifact-document:demo",
blockId: "body-1",
sourceId: "source-1",
sourceType: "web",
sourceRef: "https://openai.com",
label: "OpenAI Blog",
},
],
timelineLinksByBlockId: {},
recoveryPresentation: null,
canEditDocument: true,
canMarkAsReady: false,
inspectorTab: "overview",
setInspectorTab: vi.fn(),
editableBlocks: [selectedEditableBlock],
draftByBlockId: {
"body-1": editableDraft,
},
selectedEditableBlock,
selectedEditableDraft: editableDraft,
selectedTimelineLink: null,
isSavingEdit: false,
isUpdatingRecoveryState: false,
editSaveError: null,
recoveryActionError: null,
lastSavedAt: null,
rendererViewportRef: { current: null },
focusBlock: vi.fn(),
selectEditableBlock: vi.fn(),
handleEditDraftChange: vi.fn(),
handleEditCancel: vi.fn(),
handleEditSave: vi.fn(async () => undefined),
handleContinueEditing: vi.fn(),
handleMarkAsReady: vi.fn(async () => undefined),
onJumpToTimelineItem: vi.fn(),
...overrides,
};
}
function MockArtifactDocumentPreview({
controller,
target,
onArtifactDocumentControllerChange,
artifactDocumentLayoutMode,
}: {
controller: ArtifactWorkbenchDocumentController | null;
target: CanvasWorkbenchPreviewTarget;
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
artifactDocumentLayoutMode?: "full" | "canvas-only";
}) {
React.useEffect(() => {
onArtifactDocumentControllerChange?.(
target.kind === "artifact" ? controller : null,
);
return () => {
onArtifactDocumentControllerChange?.(null);
};
}, [controller, onArtifactDocumentControllerChange, target.kind]);
return (
<div data-testid="preview-panel">
{artifactDocumentLayoutMode}:{target.kind}:{target.title}
</div>
);
}
function mount(
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
): HTMLDivElement {
@@ -420,6 +586,65 @@ describe("CanvasWorkbenchLayout", () => {
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
});
it("命中文档产物时应把文稿 inspector 收口到右侧工作台", async () => {
const controller = createMockArtifactDocumentController();
const previewOptions: Array<{
artifactDocumentLayoutMode?: "full" | "canvas-only";
onArtifactDocumentControllerChange?: (
value: ArtifactWorkbenchDocumentController | null,
) => void;
}> = [];
const container = mount({
artifacts: [controller.artifact],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: null,
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: null,
isBinary: true,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => {
previewOptions.push({
artifactDocumentLayoutMode: options?.artifactDocumentLayoutMode,
onArtifactDocumentControllerChange:
options?.onArtifactDocumentControllerChange,
});
return (
<MockArtifactDocumentPreview
controller={controller}
target={target}
artifactDocumentLayoutMode={options?.artifactDocumentLayoutMode}
onArtifactDocumentControllerChange={
options?.onArtifactDocumentControllerChange
}
/>
);
},
});
await flushEffects();
expect(
container.querySelector('[data-testid="preview-panel"]')?.textContent,
).toContain("canvas-only:artifact:board-review.artifact.json");
expect(previewOptions.at(-1)?.artifactDocumentLayoutMode).toBe("canvas-only");
expect(
container.querySelector('[data-testid="canvas-workbench-document-inspector"]'),
).not.toBeNull();
expect(container.textContent).toContain("当前文稿");
expect(container.textContent).toContain("统一在右侧切换产物与版本");
expect(container.textContent).toContain("董事会季度复盘");
expect(container.textContent).toContain("需要优先补齐来源与版本线索。");
});
it("工作区文件为二进制时应展示不支持预览提示", async () => {
const previewTargets: CanvasWorkbenchPreviewTarget[] = [];
@@ -57,6 +57,10 @@ import {
extractFileNameFromPath,
resolveAbsoluteWorkspacePath,
} from "../workspace/workspacePath";
import {
ArtifactWorkbenchDocumentInspector,
type ArtifactWorkbenchDocumentController,
} from "../workspace/artifactWorkbenchDocument";
type CanvasWorkbenchTab =
| "artifacts"
@@ -192,6 +196,10 @@ export interface CanvasWorkbenchLayoutProps {
target: CanvasWorkbenchPreviewTarget,
options?: {
stackedWorkbenchTrigger?: ReactNode;
artifactDocumentLayoutMode?: "full" | "canvas-only";
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
},
) => ReactNode;
onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void;
@@ -494,6 +502,8 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
null,
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [artifactDocumentController, setArtifactDocumentController] =
useState<ArtifactWorkbenchDocumentController | null>(null);
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
{},
);
@@ -791,6 +801,21 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
const selectedWorkspaceFile = effectiveKey?.startsWith("workspace-file:")
? workspaceFileSelections[effectiveKey] || null
: null;
const handleArtifactDocumentControllerChange = useCallback(
(controller: ArtifactWorkbenchDocumentController | null) => {
setArtifactDocumentController((previous) =>
previous === controller ? previous : controller,
);
},
[],
);
useEffect(() => {
if (selectedEntry?.source === "artifact") {
return;
}
setArtifactDocumentController(null);
}, [selectedEntry]);
const currentTarget = useMemo<CanvasWorkbenchPreviewTarget>(() => {
if (activeTab === "team" && teamView?.enabled) {
@@ -1026,55 +1051,99 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
}
const showDocumentInspector = Boolean(
selectedEntry?.source === "artifact" && artifactDocumentController?.document,
);
return (
<div className="space-y-2">
{entries.map((entry) => (
<button
key={entry.key}
type="button"
aria-label={`选择画布产物-${entry.title}`}
onClick={() => setSelectedKey(entry.key)}
className={cn(
"w-full rounded-[22px] border px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors",
effectiveKey === entry.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-200/80 bg-slate-50/90 px-2 py-0.5 text-[10px] font-medium text-slate-500">
{entry.kindLabel}
</span>
{entry.isCurrent ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
当前
</span>
<div className="space-y-4">
<section className={cn(WORKBENCH_PANEL_CLASSNAME, "p-3")}>
<div className="border-b border-slate-200/80 px-1 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
工作项导航
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
统一在右侧切换产物与版本
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
左侧只保留正文画布,这里作为唯一的产物入口与文稿上下文区。
</p>
</div>
<div className="mt-3 space-y-2">
{entries.map((entry) => (
<button
key={entry.key}
type="button"
aria-label={`选择画布产物-${entry.title}`}
onClick={() => setSelectedKey(entry.key)}
className={cn(
"w-full rounded-[22px] border px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors",
effectiveKey === entry.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-200/80 bg-slate-50/90 px-2 py-0.5 text-[10px] font-medium text-slate-500">
{entry.kindLabel}
</span>
{entry.isCurrent ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
当前
</span>
) : null}
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">
{entry.title}
</div>
{entry.subtitle ? (
<div className="mt-1 truncate text-xs text-slate-500">
{entry.subtitle}
</div>
) : null}
{entry.previewText ? (
<div className="mt-2 line-clamp-3 text-xs leading-5 text-slate-500">
{entry.previewText}
</div>
) : null}
</div>
{entry.badgeLabel ? (
<Badge variant="outline" className="shrink-0">
{entry.badgeLabel}
</Badge>
) : null}
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">
{entry.title}
</button>
))}
</div>
</section>
{showDocumentInspector && artifactDocumentController ? (
<ArtifactWorkbenchDocumentInspector
controller={artifactDocumentController}
testId="canvas-workbench-document-inspector"
containerClassName={cn(
WORKBENCH_PANEL_CLASSNAME,
"min-h-0 overflow-hidden bg-slate-50/80",
)}
tabsClassName="flex h-full min-h-0 flex-col p-4"
header={
<div className="mb-4 rounded-[20px] border border-slate-200 bg-white px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
当前文稿
</div>
{entry.subtitle ? (
<div className="mt-1 truncate text-xs text-slate-500">
{entry.subtitle}
</div>
) : null}
{entry.previewText ? (
<div className="mt-2 line-clamp-3 text-xs leading-5 text-slate-500">
{entry.previewText}
</div>
) : null}
<div className="mt-1 text-sm font-semibold text-slate-900">
概览、来源、版本与编辑统一收口
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
当前选中的结构化文稿不再在左侧重复展开,所有上下文与编辑入口都固定在这里。
</p>
</div>
{entry.badgeLabel ? (
<Badge variant="outline" className="shrink-0">
{entry.badgeLabel}
</Badge>
) : null}
</div>
</button>
))}
}
/>
) : null}
</div>
);
};
@@ -1625,6 +1694,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
)
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
artifactDocumentLayoutMode: "canvas-only",
onArtifactDocumentControllerChange:
handleArtifactDocumentControllerChange,
})}
</div>
@@ -191,7 +191,7 @@ describe("DecisionPanel elicitation", () => {
});
describe("DecisionPanel ask_user", () => {
it("缺少 options 时应从问题文本提取可点击选项并支持提交", () => {
it("缺少 options 时应从问题文本提取可点击选项,并在点击提交按钮后发送", () => {
const request = createAskUserRequest("req-ask-user-fallback");
const { container, onSubmit } = renderDecisionPanel(request);
@@ -202,18 +202,19 @@ describe("DecisionPanel ask_user", () => {
expect(container.textContent).toContain("只读模式");
clickButton(findButtonByText(container, "自动执行(Auto)"));
clickButton(findButtonByText(container, "提交答案"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-ask-user-fallback",
confirmed: true,
response: "自动执行(Auto)",
response: JSON.stringify({ answer: "自动执行(Auto)" }),
actionType: "ask_user",
userData: { answer: "自动执行(Auto)" },
});
});
it("编号列表文本应提取为可点击选项", () => {
it("编号列表文本应提取为可点击选项,并显式提交", () => {
const request = createAskUserNumberedRequest("req-ask-user-numbered");
const { container, onSubmit } = renderDecisionPanel(request);
@@ -222,18 +223,19 @@ describe("DecisionPanel ask_user", () => {
expect(container.textContent).toContain("品牌展示海报");
clickButton(findButtonByText(container, "活动推广海报"));
clickButton(findButtonByText(container, "提交答案"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-ask-user-numbered",
confirmed: true,
response: "活动推广海报",
response: JSON.stringify({ answer: "活动推广海报" }),
actionType: "ask_user",
userData: { answer: "活动推广海报" },
});
});
it("questions.options 为字符串数组时应归一化并可点击提交", () => {
it("questions.options 为字符串数组时应归一化,并显式提交", () => {
const request: ActionRequired = {
requestId: "req-ask-user-string-options",
actionType: "ask_user",
@@ -247,12 +249,13 @@ describe("DecisionPanel ask_user", () => {
const { container, onSubmit } = renderDecisionPanel(request);
clickButton(findButtonByText(container, "确认后执行(Ask)"));
clickButton(findButtonByText(container, "提交答案"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-ask-user-string-options",
confirmed: true,
response: "确认后执行(Ask)",
response: JSON.stringify({ answer: "确认后执行(Ask)" }),
actionType: "ask_user",
userData: { answer: "确认后执行(Ask)" },
});
@@ -278,11 +281,13 @@ describe("DecisionPanel ask_user", () => {
const optionButton = findButtonByText(container, "自动执行(Auto)");
expect(optionButton.disabled).toBe(false);
clickButton(optionButton);
expect(waitingSubmitButton.disabled).toBe(false);
clickButton(waitingSubmitButton);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
requestId: "fallback:tool-1",
confirmed: true,
response: "自动执行(Auto)",
response: JSON.stringify({ answer: "自动执行(Auto)" }),
actionType: "ask_user",
userData: { answer: "自动执行(Auto)" },
});
@@ -300,7 +305,7 @@ describe("DecisionPanel ask_user", () => {
expect(container.textContent).not.toContain("取消");
});
it("自动提交选项等待回调完成时,应展示提交中并禁用交互", async () => {
it("显式提交答案等待回调完成时,应展示提交中并禁用交互", async () => {
const request = createAskUserRequest("req-ask-user-loading");
let resolveSubmit: (() => void) | null = null;
const { container, onSubmit } = renderDecisionPanel(request);
@@ -318,8 +323,16 @@ describe("DecisionPanel ask_user", () => {
await Promise.resolve();
});
const submitButton = findButtonByText(container, "提交答案");
await act(async () => {
submitButton.click();
await Promise.resolve();
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(optionButton.disabled).toBe(true);
expect(submitButton.disabled).toBe(true);
expect(container.querySelector("svg.animate-spin")).not.toBeNull();
await act(async () => {
@@ -478,40 +478,24 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
}
const answers = buildAnswers();
const response = questions.length > 0 ? JSON.stringify(answers) : undefined;
const normalizedAnswers =
questions.length === 1 && typeof Object.values(answers)[0] === "string"
? { answer: Object.values(answers)[0] as string }
: answers;
const response =
questions.length > 0 ? JSON.stringify(normalizedAnswers) : undefined;
void submitResponse(
{
requestId: request.requestId,
confirmed: true,
response,
actionType: request.actionType,
userData: questions.length > 0 ? answers : undefined,
userData: questions.length > 0 ? normalizedAnswers : undefined,
},
{ key: "allow", kind: "allow" },
);
};
const handleAutoSubmitOption = (
optionLabel: string,
qIndex: number,
actionType: ActionRequired["actionType"],
) => {
setSelectedOptions((prev) => ({
...prev,
[qIndex]: [optionLabel],
}));
void submitResponse(
{
requestId: request.requestId,
confirmed: true,
response: optionLabel,
actionType,
userData: { answer: optionLabel },
},
{ key: `option:${qIndex}:${optionLabel}`, kind: "allow" },
);
};
const handleDeny = () => {
void submitResponse(
{
@@ -898,8 +882,6 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
const isSelected = (selectedOptions[qIndex] ?? []).includes(
option.label,
);
const shouldAutoSubmit =
questions.length === 1 && !q.multiSelect;
return (
<button
@@ -913,23 +895,11 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
isSubmitting && "cursor-not-allowed opacity-70",
)}
disabled={isSubmitting}
onClick={() => {
if (shouldAutoSubmit) {
handleAutoSubmitOption(
option.label,
qIndex,
request.actionType,
);
return;
}
toggleOption(qIndex, option.label, q.multiSelect);
}}
onClick={() =>
toggleOption(qIndex, option.label, q.multiSelect)
}
>
<div className="flex items-center gap-2 font-medium">
{submissionState?.key ===
`option:${qIndex}:${option.label}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
<span>{option.label}</span>
</div>
{option.description && (
@@ -99,7 +99,6 @@ function renderPanel(
compatSubagentRuntime={{
isRunning: false,
progress: null,
events: [],
result: null,
error: null,
recentActivity: [],
@@ -1361,7 +1360,6 @@ describe("HarnessStatusPanel", () => {
currentTasks: ["legacy-task-1"],
percentage: 50,
},
events: [{ type: "started", totalTasks: 2 }],
result: null,
error: null,
recentActivity: [
@@ -103,7 +103,7 @@ import {
} from "../utils/toolDisplayInfo";
import { buildThreadReliabilityView } from "../utils/threadReliabilityView";
import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy";
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
import type { CompatSubagentRuntimeDisplaySnapshot } from "../utils/compatSubagentRuntime";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
@@ -128,7 +128,7 @@ export interface HarnessFilePreviewResult {
interface HarnessStatusPanelProps {
harnessState: HarnessSessionState;
compatSubagentRuntime: CompatSubagentRuntimeSnapshot;
compatSubagentRuntime: CompatSubagentRuntimeDisplaySnapshot;
environment: HarnessEnvironmentSummary;
layout?: "default" | "sidebar" | "dialog";
onLoadFilePreview?: (path: string) => Promise<HarnessFilePreviewResult>;
@@ -1440,7 +1440,7 @@ function CompatSubagentFallbackCard({
condensed = false,
onOpenUrl,
}: {
snapshot: CompatSubagentRuntimeSnapshot;
snapshot: CompatSubagentRuntimeDisplaySnapshot;
condensed?: boolean;
onOpenUrl: (url: string) => void | Promise<void>;
}) {
@@ -36,6 +36,7 @@ const mockAgentThreadTimeline = vi.fn(
({
actionRequests,
onOpenSavedSiteContent,
placement,
}: {
actionRequests?: Array<Record<string, unknown>>;
onOpenSavedSiteContent?: (target: {
@@ -43,9 +44,10 @@ const mockAgentThreadTimeline = vi.fn(
contentId: string;
title?: string;
}) => void;
placement?: "leading" | "trailing" | "default";
}) => (
<div
data-testid="agent-thread-timeline"
data-testid={`agent-thread-timeline:${placement || "default"}`}
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
>
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
@@ -67,6 +69,7 @@ vi.mock("./TokenUsageDisplay", () => ({
vi.mock("./AgentThreadTimeline", () => ({
AgentThreadTimeline: (props: {
actionRequests?: Array<Record<string, unknown>>;
placement?: "leading" | "trailing" | "default";
}) => mockAgentThreadTimeline(props),
}));
@@ -344,7 +347,7 @@ describe("MessageList", () => {
);
});
it("应先渲染正文与产物,再渲染执行轨迹", () => {
it("应先渲染思考与过程,再渲染正文,最后再落产物", () => {
const now = new Date();
const messages: Message[] = [
{
@@ -401,23 +404,25 @@ describe("MessageList", () => {
});
const streaming = container.querySelector('[data-testid="streaming-renderer"]');
const timeline = container.querySelector('[data-testid="agent-thread-timeline"]');
const leadingTimeline = container.querySelector(
'[data-testid="agent-thread-timeline:leading"]',
);
const artifactButton = Array.from(container.querySelectorAll("button")).find((node) =>
node.textContent?.includes("publish.md"),
);
expect(streaming).not.toBeNull();
expect(artifactButton).toBeDefined();
expect(timeline).not.toBeNull();
expect(leadingTimeline).not.toBeNull();
const streamingNode = streaming as Node;
const timelineNode = timeline as Node;
const timelineNode = leadingTimeline as Node;
const artifactButtonNode = artifactButton as Node;
expect(
streamingNode.compareDocumentPosition(timelineNode) &
timelineNode.compareDocumentPosition(streamingNode) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
artifactButtonNode.compareDocumentPosition(timelineNode) &
streamingNode.compareDocumentPosition(artifactButtonNode) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
@@ -475,9 +480,11 @@ describe("MessageList", () => {
const timelineProps = mockAgentThreadTimeline.mock.calls.at(-1)?.[0] as
| {
actionRequests?: Array<Record<string, unknown>>;
placement?: string;
}
| undefined;
expect(timelineProps?.placement).toBe("leading");
expect(timelineProps?.actionRequests).toEqual([
expect.objectContaining({
requestId: "req-browser",
@@ -585,13 +592,17 @@ describe("MessageList", () => {
container.querySelectorAll('[data-testid="streaming-renderer"]'),
);
const timelineNodes = Array.from(
container.querySelectorAll('[data-testid="agent-thread-timeline"]'),
container.querySelectorAll('[data-testid="agent-thread-timeline:leading"]'),
);
expect(streamingNodes).toHaveLength(2);
expect(timelineNodes).toHaveLength(1);
expect(
(streamingNodes[1] as Node).compareDocumentPosition(timelineNodes[0] as Node) &
(streamingNodes[0] as Node).compareDocumentPosition(timelineNodes[0] as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
(timelineNodes[0] as Node).compareDocumentPosition(streamingNodes[1] as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
@@ -653,7 +664,9 @@ describe("MessageList", () => {
});
const timelineNodes = Array.from(
container.querySelectorAll('[data-testid="agent-thread-timeline"]'),
container.querySelectorAll(
'[data-testid^="agent-thread-timeline:"]',
),
);
expect(
@@ -124,6 +124,10 @@ interface MessageListProps {
timelineFocusRequestKey?: number;
}
function isDeferredTimelineItem(item: AgentThreadItem): boolean {
return item.type === "file_artifact";
}
const MessageListInner: React.FC<MessageListProps> = ({
messages,
turns = [],
@@ -321,6 +325,24 @@ const MessageListInner: React.FC<MessageListProps> = ({
: mappedTimeline?.turn.id === currentTurnTimeline?.turn.id
? null
: mappedTimeline || null;
const primaryTimelineItems = timeline
? timeline.items.filter((item) => !isDeferredTimelineItem(item))
: [];
const trailingTimelineItems = timeline
? timeline.items.filter(isDeferredTimelineItem)
: [];
const primaryTimeline =
timeline && primaryTimelineItems.length > 0
? { ...timeline, items: primaryTimelineItems }
: null;
const trailingTimeline =
timeline && trailingTimelineItems.length > 0
? { ...timeline, items: trailingTimelineItems }
: null;
const primaryActionRequests =
primaryTimelineItems.length > 0 ? msg.actionRequests : undefined;
const trailingActionRequests =
primaryTimelineItems.length === 0 ? msg.actionRequests : undefined;
const showIdentity = options?.showIdentity ?? true;
return (
@@ -390,43 +412,63 @@ const MessageListInner: React.FC<MessageListProps> = ({
</div>
</div>
) : msg.role === "assistant" ? (
<StreamingRenderer
content={displayContent}
isStreaming={msg.isThinking}
toolCalls={msg.toolCalls}
showCursor={msg.isThinking && !displayContent}
thinkingContent={msg.thinkingContent}
runtimeStatus={msg.runtimeStatus}
contentParts={displayContentParts}
actionRequests={msg.actionRequests}
onA2UISubmit={
onA2UISubmit
? (formData) => onA2UISubmit(formData, msg.id)
: undefined
}
a2uiFormId={a2uiFormDataMap?.[msg.id]?.formId}
a2uiInitialFormData={a2uiFormDataMap?.[msg.id]?.formData}
onA2UIFormChange={onA2UIFormChange}
renderA2UIInline={renderA2UIInline}
onWriteFile={
onWriteFile
? (content, fileName, context) =>
onWriteFile(content, fileName, {
...context,
sourceMessageId: context?.sourceMessageId || msg.id,
source: context?.source || "message_content",
})
: undefined
}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onPermissionResponse={onPermissionResponse}
collapseCodeBlocks={collapseCodeBlocks}
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
onCodeBlockClick={onCodeBlockClick}
promoteActionRequestsToA2UI={promoteActionRequestsToA2UI}
renderProposedPlanBlocks={!timeline}
/>
<>
{primaryTimeline ? (
<AgentThreadTimeline
turn={primaryTimeline.turn}
items={primaryTimeline.items}
threadRead={threadRead}
actionRequests={primaryActionRequests}
isCurrentTurn={primaryTimeline.turn.id === currentTurnId}
placement="leading"
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
focusedItemId={focusedTimelineItemId}
focusRequestKey={timelineFocusRequestKey}
/>
) : null}
<StreamingRenderer
content={displayContent}
isStreaming={msg.isThinking}
toolCalls={msg.toolCalls}
showCursor={msg.isThinking && !displayContent}
thinkingContent={msg.thinkingContent}
runtimeStatus={msg.runtimeStatus}
contentParts={displayContentParts}
actionRequests={msg.actionRequests}
onA2UISubmit={
onA2UISubmit
? (formData) => onA2UISubmit(formData, msg.id)
: undefined
}
a2uiFormId={a2uiFormDataMap?.[msg.id]?.formId}
a2uiInitialFormData={a2uiFormDataMap?.[msg.id]?.formData}
onA2UIFormChange={onA2UIFormChange}
renderA2UIInline={renderA2UIInline}
onWriteFile={
onWriteFile
? (content, fileName, context) =>
onWriteFile(content, fileName, {
...context,
sourceMessageId: context?.sourceMessageId || msg.id,
source: context?.source || "message_content",
})
: undefined
}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onPermissionResponse={onPermissionResponse}
collapseCodeBlocks={collapseCodeBlocks}
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
onCodeBlockClick={onCodeBlockClick}
promoteActionRequestsToA2UI={promoteActionRequestsToA2UI}
renderProposedPlanBlocks={!timeline}
/>
</>
) : (
displayContent ? (
<MarkdownRenderer
@@ -456,13 +498,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
{msg.role === "assistant" && renderArtifactCards(msg.artifacts)}
{msg.role === "assistant" && timeline ? (
{msg.role === "assistant" && trailingTimeline ? (
<AgentThreadTimeline
turn={timeline.turn}
items={timeline.items}
turn={trailingTimeline.turn}
items={trailingTimeline.items}
threadRead={threadRead}
actionRequests={msg.actionRequests}
isCurrentTurn={timeline.turn.id === currentTurnId}
actionRequests={trailingActionRequests}
isCurrentTurn={trailingTimeline.turn.id === currentTurnId}
placement="trailing"
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
@@ -1,235 +0,0 @@
/**
* 项目选择器组件
*
* 在 EmptyState 中显示项目列表,支持搜索和快速创建
*/
import { useState, useEffect, useMemo } from "react";
import { Search, Plus, FileText, Clock } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
Project,
ProjectType,
createProject,
getCreateProjectErrorMessage,
extractErrorMessage,
listProjects,
resolveProjectRootPath,
TYPE_CONFIGS,
} from "@/lib/api/project";
import { toast } from "sonner";
import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog";
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
interface ProjectSelectorProps {
/** 当前激活的主题(用于过滤项目) */
activeTheme?: string;
/** 选择项目回调 */
onSelectProject: (projectId: string) => void;
/** 创建项目回调 */
onCreateProject?: () => void;
}
export function ProjectSelector({
activeTheme = "general",
onSelectProject,
onCreateProject: _onCreateProject,
}: ProjectSelectorProps) {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// 加载项目列表
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
setLoading(true);
try {
const allProjects = await listProjects();
setProjects(allProjects);
} catch (error) {
console.error("加载项目失败:", error);
toast.error("加载项目失败");
} finally {
setLoading(false);
}
};
// 过滤项目(按主题和搜索关键词)
const filteredProjects = useMemo(() => {
let result = projects;
// 按主题过滤
if (activeTheme !== "general") {
result = result.filter((p) => p.workspaceType === activeTheme);
}
// 搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query)),
);
}
// 排除归档项目
result = result.filter((p) => !p.isArchived);
// 按更新时间排序
result.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
return result;
}, [projects, activeTheme, searchQuery]);
const defaultProjectType = useMemo(() => {
const themeType = activeTheme as ProjectType;
if (Object.prototype.hasOwnProperty.call(TYPE_CONFIGS, themeType)) {
return themeType;
}
return "general" as ProjectType;
}, [activeTheme]);
const handleCreateProject = async (name: string, type: ProjectType) => {
try {
const projectPath = await resolveProjectRootPath(name);
const newProject = await createProject({
name,
rootPath: projectPath,
workspaceType: type,
});
notifyProjectCreatedWithRuntimeAgentsGuide(newProject, "项目创建成功");
await loadProjects();
onSelectProject(newProject.id);
} catch (error) {
console.error("创建项目失败:", error);
const errorMessage = extractErrorMessage(error);
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
toast.error(`创建项目失败: ${friendlyMessage}`);
throw error;
}
};
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "今天";
if (days === 1) return "昨天";
if (days < 7) return `${days} 天前`;
if (days < 30) return `${Math.floor(days / 7)} 周前`;
return `${Math.floor(days / 30)} 月前`;
};
return (
<div className="w-full max-w-2xl mx-auto">
{/* 搜索和快速创建 */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
新建项目
</Button>
</div>
{/* 项目列表 */}
<ScrollArea className="h-[400px] rounded-lg border bg-card">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="text-sm text-muted-foreground">加载中...</div>
</div>
) : filteredProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
<FileText className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm mb-4">
{searchQuery ? "没有找到匹配的项目" : "还没有项目"}
</p>
{!searchQuery && (
<Button onClick={() => setCreateDialogOpen(true)}>
创建第一个项目
</Button>
)}
</div>
) : (
<div className="p-2 space-y-2">
{filteredProjects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project.id)}
className={cn(
"w-full p-4 rounded-lg border bg-background",
"hover:bg-accent hover:border-primary/50",
"transition-all duration-200",
"text-left",
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">
{TYPE_CONFIGS[project.workspaceType].icon}
</span>
<h3 className="font-medium truncate">{project.name}</h3>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span>{project.stats?.content_count || 0} 个内容</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatTime(project.updatedAt)}</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge variant="secondary" className="text-xs">
{TYPE_CONFIGS[project.workspaceType].label}
</Badge>
{project.stats?.total_words && (
<span className="text-xs text-muted-foreground">
{project.stats.total_words.toLocaleString()} 字
</span>
)}
</div>
</div>
</button>
))}
</div>
)}
</ScrollArea>
<CreateProjectDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSubmit={handleCreateProject}
defaultType={defaultProjectType}
defaultName={`${TYPE_CONFIGS[defaultProjectType].label}项目`}
/>
</div>
);
}
@@ -1,41 +0,0 @@
# TaskFiles 任务文件组件
显示任务过程中生成的文件列表,支持点击查看文件内容。
## 功能
- 可折叠的文件列表,显示在输入框上方
- 支持文件夹和文档类型
- 点击文件后在右侧画布显示内容
- 文件类型过滤器
## 文件索引
| 文件 | 说明 |
|------|------|
| `index.ts` | 组件导出入口 |
| `types.ts` | 类型定义 |
| `TaskFileList.tsx` | 文件列表主组件 |
| `TaskFileItem.tsx` | 单个文件项组件 |
## 使用示例
```tsx
import { TaskFileList, type TaskFile } from './components/TaskFiles';
const [files, setFiles] = useState<TaskFile[]>([]);
const [expanded, setExpanded] = useState(false);
<TaskFileList
files={files}
selectedFileId={selectedId}
onFileClick={(file) => console.log('点击文件:', file)}
expanded={expanded}
onExpandedChange={setExpanded}
/>
```
## 依赖
- `@/lib/utils` - cn 工具函数
- `lucide-react` - 图标
@@ -1,155 +0,0 @@
/**
* @file 任务文件项组件
* @description 单个文件/文件夹的展示组件
* @module components/agent/chat/components/TaskFiles/TaskFileItem
*/
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import {
FileText,
Folder,
FolderOpen,
Image,
Code,
Database,
ChevronRight,
MoreHorizontal,
} from "lucide-react";
import type { TaskFile, TaskFileType } from "./types";
/** 文件类型图标映射 */
const FILE_ICONS: Record<TaskFileType, React.ElementType> = {
document: FileText,
folder: Folder,
image: Image,
code: Code,
data: Database,
};
/** 文件类型颜色映射 */
const FILE_COLORS: Record<TaskFileType, string> = {
document: "text-blue-500",
folder: "text-amber-500",
image: "text-green-500",
code: "text-purple-500",
data: "text-orange-500",
};
/** 文件类型背景色映射 */
const FILE_BG_COLORS: Record<TaskFileType, string> = {
document: "bg-blue-500/10",
folder: "bg-amber-500/10",
image: "bg-green-500/10",
code: "bg-purple-500/10",
data: "bg-orange-500/10",
};
interface TaskFileItemProps {
file: TaskFile;
isSelected?: boolean;
onClick: (file: TaskFile) => void;
level?: number;
}
export const TaskFileItem: React.FC<TaskFileItemProps> = ({
file,
isSelected = false,
onClick,
level = 0,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const isFolder = file.type === "folder";
const Icon = isFolder
? isExpanded
? FolderOpen
: Folder
: FILE_ICONS[file.type];
const iconColor = FILE_COLORS[file.type];
const iconBgColor = FILE_BG_COLORS[file.type];
const handleClick = () => {
if (isFolder) {
setIsExpanded(!isExpanded);
} else {
onClick(file);
}
};
const formatTime = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `大约 ${hours} 小时前`;
const days = Math.floor(hours / 24);
return `大约 ${days} 天前`;
};
return (
<div>
<div
className={cn(
"group flex items-center gap-3 px-3 py-2.5 cursor-pointer rounded-lg transition-colors",
"hover:bg-muted/50",
isSelected && "bg-primary/10",
)}
style={{ paddingLeft: `${12 + level * 16}px` }}
onClick={handleClick}
>
{/* 展开箭头(仅文件夹) */}
{isFolder && (
<ChevronRight
className={cn(
"w-4 h-4 text-muted-foreground transition-transform flex-shrink-0",
isExpanded && "rotate-90",
)}
/>
)}
{/* 文件图标 - 带背景色 */}
<div className={cn("p-2 rounded-lg flex-shrink-0", iconBgColor)}>
<Icon className={cn("w-5 h-5", iconColor)} />
</div>
{/* 文件信息 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{file.version && `Version ${file.version} · `}
{formatTime(file.updatedAt)}
</div>
</div>
{/* 更多操作 */}
<button
className="p-1.5 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
// TODO: 显示更多操作菜单
}}
>
<MoreHorizontal className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* 子文件(文件夹展开时) */}
{isFolder && isExpanded && file.children && (
<div>
{file.children.map((child) => (
<TaskFileItem
key={child.id}
file={child}
isSelected={isSelected}
onClick={onClick}
level={level + 1}
/>
))}
</div>
)}
</div>
);
};
@@ -1,172 +0,0 @@
/**
* @file 任务文件列表组件
* @description 显示任务过程中生成的所有文件,底部弹出面板形式
* @module components/agent/chat/components/TaskFiles/TaskFileList
*/
import React, { useState, useCallback } from "react";
import { cn } from "@/lib/utils";
import {
FolderOpen,
ChevronUp,
ChevronDown,
FileText,
Image,
Code,
LayoutGrid,
X,
} from "lucide-react";
import { TaskFileItem } from "./TaskFileItem";
import type { TaskFilesProps } from "./types";
/** 文件类型过滤器 */
type FileFilter = "all" | "document" | "image" | "code";
const FILTER_ICONS: Record<FileFilter, React.ElementType> = {
all: LayoutGrid,
document: FileText,
image: Image,
code: Code,
};
export const TaskFileList: React.FC<TaskFilesProps> = ({
files,
selectedFileId,
onFileClick,
expanded = false,
onExpandedChange,
}) => {
const [filter, setFilter] = useState<FileFilter>("all");
// 过滤文件
const filteredFiles = files.filter((file) => {
if (filter === "all") return true;
if (file.type === "folder") return true;
return file.type === filter;
});
// 统计文件数量
const fileCount = files.reduce((count, file) => {
if (file.type === "folder" && file.children) {
return count + file.children.length;
}
return count + 1;
}, 0);
// 切换展开状态
const handleToggle = useCallback(() => {
onExpandedChange?.(!expanded);
}, [expanded, onExpandedChange]);
// 关闭面板
const handleClose = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onExpandedChange?.(false);
},
[onExpandedChange],
);
if (files.length === 0) {
return null;
}
return (
<div className="relative">
{/* 触发按钮 - 居中显示 */}
<div className="flex items-center justify-center py-2">
<button
type="button"
className={cn(
"flex items-center gap-2 px-4 py-1.5 rounded-full",
"text-sm text-muted-foreground",
"border border-border bg-background",
"hover:bg-muted/50 transition-colors",
expanded && "bg-muted/50",
)}
onClick={handleToggle}
>
<FolderOpen className="w-4 h-4" />
<span>任务文件 ({fileCount})</span>
{expanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronUp className="w-4 h-4" />
)}
</button>
</div>
{/* 弹出面板 - 参考 AnyGen 样式 */}
{expanded && (
<div
className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2",
"bg-background border border-border rounded-xl shadow-lg",
"z-50 overflow-hidden",
"w-[360px] max-h-[320px]",
)}
>
{/* 面板头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="w-4 h-4" />
<span>所有文件</span>
</div>
<div className="flex items-center gap-2">
{/* 过滤器 */}
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1">
{(Object.keys(FILTER_ICONS) as FileFilter[]).map((key) => {
const Icon = FILTER_ICONS[key];
return (
<button
key={key}
type="button"
className={cn(
"p-1.5 rounded-md transition-colors",
filter === key
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setFilter(key)}
title={key === "all" ? "全部" : key}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
{/* 关闭按钮 */}
<button
type="button"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
onClick={handleClose}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* 文件列表 */}
<div className="max-h-[240px] overflow-y-auto">
{filteredFiles.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
暂无文件
</div>
) : (
<div className="py-2 px-2">
{filteredFiles.map((file) => (
<TaskFileItem
key={file.id}
file={file}
isSelected={file.id === selectedFileId}
onClick={onFileClick}
/>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};
@@ -1,9 +0,0 @@
/**
* @file 任务文件组件导出
* @description 导出任务文件相关组件和类型
* @module components/agent/chat/components/TaskFiles
*/
export { TaskFileList } from "./TaskFileList";
export { TaskFileItem } from "./TaskFileItem";
export type { TaskFile, TaskFileType, TaskFilesProps } from "./types";
@@ -1,48 +0,0 @@
/**
* @file 任务文件类型定义
* @description 定义任务过程中生成的文件类型
* @module components/agent/chat/components/TaskFiles/types
*/
/**
* 文件类型
*/
export type TaskFileType = "document" | "folder" | "image" | "code" | "data";
/**
* 任务文件
*/
export interface TaskFile {
/** 文件 ID */
id: string;
/** 文件名 */
name: string;
/** 文件类型 */
type: TaskFileType;
/** 文件内容(文档类型) */
content?: string;
/** 版本号 */
version?: number;
/** 创建时间 */
createdAt: number;
/** 更新时间 */
updatedAt: number;
/** 子文件(文件夹类型) */
children?: TaskFile[];
}
/**
* 任务文件列表 Props
*/
export interface TaskFilesProps {
/** 文件列表 */
files: TaskFile[];
/** 当前选中的文件 ID */
selectedFileId?: string;
/** 文件点击回调 */
onFileClick: (file: TaskFile) => void;
/** 是否展开 */
expanded?: boolean;
/** 展开状态变更回调 */
onExpandedChange?: (expanded: boolean) => void;
}
@@ -1,79 +0,0 @@
import React from "react";
import { TimelineInlineItem } from "./TimelineInlineItem";
import type { AgentThreadItem } from "@/lib/api/agentProtocol";
interface TimelineFlowDemoProps {
items: AgentThreadItem[];
}
/**
* 时间线流式展示演示组件
*
* 这是一个简化的演示,展示新的时间线 + 内联操作设计
*/
export function TimelineFlowDemo({ items }: TimelineFlowDemoProps) {
// 过滤出需要在时间线上显示的项目
const timelineItems = items.filter(
(item) =>
item.type === "tool_call" ||
item.type === "command_execution" ||
item.type === "web_search"
);
// 分组:将 Agent 消息和工具调用交错显示
const groupedItems: Array<{ type: "text" | "timeline"; content: any }> = [];
items.forEach((item, index) => {
if (item.type === "agent_message") {
groupedItems.push({
type: "text",
content: item,
});
} else if (
item.type === "tool_call" ||
item.type === "command_execution" ||
item.type === "web_search"
) {
// 检查是否已经有一个 timeline 组
const lastGroup = groupedItems[groupedItems.length - 1];
if (lastGroup && lastGroup.type === "timeline") {
lastGroup.content.push(item);
} else {
groupedItems.push({
type: "timeline",
content: [item],
});
}
}
});
return (
<div className="space-y-4">
{groupedItems.map((group, groupIndex) => {
if (group.type === "text") {
// Agent 文本消息
const item = group.content;
return (
<div key={`text-${groupIndex}`} className="text-sm text-slate-700">
{item.text}
</div>
);
} else {
// 时间线组
const timelineItems = group.content as AgentThreadItem[];
return (
<div key={`timeline-${groupIndex}`} className="space-y-0">
{timelineItems.map((item, itemIndex) => (
<TimelineInlineItem
key={item.id}
item={item}
isLast={itemIndex === timelineItems.length - 1}
/>
))}
</div>
);
}
})}
</div>
);
}
@@ -1,287 +0,0 @@
import React, { useState } from "react";
import {
ChevronDown,
ChevronUp,
FileText,
Globe,
Loader2,
Search,
TerminalSquare,
Edit3,
FileEdit,
AlertTriangle,
CheckCircle2,
XCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import type {
AgentThreadItem,
AgentThreadToolCallItem,
AgentThreadCommandExecutionItem,
AgentThreadWebSearchItem,
} from "@/lib/api/agentProtocol";
interface TimelineInlineItemProps {
item: AgentThreadItem;
isLast?: boolean;
}
/**
* 类型守卫:检查是否为工具调用类型
*/
function isToolCallItem(item: AgentThreadItem): item is AgentThreadToolCallItem {
return item.type === "tool_call";
}
function isCommandExecutionItem(item: AgentThreadItem): item is AgentThreadCommandExecutionItem {
return item.type === "command_execution";
}
function isWebSearchItem(item: AgentThreadItem): item is AgentThreadWebSearchItem {
return item.type === "web_search";
}
/**
* 获取工具调用的图标
*/
function getToolIcon(toolName: string) {
const normalized = toolName.toLowerCase();
if (normalized.includes("read") || normalized.includes("file")) {
return FileText;
}
if (normalized.includes("bash") || normalized.includes("command")) {
return TerminalSquare;
}
if (normalized.includes("web") || normalized.includes("fetch")) {
return Globe;
}
if (normalized.includes("search") || normalized.includes("grep")) {
return Search;
}
if (normalized.includes("edit")) {
return Edit3;
}
if (normalized.includes("write")) {
return FileEdit;
}
return TerminalSquare;
}
/**
* 获取状态图标
*/
function getStatusIcon(status: string) {
if (status === "running" || status === "pending") {
return Loader2;
}
if (status === "completed" || status === "success") {
return CheckCircle2;
}
if (status === "failed" || status === "error") {
return XCircle;
}
return CheckCircle2;
}
/**
* 格式化工具调用的标题
*/
function formatToolCallTitle(item: AgentThreadItem): string {
// 命令执行
if (isCommandExecutionItem(item)) {
const cmd = item.command.trim();
const shortCmd = cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd;
return `执行命令 ${shortCmd}`;
}
// Web 搜索
if (isWebSearchItem(item)) {
return item.query ? `搜索 ${item.query}` : "Web 搜索";
}
// 工具调用
if (isToolCallItem(item)) {
const toolName = item.tool_name || "操作";
const args = item.arguments;
// 尝试提取有意义的参数
if (args && typeof args === "object" && args !== null) {
// Read 工具:显示文件路径
if ("file_path" in args && typeof args.file_path === "string") {
const fileName = args.file_path.split("/").pop() || args.file_path;
return `查看 ${fileName}`;
}
// Bash 工具:显示命令
if ("command" in args && typeof args.command === "string") {
const cmd = args.command.trim();
const shortCmd = cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd;
return `执行命令 ${shortCmd}`;
}
// WebFetch 工具:显示 URL
if ("url" in args && typeof args.url === "string") {
return `访问 ${args.url}`;
}
// Grep 工具:显示搜索模式
if ("pattern" in args && typeof args.pattern === "string") {
return `搜索 ${args.pattern}`;
}
// Edit 工具:显示文件路径
if ("file_path" in args && typeof args.file_path === "string") {
const fileName = args.file_path.split("/").pop() || args.file_path;
return `编辑 ${fileName}`;
}
// Write 工具:显示文件路径
if ("file_path" in args && typeof args.file_path === "string") {
const fileName = args.file_path.split("/").pop() || args.file_path;
return `写入 ${fileName}`;
}
}
return toolName;
}
return "操作";
}
/**
* 截断长文本
*/
function truncateText(text: string, maxLines: number = 3): { preview: string; hasMore: boolean } {
const lines = text.split("\n");
if (lines.length <= maxLines) {
return { preview: text, hasMore: false };
}
const preview = lines.slice(0, maxLines).join("\n");
return { preview, hasMore: true };
}
export function TimelineInlineItem({ item, isLast }: TimelineInlineItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
// 获取工具名称
let toolName = "";
if (isToolCallItem(item)) {
toolName = item.tool_name;
} else if (isCommandExecutionItem(item)) {
toolName = "Bash";
} else if (isWebSearchItem(item)) {
toolName = "WebSearch";
}
const ToolIcon = getToolIcon(toolName);
const StatusIcon = getStatusIcon(item.status);
const title = formatToolCallTitle(item);
const isRunning = item.status === "in_progress";
const isFailed = item.status === "failed";
// 获取输出内容
let output = "";
if (isToolCallItem(item)) {
output = item.output || item.error || "";
} else if (isCommandExecutionItem(item)) {
output = item.aggregated_output || item.error || "";
} else if (isWebSearchItem(item)) {
output = item.output || "";
}
const { preview, hasMore } = truncateText(output, 3);
// 默认展开失败的工具调用
const shouldDefaultExpand = isFailed;
return (
<div className="relative flex gap-3">
{/* 左侧时间线 */}
<div className="relative flex flex-col items-center">
{/* 状态图标 */}
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border-2",
isRunning && "border-sky-300 bg-sky-50",
isFailed && "border-rose-300 bg-rose-50",
!isRunning && !isFailed && "border-slate-300 bg-white"
)}
>
<StatusIcon
className={cn(
"h-3.5 w-3.5",
isRunning && "animate-spin text-sky-600",
isFailed && "text-rose-600",
!isRunning && !isFailed && "text-emerald-600"
)}
/>
</div>
{/* 连接线 */}
{!isLast && (
<div className="w-0.5 flex-1 bg-slate-200 mt-1" />
)}
</div>
{/* 右侧内容 */}
<div className="flex-1 pb-4">
<Collapsible
open={shouldDefaultExpand || isExpanded}
onOpenChange={setIsExpanded}
>
{/* 标题行 */}
<div className="flex items-center gap-2 text-sm">
<ToolIcon className="h-4 w-4 text-slate-500" />
<span className="font-medium text-slate-700">{title}</span>
{isRunning && (
<span className="text-xs text-slate-500">正在执行...</span>
)}
</div>
{/* 输出内容 */}
{output && (
<div className="mt-2">
{/* 预览 */}
<div className={cn(
"rounded-md border px-3 py-2 text-xs font-mono",
isFailed ? "border-rose-200 bg-rose-50 text-rose-900" : "border-slate-200 bg-slate-50 text-slate-700"
)}>
<pre className="whitespace-pre-wrap break-words">
{shouldDefaultExpand || isExpanded ? output : preview}
</pre>
</div>
{/* 展开/收起按钮 */}
{hasMore && !shouldDefaultExpand && (
<CollapsibleTrigger asChild>
<button
className="mt-2 flex items-center gap-1 text-xs text-slate-600 hover:text-slate-900"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
收起
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
查看完整输出
</>
)}
</button>
</CollapsibleTrigger>
)}
</div>
)}
</Collapsible>
</div>
</div>
);
}
+1 -1
View File
@@ -37,6 +37,6 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
export { useAsterAgentChat } from "./useAsterAgentChat";
export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation";
export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime";
export { useCompatSubagentRuntime } from "./useCompatSubagentRuntime";
// compat subagent 适配仅允许内部直连,不继续从统一 hooks 入口扩散。
export { useThemeContextWorkspace } from "./useThemeContextWorkspace";
export { useTopicBranchBoard } from "./useTopicBranchBoard";
@@ -185,6 +185,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
),
})),
);
await refreshSessionReadModel(activeSessionId);
toast.info("已记录你的回答,等待系统请求就绪后自动提交");
return;
}
+5 -5
View File
@@ -176,6 +176,9 @@ vi.mock("./hooks", () => ({
useThemeContextWorkspace: mockUseThemeContextWorkspace,
useTopicBranchBoard: mockUseTopicBranchBoard,
useTeamWorkspaceRuntime: mockUseTeamWorkspaceRuntime,
}));
vi.mock("./hooks/useCompatSubagentRuntime", () => ({
useCompatSubagentRuntime: mockUseCompatSubagentRuntime,
}));
@@ -1724,9 +1727,7 @@ describe("AgentChatPage 通用工作台", () => {
});
await flushEffects(4);
expect(
container.querySelector('[data-testid="chat-sidebar"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull();
});
it("发送时不再先调用本地 Team 规划模型,而是直接发送并透传已选 Team 约束", async () => {
@@ -5199,7 +5200,7 @@ describe("AgentChatPage legacy 问卷 A2UI", () => {
.map((component) => [component.label, component.id]),
);
await act(async () => {
act(() => {
latestInputbarProps?.onA2UISubmit?.({
[componentIdByLabel["这次内容主要面向谁?"]]: ["客户"],
[componentIdByLabel["这次最想达成的目标是什么?"]]:
@@ -5208,7 +5209,6 @@ describe("AgentChatPage legacy 问卷 A2UI", () => {
[componentIdByLabel["是否需要加入明确行动号召?"]]: ["是"],
});
});
await flushEffects(10);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
`我的选择:
@@ -70,11 +70,12 @@ describe("agentThreadGrouping", () => {
"browser",
]);
expect(model.groups[0]?.items).toHaveLength(2);
expect(model.groups[0]?.previewLines).toContain("打开 https://example.com");
expect(model.groups[1]?.previewLines).toContain("Lime CDP 并行渲染");
expect(model.groups[0]?.previewLines).toContain("打开了 https://example.com");
expect(model.groups[0]?.previewLines).toContain("点了 #submit");
expect(model.groups[1]?.previewLines).toContain("搜了 Lime CDP 并行渲染");
expect(model.summaryChips).toEqual([
{ kind: "browser", label: "浏览器操作", count: 3 },
{ kind: "search", label: "联网检索", count: 1 },
{ kind: "browser", label: "页面操作", count: 3 },
{ kind: "search", label: "联网搜索", count: 1 },
]);
});
@@ -108,15 +109,15 @@ describe("agentThreadGrouping", () => {
const model = buildAgentThreadDisplayModel(items);
expect(model.summaryText).toBe("已完成 CDP 页面检查");
expect(model.summaryText).toBe("已决定:已完成 CDP 页面检查");
expect(model.groups.map((group) => group.kind)).toEqual(["file", "command"]);
expect(model.groups[0]?.previewLines).toEqual(["wechat-draft.md"]);
expect(model.groups[0]?.previewLines).toEqual(["产出了 wechat-draft.md"]);
expect(model.groups[1]?.previewLines).toEqual([
"npm test -- AgentThreadTimeline",
"执行了 npm test -- AgentThreadTimeline",
]);
expect(model.summaryChips).toEqual([
{ kind: "file", label: "文件与产物", count: 1 },
{ kind: "command", label: "命令执行", count: 1 },
{ kind: "file", label: "文件和产物", count: 1 },
{ kind: "command", label: "命令", count: 1 },
]);
});
@@ -137,7 +138,7 @@ describe("agentThreadGrouping", () => {
const model = buildAgentThreadDisplayModel(items);
expect(model.groups.map((group) => group.kind)).toEqual(["file"]);
expect(model.groups[0]?.previewLines).toEqual(["nested-draft.md"]);
expect(model.groups[0]?.previewLines).toEqual(["写了 nested-draft.md"]);
});
it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => {
@@ -163,7 +164,7 @@ describe("agentThreadGrouping", () => {
const model = buildAgentThreadDisplayModel(items);
expect(model.groups.map((group) => group.kind)).toEqual(["file"]);
expect(model.groups[0]?.previewLines).toEqual(["reports", "run.log"]);
expect(model.groups[0]?.previewLines).toEqual(["看了 reports", "动了 run.log"]);
});
it("思考块应保留在真实时序中,而不是整体前置", () => {
@@ -195,7 +196,7 @@ describe("agentThreadGrouping", () => {
"search",
]);
expect(model.groups.map((group) => group.kind)).toEqual(["browser", "search"]);
expect(model.orderedBlocks[1]?.previewLines).toEqual(["已打开公众号后台"]);
expect(model.orderedBlocks[1]?.previewLines).toEqual(["已决定:已打开公众号后台"]);
});
it("结构化问答摘要不应回退为原始 a2ui 代码块", () => {
@@ -215,7 +216,7 @@ describe("agentThreadGrouping", () => {
const model = buildAgentThreadDisplayModel(items);
expect(model.summaryText).toBe("请先确认以下选项:");
expect(model.orderedBlocks[0]?.previewLines).toEqual(["请先确认以下选项:"]);
expect(model.summaryText).toBe("已决定:请先确认以下选项:");
expect(model.orderedBlocks[0]?.previewLines).toEqual(["已决定:请先确认以下选项:"]);
});
});
@@ -115,11 +115,11 @@ function extractThinkingPreviewLine(text: string | undefined | null): string | n
const parsed = parseAIResponse(normalized, false);
for (const part of parsed.parts) {
if (part.type === "pending_a2ui") {
return "结构化问答整理中";
return "在整理表单";
}
if (part.type === "a2ui") {
return "已生成结构化问答预览";
return "已整理成表单";
}
if (typeof part.content === "string") {
@@ -144,6 +144,28 @@ function shortenText(value: string | null | undefined, maxLength = 72): string |
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function startsWithAnyPrefix(value: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => value.startsWith(prefix));
}
function prefixAction(
value: string | null | undefined,
prefix: string,
knownPrefixes: string[],
maxLength = 72,
): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
if (startsWithAnyPrefix(normalized, knownPrefixes)) {
return shortenText(normalized, maxLength);
}
return shortenText(`${prefix}${normalized}`, maxLength);
}
function resolveItemTimestamp(item: AgentThreadItem): string {
return item.completed_at || item.updated_at || item.started_at;
}
@@ -346,17 +368,17 @@ function classifyItemKind(item: AgentThreadItem): AgentThreadGroupKind {
function resolveGroupTitle(kind: Exclude<AgentThreadGroupKind, "thinking">): string {
switch (kind) {
case "approval":
return "需要你处理";
return "等你确认";
case "alert":
return "异常与提醒";
return "提醒和错误";
case "browser":
return "浏览器操作";
return "页面操作";
case "search":
return "联网检索";
return "联网搜索";
case "file":
return "文件与产物";
return "文件和产物";
case "command":
return "命令执行";
return "命令";
case "subagent":
return "协作成员";
case "other":
@@ -367,7 +389,7 @@ function resolveGroupTitle(kind: Exclude<AgentThreadGroupKind, "thinking">): str
function resolveBlockTitle(kind: AgentThreadGroupKind): string {
if (kind === "thinking") {
return "思考与计划";
return "思考";
}
return resolveGroupTitle(kind);
@@ -395,27 +417,47 @@ function summarizeBrowserItem(item: AgentThreadItem): string | null {
const normalized = normalizeToolName(item.tool_name);
const url = resolveUrlFromItem(item);
const args = asRecord(item.arguments);
const target = readString(args, [
"selector",
"element",
"target",
"label",
"text",
"ref",
"uid",
]);
if (normalized.includes("navigate") || normalized.includes("goto")) {
return shortenText(url ? `打开 ${url}` : "打开页面");
return shortenText(url ? `打开了 ${url}` : "打开了页面");
}
if (normalized.includes("click")) {
return "点击页面元素";
return shortenText(target ? `点了 ${target}` : "点了页面元素");
}
if (normalized.includes("type") || normalized.includes("presskey")) {
return "输入页面内容";
if (
normalized.includes("type") ||
normalized.includes("presskey") ||
normalized.includes("fill") ||
normalized.includes("selectoption")
) {
return shortenText(target ? `填了 ${target}` : "填了页面内容");
}
if (normalized.includes("screenshot") || normalized.includes("snapshot")) {
return "抓取页面快照";
return shortenText(url ? `抓了 ${url} 的快照` : "抓了页面快照");
}
if (normalized.includes("evaluate") || normalized.includes("runtime")) {
return "提取页面信息";
return shortenText(url ? `看了 ${url} 的页面信息` : "看了页面信息");
}
return shortenText(url ? `操作 ${url}` : resolveToolDisplayLabel(item.tool_name));
return shortenText(url ? `做了 ${url} 的页面操作` : "做了页面操作");
}
function summarizeSearchItem(item: AgentThreadItem): string | null {
if (item.type === "web_search") {
return shortenText(item.query || item.action || "联网检索");
return prefixAction(
item.query || item.action || "联网搜索",
"搜了 ",
["搜了 ", "查了 ", "搜索了 ", "检索了 "],
);
}
if (item.type !== "tool_call") {
@@ -423,20 +465,74 @@ function summarizeSearchItem(item: AgentThreadItem): string | null {
}
const args = asRecord(item.arguments);
return shortenText(
return prefixAction(
readString(args, ["query", "q", "pattern", "search", "url"]) ||
resolveToolDisplayLabel(item.tool_name),
"搜了 ",
["搜了 ", "查了 ", "搜索了 ", "检索了 "],
);
}
function summarizeFileItem(item: AgentThreadItem): string | null {
const path = resolvePathFromItem(item);
if (path) {
return `${fileNameFromPath(path)}`;
const fileLabel = path ? fileNameFromPath(path) : null;
if (item.type === "file_artifact") {
return prefixAction(
fileLabel || item.path,
"产出了 ",
["产出了 ", "写了 ", "改了 ", "看了 ", "动了 "],
);
}
if (item.type === "tool_call") {
return shortenText(resolveToolDisplayLabel(item.tool_name));
const normalized = normalizeToolName(item.tool_name);
if (
normalized.includes("read") ||
normalized.includes("view") ||
normalized.includes("cat") ||
normalized.includes("open") ||
normalized.includes("list")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"看了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
);
}
if (
normalized.includes("write") ||
normalized.includes("create") ||
normalized.includes("mkdir") ||
normalized.includes("save")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"写了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
);
}
if (
normalized.includes("edit") ||
normalized.includes("patch") ||
normalized.includes("replace") ||
normalized.includes("update")
) {
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"改了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
);
}
return prefixAction(
fileLabel || resolveToolDisplayLabel(item.tool_name),
"动了 ",
["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "],
);
}
return null;
@@ -444,14 +540,21 @@ function summarizeFileItem(item: AgentThreadItem): string | null {
function summarizeCommandItem(item: AgentThreadItem): string | null {
if (item.type === "command_execution") {
return shortenText(item.command, 64);
return prefixAction(
item.command,
"执行了 ",
["执行了 ", "跑了 ", "运行了 "],
64,
);
}
if (item.type === "tool_call") {
const args = asRecord(item.arguments);
return shortenText(
return prefixAction(
readString(args, ["command", "cmd", "script"]) ||
resolveToolDisplayLabel(item.tool_name),
"执行了 ",
["执行了 ", "跑了 ", "运行了 "],
64,
);
}
@@ -463,48 +566,79 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null {
if (item.type !== "subagent_activity") {
return null;
}
return shortenText(
return prefixAction(
resolveInternalImageTaskDisplayName(item.title) ||
item.summary ||
item.status_label,
item.status_label ||
"协作任务",
"分给协作成员处理 ",
["分给协作成员", "协作成员"],
);
}
function summarizeAlertItem(item: AgentThreadItem): string | null {
if (item.type === "warning") {
return shortenText(item.message);
return prefixAction(
item.message,
"收到提醒:",
["收到提醒:", "碰到错误:"],
);
}
if (item.type === "error") {
return shortenText(item.message);
return prefixAction(
item.message,
"碰到错误:",
["收到提醒:", "碰到错误:"],
);
}
return null;
}
function summarizeOtherItem(item: AgentThreadItem): string | null {
if (item.type === "tool_call") {
return shortenText(resolveToolDisplayLabel(item.tool_name));
return prefixAction(
resolveToolDisplayLabel(item.tool_name),
"执行了 ",
["执行了 ", "跑了 ", "运行了 "],
);
}
return null;
}
function summarizeThinkingItem(item: AgentThreadItem): string | null {
if (item.type === "turn_summary") {
return extractThinkingPreviewLine(item.text);
const preview = extractThinkingPreviewLine(item.text);
if (!preview) {
return item.status === "in_progress" ? "思考中" : "已完成思考";
}
if (startsWithAnyPrefix(preview, ["在整理表单", "已整理成表单"])) {
return preview;
}
return prefixAction(
preview,
"已决定:",
["已决定:", "决定了:", "思考中", "已完成思考"],
);
}
if (item.type === "context_compaction") {
return shortenText(
item.detail ||
(item.stage === "completed" ? "上下文已压缩" : "正在压缩上下文"),
(item.stage === "completed" ? "压了上下文" : "正在压上下文"),
);
}
if (item.type === "reasoning") {
return extractThinkingPreviewLine(item.summary?.join(";") || item.text);
return (
extractThinkingPreviewLine(item.summary?.join(";") || item.text) ||
(item.status === "in_progress" ? "思考中" : "已完成思考")
);
}
if (item.type === "plan") {
return firstMeaningfulLine(item.text);
return item.status === "in_progress" ? "还在排步骤" : "定了执行步骤";
}
return null;
@@ -529,13 +663,18 @@ function summarizeGroupPreviewLine(
return summarizeAlertItem(item);
case "approval":
if (item.type === "approval_request" || item.type === "request_user_input") {
return shortenText(
item.prompt ||
(item.action_type === "ask_user"
? "需要补充信息"
: item.action_type === "elicitation"
? "需要进一步确认"
: "需要你确认"),
const fallback =
item.action_type === "ask_user"
? "等你补充信息"
: item.action_type === "elicitation"
? "等你进一步确认"
: "等你确认这一步";
const promptPrefix = item.action_type === "ask_user" ? "等你补充:" : "等你确认:";
return prefixAction(
item.prompt || fallback,
promptPrefix,
["等你补充:", "等你确认:", "等你补充信息", "等你确认这一步"],
);
}
return null;
@@ -20,12 +20,23 @@ export interface CompatSubagentRuntimeActivity {
summary: string;
}
export interface CompatSubagentRuntimeSnapshot
extends CompatSubagentRuntimeState {
export interface CompatSubagentRuntimeStatus {
isRunning: boolean;
progress: CompatSubagentProgress | null;
}
export interface CompatSubagentRuntimeDisplaySnapshot
extends CompatSubagentRuntimeStatus {
error: string | null;
result: SchedulerExecutionResult | null;
recentActivity: CompatSubagentRuntimeActivity[];
hasSignals: boolean;
}
export interface CompatSubagentRuntimeSnapshot
extends CompatSubagentRuntimeState,
CompatSubagentRuntimeDisplaySnapshot {}
export function summarizeCompatSubagentEvent(
event: CompatSubagentEvent,
): string {
@@ -348,6 +348,29 @@ describe("ArtifactWorkbenchShell", () => {
expect(container.textContent).toContain("block hero-1");
});
it("canvas-only 模式应只保留正文画布,不再渲染 inspector 侧栏", async () => {
const container = renderShell(createArtifactDocumentArtifact(), {
layoutMode: "canvas-only",
onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined),
});
await act(async () => {
await Promise.resolve();
});
expect(
container
.querySelector('[data-testid="artifact-workbench-shell"]')
?.getAttribute("data-layout-mode"),
).toBe("canvas-only");
const tabLabels = ["概览", "来源", "版本", "差异", "编辑"];
const buttons = Array.from(container.querySelectorAll("button"));
for (const label of tabLabels) {
expect(buttons.find((button) => button.textContent?.includes(label))).toBeUndefined();
}
});
it("恢复为草稿时应展示低压状态说明", async () => {
const container = renderShell(
createArtifactDocumentArtifact({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,28 @@ interface UseWorkspaceA2UIRuntimeParams {
messages: Message[];
}
function isSamePendingA2UIResolution(
previous: PendingA2UIResolution | null,
next: PendingA2UIResolution,
): boolean {
if (!previous || previous.source.kind !== next.source.kind) {
return false;
}
if (
previous.source.kind === "action_request" &&
next.source.kind === "action_request"
) {
return previous.source.requestId === next.source.requestId;
}
return (
previous.source.kind !== "action_request" &&
next.source.kind !== "action_request" &&
previous.source.messageId === next.source.messageId
);
}
export function useWorkspaceA2UIRuntime({
messages,
}: UseWorkspaceA2UIRuntimeParams): {
@@ -168,8 +190,13 @@ export function useWorkspaceA2UIRuntime({
}
if (pendingPromotedA2UIActionRequest) {
const form = buildActionRequestA2UI(pendingPromotedA2UIActionRequest);
if (!form) {
return null;
}
return {
form: buildActionRequestA2UI(pendingPromotedA2UIActionRequest),
form,
source: {
kind: "action_request",
requestId: pendingPromotedA2UIActionRequest.requestId,
@@ -243,7 +270,11 @@ export function useWorkspaceA2UIRuntime({
useEffect(() => {
if (resolvedPendingA2UI) {
setRetainedPendingA2UI(resolvedPendingA2UI);
setRetainedPendingA2UI((previous) =>
isSamePendingA2UIResolution(previous, resolvedPendingA2UI)
? previous
: resolvedPendingA2UI,
);
return;
}
@@ -257,17 +288,18 @@ export function useWorkspaceA2UIRuntime({
return null;
}
if (previous.source.kind === "action_request") {
const source = previous.source;
if (source.kind === "action_request") {
const requestStillExists = messages.some((message) =>
(message.actionRequests || []).some(
(request) => request.requestId === previous.source.requestId,
(request) => request.requestId === source.requestId,
),
);
return requestStillExists ? previous : null;
}
const sourceMessageStillExists = messages.some(
(message) => message.id === previous.source.messageId,
(message) => message.id === source.messageId,
);
return sourceMessageStillExists ? previous : null;
});
@@ -9,6 +9,15 @@ import type { CanvasStateUnion } from "@/components/content-creator/canvas/canva
import { isCanvasStateEmpty } from "./themeWorkbenchHelpers";
import type { WorkspaceHandleSend } from "./useWorkspaceSendActions";
const shouldLogWorkspaceInfo = import.meta.env.MODE !== "test";
function logWorkspaceInfo(...args: Parameters<typeof console.log>) {
if (!shouldLogWorkspaceInfo) {
return;
}
console.log(...args);
}
interface UseWorkspaceAutoGuideRuntimeParams {
contentId?: string | null;
sessionId?: string | null;
@@ -110,7 +119,7 @@ export function useWorkspaceAutoGuideRuntime({
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
hasTriggeredGuideRef.current = true;
console.log("[AgentChatPage] 自动发送首条创作意图消息");
logWorkspaceInfo("[AgentChatPage] 自动发送首条创作意图消息");
void (async () => {
const started = await handleSend(
@@ -154,12 +163,12 @@ export function useWorkspaceAutoGuideRuntime({
}
hasTriggeredGuideRef.current = true;
console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
logWorkspaceInfo("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
void (async () => {
try {
await contentWorkflowApi.create(contentId, mappedTheme, creationMode);
console.log("[AgentChatPage] 后端工作流创建成功");
logWorkspaceInfo("[AgentChatPage] 后端工作流创建成功");
} catch (error) {
console.warn(
"[AgentChatPage] 后端工作流创建失败(不影响主流程):",
@@ -173,7 +182,7 @@ export function useWorkspaceAutoGuideRuntime({
}
hasTriggeredGuideRef.current = true;
console.log("[AgentChatPage] 自动触发 AI 创作引导");
logWorkspaceInfo("[AgentChatPage] 自动触发 AI 创作引导");
triggerAIGuideRef.current();
}, [
canvasState,
@@ -282,7 +291,7 @@ export function useWorkspaceAutoGuideRuntime({
const completedCount = workflow.steps.filter(
(step) => step.status === "completed" || step.status === "skipped",
).length;
console.log(
logWorkspaceInfo(
`[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`,
);
} catch (error) {
@@ -154,4 +154,50 @@ describe("useWorkspaceCanvasLayoutRuntime", () => {
setLayoutMode.mock.calls.some((call) => call[0] === "chat"),
).toBe(true);
});
it("stacked 自动收起侧栏后,不应在同一轮 general chat-canvas 中立刻反向展开", async () => {
const setShowSidebar = vi.fn();
const autoCollapsedTopicSidebarRef = { current: false };
const { render } = renderHook({
setShowSidebar,
autoCollapsedTopicSidebarRef,
canvasWorkbenchLayoutMode: "stacked",
showSidebar: true,
layoutMode: "chat-canvas",
});
await render();
expect(setShowSidebar).toHaveBeenCalledWith(false);
expect(autoCollapsedTopicSidebarRef.current).toBe(true);
setShowSidebar.mockClear();
await render({
canvasWorkbenchLayoutMode: "split",
showSidebar: false,
autoCollapsedTopicSidebarRef,
layoutMode: "chat-canvas",
});
expect(setShowSidebar).not.toHaveBeenCalled();
expect(autoCollapsedTopicSidebarRef.current).toBe(true);
});
it("自动收起的侧栏在离开 general chat-canvas 主路径后应恢复", async () => {
const setShowSidebar = vi.fn();
const autoCollapsedTopicSidebarRef = { current: true };
const { render } = renderHook({
setShowSidebar,
autoCollapsedTopicSidebarRef,
showSidebar: false,
canvasWorkbenchLayoutMode: "split",
layoutMode: "chat",
});
await render();
expect(setShowSidebar).toHaveBeenCalledWith(true);
expect(autoCollapsedTopicSidebarRef.current).toBe(false);
});
});
@@ -221,7 +221,15 @@ export function useWorkspaceCanvasLayoutRuntime({
return;
}
if (autoCollapsedTopicSidebarRef.current) {
const shouldRestoreAutoCollapsedSidebar =
autoCollapsedTopicSidebarRef.current &&
!showSidebar &&
(!showChatPanel ||
isThemeWorkbench ||
activeTheme !== "general" ||
layoutMode !== "chat-canvas");
if (shouldRestoreAutoCollapsedSidebar) {
autoCollapsedTopicSidebarRef.current = false;
setShowSidebar(true);
}
@@ -30,7 +30,10 @@ import {
ArtifactWorkbenchPreview,
WorkspaceLiveCanvasPreview,
} from "./workbenchPreview";
import { renderCanvasWorkbenchPreviewTarget } from "./workbenchPreviewHelpers";
import {
renderCanvasWorkbenchPreviewTarget,
type RenderArtifactWorkbenchPreviewOptions,
} from "./workbenchPreviewHelpers";
import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview";
import {
useTeamWorkbenchPresentation,
@@ -41,7 +44,10 @@ import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
type ArtifactPreviewBaseProps = Omit<
ComponentProps<typeof ArtifactWorkbenchPreview>,
"artifact" | "stackedWorkbenchTrigger"
| "artifact"
| "stackedWorkbenchTrigger"
| "artifactDocumentLayoutMode"
| "onArtifactDocumentControllerChange"
>;
type ImageWorkbenchCanvasProps = ComponentProps<typeof ImageWorkbenchCanvas>;
type GeneralCanvasPanelProps = Omit<
@@ -433,11 +439,18 @@ export function useWorkspaceCanvasPreviewPresentation({
);
const renderArtifactWorkbenchPreview = useCallback(
(artifact: Artifact, stackedWorkbenchTrigger?: ReactNode) => (
(
artifact: Artifact,
options?: RenderArtifactWorkbenchPreviewOptions,
) => (
<ArtifactWorkbenchPreview
{...artifactWorkbenchPreviewBaseProps}
artifact={artifact}
stackedWorkbenchTrigger={stackedWorkbenchTrigger}
stackedWorkbenchTrigger={options?.stackedWorkbenchTrigger}
artifactDocumentLayoutMode={options?.artifactDocumentLayoutMode}
onArtifactDocumentControllerChange={
options?.onArtifactDocumentControllerChange
}
/>
),
[artifactWorkbenchPreviewBaseProps],
@@ -29,6 +29,15 @@ import {
} from "./themeWorkbenchHelpers";
import type { GeneralArtifactSyncResult } from "./useWorkspaceGeneralResourceSync";
const shouldLogWorkspaceWriteInfo = import.meta.env.MODE !== "test";
function logWorkspaceWriteInfo(...args: Parameters<typeof console.log>) {
if (!shouldLogWorkspaceWriteInfo) {
return;
}
console.log(...args);
}
interface ThemeWorkbenchActiveQueueSummary {
run_id?: string | null;
title?: string | null;
@@ -102,7 +111,7 @@ export function useWorkspaceWriteFileAction({
}: UseWorkspaceWriteFileActionParams) {
return useCallback(
(content: string, fileName: string, context?: WriteArtifactContext) => {
console.log(
logWorkspaceWriteInfo(
"[AgentChatPage] 收到文件写入:",
fileName,
content.length,
@@ -277,7 +286,7 @@ export function useWorkspaceWriteFileAction({
console.error("[AgentChatPage] 检查内容存在性失败:", error);
});
} else if (isThemeWorkbench && !shouldApplyToMainDocument) {
console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", {
logWorkspaceWriteInfo("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", {
gate: currentGateKey,
fileName,
isPrimaryArtifact,
@@ -291,7 +300,7 @@ export function useWorkspaceWriteFileAction({
stepIndex === currentStepIndex &&
isContentCreationMode
) {
console.log(
logWorkspaceWriteInfo(
"[AgentChatPage] 推进工作流步骤:",
stepIndex,
"->",
@@ -355,12 +364,12 @@ export function useWorkspaceWriteFileAction({
const existing = previous[existingIndex];
if (existing.content === content) {
console.log("[AgentChatPage] 文件内容相同,跳过:", fileName);
logWorkspaceWriteInfo("[AgentChatPage] 文件内容相同,跳过:", fileName);
setSelectedFileId(existing.id);
return previous;
}
console.log("[AgentChatPage] 更新文件:", fileName);
logWorkspaceWriteInfo("[AgentChatPage] 更新文件:", fileName);
const nextFiles = [...previous];
nextFiles[existingIndex] = {
...existing,
@@ -380,7 +389,7 @@ export function useWorkspaceWriteFileAction({
return nextFiles;
}
console.log("[AgentChatPage] 创建新文件:", fileName);
logWorkspaceWriteInfo("[AgentChatPage] 创建新文件:", fileName);
const newFile: TaskFile = {
id: crypto.randomUUID(),
name: fileName,
@@ -406,7 +415,7 @@ export function useWorkspaceWriteFileAction({
}
setCanvasState((previous) => {
console.log("[AgentChatPage] 更新画布状态:", {
logWorkspaceWriteInfo("[AgentChatPage] 更新画布状态:", {
prevType: previous?.type,
mappedTheme,
contentLength: content.length,
@@ -425,7 +434,7 @@ export function useWorkspaceWriteFileAction({
if (titleMatch) {
musicState.spec.title = titleMatch[1].trim();
}
console.log("[AgentChatPage] 创建新音乐状态");
logWorkspaceWriteInfo("[AgentChatPage] 创建新音乐状态");
return musicState;
}
return {
@@ -439,7 +448,7 @@ export function useWorkspaceWriteFileAction({
}
if (!previous || previous.type !== "document") {
console.log("[AgentChatPage] 创建新文档状态");
logWorkspaceWriteInfo("[AgentChatPage] 创建新文档状态");
const initialDocumentState = createInitialDocumentState(content);
if (!effectiveDocumentVersionId) {
if (!socialArtifact) {
@@ -535,7 +544,7 @@ export function useWorkspaceWriteFileAction({
};
}
console.log("[AgentChatPage] 更新现有文档状态");
logWorkspaceWriteInfo("[AgentChatPage] 更新现有文档状态");
return {
...previous,
content,
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import type { ComponentProps, ReactNode } from "react";
import {
ArtifactCanvasOverlay,
@@ -20,6 +21,11 @@ import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers";
import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { AgentThreadItem } from "../types";
import {
type ArtifactWorkbenchDocumentController,
type ArtifactWorkbenchLayoutMode,
useArtifactWorkbenchDocumentController,
} from "./artifactWorkbenchDocument";
interface ArtifactWorkbenchPreviewProps {
artifact: Artifact;
@@ -47,6 +53,10 @@ interface ArtifactWorkbenchPreviewProps {
onJumpToTimelineItem?: (itemId: string) => void;
onCloseCanvas: () => void;
stackedWorkbenchTrigger?: ReactNode;
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
renderToolbarActions?: (params: {
artifact: Artifact;
document: ArtifactDocumentV1 | null;
@@ -70,6 +80,8 @@ export function ArtifactWorkbenchPreview({
onJumpToTimelineItem,
onCloseCanvas,
stackedWorkbenchTrigger,
artifactDocumentLayoutMode = "full",
onArtifactDocumentControllerChange,
renderToolbarActions,
}: ArtifactWorkbenchPreviewProps) {
const isLiveSelectedArtifact =
@@ -96,6 +108,30 @@ export function ArtifactWorkbenchPreview({
content: previewArtifact.content,
metadata: previewArtifact.meta,
});
const documentController = useArtifactWorkbenchDocumentController({
artifact: previewArtifact,
onSaveArtifactDocument,
threadItems,
focusedBlockId,
blockFocusRequestKey,
onJumpToTimelineItem,
});
useEffect(() => {
if (!onArtifactDocumentControllerChange) {
return;
}
onArtifactDocumentControllerChange(artifactDocument ? documentController : null);
return () => {
onArtifactDocumentControllerChange(null);
};
}, [
artifactDocument,
documentController,
onArtifactDocumentControllerChange,
]);
const combinedActionsSlot = (
<>
{renderToolbarActions?.({
@@ -145,6 +181,8 @@ export function ArtifactWorkbenchPreview({
onJumpToTimelineItem={onJumpToTimelineItem}
onCloseCanvas={onCloseCanvas}
actionsSlot={combinedActionsSlot}
layoutMode={artifactDocumentLayoutMode}
documentController={documentController}
/>
);
}
@@ -197,7 +235,13 @@ interface WorkspaceLiveCanvasPreviewProps {
hasDisplayedLiveArtifact: boolean;
renderArtifactPreview: (
artifact: Artifact,
stackedWorkbenchTrigger?: ReactNode,
options?: {
stackedWorkbenchTrigger?: ReactNode;
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
},
) => ReactNode;
generalCanvasPanelProps: Omit<
ComponentProps<typeof GeneralCanvasPanel>,
@@ -234,7 +278,9 @@ export function WorkspaceLiveCanvasPreview({
liveArtifact &&
hasDisplayedLiveArtifact
) {
return renderArtifactPreview(liveArtifact, stackedWorkbenchTrigger);
return renderArtifactPreview(liveArtifact, {
stackedWorkbenchTrigger,
});
}
if (canvasRenderTheme === "general") {
@@ -2,6 +2,18 @@ import type { ReactNode } from "react";
import type { DocumentVersion } from "@/components/content-creator/canvas/document/types";
import type { Artifact } from "@/lib/artifact/types";
import type { CanvasWorkbenchPreviewTarget } from "../components/CanvasWorkbenchLayout";
import type {
ArtifactWorkbenchDocumentController,
ArtifactWorkbenchLayoutMode,
} from "./artifactWorkbenchDocument";
export interface RenderArtifactWorkbenchPreviewOptions {
stackedWorkbenchTrigger?: ReactNode;
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
}
export function resolvePreviousDocumentVersionContent(
version: DocumentVersion | null | undefined,
@@ -69,7 +81,7 @@ export function renderCanvasWorkbenchPreviewTarget(params: {
renderDefaultCanvasPreview: (stackedWorkbenchTrigger?: ReactNode) => ReactNode;
renderArtifactPreview: (
artifact: Artifact,
stackedWorkbenchTrigger?: ReactNode,
options?: RenderArtifactWorkbenchPreviewOptions,
) => ReactNode;
renderTeamWorkbenchPreview: (
stackedWorkbenchTrigger?: ReactNode,
@@ -82,7 +94,9 @@ export function renderCanvasWorkbenchPreviewTarget(params: {
return params.renderDefaultCanvasPreview(stackedWorkbenchTrigger);
case "artifact":
case "synthetic-artifact":
return params.renderArtifactPreview(target.artifact, stackedWorkbenchTrigger);
return params.renderArtifactPreview(target.artifact, {
stackedWorkbenchTrigger,
});
case "loading":
return renderWorkbenchStatePreview("loading", {
text: "正在准备预览...",
@@ -7,34 +7,34 @@ export interface A2UITaskCardPreset {
}
export const DEFAULT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
title: "补充信息",
subtitle: "请先完成这一步,我再继续后续处理。",
statusLabel: "待完成 1 / 1",
loadingText: "表单加载中...",
title: "等你补充信息",
subtitle: "先补这一步,我再继续后续处理。",
statusLabel: "等你确认",
loadingText: "这一步加载中...",
};
export const CHAT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
...DEFAULT_A2UI_TASK_CARD_PRESET,
subtitle: "请先完成这一步,我再继续当前对话。",
subtitle: "先补这一步,我再继续当前对话。",
};
export const CHAT_FLOATING_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
...DEFAULT_A2UI_TASK_CARD_PRESET,
subtitle: "请先完成这一步,我再继续。",
subtitle: "先补这一步,我再继续。",
};
export const REVIEW_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
title: "结构化补充信息",
subtitle: "评审结果已切换为结构化预览,仅展示字段与提示,不直接允许提交。",
statusLabel: "评审预览",
title: "评审预览",
subtitle: "结构化补充信息",
statusLabel: "只读回显",
loadingText: "结构化评审结果加载中...",
};
export const TIMELINE_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
title: "结构化问答预览",
subtitle: "当前阶段已整理结构化字段,仅作为回合记录预览展示。",
statusLabel: "阶段预览",
loadingText: "结构化问答整理中...",
title: "这一步的信息",
subtitle: "这是这一步的回显,我按这个继续。",
statusLabel: "回合记录",
loadingText: "这一步还在整理...",
};
export const WORKSPACE_CREATE_CONFIRMATION_TASK_PRESET: A2UITaskCardPreset = {
+1 -1
View File
@@ -248,7 +248,7 @@ function renderSettingsContent(
case SettingsTabs.ChromeRelay:
return (
<>
<SettingHeader title="Chrome Relay" />
<SettingHeader title="连接器" />
<ChromeRelaySettings />
</>
);
+1 -1
View File
@@ -63,7 +63,7 @@ const groupMeta: Record<
icon: Brain,
},
system: {
description: "渠道、MCP、环境变量与安全性能设置。",
description: "连接器、渠道、MCP、环境变量与安全性能设置。",
accentClassName:
"from-amber-200/65 via-white to-white",
iconClassName: "border-amber-200 bg-amber-100 text-amber-700",
@@ -170,7 +170,7 @@ export function useSettingsCategory(): CategoryGroup[] {
},
{
key: SettingsTabs.ChromeRelay,
label: t("settings.tab.chromeRelay", "Chrome Relay"),
label: t("settings.tab.chromeRelay", "连接器"),
icon: Monitor,
},
{
@@ -3,7 +3,15 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockOpenDialog,
mockGetConfig,
mockSetBrowserConnectorInstallRoot,
mockGetBrowserConnectorSettings,
mockGetBrowserConnectorInstallStatus,
mockInstallBrowserConnectorExtension,
mockSetBrowserConnectorEnabled,
mockSetSystemConnectorEnabled,
mockOpenBrowserExtensionsPage,
mockLaunchBrowserSession,
mockOpenBrowserRuntimeDebuggerWindow,
mockGetChromeProfileSessions,
@@ -12,7 +20,15 @@ const {
mockGetBrowserBackendPolicy,
mockGetBrowserBackendsStatus,
} = vi.hoisted(() => ({
mockOpenDialog: vi.fn(),
mockGetConfig: vi.fn(),
mockSetBrowserConnectorInstallRoot: vi.fn(),
mockGetBrowserConnectorSettings: vi.fn(),
mockGetBrowserConnectorInstallStatus: vi.fn(),
mockInstallBrowserConnectorExtension: vi.fn(),
mockSetBrowserConnectorEnabled: vi.fn(),
mockSetSystemConnectorEnabled: vi.fn(),
mockOpenBrowserExtensionsPage: vi.fn(),
mockLaunchBrowserSession: vi.fn(),
mockOpenBrowserRuntimeDebuggerWindow: vi.fn(),
mockGetChromeProfileSessions: vi.fn(),
@@ -26,6 +42,10 @@ vi.mock("@/lib/api/appConfig", () => ({
getConfig: mockGetConfig,
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => mockOpenDialog(...args),
}));
vi.mock("@/features/browser-runtime", () => ({
BrowserRuntimeDebugPanel: () => <div data-testid="browser-runtime-panel" />,
}));
@@ -34,6 +54,13 @@ vi.mock("@/lib/webview-api", async () => {
const actual = await vi.importActual<object>("@/lib/webview-api");
return {
...actual,
getBrowserConnectorSettings: mockGetBrowserConnectorSettings,
setBrowserConnectorInstallRoot: mockSetBrowserConnectorInstallRoot,
getBrowserConnectorInstallStatus: mockGetBrowserConnectorInstallStatus,
installBrowserConnectorExtension: mockInstallBrowserConnectorExtension,
setBrowserConnectorEnabled: mockSetBrowserConnectorEnabled,
setSystemConnectorEnabled: mockSetSystemConnectorEnabled,
openBrowserExtensionsPage: mockOpenBrowserExtensionsPage,
launchBrowserSession: mockLaunchBrowserSession,
openBrowserRuntimeDebuggerWindow: mockOpenBrowserRuntimeDebuggerWindow,
getChromeProfileSessions: mockGetChromeProfileSessions,
@@ -57,6 +84,7 @@ interface Mounted {
}
const mounted: Mounted[] = [];
const mockWriteClipboardText = vi.fn();
function renderComponent() {
const container = document.createElement("div");
@@ -104,12 +132,97 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: mockWriteClipboardText,
},
});
mockWriteClipboardText.mockResolvedValue(undefined);
mockOpenDialog.mockResolvedValue("/Users/test/connectors");
mockGetConfig.mockResolvedValue({
web_search: {
engine: "google",
},
});
mockGetBrowserConnectorSettings.mockResolvedValue({
enabled: true,
install_root_dir: null,
install_dir: null,
system_connectors: [
{
id: "calendar",
label: "日历",
description: "读取和管理你的日历事件。",
enabled: false,
available: true,
},
],
});
mockSetBrowserConnectorInstallRoot.mockResolvedValue({
enabled: true,
install_root_dir: "/Users/test/connectors",
install_dir: "/Users/test/connectors/Lime Browser Connector",
system_connectors: [
{
id: "calendar",
label: "日历",
description: "读取和管理你的日历事件。",
enabled: false,
available: true,
},
],
});
mockGetBrowserConnectorInstallStatus.mockResolvedValue({
status: "not_installed",
install_root_dir: null,
install_dir: null,
bundled_name: "Lime Browser Connector",
bundled_version: "0.2.0",
installed_name: null,
installed_version: null,
message: "尚未选择浏览器连接器安装目录",
});
mockInstallBrowserConnectorExtension.mockResolvedValue({
install_root_dir: "/Users/test/connectors",
install_dir: "/Users/test/connectors/Lime Browser Connector",
bundled_name: "Lime Browser Connector",
bundled_version: "0.2.0",
installed_version: "0.2.0",
auto_config_path:
"/Users/test/connectors/Lime Browser Connector/auto_config.json",
});
mockSetBrowserConnectorEnabled.mockResolvedValue({
enabled: false,
install_root_dir: null,
install_dir: null,
system_connectors: [
{
id: "calendar",
label: "日历",
description: "读取和管理你的日历事件。",
enabled: false,
available: true,
},
],
});
mockSetSystemConnectorEnabled.mockResolvedValue({
enabled: true,
install_root_dir: null,
install_dir: null,
system_connectors: [
{
id: "calendar",
label: "日历",
description: "读取和管理你的日历事件。",
enabled: true,
available: true,
},
],
});
mockOpenBrowserExtensionsPage.mockResolvedValue(true);
mockLaunchBrowserSession.mockResolvedValue({
profile: {
success: true,
@@ -177,7 +290,7 @@ afterEach(() => {
});
describe("ChromeRelaySettings", () => {
it("应通过页签切换到浏览器实时调试面板", async () => {
it("应在展开高级控制后切换到浏览器实时调试面板", async () => {
const container = renderComponent();
await flushEffects();
@@ -185,6 +298,12 @@ describe("ChromeRelaySettings", () => {
container.querySelector('[data-testid="browser-runtime-panel"]'),
).toBeNull();
const expandButton = findButton(container, "展开高级控制");
await act(async () => {
expandButton.click();
await flushEffects();
});
const tabButton = findTabButton(container, "调试");
await act(async () => {
tabButton.click();
@@ -196,6 +315,49 @@ describe("ChromeRelaySettings", () => {
).not.toBeNull();
});
it("选择目录后应安装浏览器连接器到固定子目录", async () => {
const container = renderComponent();
await flushEffects();
const button = findButton(container, "选择目录并安装");
await act(async () => {
button.click();
await flushEffects();
});
expect(mockOpenDialog).toHaveBeenCalledTimes(1);
expect(mockSetBrowserConnectorInstallRoot).toHaveBeenCalledWith(
"/Users/test/connectors",
);
expect(mockInstallBrowserConnectorExtension).toHaveBeenCalledWith({
install_root_dir: "/Users/test/connectors",
profile_key: "default",
});
expect(container.textContent).toContain(
"浏览器连接器已同步到 /Users/test/connectors/Lime Browser Connector",
);
});
it("应复制默认连接器配置到剪贴板", async () => {
const container = renderComponent();
await flushEffects();
const button = findButton(container, "复制配置");
await act(async () => {
button.click();
await flushEffects();
});
expect(mockWriteClipboardText).toHaveBeenCalledTimes(1);
expect(mockWriteClipboardText.mock.calls[0]?.[0]).toContain(
'"profileKey": "default"',
);
expect(mockWriteClipboardText.mock.calls[0]?.[0]).toContain(
'"bridgeKey": "proxy_cast"',
);
expect(container.textContent).toContain("默认浏览器连接器 配置已复制到剪贴板");
});
it("点击一键按钮时应启动浏览器协助", async () => {
const container = renderComponent();
await flushEffects();
File diff suppressed because it is too large Load Diff
+44 -2
View File
@@ -186,6 +186,32 @@ export interface UseDeepLinkReturn {
clearError: () => void;
}
export interface UseDeepLinkOptions {
onOpenBrowserConnectorSettings?: (params: { enable: boolean }) => void;
}
function parseBrowserConnectorDeepLink(
url: string,
): { enable: boolean } | null {
try {
const parsed = new URL(url);
if (
parsed.protocol !== "lime:" ||
parsed.host !== "connectors" ||
parsed.pathname !== "/browser"
) {
return null;
}
const enableParam = parsed.searchParams.get("enable");
return {
enable: enableParam === "true" || enableParam === "1",
};
} catch {
return null;
}
}
/**
* Deep Link 事件处理 Hook
*
@@ -225,7 +251,12 @@ export interface UseDeepLinkReturn {
*
* @returns Hook 返回值
*/
export function useDeepLink(): UseDeepLinkReturn {
export function useDeepLink(
options?: UseDeepLinkOptions,
): UseDeepLinkReturn {
const onOpenBrowserConnectorSettings =
options?.onOpenBrowserConnectorSettings;
// 状态
const [connectPayload, setConnectPayload] = useState<ConnectPayload | null>(
null,
@@ -426,6 +457,12 @@ export function useDeepLink(): UseDeepLinkReturn {
console.log("[useDeepLink] 收到 Deep Link URL:", urls);
for (const url of urls) {
const connectorParams = parseBrowserConnectorDeepLink(url);
if (connectorParams) {
onOpenBrowserConnectorSettings?.(connectorParams);
continue;
}
if (await handleOauthCallbackUrl(url)) {
continue;
}
@@ -507,7 +544,12 @@ export function useDeepLink(): UseDeepLinkReturn {
}
console.log("[useDeepLink] 已取消 Deep Link 监听器");
};
}, [handleDeepLinkEvent, handleDeepLinkError, handleOauthCallbackUrl]);
}, [
handleDeepLinkEvent,
handleDeepLinkError,
handleOauthCallbackUrl,
onOpenBrowserConnectorSettings,
]);
return {
connectPayload,
@@ -57,6 +57,13 @@ const mockPriorityCommands = new Set<string>([
"get_webview_panels",
"focus_webview_panel",
"navigate_webview_panel",
"get_browser_connector_settings_cmd",
"set_browser_connector_install_root_cmd",
"set_browser_connector_enabled_cmd",
"set_system_connector_enabled_cmd",
"get_browser_connector_install_status_cmd",
"install_browser_connector_extension_cmd",
"open_browser_extensions_page_cmd",
"launch_browser_session",
"launch_browser_profile_runtime_assist_cmd",
"get_browser_action_audit_logs",
@@ -133,6 +133,45 @@
"targets": ["src/lib/api/contextMemory.ts"],
"allowedPaths": []
},
{
"id": "agent-chat-timeline-flow-demo",
"classification": "dead-candidate",
"description": "已删除的聊天时间线演示组件",
"targets": [
"src/components/agent/chat/components/TimelineFlowDemo.tsx"
],
"allowedPaths": []
},
{
"id": "agent-chat-legacy-project-selector",
"classification": "dead-candidate",
"description": "已删除的聊天目录旧项目选择器",
"targets": [
"src/components/agent/chat/components/ProjectSelector.tsx"
],
"allowedPaths": []
},
{
"id": "agent-chat-timeline-inline-item",
"classification": "dead-candidate",
"description": "已删除的聊天时间线内联原型项组件",
"targets": [
"src/components/agent/chat/components/TimelineInlineItem.tsx"
],
"allowedPaths": []
},
{
"id": "agent-chat-task-files-directory-impl",
"classification": "dead-candidate",
"description": "已删除的聊天任务文件旧目录实现",
"targets": [
"src/components/agent/chat/components/TaskFiles/index.ts",
"src/components/agent/chat/components/TaskFiles/types.ts",
"src/components/agent/chat/components/TaskFiles/TaskFileList.tsx",
"src/components/agent/chat/components/TaskFiles/TaskFileItem.tsx"
],
"allowedPaths": []
},
{
"id": "team-subagent-scheduler-hook",
"classification": "compat",
+162 -4
View File
@@ -15,6 +15,14 @@ import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands";
// 模拟的命令处理器
const mockCommands = new Map<string, (...args: any[]) => any>();
const shouldLogMockInfo = import.meta.env.MODE !== "test";
function logMockInfo(...args: Parameters<typeof console.log>) {
if (!shouldLogMockInfo) {
return;
}
console.log(...args);
}
const createDeprecatedCommandMock =
(command: string, replacement: string) => () => {
@@ -73,6 +81,30 @@ type MockBrowserEnvironmentPresetRecord = {
archived_at: string | null;
};
type MockBrowserConnectorSettings = {
enabled: boolean;
install_root_dir: string | null;
install_dir: string | null;
system_connectors: Array<{
id: string;
label: string;
description: string;
enabled: boolean;
available: boolean;
}>;
};
type MockBrowserConnectorInstallStatus = {
status: string;
install_root_dir: string | null;
install_dir: string | null;
bundled_name: string;
bundled_version: string;
installed_name: string | null;
installed_version: string | null;
message: string | null;
};
const mockBrowserProfiles: MockBrowserProfileRecord[] = [
{
id: "browser-profile-general",
@@ -115,6 +147,60 @@ const mockBrowserEnvironmentPresets: MockBrowserEnvironmentPresetRecord[] = [
},
];
let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
enabled: true,
install_root_dir: "/mock/path/to/connectors",
install_dir: "/mock/path/to/connectors/Lime Browser Connector",
system_connectors: [
{
id: "reminders",
label: "提醒事项",
description: "读取和管理你的提醒事项和任务列表。",
enabled: false,
available: true,
},
{
id: "calendar",
label: "日历",
description: "读取和管理你的日历事件。",
enabled: false,
available: true,
},
{
id: "notes",
label: "备忘录",
description: "读取和创建你的备忘录。",
enabled: false,
available: true,
},
{
id: "mail",
label: "邮件",
description: "读取邮件和创建草稿。",
enabled: false,
available: true,
},
{
id: "contacts",
label: "通讯录",
description: "搜索、读取和创建联系人。",
enabled: false,
available: true,
},
],
};
let mockBrowserConnectorInstallStatus: MockBrowserConnectorInstallStatus = {
status: "not_installed",
install_root_dir: "/mock/path/to/connectors",
install_dir: "/mock/path/to/connectors/Lime Browser Connector",
bundled_name: "Lime Browser Connector",
bundled_version: "0.1.0",
installed_name: null,
installed_version: null,
message: "尚未导出浏览器连接器",
};
const now = () => new Date().toISOString();
const mockBrowserSessionStates = new Map<string, any>();
let mockExistingSessionTabs = [
@@ -875,7 +961,7 @@ const defaultMocks: Record<string, any> = {
}),
save_config: (config: any) => {
console.log("[Mock] Config saved:", config);
logMockInfo("[Mock] Config saved:", config);
return { success: true };
},
@@ -885,7 +971,7 @@ const defaultMocks: Record<string, any> = {
get_default_provider: () => "kiro",
set_default_provider: (args: any) => {
const provider = args?.provider ?? args;
console.log("[Mock] Default provider set to:", provider);
logMockInfo("[Mock] Default provider set to:", provider);
return provider;
},
get_available_models: () => [],
@@ -1650,6 +1736,78 @@ const defaultMocks: Record<string, any> = {
controls: [],
pending_commands: [],
}),
get_browser_connector_settings_cmd: () => mockBrowserConnectorSettings,
set_browser_connector_install_root_cmd: (args: any) => {
const installRootDir =
typeof args?.request?.install_root_dir === "string" &&
args.request.install_root_dir.trim()
? args.request.install_root_dir.trim()
: "/mock/path/to/connectors";
mockBrowserConnectorSettings = {
...mockBrowserConnectorSettings,
install_root_dir: installRootDir,
install_dir: `${installRootDir}/Lime Browser Connector`,
};
mockBrowserConnectorInstallStatus = {
...mockBrowserConnectorInstallStatus,
install_root_dir: installRootDir,
install_dir: `${installRootDir}/Lime Browser Connector`,
};
return mockBrowserConnectorSettings;
},
set_browser_connector_enabled_cmd: (args: any) => {
mockBrowserConnectorSettings = {
...mockBrowserConnectorSettings,
enabled: args?.enabled !== false,
};
return mockBrowserConnectorSettings;
},
set_system_connector_enabled_cmd: (args: any) => {
const request = args?.request ?? {};
mockBrowserConnectorSettings = {
...mockBrowserConnectorSettings,
system_connectors: mockBrowserConnectorSettings.system_connectors.map(
(connector) =>
connector.id === request.id
? { ...connector, enabled: request.enabled === true }
: connector,
),
};
return mockBrowserConnectorSettings;
},
get_browser_connector_install_status_cmd: () => mockBrowserConnectorInstallStatus,
install_browser_connector_extension_cmd: (args: any) => {
const installRootDir =
typeof args?.request?.install_root_dir === "string" &&
args.request.install_root_dir.trim()
? args.request.install_root_dir.trim()
: mockBrowserConnectorSettings.install_root_dir ??
"/mock/path/to/connectors";
const installDir = `${installRootDir}/Lime Browser Connector`;
mockBrowserConnectorSettings = {
...mockBrowserConnectorSettings,
install_root_dir: installRootDir,
install_dir: installDir,
};
mockBrowserConnectorInstallStatus = {
...mockBrowserConnectorInstallStatus,
status: "installed",
install_root_dir: installRootDir,
install_dir: installDir,
installed_name: "Lime Browser Connector",
installed_version: mockBrowserConnectorInstallStatus.bundled_version,
message: "已安装最新版本浏览器连接器",
};
return {
install_root_dir: installRootDir,
install_dir: installDir,
bundled_name: "Lime Browser Connector",
bundled_version: mockBrowserConnectorInstallStatus.bundled_version,
installed_version: mockBrowserConnectorInstallStatus.bundled_version,
auto_config_path: `${installDir}/auto_config.json`,
};
},
open_browser_extensions_page_cmd: () => true,
chrome_bridge_execute_command: (args: any) => ({
success: true,
request_id: `mock-${Date.now()}`,
@@ -3369,7 +3527,7 @@ export async function invoke<T = any>(
cmd: string,
args?: Record<string, unknown>,
): Promise<T> {
console.log(`[Mock] invoke: ${cmd}`, args);
logMockInfo(`[Mock] invoke: ${cmd}`, args);
// 检查是否有自定义 mock
if (mockCommands.has(cmd)) {
@@ -3422,7 +3580,7 @@ export function clearMocks() {
export function convertFileSrc(filePath: string, _protocol?: string): string {
// 在 mock 环境中,返回一个占位符或原始路径
// 实际图片无法在 web 环境中显示,但不会导致构建错误
console.log(`[Mock] convertFileSrc: ${filePath}`);
logMockInfo(`[Mock] convertFileSrc: ${filePath}`);
return filePath;
}
+12 -4
View File
@@ -7,6 +7,14 @@ type UnlistenFn = () => void;
// 存储事件监听器
const listeners = new Map<string, Set<EventCallback<any>>>();
const shouldLogMockEventInfo = import.meta.env.MODE !== "test";
function logMockEventInfo(...args: Parameters<typeof console.log>) {
if (!shouldLogMockEventInfo) {
return;
}
console.log(...args);
}
/**
* Mock listen function
@@ -15,7 +23,7 @@ export async function listen<T = any>(
event: string,
handler: EventCallback<T>,
): Promise<UnlistenFn> {
console.log(`[Mock] listen: ${event}`);
logMockEventInfo(`[Mock] listen: ${event}`);
if (!listeners.has(event)) {
listeners.set(event, new Set());
@@ -32,7 +40,7 @@ export async function listen<T = any>(
listeners.delete(event);
}
}
console.log(`[Mock] unlisten: ${event}`);
logMockEventInfo(`[Mock] unlisten: ${event}`);
};
}
@@ -43,7 +51,7 @@ export async function once<T = any>(
event: string,
handler: EventCallback<T>,
): Promise<UnlistenFn> {
console.log(`[Mock] once: ${event}`);
logMockEventInfo(`[Mock] once: ${event}`);
const wrappedHandler = (data: T) => {
handler(data);
@@ -61,7 +69,7 @@ export async function once<T = any>(
* Mock emit function - 用于触发事件
*/
export async function emit(event: string, payload?: any): Promise<void> {
console.log(`[Mock] emit: ${event}`, payload);
logMockEventInfo(`[Mock] emit: ${event}`, payload);
const set = listeners.get(event);
if (set) {
+103
View File
@@ -276,6 +276,46 @@ export interface ChromeBridgeCommandResult {
data?: unknown;
}
export interface SystemConnectorSnapshot {
id: string;
label: string;
description: string;
enabled: boolean;
available: boolean;
}
export interface BrowserConnectorSettingsSnapshot {
enabled: boolean;
install_root_dir?: string | null;
install_dir?: string | null;
system_connectors: SystemConnectorSnapshot[];
}
export interface BrowserConnectorInstallStatus {
status: "not_installed" | "installed" | "update_available" | "broken";
install_root_dir?: string | null;
install_dir?: string | null;
bundled_name: string;
bundled_version: string;
installed_name?: string | null;
installed_version?: string | null;
message?: string | null;
}
export interface BrowserConnectorInstallRequest {
install_root_dir?: string;
profile_key?: string;
}
export interface BrowserConnectorInstallResult {
install_root_dir: string;
install_dir: string;
bundled_name: string;
bundled_version: string;
installed_version: string;
auto_config_path: string;
}
export type BrowserBackendType =
| "aster_compat"
| "lime_extension_bridge"
@@ -798,6 +838,69 @@ export async function getChromeBridgeStatus(): Promise<ChromeBridgeStatusSnapsho
return safeInvoke<ChromeBridgeStatusSnapshot>("get_chrome_bridge_status");
}
export async function getBrowserConnectorSettings(): Promise<BrowserConnectorSettingsSnapshot> {
return safeInvoke<BrowserConnectorSettingsSnapshot>(
"get_browser_connector_settings_cmd",
);
}
export async function setBrowserConnectorInstallRoot(
installRootDir: string,
): Promise<BrowserConnectorSettingsSnapshot> {
return safeInvoke<BrowserConnectorSettingsSnapshot>(
"set_browser_connector_install_root_cmd",
{
request: {
install_root_dir: installRootDir,
},
},
);
}
export async function setBrowserConnectorEnabled(
enabled: boolean,
): Promise<BrowserConnectorSettingsSnapshot> {
return safeInvoke<BrowserConnectorSettingsSnapshot>(
"set_browser_connector_enabled_cmd",
{
enabled,
},
);
}
export async function setSystemConnectorEnabled(request: {
id: string;
enabled: boolean;
}): Promise<BrowserConnectorSettingsSnapshot> {
return safeInvoke<BrowserConnectorSettingsSnapshot>(
"set_system_connector_enabled_cmd",
{
request,
},
);
}
export async function getBrowserConnectorInstallStatus(): Promise<BrowserConnectorInstallStatus> {
return safeInvoke<BrowserConnectorInstallStatus>(
"get_browser_connector_install_status_cmd",
);
}
export async function installBrowserConnectorExtension(
request: BrowserConnectorInstallRequest,
): Promise<BrowserConnectorInstallResult> {
return safeInvoke<BrowserConnectorInstallResult>(
"install_browser_connector_extension_cmd",
{
request,
},
);
}
export async function openBrowserExtensionsPage(): Promise<boolean> {
return safeInvoke<boolean>("open_browser_extensions_page_cmd");
}
/**
* 通过 ChromeBridge 发送测试命令
*/