mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Release v0.90.0
This commit is contained in:
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Agent API 现役门面
|
||||
*
|
||||
* 新代码请优先直接使用:
|
||||
* - `agentRuntime.ts`:现役运行时 API
|
||||
* - `agentStream.ts`:流式事件与 UI 类型
|
||||
*
|
||||
* 历史 `agentCompat.ts` 已删除,避免新代码和 AI 自动补全继续误用旧 API。
|
||||
*/
|
||||
|
||||
export * from "./agentRuntime";
|
||||
export * from "./agentStream";
|
||||
+66
-374
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
},
|
||||
|
||||
// 获取单个凭证的健康状态
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: () => ({}),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user