march-control-cli 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +220 -0
  2. package/core/apply.js +152 -0
  3. package/core/backup.js +53 -0
  4. package/core/constants.js +55 -0
  5. package/core/desktop-service.js +219 -0
  6. package/core/desktop-state.js +511 -0
  7. package/core/index.js +1293 -0
  8. package/core/paths.js +71 -0
  9. package/core/presets.js +171 -0
  10. package/core/probe.js +70 -0
  11. package/core/store.js +218 -0
  12. package/core/utils.js +178 -0
  13. package/core/writers/codex.js +102 -0
  14. package/core/writers/index.js +16 -0
  15. package/core/writers/openclaw.js +93 -0
  16. package/core/writers/opencode.js +91 -0
  17. package/desktop/assets/march-mark.svg +21 -0
  18. package/desktop/main.js +192 -0
  19. package/desktop/preload.js +49 -0
  20. package/desktop/renderer/app.js +327 -0
  21. package/desktop/renderer/index.html +130 -0
  22. package/desktop/renderer/styles.css +413 -0
  23. package/package.json +106 -0
  24. package/scripts/desktop-dev.mjs +90 -0
  25. package/scripts/postinstall.mjs +28 -0
  26. package/scripts/serve-site.mjs +51 -0
  27. package/site/app.js +10 -0
  28. package/site/assets/march-mark.svg +22 -0
  29. package/site/index.html +286 -0
  30. package/site/styles.css +566 -0
  31. package/src/App.tsx +1186 -0
  32. package/src/components/layout/app-sidebar.tsx +103 -0
  33. package/src/components/layout/top-toolbar.tsx +44 -0
  34. package/src/components/layout/workspace-tabs.tsx +32 -0
  35. package/src/components/providers/inspector-panel.tsx +84 -0
  36. package/src/components/providers/metric-strip.tsx +26 -0
  37. package/src/components/providers/provider-editor.tsx +87 -0
  38. package/src/components/providers/provider-table.tsx +85 -0
  39. package/src/components/ui/logo-mark.tsx +16 -0
  40. package/src/features/mcp/mcp-view.tsx +45 -0
  41. package/src/features/prompts/prompts-view.tsx +40 -0
  42. package/src/features/providers/providers-view.tsx +40 -0
  43. package/src/features/providers/types.ts +8 -0
  44. package/src/features/skills/skills-view.tsx +44 -0
  45. package/src/hooks/use-control-workspace.ts +184 -0
  46. package/src/index.css +22 -0
  47. package/src/lib/client.ts +944 -0
  48. package/src/lib/query-client.ts +3 -0
  49. package/src/lib/workspace-sections.ts +34 -0
  50. package/src/main.tsx +14 -0
  51. package/src/types.ts +76 -0
  52. package/src/vite-env.d.ts +56 -0
  53. package/src-tauri/README.md +11 -0
