mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.98.0
This commit is contained in:
@@ -161,6 +161,8 @@ describe("Agent API 治理护栏", () => {
|
||||
provider_preference: "custom-provider",
|
||||
model_preference: "gpt-5.3-codex",
|
||||
thinking_enabled: true,
|
||||
approval_policy: "on-request",
|
||||
sandbox_policy: "workspace-write",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,11 +176,29 @@ describe("Agent API 治理护栏", () => {
|
||||
provider_preference: "custom-provider",
|
||||
model_preference: "gpt-5.3-codex",
|
||||
thinking_enabled: true,
|
||||
approval_policy: "on-request",
|
||||
sandbox_policy: "workspace-write",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updateAgentRuntimeSession 应支持 recent_access_mode", async () => {
|
||||
mockSafeInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await updateAgentRuntimeSession({
|
||||
session_id: "session-runtime-access",
|
||||
recent_access_mode: "full-access",
|
||||
});
|
||||
|
||||
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_update_session", {
|
||||
request: {
|
||||
session_id: "session-runtime-access",
|
||||
recent_access_mode: "full-access",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("respondAgentRuntimeAction 应走统一 action 响应命令", async () => {
|
||||
mockSafeInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -585,6 +605,20 @@ describe("Agent API 治理护栏", () => {
|
||||
timestamp: 1710002000,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: "turn-summary-1",
|
||||
thread_id: "thread-runtime-2",
|
||||
turn_id: "turn-runtime-2",
|
||||
sequence: 1,
|
||||
status: "completed",
|
||||
started_at: "2026-03-29T10:00:00Z",
|
||||
completed_at: "2026-03-29T10:00:02Z",
|
||||
updated_at: "2026-03-29T10:00:02Z",
|
||||
type: "turn_summary",
|
||||
text: "已决定:直接回答优先\n当前请求无需默认升级为搜索或任务。",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getAgentRuntimeSession("session-runtime-2")).resolves.toEqual({
|
||||
@@ -666,6 +700,20 @@ describe("Agent API 治理护栏", () => {
|
||||
timestamp: 1710002000,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: "turn-summary-1",
|
||||
thread_id: "thread-runtime-2",
|
||||
turn_id: "turn-runtime-2",
|
||||
sequence: 1,
|
||||
status: "completed",
|
||||
started_at: "2026-03-29T10:00:00Z",
|
||||
completed_at: "2026-03-29T10:00:02Z",
|
||||
updated_at: "2026-03-29T10:00:02Z",
|
||||
type: "turn_summary",
|
||||
text: "直接回答优先\n当前请求无需默认升级为搜索或任务。",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_get_session", {
|
||||
sessionId: "session-runtime-2",
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export type AsterExecutionStrategy = "react" | "code_orchestrated" | "auto";
|
||||
export type AsterApprovalPolicy =
|
||||
| "never"
|
||||
| "on-request"
|
||||
| "on-failure"
|
||||
| "untrusted";
|
||||
export type AsterSandboxPolicy =
|
||||
| "read-only"
|
||||
| "workspace-write"
|
||||
| "danger-full-access";
|
||||
export type AsterSessionExecutionRuntimeAccessMode =
|
||||
| "read-only"
|
||||
| "current"
|
||||
| "full-access";
|
||||
|
||||
export type AsterSessionExecutionRuntimeSource =
|
||||
| "session"
|
||||
@@ -70,6 +83,7 @@ export interface AsterSessionExecutionRuntime {
|
||||
| "closed"
|
||||
| "not_found"
|
||||
| null;
|
||||
recent_access_mode?: AsterSessionExecutionRuntimeAccessMode | null;
|
||||
recent_preferences?: AsterSessionExecutionRuntimePreferences | null;
|
||||
recent_team_selection?: AsterSessionExecutionRuntimeRecentTeamSelection | null;
|
||||
recent_theme?: string | null;
|
||||
|
||||
@@ -19,6 +19,8 @@ describe("agentProtocol", () => {
|
||||
modelPreference: "gpt-5.4",
|
||||
thinking: true,
|
||||
webSearch: false,
|
||||
approvalPolicy: "on-request",
|
||||
sandboxPolicy: "workspace-write",
|
||||
executionStrategy: "react",
|
||||
autoContinue: {
|
||||
enabled: true,
|
||||
@@ -46,6 +48,8 @@ describe("agentProtocol", () => {
|
||||
provider_preference: "openai",
|
||||
model_preference: "gpt-5.4",
|
||||
thinking_enabled: true,
|
||||
approval_policy: "on-request",
|
||||
sandbox_policy: "workspace-write",
|
||||
execution_strategy: "react",
|
||||
web_search: false,
|
||||
auto_continue: {
|
||||
@@ -88,6 +92,8 @@ describe("agentProtocol", () => {
|
||||
provider_preference: undefined,
|
||||
model_preference: undefined,
|
||||
thinking_enabled: undefined,
|
||||
approval_policy: undefined,
|
||||
sandbox_policy: undefined,
|
||||
execution_strategy: undefined,
|
||||
auto_continue: undefined,
|
||||
},
|
||||
@@ -233,12 +239,44 @@ describe("agentProtocol", () => {
|
||||
type: "runtime_status",
|
||||
status: {
|
||||
phase: "routing",
|
||||
title: "已决定:先深度思考",
|
||||
title: "先深度思考",
|
||||
detail: "先做意图理解,再决定是否搜索。",
|
||||
checkpoints: ["thinking 已开启", "搜索保持候选状态"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
parseAgentEvent({
|
||||
type: "item_updated",
|
||||
item: {
|
||||
id: "turn-summary-1",
|
||||
thread_id: "thread-1",
|
||||
turn_id: "turn-1",
|
||||
sequence: 1,
|
||||
status: "completed",
|
||||
started_at: "2026-03-29T10:00:00Z",
|
||||
completed_at: "2026-03-29T10:00:01Z",
|
||||
updated_at: "2026-03-29T10:00:01Z",
|
||||
type: "turn_summary",
|
||||
text: "已决定:直接回答优先\n当前请求无需默认升级为搜索或任务。",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "item_updated",
|
||||
item: {
|
||||
id: "turn-summary-1",
|
||||
thread_id: "thread-1",
|
||||
turn_id: "turn-1",
|
||||
sequence: 1,
|
||||
status: "completed",
|
||||
started_at: "2026-03-29T10:00:00Z",
|
||||
completed_at: "2026-03-29T10:00:01Z",
|
||||
updated_at: "2026-03-29T10:00:01Z",
|
||||
type: "turn_summary",
|
||||
text: "直接回答优先\n当前请求无需默认升级为搜索或任务。",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
parseAgentEvent({
|
||||
type: "thinking_delta",
|
||||
|
||||
@@ -2,10 +2,16 @@ import {
|
||||
normalizeQueuedTurnSnapshot,
|
||||
type QueuedTurnSnapshot,
|
||||
} from "./queuedTurn";
|
||||
import {
|
||||
normalizeLegacyRuntimeStatusTitle,
|
||||
normalizeLegacyThreadItem,
|
||||
} from "./agentTextNormalization";
|
||||
import type { AsterTurnOutputSchemaRuntime } from "./agentExecutionRuntime";
|
||||
import type {
|
||||
AsterApprovalPolicy,
|
||||
AgentRuntimeSubmitTurnRequest,
|
||||
AsterExecutionStrategy,
|
||||
AsterSandboxPolicy,
|
||||
AutoContinueRequestPayload,
|
||||
ImageInput,
|
||||
} from "./agentRuntime";
|
||||
@@ -520,6 +526,8 @@ export interface AgentUserPreferences {
|
||||
modelPreference?: string;
|
||||
thinking?: boolean;
|
||||
webSearch?: boolean;
|
||||
approvalPolicy?: AsterApprovalPolicy;
|
||||
sandboxPolicy?: AsterSandboxPolicy;
|
||||
executionStrategy?: AsterExecutionStrategy;
|
||||
autoContinue?: AutoContinueRequestPayload;
|
||||
}
|
||||
@@ -626,17 +634,17 @@ export function parseAgentEvent(data: unknown): AgentEvent | null {
|
||||
case "item_started":
|
||||
return {
|
||||
type: "item_started",
|
||||
item: event.item as AgentThreadItem,
|
||||
item: normalizeLegacyThreadItem(event.item as AgentThreadItem),
|
||||
};
|
||||
case "item_updated":
|
||||
return {
|
||||
type: "item_updated",
|
||||
item: event.item as AgentThreadItem,
|
||||
item: normalizeLegacyThreadItem(event.item as AgentThreadItem),
|
||||
};
|
||||
case "item_completed":
|
||||
return {
|
||||
type: "item_completed",
|
||||
item: event.item as AgentThreadItem,
|
||||
item: normalizeLegacyThreadItem(event.item as AgentThreadItem),
|
||||
};
|
||||
case "turn_completed":
|
||||
return {
|
||||
@@ -790,7 +798,10 @@ export function parseAgentEvent(data: unknown): AgentEvent | null {
|
||||
phase === "failed"
|
||||
? phase
|
||||
: "routing",
|
||||
title: typeof status?.title === "string" ? status.title : "",
|
||||
title:
|
||||
typeof status?.title === "string"
|
||||
? normalizeLegacyRuntimeStatusTitle(status.title)
|
||||
: "",
|
||||
detail: typeof status?.detail === "string" ? status.detail : "",
|
||||
checkpoints: Array.isArray(status?.checkpoints)
|
||||
? (status?.checkpoints as string[])
|
||||
@@ -931,6 +942,8 @@ export function createSubmitTurnRequestFromAgentOp(
|
||||
provider_preference: preferences?.providerPreference,
|
||||
model_preference: preferences?.modelPreference,
|
||||
thinking_enabled: preferences?.thinking,
|
||||
approval_policy: preferences?.approvalPolicy,
|
||||
sandbox_policy: preferences?.sandboxPolicy,
|
||||
execution_strategy: preferences?.executionStrategy,
|
||||
web_search: preferences?.webSearch,
|
||||
auto_continue: preferences?.autoContinue,
|
||||
|
||||
@@ -6,14 +6,31 @@
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { logAgentDebug } from "@/lib/agentDebug";
|
||||
import type {
|
||||
RunSiteAdapterRequest,
|
||||
SavedSiteAdapterContent,
|
||||
SaveSiteAdapterResultRequest,
|
||||
SiteAdapterCatalogStatus,
|
||||
SiteAdapterDefinition,
|
||||
SiteAdapterImportResult,
|
||||
SiteAdapterImportYamlBundleRequest,
|
||||
SiteAdapterLaunchReadinessRequest,
|
||||
SiteAdapterLaunchReadinessResult,
|
||||
SiteAdapterRecommendation,
|
||||
SiteAdapterRunResult,
|
||||
} from "@/lib/webview-api";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentThreadItem,
|
||||
AgentThreadTurn,
|
||||
} from "./agentProtocol";
|
||||
import { normalizeLegacyThreadItem } from "./agentTextNormalization";
|
||||
import type {
|
||||
AsterApprovalPolicy,
|
||||
AsterExecutionStrategy,
|
||||
AsterSandboxPolicy,
|
||||
AsterSessionExecutionRuntime,
|
||||
AsterSessionExecutionRuntimeAccessMode,
|
||||
AsterSessionExecutionRuntimePreferences,
|
||||
AsterSessionExecutionRuntimeRecentTeamSelection,
|
||||
} from "./agentExecutionRuntime";
|
||||
@@ -24,8 +41,11 @@ import {
|
||||
|
||||
export type { QueuedTurnSnapshot } from "./queuedTurn";
|
||||
export type {
|
||||
AsterApprovalPolicy,
|
||||
AsterExecutionStrategy,
|
||||
AsterSandboxPolicy,
|
||||
AsterSessionExecutionRuntime,
|
||||
AsterSessionExecutionRuntimeAccessMode,
|
||||
AsterSessionExecutionRuntimePreferences,
|
||||
AsterSessionExecutionRuntimeRecentTeamRole,
|
||||
AsterSessionExecutionRuntimeRecentTeamSelection,
|
||||
@@ -882,6 +902,8 @@ export interface AgentTurnConfigSnapshot {
|
||||
provider_preference?: string;
|
||||
model_preference?: string;
|
||||
thinking_enabled?: boolean;
|
||||
approval_policy?: AsterApprovalPolicy;
|
||||
sandbox_policy?: AsterSandboxPolicy;
|
||||
execution_strategy?: AsterExecutionStrategy;
|
||||
web_search?: boolean;
|
||||
auto_continue?: AutoContinueRequestPayload;
|
||||
@@ -968,6 +990,7 @@ export interface AgentRuntimeUpdateSessionRequest {
|
||||
provider_name?: string;
|
||||
model_name?: string;
|
||||
execution_strategy?: AsterExecutionStrategy;
|
||||
recent_access_mode?: AsterSessionExecutionRuntimeAccessMode;
|
||||
recent_preferences?: AsterSessionExecutionRuntimePreferences;
|
||||
recent_team_selection?: AsterSessionExecutionRuntimeRecentTeamSelection;
|
||||
}
|
||||
@@ -1355,6 +1378,11 @@ export async function getAgentRuntimeSession(
|
||||
const normalizedDetail = detail as AsterSessionDetail | null | undefined;
|
||||
return {
|
||||
...(detail as AsterSessionDetail),
|
||||
items: Array.isArray(normalizedDetail?.items)
|
||||
? normalizedDetail.items.map((item) =>
|
||||
normalizeLegacyThreadItem(item as AgentThreadItem),
|
||||
)
|
||||
: normalizedDetail?.items,
|
||||
queued_turns: normalizeQueuedTurnSnapshots(normalizedDetail?.queued_turns),
|
||||
thread_read: normalizeThreadReadModel(normalizedDetail?.thread_read),
|
||||
};
|
||||
@@ -1473,6 +1501,90 @@ export async function deleteAgentRuntimeSession(
|
||||
return await safeInvoke("agent_runtime_delete_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function siteListAdapters(): Promise<SiteAdapterDefinition[]> {
|
||||
return await safeInvoke("site_list_adapters");
|
||||
}
|
||||
|
||||
export async function siteRecommendAdapters(
|
||||
limit?: number,
|
||||
): Promise<SiteAdapterRecommendation[]> {
|
||||
return await safeInvoke("site_recommend_adapters", {
|
||||
request: { limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteSearchAdapters(
|
||||
query: string,
|
||||
): Promise<SiteAdapterDefinition[]> {
|
||||
return await safeInvoke("site_search_adapters", {
|
||||
request: { query },
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteGetAdapterInfo(
|
||||
name: string,
|
||||
): Promise<SiteAdapterDefinition> {
|
||||
return await safeInvoke("site_get_adapter_info", {
|
||||
request: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteGetAdapterLaunchReadiness(
|
||||
request: SiteAdapterLaunchReadinessRequest,
|
||||
): Promise<SiteAdapterLaunchReadinessResult> {
|
||||
return await safeInvoke("site_get_adapter_launch_readiness", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteGetAdapterCatalogStatus(): Promise<SiteAdapterCatalogStatus> {
|
||||
return await safeInvoke("site_get_adapter_catalog_status");
|
||||
}
|
||||
|
||||
export async function siteApplyAdapterCatalogBootstrap(
|
||||
payload: unknown,
|
||||
): Promise<SiteAdapterCatalogStatus> {
|
||||
return await safeInvoke("site_apply_adapter_catalog_bootstrap", {
|
||||
request: {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteClearAdapterCatalogCache(): Promise<SiteAdapterCatalogStatus> {
|
||||
return await safeInvoke("site_clear_adapter_catalog_cache");
|
||||
}
|
||||
|
||||
export async function siteImportAdapterYamlBundle(
|
||||
request: SiteAdapterImportYamlBundleRequest,
|
||||
): Promise<SiteAdapterImportResult> {
|
||||
return await safeInvoke("site_import_adapter_yaml_bundle", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteRunAdapter(
|
||||
request: RunSiteAdapterRequest,
|
||||
): Promise<SiteAdapterRunResult> {
|
||||
return await safeInvoke("site_run_adapter", { request });
|
||||
}
|
||||
|
||||
export async function siteDebugRunAdapter(
|
||||
request: RunSiteAdapterRequest,
|
||||
): Promise<SiteAdapterRunResult> {
|
||||
return await safeInvoke("site_debug_run_adapter", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteSaveAdapterResult(
|
||||
request: SaveSiteAdapterResultRequest,
|
||||
): Promise<SavedSiteAdapterContent> {
|
||||
return await safeInvoke("site_save_adapter_result", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Agent(初始化原生 Agent)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
dedupeAgentRuntimeEventNames,
|
||||
getAgentSubagentStatusEventName,
|
||||
getAgentSubagentStreamEventName,
|
||||
listenAgentRuntimeEvent,
|
||||
listenAgentSubagentStatus,
|
||||
listenAgentSubagentStream,
|
||||
} from "./agentRuntimeEvents";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("agentRuntimeEvents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应生成并去重子代理运行时事件名", () => {
|
||||
expect(getAgentSubagentStatusEventName("session-1")).toBe(
|
||||
"agent_subagent_status:session-1",
|
||||
);
|
||||
expect(getAgentSubagentStreamEventName("session-1")).toBe(
|
||||
"agent_subagent_stream:session-1",
|
||||
);
|
||||
expect(
|
||||
dedupeAgentRuntimeEventNames([
|
||||
"agent_subagent_status:session-1",
|
||||
null,
|
||||
"agent_subagent_status:session-1",
|
||||
undefined,
|
||||
"agent_subagent_status:session-2",
|
||||
]),
|
||||
).toEqual([
|
||||
"agent_subagent_status:session-1",
|
||||
"agent_subagent_status:session-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("应代理子代理状态与流事件监听", async () => {
|
||||
vi.mocked(safeListen)
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({
|
||||
payload: {
|
||||
type: "subagent_status_changed",
|
||||
session_id: "session-1",
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_id: "tool-1",
|
||||
tool_name: "browser_snapshot",
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const statusListener = vi.fn();
|
||||
const streamListener = vi.fn();
|
||||
|
||||
await listenAgentSubagentStatus("session-1", statusListener);
|
||||
await listenAgentSubagentStream("session-1", streamListener);
|
||||
|
||||
expect(safeListen).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"agent_subagent_status:session-1",
|
||||
statusListener,
|
||||
);
|
||||
expect(safeListen).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"agent_subagent_stream:session-1",
|
||||
streamListener,
|
||||
);
|
||||
expect(statusListener).toHaveBeenCalledTimes(1);
|
||||
expect(streamListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应代理通用 runtime 事件监听", async () => {
|
||||
vi.mocked(safeListen).mockImplementationOnce(async (_event, handler) => {
|
||||
handler({
|
||||
payload: {
|
||||
type: "text_delta",
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
await listenAgentRuntimeEvent("agent_turn_stream:session-1", listener);
|
||||
|
||||
expect(safeListen).toHaveBeenCalledWith(
|
||||
"agent_turn_stream:session-1",
|
||||
listener,
|
||||
);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { AgentEvent } from "@/lib/api/agentProtocol";
|
||||
|
||||
export function getAgentSubagentStatusEventName(sessionId: string): string {
|
||||
return `agent_subagent_status:${sessionId}`;
|
||||
}
|
||||
|
||||
export function getAgentSubagentStreamEventName(sessionId: string): string {
|
||||
return `agent_subagent_stream:${sessionId}`;
|
||||
}
|
||||
|
||||
export function dedupeAgentRuntimeEventNames(
|
||||
eventNames: Array<string | null | undefined>,
|
||||
): string[] {
|
||||
return eventNames.filter((value, index, values): value is string => {
|
||||
return Boolean(value) && values.indexOf(value) === index;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listenAgentRuntimeEvent(
|
||||
eventName: string,
|
||||
handler: (event: { payload: AgentEvent | unknown }) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return safeListen<AgentEvent>(eventName, handler);
|
||||
}
|
||||
|
||||
export async function listenAgentSubagentStatus(
|
||||
sessionId: string,
|
||||
handler: (event: { payload: AgentEvent | unknown }) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listenAgentRuntimeEvent(getAgentSubagentStatusEventName(sessionId), handler);
|
||||
}
|
||||
|
||||
export async function listenAgentSubagentStream(
|
||||
sessionId: string,
|
||||
handler: (event: { payload: AgentEvent | unknown }) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listenAgentRuntimeEvent(getAgentSubagentStreamEventName(sessionId), handler);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const LEGACY_DECISION_PREFIX_RE = /^已决定[::]\s*/;
|
||||
|
||||
export function normalizeLegacyRuntimeStatusTitle(title: string): string {
|
||||
return title.replace(LEGACY_DECISION_PREFIX_RE, "").trim();
|
||||
}
|
||||
|
||||
export function normalizeLegacyTurnSummaryText(text: string): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [firstLine = "", ...rest] = normalized.split(/\r?\n/);
|
||||
const normalizedFirstLine = normalizeLegacyRuntimeStatusTitle(firstLine);
|
||||
|
||||
if (rest.length === 0) {
|
||||
return normalizedFirstLine;
|
||||
}
|
||||
|
||||
return [normalizedFirstLine, ...rest].filter((line, index) => index > 0 || line).join("\n");
|
||||
}
|
||||
|
||||
export function normalizeLegacyThreadItem<T extends { type?: unknown; text?: unknown }>(
|
||||
item: T,
|
||||
): T {
|
||||
if (item.type !== "turn_summary" || typeof item.text !== "string") {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
text: normalizeLegacyTurnSummaryText(item.text),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLegacyThreadItems<
|
||||
T extends { type?: unknown; text?: unknown },
|
||||
>(items: T[]): T[] {
|
||||
return items.map((item) => normalizeLegacyThreadItem(item));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import type { Config, EnvironmentPreview } from "./appConfigTypes";
|
||||
|
||||
const APP_CONFIG_CHANGE_STAMP_KEY = "lime.app-config.changed-at";
|
||||
const APP_CONFIG_CHANGED_EVENT = "lime:app-config-changed";
|
||||
|
||||
let configCache: Config | null = null;
|
||||
let configLoadingPromise: Promise<Config> | null = null;
|
||||
@@ -12,6 +13,7 @@ export type {
|
||||
CrashReportingConfig,
|
||||
ChatAppearanceConfig,
|
||||
ContentCreatorConfig,
|
||||
DeveloperConfig,
|
||||
EnvironmentConfig,
|
||||
EnvironmentPreview,
|
||||
EnvironmentPreviewEntry,
|
||||
@@ -62,6 +64,12 @@ function markAppConfigChanged(): string | null {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(APP_CONFIG_CHANGED_EVENT));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return nextStamp;
|
||||
@@ -77,6 +85,29 @@ export function invalidateAppConfigCache(): void {
|
||||
invalidateConfigCache();
|
||||
}
|
||||
|
||||
export function subscribeAppConfigChanged(listener: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handleCustomChange = () => {
|
||||
listener();
|
||||
};
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === APP_CONFIG_CHANGE_STAMP_KEY) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(APP_CONFIG_CHANGED_EVENT, handleCustomChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(APP_CONFIG_CHANGED_EVENT, handleCustomChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConfig(
|
||||
options: GetConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface ChatAppearanceConfig {
|
||||
append_selected_text_to_recommendation?: boolean;
|
||||
}
|
||||
|
||||
export interface DeveloperConfig {
|
||||
workspace_harness_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceConfig {
|
||||
tts_service?: "openai" | "azure" | "google" | "edge" | "macos";
|
||||
stt_service?: "openai" | "azure" | "google" | "whisper";
|
||||
@@ -237,6 +241,7 @@ export interface Config {
|
||||
voice?: VoiceConfig;
|
||||
image_gen?: ImageGenConfig;
|
||||
user_profile?: UserProfile;
|
||||
developer?: DeveloperConfig;
|
||||
gateway?: GatewayConfig;
|
||||
channels?: ChannelsConfig;
|
||||
crash_reporting?: CrashReportingConfig;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { openPathWithDefaultApp, revealPathInFinder } from "./fileSystem";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
convertLocalFileSrc,
|
||||
openPathWithDefaultApp,
|
||||
revealPathInFinder,
|
||||
} from "./fileSystem";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
convertFileSrc: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("fileSystem API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -30,4 +39,11 @@ describe("fileSystem API", () => {
|
||||
path: "/tmp/demo.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("应代理 convertFileSrc", () => {
|
||||
vi.mocked(convertFileSrc).mockReturnValueOnce("asset://demo.txt");
|
||||
|
||||
expect(convertLocalFileSrc("/tmp/demo.txt")).toBe("asset://demo.txt");
|
||||
expect(convertFileSrc).toHaveBeenCalledWith("/tmp/demo.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export async function revealPathInFinder(path: string): Promise<void> {
|
||||
@@ -7,3 +8,7 @@ export async function revealPathInFinder(path: string): Promise<void> {
|
||||
export async function openPathWithDefaultApp(path: string): Promise<void> {
|
||||
await safeInvoke("open_with_default_app", { path });
|
||||
}
|
||||
|
||||
export function convertLocalFileSrc(path: string): string {
|
||||
return convertFileSrc(path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export type AspectRatioFilter = "all" | "landscape" | "portrait" | "square";
|
||||
|
||||
export interface PixabaySearchRequest {
|
||||
query: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
orientation?: string;
|
||||
}
|
||||
|
||||
export interface PixabaySearchResponse {
|
||||
total: number;
|
||||
total_hits?: number;
|
||||
totalHits?: number;
|
||||
hits: Array<{
|
||||
id: number;
|
||||
preview_url?: string;
|
||||
previewUrl?: string;
|
||||
large_image_url?: string;
|
||||
largeImageUrl?: string;
|
||||
image_width?: number;
|
||||
imageWidth?: number;
|
||||
image_height?: number;
|
||||
imageHeight?: number;
|
||||
tags: string;
|
||||
page_url?: string;
|
||||
pageUrl?: string;
|
||||
user: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WebImageSearchRequest {
|
||||
query: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
aspect?: AspectRatioFilter;
|
||||
}
|
||||
|
||||
export interface WebImageSearchResponse {
|
||||
total: number;
|
||||
totalResults?: number;
|
||||
photos?: Array<{
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
alt?: string;
|
||||
src: {
|
||||
medium?: string;
|
||||
small?: string;
|
||||
tiny?: string;
|
||||
large?: string;
|
||||
large2x?: string;
|
||||
original?: string;
|
||||
landscape?: string;
|
||||
portrait?: string;
|
||||
};
|
||||
}>;
|
||||
provider: string;
|
||||
hits: Array<{
|
||||
id: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnailUrl?: string;
|
||||
content_url?: string;
|
||||
contentUrl?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
host_page_url?: string;
|
||||
hostPageUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function searchPixabayImages(
|
||||
req: PixabaySearchRequest,
|
||||
): Promise<PixabaySearchResponse> {
|
||||
return safeInvoke<PixabaySearchResponse>("search_pixabay_images", { req });
|
||||
}
|
||||
|
||||
export async function searchWebImages(
|
||||
req: WebImageSearchRequest,
|
||||
): Promise<WebImageSearchResponse> {
|
||||
return safeInvoke<WebImageSearchResponse>("search_web_images", { req });
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { notificationService } from "@/lib/notificationService";
|
||||
import { showSystemNotification } from "./notification";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
vi.mock("@/lib/notificationService", () => ({
|
||||
notificationService: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("notification API", () => {
|
||||
@@ -12,7 +14,7 @@ describe("notification API", () => {
|
||||
});
|
||||
|
||||
it("应代理系统通知命令", async () => {
|
||||
vi.mocked(safeInvoke).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(notificationService.notify).mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
showSystemNotification({
|
||||
@@ -21,5 +23,10 @@ describe("notification API", () => {
|
||||
icon: "icon",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(notificationService.notify).toHaveBeenCalledWith({
|
||||
title: "title",
|
||||
body: "body",
|
||||
type: "info",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface OemCloudBootstrapResponse {
|
||||
};
|
||||
providerOffersSummary: OemCloudProviderOfferSummary[];
|
||||
providerPreference: OemCloudProviderPreference;
|
||||
skillCatalog?: unknown;
|
||||
serviceSkillCatalog?: unknown;
|
||||
siteAdapterCatalog?: unknown;
|
||||
sceneCatalog?: Array<{ id: string }>;
|
||||
@@ -812,6 +813,7 @@ function parseBootstrap(value: unknown): OemCloudBootstrapResponse {
|
||||
? value.providerOffersSummary.map(parseProviderOfferSummary)
|
||||
: [],
|
||||
providerPreference: parseProviderPreference(value.providerPreference),
|
||||
skillCatalog: value.skillCatalog,
|
||||
serviceSkillCatalog: value.serviceSkillCatalog,
|
||||
siteAdapterCatalog: value.siteAdapterCatalog ?? value.site_adapter_catalog,
|
||||
sceneCatalog: Array.isArray(value.sceneCatalog)
|
||||
|
||||
@@ -6,6 +6,16 @@ import type {
|
||||
PersonaUpdate,
|
||||
} from "@/types/persona";
|
||||
|
||||
export interface GeneratedPersonaDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
tone: string;
|
||||
targetAudience: string;
|
||||
forbiddenWords: string[];
|
||||
preferredWords: string[];
|
||||
}
|
||||
|
||||
export async function listPersonas(projectId: string): Promise<Persona[]> {
|
||||
return safeInvoke<Persona[]>("list_personas", { projectId });
|
||||
}
|
||||
@@ -43,3 +53,9 @@ export async function setDefaultPersona(
|
||||
export async function listPersonaTemplates(): Promise<PersonaTemplate[]> {
|
||||
return safeInvoke<PersonaTemplate[]>("list_persona_templates");
|
||||
}
|
||||
|
||||
export async function generatePersona(
|
||||
prompt: string,
|
||||
): Promise<GeneratedPersonaDraft> {
|
||||
return safeInvoke<GeneratedPersonaDraft>("generate_persona", { prompt });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
cancelPluginTask,
|
||||
disablePlugin,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getPluginStatus,
|
||||
getPlugins,
|
||||
getPluginTask,
|
||||
listenPluginInstallProgress,
|
||||
listenPluginTaskEvent,
|
||||
listInstalledPlugins,
|
||||
listPluginTasks,
|
||||
reloadPlugins,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
safeListen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("plugins API", () => {
|
||||
@@ -69,4 +72,35 @@ describe("plugins API", () => {
|
||||
await expect(uninstallPlugin("plugin-1")).resolves.toBe(true);
|
||||
await expect(cancelPluginTask("task-1")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("应代理插件事件监听", async () => {
|
||||
vi.mocked(safeListen)
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({
|
||||
payload: {
|
||||
stage: "downloading",
|
||||
percent: 42,
|
||||
message: "下载中",
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: undefined });
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const progressListener = vi.fn();
|
||||
const taskListener = vi.fn();
|
||||
|
||||
await listenPluginInstallProgress(progressListener);
|
||||
await listenPluginTaskEvent(taskListener);
|
||||
|
||||
expect(progressListener).toHaveBeenCalledWith({
|
||||
stage: "downloading",
|
||||
percent: 42,
|
||||
message: "下载中",
|
||||
});
|
||||
expect(taskListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-1
@@ -1,4 +1,14 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface PluginInstallProgress {
|
||||
stage: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const PLUGIN_INSTALL_PROGRESS_EVENT = "plugin-install-progress";
|
||||
export const PLUGIN_TASK_EVENT = "plugin-task-event";
|
||||
|
||||
export interface ListPluginTasksParams {
|
||||
taskState?: string | null;
|
||||
@@ -54,3 +64,22 @@ export async function uninstallPlugin(pluginId: string): Promise<boolean> {
|
||||
export async function cancelPluginTask(taskId: string): Promise<boolean> {
|
||||
return safeInvoke<boolean>("cancel_plugin_task", { taskId });
|
||||
}
|
||||
|
||||
export async function listenPluginInstallProgress(
|
||||
handler: (payload: PluginInstallProgress) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return safeListen<PluginInstallProgress>(
|
||||
PLUGIN_INSTALL_PROGRESS_EVENT,
|
||||
(event) => {
|
||||
handler(event.payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listenPluginTaskEvent(
|
||||
handler: () => void | Promise<void>,
|
||||
): Promise<UnlistenFn> {
|
||||
return safeListen(PLUGIN_TASK_EVENT, () => {
|
||||
void handler();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import type { ProjectContext } from "@/types/context";
|
||||
|
||||
export async function getProjectContext(
|
||||
projectId: string,
|
||||
): Promise<ProjectContext> {
|
||||
return safeInvoke<ProjectContext>("get_project_context", { projectId });
|
||||
}
|
||||
|
||||
export async function buildProjectSystemPrompt(
|
||||
projectId: string,
|
||||
): Promise<string> {
|
||||
return safeInvoke<string>("build_project_system_prompt", { projectId });
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
onAntigravityAuthUrl,
|
||||
onClaudeOAuthAuthUrl,
|
||||
onCodexAuthUrl,
|
||||
onGeminiAuthUrl,
|
||||
} from "./providerAuthEvents";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("providerAuthEvents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理各 Provider OAuth 授权 URL 事件", async () => {
|
||||
vi.mocked(safeListen)
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: { auth_url: "https://anti.example/auth" } });
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: { auth_url: "https://claude.example/auth" } });
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: { auth_url: "https://codex.example/auth" } });
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({
|
||||
payload: {
|
||||
auth_url: "https://gemini.example/auth",
|
||||
session_id: "session-1",
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const antiListener = vi.fn();
|
||||
const claudeListener = vi.fn();
|
||||
const codexListener = vi.fn();
|
||||
const geminiListener = vi.fn();
|
||||
|
||||
await onAntigravityAuthUrl(antiListener);
|
||||
await onClaudeOAuthAuthUrl(claudeListener);
|
||||
await onCodexAuthUrl(codexListener);
|
||||
await onGeminiAuthUrl(geminiListener);
|
||||
|
||||
expect(antiListener).toHaveBeenCalledWith({
|
||||
auth_url: "https://anti.example/auth",
|
||||
});
|
||||
expect(claudeListener).toHaveBeenCalledWith({
|
||||
auth_url: "https://claude.example/auth",
|
||||
});
|
||||
expect(codexListener).toHaveBeenCalledWith({
|
||||
auth_url: "https://codex.example/auth",
|
||||
});
|
||||
expect(geminiListener).toHaveBeenCalledWith({
|
||||
auth_url: "https://gemini.example/auth",
|
||||
session_id: "session-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface OAuthUrlEventPayload {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
export interface GeminiOAuthUrlEventPayload extends OAuthUrlEventPayload {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export const ANTIGRAVITY_AUTH_URL_EVENT = "antigravity-auth-url";
|
||||
export const CLAUDE_OAUTH_AUTH_URL_EVENT = "claude-oauth-auth-url";
|
||||
export const CODEX_AUTH_URL_EVENT = "codex-auth-url";
|
||||
export const GEMINI_AUTH_URL_EVENT = "gemini-auth-url";
|
||||
|
||||
async function onProviderAuthEvent<TPayload>(
|
||||
eventName: string,
|
||||
callback: (payload: TPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return safeListen<TPayload>(eventName, (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onAntigravityAuthUrl(
|
||||
callback: (payload: OAuthUrlEventPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return onProviderAuthEvent(ANTIGRAVITY_AUTH_URL_EVENT, callback);
|
||||
}
|
||||
|
||||
export async function onClaudeOAuthAuthUrl(
|
||||
callback: (payload: OAuthUrlEventPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return onProviderAuthEvent(CLAUDE_OAUTH_AUTH_URL_EVENT, callback);
|
||||
}
|
||||
|
||||
export async function onCodexAuthUrl(
|
||||
callback: (payload: OAuthUrlEventPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return onProviderAuthEvent(CODEX_AUTH_URL_EVENT, callback);
|
||||
}
|
||||
|
||||
export async function onGeminiAuthUrl(
|
||||
callback: (payload: GeminiOAuthUrlEventPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return onProviderAuthEvent(GEMINI_AUTH_URL_EVENT, callback);
|
||||
}
|
||||
@@ -66,23 +66,56 @@ describe("serviceSkills API", () => {
|
||||
expect(catalog.items.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
catalog.items.find((item) => item.id === "github-repo-radar")
|
||||
?.siteCapabilityBinding,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
adapterName: "github/search",
|
||||
autoRun: true,
|
||||
requireAttachedSession: true,
|
||||
saveMode: "current_content",
|
||||
skillType: "site",
|
||||
outputDestination:
|
||||
"采集结果会优先写回当前内容;如果当前内容不可用,再沉淀为项目资源。",
|
||||
triggerHints: expect.arrayContaining(["需要围绕某个技术主题快速找 GitHub 仓库线索时使用。"]),
|
||||
setupRequirements: expect.arrayContaining(["需要浏览器里已有 GitHub 登录态。"]),
|
||||
examples: expect.arrayContaining([
|
||||
"帮我查一批和 MCP browser automation 相关的 GitHub 仓库。",
|
||||
]),
|
||||
siteCapabilityBinding: expect.objectContaining({
|
||||
adapterName: "github/search",
|
||||
autoRun: true,
|
||||
requireAttachedSession: true,
|
||||
saveMode: "current_content",
|
||||
}),
|
||||
skillBundle: expect.objectContaining({
|
||||
name: "github-repo-radar",
|
||||
standardCompliance: expect.objectContaining({
|
||||
isStandard: true,
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
Lime_skill_type: "site",
|
||||
Lime_site_adapter: "github/search",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
catalog.items.find((item) => item.id === "carousel-post-replication"),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
skillType: "service",
|
||||
entryHint: expect.any(String),
|
||||
aliases: expect.arrayContaining(["轮播帖"]),
|
||||
usageGuidelines: expect.arrayContaining([
|
||||
"适合先产出一版结构化草稿,再在当前工作区继续精修。",
|
||||
]),
|
||||
surfaceScopes: expect.arrayContaining(["mention"]),
|
||||
promptTemplateKey: "replication",
|
||||
skillBundle: expect.objectContaining({
|
||||
name: "carousel-post-replication",
|
||||
standardCompliance: expect.objectContaining({
|
||||
isStandard: true,
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
Lime_skill_type: "service",
|
||||
Lime_prompt_template_key: "replication",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -104,6 +137,31 @@ describe("serviceSkills API", () => {
|
||||
expect(skills[0]?.id).toBe("tenant-remote-skill");
|
||||
});
|
||||
|
||||
it("旧格式远端目录缺少 skillBundle 时应自动补齐标准摘要", async () => {
|
||||
const remoteCatalog = buildRemoteCatalog();
|
||||
if (remoteCatalog.items[0]) {
|
||||
delete remoteCatalog.items[0].skillBundle;
|
||||
}
|
||||
|
||||
saveServiceSkillCatalog(remoteCatalog, "bootstrap_sync");
|
||||
|
||||
const catalog = await getServiceSkillCatalog();
|
||||
|
||||
expect(catalog.items[0]?.skillBundle).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "carousel-post-replication",
|
||||
standardCompliance: expect.objectContaining({
|
||||
isStandard: true,
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
Lime_skill_type: "service",
|
||||
Lime_output_destination:
|
||||
"结果会写回当前工作区中的内容草稿,方便继续改写和发布。",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("清空缓存后应恢复到 seeded catalog", async () => {
|
||||
saveServiceSkillCatalog(buildRemoteCatalog(), "bootstrap_sync");
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
|
||||
export type ServiceSkillSource = "cloud_catalog" | "local_custom";
|
||||
|
||||
export type ServiceSkillType = "service" | "site" | "prompt";
|
||||
|
||||
export type ServiceSkillRunnerType = "instant" | "scheduled" | "managed";
|
||||
|
||||
export type ServiceSkillExecutionLocation = "client_default" | "cloud_required";
|
||||
@@ -80,26 +82,56 @@ export interface ServiceSkillSiteCapabilityBinding {
|
||||
suggestedTitleTemplate?: string;
|
||||
}
|
||||
|
||||
export interface ServiceSkillBundleResourceSummary {
|
||||
hasScripts: boolean;
|
||||
hasReferences: boolean;
|
||||
hasAssets: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceSkillBundleStandardCompliance {
|
||||
isStandard: boolean;
|
||||
validationErrors?: string[];
|
||||
deprecatedFields?: string[];
|
||||
}
|
||||
|
||||
export interface ServiceSkillBundleSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
metadata?: Record<string, string>;
|
||||
allowedTools?: string[];
|
||||
resourceSummary: ServiceSkillBundleResourceSummary;
|
||||
standardCompliance: ServiceSkillBundleStandardCompliance;
|
||||
}
|
||||
|
||||
export interface ServiceSkillItem {
|
||||
id: string;
|
||||
skillKey?: string;
|
||||
skillType?: ServiceSkillType;
|
||||
title: string;
|
||||
summary: string;
|
||||
entryHint?: string;
|
||||
aliases?: string[];
|
||||
category: string;
|
||||
outputHint: string;
|
||||
triggerHints?: string[];
|
||||
source: ServiceSkillSource;
|
||||
runnerType: ServiceSkillRunnerType;
|
||||
defaultExecutorBinding: ServiceSkillExecutorBinding;
|
||||
executionLocation: ServiceSkillExecutionLocation;
|
||||
defaultArtifactKind?: ServiceSkillArtifactKind;
|
||||
readinessRequirements?: ServiceSkillReadinessRequirements;
|
||||
usageGuidelines?: string[];
|
||||
setupRequirements?: string[];
|
||||
examples?: string[];
|
||||
outputDestination?: string;
|
||||
siteCapabilityBinding?: ServiceSkillSiteCapabilityBinding;
|
||||
slotSchema: ServiceSkillSlotDefinition[];
|
||||
surfaceScopes?: ServiceSkillSurfaceScope[];
|
||||
promptTemplateKey?: ServiceSkillPromptTemplateKey;
|
||||
themeTarget?: string;
|
||||
skillBundle?: ServiceSkillBundleSummary;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@@ -141,6 +173,12 @@ const SERVICE_SKILL_SURFACE_SCOPES: ServiceSkillSurfaceScope[] = [
|
||||
"workspace",
|
||||
];
|
||||
|
||||
const SERVICE_SKILL_TYPES: ServiceSkillType[] = [
|
||||
"service",
|
||||
"site",
|
||||
"prompt",
|
||||
];
|
||||
|
||||
const SERVICE_SKILL_PROMPT_TEMPLATE_KEYS: ServiceSkillPromptTemplateKey[] = [
|
||||
"generic",
|
||||
"replication",
|
||||
@@ -148,6 +186,189 @@ const SERVICE_SKILL_PROMPT_TEMPLATE_KEYS: ServiceSkillPromptTemplateKey[] = [
|
||||
"account_growth",
|
||||
];
|
||||
|
||||
const MAX_SERVICE_SKILL_BUNDLE_NAME_LENGTH = 64;
|
||||
const MAX_SERVICE_SKILL_BUNDLE_DESCRIPTION_LENGTH = 1024;
|
||||
const MAX_SERVICE_SKILL_BUNDLE_COMPATIBILITY_LENGTH = 500;
|
||||
|
||||
function trimToUndefined(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function toServiceSkillBundleMetadata(
|
||||
item: ServiceSkillItem,
|
||||
): Record<string, string> | undefined {
|
||||
const metadata: Record<string, string> = {};
|
||||
const skillType =
|
||||
item.skillType ??
|
||||
(item.defaultExecutorBinding === "browser_assist" || item.siteCapabilityBinding
|
||||
? "site"
|
||||
: "service");
|
||||
const outputDestination = resolveDerivedServiceSkillOutputDestination(item);
|
||||
|
||||
const candidates: Record<string, string | undefined> = {
|
||||
Lime_skill_type: skillType,
|
||||
Lime_category: trimToUndefined(item.category),
|
||||
Lime_runner_type: item.runnerType,
|
||||
Lime_execution_location: item.executionLocation,
|
||||
Lime_executor_binding: item.defaultExecutorBinding,
|
||||
Lime_output_destination: trimToUndefined(outputDestination),
|
||||
Lime_output_hint: trimToUndefined(item.outputHint),
|
||||
Lime_entry_hint: trimToUndefined(item.entryHint),
|
||||
Lime_prompt_template_key: item.promptTemplateKey,
|
||||
Lime_theme_target: trimToUndefined(item.themeTarget),
|
||||
Lime_site_adapter: trimToUndefined(item.siteCapabilityBinding?.adapterName),
|
||||
Lime_surface_scopes:
|
||||
item.surfaceScopes && item.surfaceScopes.length > 0
|
||||
? JSON.stringify(item.surfaceScopes)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(candidates)) {
|
||||
if (value) {
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
function resolveDerivedServiceSkillOutputDestination(item: ServiceSkillItem): string {
|
||||
if (trimToUndefined(item.outputDestination)) {
|
||||
return item.outputDestination!.trim();
|
||||
}
|
||||
|
||||
if (item.executionLocation === "cloud_required") {
|
||||
return "运行结果会在云端完成后回流到当前工作区。";
|
||||
}
|
||||
|
||||
if (item.siteCapabilityBinding) {
|
||||
return item.siteCapabilityBinding.saveMode === "project_resource"
|
||||
? "结果会沉淀为当前项目资源,方便后续复用。"
|
||||
: "结果会优先写回当前内容,继续在当前工作区整理。";
|
||||
}
|
||||
|
||||
if (item.runnerType === "scheduled") {
|
||||
return "首轮结果会进入当前工作区;后续结果会同步到任务中心。";
|
||||
}
|
||||
|
||||
if (item.runnerType === "managed") {
|
||||
return "首轮策略会进入当前工作区;后续跟踪结果会持续回流。";
|
||||
}
|
||||
|
||||
return "结果会写回当前工作区,方便继续编辑。";
|
||||
}
|
||||
|
||||
function buildDerivedServiceSkillCompatibility(item: ServiceSkillItem): string {
|
||||
const parts = ["适用于 Lime 客户端技能目录"];
|
||||
|
||||
if (item.readinessRequirements?.requiresModel) {
|
||||
parts.push("需要已启用模型");
|
||||
}
|
||||
if (item.readinessRequirements?.requiresBrowser) {
|
||||
parts.push("需要浏览器登录态");
|
||||
}
|
||||
if (item.readinessRequirements?.requiresProject) {
|
||||
parts.push("建议在项目上下文中启动");
|
||||
}
|
||||
if (item.executionLocation === "cloud_required") {
|
||||
parts.push("需要云端执行");
|
||||
}
|
||||
if (item.defaultExecutorBinding === "browser_assist") {
|
||||
parts.push("会复用浏览器站点上下文");
|
||||
}
|
||||
|
||||
const compatibility = parts.join(";");
|
||||
return compatibility.length > MAX_SERVICE_SKILL_BUNDLE_COMPATIBILITY_LENGTH
|
||||
? compatibility.slice(0, MAX_SERVICE_SKILL_BUNDLE_COMPATIBILITY_LENGTH).trim()
|
||||
: compatibility;
|
||||
}
|
||||
|
||||
function validateDerivedServiceSkillBundleName(name: string): string[] {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return ["skillBundle.name 不能为空"];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
if (trimmed.length > MAX_SERVICE_SKILL_BUNDLE_NAME_LENGTH) {
|
||||
errors.push(
|
||||
`skillBundle.name 不能超过 ${MAX_SERVICE_SKILL_BUNDLE_NAME_LENGTH} 个字符`,
|
||||
);
|
||||
}
|
||||
if (trimmed !== trimmed.toLowerCase()) {
|
||||
errors.push("skillBundle.name 必须为小写");
|
||||
}
|
||||
if (trimmed.startsWith("-") || trimmed.endsWith("-")) {
|
||||
errors.push("skillBundle.name 不能以连字符开头或结尾");
|
||||
}
|
||||
if (trimmed.includes("--")) {
|
||||
errors.push("skillBundle.name 不能包含连续连字符");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(trimmed)) {
|
||||
errors.push("skillBundle.name 只能包含小写字母、数字和连字符");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateDerivedServiceSkillBundleDescription(description: string): string[] {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
return ["skillBundle.description 不能为空"];
|
||||
}
|
||||
if (trimmed.length > MAX_SERVICE_SKILL_BUNDLE_DESCRIPTION_LENGTH) {
|
||||
return [
|
||||
`skillBundle.description 不能超过 ${MAX_SERVICE_SKILL_BUNDLE_DESCRIPTION_LENGTH} 个字符`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateDerivedServiceSkillBundleCompatibility(
|
||||
compatibility: string,
|
||||
): string[] {
|
||||
const trimmed = compatibility.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (trimmed.length > MAX_SERVICE_SKILL_BUNDLE_COMPATIBILITY_LENGTH) {
|
||||
return [
|
||||
`skillBundle.compatibility 不能超过 ${MAX_SERVICE_SKILL_BUNDLE_COMPATIBILITY_LENGTH} 个字符`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildDerivedServiceSkillBundleSummary(
|
||||
item: ServiceSkillItem,
|
||||
): ServiceSkillBundleSummary {
|
||||
const name = trimToUndefined(item.skillKey) ?? item.id.trim();
|
||||
const description = trimToUndefined(item.summary) ?? item.title.trim();
|
||||
const compatibility = buildDerivedServiceSkillCompatibility(item);
|
||||
const validationErrors = [
|
||||
...validateDerivedServiceSkillBundleName(name),
|
||||
...validateDerivedServiceSkillBundleDescription(description),
|
||||
...validateDerivedServiceSkillBundleCompatibility(compatibility),
|
||||
];
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
compatibility,
|
||||
metadata: toServiceSkillBundleMetadata(item),
|
||||
resourceSummary: {
|
||||
hasScripts: false,
|
||||
hasReferences: false,
|
||||
hasAssets: false,
|
||||
},
|
||||
standardCompliance: {
|
||||
isStandard: validationErrors.length === 0,
|
||||
validationErrors: validationErrors.length > 0 ? validationErrors : undefined,
|
||||
deprecatedFields: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
|
||||
tenantId: "local-seeded",
|
||||
@@ -156,6 +377,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "carousel-post-replication",
|
||||
skillKey: "carousel-post-replication",
|
||||
skillType: "service",
|
||||
title: "复制轮播帖",
|
||||
summary:
|
||||
"拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。",
|
||||
@@ -164,6 +386,10 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
aliases: ["复刻轮播帖", "轮播帖", "小红书轮播", "轮播复刻"],
|
||||
category: "社媒内容",
|
||||
outputHint: "轮播结构 + 文案初稿",
|
||||
triggerHints: [
|
||||
"已经有参考轮播帖,希望快速拆结构并生成一版同风格内容时使用。",
|
||||
"需要保留品牌、活动或结论信息,但重新整理表达方式时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "agent_turn",
|
||||
@@ -173,6 +399,19 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先产出一版结构化草稿,再在当前工作区继续精修。",
|
||||
"参考内容越完整,结构复刻和卖点提炼会越稳定。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在目标项目内启动,方便结果直接写回当前工作区。",
|
||||
],
|
||||
examples: [
|
||||
"帮我按这篇小红书轮播帖复刻一版,但保留我的品牌名和活动信息。",
|
||||
"参考这个轮播结构,做一版更克制、更像真实用户分享的文案。",
|
||||
],
|
||||
outputDestination: "结果会写回当前工作区中的内容草稿,方便继续改写和发布。",
|
||||
surfaceScopes: SERVICE_SKILL_SURFACE_SCOPES,
|
||||
promptTemplateKey: "replication",
|
||||
themeTarget: "social-media",
|
||||
@@ -218,6 +457,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "short-video-script-replication",
|
||||
skillKey: "short-video-script-replication",
|
||||
skillType: "service",
|
||||
title: "复制短视频脚本",
|
||||
summary: "围绕参考视频的结构和节奏,输出一版可直接继续加工的脚本。",
|
||||
entryHint:
|
||||
@@ -225,6 +465,10 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
aliases: ["复刻短视频", "短视频脚本", "视频脚本复刻", "视频复刻"],
|
||||
category: "视频创作",
|
||||
outputHint: "脚本大纲 + 镜头节奏",
|
||||
triggerHints: [
|
||||
"已经有参考视频,希望先得到一版结构接近的脚本时使用。",
|
||||
"需要围绕同类视频快速起草镜头节奏和口播框架时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "agent_turn",
|
||||
@@ -234,6 +478,19 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先锁定结构和节奏,再继续补镜头、字幕和口播细节。",
|
||||
"如果希望明显偏离原视频,建议在重点调整点里写清楚。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在视频项目内启动,方便脚本直接回到当前工作区。",
|
||||
],
|
||||
examples: [
|
||||
"参考这个抖音视频结构,帮我写一版同节奏但更适合新品开箱的脚本。",
|
||||
"按这个视频框架拆一版口播脚本,重点弱化夸张表达。",
|
||||
],
|
||||
outputDestination: "结果会写回当前工作区中的脚本草稿,方便继续补镜头与口播。",
|
||||
surfaceScopes: SERVICE_SKILL_SURFACE_SCOPES,
|
||||
promptTemplateKey: "replication",
|
||||
themeTarget: "video",
|
||||
@@ -279,10 +536,15 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "article-to-slide-video-outline",
|
||||
skillKey: "article-to-slide-video-outline",
|
||||
skillType: "service",
|
||||
title: "文章转 Slide 视频提纲",
|
||||
summary: "把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。",
|
||||
category: "知识转化",
|
||||
outputHint: "Slide 分镜 + 提纲结构",
|
||||
triggerHints: [
|
||||
"已经有文章或长文素材,想尽快转成视频提纲时使用。",
|
||||
"需要把知识内容改造成适合 Slide 演示的视频结构时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "native_skill",
|
||||
@@ -292,6 +554,19 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先确定章节切分和镜头逻辑,再继续补页面文案和旁白。",
|
||||
"文章正文越完整,提纲拆分和重点保留会越稳定。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在知识类项目内启动,方便提纲直接沉淀为当前内容。",
|
||||
],
|
||||
examples: [
|
||||
"把这篇公众号文章拆成一个 90 秒 Slide 视频提纲。",
|
||||
"按文章内容生成一版适合 Bilibili 讲解视频的分镜结构。",
|
||||
],
|
||||
outputDestination: "结果会写回当前工作区中的提纲文档,方便继续补正文和分镜。",
|
||||
themeTarget: "knowledge",
|
||||
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
|
||||
slotSchema: [
|
||||
@@ -324,10 +599,15 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "video-dubbing-language",
|
||||
skillKey: "video-dubbing-language",
|
||||
skillType: "service",
|
||||
title: "视频配音成其他语言",
|
||||
summary: "先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。",
|
||||
category: "视频创作",
|
||||
outputHint: "配音脚本 + 语言说明",
|
||||
triggerHints: [
|
||||
"已有视频素材,希望先得到一版多语言配音稿时使用。",
|
||||
"需要围绕目标语言和字幕要求快速整理配音文本时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "agent_turn",
|
||||
@@ -337,6 +617,19 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先整理语言版本和字幕要求,再进入正式配音或剪辑流程。",
|
||||
"如果视频有专业术语,建议在素材说明里一并补充。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在视频项目内启动,方便配音稿直接回到当前工作区。",
|
||||
],
|
||||
examples: [
|
||||
"把这个中文视频整理成一版英文配音稿,并保留双语字幕要求。",
|
||||
"参考这段素材,输出一版日文配音文本和字幕说明。",
|
||||
],
|
||||
outputDestination: "结果会写回当前工作区中的配音稿,方便继续进入配音与剪辑流程。",
|
||||
themeTarget: "video",
|
||||
version: SEEDED_SERVICE_SKILL_CATALOG_VERSION,
|
||||
slotSchema: [
|
||||
@@ -373,11 +666,16 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "github-repo-radar",
|
||||
skillKey: "github-repo-radar",
|
||||
skillType: "site",
|
||||
title: "GitHub 仓库线索检索",
|
||||
summary:
|
||||
"复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。",
|
||||
category: "情报研究",
|
||||
outputHint: "仓库列表 + 关键线索",
|
||||
triggerHints: [
|
||||
"需要围绕某个技术主题快速找 GitHub 仓库线索时使用。",
|
||||
"已经登录 GitHub,希望直接把真实检索结果沉淀到当前内容时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "browser_assist",
|
||||
@@ -387,6 +685,20 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresBrowser: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先做一轮主题检索,再回到工作区筛选值得继续跟进的仓库。",
|
||||
"检索词尽量包含技术栈、业务场景或目标能力,结果会更聚焦。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要浏览器里已有 GitHub 登录态。",
|
||||
"建议在目标项目内启动,方便结果优先写回当前内容。",
|
||||
],
|
||||
examples: [
|
||||
"帮我查一批和 MCP browser automation 相关的 GitHub 仓库。",
|
||||
"围绕 AI coding agent 这个主题搜 GitHub 仓库,并沉淀关键线索。",
|
||||
],
|
||||
outputDestination:
|
||||
"采集结果会优先写回当前内容;如果当前内容不可用,再沉淀为项目资源。",
|
||||
siteCapabilityBinding: {
|
||||
adapterName: "github/search",
|
||||
autoRun: true,
|
||||
@@ -409,14 +721,14 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "例如 MCP agent browser automation",
|
||||
helpText:
|
||||
"进入后会自动打开浏览器站点工作台,并执行 GitHub 仓库搜索。",
|
||||
helpText: "开始后会复用当前浏览器登录态执行 GitHub 仓库搜索。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "daily-trend-briefing",
|
||||
skillKey: "daily-trend-briefing",
|
||||
skillType: "service",
|
||||
title: "每日趋势摘要",
|
||||
summary:
|
||||
"围绕指定平台、行业和地区,先产出一版趋势摘要和后续本地定时任务建议。",
|
||||
@@ -425,6 +737,10 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
aliases: ["趋势摘要", "趋势报告", "热点摘要", "每日趋势"],
|
||||
category: "社媒运营",
|
||||
outputHint: "趋势摘要 + 调度建议",
|
||||
triggerHints: [
|
||||
"想先做一版趋势摘要,再决定是否开启定时跟踪时使用。",
|
||||
"需要围绕平台、行业关键词持续追热点时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "scheduled",
|
||||
defaultExecutorBinding: "automation_job",
|
||||
@@ -434,6 +750,20 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"先完成一次首轮摘要,再根据结果决定推送频率和关键词范围。",
|
||||
"关键词尽量聚焦一个主题域,定时结果会更稳定可读。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在目标项目内启动,方便首轮结果和后续回流都落到同一工作区。",
|
||||
],
|
||||
examples: [
|
||||
"每天早上帮我跟踪 AI Agent 行业热点,并生成一版趋势摘要。",
|
||||
"围绕跨境电商和北美地区做一个每日趋势巡检任务。",
|
||||
],
|
||||
outputDestination:
|
||||
"首轮结果会进入当前工作区;后续执行结果会同步到任务中心与对应项目内容。",
|
||||
surfaceScopes: SERVICE_SKILL_SURFACE_SCOPES,
|
||||
promptTemplateKey: "trend_briefing",
|
||||
themeTarget: "social-media",
|
||||
@@ -484,6 +814,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
{
|
||||
id: "account-performance-tracking",
|
||||
skillKey: "account-performance-tracking",
|
||||
skillType: "service",
|
||||
title: "账号自动增长",
|
||||
summary:
|
||||
"围绕参考账号和目标平台先做一版增长策略,再整理后续持续跟踪需要的指标和告警条件。",
|
||||
@@ -492,6 +823,10 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
aliases: ["账号增长", "自动增长", "涨粉", "账号表现"],
|
||||
category: "社媒运营",
|
||||
outputHint: "增长策略 + 发布节奏 + 跟踪指标",
|
||||
triggerHints: [
|
||||
"想先产出增长策略,再持续观察账号表现时使用。",
|
||||
"需要围绕目标账号建立长期跟踪和告警规则时使用。",
|
||||
],
|
||||
source: "cloud_catalog",
|
||||
runnerType: "managed",
|
||||
defaultExecutorBinding: "automation_job",
|
||||
@@ -501,6 +836,20 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = {
|
||||
requiresModel: true,
|
||||
requiresProject: true,
|
||||
},
|
||||
usageGuidelines: [
|
||||
"适合先产出首版增长打法,再逐步收紧指标和告警条件。",
|
||||
"账号列表越明确,后续持续跟踪和告警命中会越准确。",
|
||||
],
|
||||
setupRequirements: [
|
||||
"需要已选择可用模型。",
|
||||
"建议在目标项目内启动,方便首轮策略和持续跟踪结果沉淀到同一工作区。",
|
||||
],
|
||||
examples: [
|
||||
"围绕这几个小红书账号做一版自动增长策略,并设置日更追踪。",
|
||||
"帮我针对 X 上的目标账号生成增长计划和后续告警阈值。",
|
||||
],
|
||||
outputDestination:
|
||||
"首轮策略会写回当前工作区;后续跟踪结果会持续回流到任务中心与项目内容。",
|
||||
surfaceScopes: SERVICE_SKILL_SURFACE_SCOPES,
|
||||
promptTemplateKey: "account_growth",
|
||||
themeTarget: "social-media",
|
||||
@@ -604,6 +953,59 @@ function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSkillBundleResourceSummary(
|
||||
value: unknown,
|
||||
): value is ServiceSkillBundleResourceSummary {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = value as Partial<ServiceSkillBundleResourceSummary>;
|
||||
return (
|
||||
typeof summary.hasScripts === "boolean" &&
|
||||
typeof summary.hasReferences === "boolean" &&
|
||||
typeof summary.hasAssets === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSkillBundleStandardCompliance(
|
||||
value: unknown,
|
||||
): value is ServiceSkillBundleStandardCompliance {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compliance = value as Partial<ServiceSkillBundleStandardCompliance>;
|
||||
return (
|
||||
typeof compliance.isStandard === "boolean" &&
|
||||
(compliance.validationErrors === undefined ||
|
||||
isStringArray(compliance.validationErrors)) &&
|
||||
(compliance.deprecatedFields === undefined ||
|
||||
isStringArray(compliance.deprecatedFields))
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSkillBundleSummary(
|
||||
value: unknown,
|
||||
): value is ServiceSkillBundleSummary {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bundle = value as Partial<ServiceSkillBundleSummary>;
|
||||
return (
|
||||
typeof bundle.name === "string" &&
|
||||
typeof bundle.description === "string" &&
|
||||
(bundle.license === undefined || typeof bundle.license === "string") &&
|
||||
(bundle.compatibility === undefined ||
|
||||
typeof bundle.compatibility === "string") &&
|
||||
(bundle.metadata === undefined || isStringRecord(bundle.metadata)) &&
|
||||
(bundle.allowedTools === undefined || isStringArray(bundle.allowedTools)) &&
|
||||
isServiceSkillBundleResourceSummary(bundle.resourceSummary) &&
|
||||
isServiceSkillBundleStandardCompliance(bundle.standardCompliance)
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSkillSiteCapabilityBinding(
|
||||
value: unknown,
|
||||
): value is ServiceSkillSiteCapabilityBinding {
|
||||
@@ -637,6 +1039,8 @@ function isServiceSkillItem(value: unknown): value is ServiceSkillItem {
|
||||
}
|
||||
|
||||
const item = value as Partial<ServiceSkillItem>;
|
||||
const skillTypeValid =
|
||||
item.skillType === undefined || SERVICE_SKILL_TYPES.includes(item.skillType);
|
||||
const promptTemplateKeyValid =
|
||||
item.promptTemplateKey === undefined ||
|
||||
SERVICE_SKILL_PROMPT_TEMPLATE_KEYS.includes(item.promptTemplateKey);
|
||||
@@ -655,24 +1059,35 @@ function isServiceSkillItem(value: unknown): value is ServiceSkillItem {
|
||||
return (
|
||||
typeof item.id === "string" &&
|
||||
(item.skillKey === undefined || typeof item.skillKey === "string") &&
|
||||
skillTypeValid &&
|
||||
typeof item.title === "string" &&
|
||||
typeof item.summary === "string" &&
|
||||
(item.entryHint === undefined || typeof item.entryHint === "string") &&
|
||||
(item.aliases === undefined || isStringArray(item.aliases)) &&
|
||||
typeof item.category === "string" &&
|
||||
typeof item.outputHint === "string" &&
|
||||
(item.triggerHints === undefined || isStringArray(item.triggerHints)) &&
|
||||
typeof item.source === "string" &&
|
||||
typeof item.runnerType === "string" &&
|
||||
typeof item.defaultExecutorBinding === "string" &&
|
||||
typeof item.executionLocation === "string" &&
|
||||
artifactKindValid &&
|
||||
promptTemplateKeyValid &&
|
||||
(item.usageGuidelines === undefined ||
|
||||
isStringArray(item.usageGuidelines)) &&
|
||||
(item.setupRequirements === undefined ||
|
||||
isStringArray(item.setupRequirements)) &&
|
||||
(item.examples === undefined || isStringArray(item.examples)) &&
|
||||
(item.outputDestination === undefined ||
|
||||
typeof item.outputDestination === "string") &&
|
||||
(item.siteCapabilityBinding === undefined ||
|
||||
isServiceSkillSiteCapabilityBinding(item.siteCapabilityBinding)) &&
|
||||
Array.isArray(item.slotSchema) &&
|
||||
item.slotSchema.every(isServiceSkillSlotDefinition) &&
|
||||
(item.surfaceScopes === undefined ||
|
||||
isServiceSkillSurfaceScopeArray(item.surfaceScopes)) &&
|
||||
(item.skillBundle === undefined ||
|
||||
isServiceSkillBundleSummary(item.skillBundle)) &&
|
||||
typeof item.version === "string"
|
||||
);
|
||||
}
|
||||
@@ -700,6 +1115,14 @@ function cloneServiceSkillCatalog(
|
||||
items: catalog.items.map((item) => ({
|
||||
...item,
|
||||
aliases: item.aliases ? [...item.aliases] : undefined,
|
||||
triggerHints: item.triggerHints ? [...item.triggerHints] : undefined,
|
||||
usageGuidelines: item.usageGuidelines
|
||||
? [...item.usageGuidelines]
|
||||
: undefined,
|
||||
setupRequirements: item.setupRequirements
|
||||
? [...item.setupRequirements]
|
||||
: undefined,
|
||||
examples: item.examples ? [...item.examples] : undefined,
|
||||
slotSchema: item.slotSchema.map((slot) => ({
|
||||
...slot,
|
||||
options: slot.options ? [...slot.options] : undefined,
|
||||
@@ -719,6 +1142,27 @@ function cloneServiceSkillCatalog(
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
skillBundle: item.skillBundle
|
||||
? {
|
||||
...item.skillBundle,
|
||||
metadata: item.skillBundle.metadata
|
||||
? { ...item.skillBundle.metadata }
|
||||
: undefined,
|
||||
allowedTools: item.skillBundle.allowedTools
|
||||
? [...item.skillBundle.allowedTools]
|
||||
: undefined,
|
||||
resourceSummary: { ...item.skillBundle.resourceSummary },
|
||||
standardCompliance: {
|
||||
...item.skillBundle.standardCompliance,
|
||||
validationErrors: item.skillBundle.standardCompliance.validationErrors
|
||||
? [...item.skillBundle.standardCompliance.validationErrors]
|
||||
: undefined,
|
||||
deprecatedFields: item.skillBundle.standardCompliance.deprecatedFields
|
||||
? [...item.skillBundle.standardCompliance.deprecatedFields]
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: buildDerivedServiceSkillBundleSummary(item),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { siteListAdaptersMock } = vi.hoisted(() => ({
|
||||
siteListAdaptersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../webview-api", () => ({
|
||||
siteListAdapters: siteListAdaptersMock,
|
||||
}));
|
||||
|
||||
import { clearSkillCatalogCache, getSkillCatalog } from "./skillCatalog";
|
||||
|
||||
describe("skillCatalog", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
siteListAdaptersMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
clearSkillCatalogCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("应把运行时站点 adapter 合并为技能目录项,并为新站点补齐分组", async () => {
|
||||
siteListAdaptersMock.mockResolvedValue([
|
||||
{
|
||||
name: "bilibili/search",
|
||||
domain: "search.bilibili.com",
|
||||
description: "按关键词采集 B 站视频搜索结果。",
|
||||
read_only: true,
|
||||
capabilities: ["search", "video", "research"],
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "搜索关键词",
|
||||
example: "AI Agent",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "返回条目数量上限",
|
||||
example: 5,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: true,
|
||||
},
|
||||
example_args: {
|
||||
query: "AI Agent",
|
||||
limit: 5,
|
||||
},
|
||||
example: 'bilibili/search {"query":"AI Agent","limit":5}',
|
||||
source_kind: "bundled",
|
||||
source_version: "2026-03-25",
|
||||
},
|
||||
{
|
||||
name: "github/search",
|
||||
domain: "github.com",
|
||||
description: "按关键词采集 GitHub 仓库搜索结果。",
|
||||
read_only: true,
|
||||
capabilities: ["search", "repository", "research"],
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "搜索关键词",
|
||||
example: "AI Agent",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: true,
|
||||
},
|
||||
example_args: {
|
||||
query: "AI Agent",
|
||||
},
|
||||
example: 'github/search {"query":"AI Agent"}',
|
||||
source_kind: "bundled",
|
||||
source_version: "2026-03-25",
|
||||
},
|
||||
]);
|
||||
|
||||
const catalog = await getSkillCatalog();
|
||||
const bilibiliSkill = catalog.items.find(
|
||||
(item) => item.id === "site-adapter-bilibili-search",
|
||||
);
|
||||
|
||||
expect(bilibiliSkill).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Bilibili 视频检索",
|
||||
groupKey: "bilibili",
|
||||
skillType: "site",
|
||||
themeTarget: "video",
|
||||
siteCapabilityBinding: expect.objectContaining({
|
||||
adapterName: "bilibili/search",
|
||||
autoRun: true,
|
||||
requireAttachedSession: true,
|
||||
saveMode: "current_content",
|
||||
}),
|
||||
execution: expect.objectContaining({
|
||||
kind: "site_adapter",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(bilibiliSkill?.slotSchema).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "query",
|
||||
label: "检索词",
|
||||
required: true,
|
||||
defaultValue: "AI Agent",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "limit",
|
||||
label: "条目上限",
|
||||
required: false,
|
||||
defaultValue: "5",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
catalog.groups.find((group) => group.key === "bilibili"),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Bilibili",
|
||||
itemCount: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
catalog.items.some((item) => item.id === "site-adapter-github-search"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
catalog.items.some(
|
||||
(item) => item.siteCapabilityBinding?.adapterName === "github/search",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("站点 adapter 读取失败时应回退到原有 seeded 目录", async () => {
|
||||
siteListAdaptersMock.mockRejectedValue(new Error("bridge unavailable"));
|
||||
|
||||
const catalog = await getSkillCatalog();
|
||||
|
||||
expect(catalog.items.some((item) => item.id === "github-repo-radar")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
catalog.items.some((item) => item.id === "site-adapter-bilibili-search"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
onVoiceStartRecording,
|
||||
onVoiceStopRecording,
|
||||
} from "./voiceShortcutEvents";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("voiceShortcutEvents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理开始与停止录音事件", async () => {
|
||||
vi.mocked(safeListen)
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: undefined });
|
||||
return vi.fn();
|
||||
})
|
||||
.mockImplementationOnce(async (_event, handler) => {
|
||||
handler({ payload: undefined });
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
await onVoiceStartRecording(onStart);
|
||||
await onVoiceStopRecording(onStop);
|
||||
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export const VOICE_START_RECORDING_EVENT = "voice-start-recording";
|
||||
export const VOICE_STOP_RECORDING_EVENT = "voice-stop-recording";
|
||||
|
||||
async function onVoiceShortcutEvent(
|
||||
eventName: string,
|
||||
callback: () => void | Promise<void>,
|
||||
): Promise<UnlistenFn> {
|
||||
return safeListen(eventName, () => {
|
||||
void callback();
|
||||
});
|
||||
}
|
||||
|
||||
export async function onVoiceStartRecording(
|
||||
callback: () => void | Promise<void>,
|
||||
): Promise<UnlistenFn> {
|
||||
return onVoiceShortcutEvent(VOICE_START_RECORDING_EVENT, callback);
|
||||
}
|
||||
|
||||
export async function onVoiceStopRecording(
|
||||
callback: () => void | Promise<void>,
|
||||
): Promise<UnlistenFn> {
|
||||
return onVoiceShortcutEvent(VOICE_STOP_RECORDING_EVENT, callback);
|
||||
}
|
||||
@@ -128,7 +128,6 @@ export function useArtifactParser(
|
||||
* 开始新的解析会话
|
||||
*/
|
||||
const startParsing = useCallback(() => {
|
||||
console.log("[useArtifactParser] 开始新的解析会话");
|
||||
// 创建新的解析器实例
|
||||
parserRef.current = new ArtifactParser(config);
|
||||
// 清空已知 ID 集合
|
||||
@@ -141,25 +140,11 @@ export function useArtifactParser(
|
||||
const appendChunk = useCallback(
|
||||
(chunk: string): ParseResult | undefined => {
|
||||
if (!parserRef.current) {
|
||||
console.log("[useArtifactParser] 解析器未初始化,跳过 chunk");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = parserRef.current.append(chunk);
|
||||
|
||||
// 调试日志
|
||||
if (result.artifacts.length > 0) {
|
||||
console.log(
|
||||
"[useArtifactParser] 解析到 artifacts:",
|
||||
result.artifacts.length,
|
||||
result.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 处理解析结果,更新 store
|
||||
processParseResult(result);
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface UseDebouncedValueOptions {
|
||||
maxWait?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖值 Hook
|
||||
*
|
||||
@@ -29,28 +33,70 @@ import { useState, useEffect, useRef } from "react";
|
||||
*
|
||||
* @requirements 11.2
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number = 100): T {
|
||||
export function useDebouncedValue<T>(
|
||||
value: T,
|
||||
delay: number = 100,
|
||||
options: UseDebouncedValueOptions = {},
|
||||
): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const burstStartedAtRef = useRef<number | null>(null);
|
||||
const latestValueRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
// 清除之前的定时器
|
||||
latestValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay <= 0) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
burstStartedAtRef.current = null;
|
||||
setDebouncedValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
const now = Date.now();
|
||||
if (burstStartedAtRef.current === null) {
|
||||
burstStartedAtRef.current = now;
|
||||
}
|
||||
|
||||
const normalizedMaxWait =
|
||||
typeof options.maxWait === "number" && options.maxWait > 0
|
||||
? options.maxWait
|
||||
: null;
|
||||
const elapsedMs = now - burstStartedAtRef.current;
|
||||
const remainingMaxWaitMs =
|
||||
normalizedMaxWait === null
|
||||
? delay
|
||||
: Math.max(normalizedMaxWait - elapsedMs, 0);
|
||||
const waitMs = Math.min(delay, remainingMaxWaitMs);
|
||||
|
||||
if (waitMs === 0) {
|
||||
timerRef.current = null;
|
||||
burstStartedAtRef.current = now;
|
||||
setDebouncedValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
timerRef.current = null;
|
||||
burstStartedAtRef.current = null;
|
||||
setDebouncedValue(latestValueRef.current);
|
||||
}, waitMs);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, delay]);
|
||||
}, [value, delay, options.maxWait]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
@@ -259,13 +259,11 @@ export class ArtifactParser {
|
||||
const artifactAttrs = parseArtifactAttributes(line);
|
||||
if (artifactAttrs) {
|
||||
// artifact fence 开始
|
||||
console.log("[ArtifactParser] 检测到 artifact fence:", line);
|
||||
this.startFence(artifactAttrs, lineStart, lineEnd + 1);
|
||||
} else {
|
||||
const codeFence = parseCodeFence(line);
|
||||
if (codeFence && this.config.treatCodeBlockAsArtifact) {
|
||||
// 标准 code fence 开始
|
||||
console.log("[ArtifactParser] 检测到 code fence:", line, codeFence);
|
||||
const type = this.config.autoDetectLanguage
|
||||
? inferTypeFromLanguage(codeFence.language)
|
||||
: "code";
|
||||
|
||||
@@ -127,10 +127,10 @@ export interface RendererEntry {
|
||||
displayName: string;
|
||||
/** 图标标识 */
|
||||
icon: string;
|
||||
/** 懒加载的渲染器组件 */
|
||||
component: React.LazyExoticComponent<
|
||||
React.ComponentType<ArtifactRendererProps>
|
||||
>;
|
||||
/** 渲染器组件,允许普通组件或懒加载组件 */
|
||||
component:
|
||||
| React.ComponentType<ArtifactRendererProps>
|
||||
| React.ExoticComponent<ArtifactRendererProps>;
|
||||
/** 是否支持编辑 */
|
||||
canEdit?: boolean;
|
||||
/** 下载时的文件扩展名 */
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockMessageEvent = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readonly withCredentials = false;
|
||||
readyState = 0;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MockMessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event | unknown) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.readyState = 2;
|
||||
}
|
||||
|
||||
emitOpen() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
|
||||
emitMessage(data: string) {
|
||||
this.onmessage?.({ data });
|
||||
}
|
||||
}
|
||||
|
||||
describe("http-client listenViaHttpEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
MockEventSource.instances = [];
|
||||
vi.stubEnv("MODE", "development");
|
||||
vi.stubEnv("DEV", "true");
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("会等待事件流真正连上后再返回订阅", async () => {
|
||||
const { listenViaHttpEvent } = await import("./http-client");
|
||||
const handler = vi.fn();
|
||||
let resolved = false;
|
||||
|
||||
const promise = listenViaHttpEvent("agent_stream", handler).then((unlisten) => {
|
||||
resolved = true;
|
||||
return unlisten;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
const source = MockEventSource.instances[0];
|
||||
source.emitOpen();
|
||||
|
||||
const unlisten = await promise;
|
||||
expect(resolved).toBe(true);
|
||||
|
||||
source.emitMessage(JSON.stringify({ payload: { delta: "hello" } }));
|
||||
expect(handler).toHaveBeenCalledWith({ payload: { delta: "hello" } });
|
||||
|
||||
unlisten();
|
||||
expect(source.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("连接超时会拒绝订阅并关闭事件流", async () => {
|
||||
const { listenViaHttpEvent } = await import("./http-client");
|
||||
|
||||
const promise = listenViaHttpEvent("agent_stream", vi.fn());
|
||||
const rejection = expect(promise).rejects.toThrow("事件流连接超时");
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
await rejection;
|
||||
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
|
||||
const BRIDGE_URL = "http://127.0.0.1:3030/invoke";
|
||||
const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health";
|
||||
const BRIDGE_EVENTS_URL = "http://127.0.0.1:3030/events";
|
||||
const DEV_BRIDGE_EVENT_CONNECT_TIMEOUT_MS = 1500;
|
||||
|
||||
export interface InvokeRequest {
|
||||
cmd: string;
|
||||
@@ -23,6 +25,16 @@ export interface InvokeResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type DevBridgeEventHandler<T> = (event: { payload: T }) => void;
|
||||
|
||||
interface DevBridgeEventHub {
|
||||
listeners: Set<DevBridgeEventHandler<unknown>>;
|
||||
source: EventSource;
|
||||
openPromise: Promise<void>;
|
||||
}
|
||||
|
||||
const bridgeEventHubs = new Map<string, DevBridgeEventHub>();
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Unknown error";
|
||||
@@ -94,6 +106,140 @@ export function isDevBridgeAvailable(): boolean {
|
||||
return isBrowser;
|
||||
}
|
||||
|
||||
export function hasDevBridgeEventListenerCapability(): boolean {
|
||||
return (
|
||||
isDevBridgeAvailable() &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.EventSource === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function parseBridgeEventPayload<T>(raw: string): { payload: T } | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { payload?: T };
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"payload" in parsed
|
||||
) {
|
||||
return { payload: parsed.payload as T };
|
||||
}
|
||||
return { payload: parsed as T };
|
||||
} catch {
|
||||
return { payload: raw as T };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listenViaHttpEvent<T = unknown>(
|
||||
event: string,
|
||||
handler: (event: { payload: T }) => void,
|
||||
): Promise<() => void> {
|
||||
if (!hasDevBridgeEventListenerCapability()) {
|
||||
throw new Error(`[DevBridge] 浏览器模式事件桥不可用: ${event}`);
|
||||
}
|
||||
|
||||
const normalizedEvent = event.trim();
|
||||
if (!normalizedEvent) {
|
||||
throw new Error("[DevBridge] 事件名不能为空");
|
||||
}
|
||||
|
||||
let hub = bridgeEventHubs.get(normalizedEvent);
|
||||
if (!hub) {
|
||||
const source = new window.EventSource(
|
||||
`${BRIDGE_EVENTS_URL}?event=${encodeURIComponent(normalizedEvent)}`,
|
||||
);
|
||||
const listeners = new Set<DevBridgeEventHandler<unknown>>();
|
||||
let hasOpened = false;
|
||||
let settleOpen:
|
||||
| ((value: void | PromiseLike<void>) => void)
|
||||
| null = null;
|
||||
let settleOpenError: ((reason?: unknown) => void) | null = null;
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
settleOpen = resolve;
|
||||
settleOpenError = reject;
|
||||
});
|
||||
const connectTimeout = window.setTimeout(() => {
|
||||
if (hasOpened) {
|
||||
return;
|
||||
}
|
||||
bridgeEventHubs.delete(normalizedEvent);
|
||||
source.close();
|
||||
settleOpenError?.(
|
||||
new Error(`[DevBridge] 事件流连接超时: ${normalizedEvent}`),
|
||||
);
|
||||
}, DEV_BRIDGE_EVENT_CONNECT_TIMEOUT_MS);
|
||||
|
||||
source.onmessage = (messageEvent) => {
|
||||
const parsed = parseBridgeEventPayload<unknown>(messageEvent.data);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(parsed);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DevBridge] 事件监听器执行失败: ${normalizedEvent}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.onopen = () => {
|
||||
hasOpened = true;
|
||||
window.clearTimeout(connectTimeout);
|
||||
settleOpen?.();
|
||||
settleOpen = null;
|
||||
settleOpenError = null;
|
||||
};
|
||||
|
||||
source.onerror = (error) => {
|
||||
console.warn(`[DevBridge] 事件流异常: ${normalizedEvent}`, error);
|
||||
if (hasOpened) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(connectTimeout);
|
||||
bridgeEventHubs.delete(normalizedEvent);
|
||||
source.close();
|
||||
settleOpenError?.(
|
||||
new Error(`[DevBridge] 事件流连接失败: ${normalizedEvent}`),
|
||||
);
|
||||
settleOpen = null;
|
||||
settleOpenError = null;
|
||||
};
|
||||
|
||||
hub = {
|
||||
listeners,
|
||||
source,
|
||||
openPromise,
|
||||
};
|
||||
bridgeEventHubs.set(normalizedEvent, hub);
|
||||
}
|
||||
|
||||
const listener = handler as DevBridgeEventHandler<unknown>;
|
||||
hub.listeners.add(listener);
|
||||
|
||||
await hub.openPromise;
|
||||
|
||||
return () => {
|
||||
const currentHub = bridgeEventHubs.get(normalizedEvent);
|
||||
if (!currentHub) {
|
||||
return;
|
||||
}
|
||||
currentHub.listeners.delete(listener);
|
||||
if (currentHub.listeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
currentHub.source.close();
|
||||
bridgeEventHubs.delete(normalizedEvent);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 HTTP 桥接调用 Tauri 命令
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export {
|
||||
invokeViaHttp,
|
||||
isDevBridgeAvailable,
|
||||
hasDevBridgeEventListenerCapability,
|
||||
healthCheck,
|
||||
getBridgeStatus,
|
||||
} from "./http-client";
|
||||
|
||||
@@ -64,6 +64,7 @@ const mockPriorityCommands = new Set<string>([
|
||||
"get_browser_connector_install_status_cmd",
|
||||
"install_browser_connector_extension_cmd",
|
||||
"open_browser_extensions_page_cmd",
|
||||
"disconnect_browser_connector_session",
|
||||
"launch_browser_session",
|
||||
"launch_browser_profile_runtime_assist_cmd",
|
||||
"get_browser_action_audit_logs",
|
||||
|
||||
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
|
||||
baseInvoke: vi.fn(),
|
||||
baseListen: vi.fn(),
|
||||
baseEmit: vi.fn(),
|
||||
listenViaHttpEvent: vi.fn(),
|
||||
hasDevBridgeEventListenerCapability: vi.fn(),
|
||||
invokeViaHttp: vi.fn(),
|
||||
isDevBridgeAvailable: vi.fn(),
|
||||
normalizeDevBridgeError: vi.fn((cmd: string, error: unknown) => {
|
||||
@@ -25,8 +27,11 @@ vi.mock("@tauri-apps/api/event", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./http-client", () => ({
|
||||
hasDevBridgeEventListenerCapability:
|
||||
mocks.hasDevBridgeEventListenerCapability,
|
||||
invokeViaHttp: mocks.invokeViaHttp,
|
||||
isDevBridgeAvailable: mocks.isDevBridgeAvailable,
|
||||
listenViaHttpEvent: mocks.listenViaHttpEvent,
|
||||
normalizeDevBridgeError: mocks.normalizeDevBridgeError,
|
||||
}));
|
||||
|
||||
@@ -48,6 +53,7 @@ describe("safeInvoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isDevBridgeAvailable.mockReturnValue(true);
|
||||
mocks.hasDevBridgeEventListenerCapability.mockReturnValue(false);
|
||||
window.localStorage.clear();
|
||||
clearInvokeErrorBuffer();
|
||||
clearInvokeTraceBuffer();
|
||||
@@ -136,7 +142,24 @@ describe("safeInvoke", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("浏览器开发模式下 safeListen 优先走 HTTP 事件桥", async () => {
|
||||
const unlisten = vi.fn();
|
||||
mocks.hasDevBridgeEventListenerCapability.mockReturnValue(true);
|
||||
mocks.listenViaHttpEvent.mockResolvedValueOnce(unlisten);
|
||||
|
||||
await expect(safeListen("config-changed", vi.fn())).resolves.toBe(unlisten);
|
||||
|
||||
expect(mocks.listenViaHttpEvent).toHaveBeenCalledWith(
|
||||
"config-changed",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mocks.baseListen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Tauri 运行时存在但事件桥缺失时 safeListen 返回空清理函数", async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
vi.useFakeTimers();
|
||||
(window as any).__TAURI__ = {
|
||||
core: {
|
||||
@@ -144,16 +167,23 @@ describe("safeInvoke", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const promise = safeListen("config-changed", vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
const unlisten = await promise;
|
||||
try {
|
||||
const promise = safeListen("config-changed", vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
const unlisten = await promise;
|
||||
|
||||
expect(typeof unlisten).toBe("function");
|
||||
expect(mocks.baseListen).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
expect(typeof unlisten).toBe("function");
|
||||
expect(mocks.baseListen).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("事件桥调用异常时 safeListen 降级为空清理函数", async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
(window as any).__TAURI_INTERNALS__ = {
|
||||
invoke: vi.fn(),
|
||||
transformCallback: vi.fn(),
|
||||
@@ -164,12 +194,16 @@ describe("safeInvoke", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const unlisten = await safeListen("plugin-task-event", vi.fn());
|
||||
try {
|
||||
const unlisten = await safeListen("plugin-task-event", vi.fn());
|
||||
|
||||
expect(typeof unlisten).toBe("function");
|
||||
expect(mocks.baseListen).toHaveBeenCalledWith(
|
||||
"plugin-task-event",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(typeof unlisten).toBe("function");
|
||||
expect(mocks.baseListen).toHaveBeenCalledWith(
|
||||
"plugin-task-event",
|
||||
expect.any(Function),
|
||||
);
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ import { invoke as baseInvoke } from "@tauri-apps/api/core";
|
||||
import { listen as baseListen, emit as baseEmit } from "@tauri-apps/api/event";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
hasDevBridgeEventListenerCapability,
|
||||
invokeViaHttp,
|
||||
isDevBridgeAvailable,
|
||||
listenViaHttpEvent,
|
||||
normalizeDevBridgeError,
|
||||
} from "./http-client";
|
||||
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
|
||||
@@ -495,6 +497,15 @@ export async function safeListen<T = any>(
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevBridgeEventListenerCapability()) {
|
||||
try {
|
||||
return await listenViaHttpEvent(event, handler);
|
||||
} catch (error) {
|
||||
console.warn(`[safeListen] DevBridge 事件流调用失败,跳过监听: ${event}`, error);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTauriRuntimeMarkers()) {
|
||||
console.warn(`[safeListen] Tauri 事件桥未就绪,跳过监听: ${event}`);
|
||||
return () => {};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Config, DeveloperConfig } from "@/lib/api/appConfig";
|
||||
|
||||
export const DEFAULT_DEVELOPER_CONFIG: DeveloperConfig = {
|
||||
workspace_harness_enabled: false,
|
||||
};
|
||||
|
||||
export function normalizeDeveloperConfig(
|
||||
config?: DeveloperConfig | null,
|
||||
): DeveloperConfig {
|
||||
return {
|
||||
...DEFAULT_DEVELOPER_CONFIG,
|
||||
...(config ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isWorkspaceHarnessEnabled(
|
||||
config?: Pick<Config, "developer"> | null,
|
||||
): boolean {
|
||||
return normalizeDeveloperConfig(config?.developer).workspace_harness_enabled === true;
|
||||
}
|
||||
@@ -30,7 +30,19 @@
|
||||
"agent_runtime_resume_subagent",
|
||||
"agent_runtime_close_subagent",
|
||||
"agent_runtime_update_session",
|
||||
"agent_runtime_delete_session"
|
||||
"agent_runtime_delete_session",
|
||||
"site_list_adapters",
|
||||
"site_recommend_adapters",
|
||||
"site_search_adapters",
|
||||
"site_get_adapter_info",
|
||||
"site_get_adapter_launch_readiness",
|
||||
"site_get_adapter_catalog_status",
|
||||
"site_apply_adapter_catalog_bootstrap",
|
||||
"site_clear_adapter_catalog_cache",
|
||||
"site_import_adapter_yaml_bundle",
|
||||
"site_run_adapter",
|
||||
"site_debug_run_adapter",
|
||||
"site_save_adapter_result"
|
||||
],
|
||||
"deprecatedCommandReplacements": {
|
||||
"get_agent_process_status": "agent_get_process_status",
|
||||
|
||||
@@ -426,6 +426,148 @@
|
||||
"description": "已零引用的 @/stores 根 barrel 回流",
|
||||
"patterns": ["from \"@/stores\"", "from '@/stores'"],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "inputbar-skill-selector-panel-imports",
|
||||
"classification": "dead-candidate",
|
||||
"description": "旧 SkillSelectorPanel 面板路径不允许回流,统一收敛到共享技能面板实现",
|
||||
"patterns": [
|
||||
"from \"./SkillSelectorPanel\"",
|
||||
"from './SkillSelectorPanel'",
|
||||
"import(\"./SkillSelectorPanel\")",
|
||||
"import('./SkillSelectorPanel')",
|
||||
"from \"@/components/agent/chat/components/Inputbar/components/SkillSelectorPanel\"",
|
||||
"from '@/components/agent/chat/components/Inputbar/components/SkillSelectorPanel'",
|
||||
"import(\"@/components/agent/chat/components/Inputbar/components/SkillSelectorPanel\")",
|
||||
"import('@/components/agent/chat/components/Inputbar/components/SkillSelectorPanel')"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "inputbar-composer-flat-skill-parent-props",
|
||||
"classification": "dead-candidate",
|
||||
"description": "Inputbar 顶层不允许再向 ComposerSection 透传扁平 skill props,必须统一收敛到 skillSelection",
|
||||
"patterns": [
|
||||
"activeSkill={activeSkill}",
|
||||
"onSelectSkill={setActiveSkill}",
|
||||
"onSelectServiceSkill={onSelectServiceSkill}",
|
||||
"onClearSkill={clearActiveSkill}",
|
||||
"serviceSkills={serviceSkills}",
|
||||
"isSkillsLoading={isSkillsLoading}",
|
||||
"onNavigateToSettings={onNavigateToSettings}",
|
||||
"onImportSkill={onImportSkill}",
|
||||
"onRefreshSkills={onRefreshSkills}"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/Inputbar/index.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "empty-state-composer-flat-skill-parent-props",
|
||||
"classification": "dead-candidate",
|
||||
"description": "EmptyState 顶层不允许再向首页输入面板透传扁平 skill props,必须统一收敛到 skillSelection",
|
||||
"patterns": [
|
||||
"activeSkill={activeSkill}",
|
||||
"setActiveSkill={setActiveSkill}",
|
||||
"onSelectServiceSkill={onSelectServiceSkill}",
|
||||
"clearActiveSkill={clearActiveSkill}",
|
||||
"serviceSkills={serviceSkills}",
|
||||
"isSkillsLoading={isSkillsLoading}",
|
||||
"onNavigateToSettings={onNavigateToSettings}",
|
||||
"onImportSkill={onImportSkill}",
|
||||
"onRefreshSkills={onRefreshSkills}"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/EmptyState.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "inputbar-composer-flat-skill-prop-contract",
|
||||
"classification": "dead-candidate",
|
||||
"description": "InputbarComposerSection 不允许恢复扁平 skill props 契约,必须只消费共享 skillSelection",
|
||||
"patterns": [],
|
||||
"regexPatterns": [
|
||||
"\\bactiveSkill\\s*\\?:",
|
||||
"\\bonSelectSkill\\s*:",
|
||||
"\\bonSelectServiceSkill\\s*\\?:",
|
||||
"\\bonClearSkill\\s*\\?:",
|
||||
"\\bserviceSkills\\s*\\?:",
|
||||
"\\bisSkillsLoading\\s*\\?:",
|
||||
"\\bonNavigateToSettings\\s*\\?:",
|
||||
"\\bonImportSkill\\s*\\?:",
|
||||
"\\bonRefreshSkills\\s*\\?:"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "empty-state-composer-flat-skill-prop-contract",
|
||||
"classification": "dead-candidate",
|
||||
"description": "EmptyStateComposerPanel 不允许恢复扁平 skill props 契约,必须只消费共享 skillSelection",
|
||||
"patterns": [],
|
||||
"regexPatterns": [
|
||||
"\\bactiveSkill\\s*\\?:",
|
||||
"\\bsetActiveSkill\\s*:",
|
||||
"\\bonSelectServiceSkill\\s*\\?:",
|
||||
"\\bclearActiveSkill\\s*:",
|
||||
"\\bisSkillsLoading\\s*:",
|
||||
"\\bonNavigateToSettings\\s*\\?:",
|
||||
"\\bonImportSkill\\s*\\?:",
|
||||
"\\bonRefreshSkills\\s*\\?:",
|
||||
"\\bserviceSkills\\s*\\?:"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/EmptyStateComposerPanel.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "skill-selection-direct-construction-runtime-usage",
|
||||
"classification": "dead-candidate",
|
||||
"description": "运行时代码不允许绕过 useActiveSkill 直接构造 createSkillSelectionProps,必须收敛到共享激活态边界",
|
||||
"patterns": ["createSkillSelectionProps("],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components"
|
||||
],
|
||||
"allowedPaths": [
|
||||
"src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts",
|
||||
"src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty-state-local-active-skill-hook-usage",
|
||||
"classification": "dead-candidate",
|
||||
"description": "EmptyState 显示层不允许重新直读 useActiveSkill 的 activeSkill/clearActiveSkill,本地技能态必须通过 skillSelection 消费",
|
||||
"patterns": [],
|
||||
"regexPatterns": [
|
||||
"(?<!\\.)\\bactiveSkill\\s*[,}]",
|
||||
"(?<!\\.)\\bclearActiveSkill\\s*[,}]"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/EmptyState.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "skill-selection-legacy-display-copy",
|
||||
"classification": "dead-candidate",
|
||||
"description": "技能入口页面层不允许回到分散手写旧展示文案,必须收敛到共享 skillSelectionDisplay helper",
|
||||
"patterns": [
|
||||
"当前技能 ",
|
||||
"当前已启用 ",
|
||||
"为当前任务挂载额外能力",
|
||||
"按需挂载能力",
|
||||
"项技能可用"
|
||||
],
|
||||
"includePathPrefixes": [
|
||||
"src/components/agent/chat/components/EmptyState.tsx",
|
||||
"src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
}
|
||||
],
|
||||
"rustText": [
|
||||
|
||||
@@ -29,4 +29,96 @@ describe("legacySurfaceCatalog", () => {
|
||||
expect("legacyCommandSurfaceMonitors" in agentCommandCatalog).toBe(false);
|
||||
expect("legacyHelperSurfaceMonitors" in agentCommandCatalog).toBe(false);
|
||||
});
|
||||
|
||||
it("应禁止 SkillSelectorPanel 旧面板路径重新回流", () => {
|
||||
const legacyPanelPath = `./${"SkillSelectorPanel"}`;
|
||||
const monitor = legacySurfaceCatalogJson.frontendText.find(
|
||||
(entry) => entry.id === "inputbar-skill-selector-panel-imports",
|
||||
);
|
||||
|
||||
expect(monitor).toBeTruthy();
|
||||
expect(monitor?.classification).toBe("dead-candidate");
|
||||
expect(monitor?.allowedPaths).toEqual([]);
|
||||
expect(monitor?.patterns).toEqual(
|
||||
expect.arrayContaining([
|
||||
`from "${legacyPanelPath}"`,
|
||||
`import('${legacyPanelPath}')`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("应禁止技能入口重新回到扁平 props 透传与扁平契约", () => {
|
||||
const parentMonitorIds = [
|
||||
"inputbar-composer-flat-skill-parent-props",
|
||||
"empty-state-composer-flat-skill-parent-props",
|
||||
];
|
||||
const contractMonitorIds = [
|
||||
"inputbar-composer-flat-skill-prop-contract",
|
||||
"empty-state-composer-flat-skill-prop-contract",
|
||||
];
|
||||
|
||||
for (const monitorId of [...parentMonitorIds, ...contractMonitorIds]) {
|
||||
const monitor = legacySurfaceCatalogJson.frontendText.find(
|
||||
(entry) => entry.id === monitorId,
|
||||
);
|
||||
|
||||
expect(monitor).toBeTruthy();
|
||||
expect(monitor?.classification).toBe("dead-candidate");
|
||||
expect(monitor?.allowedPaths).toEqual([]);
|
||||
expect(
|
||||
(monitor?.patterns?.length ?? 0) + (monitor?.regexPatterns?.length ?? 0),
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("应禁止运行时代码绕过 useActiveSkill 直接构造 skillSelection", () => {
|
||||
const monitor = legacySurfaceCatalogJson.frontendText.find(
|
||||
(entry) => entry.id === "skill-selection-direct-construction-runtime-usage",
|
||||
);
|
||||
|
||||
expect(monitor).toBeTruthy();
|
||||
expect(monitor?.classification).toBe("dead-candidate");
|
||||
expect(monitor?.patterns).toEqual(["createSkillSelectionProps("]);
|
||||
expect(monitor?.allowedPaths).toEqual([
|
||||
"src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts",
|
||||
"src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("应禁止 EmptyState 重新直读本地 activeSkill hook 状态", () => {
|
||||
const monitor = legacySurfaceCatalogJson.frontendText.find(
|
||||
(entry) => entry.id === "empty-state-local-active-skill-hook-usage",
|
||||
);
|
||||
|
||||
expect(monitor).toBeTruthy();
|
||||
expect(monitor?.classification).toBe("dead-candidate");
|
||||
expect(monitor?.allowedPaths).toEqual([]);
|
||||
expect(monitor?.regexPatterns).toEqual([
|
||||
"(?<!\\.)\\bactiveSkill\\s*[,}]",
|
||||
"(?<!\\.)\\bclearActiveSkill\\s*[,}]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("应禁止技能入口旧展示文案重新回到页面层手写", () => {
|
||||
const monitor = legacySurfaceCatalogJson.frontendText.find(
|
||||
(entry) => entry.id === "skill-selection-legacy-display-copy",
|
||||
);
|
||||
|
||||
expect(monitor).toBeTruthy();
|
||||
expect(monitor?.classification).toBe("dead-candidate");
|
||||
expect(monitor?.allowedPaths).toEqual([]);
|
||||
expect(monitor?.includePathPrefixes).toEqual([
|
||||
"src/components/agent/chat/components/EmptyState.tsx",
|
||||
"src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx",
|
||||
]);
|
||||
expect(monitor?.patterns).toEqual(
|
||||
expect.arrayContaining([
|
||||
"当前技能 ",
|
||||
"当前已启用 ",
|
||||
"为当前任务挂载额外能力",
|
||||
"按需挂载能力",
|
||||
"项技能可用",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Video,
|
||||
Wrench,
|
||||
@@ -57,6 +58,14 @@ export const MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [
|
||||
currentPage === "agent" &&
|
||||
(currentParams as AgentPageParams | undefined)?.agentEntry !== "new-task",
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
label: "技能",
|
||||
icon: Sparkles,
|
||||
page: "skills",
|
||||
isActive: (currentPage) => currentPage === "skills",
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
id: "video",
|
||||
label: "视频",
|
||||
@@ -76,6 +85,12 @@ export const MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [
|
||||
{ id: "plugins", label: "插件中心", icon: Compass, page: "plugins" },
|
||||
];
|
||||
|
||||
export const FIXED_MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] =
|
||||
MAIN_SIDEBAR_NAV_ITEMS.filter((item) => item.configurable === false);
|
||||
|
||||
export const CONFIGURABLE_MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] =
|
||||
MAIN_SIDEBAR_NAV_ITEMS.filter((item) => item.configurable !== false);
|
||||
|
||||
export const FOOTER_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [
|
||||
{
|
||||
id: "openclaw",
|
||||
@@ -131,7 +146,7 @@ export const CONFIGURABLE_FOOTER_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] =
|
||||
FOOTER_SIDEBAR_NAV_ITEMS.filter((item) => item.configurable !== false);
|
||||
|
||||
export const CONFIGURABLE_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [
|
||||
...MAIN_SIDEBAR_NAV_ITEMS,
|
||||
...CONFIGURABLE_MAIN_SIDEBAR_NAV_ITEMS,
|
||||
...CONFIGURABLE_FOOTER_SIDEBAR_NAV_ITEMS,
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setOemCloudBootstrapSnapshot,
|
||||
setStoredOemCloudSessionState,
|
||||
} from "@/lib/oemCloudSession";
|
||||
import { syncSkillCatalogFromBootstrapPayload } from "@/lib/skillCatalogBootstrap";
|
||||
import { syncServiceSkillCatalogFromBootstrapPayload } from "@/lib/serviceSkillCatalogBootstrap";
|
||||
import { syncSiteAdapterCatalogFromBootstrapPayload } from "@/lib/siteAdapterCatalogBootstrap";
|
||||
|
||||
@@ -186,6 +187,7 @@ export async function completeOemCloudDesktopOAuthLogin(
|
||||
|
||||
setStoredOemCloudSessionState(nextSession);
|
||||
setOemCloudBootstrapSnapshot(nextBootstrap);
|
||||
syncSkillCatalogFromBootstrapPayload(nextBootstrap);
|
||||
syncServiceSkillCatalogFromBootstrapPayload(nextBootstrap);
|
||||
void syncSiteAdapterCatalogFromBootstrapPayload(nextBootstrap);
|
||||
dispatchOemCloudOAuthCompleted({
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
applyServerSyncedSkillCatalog,
|
||||
parseSkillCatalog,
|
||||
type SkillCatalog,
|
||||
} from "@/lib/api/skillCatalog";
|
||||
|
||||
export const SKILL_CATALOG_BOOTSTRAP_EVENT = "lime:skill-catalog-bootstrap";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LIME_BOOTSTRAP__?: unknown;
|
||||
__LIME_SKILL_CATALOG__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function hasWindow(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function extractFromRecord(record: Record<string, unknown>): SkillCatalog | null {
|
||||
const directCatalog = parseSkillCatalog(record.skillCatalog);
|
||||
if (directCatalog) {
|
||||
return directCatalog;
|
||||
}
|
||||
|
||||
const nestedBootstrap = record.bootstrap;
|
||||
if (nestedBootstrap && typeof nestedBootstrap === "object") {
|
||||
return extractSkillCatalogFromBootstrapPayload(nestedBootstrap);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSkillCatalogFromBootstrapPayload(
|
||||
payload: unknown,
|
||||
): SkillCatalog | null {
|
||||
const directCatalog = parseSkillCatalog(payload);
|
||||
if (directCatalog) {
|
||||
return directCatalog;
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractFromRecord(payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function syncSkillCatalogFromBootstrapPayload(
|
||||
payload: unknown,
|
||||
): SkillCatalog | null {
|
||||
const catalog = extractSkillCatalogFromBootstrapPayload(payload);
|
||||
if (!catalog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyServerSyncedSkillCatalog(catalog, "bootstrap_sync");
|
||||
}
|
||||
|
||||
export function applyInitialSkillCatalogBootstrap(): SkillCatalog | null {
|
||||
if (!hasWindow()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window.__LIME_SKILL_CATALOG__ !== undefined) {
|
||||
const directCatalog = syncSkillCatalogFromBootstrapPayload({
|
||||
skillCatalog: window.__LIME_SKILL_CATALOG__,
|
||||
});
|
||||
if (directCatalog) {
|
||||
return directCatalog;
|
||||
}
|
||||
}
|
||||
|
||||
return syncSkillCatalogFromBootstrapPayload(window.__LIME_BOOTSTRAP__);
|
||||
}
|
||||
|
||||
export function emitSkillCatalogBootstrap(payload: unknown): void {
|
||||
if (!hasWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SKILL_CATALOG_BOOTSTRAP_EVENT, {
|
||||
detail: payload,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeSkillCatalogBootstrap(
|
||||
listener?: (catalog: SkillCatalog | null) => void,
|
||||
): () => void {
|
||||
if (!hasWindow()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listener?.(syncSkillCatalogFromBootstrapPayload(event.detail));
|
||||
};
|
||||
|
||||
window.addEventListener(SKILL_CATALOG_BOOTSTRAP_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(SKILL_CATALOG_BOOTSTRAP_EVENT, handler);
|
||||
};
|
||||
}
|
||||
@@ -53,28 +53,38 @@ describe("tauri-mock/core invoke", () => {
|
||||
});
|
||||
|
||||
it("bridge 失败且命令存在 mock 时回退默认 mock 数据", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
await expect(invoke("workspace_get_projects_root")).resolves.toBe(
|
||||
"/mock/workspace/projects",
|
||||
);
|
||||
try {
|
||||
await expect(invoke("workspace_get_projects_root")).resolves.toBe(
|
||||
"/mock/workspace/projects",
|
||||
);
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("OpenClaw 环境状态命令在 bridge 失败时回退默认 mock", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
await expect(invoke("openclaw_get_environment_status")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
recommendedAction: "install_openclaw",
|
||||
summary: "运行环境已就绪,可以继续一键安装 OpenClaw。",
|
||||
diagnostics: expect.objectContaining({
|
||||
npmPath: "/opt/homebrew/bin/npm",
|
||||
npmGlobalPrefix: "/opt/homebrew",
|
||||
try {
|
||||
await expect(invoke("openclaw_get_environment_status")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
recommendedAction: "install_openclaw",
|
||||
summary: "运行环境已就绪,可以继续一键安装 OpenClaw。",
|
||||
diagnostics: expect.objectContaining({
|
||||
npmPath: "/opt/homebrew/bin/npm",
|
||||
npmGlobalPrefix: "/opt/homebrew",
|
||||
}),
|
||||
node: expect.objectContaining({ status: "ok" }),
|
||||
git: expect.objectContaining({ status: "ok" }),
|
||||
}),
|
||||
node: expect.objectContaining({ status: "ok" }),
|
||||
git: expect.objectContaining({ status: "ok" }),
|
||||
}),
|
||||
);
|
||||
);
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("旧 Agent 命令别名应直接报废弃错误,不再静默返回 mock 成功结果", async () => {
|
||||
|
||||
+321
-64
@@ -91,6 +91,10 @@ type MockBrowserConnectorSettings = {
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
visible: boolean;
|
||||
authorization_status: string;
|
||||
last_error: string | null;
|
||||
capabilities: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -158,6 +162,14 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
description: "读取和管理你的提醒事项和任务列表。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: "not_determined",
|
||||
last_error: null,
|
||||
capabilities: [
|
||||
"list_reminders",
|
||||
"create_reminder",
|
||||
"update_reminder",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
@@ -165,6 +177,10 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: "not_determined",
|
||||
last_error: null,
|
||||
capabilities: ["list_events", "create_event", "update_event"],
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
@@ -172,6 +188,10 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
description: "读取和创建你的备忘录。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: "not_determined",
|
||||
last_error: null,
|
||||
capabilities: ["list_notes", "read_note", "create_note"],
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
@@ -179,6 +199,10 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
description: "读取邮件和创建草稿。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: "not_determined",
|
||||
last_error: null,
|
||||
capabilities: ["list_mailboxes", "read_messages", "create_draft"],
|
||||
},
|
||||
{
|
||||
id: "contacts",
|
||||
@@ -186,6 +210,10 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
description: "搜索、读取和创建联系人。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: "not_determined",
|
||||
last_error: null,
|
||||
capabilities: ["search_contacts", "read_contact", "create_contact"],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -201,6 +229,15 @@ let mockBrowserConnectorInstallStatus: MockBrowserConnectorInstallStatus = {
|
||||
message: "尚未导出浏览器连接器",
|
||||
};
|
||||
|
||||
let mockChromeBridgeStatus = {
|
||||
observer_count: 0,
|
||||
control_count: 0,
|
||||
pending_command_count: 0,
|
||||
observers: [],
|
||||
controls: [],
|
||||
pending_commands: [],
|
||||
};
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const mockBrowserSessionStates = new Map<string, any>();
|
||||
let mockExistingSessionTabs = [
|
||||
@@ -220,7 +257,7 @@ let mockExistingSessionTabs = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockSiteAdapters = [
|
||||
const mockBundledSiteAdapters = [
|
||||
{
|
||||
name: "github/search",
|
||||
domain: "github.com",
|
||||
@@ -274,7 +311,7 @@ const mockSiteAdapters = [
|
||||
|
||||
const mockSiteRecommendations = [
|
||||
{
|
||||
adapter: mockSiteAdapters[0],
|
||||
adapter: mockBundledSiteAdapters[0],
|
||||
reason:
|
||||
"已检测到资料 research_attach 当前停留在 github.com,可直接复用已连接的 Chrome 上下文。",
|
||||
profile_key: "research_attach",
|
||||
@@ -284,7 +321,7 @@ const mockSiteRecommendations = [
|
||||
score: 100,
|
||||
},
|
||||
{
|
||||
adapter: mockSiteAdapters[1],
|
||||
adapter: mockBundledSiteAdapters[1],
|
||||
reason:
|
||||
"资料 通用浏览器资料 已绑定站点范围 www.zhihu.com,可优先作为该适配器的执行上下文。",
|
||||
profile_key: "general_browser_assist",
|
||||
@@ -293,9 +330,12 @@ const mockSiteRecommendations = [
|
||||
},
|
||||
];
|
||||
|
||||
let mockImportedSiteAdapters: any[] = [];
|
||||
let mockServerSyncedSiteAdapters: any[] = [];
|
||||
|
||||
let mockSiteAdapterCatalogStatus: {
|
||||
exists: boolean;
|
||||
source_kind: "bundled" | "server_synced";
|
||||
source_kind: "bundled" | "imported" | "server_synced";
|
||||
registry_version: number;
|
||||
directory: string;
|
||||
catalog_version?: string;
|
||||
@@ -307,9 +347,118 @@ let mockSiteAdapterCatalogStatus: {
|
||||
source_kind: "bundled",
|
||||
registry_version: 1,
|
||||
directory: "/tmp/lime/site-adapters/server-synced",
|
||||
adapter_count: mockSiteAdapters.length,
|
||||
adapter_count: mockBundledSiteAdapters.length,
|
||||
};
|
||||
|
||||
function getMockEffectiveSiteAdapters() {
|
||||
const merged = new Map<string, any>();
|
||||
for (const adapter of [
|
||||
...mockBundledSiteAdapters,
|
||||
...mockImportedSiteAdapters,
|
||||
...mockServerSyncedSiteAdapters,
|
||||
]) {
|
||||
const normalizedName = String(adapter?.name ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
merged.set(normalizedName, adapter);
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function buildMockSiteCatalogStatus(
|
||||
sourceKind: "bundled" | "imported" | "server_synced",
|
||||
adapterCount: number,
|
||||
overrides?: Partial<{
|
||||
exists: boolean;
|
||||
registry_version: number;
|
||||
directory: string;
|
||||
catalog_version: string | null;
|
||||
tenant_id: string | null;
|
||||
synced_at: string | null;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
exists: overrides?.exists ?? sourceKind !== "bundled",
|
||||
source_kind: sourceKind,
|
||||
registry_version: overrides?.registry_version ?? 1,
|
||||
directory:
|
||||
overrides?.directory ??
|
||||
(sourceKind === "imported"
|
||||
? "/tmp/lime/site-adapters/imported"
|
||||
: "/tmp/lime/site-adapters/server-synced"),
|
||||
catalog_version: overrides?.catalog_version ?? undefined,
|
||||
tenant_id: overrides?.tenant_id ?? undefined,
|
||||
synced_at: overrides?.synced_at ?? undefined,
|
||||
adapter_count: adapterCount,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMockSiteAdapterPayload(
|
||||
adapter: any,
|
||||
sourceKind: "imported" | "server_synced",
|
||||
) {
|
||||
const name = String(adapter?.name ?? "").trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
domain: String(adapter?.domain ?? "example.com").trim() || "example.com",
|
||||
description:
|
||||
String(adapter?.description ?? "导入的站点适配器").trim() ||
|
||||
"导入的站点适配器",
|
||||
read_only: adapter?.read_only ?? adapter?.readOnly ?? true,
|
||||
capabilities: Array.isArray(adapter?.capabilities)
|
||||
? adapter.capabilities.map((item: unknown) => String(item))
|
||||
: ["research"],
|
||||
input_schema: { type: "object" },
|
||||
example_args: {},
|
||||
example: String(adapter?.example ?? `${name} {}`),
|
||||
auth_hint:
|
||||
typeof adapter?.auth_hint === "string" ? adapter.auth_hint : undefined,
|
||||
source_kind: sourceKind,
|
||||
source_version:
|
||||
typeof adapter?.source_version === "string"
|
||||
? adapter.source_version
|
||||
: typeof adapter?.sourceVersion === "string"
|
||||
? adapter.sourceVersion
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMockImportedYamlBundle(bundle: string, sourceVersion?: string) {
|
||||
return bundle
|
||||
.split(/^---\s*$/m)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item, index) => {
|
||||
const site = item.match(/(?:^|\n)site:\s*([^\n]+)/)?.[1]?.trim();
|
||||
const name = item.match(/(?:^|\n)name:\s*([^\n]+)/)?.[1]?.trim();
|
||||
const domain = item.match(/(?:^|\n)domain:\s*([^\n]+)/)?.[1]?.trim();
|
||||
const description =
|
||||
item.match(/(?:^|\n)description:\s*([^\n]+)/)?.[1]?.trim() ??
|
||||
"从外部来源导入的站点适配器";
|
||||
if (!site || !name || !domain) {
|
||||
throw new Error(`第 ${index + 1} 个 YAML 文档缺少 site/name/domain`);
|
||||
}
|
||||
return {
|
||||
name: `${site}/${name}`,
|
||||
domain,
|
||||
description,
|
||||
read_only: true,
|
||||
capabilities: ["research"],
|
||||
input_schema: { type: "object" },
|
||||
example_args: {},
|
||||
example: `${site}/${name} {}`,
|
||||
source_kind: "imported",
|
||||
source_version: sourceVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function upsertMockBrowserSessionState(launchResponse: any) {
|
||||
mockBrowserSessionStates.set(
|
||||
launchResponse.session.session_id,
|
||||
@@ -1503,7 +1652,7 @@ const defaultMocks: Record<string, any> = {
|
||||
open_window: args?.request?.open_window,
|
||||
stream_mode: args?.request?.stream_mode,
|
||||
}),
|
||||
site_list_adapters: () => mockSiteAdapters,
|
||||
site_list_adapters: () => getMockEffectiveSiteAdapters(),
|
||||
site_recommend_adapters: (args: any) => {
|
||||
const rawLimit = Number(
|
||||
args?.request?.limit ?? mockSiteRecommendations.length,
|
||||
@@ -1517,25 +1666,81 @@ const defaultMocks: Record<string, any> = {
|
||||
const query = String(args?.request?.query ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const effectiveAdapters = getMockEffectiveSiteAdapters();
|
||||
if (!query) {
|
||||
return mockSiteAdapters;
|
||||
return effectiveAdapters;
|
||||
}
|
||||
return mockSiteAdapters.filter(
|
||||
return effectiveAdapters.filter(
|
||||
(adapter) =>
|
||||
adapter.name.toLowerCase().includes(query) ||
|
||||
adapter.domain.toLowerCase().includes(query) ||
|
||||
adapter.description.toLowerCase().includes(query) ||
|
||||
adapter.capabilities.some((item) => item.toLowerCase().includes(query)),
|
||||
adapter.capabilities.some((item: string) =>
|
||||
item.toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
},
|
||||
site_get_adapter_info: (args: any) => {
|
||||
const name = String(args?.request?.name ?? "");
|
||||
const adapter = mockSiteAdapters.find((item) => item.name === name);
|
||||
const adapter = getMockEffectiveSiteAdapters().find(
|
||||
(item) => item.name === name,
|
||||
);
|
||||
if (!adapter) {
|
||||
throw new Error("未找到对应的站点适配器");
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
site_get_adapter_launch_readiness: (args: any) => {
|
||||
const request = args?.request ?? {};
|
||||
const adapterName = String(request.adapter_name ?? "");
|
||||
const adapter = getMockEffectiveSiteAdapters().find(
|
||||
(item) => item.name === adapterName,
|
||||
);
|
||||
if (!adapter) {
|
||||
throw new Error("未找到对应的站点适配器");
|
||||
}
|
||||
|
||||
const requestedProfileKey =
|
||||
typeof request.profile_key === "string" ? request.profile_key.trim() : "";
|
||||
const requestedTargetId =
|
||||
typeof request.target_id === "string" ? request.target_id.trim() : "";
|
||||
const matchingTab = mockExistingSessionTabs.find((tab) =>
|
||||
tab.url?.toLowerCase().includes(adapter.domain.replace(/^www\./, "")),
|
||||
);
|
||||
|
||||
if (requestedProfileKey === "general_browser_assist") {
|
||||
return {
|
||||
status: "requires_browser_runtime",
|
||||
adapter: adapter.name,
|
||||
domain: adapter.domain,
|
||||
profile_key: requestedProfileKey,
|
||||
message:
|
||||
"当前资料属于 Lime 托管浏览器,不允许在 Claw 内静默接管执行;请改走浏览器工作台。",
|
||||
report_hint:
|
||||
"Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。",
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedTargetId || matchingTab) {
|
||||
return {
|
||||
status: "ready",
|
||||
adapter: adapter.name,
|
||||
domain: adapter.domain,
|
||||
profile_key: requestedProfileKey || "attached-site-session",
|
||||
target_id: requestedTargetId || String(matchingTab?.id ?? "mock-target-1"),
|
||||
message: `已检测到 ${adapter.domain} 的真实浏览器页面,Claw 可以直接复用当前会话执行。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "requires_browser_runtime",
|
||||
adapter: adapter.name,
|
||||
domain: adapter.domain,
|
||||
message: `当前没有检测到已附着到真实浏览器的 ${adapter.domain} 页面,请先去浏览器工作台连接浏览器并打开目标页面。`,
|
||||
report_hint:
|
||||
"Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。",
|
||||
};
|
||||
},
|
||||
site_get_adapter_catalog_status: () => mockSiteAdapterCatalogStatus,
|
||||
site_apply_adapter_catalog_bootstrap: (args: any) => {
|
||||
const payload =
|
||||
@@ -1547,60 +1752,95 @@ const defaultMocks: Record<string, any> = {
|
||||
)
|
||||
? payload.adapters
|
||||
: [];
|
||||
const normalizedBundledNames = new Set(
|
||||
mockSiteAdapters.map((adapter) =>
|
||||
String(adapter.name).trim().toLowerCase(),
|
||||
),
|
||||
mockServerSyncedSiteAdapters = syncedAdapters
|
||||
.map((adapter) =>
|
||||
normalizeMockSiteAdapterPayload(adapter, "server_synced"),
|
||||
)
|
||||
.filter(Boolean);
|
||||
mockSiteAdapterCatalogStatus = buildMockSiteCatalogStatus(
|
||||
"server_synced",
|
||||
mockServerSyncedSiteAdapters.length,
|
||||
{
|
||||
exists: syncedAdapters.length > 0,
|
||||
registry_version:
|
||||
Number.isFinite(payload?.registry_version) &&
|
||||
payload.registry_version > 0
|
||||
? payload.registry_version
|
||||
: 1,
|
||||
directory: "/tmp/lime/site-adapters/server-synced",
|
||||
catalog_version:
|
||||
payload?.catalogVersion ??
|
||||
payload?.catalog_version ??
|
||||
payload?.version ??
|
||||
null,
|
||||
tenant_id: payload?.tenantId ?? payload?.tenant_id ?? null,
|
||||
synced_at: payload?.syncedAt ?? payload?.synced_at ?? null,
|
||||
},
|
||||
);
|
||||
const normalizedSyncedNames = new Set<string>(
|
||||
syncedAdapters.reduce<string[]>((names, adapter) => {
|
||||
if (typeof adapter?.name !== "string") {
|
||||
return names;
|
||||
}
|
||||
|
||||
const normalizedName = adapter.name.trim().toLowerCase();
|
||||
if (normalizedName) {
|
||||
names.push(normalizedName);
|
||||
}
|
||||
|
||||
return names;
|
||||
}, []),
|
||||
);
|
||||
const adapterCount =
|
||||
mockSiteAdapters.length +
|
||||
Array.from(normalizedSyncedNames).filter(
|
||||
(name) => !normalizedBundledNames.has(name),
|
||||
).length;
|
||||
mockSiteAdapterCatalogStatus = {
|
||||
exists: syncedAdapters.length > 0,
|
||||
source_kind: "server_synced",
|
||||
registry_version:
|
||||
Number.isFinite(payload?.registry_version) &&
|
||||
payload.registry_version > 0
|
||||
? payload.registry_version
|
||||
: 1,
|
||||
directory: "/tmp/lime/site-adapters/server-synced",
|
||||
catalog_version:
|
||||
payload?.catalogVersion ?? payload?.catalog_version ?? payload?.version,
|
||||
tenant_id: payload?.tenantId ?? payload?.tenant_id,
|
||||
synced_at: payload?.syncedAt ?? payload?.synced_at,
|
||||
adapter_count: adapterCount,
|
||||
};
|
||||
return mockSiteAdapterCatalogStatus;
|
||||
},
|
||||
site_clear_adapter_catalog_cache: () => {
|
||||
mockSiteAdapterCatalogStatus = {
|
||||
exists: false,
|
||||
source_kind: "bundled",
|
||||
registry_version: 1,
|
||||
directory: "/tmp/lime/site-adapters/server-synced",
|
||||
adapter_count: mockSiteAdapters.length,
|
||||
site_import_adapter_yaml_bundle: (args: any) => {
|
||||
const yamlBundle = String(args?.request?.yaml_bundle ?? "").trim();
|
||||
if (!yamlBundle) {
|
||||
throw new Error("请先输入外部来源 YAML");
|
||||
}
|
||||
|
||||
mockImportedSiteAdapters = parseMockImportedYamlBundle(
|
||||
yamlBundle,
|
||||
typeof args?.request?.source_version === "string"
|
||||
? args.request.source_version
|
||||
: undefined,
|
||||
);
|
||||
const catalogVersion =
|
||||
typeof args?.request?.catalog_version === "string"
|
||||
? args.request.catalog_version
|
||||
: undefined;
|
||||
mockSiteAdapterCatalogStatus = buildMockSiteCatalogStatus(
|
||||
"imported",
|
||||
mockImportedSiteAdapters.length,
|
||||
{
|
||||
directory: "/tmp/lime/site-adapters/imported",
|
||||
catalog_version: catalogVersion ?? null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
directory: "/tmp/lime/site-adapters/imported",
|
||||
adapter_count: mockImportedSiteAdapters.length,
|
||||
catalog_version: catalogVersion,
|
||||
};
|
||||
},
|
||||
site_clear_adapter_catalog_cache: () => {
|
||||
mockImportedSiteAdapters = [];
|
||||
mockServerSyncedSiteAdapters = [];
|
||||
mockSiteAdapterCatalogStatus = buildMockSiteCatalogStatus(
|
||||
"bundled",
|
||||
mockBundledSiteAdapters.length,
|
||||
{
|
||||
exists: false,
|
||||
},
|
||||
);
|
||||
return mockSiteAdapterCatalogStatus;
|
||||
},
|
||||
site_run_adapter: (args: any) => {
|
||||
const request = args?.request ?? {};
|
||||
const adapterName = String(request.adapter_name ?? "");
|
||||
if (
|
||||
request.require_attached_session &&
|
||||
(!request.profile_key || request.profile_key === "general_browser_assist")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
adapter: adapterName || "github/search",
|
||||
domain: adapterName.startsWith("zhihu") ? "www.zhihu.com" : "github.com",
|
||||
profile_key: request.profile_key ?? "general_browser_assist",
|
||||
entry_url: "https://example.com/mock-site",
|
||||
error_code: "attached_session_required",
|
||||
error_message:
|
||||
"当前执行链路没有附着到真实浏览器会话,请先去浏览器工作台连接目标站点后重试。",
|
||||
report_hint:
|
||||
"Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。",
|
||||
};
|
||||
}
|
||||
const targetContentId =
|
||||
typeof request.content_id === "string" && request.content_id.trim()
|
||||
? request.content_id.trim()
|
||||
@@ -1728,14 +1968,25 @@ const defaultMocks: Record<string, any> = {
|
||||
"ws://127.0.0.1:8999/lime-chrome-control/Lime_Key=proxy_cast",
|
||||
bridge_key: "proxy_cast",
|
||||
}),
|
||||
get_chrome_bridge_status: () => ({
|
||||
observer_count: 0,
|
||||
control_count: 0,
|
||||
pending_command_count: 0,
|
||||
observers: [],
|
||||
controls: [],
|
||||
pending_commands: [],
|
||||
}),
|
||||
get_chrome_bridge_status: () => mockChromeBridgeStatus,
|
||||
disconnect_browser_connector_session: () => {
|
||||
const disconnectedObserverCount = mockChromeBridgeStatus.observer_count;
|
||||
const disconnectedControlCount = mockChromeBridgeStatus.control_count;
|
||||
mockChromeBridgeStatus = {
|
||||
...mockChromeBridgeStatus,
|
||||
observer_count: 0,
|
||||
control_count: 0,
|
||||
pending_command_count: 0,
|
||||
observers: [],
|
||||
controls: [],
|
||||
pending_commands: [],
|
||||
};
|
||||
return {
|
||||
disconnected_observer_count: disconnectedObserverCount,
|
||||
disconnected_control_count: disconnectedControlCount,
|
||||
status: mockChromeBridgeStatus,
|
||||
};
|
||||
},
|
||||
get_browser_connector_settings_cmd: () => mockBrowserConnectorSettings,
|
||||
set_browser_connector_install_root_cmd: (args: any) => {
|
||||
const installRootDir =
|
||||
@@ -1769,7 +2020,13 @@ const defaultMocks: Record<string, any> = {
|
||||
system_connectors: mockBrowserConnectorSettings.system_connectors.map(
|
||||
(connector) =>
|
||||
connector.id === request.id
|
||||
? { ...connector, enabled: request.enabled === true }
|
||||
? {
|
||||
...connector,
|
||||
enabled: request.enabled === true,
|
||||
authorization_status:
|
||||
request.enabled === true ? "authorized" : "not_determined",
|
||||
last_error: null,
|
||||
}
|
||||
: connector,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
/**
|
||||
* Mock open function (opens URL in external browser)
|
||||
*/
|
||||
export async function open(path: string, openWith?: string): Promise<void> {
|
||||
console.log("[Mock] Shell open:", path, openWith ? `with: ${openWith}` : "");
|
||||
|
||||
export async function open(path: string, _openWith?: string): Promise<void> {
|
||||
// 在浏览器开发环境中,直接在当前标签页打开
|
||||
if (typeof window !== "undefined") {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
|
||||
+88
-33
@@ -7,6 +7,20 @@
|
||||
* @module lib/webview-api
|
||||
*/
|
||||
|
||||
import {
|
||||
siteApplyAdapterCatalogBootstrap as applySiteAdapterCatalogBootstrapViaRuntime,
|
||||
siteClearAdapterCatalogCache as clearSiteAdapterCatalogCacheViaRuntime,
|
||||
siteDebugRunAdapter as debugRunSiteAdapterViaRuntime,
|
||||
siteGetAdapterCatalogStatus as getSiteAdapterCatalogStatusViaRuntime,
|
||||
siteGetAdapterInfo as getSiteAdapterInfoViaRuntime,
|
||||
siteGetAdapterLaunchReadiness as getSiteAdapterLaunchReadinessViaRuntime,
|
||||
siteImportAdapterYamlBundle as importSiteAdapterYamlBundleViaRuntime,
|
||||
siteListAdapters as listSiteAdaptersViaRuntime,
|
||||
siteRecommendAdapters as recommendSiteAdaptersViaRuntime,
|
||||
siteRunAdapter as runSiteAdapterViaRuntime,
|
||||
siteSaveAdapterResult as saveSiteAdapterResultViaRuntime,
|
||||
siteSearchAdapters as searchSiteAdaptersViaRuntime,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
/**
|
||||
@@ -255,6 +269,12 @@ export interface ChromeBridgeStatusSnapshot {
|
||||
pending_commands: ChromeBridgePendingCommandSnapshot[];
|
||||
}
|
||||
|
||||
export interface ChromeBridgeDisconnectResult {
|
||||
disconnected_observer_count: number;
|
||||
disconnected_control_count: number;
|
||||
status: ChromeBridgeStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeCommandRequest {
|
||||
profile_key?: string;
|
||||
command: string;
|
||||
@@ -282,6 +302,10 @@ export interface SystemConnectorSnapshot {
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
visible: boolean;
|
||||
authorization_status: string;
|
||||
last_error?: string | null;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface BrowserConnectorSettingsSnapshot {
|
||||
@@ -380,7 +404,7 @@ export interface SiteAdapterDefinition {
|
||||
example_args: Record<string, unknown>;
|
||||
example: string;
|
||||
auth_hint?: string;
|
||||
source_kind?: "bundled" | "server_synced";
|
||||
source_kind?: "bundled" | "imported" | "server_synced";
|
||||
source_version?: string;
|
||||
}
|
||||
|
||||
@@ -395,7 +419,7 @@ export interface SiteAdapterRecommendation {
|
||||
|
||||
export interface SiteAdapterCatalogStatus {
|
||||
exists: boolean;
|
||||
source_kind: "bundled" | "server_synced";
|
||||
source_kind: "bundled" | "imported" | "server_synced";
|
||||
registry_version: number;
|
||||
directory?: string;
|
||||
catalog_version?: string;
|
||||
@@ -404,6 +428,35 @@ export interface SiteAdapterCatalogStatus {
|
||||
adapter_count: number;
|
||||
}
|
||||
|
||||
export interface SiteAdapterImportYamlBundleRequest {
|
||||
yaml_bundle: string;
|
||||
catalog_version?: string;
|
||||
source_version?: string;
|
||||
read_only?: boolean;
|
||||
}
|
||||
|
||||
export interface SiteAdapterImportResult {
|
||||
directory: string;
|
||||
adapter_count: number;
|
||||
catalog_version?: string;
|
||||
}
|
||||
|
||||
export interface SiteAdapterLaunchReadinessRequest {
|
||||
adapter_name: string;
|
||||
profile_key?: string;
|
||||
target_id?: string;
|
||||
}
|
||||
|
||||
export interface SiteAdapterLaunchReadinessResult {
|
||||
status: "ready" | "requires_browser_runtime";
|
||||
adapter: string;
|
||||
domain: string;
|
||||
profile_key?: string;
|
||||
target_id?: string;
|
||||
message: string;
|
||||
report_hint?: string;
|
||||
}
|
||||
|
||||
export interface RunSiteAdapterRequest {
|
||||
adapter_name: string;
|
||||
args?: Record<string, unknown>;
|
||||
@@ -413,6 +466,8 @@ export interface RunSiteAdapterRequest {
|
||||
content_id?: string;
|
||||
project_id?: string;
|
||||
save_title?: string;
|
||||
require_attached_session?: boolean;
|
||||
skill_title?: string;
|
||||
}
|
||||
|
||||
export interface SiteAdapterRunResult {
|
||||
@@ -838,6 +893,15 @@ export async function getChromeBridgeStatus(): Promise<ChromeBridgeStatusSnapsho
|
||||
return safeInvoke<ChromeBridgeStatusSnapshot>("get_chrome_bridge_status");
|
||||
}
|
||||
|
||||
export async function disconnectBrowserConnectorSession(params?: {
|
||||
profile_key?: string;
|
||||
}): Promise<ChromeBridgeDisconnectResult> {
|
||||
return safeInvoke<ChromeBridgeDisconnectResult>(
|
||||
"disconnect_browser_connector_session",
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrowserConnectorSettings(): Promise<BrowserConnectorSettingsSnapshot> {
|
||||
return safeInvoke<BrowserConnectorSettingsSnapshot>(
|
||||
"get_browser_connector_settings_cmd",
|
||||
@@ -1077,78 +1141,69 @@ export async function browserExecuteAction(
|
||||
}
|
||||
|
||||
export async function siteListAdapters(): Promise<SiteAdapterDefinition[]> {
|
||||
return safeInvoke<SiteAdapterDefinition[]>("site_list_adapters");
|
||||
return listSiteAdaptersViaRuntime();
|
||||
}
|
||||
|
||||
export async function siteRecommendAdapters(
|
||||
limit?: number,
|
||||
): Promise<SiteAdapterRecommendation[]> {
|
||||
return safeInvoke<SiteAdapterRecommendation[]>("site_recommend_adapters", {
|
||||
request: { limit },
|
||||
});
|
||||
return recommendSiteAdaptersViaRuntime(limit);
|
||||
}
|
||||
|
||||
export async function siteSearchAdapters(
|
||||
query: string,
|
||||
): Promise<SiteAdapterDefinition[]> {
|
||||
return safeInvoke<SiteAdapterDefinition[]>("site_search_adapters", {
|
||||
request: { query },
|
||||
});
|
||||
return searchSiteAdaptersViaRuntime(query);
|
||||
}
|
||||
|
||||
export async function siteGetAdapterInfo(
|
||||
name: string,
|
||||
): Promise<SiteAdapterDefinition> {
|
||||
return safeInvoke<SiteAdapterDefinition>("site_get_adapter_info", {
|
||||
request: { name },
|
||||
});
|
||||
return getSiteAdapterInfoViaRuntime(name);
|
||||
}
|
||||
|
||||
export async function siteGetAdapterLaunchReadiness(
|
||||
request: SiteAdapterLaunchReadinessRequest,
|
||||
): Promise<SiteAdapterLaunchReadinessResult> {
|
||||
return getSiteAdapterLaunchReadinessViaRuntime(request);
|
||||
}
|
||||
|
||||
export async function siteGetAdapterCatalogStatus(): Promise<SiteAdapterCatalogStatus> {
|
||||
return safeInvoke<SiteAdapterCatalogStatus>(
|
||||
"site_get_adapter_catalog_status",
|
||||
);
|
||||
return getSiteAdapterCatalogStatusViaRuntime();
|
||||
}
|
||||
|
||||
export async function siteApplyAdapterCatalogBootstrap(
|
||||
payload: unknown,
|
||||
): Promise<SiteAdapterCatalogStatus> {
|
||||
return safeInvoke<SiteAdapterCatalogStatus>(
|
||||
"site_apply_adapter_catalog_bootstrap",
|
||||
{
|
||||
request: {
|
||||
payload,
|
||||
},
|
||||
},
|
||||
);
|
||||
return applySiteAdapterCatalogBootstrapViaRuntime(payload);
|
||||
}
|
||||
|
||||
export async function siteClearAdapterCatalogCache(): Promise<SiteAdapterCatalogStatus> {
|
||||
return safeInvoke<SiteAdapterCatalogStatus>(
|
||||
"site_clear_adapter_catalog_cache",
|
||||
);
|
||||
return clearSiteAdapterCatalogCacheViaRuntime();
|
||||
}
|
||||
|
||||
export async function siteImportAdapterYamlBundle(
|
||||
request: SiteAdapterImportYamlBundleRequest,
|
||||
): Promise<SiteAdapterImportResult> {
|
||||
return importSiteAdapterYamlBundleViaRuntime(request);
|
||||
}
|
||||
|
||||
export async function siteRunAdapter(
|
||||
request: RunSiteAdapterRequest,
|
||||
): Promise<SiteAdapterRunResult> {
|
||||
return safeInvoke<SiteAdapterRunResult>("site_run_adapter", { request });
|
||||
return runSiteAdapterViaRuntime(request);
|
||||
}
|
||||
|
||||
export async function siteDebugRunAdapter(
|
||||
request: RunSiteAdapterRequest,
|
||||
): Promise<SiteAdapterRunResult> {
|
||||
return safeInvoke<SiteAdapterRunResult>("site_debug_run_adapter", {
|
||||
request,
|
||||
});
|
||||
return debugRunSiteAdapterViaRuntime(request);
|
||||
}
|
||||
|
||||
export async function siteSaveAdapterResult(
|
||||
request: SaveSiteAdapterResultRequest,
|
||||
): Promise<SavedSiteAdapterContent> {
|
||||
return safeInvoke<SavedSiteAdapterContent>("site_save_adapter_result", {
|
||||
request,
|
||||
});
|
||||
return saveSiteAdapterResultViaRuntime(request);
|
||||
}
|
||||
|
||||
export async function getBrowserRuntimeAuditLogs(
|
||||
|
||||
Reference in New Issue
Block a user