Release v0.90.0

This commit is contained in:
coso
2026-03-18 10:39:02 +08:00
parent 157e400c97
commit 8c2cf9f97b
225 changed files with 11935 additions and 7890 deletions
+174
View File
@@ -0,0 +1,174 @@
import { reportFrontendDebugLog } from "@/lib/api/frontendDebug";
export const AGENT_DEBUG_FLAG_KEY = "lime:agent-debug";
type AgentDebugLevel = "debug" | "info" | "warn" | "error";
interface AgentDebugOptions {
level?: AgentDebugLevel;
throttleMs?: number;
dedupeKey?: string;
consoleOnly?: boolean;
}
type SerializableDebugValue =
| null
| boolean
| number
| string
| SerializableDebugValue[]
| { [key: string]: SerializableDebugValue };
const logThrottleMap = new Map<string, number>();
function readAgentDebugFlag(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(AGENT_DEBUG_FLAG_KEY);
} catch {
return null;
}
}
export function isAgentDebugEnabled(): boolean {
const flag = readAgentDebugFlag();
if (flag === "0") {
return false;
}
if (flag === "1") {
return true;
}
if (import.meta.env.MODE === "test") {
return false;
}
return Boolean(import.meta.env.DEV);
}
function sanitizeDebugValue(
value: unknown,
depth = 0,
): SerializableDebugValue | undefined {
if (value == null) {
return null;
}
if (
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string"
) {
if (typeof value === "string" && value.length > 300) {
return `${value.slice(0, 300)}...`;
}
return value;
}
if (value instanceof Date) {
return value.toISOString();
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack
? value.stack.split("\n").slice(0, 4).join(" | ")
: null,
};
}
if (depth >= 3) {
return String(value);
}
if (Array.isArray(value)) {
return value
.slice(0, 12)
.map((item) => sanitizeDebugValue(item, depth + 1) ?? null);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.slice(0, 24);
const result: Record<string, SerializableDebugValue> = {};
for (const [key, item] of entries) {
const sanitized = sanitizeDebugValue(item, depth + 1);
if (sanitized !== undefined) {
result[key] = sanitized;
}
}
return result;
}
return String(value);
}
function getConsoleMethod(level: AgentDebugLevel): typeof console.log {
if (level === "error") {
return console.error;
}
if (level === "warn") {
return console.warn;
}
if (level === "debug") {
return console.debug;
}
return console.info;
}
function shouldEmitLog(key: string, throttleMs: number): boolean {
if (throttleMs <= 0) {
return true;
}
const now = Date.now();
const lastLoggedAt = logThrottleMap.get(key) ?? 0;
if (now - lastLoggedAt < throttleMs) {
return false;
}
logThrottleMap.set(key, now);
return true;
}
export function logAgentDebug(
component: string,
phase: string,
context?: Record<string, unknown>,
options: AgentDebugOptions = {},
): void {
if (!isAgentDebugEnabled()) {
return;
}
const level = options.level ?? "info";
const sanitizedContext = sanitizeDebugValue(context ?? {}) as
| Record<string, SerializableDebugValue>
| undefined;
const dedupeKey =
options.dedupeKey ??
`${component}:${phase}:${JSON.stringify(sanitizedContext ?? {})}`;
if (!shouldEmitLog(dedupeKey, options.throttleMs ?? 0)) {
return;
}
getConsoleMethod(level)(
`[AgentDebug] ${component}.${phase}`,
sanitizedContext ?? {},
);
if (options.consoleOnly) {
return;
}
void reportFrontendDebugLog({
level,
category: "agent",
message: `${component}.${phase}`,
context: sanitizedContext ?? {},
}).catch(() => {
// 调试日志不上抛,避免影响主流程
});
}
+167 -78
View File
@@ -8,12 +8,15 @@ vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockSafeInvoke,
}));
import * as AgentApi from "./agent";
import {
createAgentRuntimeSession,
deleteAgentRuntimeSession,
getAsterAgentStatus,
generateAgentRuntimeSessionTitle,
getAgentRuntimeSession,
interruptAgentRuntimeTurn,
listAgentRuntimeSessions,
respondAgentRuntimeAction,
sendAsterMessageStream,
submitAgentRuntimeTurn,
updateAgentRuntimeSession,
} from "./agentRuntime";
@@ -23,68 +26,21 @@ describe("Agent API 治理护栏", () => {
vi.clearAllMocks();
});
it("sendAsterMessageStream 应走统一 helper 并透传现役字段", async () => {
mockSafeInvoke.mockResolvedValueOnce(undefined);
it("createAgentRuntimeSession 应走统一 runtime create 命令", async () => {
mockSafeInvoke.mockResolvedValueOnce("session-created");
await sendAsterMessageStream(
"hello",
"session-2",
"event-2",
"workspace-2",
[{ data: "base64", media_type: "image/jpeg" }],
await expect(
createAgentRuntimeSession("workspace-2", "新会话", "auto"),
).resolves.toBe("session-created");
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_create_session",
{
provider_id: "provider-2",
provider_name: "Provider 2",
model_name: "model-2",
},
"auto",
true,
{
enabled: true,
fast_mode_enabled: false,
continuation_length: 256,
sensitivity: 0.4,
},
"system prompt",
"project-2",
{
harness: {
theme: "social-media",
gate_key: "write_mode",
},
workspaceId: "workspace-2",
name: "新会话",
executionStrategy: "auto",
},
);
expect(mockSafeInvoke).toHaveBeenCalledWith("aster_agent_chat_stream", {
request: {
message: "hello",
session_id: "session-2",
event_name: "event-2",
images: [{ data: "base64", media_type: "image/jpeg" }],
provider_config: {
provider_id: "provider-2",
provider_name: "Provider 2",
model_name: "model-2",
},
project_id: "project-2",
workspace_id: "workspace-2",
execution_strategy: "auto",
web_search: true,
auto_continue: {
enabled: true,
fast_mode_enabled: false,
continuation_length: 256,
sensitivity: 0.4,
},
system_prompt: "system prompt",
metadata: {
harness: {
theme: "social-media",
gate_key: "write_mode",
},
},
},
});
});
it("getAsterAgentStatus 应返回现役状态结构", async () => {
@@ -187,20 +143,23 @@ describe("Agent API 治理护栏", () => {
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: "{\"answer\":\"A\"}",
response: '{"answer":"A"}',
user_data: { answer: "A" },
});
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_respond_action", {
request: {
session_id: "session-runtime",
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: "{\"answer\":\"A\"}",
user_data: { answer: "A" },
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_respond_action",
{
request: {
session_id: "session-runtime",
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: '{"answer":"A"}',
user_data: { answer: "A" },
},
},
});
);
});
it("interruptAgentRuntimeTurn 与 updateAgentRuntimeSession 应走统一 runtime 命令", async () => {
@@ -239,13 +198,143 @@ describe("Agent API 治理护栏", () => {
);
});
it("agent 门面只暴露现役 API", () => {
expect("sendAsterMessageStream" in AgentApi).toBe(true);
expect("getAsterAgentStatus" in AgentApi).toBe(true);
expect("submitAgentRuntimeTurn" in AgentApi).toBe(true);
expect("respondAgentRuntimeAction" in AgentApi).toBe(true);
expect("createasterSession" in AgentApi).toBe(false);
expect("sendAgentMessage" in AgentApi).toBe(false);
expect("getasterAgentStatus" in AgentApi).toBe(false);
it("listAgentRuntimeSessions 应返回现役 runtime 会话列表", async () => {
mockSafeInvoke.mockResolvedValueOnce([
{
id: "session-runtime-1",
name: "Runtime Session",
model: "claude-sonnet-4-20250514",
created_at: 1710000000,
updated_at: 1710000123,
messages_count: 3,
execution_strategy: "auto",
workspace_id: "workspace-1",
working_dir: "/tmp/workspace-1",
},
]);
await expect(listAgentRuntimeSessions()).resolves.toEqual([
{
id: "session-runtime-1",
name: "Runtime Session",
model: "claude-sonnet-4-20250514",
created_at: 1710000000,
updated_at: 1710000123,
messages_count: 3,
workspace_id: "workspace-1",
working_dir: "/tmp/workspace-1",
execution_strategy: "auto",
},
]);
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_list_sessions");
});
it("getAgentRuntimeSession 应返回现役 runtime 详情并归一 queued_turns", async () => {
mockSafeInvoke.mockResolvedValueOnce({
id: "session-runtime-2",
name: "Runtime Detail",
model: "gpt-5.4",
created_at: 1710001000,
updated_at: 1710002000,
workspace_id: "workspace-2",
working_dir: "/tmp/workspace-2",
execution_strategy: "react",
queued_turns: [
{
queued_turn_id: "queued-1",
message_text: "排队中的任务",
message_preview: "排队中的任务",
created_at: 1710001500,
image_count: 0,
position: 2,
},
],
messages: [
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1710001000,
},
{
role: "assistant",
content: [{ type: "text", text: "world" }],
timestamp: 1710002000,
},
],
});
await expect(getAgentRuntimeSession("session-runtime-2")).resolves.toEqual({
id: "session-runtime-2",
name: "Runtime Detail",
model: "gpt-5.4",
created_at: 1710001000,
updated_at: 1710002000,
workspace_id: "workspace-2",
working_dir: "/tmp/workspace-2",
execution_strategy: "react",
queued_turns: [
{
queued_turn_id: "queued-1",
message_text: "排队中的任务",
message_preview: "排队中的任务",
created_at: 1710001500,
image_count: 0,
position: 2,
},
],
messages: [
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1710001000,
},
{
role: "assistant",
content: [{ type: "text", text: "world" }],
timestamp: 1710002000,
},
],
});
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_get_session", {
sessionId: "session-runtime-2",
});
});
it("deleteAgentRuntimeSession / updateAgentRuntimeSession / generateAgentRuntimeSessionTitle 应走现役命令", async () => {
mockSafeInvoke
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce("新的智能标题");
await deleteAgentRuntimeSession("session-runtime-3");
await updateAgentRuntimeSession({
session_id: "session-runtime-3",
name: "重命名后的标题",
});
await expect(
generateAgentRuntimeSessionTitle("session-runtime-3"),
).resolves.toBe("新的智能标题");
expect(mockSafeInvoke).toHaveBeenNthCalledWith(
1,
"agent_runtime_delete_session",
{
sessionId: "session-runtime-3",
},
);
expect(mockSafeInvoke).toHaveBeenNthCalledWith(
2,
"agent_runtime_update_session",
{
request: {
session_id: "session-runtime-3",
name: "重命名后的标题",
},
},
);
expect(mockSafeInvoke).toHaveBeenNthCalledWith(3, "agent_generate_title", {
sessionId: "session-runtime-3",
});
});
});
-12
View File
@@ -1,12 +0,0 @@
/**
* Agent API 现役门面
*
* 新代码请优先直接使用:
* - `agentRuntime.ts`:现役运行时 API
* - `agentStream.ts`:流式事件与 UI 类型
*
* 历史 `agentCompat.ts` 已删除,避免新代码和 AI 自动补全继续误用旧 API。
*/
export * from "./agentRuntime";
export * from "./agentStream";
+66 -374
View File
@@ -5,6 +5,7 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { logAgentDebug } from "@/lib/agentDebug";
import type {
AgentThreadItem,
AgentThreadTurn,
@@ -26,36 +27,8 @@ export interface AgentProcessStatus {
port?: number;
}
/**
* 创建会话响应
*/
export interface CreateSessionResponse {
session_id: string;
credential_name: string;
credential_uuid: string;
provider_type: string;
model?: string;
execution_strategy?: AsterExecutionStrategy;
}
export type AsterExecutionStrategy = "react" | "code_orchestrated" | "auto";
/**
* 会话信息
*/
export interface SessionInfo {
session_id: string;
provider_type: string;
model?: string;
title?: string;
created_at: string;
last_activity: string;
messages_count: number;
workspace_id?: string;
working_dir?: string;
execution_strategy?: AsterExecutionStrategy;
}
/**
* 图片输入
*/
@@ -64,15 +37,6 @@ export interface ImageInput {
media_type: string;
}
/**
* Skill 信息
*/
export interface SkillInfo {
name: string;
description?: string;
path?: string;
}
const requireWorkspaceId = (
workspaceId?: string,
fallbackWorkspaceId?: string,
@@ -123,8 +87,11 @@ export interface AsterSessionInfo {
name?: string;
created_at: number;
updated_at: number;
model?: string;
messages_count?: number;
execution_strategy?: AsterExecutionStrategy;
workspace_id?: string;
working_dir?: string;
}
/**
@@ -133,6 +100,7 @@ export interface AsterSessionInfo {
export interface TauriMessageContent {
type: string;
text?: string;
image_url?: { url: string; detail?: string } | string;
id?: string;
action_type?: string;
data?: unknown;
@@ -154,6 +122,9 @@ export interface AsterSessionDetail {
name?: string;
created_at: number;
updated_at: number;
model?: string;
workspace_id?: string;
working_dir?: string;
execution_strategy?: AsterExecutionStrategy;
messages: Array<{
id?: string;
@@ -181,6 +152,7 @@ export interface AgentRuntimeSubmitTurnRequest {
session_id: string;
event_name: string;
workspace_id: string;
turn_id?: string;
images?: ImageInput[];
turn_config?: AgentTurnConfigSnapshot;
queue_if_busy?: boolean;
@@ -213,58 +185,6 @@ export interface AgentRuntimeUpdateSessionRequest {
execution_strategy?: AsterExecutionStrategy;
}
interface InvokeAsterChatStreamOptions {
message: string;
sessionId: string;
eventName: string;
workspaceId: string;
images?: ImageInput[];
providerConfig?: AsterProviderConfig;
executionStrategy?: AsterExecutionStrategy;
webSearch?: boolean;
searchMode?: AgentSearchMode;
autoContinue?: AutoContinueRequestPayload;
systemPrompt?: string;
projectId?: string;
metadata?: Record<string, unknown>;
}
const invokeAsterChatStream = async ({
message,
sessionId,
eventName,
workspaceId,
images,
providerConfig,
executionStrategy,
webSearch,
searchMode,
autoContinue,
systemPrompt,
projectId,
metadata,
}: InvokeAsterChatStreamOptions): Promise<void> => {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId, projectId);
return await safeInvoke("aster_agent_chat_stream", {
request: {
message,
session_id: sessionId,
event_name: eventName,
images,
provider_config: providerConfig,
project_id: projectId,
workspace_id: resolvedWorkspaceId,
execution_strategy: executionStrategy,
web_search: webSearch,
search_mode: searchMode,
auto_continue: autoContinue,
system_prompt: systemPrompt,
metadata,
},
});
};
export async function submitAgentRuntimeTurn(
request: AgentRuntimeSubmitTurnRequest,
): Promise<void> {
@@ -302,7 +222,58 @@ export async function createAgentRuntimeSession(
}
export async function listAgentRuntimeSessions(): Promise<AsterSessionInfo[]> {
return await safeInvoke("agent_runtime_list_sessions");
const startedAt = Date.now();
let settled = false;
const slowTimer: ReturnType<typeof setTimeout> | null =
typeof window !== "undefined"
? window.setTimeout(() => {
if (settled) {
return;
}
logAgentDebug(
"AgentApi",
"runtimeListSessions.slow",
{
elapsedMs: Date.now() - startedAt,
},
{
dedupeKey: "runtimeListSessions.slow",
level: "warn",
throttleMs: 1000,
},
);
}, 1000)
: null;
logAgentDebug("AgentApi", "runtimeListSessions.start");
try {
const sessions = await safeInvoke<AsterSessionInfo[]>(
"agent_runtime_list_sessions",
);
settled = true;
logAgentDebug("AgentApi", "runtimeListSessions.success", {
durationMs: Date.now() - startedAt,
sessionsCount: sessions.length,
});
return sessions;
} catch (error) {
settled = true;
logAgentDebug(
"AgentApi",
"runtimeListSessions.error",
{
durationMs: Date.now() - startedAt,
error,
},
{ level: "error" },
);
throw error;
} finally {
if (slowTimer !== null) {
clearTimeout(slowTimer);
}
}
}
export async function getAgentRuntimeSession(
@@ -351,114 +322,13 @@ export async function getAgentProcessStatus(): Promise<AgentProcessStatus> {
}
/**
* 创建 Agent 会话
* 生成会话智能标题
*
* 现役 runtime 命名入口。
*/
export async function createAgentSession(
providerType: string,
workspaceId: string,
model?: string,
systemPrompt?: string,
skills?: SkillInfo[],
executionStrategy?: AsterExecutionStrategy,
): Promise<CreateSessionResponse> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("agent_create_session", {
providerType,
model,
systemPrompt,
skills,
workspaceId: resolvedWorkspaceId,
executionStrategy,
});
}
/**
* 获取会话列表
*/
export async function listAgentSessions(): Promise<SessionInfo[]> {
return await safeInvoke("agent_list_sessions");
}
/**
* 获取会话详情
*/
export async function getAgentSession(sessionId: string): Promise<SessionInfo> {
return await safeInvoke("agent_get_session", {
sessionId,
});
}
/**
* 删除会话
*/
export async function deleteAgentSession(sessionId: string): Promise<void> {
return await safeInvoke("agent_delete_session", {
sessionId,
});
}
/**
* Agent 消息内容类型
*/
export type AgentMessageContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string; detail?: string } }
>;
/**
* 工具调用
*/
export interface AgentToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}
/**
* Agent 消息
*/
export interface AgentMessage {
role: string;
content: AgentMessageContent;
timestamp: string;
tool_calls?: AgentToolCall[];
tool_call_id?: string;
}
/**
* 获取会话消息列表
*/
export async function getAgentSessionMessages(
export async function generateAgentRuntimeSessionTitle(
sessionId: string,
): Promise<AgentMessage[]> {
return await safeInvoke("agent_get_session_messages", {
sessionId,
});
}
/**
* 重命名会话(更新标题)
*/
export async function renameAgentSession(
sessionId: string,
title: string,
): Promise<void> {
return await safeInvoke("agent_rename_session", {
sessionId,
title,
});
}
/**
* 生成智能标题
*/
export async function generateAgentTitle(sessionId: string): Promise<string> {
): Promise<string> {
return await safeInvoke("agent_generate_title", {
sessionId,
});
@@ -491,155 +361,6 @@ export async function configureAsterProvider(
});
}
/**
* 发送消息到 Aster Agent (流式响应)
*
* 通过 Tauri 事件接收响应流
*/
export async function sendAsterMessageStream(
message: string,
sessionId: string,
eventName: string,
workspaceId: string,
images?: ImageInput[],
providerConfig?: AsterProviderConfig,
executionStrategy?: AsterExecutionStrategy,
webSearch?: boolean,
autoContinue?: AutoContinueRequestPayload,
systemPrompt?: string,
projectId?: string,
metadata?: Record<string, unknown>,
): Promise<void> {
return await invokeAsterChatStream({
message,
sessionId,
eventName,
workspaceId,
images,
providerConfig,
executionStrategy,
webSearch,
autoContinue,
systemPrompt,
projectId,
metadata,
});
}
/**
* 停止 Aster Agent 会话
*/
export async function stopAsterSession(sessionId: string): Promise<boolean> {
return await safeInvoke("aster_agent_stop", { sessionId });
}
/**
* 创建 Aster 会话
*/
export async function createAsterSession(
workspaceId: string,
workingDir?: string,
name?: string,
executionStrategy?: AsterExecutionStrategy,
): Promise<string> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("aster_session_create", {
workingDir,
workspaceId: resolvedWorkspaceId,
name,
executionStrategy,
});
}
/**
* 获取 Aster 会话列表
*/
export async function listAsterSessions(): Promise<AsterSessionInfo[]> {
return await safeInvoke("aster_session_list");
}
/**
* 获取 Aster 会话详情
*/
export async function getAsterSession(
sessionId: string,
): Promise<AsterSessionDetail> {
const detail = await safeInvoke("aster_session_get", { sessionId });
return {
...(detail as AsterSessionDetail),
queued_turns: normalizeQueuedTurnSnapshots(
(detail as AsterSessionDetail | null | undefined)?.queued_turns,
),
};
}
/**
* 重命名 Aster 会话
*/
export async function renameAsterSession(
sessionId: string,
name: string,
): Promise<void> {
return await safeInvoke("aster_session_rename", { sessionId, name });
}
/**
* 设置 Aster 会话执行策略
*/
export async function setAsterSessionExecutionStrategy(
sessionId: string,
executionStrategy: AsterExecutionStrategy,
): Promise<void> {
return await safeInvoke("aster_session_set_execution_strategy", {
sessionId,
executionStrategy,
});
}
/**
* 删除 Aster 会话
*/
export async function deleteAsterSession(sessionId: string): Promise<void> {
return await safeInvoke("aster_session_delete", { sessionId });
}
/**
* 确认 Aster Agent 权限请求
*/
export async function confirmAsterAction(
requestId: string,
confirmed: boolean,
response?: string,
): Promise<void> {
return await safeInvoke("aster_agent_confirm", {
request: {
request_id: requestId,
confirmed,
response,
},
});
}
/**
* 提交 Aster Agent elicitation 响应
*/
export async function submitAsterElicitationResponse(
sessionId: string,
requestId: string,
userData: unknown,
metadata?: Record<string, unknown>,
): Promise<void> {
return await safeInvoke("aster_agent_submit_elicitation_response", {
sessionId,
request: {
request_id: requestId,
user_data: userData,
metadata,
},
});
}
/**
* 终端命令请求(从后端发送到前端)
*/
@@ -745,32 +466,3 @@ export async function sendTermScrollbackResponse(
error: response.error,
});
}
/**
* 权限确认响应
*/
export interface PermissionResponse {
/** 请求 ID */
requestId: string;
/** 是否确认 */
confirmed: boolean;
/** 响应内容(用户输入或选择的答案) */
response?: string;
}
/**
* 发送权限确认响应到后端
*
* 当用户确认或拒绝权限请求后,调用此函数将结果发送给 Agent
*/
export async function sendPermissionResponse(
response: PermissionResponse,
): Promise<void> {
return await safeInvoke("aster_agent_confirm", {
request: {
request_id: response.requestId,
confirmed: response.confirmed,
response: response.response,
},
});
}
+96 -1
View File
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { apiKeyProviderApi } from "./apiKeyProvider";
import {
apiKeyProviderApi,
invalidateApiKeyProviderCache,
} from "./apiKeyProvider";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
@@ -9,6 +12,7 @@ vi.mock("@/lib/dev-bridge", () => ({
describe("apiKeyProvider API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateApiKeyProviderCache();
});
it("应代理现役 provider 命令", async () => {
@@ -36,4 +40,95 @@ describe("apiKeyProvider API", () => {
expect("migrateLegacyCredentials" in apiKeyProviderApi).toBe(false);
expect("deleteLegacyCredential" in apiKeyProviderApi).toBe(false);
});
it("getProviders 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [{ id: "key-1", provider_id: "openai", enabled: true }],
},
]);
const [first, second] = await Promise.all([
apiKeyProviderApi.getProviders(),
apiKeyProviderApi.getProviders(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("forceRefresh 应绕过 Provider 缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [],
},
])
.mockResolvedValueOnce([
{
id: "deepseek",
name: "DeepSeek",
type: "deepseek",
enabled: true,
api_key_count: 2,
api_keys: [],
},
]);
await expect(apiKeyProviderApi.getProviders()).resolves.toEqual([
expect.objectContaining({ id: "openai" }),
]);
await expect(
apiKeyProviderApi.getProviders({ forceRefresh: true }),
).resolves.toEqual([expect.objectContaining({ id: "deepseek" })]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("写操作成功后应失效缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [],
},
])
.mockResolvedValueOnce({ id: "key-2" })
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 2,
api_keys: [{ id: "key-2", provider_id: "openai", enabled: true }],
},
]);
await apiKeyProviderApi.getProviders();
await apiKeyProviderApi.addApiKey({
provider_id: "openai",
api_key: "sk-test",
});
await expect(apiKeyProviderApi.getProviders()).resolves.toEqual([
expect.objectContaining({ api_key_count: 2 }),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+86 -11
View File
@@ -9,6 +9,65 @@
import { safeInvoke } from "@/lib/dev-bridge";
interface ProviderQueryOptions {
forceRefresh?: boolean;
}
let providersCache: ProviderWithKeysDisplay[] | null = null;
let providersLoadingPromise: Promise<ProviderWithKeysDisplay[]> | null = null;
function cloneProviderList(
providers: ProviderWithKeysDisplay[],
): ProviderWithKeysDisplay[] {
return providers.map((provider) => ({
...provider,
api_keys: Array.isArray(provider.api_keys)
? provider.api_keys.map((apiKey) => ({ ...apiKey }))
: [],
custom_models: Array.isArray(provider.custom_models)
? [...provider.custom_models]
: [],
}));
}
export function invalidateApiKeyProviderCache(): void {
providersCache = null;
providersLoadingPromise = null;
}
async function loadProviders(
options: ProviderQueryOptions = {},
): Promise<ProviderWithKeysDisplay[]> {
if (options.forceRefresh) {
invalidateApiKeyProviderCache();
}
if (providersCache) {
return cloneProviderList(providersCache);
}
if (!providersLoadingPromise) {
providersLoadingPromise = safeInvoke<ProviderWithKeysDisplay[]>(
"get_api_key_providers",
)
.then((providers) => {
providersCache = cloneProviderList(providers);
return providersCache;
})
.finally(() => {
providersLoadingPromise = null;
});
}
return cloneProviderList(await providersLoadingPromise);
}
async function invalidateAfterMutation<T>(promise: Promise<T>): Promise<T> {
const result = await promise;
invalidateApiKeyProviderCache();
return result;
}
// ============================================================================
// 请求类型
// ============================================================================
@@ -156,8 +215,10 @@ export const apiKeyProviderApi = {
/**
* 获取所有 API Key Provider(包含 API Keys)
*/
async getProviders(): Promise<ProviderWithKeysDisplay[]> {
return safeInvoke("get_api_key_providers");
async getProviders(
options: ProviderQueryOptions = {},
): Promise<ProviderWithKeysDisplay[]> {
return loadProviders(options);
},
/**
@@ -173,7 +234,9 @@ export const apiKeyProviderApi = {
async addCustomProvider(
request: AddCustomProviderRequest,
): Promise<ProviderDisplay> {
return safeInvoke("add_custom_api_key_provider", { request });
return invalidateAfterMutation(
safeInvoke("add_custom_api_key_provider", { request }),
);
},
/**
@@ -183,35 +246,41 @@ export const apiKeyProviderApi = {
id: string,
request: UpdateProviderRequest,
): Promise<ProviderDisplay> {
return safeInvoke("update_api_key_provider", { id, request });
return invalidateAfterMutation(
safeInvoke("update_api_key_provider", { id, request }),
);
},
/**
* 删除自定义 Provider
*/
async deleteCustomProvider(id: string): Promise<boolean> {
return safeInvoke("delete_custom_api_key_provider", { id });
return invalidateAfterMutation(
safeInvoke("delete_custom_api_key_provider", { id }),
);
},
/**
* 添加 API Key
*/
async addApiKey(request: AddApiKeyRequest): Promise<ApiKeyDisplay> {
return safeInvoke("add_api_key", { request });
return invalidateAfterMutation(safeInvoke("add_api_key", { request }));
},
/**
* 删除 API Key
*/
async deleteApiKey(keyId: string): Promise<boolean> {
return safeInvoke("delete_api_key", { keyId });
return invalidateAfterMutation(safeInvoke("delete_api_key", { keyId }));
},
/**
* 切换 API Key 启用状态
*/
async toggleApiKey(keyId: string, enabled: boolean): Promise<ApiKeyDisplay> {
return safeInvoke("toggle_api_key", { keyId, enabled });
return invalidateAfterMutation(
safeInvoke("toggle_api_key", { keyId, enabled }),
);
},
/**
@@ -221,7 +290,9 @@ export const apiKeyProviderApi = {
keyId: string,
alias?: string,
): Promise<ApiKeyDisplay> {
return safeInvoke("update_api_key_alias", { keyId, alias });
return invalidateAfterMutation(
safeInvoke("update_api_key_alias", { keyId, alias }),
);
},
/**
@@ -264,7 +335,9 @@ export const apiKeyProviderApi = {
* **Validates: Requirements 8.4**
*/
async updateSortOrders(sortOrders: [string, number][]): Promise<void> {
return safeInvoke("update_provider_sort_orders", { sortOrders });
return invalidateAfterMutation(
safeInvoke("update_provider_sort_orders", { sortOrders }),
);
},
/**
@@ -278,7 +351,9 @@ export const apiKeyProviderApi = {
* 导入 Provider 配置
*/
async importConfig(configJson: string): Promise<ImportResult> {
return safeInvoke("import_api_key_providers", { configJson });
return invalidateAfterMutation(
safeInvoke("import_api_key_providers", { configJson }),
);
},
// ============================================================================
+81
View File
@@ -3,6 +3,7 @@ import { safeInvoke } from "@/lib/dev-bridge";
import {
getConfig,
getDefaultProvider,
invalidateAppConfigCache,
getEnvironmentPreview,
saveConfig,
setDefaultProvider,
@@ -16,6 +17,8 @@ vi.mock("@/lib/dev-bridge", () => ({
describe("appConfig API", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
invalidateAppConfigCache();
});
it("应代理读取配置命令", async () => {
@@ -47,4 +50,82 @@ describe("appConfig API", () => {
updateProviderEnvVars("openai", "https://example.com", "key"),
).resolves.toBeUndefined();
});
it("getConfig 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
});
const [first, second] = await Promise.all([getConfig(), getConfig()]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(
expect.objectContaining({ default_provider: "claude" }),
);
expect(second).toEqual(
expect.objectContaining({ default_provider: "claude" }),
);
expect(first).not.toBe(second);
});
it("saveConfig 后后续 getConfig 应直接命中新缓存", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce(undefined);
const nextConfig = {
default_provider: "kiro",
navigation: { enabled_items: ["agent", "tools"] },
} as never;
await expect(saveConfig(nextConfig)).resolves.toBeUndefined();
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({
default_provider: "kiro",
navigation: { enabled_items: ["agent", "tools"] },
}),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
});
it("setDefaultProvider 应更新已缓存配置中的 default_provider", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
})
.mockResolvedValueOnce("gemini");
await getConfig();
await expect(setDefaultProvider("gemini")).resolves.toBe("gemini");
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({ default_provider: "gemini" }),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("updateProviderEnvVars 后应失效缓存并触发下一次重新读取", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
default_provider: "openai",
navigation: { enabled_items: ["agent", "tools"] },
});
await getConfig();
await updateProviderEnvVars("openai", "https://example.com", "key");
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({
default_provider: "openai",
navigation: { enabled_items: ["agent", "tools"] },
}),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+100 -5
View File
@@ -1,6 +1,12 @@
import { safeInvoke } from "@/lib/dev-bridge";
import type { Config, EnvironmentPreview } from "./appConfigTypes";
const APP_CONFIG_CHANGE_STAMP_KEY = "lime.app-config.changed-at";
let configCache: Config | null = null;
let configLoadingPromise: Promise<Config> | null = null;
let configCacheStamp: string | null = null;
export type {
Config,
CrashReportingConfig,
@@ -24,12 +30,88 @@ export type {
VoiceConfig,
} from "./appConfigTypes";
export async function getConfig(): Promise<Config> {
return safeInvoke("get_config");
interface GetConfigOptions {
forceRefresh?: boolean;
}
function cloneConfig(config: Config): Config {
if (typeof structuredClone === "function") {
return structuredClone(config);
}
return JSON.parse(JSON.stringify(config)) as Config;
}
function readAppConfigChangeStamp(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(APP_CONFIG_CHANGE_STAMP_KEY);
} catch {
return null;
}
}
function markAppConfigChanged(): string | null {
const nextStamp = String(Date.now());
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(APP_CONFIG_CHANGE_STAMP_KEY, nextStamp);
} catch {
// ignore
}
}
return nextStamp;
}
function invalidateConfigCache(): void {
configCache = null;
configLoadingPromise = null;
configCacheStamp = null;
}
export function invalidateAppConfigCache(): void {
invalidateConfigCache();
}
export async function getConfig(
options: GetConfigOptions = {},
): Promise<Config> {
if (options.forceRefresh) {
invalidateConfigCache();
}
const currentStamp = readAppConfigChangeStamp();
if (configCache && configCacheStamp !== currentStamp) {
invalidateConfigCache();
}
if (configCache) {
return cloneConfig(configCache);
}
if (!configLoadingPromise) {
configLoadingPromise = safeInvoke<Config>("get_config")
.then((config) => {
configCache = cloneConfig(config);
configCacheStamp = readAppConfigChangeStamp();
return configCache;
})
.finally(() => {
configLoadingPromise = null;
});
}
return cloneConfig(await configLoadingPromise);
}
export async function saveConfig(config: Config): Promise<void> {
return safeInvoke("save_config", { config });
await safeInvoke("save_config", { config });
configCache = cloneConfig(config);
configCacheStamp = markAppConfigChanged();
}
export async function getEnvironmentPreview(): Promise<EnvironmentPreview> {
@@ -41,7 +123,18 @@ export async function getDefaultProvider(): Promise<string> {
}
export async function setDefaultProvider(provider: string): Promise<string> {
return safeInvoke("set_default_provider", { provider });
const nextProvider = await safeInvoke<string>("set_default_provider", {
provider,
});
if (configCache) {
configCache = {
...cloneConfig(configCache),
default_provider: nextProvider,
};
}
configCacheStamp = markAppConfigChanged();
return nextProvider;
}
export async function updateProviderEnvVars(
@@ -49,9 +142,11 @@ export async function updateProviderEnvVars(
apiHost: string,
apiKey?: string,
): Promise<void> {
return safeInvoke("update_provider_env_vars", {
await safeInvoke("update_provider_env_vars", {
providerType,
apiHost,
apiKey: apiKey || null,
});
invalidateConfigCache();
configCacheStamp = markAppConfigChanged();
}
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { reportFrontendDebugLog } from "./frontendDebug";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("frontendDebug API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("应代理前端调试日志上报命令", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce(undefined);
await expect(
reportFrontendDebugLog({
message: "AgentChatPage.loadData.start",
category: "agent",
}),
).resolves.toBeUndefined();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { safeInvoke } from "@/lib/dev-bridge";
export interface FrontendDebugLogReport {
message: string;
level?: "debug" | "info" | "warn" | "error";
category?: string;
context?: unknown;
}
export async function reportFrontendDebugLog(
report: FrontendDebugLogReport,
): Promise<void> {
await safeInvoke("report_frontend_debug_log", { report });
}
+59 -1
View File
@@ -5,6 +5,7 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { logAgentDebug } from "@/lib/agentDebug";
// ==================== 类型定义 ====================
@@ -261,7 +262,64 @@ export async function deleteOutlineNode(id: string): Promise<boolean> {
export async function getProjectMemory(
projectId: string,
): Promise<ProjectMemory> {
return safeInvoke<ProjectMemory>("project_memory_get", { projectId });
const startedAt = Date.now();
let settled = false;
const slowTimer: ReturnType<typeof setTimeout> | null =
typeof window !== "undefined"
? window.setTimeout(() => {
if (settled) {
return;
}
logAgentDebug(
"AgentApi",
"projectMemoryGet.slow",
{
elapsedMs: Date.now() - startedAt,
projectId,
},
{
dedupeKey: `projectMemoryGet.slow:${projectId}`,
level: "warn",
throttleMs: 1000,
},
);
}, 1000)
: null;
logAgentDebug("AgentApi", "projectMemoryGet.start", { projectId });
try {
const memory = await safeInvoke<ProjectMemory>("project_memory_get", {
projectId,
});
settled = true;
logAgentDebug("AgentApi", "projectMemoryGet.success", {
charactersCount: memory.characters.length,
durationMs: Date.now() - startedAt,
hasStyleGuide: Boolean(memory.style_guide),
hasWorldBuilding: Boolean(memory.world_building),
outlineCount: memory.outline.length,
projectId,
});
return memory;
} catch (error) {
settled = true;
logAgentDebug(
"AgentApi",
"projectMemoryGet.error",
{
durationMs: Date.now() - startedAt,
error,
projectId,
},
{ level: "error" },
);
throw error;
} finally {
if (slowTimer !== null) {
clearTimeout(slowTimer);
}
}
}
// ==================== 辅助函数 ====================
+95
View File
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import {
getAllAliasConfigs,
getModelRegistry,
getProviderAliasConfig,
invalidateModelRegistryCache,
refreshModelRegistry,
} from "./modelRegistry";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("modelRegistry API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateModelRegistryCache();
});
it("getModelRegistry 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
},
]);
const [first, second] = await Promise.all([
getModelRegistry(),
getModelRegistry(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("getProviderAliasConfig 应复用已加载的全量别名配置", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce({
kiro: {
models: ["kimi-k2"],
aliases: {
"kimi-k2": {
actual: "kimi-k2",
},
},
},
});
await expect(getAllAliasConfigs()).resolves.toEqual(
expect.objectContaining({
kiro: expect.objectContaining({
models: ["kimi-k2"],
}),
}),
);
await expect(getProviderAliasConfig("kiro")).resolves.toEqual(
expect.objectContaining({ models: ["kimi-k2"] }),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
});
it("refreshModelRegistry 后应失效缓存并触发下一次重新读取", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
},
])
.mockResolvedValueOnce(285)
.mockResolvedValueOnce([
{
id: "gpt-5",
display_name: "GPT-5",
provider_id: "openai",
provider_name: "OpenAI",
},
]);
await getModelRegistry();
await expect(refreshModelRegistry()).resolves.toBe(285);
await expect(getModelRegistry()).resolves.toEqual([
expect.objectContaining({ id: "gpt-5" }),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+142 -6
View File
@@ -13,11 +13,75 @@ import type {
UserModelPreference,
} from "@/lib/types/modelRegistry";
interface ModelRegistryQueryOptions {
forceRefresh?: boolean;
}
let modelRegistryCache: EnhancedModelMetadata[] | null = null;
let modelRegistryLoadingPromise: Promise<EnhancedModelMetadata[]> | null = null;
let allAliasConfigsCache: Record<string, ProviderAliasConfig> | null = null;
let allAliasConfigsLoadingPromise: Promise<
Record<string, ProviderAliasConfig>
> | null = null;
const providerAliasConfigCache = new Map<string, ProviderAliasConfig | null>();
const providerAliasConfigLoadingPromises = new Map<
string,
Promise<ProviderAliasConfig | null>
>();
function cloneValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function normalizeProviderKey(provider: string): string {
return provider.trim();
}
function invalidateAliasConfigCache(): void {
allAliasConfigsCache = null;
allAliasConfigsLoadingPromise = null;
providerAliasConfigCache.clear();
providerAliasConfigLoadingPromises.clear();
}
export function invalidateModelRegistryCache(): void {
modelRegistryCache = null;
modelRegistryLoadingPromise = null;
invalidateAliasConfigCache();
}
/**
* 获取所有模型
*/
export async function getModelRegistry(): Promise<EnhancedModelMetadata[]> {
return safeInvoke("get_model_registry");
export async function getModelRegistry(
options: ModelRegistryQueryOptions = {},
): Promise<EnhancedModelMetadata[]> {
if (options.forceRefresh) {
modelRegistryCache = null;
modelRegistryLoadingPromise = null;
}
if (modelRegistryCache) {
return cloneValue(modelRegistryCache);
}
if (!modelRegistryLoadingPromise) {
modelRegistryLoadingPromise = safeInvoke<EnhancedModelMetadata[]>(
"get_model_registry",
)
.then((models) => {
modelRegistryCache = cloneValue(models);
return modelRegistryCache;
})
.finally(() => {
modelRegistryLoadingPromise = null;
});
}
return cloneValue(await modelRegistryLoadingPromise);
}
/**
@@ -32,7 +96,9 @@ export async function getModelRegistryProviderIds(): Promise<string[]> {
* @returns 加载的模型数量
*/
export async function refreshModelRegistry(): Promise<number> {
return safeInvoke("refresh_model_registry");
const count = await safeInvoke<number>("refresh_model_registry");
invalidateModelRegistryCache();
return count;
}
/**
@@ -113,8 +179,47 @@ export async function getModelsByTier(
*/
export async function getProviderAliasConfig(
provider: string,
options: ModelRegistryQueryOptions = {},
): Promise<ProviderAliasConfig | null> {
return safeInvoke("get_provider_alias_config", { provider });
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) {
return null;
}
if (options.forceRefresh) {
invalidateAliasConfigCache();
}
if (allAliasConfigsCache) {
return cloneValue(allAliasConfigsCache[normalizedProvider] ?? null);
}
if (providerAliasConfigCache.has(normalizedProvider)) {
return cloneValue(providerAliasConfigCache.get(normalizedProvider) ?? null);
}
const existingPromise = providerAliasConfigLoadingPromises.get(
normalizedProvider,
);
if (existingPromise) {
return cloneValue(await existingPromise);
}
const loadingPromise = safeInvoke<ProviderAliasConfig | null>(
"get_provider_alias_config",
{ provider: normalizedProvider },
)
.then((config) => {
const snapshot = config ? cloneValue(config) : null;
providerAliasConfigCache.set(normalizedProvider, snapshot);
return snapshot;
})
.finally(() => {
providerAliasConfigLoadingPromises.delete(normalizedProvider);
});
providerAliasConfigLoadingPromises.set(normalizedProvider, loadingPromise);
return cloneValue(await loadingPromise);
}
/**
@@ -123,7 +228,38 @@ export async function getProviderAliasConfig(
export async function getAllAliasConfigs(): Promise<
Record<string, ProviderAliasConfig>
> {
return safeInvoke("get_all_alias_configs");
return getAllAliasConfigsCached();
}
async function getAllAliasConfigsCached(
options: ModelRegistryQueryOptions = {},
): Promise<Record<string, ProviderAliasConfig>> {
if (options.forceRefresh) {
invalidateAliasConfigCache();
}
if (allAliasConfigsCache) {
return cloneValue(allAliasConfigsCache);
}
if (!allAliasConfigsLoadingPromise) {
allAliasConfigsLoadingPromise = safeInvoke<Record<string, ProviderAliasConfig>>(
"get_all_alias_configs",
)
.then((configs) => {
allAliasConfigsCache = cloneValue(configs);
providerAliasConfigCache.clear();
Object.entries(allAliasConfigsCache).forEach(([key, value]) => {
providerAliasConfigCache.set(key, cloneValue(value));
});
return allAliasConfigsCache;
})
.finally(() => {
allAliasConfigsLoadingPromise = null;
});
}
return cloneValue(await allAliasConfigsLoadingPromise);
}
/**
@@ -142,5 +278,5 @@ export const modelRegistryApi = {
getModelsForProvider,
getModelsByTier,
getProviderAliasConfig,
getAllAliasConfigs,
getAllAliasConfigs: getAllAliasConfigsCached,
};
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockSafeInvoke } = vi.hoisted(() => ({
mockSafeInvoke: vi.fn(),
}));
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockSafeInvoke,
}));
import {
getPluginsForSurface,
getPluginsWithUI,
invalidatePluginUICache,
notifyPluginUIChanged,
type PluginUIInfo,
} from "./pluginUI";
const BASE_PLUGINS: PluginUIInfo[] = [
{
pluginId: "plugin.sidebar",
name: "Sidebar Plugin",
description: "show in sidebar",
icon: "PanelLeftOpen",
surfaces: ["sidebar", "tools"],
},
{
pluginId: "plugin.tools",
name: "Tools Plugin",
description: "show in tools",
icon: "Wrench",
surfaces: ["tools"],
},
];
describe("pluginUI API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidatePluginUICache();
window.localStorage.clear();
});
afterEach(() => {
invalidatePluginUICache();
window.localStorage.clear();
});
it("应缓存插件目录并复用同一轮请求", async () => {
mockSafeInvoke.mockResolvedValueOnce(BASE_PLUGINS);
const [first, second] = await Promise.all([
getPluginsWithUI(),
getPluginsWithUI(),
]);
expect(mockSafeInvoke).toHaveBeenCalledTimes(1);
expect(first).toEqual(BASE_PLUGINS);
expect(second).toEqual(BASE_PLUGINS);
expect(first).not.toBe(second);
});
it("getPluginsForSurface 应按 surface 过滤结果", async () => {
mockSafeInvoke.mockResolvedValueOnce(BASE_PLUGINS);
await expect(getPluginsForSurface("sidebar")).resolves.toEqual([
BASE_PLUGINS[0],
]);
});
it("forceRefresh 应绕过缓存重新请求", async () => {
mockSafeInvoke
.mockResolvedValueOnce(BASE_PLUGINS)
.mockResolvedValueOnce([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin v2",
},
]);
await expect(getPluginsWithUI()).resolves.toEqual(BASE_PLUGINS);
await expect(getPluginsWithUI({ forceRefresh: true })).resolves.toEqual([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin v2",
},
]);
expect(mockSafeInvoke).toHaveBeenCalledTimes(2);
});
it("notifyPluginUIChanged 应清空缓存并广播变更", async () => {
mockSafeInvoke
.mockResolvedValueOnce(BASE_PLUGINS)
.mockResolvedValueOnce([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin refreshed",
},
]);
const changedListener = vi.fn();
window.addEventListener("plugin-changed", changedListener);
try {
await getPluginsWithUI();
notifyPluginUIChanged();
await expect(getPluginsWithUI()).resolves.toEqual([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin refreshed",
},
]);
expect(changedListener).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem("plugin-changed")).toBeTruthy();
expect(mockSafeInvoke).toHaveBeenCalledTimes(2);
} finally {
window.removeEventListener("plugin-changed", changedListener);
}
});
});
+83 -3
View File
@@ -9,6 +9,12 @@
import { safeInvoke } from "@/lib/dev-bridge";
const PLUGIN_UI_CHANGE_EVENT = "plugin-changed";
let pluginsWithUICache: PluginUIInfo[] | null = null;
let pluginsWithUILoadingPromise: Promise<PluginUIInfo[]> | null = null;
let pluginsWithUICacheStamp: string | null = null;
/**
* 插件 UI 信息
*
@@ -27,6 +33,51 @@ export interface PluginUIInfo {
surfaces: string[];
}
interface PluginUIQueryOptions {
forceRefresh?: boolean;
}
function clonePluginList(plugins: PluginUIInfo[]): PluginUIInfo[] {
return plugins.map((plugin) => ({
...plugin,
surfaces: [...plugin.surfaces],
}));
}
function readPluginUIChangeStamp(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(PLUGIN_UI_CHANGE_EVENT);
} catch {
return null;
}
}
export function invalidatePluginUICache(): void {
pluginsWithUICache = null;
pluginsWithUILoadingPromise = null;
pluginsWithUICacheStamp = null;
}
export function notifyPluginUIChanged(): void {
invalidatePluginUICache();
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(PLUGIN_UI_CHANGE_EVENT, String(Date.now()));
} catch {
// ignore
}
window.dispatchEvent(new CustomEvent(PLUGIN_UI_CHANGE_EVENT));
}
/**
* 获取带有 UI 配置的已安装插件列表
*
@@ -35,8 +86,36 @@ export interface PluginUIInfo {
*
* @returns 带有 UI 配置的插件列表
*/
export async function getPluginsWithUI(): Promise<PluginUIInfo[]> {
return safeInvoke<PluginUIInfo[]>("get_plugins_with_ui");
export async function getPluginsWithUI(
options: PluginUIQueryOptions = {},
): Promise<PluginUIInfo[]> {
if (options.forceRefresh) {
invalidatePluginUICache();
}
const currentStamp = readPluginUIChangeStamp();
if (pluginsWithUICache && pluginsWithUICacheStamp !== currentStamp) {
invalidatePluginUICache();
}
if (pluginsWithUICache) {
return clonePluginList(pluginsWithUICache);
}
if (!pluginsWithUILoadingPromise) {
pluginsWithUILoadingPromise = safeInvoke<PluginUIInfo[]>("get_plugins_with_ui")
.then((plugins) => {
const snapshot = clonePluginList(plugins);
pluginsWithUICache = snapshot;
pluginsWithUICacheStamp = readPluginUIChangeStamp();
return snapshot;
})
.finally(() => {
pluginsWithUILoadingPromise = null;
});
}
return clonePluginList(await pluginsWithUILoadingPromise);
}
/**
@@ -49,7 +128,8 @@ export async function getPluginsWithUI(): Promise<PluginUIInfo[]> {
*/
export async function getPluginsForSurface(
surface: string,
options: PluginUIQueryOptions = {},
): Promise<PluginUIInfo[]> {
const plugins = await getPluginsWithUI();
const plugins = await getPluginsWithUI(options);
return plugins.filter((plugin) => plugin.surfaces.includes(surface));
}
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import {
invalidateProviderPoolOverviewCache,
providerPoolApi,
} from "./providerPool";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("providerPool API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateProviderPoolOverviewCache();
});
it("getOverview 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
provider_type: "kiro",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [
{
uuid: "cred-1",
provider_type: "kiro",
credential_type: "kiro_oauth",
display_credential: "kiro",
is_healthy: true,
is_disabled: false,
check_health: true,
not_supported_models: [],
usage_count: 0,
error_count: 0,
created_at: "",
updated_at: "",
source: "manual",
},
],
},
]);
const [first, second] = await Promise.all([
providerPoolApi.getOverview(),
providerPoolApi.getOverview(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("forceRefresh 应绕过概览缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
provider_type: "kiro",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
])
.mockResolvedValueOnce([
{
provider_type: "gemini",
stats: {
total: 2,
healthy: 2,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
]);
await expect(providerPoolApi.getOverview()).resolves.toEqual([
expect.objectContaining({ provider_type: "kiro" }),
]);
await expect(
providerPoolApi.getOverview({ forceRefresh: true }),
).resolves.toEqual([expect.objectContaining({ provider_type: "gemini" })]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("写操作成功后应失效概览缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
provider_type: "openai",
stats: {
total: 0,
healthy: 0,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
])
.mockResolvedValueOnce({ uuid: "cred-2" })
.mockResolvedValueOnce([
{
provider_type: "openai",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [
{
uuid: "cred-2",
provider_type: "openai",
credential_type: "openai_key",
display_credential: "sk-***",
is_healthy: true,
is_disabled: false,
check_health: true,
not_supported_models: [],
usage_count: 0,
error_count: 0,
created_at: "",
updated_at: "",
source: "manual",
},
],
},
]);
await providerPoolApi.getOverview();
await providerPoolApi.addOpenAIKey("sk-test");
await expect(providerPoolApi.getOverview()).resolves.toEqual([
expect.objectContaining({
stats: expect.objectContaining({ total: 1 }),
}),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+184 -62
View File
@@ -1,5 +1,65 @@
import { safeInvoke } from "@/lib/dev-bridge";
interface ProviderPoolQueryOptions {
forceRefresh?: boolean;
}
let overviewCache: ProviderPoolOverview[] | null = null;
let overviewLoadingPromise: Promise<ProviderPoolOverview[]> | null = null;
function cloneOverview(
overview: ProviderPoolOverview[],
): ProviderPoolOverview[] {
return overview.map((pool) => ({
...pool,
stats: { ...pool.stats },
credentials: pool.credentials.map((credential) => ({
...credential,
not_supported_models: [...credential.not_supported_models],
})),
}));
}
export function invalidateProviderPoolOverviewCache(): void {
overviewCache = null;
overviewLoadingPromise = null;
}
async function loadOverview(
options: ProviderPoolQueryOptions = {},
): Promise<ProviderPoolOverview[]> {
if (options.forceRefresh) {
invalidateProviderPoolOverviewCache();
}
if (overviewCache) {
return cloneOverview(overviewCache);
}
if (!overviewLoadingPromise) {
overviewLoadingPromise = safeInvoke<ProviderPoolOverview[]>(
"get_provider_pool_overview",
)
.then((overview) => {
overviewCache = cloneOverview(overview);
return overviewCache;
})
.finally(() => {
overviewLoadingPromise = null;
});
}
return cloneOverview(await overviewLoadingPromise);
}
async function invalidateOverviewAfterMutation<T>(
promise: Promise<T>,
): Promise<T> {
const result = await promise;
invalidateProviderPoolOverviewCache();
return result;
}
// Provider types supported by the pool
export type PoolProviderType =
| "kiro"
@@ -201,8 +261,10 @@ export interface UpdateCredentialRequest {
export const providerPoolApi = {
// Get overview of all provider pools
async getOverview(): Promise<ProviderPoolOverview[]> {
return safeInvoke("get_provider_pool_overview");
async getOverview(
options: ProviderPoolQueryOptions = {},
): Promise<ProviderPoolOverview[]> {
return loadOverview(options);
},
// Get credentials for a specific provider type
@@ -216,7 +278,9 @@ export const providerPoolApi = {
async addCredential(
request: AddCredentialRequest,
): Promise<ProviderCredential> {
return safeInvoke("add_provider_pool_credential", { request });
return invalidateOverviewAfterMutation(
safeInvoke("add_provider_pool_credential", { request }),
);
},
// Update a credential
@@ -224,7 +288,9 @@ export const providerPoolApi = {
uuid: string,
request: UpdateCredentialRequest,
): Promise<ProviderCredential> {
return safeInvoke("update_provider_pool_credential", { uuid, request });
return invalidateOverviewAfterMutation(
safeInvoke("update_provider_pool_credential", { uuid, request }),
);
},
// Delete a credential
@@ -232,10 +298,12 @@ export const providerPoolApi = {
uuid: string,
providerType?: PoolProviderType,
): Promise<boolean> {
return safeInvoke("delete_provider_pool_credential", {
uuid,
providerType,
});
return invalidateOverviewAfterMutation(
safeInvoke("delete_provider_pool_credential", {
uuid,
providerType,
}),
);
},
// Toggle credential enabled/disabled
@@ -243,29 +311,39 @@ export const providerPoolApi = {
uuid: string,
isDisabled: boolean,
): Promise<ProviderCredential> {
return safeInvoke("toggle_provider_pool_credential", { uuid, isDisabled });
return invalidateOverviewAfterMutation(
safeInvoke("toggle_provider_pool_credential", { uuid, isDisabled }),
);
},
// Reset credential counters
async resetCredential(uuid: string): Promise<void> {
return safeInvoke("reset_provider_pool_credential", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("reset_provider_pool_credential", { uuid }),
);
},
// Reset health status for all credentials of a type
async resetHealth(providerType: PoolProviderType): Promise<number> {
return safeInvoke("reset_provider_pool_health", { providerType });
return invalidateOverviewAfterMutation(
safeInvoke("reset_provider_pool_health", { providerType }),
);
},
// Check health of a single credential
async checkCredentialHealth(uuid: string): Promise<HealthCheckResult> {
return safeInvoke("check_provider_pool_credential_health", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("check_provider_pool_credential_health", { uuid }),
);
},
// Check health of all credentials of a type
async checkTypeHealth(
providerType: PoolProviderType,
): Promise<HealthCheckResult[]> {
return safeInvoke("check_provider_pool_type_health", { providerType });
return invalidateOverviewAfterMutation(
safeInvoke("check_provider_pool_type_health", { providerType }),
);
},
// Provider-specific add methods
@@ -273,7 +351,9 @@ export const providerPoolApi = {
credsFilePath: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_kiro_oauth_credential", { credsFilePath, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_oauth_credential", { credsFilePath, name }),
);
},
// 从 JSON 内容添加 Kiro 凭证(直接粘贴 JSON)
@@ -281,7 +361,9 @@ export const providerPoolApi = {
jsonContent: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_kiro_from_json", { jsonContent, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_from_json", { jsonContent, name }),
);
},
async addGeminiOAuth(
@@ -289,11 +371,13 @@ export const providerPoolApi = {
projectId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_gemini_oauth_credential", {
credsFilePath,
projectId,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_gemini_oauth_credential", {
credsFilePath,
projectId,
name,
}),
);
},
async addOpenAIKey(
@@ -301,7 +385,9 @@ export const providerPoolApi = {
baseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_openai_key_credential", { apiKey, baseUrl, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_openai_key_credential", { apiKey, baseUrl, name }),
);
},
async addClaudeKey(
@@ -309,7 +395,9 @@ export const providerPoolApi = {
baseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_claude_key_credential", { apiKey, baseUrl, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_claude_key_credential", { apiKey, baseUrl, name }),
);
},
async addGeminiApiKey(
@@ -318,12 +406,14 @@ export const providerPoolApi = {
excludedModels?: string[],
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_gemini_api_key_credential", {
apiKey,
baseUrl,
excludedModels,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_gemini_api_key_credential", {
apiKey,
baseUrl,
excludedModels,
name,
}),
);
},
async addAntigravityOAuth(
@@ -331,11 +421,13 @@ export const providerPoolApi = {
projectId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_antigravity_oauth_credential", {
credsFilePath,
projectId,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_antigravity_oauth_credential", {
credsFilePath,
projectId,
name,
}),
);
},
async addCodexOAuth(
@@ -343,18 +435,22 @@ export const providerPoolApi = {
apiBaseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_codex_oauth_credential", {
credsFilePath,
apiBaseUrl,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_codex_oauth_credential", {
credsFilePath,
apiBaseUrl,
name,
}),
);
},
async addClaudeOAuth(
credsFilePath: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_claude_oauth_credential", { credsFilePath, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_claude_oauth_credential", { credsFilePath, name }),
);
},
// Antigravity OAuth 登录(打开浏览器授权)
@@ -362,10 +458,12 @@ export const providerPoolApi = {
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return safeInvoke("start_antigravity_oauth_login", {
name,
skipProjectIdFetch,
});
return invalidateOverviewAfterMutation(
safeInvoke("start_antigravity_oauth_login", {
name,
skipProjectIdFetch,
}),
);
},
// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器)
@@ -375,26 +473,34 @@ export const providerPoolApi = {
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return safeInvoke("get_antigravity_auth_url_and_wait", {
name,
skipProjectIdFetch,
});
return invalidateOverviewAfterMutation(
safeInvoke("get_antigravity_auth_url_and_wait", {
name,
skipProjectIdFetch,
}),
);
},
// Codex OAuth 登录(打开浏览器授权)
async startCodexOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_codex_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_codex_oauth_login", { name }),
);
},
// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getCodexAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return safeInvoke("get_codex_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_codex_auth_url_and_wait", { name }),
);
},
// Claude OAuth 登录(打开浏览器授权)
async startClaudeOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_claude_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_claude_oauth_login", { name }),
);
},
// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器)
@@ -402,7 +508,9 @@ export const providerPoolApi = {
async getClaudeOAuthAuthUrlAndWait(
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("get_claude_oauth_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_claude_oauth_auth_url_and_wait", { name }),
);
},
// Claude Cookie 自动授权(使用 sessionKey 自动完成 OAuth 流程)
@@ -412,22 +520,28 @@ export const providerPoolApi = {
isSetupToken?: boolean,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("claude_oauth_with_cookie", {
sessionKey,
isSetupToken,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("claude_oauth_with_cookie", {
sessionKey,
isSetupToken,
name,
}),
);
},
// Gemini OAuth 登录(打开浏览器授权)
async startGeminiOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_gemini_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_gemini_oauth_login", { name }),
);
},
// 获取 Gemini OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getGeminiAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return safeInvoke("get_gemini_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_gemini_auth_url_and_wait", { name }),
);
},
// 用 Gemini 授权码交换 token
@@ -436,7 +550,9 @@ export const providerPoolApi = {
sessionId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("exchange_gemini_code", { code, sessionId, name });
return invalidateOverviewAfterMutation(
safeInvoke("exchange_gemini_code", { code, sessionId, name }),
);
},
// ============ Kiro Builder ID 登录 ============
@@ -460,7 +576,9 @@ export const providerPoolApi = {
// 从 Builder ID 授权结果添加 Kiro 凭证
async addKiroFromBuilderIdAuth(name?: string): Promise<ProviderCredential> {
return safeInvoke("add_kiro_from_builder_id_auth", { name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_from_builder_id_auth", { name }),
);
},
// ============ Kiro Social Auth 登录 (Google/GitHub) ============
@@ -492,7 +610,9 @@ export const providerPoolApi = {
// OAuth token management
async refreshCredentialToken(uuid: string): Promise<string> {
return safeInvoke("refresh_pool_credential_token", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("refresh_pool_credential_token", { uuid }),
);
},
async getCredentialOAuthStatus(uuid: string): Promise<OAuthStatus> {
@@ -501,7 +621,9 @@ export const providerPoolApi = {
// Migration API
async migratePrivateConfig(config: unknown): Promise<MigrationResult> {
return safeInvoke("migrate_private_config_to_pool", { config });
return invalidateOverviewAfterMutation(
safeInvoke("migrate_private_config_to_pool", { config }),
);
},
// 获取单个凭证的健康状态
+44
View File
@@ -0,0 +1,44 @@
import { safeInvoke } from "@/lib/dev-bridge";
export const TRAY_MODEL_SELECTED_EVENT = "tray-model-selected";
export interface TrayQuickModelItem {
provider_type: string;
provider_label: string;
model: string;
}
export interface TrayQuickModelGroup {
provider_type: string;
provider_label: string;
models: TrayQuickModelItem[];
}
export interface TrayModelSelectedPayload {
providerType: string;
model: string;
}
export interface SyncTrayModelShortcutsPayload {
current_model_provider_type: string;
current_model_provider_label: string;
current_model: string;
current_theme_label: string;
quick_model_groups: TrayQuickModelGroup[];
}
export async function syncTrayModelShortcuts(
payload: SyncTrayModelShortcutsPayload,
): Promise<void> {
await safeInvoke("sync_tray_model_shortcuts", {
currentModelProviderType: payload.current_model_provider_type,
currentModelProviderLabel: payload.current_model_provider_label,
currentModel: payload.current_model,
currentThemeLabel: payload.current_theme_label,
quickModelGroups: payload.quick_model_groups,
});
}
export const trayApi = {
syncTrayModelShortcuts,
};
+8 -2
View File
@@ -5,6 +5,11 @@
* 通过 HTTP 与运行中的 Tauri 后端通信。
*/
import {
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
const BRIDGE_URL = "http://127.0.0.1:3030/invoke";
const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health";
@@ -79,7 +84,8 @@ export function isDevBridgeAvailable(): boolean {
// 检查是否在浏览器环境(非 Tauri webview)
const isBrowser =
typeof window !== "undefined" &&
!(window as any).__TAURI__ &&
!hasTauriRuntimeMarkers() &&
!hasTauriInvokeCapability() &&
// 进一步检查是否在开发模式
(import.meta.env.DEV ||
location.hostname === "localhost" ||
@@ -156,7 +162,7 @@ export interface BridgeStatus {
* 获取当前桥接状态
*/
export function getBridgeStatus(): BridgeStatus {
const hasTauri = typeof window !== "undefined" && (window as any).__TAURI__;
const hasTauri = hasTauriInvokeCapability() || hasTauriRuntimeMarkers();
const devAvailable = isDevBridgeAvailable();
return {
@@ -7,7 +7,6 @@ describe("mockPriorityCommands", () => {
expect(
shouldPreferMockInBrowser("execution_run_get_theme_workbench_state"),
).toBe(true);
expect(shouldPreferMockInBrowser("aster_agent_chat_stream")).toBe(true);
expect(shouldPreferMockInBrowser("get_hint_routes")).toBe(true);
expect(shouldPreferMockInBrowser("content_workflow_get_by_content")).toBe(true);
});
@@ -25,7 +25,6 @@ const mockPriorityCommands = new Set<string>([
"session_files_read_file",
"session_files_delete_file",
"execution_run_get_theme_workbench_state",
"aster_agent_chat_stream",
"agent_runtime_submit_turn",
"agent_runtime_interrupt_turn",
"agent_runtime_create_session",
+54
View File
@@ -39,6 +39,7 @@ import {
clearInvokeTraceBuffer,
getInvokeErrorBuffer,
getInvokeTraceBuffer,
safeListen,
safeInvoke,
} from "./safeInvoke";
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
@@ -51,6 +52,7 @@ describe("safeInvoke", () => {
clearInvokeErrorBuffer();
clearInvokeTraceBuffer();
delete (window as any).__TAURI__;
delete (window as any).__TAURI_INTERNALS__;
});
it("浏览器开发模式下优先走 HTTP bridge", async () => {
@@ -118,4 +120,56 @@ describe("safeInvoke", () => {
"[workspace_list] Failed to fetch",
);
});
it("事件 internals 已就绪时 safeListen 走原生 event API", async () => {
const unlisten = vi.fn();
(window as any).__TAURI_INTERNALS__ = {
invoke: vi.fn(),
transformCallback: vi.fn(),
};
mocks.baseListen.mockResolvedValueOnce(unlisten);
await expect(safeListen("config-changed", vi.fn())).resolves.toBe(unlisten);
expect(mocks.baseListen).toHaveBeenCalledWith(
"config-changed",
expect.any(Function),
);
});
it("Tauri 运行时存在但事件桥缺失时 safeListen 返回空清理函数", async () => {
vi.useFakeTimers();
(window as any).__TAURI__ = {
core: {
invoke: vi.fn(),
},
};
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();
});
it("事件桥调用异常时 safeListen 降级为空清理函数", async () => {
(window as any).__TAURI_INTERNALS__ = {
invoke: vi.fn(),
transformCallback: vi.fn(),
};
mocks.baseListen.mockRejectedValueOnce(
new TypeError(
"Cannot read properties of undefined (reading 'transformCallback')",
),
);
const unlisten = await safeListen("plugin-task-event", vi.fn());
expect(typeof unlisten).toBe("function");
expect(mocks.baseListen).toHaveBeenCalledWith(
"plugin-task-event",
expect.any(Function),
);
});
});
+130 -14
View File
@@ -17,6 +17,13 @@ import {
normalizeDevBridgeError,
} from "./http-client";
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
import {
getTauriGlobal,
hasTauriEventCapability,
hasTauriEventListenerCapability,
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
export interface InvokeErrorBufferEntry {
timestamp: string;
@@ -41,6 +48,7 @@ const INVOKE_ERROR_BUFFER_LIMIT = 120;
const INVOKE_TRACE_BUFFER_KEY = "lime_invoke_trace_buffer_v1";
const INVOKE_TRACE_BUFFER_LIMIT = 240;
const INVOKE_ERROR_TEXT_LIMIT = 800;
const USER_TIMING_PREFIX = "lime:safeInvoke";
const SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"],
@@ -102,6 +110,63 @@ function toErrorMessage(error: unknown): string {
);
}
function supportsUserTiming(): boolean {
return (
typeof performance !== "undefined" &&
typeof performance.mark === "function" &&
typeof performance.measure === "function"
);
}
function sanitizeTimingLabel(input: string): string {
const normalized = input.replace(/[^a-zA-Z0-9:_-]+/g, "_").slice(0, 120);
return normalized || "invoke";
}
function startInvokeTiming(command: string): string | null {
if (!supportsUserTiming()) {
return null;
}
const timingId = `${USER_TIMING_PREFIX}:${sanitizeTimingLabel(command)}:${Date.now()}:${Math.random()
.toString(36)
.slice(2, 8)}`;
try {
performance.mark(`${timingId}:start`);
return timingId;
} catch {
return null;
}
}
function finishInvokeTiming(
timingId: string | null,
command: string,
transport: InvokeTraceBufferEntry["transport"],
status: InvokeTraceBufferEntry["status"],
): void {
if (!timingId || !supportsUserTiming()) {
return;
}
const startMark = `${timingId}:start`;
const endMark = `${timingId}:end`;
const measureName = `${USER_TIMING_PREFIX}:${sanitizeTimingLabel(command)}:${transport}:${status}`;
try {
performance.mark(endMark);
performance.measure(measureName, startMark, endMark);
} catch {
// ignore
} finally {
try {
performance.clearMarks(startMark);
performance.clearMarks(endMark);
} catch {
// ignore
}
}
}
function readInvokeErrorBuffer(): InvokeErrorBufferEntry[] {
if (typeof window === "undefined") {
return [];
@@ -279,6 +344,7 @@ export async function safeInvoke<T = any>(
args?: Record<string, unknown>,
): Promise<T> {
const startedAt = Date.now();
const timingId = startInvokeTiming(cmd);
// 1. 优先使用 Tauri IPC (生产环境或 Tauri webview 可用时)
if (
@@ -291,10 +357,12 @@ export async function safeInvoke<T = any>(
args,
)) as T;
recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-ipc");
recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "error");
throw error;
}
}
@@ -304,10 +372,28 @@ export async function safeInvoke<T = any>(
try {
const result = (await (window as any).__TAURI__.invoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "tauri-legacy", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-legacy", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-legacy");
recordInvokeTrace(cmd, args, "tauri-legacy", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-legacy", "error");
throw error;
}
}
// Tauri IPC 尚未就绪时不再轮询等待,直接 fall through 到后续通道。
// 避免首屏并发大量 safeInvoke 时全部阻塞在 waitForTauriCapability 上。
if (hasTauriInvokeCapability()) {
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-ipc");
recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "error");
throw error;
}
}
@@ -317,6 +403,7 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
@@ -328,6 +415,7 @@ export async function safeInvoke<T = any>(
startedAt,
error,
);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw error;
}
}
@@ -337,6 +425,7 @@ export async function safeInvoke<T = any>(
try {
const result = await invokeViaHttp(cmd, args);
recordInvokeTrace(cmd, args, "http-bridge", "success", startedAt);
finishInvokeTiming(timingId, cmd, "http-bridge", "success");
return result as T;
} catch (error) {
const normalizedError = normalizeDevBridgeError(cmd, error);
@@ -353,6 +442,7 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (fallbackError) {
recordInvokeError(cmd, args, fallbackError, "fallback-invoke");
@@ -364,6 +454,7 @@ export async function safeInvoke<T = any>(
startedAt,
fallbackError,
);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw normalizedError;
}
}
@@ -373,10 +464,12 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
recordInvokeTrace(cmd, args, "fallback-invoke", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw error;
}
}
@@ -389,22 +482,29 @@ export async function safeListen<T = any>(
event: string,
handler: (event: { payload: T }) => void,
): Promise<UnlistenFn> {
// 1. 优先使用 Tauri event API
if (
typeof window !== "undefined" &&
(window as any).__TAURI__?.event?.listen
) {
return (window as any).__TAURI__.event.listen(event, handler);
// 同步检查即可,不轮询等待,避免首屏并发监听全部阻塞
if (hasTauriEventListenerCapability()) {
try {
return await baseListen(event, handler);
} catch (error) {
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeListen] Tauri 事件桥调用失败,跳过监听: ${event}`, error);
return () => {};
}
throw error;
}
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeListen] Tauri 事件桥未就绪,跳过监听: ${event}`);
return () => {};
}
// 2. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入)
return baseListen(event, handler);
}
export function hasNativeTauriEventSupport(): boolean {
return Boolean(
typeof window !== "undefined" && (window as any).__TAURI__?.event?.listen,
);
return hasTauriEventListenerCapability();
}
/**
@@ -415,12 +515,28 @@ export async function safeEmit(
event: string,
payload?: unknown,
): Promise<void> {
// 1. 优先使用 Tauri event API
if (typeof window !== "undefined" && (window as any).__TAURI__?.event?.emit) {
return (window as any).__TAURI__.event.emit(event, payload);
const tauriGlobal = getTauriGlobal() as
| {
event?: {
emit?: (event: string, payload?: unknown) => Promise<void>;
};
}
| null;
if (typeof tauriGlobal?.event?.emit === "function") {
return tauriGlobal.event.emit(event, payload);
}
// 同步检查,不轮询
if (hasTauriEventCapability()) {
return baseEmit(event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeEmit] Tauri 事件桥未就绪,跳过发送: ${event}`);
return;
}
// 2. Fallback 到 mock
return baseEmit(event, payload);
}
+133
View File
@@ -0,0 +1,133 @@
{
"runtimeGatewayCommands": [
"agent_start_process",
"agent_stop_process",
"agent_get_process_status",
"agent_generate_title",
"agent_terminal_command_response",
"agent_term_scrollback_response",
"aster_agent_init",
"aster_agent_status",
"aster_agent_configure_provider",
"aster_agent_configure_from_pool",
"aster_agent_reset",
"agent_runtime_submit_turn",
"agent_runtime_interrupt_turn",
"agent_runtime_remove_queued_turn",
"agent_runtime_respond_action",
"agent_runtime_create_session",
"agent_runtime_list_sessions",
"agent_runtime_get_session",
"agent_runtime_update_session",
"agent_runtime_delete_session"
],
"deprecatedCommandReplacements": {
"get_agent_process_status": "agent_get_process_status",
"list_agent_sessions": "agent_runtime_list_sessions",
"agent_chat_stream": "agent_runtime_submit_turn",
"agent_create_session": "agent_runtime_create_session",
"agent_list_sessions": "agent_runtime_list_sessions",
"agent_get_session": "agent_runtime_get_session",
"agent_get_session_messages": "agent_runtime_get_session",
"agent_delete_session": "agent_runtime_delete_session",
"agent_rename_session": "agent_runtime_update_session",
"aster_agent_chat_stream": "agent_runtime_submit_turn",
"aster_agent_stop": "agent_runtime_interrupt_turn",
"aster_agent_confirm": "agent_runtime_respond_action",
"aster_agent_submit_elicitation_response": "agent_runtime_respond_action",
"aster_session_create": "agent_runtime_create_session",
"aster_session_list": "agent_runtime_list_sessions",
"aster_session_get": "agent_runtime_get_session",
"aster_session_rename": "agent_runtime_update_session",
"aster_session_set_execution_strategy": "agent_runtime_update_session",
"aster_session_delete": "agent_runtime_delete_session"
},
"deprecatedHelperReplacements": {
"createAgentSession": "createAgentRuntimeSession",
"listAgentSessions": "listAgentRuntimeSessions",
"getAgentSession": "getAgentRuntimeSession",
"getAgentSessionMessages": "getAgentRuntimeSession",
"renameAgentSession": "updateAgentRuntimeSession",
"deleteAgentSession": "deleteAgentRuntimeSession",
"generateAgentTitle": "generateAgentRuntimeSessionTitle",
"sendAsterMessageStream": "submitAgentRuntimeTurn",
"confirmAsterAction": "respondAgentRuntimeAction",
"submitAsterElicitationResponse": "respondAgentRuntimeAction",
"stopAsterSession": "interruptAgentRuntimeTurn",
"createAsterSession": "createAgentRuntimeSession",
"listAsterSessions": "listAgentRuntimeSessions",
"getAsterSession": "getAgentRuntimeSession",
"deleteAsterSession": "deleteAgentRuntimeSession",
"renameAsterSession": "updateAgentRuntimeSession"
},
"legacyCommandSurfaceMonitors": [
{
"id": "agent-create-session-compat-command",
"classification": "compat",
"description": "agent_create_session compat 命令前端边界",
"commands": ["agent_create_session"],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-session-message-legacy-command",
"classification": "deprecated",
"description": "旧 agent session message 命令前端边界",
"commands": ["agent_get_session_messages"],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-session-compat-commands",
"classification": "deprecated",
"description": "旧 agent session compat 命令前端边界",
"commands": [
"agent_list_sessions",
"agent_get_session",
"agent_delete_session",
"agent_rename_session"
],
"allowedPaths": []
}
],
"legacyHelperSurfaceMonitors": [
{
"id": "agent-legacy-session-api-helpers",
"classification": "deprecated",
"description": "旧 Agent session compat helper 直连回流",
"helpers": [
"createAgentSession",
"listAgentSessions",
"getAgentSession",
"getAgentSessionMessages",
"renameAgentSession",
"deleteAgentSession",
"generateAgentTitle"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-legacy-stream-action-helpers",
"classification": "deprecated",
"description": "旧 Aster stream/action helper 直连回流",
"helpers": [
"sendAsterMessageStream",
"confirmAsterAction",
"submitAsterElicitationResponse",
"stopAsterSession"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "aster-session-helper-direct-usage",
"classification": "deprecated",
"description": "前端 direct Aster session helper 回流",
"helpers": [
"createAsterSession",
"listAsterSessions",
"getAsterSession",
"deleteAsterSession",
"renameAsterSession"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
}
]
}
+128
View File
@@ -0,0 +1,128 @@
import {
emit as realEmit,
emitTo as realEmitTo,
listen as realListen,
once as realOnce,
TauriEvent,
} from "../../node_modules/@tauri-apps/api/event.js";
import type {
Event,
EventCallback,
EventName,
EventTarget,
Options,
UnlistenFn,
} from "../../node_modules/@tauri-apps/api/event.js";
import {
hasTauriEventListenerCapability,
hasTauriRuntimeMarkers,
waitForTauriCapability,
waitForTauriEventListenerCapability,
} from "@/lib/tauri-runtime";
const NOOP_UNLISTEN: UnlistenFn = () => {};
export type {
Event,
EventCallback,
EventName,
EventTarget,
Options,
UnlistenFn,
};
export { TauriEvent };
export async function listen<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options,
): Promise<UnlistenFn> {
const nativeListenReady = hasTauriEventListenerCapability()
? true
: await waitForTauriEventListenerCapability();
if (nativeListenReady) {
try {
return await realListen(event, handler, options);
} catch (error) {
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] 原生事件监听失败,跳过监听: ${event}`, error);
return NOOP_UNLISTEN;
}
throw error;
}
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过监听: ${event}`);
return NOOP_UNLISTEN;
}
try {
return await realListen(event, handler, options);
} catch (error) {
console.warn(`[tauri-event] 事件监听不可用,跳过监听: ${event}`, error);
return NOOP_UNLISTEN;
}
}
export async function once<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options,
): Promise<UnlistenFn> {
if (!hasTauriRuntimeMarkers()) {
return realOnce(event, handler, options);
}
let resolvedUnlisten: UnlistenFn = NOOP_UNLISTEN;
const unlisten = await listen<T>(
event,
(eventData) => {
resolvedUnlisten();
handler(eventData);
},
options,
);
resolvedUnlisten = unlisten;
return unlisten;
}
export async function emit<T>(event: string, payload?: T): Promise<void> {
if (await waitForTauriCapability("event")) {
return realEmit(event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过发送: ${event}`);
return;
}
try {
return await realEmit(event, payload);
} catch (error) {
console.warn(`[tauri-event] 事件发送不可用,跳过发送: ${event}`, error);
}
}
export async function emitTo<T>(
target: EventTarget | string,
event: string,
payload?: T,
): Promise<void> {
if (await waitForTauriCapability("event")) {
return realEmitTo(target, event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过定向发送: ${event}`);
return;
}
try {
return await realEmitTo(target, event, payload);
} catch (error) {
console.warn(`[tauri-event] 定向事件发送不可用,跳过发送: ${event}`, error);
}
}
+11
View File
@@ -76,4 +76,15 @@ describe("tauri-mock/core invoke", () => {
}),
);
});
it("旧 Agent 命令别名应直接报废弃错误,不再静默返回 mock 成功结果", async () => {
mocks.isDevBridgeAvailable.mockReturnValue(false);
await expect(invoke("list_agent_sessions")).rejects.toThrow(
"命令 list_agent_sessions 已废弃,请迁移到 agent_runtime_list_sessions",
);
await expect(invoke("get_agent_process_status")).rejects.toThrow(
"命令 get_agent_process_status 已废弃,请迁移到 agent_get_process_status",
);
});
});
+15 -23
View File
@@ -10,6 +10,7 @@ import {
isDevBridgeAvailable,
normalizeDevBridgeError,
} from "../dev-bridge/http-client";
import agentCommandCatalog from "../governance/agentCommandCatalog.json";
import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands";
// 模拟的命令处理器
@@ -22,6 +23,18 @@ const createDeprecatedCommandMock =
);
};
const deprecatedAgentCommandReplacements =
agentCommandCatalog.deprecatedCommandReplacements as Record<string, string>;
const deprecatedAgentCommandMocks = Object.fromEntries(
Object.entries(deprecatedAgentCommandReplacements).map(
([command, replacement]) => [
command,
createDeprecatedCommandMock(command, replacement),
],
),
) as Record<string, () => never>;
type MockBrowserProfileRecord = {
id: string;
profile_key: string;
@@ -1508,24 +1521,10 @@ const defaultMocks: Record<string, any> = {
}),
// Agent 相关
list_agent_sessions: () => [],
agent_list_sessions: () => [],
get_agent_process_status: () => ({ running: false }),
...deprecatedAgentCommandMocks,
agent_get_process_status: () => ({ running: false }),
agent_start_process: () => ({ success: true }),
agent_stop_process: () => ({ success: true }),
agent_create_session: () => ({ session_id: "mock-session-id" }),
agent_send_message: createDeprecatedCommandMock(
"agent_send_message",
"aster_agent_chat_stream",
),
agent_get_session: () => ({ session: null }),
agent_delete_session: () => ({ success: true }),
agent_get_session_messages: () => [],
agent_chat_stream: createDeprecatedCommandMock(
"agent_chat_stream",
"aster_agent_chat_stream",
),
agent_terminal_command_response: () => ({}),
agent_term_scrollback_response: () => ({}),
@@ -1543,14 +1542,6 @@ const defaultMocks: Record<string, any> = {
initialized: true,
provider_configured: true,
}),
aster_agent_chat_stream: () => ({}),
aster_agent_stop: () => true,
aster_session_create: () => "mock-aster-session",
aster_session_set_execution_strategy: () => ({}),
aster_session_list: () => [],
aster_session_get: () => ({ id: "mock", messages: [] }),
aster_agent_confirm: () => ({}),
aster_agent_submit_elicitation_response: () => ({}),
agent_runtime_submit_turn: () => ({}),
agent_runtime_interrupt_turn: () => true,
agent_runtime_create_session: () => "mock-aster-session",
@@ -1845,6 +1836,7 @@ const defaultMocks: Record<string, any> = {
get_models_by_tier: () => [],
get_provider_alias_config: () => ({ alias: {} }),
get_all_alias_configs: () => ({}),
sync_tray_model_shortcuts: () => ({}),
// Orchestrator 相关
init_orchestrator: () => ({}),
+133
View File
@@ -0,0 +1,133 @@
const DEV_READY_TIMEOUT_MS = 2500;
const PROD_READY_TIMEOUT_MS = 800;
const READY_POLL_INTERVAL_MS = 25;
type TauriInternals = {
invoke?: unknown;
transformCallback?: unknown;
};
function getWindowObject(): (Window & typeof globalThis) | null {
return typeof window === "undefined" ? null : window;
}
export function getTauriGlobal(): Record<string, unknown> | null {
const currentWindow = getWindowObject() as
| ((Window & typeof globalThis) & { __TAURI__?: Record<string, unknown> })
| null;
return currentWindow?.__TAURI__ ?? null;
}
export function getTauriInternals(): TauriInternals | null {
const currentWindow = getWindowObject() as
| ((Window & typeof globalThis) & {
__TAURI_INTERNALS__?: TauriInternals;
})
| null;
return currentWindow?.__TAURI_INTERNALS__ ?? null;
}
export function hasTauriRuntimeMarkers(): boolean {
const currentWindow = getWindowObject();
if (!currentWindow) {
return false;
}
return Boolean(getTauriGlobal()) || "__TAURI_INTERNALS__" in currentWindow;
}
export function hasTauriInvokeCapability(): boolean {
const tauriGlobal = getTauriGlobal() as
| {
core?: { invoke?: unknown };
invoke?: unknown;
}
| null;
const internals = getTauriInternals();
return (
typeof tauriGlobal?.core?.invoke === "function" ||
typeof tauriGlobal?.invoke === "function" ||
typeof internals?.invoke === "function"
);
}
export function hasTauriEventCapability(): boolean {
const tauriGlobal = getTauriGlobal() as
| {
event?: {
listen?: unknown;
emit?: unknown;
};
}
| null;
const internals = getTauriInternals();
return (
typeof tauriGlobal?.event?.listen === "function" ||
(typeof internals?.invoke === "function" &&
typeof internals?.transformCallback === "function")
);
}
export function hasTauriEventListenerCapability(): boolean {
const internals = getTauriInternals();
return (
typeof internals?.invoke === "function" &&
typeof internals?.transformCallback === "function"
);
}
function getReadyTimeoutMs(): number {
return import.meta.env.DEV ? DEV_READY_TIMEOUT_MS : PROD_READY_TIMEOUT_MS;
}
async function waitForCapability(
hasCapability: () => boolean,
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
if (hasCapability()) {
return true;
}
if (!hasTauriRuntimeMarkers()) {
return false;
}
const currentWindow = getWindowObject();
if (!currentWindow) {
return false;
}
const deadline = Date.now() + Math.max(0, timeoutMs);
while (Date.now() < deadline) {
await new Promise<void>((resolve) => {
currentWindow.setTimeout(resolve, READY_POLL_INTERVAL_MS);
});
if (hasCapability()) {
return true;
}
}
return hasCapability();
}
export async function waitForTauriCapability(
capability: "invoke" | "event",
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
const hasCapability =
capability === "event"
? hasTauriEventCapability
: hasTauriInvokeCapability;
return waitForCapability(hasCapability, timeoutMs);
}
export async function waitForTauriEventListenerCapability(
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
return waitForCapability(hasTauriEventListenerCapability, timeoutMs);
}
+1 -19
View File
@@ -8,7 +8,6 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { Webview } from "@tauri-apps/api/webview";
/**
* Webview 面板信息
@@ -914,24 +913,7 @@ export async function getBrowserActionAuditLogs(
export async function closeWebviewPanel(panelId: string): Promise<boolean> {
console.log("[webview-api] 尝试关闭 webview:", panelId);
// 方法 1: 尝试使用 Tauri JavaScript API 直接关闭
try {
const webview = await Webview.getByLabel(panelId);
if (webview) {
console.log("[webview-api] 找到 webview,尝试关闭");
await webview.close();
console.log("[webview-api] Tauri API 关闭成功");
// 也调用后端清理状态
await safeInvoke<boolean>("close_webview_panel", {
panelId,
}).catch(() => {});
return true;
}
} catch (e) {
console.warn("[webview-api] Tauri API 关闭失败:", e);
}
// 方法 2: 使用后端命令关闭
// 统一走后端命令,避免前端在启动期提前加载 Tauri webview/window/event 链路。
try {
const result = await safeInvoke<boolean>("close_webview_panel", {
panelId,