@@ -0,0 +1,944 @@
1
+ import type {
2
+ AppSnapshot,
3
+ McpServer,
4
+ PlatformId,
5
+ PlatformSnapshot,
6
+ ProbeResult,
7
+ PromptProfile,
8
+ Provider,
9
+ SkillProfile,
10
+ SkillRepo
11
+ } from "../types";
12
+
13
+ const STORAGE_KEY = "march-control-state";
14
+ const API_PREFIX = "/api";
15
+ const DEFAULT_MODELS = ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max"];
16
+ const DEFAULT_PLATFORMS: PlatformId[] = ["codex", "opencode", "openclaw"];
17
+
18
+ type StoredProvider = Omit<Provider, "maskedApiKey"> & { apiKey: string };
19
+ type StoredPlatform = Omit<PlatformSnapshot, "providers"> & { providers: StoredProvider[] };
20
+ type StoredSnapshot = Omit<AppSnapshot, "platforms"> & { platforms: StoredPlatform[] };
21
+
22
+ type PlatformsApiResponse = {
23
+ appName?: string;
24
+ version?: string;
25
+ generatedAt?: string;
26
+ models?: string[];
27
+ platforms: PlatformSnapshot[];
28
+ mcpServers?: McpServer[];
29
+ prompts?: PromptProfile[];
30
+ skills?: SkillProfile[];
31
+ skillRepos?: SkillRepo[];
32
+ };
33
+
34
+ type ProbePlatformApiResponse = {
35
+ results: ProbeResult[];
36
+ best?: ProbeResult | null;
37
+ };
38
+
39
+ type ProbeCandidateApiResponse = {
40
+ result: ProbeResult | null;
41
+ };
42
+
43
+ type OpenPathApiResponse = {
44
+ ok: true;
45
+ targetPath: string;
46
+ };
47
+
48
+ type DesktopBridge = NonNullable<Window["marchDesktop"]>;
49
+
50
+ type DesktopSnapshotResponse = {
51
+ appName?: string;
52
+ version?: string;
53
+ generatedAt?: string;
54
+ models?: Array<string | { id?: string }>;
55
+ platforms: PlatformSnapshot[];
56
+ mcpServers?: McpServer[];
57
+ prompts?: PromptProfile[];
58
+ skills?: SkillProfile[];
59
+ skillRepos?: SkillRepo[];
60
+ };
61
+
62
+ function getDesktopBridge(): DesktopBridge | null {
63
+ if (typeof window === "undefined") {
64
+ return null;
65
+ }
66
+ return window.marchDesktop ?? null;
67
+ }
68
+
69
+ function normalizeBridgeModels(models?: Array<string | { id?: string }>) {
70
+ if (!Array.isArray(models)) {
71
+ return undefined;
72
+ }
73
+
74
+ return models
75
+ .map((item) => (typeof item === "string" ? item : `${item?.id || ""}`))
76
+ .map((item) => item.trim())
77
+ .filter(Boolean);
78
+ }
79
+
80
+ class ApiUnavailableError extends Error {
81
+ constructor(message = "本地 API 不可用") {
82
+ super(message);
83
+ this.name = "ApiUnavailableError";
84
+ }
85
+ }
86
+
87
+ function isApiUnavailable(error: unknown): error is ApiUnavailableError {
88
+ return error instanceof ApiUnavailableError;
89
+ }
90
+
91
+ function maskApiKey(apiKey: string) {
92
+ if (!apiKey) return "(empty)";
93
+ if (apiKey.length <= 8) return `${apiKey.slice(0, 2)}***${apiKey.slice(-2)}`;
94
+ return `${apiKey.slice(0, 4)}***${apiKey.slice(-4)}`;
95
+ }
96
+
97
+ function now() {
98
+ return new Date().toISOString();
99
+ }
100
+
101
+ function sanitizeModels(models?: string[]) {
102
+ if (!Array.isArray(models) || models.length === 0) {
103
+ return [...DEFAULT_MODELS];
104
+ }
105
+
106
+ const deduped = [...new Set(models.map((item) => `${item || ""}`.trim()).filter(Boolean))];
107
+ return deduped.length > 0 ? deduped : [...DEFAULT_MODELS];
108
+ }
109
+
110
+ function sanitizePlatforms(platforms?: PlatformId[]) {
111
+ if (!Array.isArray(platforms) || platforms.length === 0) {
112
+ return [...DEFAULT_PLATFORMS];
113
+ }
114
+
115
+ const allowed = new Set<PlatformId>(DEFAULT_PLATFORMS);
116
+ const deduped = [...new Set(platforms)].filter((item): item is PlatformId => allowed.has(item));
117
+ return deduped.length > 0 ? deduped : [...DEFAULT_PLATFORMS];
118
+ }
119
+
120
+ function createProvider(name: string, baseUrl: string, model: string, apiKey: string, isActive: boolean): StoredProvider {
121
+ return {
122
+ id: `${name.toLowerCase()}-${Math.random().toString(36).slice(2, 8)}`,
123
+ name,
124
+ baseUrl,
125
+ model,
126
+ apiKey,
127
+ isActive,
128
+ updatedAt: now()
129
+ };
130
+ }
131
+
132
+ function createMcpServer(
133
+ id: string,
134
+ name: string,
135
+ description: string,
136
+ tags: string[],
137
+ enabledPlatforms: PlatformId[],
138
+ homepage?: string
139
+ ): McpServer {
140
+ return { id, name, description, tags, enabledPlatforms, homepage };
141
+ }
142
+
143
+ function createPrompt(
144
+ id: string,
145
+ appType: PromptProfile["appType"],
146
+ name: string,
147
+ description: string,
148
+ content: string,
149
+ enabled: boolean
150
+ ): PromptProfile {
151
+ return { id, appType, name, description, content, enabled, updatedAt: now() };
152
+ }
153
+
154
+ function createSkill(id: string, name: string, description: string, directory: string, enabledPlatforms: PlatformId[]): SkillProfile {
155
+ return { id, name, description, directory, enabledPlatforms };
156
+ }
157
+
158
+ function createSkillRepo(id: string, owner: string, name: string, branch: string, enabled: boolean): SkillRepo {
159
+ return { id, owner, name, branch, enabled };
160
+ }
161
+
162
+ function createInitialState(): StoredSnapshot {
163
+ return {
164
+ appName: "March 控制台",
165
+ version: "0.3.0-alpha",
166
+ generatedAt: now(),
167
+ models: [...DEFAULT_MODELS],
168
+ platforms: [
169
+ {
170
+ id: "codex",
171
+ label: "Codex",
172
+ currentProviderName: "March CN",
173
+ providerCount: 2,
174
+ targetFiles: ["~/.codex/config.toml", "~/.codex/auth.json"],
175
+ providers: [
176
+ createProvider("March CN", "https://gmncode.cn", "gpt-5.4", "sk-live-demo-march-cn", true),
177
+ createProvider("OpenAI Official", "https://api.openai.com", "gpt-5.4", "sk-live-demo-openai", false)
178
+ ]
179
+ },
180
+ {
181
+ id: "opencode",
182
+ label: "OpenCode",
183
+ currentProviderName: "March CN",
184
+ providerCount: 2,
185
+ targetFiles: ["~/.config/opencode/opencode.json"],
186
+ providers: [
187
+ createProvider("March CN", "https://gmncode.cn", "gpt-5.4", "sk-live-demo-march-cn", true),
188
+ createProvider("Backup Relay", "https://relay.example.com", "gpt-5.3-codex", "sk-live-demo-backup", false)
189
+ ]
190
+ },
191
+ {
192
+ id: "openclaw",
193
+ label: "OpenClaw",
194
+ currentProviderName: "March CN",
195
+ providerCount: 1,
196
+ targetFiles: ["~/.openclaw/openclaw.json", "~/.openclaw/agents/main/agent/models.json"],
197
+ providers: [createProvider("March CN", "https://gmncode.cn/v1", "gpt-5.4", "sk-live-demo-march-cn", true)]
198
+ }
199
+ ],
200
+ mcpServers: [
201
+ createMcpServer("filesystem", "文件系统", "已挂载工作区根目录,可读写", ["files", "storage"], ["codex", "opencode", "openclaw"]),
202
+ createMcpServer("browser", "浏览器", "可访问文档、版本发布和参考资料", ["web", "docs"], ["codex", "openclaw"]),
203
+ createMcpServer("git", "Git", "可读取仓库元数据与分支关系", ["code", "history"], ["codex", "opencode", "openclaw"]),
204
+ createMcpServer("shell", "命令行", "可在工作区边界内执行本地命令", ["terminal"], ["codex", "opencode"])
205
+ ],
206
+ prompts: [
207
+ createPrompt(
208
+ "default-coding",
209
+ "global",
210
+ "默认编码",
211
+ "用于开发实现、调试和仓库级改动。",
212
+ "优先做可落地实现、Provider 流程和直接代码修改,减少空泛规划。",
213
+ true
214
+ ),
215
+ createPrompt(
216
+ "ui-review",
217
+ "codex",
218
+ "界面评审",
219
+ "用于视觉比对、布局评估和细节打磨。",
220
+ "按目标参考评审当前界面,识别间距、密度、交互和品牌表达差距。",
221
+ false
222
+ ),
223
+ createPrompt(
224
+ "provider-setup",
225
+ "openclaw",
226
+ "Provider 配置",
227
+ "用于中转配置、路由切换和安装流程。",
228
+ "重点检查 Provider 连通性、路由健康度、代理设置和目标配置文件。",
229
+ true
230
+ )
231
+ ],
232
+ skills: [
233
+ createSkill("openai-docs", "openai-docs", "查询 OpenAI 官方文档", "~/.codex/skills/.system/openai-docs", ["codex"]),
234
+ createSkill("plugin-creator", "plugin-creator", "创建并生成 Codex 插件骨架", "~/.codex/skills/.system/plugin-creator", ["codex"]),
235
+ createSkill("skill-installer", "skill-installer", "从精选来源安装技能", "~/.codex/skills/.system/skill-installer", ["codex"]),
236
+ createSkill("xianyu-keyword-research", "xianyu-keyword-research", "构建闲鱼关键词结构化词池", "~/.codex/skills/xianyu-keyword-research", [
237
+ "codex",
238
+ "openclaw"
239
+ ])
240
+ ],
241
+ skillRepos: [
242
+ createSkillRepo("anthropics-skills", "anthropics", "skills", "main", true),
243
+ createSkillRepo("composio-awesome-claude-skills", "ComposioHQ", "awesome-claude-skills", "master", true),
244
+ createSkillRepo("cexll-myclaude", "cexll", "myclaude", "master", true),
245
+ createSkillRepo("jimliu-baoyu-skills", "JimLiu", "baoyu-skills", "main", true)
246
+ ]
247
+ };
248
+ }
249
+
250
+ function loadState(): StoredSnapshot {
251
+ const raw = localStorage.getItem(STORAGE_KEY);
252
+ if (!raw) {
253
+ const next = createInitialState();
254
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
255
+ return next;
256
+ }
257
+
258
+ try {
259
+ const parsed = JSON.parse(raw) as Partial<StoredSnapshot>;
260
+ const fallback = createInitialState();
261
+ return {
262
+ ...fallback,
263
+ ...parsed,
264
+ models: sanitizeModels(parsed.models ?? fallback.models),
265
+ platforms: parsed.platforms ?? fallback.platforms,
266
+ mcpServers: parsed.mcpServers ?? fallback.mcpServers,
267
+ prompts: parsed.prompts ?? fallback.prompts,
268
+ skills: parsed.skills ?? fallback.skills,
269
+ skillRepos: parsed.skillRepos ?? fallback.skillRepos
270
+ } as StoredSnapshot;
271
+ } catch {
272
+ const next = createInitialState();
273
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
274
+ return next;
275
+ }
276
+ }
277
+
278
+ function saveState(snapshot: StoredSnapshot) {
279
+ localStorage.setItem(
280
+ STORAGE_KEY,
281
+ JSON.stringify({
282
+ ...snapshot,
283
+ generatedAt: now(),
284
+ models: sanitizeModels(snapshot.models),
285
+ platforms: snapshot.platforms.map((platform) => ({
286
+ ...platform,
287
+ providerCount: platform.providers.length,
288
+ currentProviderName: platform.providers.find((provider) => provider.isActive)?.name || null
289
+ }))
290
+ })
291
+ );
292
+ }
293
+
294
+ function toPublic(snapshot: StoredSnapshot): AppSnapshot {
295
+ return {
296
+ ...snapshot,
297
+ models: sanitizeModels(snapshot.models),
298
+ platforms: snapshot.platforms.map((platform) => ({
299
+ ...platform,
300
+ providerCount: platform.providers.length,
301
+ currentProviderName: platform.providers.find((provider) => provider.isActive)?.name || null,
302
+ providers: platform.providers.map((provider) => ({
303
+ id: provider.id,
304
+ name: provider.name,
305
+ baseUrl: provider.baseUrl,
306
+ model: provider.model,
307
+ maskedApiKey: maskApiKey(provider.apiKey),
308
+ isActive: provider.isActive,
309
+ updatedAt: provider.updatedAt
310
+ }))
311
+ }))
312
+ };
313
+ }
314
+
315
+ function normalizeLivePlatform(platform: PlatformSnapshot): PlatformSnapshot {
316
+ return {
317
+ id: platform.id,
318
+ label: platform.label,
319
+ currentProviderName: platform.currentProviderName || null,
320
+ providerCount: platform.providerCount,
321
+ targetFiles: Array.isArray(platform.targetFiles) ? platform.targetFiles : [],
322
+ providers: platform.providers.map((provider) => ({
323
+ id: provider.id,
324
+ name: provider.name,
325
+ baseUrl: provider.baseUrl,
326
+ model: provider.model,
327
+ maskedApiKey: provider.maskedApiKey || "(hidden)",
328
+ isActive: provider.isActive,
329
+ updatedAt: provider.updatedAt || now()
330
+ }))
331
+ };
332
+ }
333
+
334
+ function toStoredPlatforms(platforms: PlatformSnapshot[]): StoredPlatform[] {
335
+ return platforms.map((platform) => ({
336
+ id: platform.id,
337
+ label: platform.label,
338
+ currentProviderName: platform.currentProviderName || null,
339
+ providerCount: platform.providerCount,
340
+ targetFiles: Array.isArray(platform.targetFiles) ? platform.targetFiles : [],
341
+ providers: platform.providers.map((provider) => ({
342
+ id: provider.id,
343
+ name: provider.name,
344
+ baseUrl: provider.baseUrl,
345
+ model: provider.model,
346
+ apiKey: "",
347
+ isActive: provider.isActive,
348
+ updatedAt: provider.updatedAt || now()
349
+ }))
350
+ }));
351
+ }
352
+
353
+ function withLiveSnapshot(snapshot: StoredSnapshot, livePlatforms: PlatformSnapshot[]): AppSnapshot {
354
+ const base = toPublic(snapshot);
355
+ return {
356
+ ...base,
357
+ platforms: livePlatforms
358
+ };
359
+ }
360
+
361
+ function getPlatform(snapshot: StoredSnapshot, platformId: PlatformId) {
362
+ const platform = snapshot.platforms.find((item) => item.id === platformId);
363
+ if (!platform) throw new Error(`Platform not found: ${platformId}`);
364
+ return platform;
365
+ }
366
+
367
+ async function readApiError(response: Response) {
368
+ let fallback = `Request failed (${response.status})`;
369
+ const text = await response.text();
370
+ if (!text) {
371
+ return fallback;
372
+ }
373
+
374
+ try {
375
+ const parsed = JSON.parse(text) as { error?: string; message?: string };
376
+ return parsed.error || parsed.message || fallback;
377
+ } catch {
378
+ fallback = text.trim() || fallback;
379
+ return fallback;
380
+ }
381
+ }
382
+
383
+ async function requestApi<T>(path: string, options: RequestInit = {}): Promise<T> {
384
+ const headers = new Headers(options.headers ?? {});
385
+ if (options.body !== undefined && !headers.has("Content-Type")) {
386
+ headers.set("Content-Type", "application/json");
387
+ }
388
+
389
+ let response: Response;
390
+ try {
391
+ response = await fetch(`${API_PREFIX}${path}`, { ...options, headers });
392
+ } catch {
393
+ throw new ApiUnavailableError();
394
+ }
395
+
396
+ if (!response.ok) {
397
+ if (response.status === 404 || response.status === 405) {
398
+ throw new ApiUnavailableError();
399
+ }
400
+ throw new Error(await readApiError(response));
401
+ }
402
+
403
+ if (response.status === 204) {
404
+ return undefined as T;
405
+ }
406
+
407
+ return (await response.json()) as T;
408
+ }
409
+
410
+ function localSaveProvider(input: { platform: PlatformId; name: string; baseUrl: string; apiKey: string; model: string }) {
411
+ const snapshot = loadState();
412
+ const platform = getPlatform(snapshot, input.platform);
413
+ const existing = platform.providers.find((provider) => provider.name.trim().toLowerCase() === input.name.trim().toLowerCase());
414
+
415
+ platform.providers = platform.providers.map((provider) => ({ ...provider, isActive: false }));
416
+
417
+ if (existing) {
418
+ existing.baseUrl = input.baseUrl;
419
+ existing.apiKey = input.apiKey;
420
+ existing.model = input.model;
421
+ existing.updatedAt = now();
422
+ existing.isActive = true;
423
+ } else {
424
+ platform.providers.unshift(createProvider(input.name.trim(), input.baseUrl.trim(), input.model.trim(), input.apiKey.trim(), true));
425
+ }
426
+
427
+ saveState(snapshot);
428
+ return toPublic(snapshot);
429
+ }
430
+
431
+ function localActivateProvider(input: { platform: PlatformId; providerId: string }) {
432
+ const snapshot = loadState();
433
+ const platform = getPlatform(snapshot, input.platform);
434
+ platform.providers = platform.providers.map((provider) => ({ ...provider, isActive: provider.id === input.providerId }));
435
+ saveState(snapshot);
436
+ return toPublic(snapshot);
437
+ }
438
+
439
+ function localProbePlatform(platformId: PlatformId) {
440
+ const snapshot = loadState();
441
+ const platform = getPlatform(snapshot, platformId);
442
+ const results: ProbeResult[] = platform.providers.map((provider, index) => ({
443
+ baseUrl: provider.baseUrl,
444
+ ok: true,
445
+ latency: 68 + index * 17 + Math.floor(Math.random() * 35)
446
+ }));
447
+ return results.sort((left, right) => left.latency - right.latency);
448
+ }
449
+
450
+ function localUpsertMcp(input: {
451
+ id?: string;
452
+ name: string;
453
+ description: string;
454
+ tags: string[];
455
+ enabledPlatforms: PlatformId[];
456
+ homepage?: string;
457
+ }) {
458
+ const snapshot = loadState();
459
+ const payload: McpServer = {
460
+ id: `${input.id || `mcp-${Date.now().toString(36)}`}`.trim(),
461
+ name: input.name.trim(),
462
+ description: input.description.trim(),
463
+ tags: Array.isArray(input.tags) ? [...new Set(input.tags.map((item) => `${item}`.trim()).filter(Boolean))] : [],
464
+ homepage: `${input.homepage || ""}`.trim() || undefined,
465
+ enabledPlatforms: sanitizePlatforms(input.enabledPlatforms)
466
+ };
467
+
468
+ const index = snapshot.mcpServers.findIndex((item) => item.id === payload.id);
469
+ if (index >= 0) {
470
+ snapshot.mcpServers[index] = payload;
471
+ } else {
472
+ snapshot.mcpServers.unshift(payload);
473
+ }
474
+
475
+ saveState(snapshot);
476
+ return toPublic(snapshot);
477
+ }
478
+
479
+ function localDeleteMcp(input: { serverId: string }) {
480
+ const snapshot = loadState();
481
+ snapshot.mcpServers = snapshot.mcpServers.filter((item) => item.id !== input.serverId);
482
+ saveState(snapshot);
483
+ return toPublic(snapshot);
484
+ }
485
+
486
+ function localUpsertPrompt(input: {
487
+ id?: string;
488
+ appType: "global" | PlatformId;
489
+ name: string;
490
+ description: string;
491
+ content: string;
492
+ enabled: boolean;
493
+ }) {
494
+ const snapshot = loadState();
495
+ const payload = {
496
+ id: `${input.id || `prompt-${Date.now().toString(36)}`}`.trim(),
497
+ appType: input.appType,
498
+ name: input.name.trim(),
499
+ description: input.description.trim(),
500
+ content: input.content.trim(),
501
+ enabled: Boolean(input.enabled),
502
+ updatedAt: now()
503
+ };
504
+
505
+ const index = snapshot.prompts.findIndex((item) => item.id === payload.id);
506
+ if (index >= 0) {
507
+ snapshot.prompts[index] = payload;
508
+ } else {
509
+ snapshot.prompts.unshift(payload);
510
+ }
511
+
512
+ saveState(snapshot);
513
+ return toPublic(snapshot);
514
+ }
515
+
516
+ function localDeletePrompt(input: { promptId: string }) {
517
+ const snapshot = loadState();
518
+ snapshot.prompts = snapshot.prompts.filter((item) => item.id !== input.promptId);
519
+ saveState(snapshot);
520
+ return toPublic(snapshot);
521
+ }
522
+
523
+ function localUpsertSkill(input: {
524
+ id?: string;
525
+ name: string;
526
+ description: string;
527
+ directory: string;
528
+ enabledPlatforms: PlatformId[];
529
+ }) {
530
+ const snapshot = loadState();
531
+ const payload: SkillProfile = {
532
+ id: `${input.id || `skill-${Date.now().toString(36)}`}`.trim(),
533
+ name: input.name.trim(),
534
+ description: input.description.trim(),
535
+ directory: input.directory.trim(),
536
+ enabledPlatforms: sanitizePlatforms(input.enabledPlatforms)
537
+ };
538
+
539
+ const index = snapshot.skills.findIndex((item) => item.id === payload.id);
540
+ if (index >= 0) {
541
+ snapshot.skills[index] = payload;
542
+ } else {
543
+ snapshot.skills.unshift(payload);
544
+ }
545
+
546
+ saveState(snapshot);
547
+ return toPublic(snapshot);
548
+ }
549
+
550
+ function localDeleteSkill(input: { skillId: string }) {
551
+ const snapshot = loadState();
552
+ snapshot.skills = snapshot.skills.filter((item) => item.id !== input.skillId);
553
+ saveState(snapshot);
554
+ return toPublic(snapshot);
555
+ }
556
+
557
+ async function getSnapshotInternal(): Promise<AppSnapshot> {
558
+ const snapshot = loadState();
559
+ const desktopBridge = getDesktopBridge();
560
+
561
+ if (desktopBridge) {
562
+ const response = (await desktopBridge.getSnapshot()) as DesktopSnapshotResponse;
563
+ const livePlatforms = response.platforms.map(normalizeLivePlatform);
564
+ const nextSnapshot: StoredSnapshot = {
565
+ ...snapshot,
566
+ appName: response.appName || snapshot.appName,
567
+ version: response.version || snapshot.version,
568
+ generatedAt: response.generatedAt || now(),
569
+ models: sanitizeModels(normalizeBridgeModels(response.models) ?? snapshot.models),
570
+ platforms: toStoredPlatforms(livePlatforms),
571
+ mcpServers: Array.isArray(response.mcpServers) ? response.mcpServers : snapshot.mcpServers,
572
+ prompts: Array.isArray(response.prompts) ? response.prompts : snapshot.prompts,
573
+ skills: Array.isArray(response.skills) ? response.skills : snapshot.skills,
574
+ skillRepos: Array.isArray(response.skillRepos) ? response.skillRepos : snapshot.skillRepos
575
+ };
576
+ saveState(nextSnapshot);
577
+ return withLiveSnapshot(nextSnapshot, livePlatforms);
578
+ }
579
+
580
+ try {
581
+ const response = await requestApi<PlatformsApiResponse>("/platforms");
582
+ const livePlatforms = response.platforms.map(normalizeLivePlatform);
583
+ const nextSnapshot: StoredSnapshot = {
584
+ ...snapshot,
585
+ appName: response.appName || snapshot.appName,
586
+ version: response.version || snapshot.version,
587
+ generatedAt: response.generatedAt || now(),
588
+ models: sanitizeModels(response.models ?? snapshot.models),
589
+ platforms: toStoredPlatforms(livePlatforms),
590
+ mcpServers: Array.isArray(response.mcpServers) ? response.mcpServers : snapshot.mcpServers,
591
+ prompts: Array.isArray(response.prompts) ? response.prompts : snapshot.prompts,
592
+ skills: Array.isArray(response.skills) ? response.skills : snapshot.skills,
593
+ skillRepos: Array.isArray(response.skillRepos) ? response.skillRepos : snapshot.skillRepos
594
+ };
595
+ saveState(nextSnapshot);
596
+ return withLiveSnapshot(nextSnapshot, livePlatforms);
597
+ } catch (error) {
598
+ if (!isApiUnavailable(error)) {
599
+ throw error;
600
+ }
601
+ return toPublic(snapshot);
602
+ }
603
+ }
604
+
605
+ export const client = {
606
+ getSnapshot: getSnapshotInternal,
607
+
608
+ async saveProvider(input: { platform: PlatformId; name: string; baseUrl: string; apiKey: string; model: string }) {
609
+ const desktopBridge = getDesktopBridge();
610
+ if (desktopBridge) {
611
+ await desktopBridge.saveProvider(input);
612
+ return getSnapshotInternal();
613
+ }
614
+
615
+ try {
616
+ await requestApi<{ snapshot: PlatformSnapshot }>("/save-provider", {
617
+ method: "POST",
618
+ body: JSON.stringify(input)
619
+ });
620
+ return getSnapshotInternal();
621
+ } catch (error) {
622
+ if (!isApiUnavailable(error)) {
623
+ throw error;
624
+ }
625
+ return localSaveProvider(input);
626
+ }
627
+ },
628
+
629
+ async activateProvider(input: { platform: PlatformId; providerId: string }) {
630
+ const desktopBridge = getDesktopBridge();
631
+ if (desktopBridge) {
632
+ await desktopBridge.activateProvider(input);
633
+ return getSnapshotInternal();
634
+ }
635
+
636
+ try {
637
+ await requestApi<{ snapshot: PlatformSnapshot }>("/activate-provider", {
638
+ method: "POST",
639
+ body: JSON.stringify(input)
640
+ });
641
+ return getSnapshotInternal();
642
+ } catch (error) {
643
+ if (!isApiUnavailable(error)) {
644
+ throw error;
645
+ }
646
+ return localActivateProvider(input);
647
+ }
648
+ },
649
+
650
+ async toggleMcpServer(input: { serverId: string; platform: PlatformId }) {
651
+ const desktopBridge = getDesktopBridge();
652
+ if (desktopBridge) {
653
+ await desktopBridge.toggleMcp(input);
654
+ return getSnapshotInternal();
655
+ }
656
+
657
+ try {
658
+ await requestApi<{ snapshot: AppSnapshot }>("/toggle-mcp", {
659
+ method: "POST",
660
+ body: JSON.stringify(input)
661
+ });
662
+ return getSnapshotInternal();
663
+ } catch (error) {
664
+ if (!isApiUnavailable(error)) {
665
+ throw error;
666
+ }
667
+
668
+ const snapshot = loadState();
669
+ snapshot.mcpServers = snapshot.mcpServers.map((server) =>
670
+ server.id !== input.serverId
671
+ ? server
672
+ : {
673
+ ...server,
674
+ enabledPlatforms: server.enabledPlatforms.includes(input.platform)
675
+ ? server.enabledPlatforms.filter((platform) => platform !== input.platform)
676
+ : [...server.enabledPlatforms, input.platform]
677
+ }
678
+ );
679
+ saveState(snapshot);
680
+ return toPublic(snapshot);
681
+ }
682
+ },
683
+
684
+ async upsertMcp(input: {
685
+ id?: string;
686
+ name: string;
687
+ description: string;
688
+ tags: string[];
689
+ enabledPlatforms: PlatformId[];
690
+ homepage?: string;
691
+ }) {
692
+ const desktopBridge = getDesktopBridge();
693
+ if (desktopBridge) {
694
+ await desktopBridge.upsertMcp(input);
695
+ return getSnapshotInternal();
696
+ }
697
+
698
+ try {
699
+ await requestApi<{ snapshot: AppSnapshot }>("/upsert-mcp", {
700
+ method: "POST",
701
+ body: JSON.stringify(input)
702
+ });
703
+ return getSnapshotInternal();
704
+ } catch (error) {
705
+ if (!isApiUnavailable(error)) {
706
+ throw error;
707
+ }
708
+ return localUpsertMcp(input);
709
+ }
710
+ },
711
+
712
+ async deleteMcp(input: { serverId: string }) {
713
+ const desktopBridge = getDesktopBridge();
714
+ if (desktopBridge) {
715
+ await desktopBridge.deleteMcp(input);
716
+ return getSnapshotInternal();
717
+ }
718
+
719
+ try {
720
+ await requestApi<{ snapshot: AppSnapshot }>("/delete-mcp", {
721
+ method: "POST",
722
+ body: JSON.stringify(input)
723
+ });
724
+ return getSnapshotInternal();
725
+ } catch (error) {
726
+ if (!isApiUnavailable(error)) {
727
+ throw error;
728
+ }
729
+ return localDeleteMcp(input);
730
+ }
731
+ },
732
+
733
+ async togglePrompt(input: { promptId: string }) {
734
+ const desktopBridge = getDesktopBridge();
735
+ if (desktopBridge) {
736
+ await desktopBridge.togglePrompt(input);
737
+ return getSnapshotInternal();
738
+ }
739
+
740
+ try {
741
+ await requestApi<{ snapshot: AppSnapshot }>("/toggle-prompt", {
742
+ method: "POST",
743
+ body: JSON.stringify(input)
744
+ });
745
+ return getSnapshotInternal();
746
+ } catch (error) {
747
+ if (!isApiUnavailable(error)) {
748
+ throw error;
749
+ }
750
+
751
+ const snapshot = loadState();
752
+ snapshot.prompts = snapshot.prompts.map((prompt) =>
753
+ prompt.id === input.promptId ? { ...prompt, enabled: !prompt.enabled, updatedAt: now() } : prompt
754
+ );
755
+ saveState(snapshot);
756
+ return toPublic(snapshot);
757
+ }
758
+ },
759
+
760
+ async upsertPrompt(input: {
761
+ id?: string;
762
+ appType: "global" | PlatformId;
763
+ name: string;
764
+ description: string;
765
+ content: string;
766
+ enabled: boolean;
767
+ }) {
768
+ const desktopBridge = getDesktopBridge();
769
+ if (desktopBridge) {
770
+ await desktopBridge.upsertPrompt(input);
771
+ return getSnapshotInternal();
772
+ }
773
+
774
+ try {
775
+ await requestApi<{ snapshot: AppSnapshot }>("/upsert-prompt", {
776
+ method: "POST",
777
+ body: JSON.stringify(input)
778
+ });
779
+ return getSnapshotInternal();
780
+ } catch (error) {
781
+ if (!isApiUnavailable(error)) {
782
+ throw error;
783
+ }
784
+ return localUpsertPrompt(input);
785
+ }
786
+ },
787
+
788
+ async deletePrompt(input: { promptId: string }) {
789
+ const desktopBridge = getDesktopBridge();
790
+ if (desktopBridge) {
791
+ await desktopBridge.deletePrompt(input);
792
+ return getSnapshotInternal();
793
+ }
794
+
795
+ try {
796
+ await requestApi<{ snapshot: AppSnapshot }>("/delete-prompt", {
797
+ method: "POST",
798
+ body: JSON.stringify(input)
799
+ });
800
+ return getSnapshotInternal();
801
+ } catch (error) {
802
+ if (!isApiUnavailable(error)) {
803
+ throw error;
804
+ }
805
+ return localDeletePrompt(input);
806
+ }
807
+ },
808
+
809
+ async toggleSkillRepo(input: { repoId: string }) {
810
+ const desktopBridge = getDesktopBridge();
811
+ if (desktopBridge) {
812
+ await desktopBridge.toggleSkillRepo(input);
813
+ return getSnapshotInternal();
814
+ }
815
+
816
+ try {
817
+ await requestApi<{ snapshot: AppSnapshot }>("/toggle-skill-repo", {
818
+ method: "POST",
819
+ body: JSON.stringify(input)
820
+ });
821
+ return getSnapshotInternal();
822
+ } catch (error) {
823
+ if (!isApiUnavailable(error)) {
824
+ throw error;
825
+ }
826
+
827
+ const snapshot = loadState();
828
+ snapshot.skillRepos = snapshot.skillRepos.map((repo) => (repo.id === input.repoId ? { ...repo, enabled: !repo.enabled } : repo));
829
+ saveState(snapshot);
830
+ return toPublic(snapshot);
831
+ }
832
+ },
833
+
834
+ async upsertSkill(input: {
835
+ id?: string;
836
+ name: string;
837
+ description: string;
838
+ directory: string;
839
+ enabledPlatforms: PlatformId[];
840
+ }) {
841
+ const desktopBridge = getDesktopBridge();
842
+ if (desktopBridge) {
843
+ await desktopBridge.upsertSkill(input);
844
+ return getSnapshotInternal();
845
+ }
846
+
847
+ try {
848
+ await requestApi<{ snapshot: AppSnapshot }>("/upsert-skill", {
849
+ method: "POST",
850
+ body: JSON.stringify(input)
851
+ });
852
+ return getSnapshotInternal();
853
+ } catch (error) {
854
+ if (!isApiUnavailable(error)) {
855
+ throw error;
856
+ }
857
+ return localUpsertSkill(input);
858
+ }
859
+ },
860
+
861
+ async deleteSkill(input: { skillId: string }) {
862
+ const desktopBridge = getDesktopBridge();
863
+ if (desktopBridge) {
864
+ await desktopBridge.deleteSkill(input);
865
+ return getSnapshotInternal();
866
+ }
867
+
868
+ try {
869
+ await requestApi<{ snapshot: AppSnapshot }>("/delete-skill", {
870
+ method: "POST",
871
+ body: JSON.stringify(input)
872
+ });
873
+ return getSnapshotInternal();
874
+ } catch (error) {
875
+ if (!isApiUnavailable(error)) {
876
+ throw error;
877
+ }
878
+ return localDeleteSkill(input);
879
+ }
880
+ },
881
+
882
+ async probePlatform(platformId: PlatformId) {
883
+ const desktopBridge = getDesktopBridge();
884
+ if (desktopBridge) {
885
+ const response = (await desktopBridge.probePlatform({ platform: platformId })) as ProbePlatformApiResponse;
886
+ return response.results ?? [];
887
+ }
888
+
889
+ try {
890
+ const response = await requestApi<ProbePlatformApiResponse>("/probe-platform", {
891
+ method: "POST",
892
+ body: JSON.stringify({ platform: platformId })
893
+ });
894
+ return response.results;
895
+ } catch (error) {
896
+ if (!isApiUnavailable(error)) {
897
+ throw error;
898
+ }
899
+ return localProbePlatform(platformId);
900
+ }
901
+ },
902
+
903
+ async probeCandidate(input: { platform: PlatformId; baseUrl: string }) {
904
+ const desktopBridge = getDesktopBridge();
905
+ if (desktopBridge) {
906
+ return (await desktopBridge.probeCandidate(input)) as ProbeCandidateApiResponse;
907
+ }
908
+
909
+ try {
910
+ return await requestApi<ProbeCandidateApiResponse>("/probe-candidate", {
911
+ method: "POST",
912
+ body: JSON.stringify(input)
913
+ });
914
+ } catch (error) {
915
+ if (!isApiUnavailable(error)) {
916
+ throw error;
917
+ }
918
+ return { result: null };
919
+ }
920
+ },
921
+
922
+ async openPath(input: { targetPath: string }) {
923
+ const desktopBridge = getDesktopBridge();
924
+ if (desktopBridge) {
925
+ const response = (await desktopBridge.openPath(input.targetPath)) as OpenPathApiResponse;
926
+ return {
927
+ ok: true,
928
+ targetPath: response.targetPath || input.targetPath
929
+ };
930
+ }
931
+
932
+ try {
933
+ return await requestApi<OpenPathApiResponse>("/open-path", {
934
+ method: "POST",
935
+ body: JSON.stringify(input)
936
+ });
937
+ } catch (error) {
938
+ if (!isApiUnavailable(error)) {
939
+ throw error;
940
+ }
941
+ throw new Error("当前模式不支持打开文件位置");
942
+ }
943
+ }
944
+ };