mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: refresh latest v0.97.0 release payload
This commit is contained in:
+19
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -248,7 +248,7 @@ function renderSettingsContent(
|
||||
case SettingsTabs.ChromeRelay:
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title="Chrome Relay" />
|
||||
<SettingHeader title="连接器" />
|
||||
<ChromeRelaySettings />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 发送测试命令
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user