chore: release v0.98.0

This commit is contained in:
coso
2026-03-29 14:15:34 +08:00
parent dfbbb3d204
commit deceacc699
435 changed files with 35673 additions and 8535 deletions
+48
View File
@@ -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",
+14
View File
@@ -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;
+39 -1
View File
@@ -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",
+17 -4
View File
@@ -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,
+112
View File
@@ -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)
*/
+105
View File
@@ -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);
});
});
+40
View File
@@ -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);
}
+40
View File
@@ -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));
}
+31
View File
@@ -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> {
+5
View File
@@ -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;
+17 -1
View File
@@ -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");
});
});
+5
View File
@@ -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);
}
+85
View File
@@ -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 });
}
+11 -4
View File
@@ -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",
});
});
});
+2
View File
@@ -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)
+16
View File
@@ -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 });
}
+35 -1
View File
@@ -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
View File
@@ -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();
});
}
+14
View File
@@ -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 });
}
+67
View File
@@ -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",
});
});
});
+48
View File
@@ -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);
}
+63 -5
View File
@@ -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");
+446 -2
View File
@@ -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),
})),
};
}
+153
View File
@@ -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
+37
View File
@@ -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);
});
});
+26
View File
@@ -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);
+53 -7
View File
@@ -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;
}
-2
View File
@@ -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";
+4 -4
View File
@@ -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;
/** 下载时的文件扩展名 */
+92
View File
@@ -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);
});
});
+146
View File
@@ -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 命令
*
+1
View File
@@ -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",
+46 -12
View File
@@ -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();
}
});
});
+11
View File
@@ -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 () => {};
+20
View File
@@ -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;
}
+13 -1
View File
@@ -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([
"当前技能 ",
"当前已启用 ",
"为当前任务挂载额外能力",
"按需挂载能力",
"项技能可用",
]),
);
});
});
+16 -1
View File
@@ -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,
];
+2
View File
@@ -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({
+108
View File
@@ -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);
};
}
+24 -14
View File
@@ -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
View File
@@ -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,
),
};
+1 -3
View File
@@ -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
View File
@@ -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(