skyloom 1.12.0 → 1.13.0

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 (135) hide show
  1. package/.github/workflows/ci.yml +36 -36
  2. package/README.md +142 -46
  3. package/config/default.yaml +43 -47
  4. package/config/models.yaml +155 -155
  5. package/config/providers.yaml +39 -39
  6. package/config/skills/api_integrator/SKILL.md +15 -15
  7. package/config/skills/arch_designer/SKILL.md +13 -13
  8. package/config/skills/ci_cd_manager/SKILL.md +14 -14
  9. package/config/skills/code_analysis/SKILL.md +13 -13
  10. package/config/skills/code_generator/SKILL.md +12 -12
  11. package/config/skills/code_reviewer/SKILL.md +13 -13
  12. package/config/skills/content_writer/SKILL.md +14 -14
  13. package/config/skills/data_transformer/SKILL.md +15 -15
  14. package/config/skills/document_analysis/SKILL.md +13 -13
  15. package/config/skills/emotional_companion/SKILL.md +15 -15
  16. package/config/skills/performance_checker/SKILL.md +14 -14
  17. package/config/skills/security_auditor/SKILL.md +14 -14
  18. package/config/skills/self_evolve/SKILL.md +13 -13
  19. package/config/skills/sys_operator/SKILL.md +15 -15
  20. package/config/skills/task_planner/SKILL.md +14 -14
  21. package/config/skills/web_research/SKILL.md +14 -14
  22. package/config/skills/workflow_designer/SKILL.md +13 -13
  23. package/dist/agents/dew.js +52 -52
  24. package/dist/agents/fair.js +84 -84
  25. package/dist/agents/fog.js +30 -30
  26. package/dist/agents/frost.js +32 -32
  27. package/dist/agents/rain.js +32 -32
  28. package/dist/agents/snow.js +68 -68
  29. package/dist/cli/main.js +103 -51
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli/tui.d.ts.map +1 -1
  32. package/dist/cli/tui.js +8 -1
  33. package/dist/cli/tui.js.map +1 -1
  34. package/dist/core/agent/task.d.ts +58 -0
  35. package/dist/core/agent/task.d.ts.map +1 -0
  36. package/dist/core/agent/task.js +83 -0
  37. package/dist/core/agent/task.js.map +1 -0
  38. package/dist/core/agent.d.ts +2 -45
  39. package/dist/core/agent.d.ts.map +1 -1
  40. package/dist/core/agent.js +61 -145
  41. package/dist/core/agent.js.map +1 -1
  42. package/dist/core/agent_helpers.d.ts +10 -0
  43. package/dist/core/agent_helpers.d.ts.map +1 -1
  44. package/dist/core/agent_helpers.js +39 -0
  45. package/dist/core/agent_helpers.js.map +1 -1
  46. package/dist/core/catalog.d.ts +71 -0
  47. package/dist/core/catalog.d.ts.map +1 -0
  48. package/dist/core/catalog.js +176 -0
  49. package/dist/core/catalog.js.map +1 -0
  50. package/dist/core/config.d.ts +8 -0
  51. package/dist/core/config.d.ts.map +1 -1
  52. package/dist/core/config.js +12 -4
  53. package/dist/core/config.js.map +1 -1
  54. package/dist/core/factory.js +16 -16
  55. package/dist/core/llm.d.ts +7 -0
  56. package/dist/core/llm.d.ts.map +1 -1
  57. package/dist/core/llm.js +139 -7
  58. package/dist/core/llm.js.map +1 -1
  59. package/dist/core/longdoc.js +5 -5
  60. package/dist/core/memory.d.ts.map +1 -1
  61. package/dist/core/memory.js +69 -62
  62. package/dist/core/memory.js.map +1 -1
  63. package/dist/core/theme.d.ts +46 -0
  64. package/dist/core/theme.d.ts.map +1 -0
  65. package/dist/core/theme.js +42 -0
  66. package/dist/core/theme.js.map +1 -0
  67. package/dist/web/server.js +542 -519
  68. package/dist/web/server.js.map +1 -1
  69. package/docs/AESTHETIC_DESIGN.md +144 -0
  70. package/docs/OPTIMIZATION_PLAN.md +178 -0
  71. package/package.json +60 -60
  72. package/scripts/install.js +48 -48
  73. package/scripts/link.js +10 -10
  74. package/setup.bat +79 -79
  75. package/skill-test-ty2fOA/test.md +10 -10
  76. package/src/agents/dew.ts +70 -70
  77. package/src/agents/fair.ts +102 -102
  78. package/src/agents/fog.ts +48 -48
  79. package/src/agents/frost.ts +50 -50
  80. package/src/agents/rain.ts +50 -50
  81. package/src/agents/snow.ts +239 -239
  82. package/src/cli/main.ts +425 -372
  83. package/src/cli/mode.ts +58 -58
  84. package/src/cli/tui.ts +272 -269
  85. package/src/core/agent/task.ts +100 -0
  86. package/src/core/agent.ts +1446 -1549
  87. package/src/core/agent_helpers.ts +496 -461
  88. package/src/core/arbitrate.ts +162 -162
  89. package/src/core/catalog.ts +178 -0
  90. package/src/core/checkpoint.ts +94 -94
  91. package/src/core/config.ts +20 -4
  92. package/src/core/estimate.ts +104 -104
  93. package/src/core/evolve.ts +191 -191
  94. package/src/core/factory.ts +627 -627
  95. package/src/core/filter.ts +103 -103
  96. package/src/core/graph.ts +156 -156
  97. package/src/core/icons.ts +53 -53
  98. package/src/core/index.ts +37 -37
  99. package/src/core/learn.ts +146 -146
  100. package/src/core/llm.ts +108 -5
  101. package/src/core/longdoc.ts +155 -155
  102. package/src/core/mcp_server.ts +176 -176
  103. package/src/core/memory.ts +1178 -1171
  104. package/src/core/profile.ts +255 -255
  105. package/src/core/router.ts +124 -124
  106. package/src/core/sandbox.ts +142 -142
  107. package/src/core/security.ts +243 -243
  108. package/src/core/skill.ts +342 -342
  109. package/src/core/theme.ts +65 -0
  110. package/src/core/tool_router.ts +193 -193
  111. package/src/core/vector.ts +152 -152
  112. package/src/core/workspace.ts +150 -150
  113. package/src/plugins/loader.ts +66 -66
  114. package/src/skills/loader.ts +46 -46
  115. package/src/sql.js.d.ts +29 -29
  116. package/src/tools/builtin.ts +380 -380
  117. package/src/tools/computer.ts +269 -269
  118. package/src/tools/delegate.ts +49 -49
  119. package/src/web/server.ts +660 -634
  120. package/src/web/tts.ts +93 -93
  121. package/tests/agent_helpers.test.ts +48 -0
  122. package/tests/bus.test.ts +121 -121
  123. package/tests/catalog.test.ts +86 -0
  124. package/tests/config.test.ts +41 -0
  125. package/tests/icons.test.ts +45 -45
  126. package/tests/memory.test.ts +147 -0
  127. package/tests/router.test.ts +86 -86
  128. package/tests/schemas.test.ts +51 -51
  129. package/tests/semantic.test.ts +83 -83
  130. package/tests/setup.ts +10 -10
  131. package/tests/skill.test.ts +172 -172
  132. package/tests/task.test.ts +60 -0
  133. package/tests/tool.test.ts +108 -108
  134. package/tests/tool_router.test.ts +71 -71
  135. package/vitest.config.ts +17 -17
@@ -1,162 +1,162 @@
1
- /**
2
- * 多Agent冲突仲裁 — majority voting, quality scoring, tie-breaking.
3
- *
4
- * When multiple agents produce conflicting outputs on the same task
5
- * (or when a reviewer disagrees with an executor), this module
6
- * provides structured conflict resolution.
7
- */
8
-
9
- import type { TaskExecutionResult } from "./factory";
10
-
11
- /* ═══════════════════════════════════════
12
- Conflict detection
13
- ═══════════════════════════════════════ */
14
- export interface Conflict {
15
- taskId: string;
16
- results: TaskExecutionResult[];
17
- description: string;
18
- severity: "low" | "medium" | "high";
19
- }
20
-
21
- /** Detect if two results conflict based on success status and content overlap. */
22
- export function detectConflicts(results: TaskExecutionResult[]): Conflict[] {
23
- const byTask = new Map<string, TaskExecutionResult[]>();
24
- for (const r of results) { const id = r.id; if (!byTask.has(id)) byTask.set(id, []); byTask.get(id)!.push(r); }
25
-
26
- const conflicts: Conflict[] = [];
27
- for (const [id, items] of byTask) {
28
- if (items.length < 2) continue;
29
-
30
- const successes = items.filter(r => r.success);
31
- const failures = items.filter(r => !r.success);
32
-
33
- // All succeeded — check content divergence
34
- if (successes.length >= 2) {
35
- const contents = successes.map(r => (r.content || "").toLowerCase());
36
- const similarity = pairwiseSimilarity(contents);
37
- if (similarity < 0.3) {
38
- conflicts.push({ taskId: id, results: successes, description: "Multiple agents produced divergent successful outputs", severity: "medium" });
39
- }
40
- }
41
-
42
- // Mix of success and failure
43
- if (successes.length > 0 && failures.length > 0) {
44
- conflicts.push({ taskId: id, results: items, description: `${successes.length} succeeded, ${failures.length} failed — need tiebreaker`, severity: "medium" });
45
- }
46
-
47
- // All failed
48
- if (failures.length >= 2 && successes.length === 0) {
49
- conflicts.push({ taskId: id, results: failures, description: "All agents failed on this task", severity: "high" });
50
- }
51
- }
52
-
53
- return conflicts;
54
- }
55
-
56
- /* ═══════════════════════════════════════
57
- Content similarity (n-gram Jaccard)
58
- ═══════════════════════════════════════ */
59
- function pairwiseSimilarity(texts: string[]): number {
60
- if (texts.length < 2) return 1.0;
61
- let total = 0;
62
- let count = 0;
63
- for (let i = 0; i < texts.length; i++) {
64
- for (let j = i + 1; j < texts.length; j++) {
65
- total += ngramJaccard(texts[i], texts[j], 3);
66
- count++;
67
- }
68
- }
69
- return count === 0 ? 0 : total / count;
70
- }
71
-
72
- function ngramJaccard(a: string, b: string, n: number): number {
73
- const as = ngrams(a, n), bs = ngrams(b, n);
74
- if (as.size === 0 && bs.size === 0) return 1;
75
- let intersection = 0;
76
- for (const g of as) { if (bs.has(g)) intersection++; }
77
- const union = as.size + bs.size - intersection;
78
- return union === 0 ? 0 : intersection / union;
79
- }
80
-
81
- function ngrams(s: string, n: number): Set<string> {
82
- const out = new Set<string>();
83
- for (let i = 0; i <= s.length - n; i++) out.add(s.slice(i, i + n));
84
- return out;
85
- }
86
-
87
- /* ═══════════════════════════════════════
88
- Majority voting / arbitration
89
- ═══════════════════════════════════════ */
90
- export interface ArbitrationResult {
91
- winner: TaskExecutionResult;
92
- method: "unanimous" | "majority" | "tiebreaker" | "single";
93
- confidence: number; // 0-1
94
- reasoning: string;
95
- }
96
-
97
- /** Pick the best result from conflicting ones via majority vote. */
98
- export function arbitrate(results: TaskExecutionResult[]): ArbitrationResult {
99
- if (results.length === 0) throw new Error("No results to arbitrate");
100
- if (results.length === 1) return { winner: results[0], method: "single", confidence: 0.8, reasoning: "Only one result available" };
101
-
102
- const success = results.filter(r => r.success);
103
- const fail = results.filter(r => !r.success);
104
-
105
- // All agree (success)
106
- if (success.length === results.length) {
107
- const longest = success.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
108
- return { winner: longest, method: "unanimous", confidence: 0.95, reasoning: `${results.length}/${results.length} agents agreed` };
109
- }
110
-
111
- // Majority success
112
- if (success.length > fail.length) {
113
- // Pick the longest successful content (most detailed)
114
- const best = success.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
115
- return { winner: best, method: "majority", confidence: success.length / results.length, reasoning: `${success.length}/${results.length} succeeded, selected most detailed` };
116
- }
117
-
118
- // Majority failure — pick the "closest to success" (longest content)
119
- if (fail.length > success.length) {
120
- const best = fail.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
121
- return { winner: best, method: "majority", confidence: 0.3, reasoning: `Majority failed (${fail.length}/${results.length}), best-effort from partial output` };
122
- }
123
-
124
- // Tie — prefer success, or longest content
125
- const tie = success.length > 0 ? success[0] : fail[0];
126
- return { winner: tie, method: "tiebreaker", confidence: 0.5, reasoning: `Tie — selected ${tie.success ? "success" : "longest"} result` };
127
- }
128
-
129
- /* ═══════════════════════════════════════
130
- Quality scoring for individual results
131
- ═══════════════════════════════════════ */
132
- export interface QualityScore {
133
- score: number; // 0-100
134
- completeness: number; // how much of the task was addressed
135
- richness: number; // detail level of the output
136
- correctness: number; // did it match expectations (requires ground truth)
137
- }
138
-
139
- export function scoreQuality(result: TaskExecutionResult): QualityScore {
140
- const content = result.content || "";
141
-
142
- // Completeness: length is a weak proxy but useful
143
- const completeness = content.length > 500 ? 80 : content.length > 100 ? 50 : content.length > 0 ? 20 : 0;
144
-
145
- // Richness: code blocks, structured output, bullet points
146
- let richness = 50;
147
- if (/```/.test(content)) richness += 20;
148
- if (/\|.*\|.*\|/.test(content)) richness += 15; // tables
149
- if (/^[-*] /.test(content)) richness += 10; // bullets
150
- if (/\d+\./.test(content)) richness += 10; // numbered lists
151
- richness = Math.min(100, richness);
152
-
153
- // Correctness: basic sanity checks
154
- let correctness = 70;
155
- if (content.includes("Error") || content.includes("error")) correctness -= 20;
156
- if (content.includes("[REDACTED]")) correctness -= 10;
157
- if (content.includes("truncated")) correctness -= 15;
158
- correctness = Math.max(0, correctness);
159
-
160
- const score = Math.round((completeness * 0.3 + richness * 0.3 + correctness * 0.4));
161
- return { score, completeness, richness, correctness };
162
- }
1
+ /**
2
+ * 多Agent冲突仲裁 — majority voting, quality scoring, tie-breaking.
3
+ *
4
+ * When multiple agents produce conflicting outputs on the same task
5
+ * (or when a reviewer disagrees with an executor), this module
6
+ * provides structured conflict resolution.
7
+ */
8
+
9
+ import type { TaskExecutionResult } from "./factory";
10
+
11
+ /* ═══════════════════════════════════════
12
+ Conflict detection
13
+ ═══════════════════════════════════════ */
14
+ export interface Conflict {
15
+ taskId: string;
16
+ results: TaskExecutionResult[];
17
+ description: string;
18
+ severity: "low" | "medium" | "high";
19
+ }
20
+
21
+ /** Detect if two results conflict based on success status and content overlap. */
22
+ export function detectConflicts(results: TaskExecutionResult[]): Conflict[] {
23
+ const byTask = new Map<string, TaskExecutionResult[]>();
24
+ for (const r of results) { const id = r.id; if (!byTask.has(id)) byTask.set(id, []); byTask.get(id)!.push(r); }
25
+
26
+ const conflicts: Conflict[] = [];
27
+ for (const [id, items] of byTask) {
28
+ if (items.length < 2) continue;
29
+
30
+ const successes = items.filter(r => r.success);
31
+ const failures = items.filter(r => !r.success);
32
+
33
+ // All succeeded — check content divergence
34
+ if (successes.length >= 2) {
35
+ const contents = successes.map(r => (r.content || "").toLowerCase());
36
+ const similarity = pairwiseSimilarity(contents);
37
+ if (similarity < 0.3) {
38
+ conflicts.push({ taskId: id, results: successes, description: "Multiple agents produced divergent successful outputs", severity: "medium" });
39
+ }
40
+ }
41
+
42
+ // Mix of success and failure
43
+ if (successes.length > 0 && failures.length > 0) {
44
+ conflicts.push({ taskId: id, results: items, description: `${successes.length} succeeded, ${failures.length} failed — need tiebreaker`, severity: "medium" });
45
+ }
46
+
47
+ // All failed
48
+ if (failures.length >= 2 && successes.length === 0) {
49
+ conflicts.push({ taskId: id, results: failures, description: "All agents failed on this task", severity: "high" });
50
+ }
51
+ }
52
+
53
+ return conflicts;
54
+ }
55
+
56
+ /* ═══════════════════════════════════════
57
+ Content similarity (n-gram Jaccard)
58
+ ═══════════════════════════════════════ */
59
+ function pairwiseSimilarity(texts: string[]): number {
60
+ if (texts.length < 2) return 1.0;
61
+ let total = 0;
62
+ let count = 0;
63
+ for (let i = 0; i < texts.length; i++) {
64
+ for (let j = i + 1; j < texts.length; j++) {
65
+ total += ngramJaccard(texts[i], texts[j], 3);
66
+ count++;
67
+ }
68
+ }
69
+ return count === 0 ? 0 : total / count;
70
+ }
71
+
72
+ function ngramJaccard(a: string, b: string, n: number): number {
73
+ const as = ngrams(a, n), bs = ngrams(b, n);
74
+ if (as.size === 0 && bs.size === 0) return 1;
75
+ let intersection = 0;
76
+ for (const g of as) { if (bs.has(g)) intersection++; }
77
+ const union = as.size + bs.size - intersection;
78
+ return union === 0 ? 0 : intersection / union;
79
+ }
80
+
81
+ function ngrams(s: string, n: number): Set<string> {
82
+ const out = new Set<string>();
83
+ for (let i = 0; i <= s.length - n; i++) out.add(s.slice(i, i + n));
84
+ return out;
85
+ }
86
+
87
+ /* ═══════════════════════════════════════
88
+ Majority voting / arbitration
89
+ ═══════════════════════════════════════ */
90
+ export interface ArbitrationResult {
91
+ winner: TaskExecutionResult;
92
+ method: "unanimous" | "majority" | "tiebreaker" | "single";
93
+ confidence: number; // 0-1
94
+ reasoning: string;
95
+ }
96
+
97
+ /** Pick the best result from conflicting ones via majority vote. */
98
+ export function arbitrate(results: TaskExecutionResult[]): ArbitrationResult {
99
+ if (results.length === 0) throw new Error("No results to arbitrate");
100
+ if (results.length === 1) return { winner: results[0], method: "single", confidence: 0.8, reasoning: "Only one result available" };
101
+
102
+ const success = results.filter(r => r.success);
103
+ const fail = results.filter(r => !r.success);
104
+
105
+ // All agree (success)
106
+ if (success.length === results.length) {
107
+ const longest = success.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
108
+ return { winner: longest, method: "unanimous", confidence: 0.95, reasoning: `${results.length}/${results.length} agents agreed` };
109
+ }
110
+
111
+ // Majority success
112
+ if (success.length > fail.length) {
113
+ // Pick the longest successful content (most detailed)
114
+ const best = success.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
115
+ return { winner: best, method: "majority", confidence: success.length / results.length, reasoning: `${success.length}/${results.length} succeeded, selected most detailed` };
116
+ }
117
+
118
+ // Majority failure — pick the "closest to success" (longest content)
119
+ if (fail.length > success.length) {
120
+ const best = fail.reduce((a, b) => (b.content || "").length > (a.content || "").length ? b : a);
121
+ return { winner: best, method: "majority", confidence: 0.3, reasoning: `Majority failed (${fail.length}/${results.length}), best-effort from partial output` };
122
+ }
123
+
124
+ // Tie — prefer success, or longest content
125
+ const tie = success.length > 0 ? success[0] : fail[0];
126
+ return { winner: tie, method: "tiebreaker", confidence: 0.5, reasoning: `Tie — selected ${tie.success ? "success" : "longest"} result` };
127
+ }
128
+
129
+ /* ═══════════════════════════════════════
130
+ Quality scoring for individual results
131
+ ═══════════════════════════════════════ */
132
+ export interface QualityScore {
133
+ score: number; // 0-100
134
+ completeness: number; // how much of the task was addressed
135
+ richness: number; // detail level of the output
136
+ correctness: number; // did it match expectations (requires ground truth)
137
+ }
138
+
139
+ export function scoreQuality(result: TaskExecutionResult): QualityScore {
140
+ const content = result.content || "";
141
+
142
+ // Completeness: length is a weak proxy but useful
143
+ const completeness = content.length > 500 ? 80 : content.length > 100 ? 50 : content.length > 0 ? 20 : 0;
144
+
145
+ // Richness: code blocks, structured output, bullet points
146
+ let richness = 50;
147
+ if (/```/.test(content)) richness += 20;
148
+ if (/\|.*\|.*\|/.test(content)) richness += 15; // tables
149
+ if (/^[-*] /.test(content)) richness += 10; // bullets
150
+ if (/\d+\./.test(content)) richness += 10; // numbered lists
151
+ richness = Math.min(100, richness);
152
+
153
+ // Correctness: basic sanity checks
154
+ let correctness = 70;
155
+ if (content.includes("Error") || content.includes("error")) correctness -= 20;
156
+ if (content.includes("[REDACTED]")) correctness -= 10;
157
+ if (content.includes("truncated")) correctness -= 15;
158
+ correctness = Math.max(0, correctness);
159
+
160
+ const score = Math.round((completeness * 0.3 + richness * 0.3 + correctness * 0.4));
161
+ return { score, completeness, richness, correctness };
162
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Model & Provider Catalog — single source of truth.
3
+ *
4
+ * Replaces the three drifting copies of model data (config/models.yaml,
5
+ * the hardcoded setup-wizard list in cli/main.ts, and the README table)
6
+ * with one typed, validated catalog loaded from config/models.yaml.
7
+ *
8
+ * Every model exposed here is intended to be directly callable. Fictional
9
+ * or unreleased models must not appear in models.yaml.
10
+ */
11
+
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as yaml from "yaml";
15
+ import { CONFIG_DIR } from "./config";
16
+ import { getLogger } from "./logger";
17
+
18
+ const log = getLogger("catalog");
19
+
20
+ /** A single callable model. Costs are USD per 1M tokens. */
21
+ export interface ModelInfo {
22
+ /** Model id as passed to the provider (e.g. "gpt-4o", "openai/gpt-4.1"). */
23
+ id: string;
24
+ /** Catalog provider key (e.g. "openai", "deepseek"). */
25
+ provider: string;
26
+ /** Context window in tokens. */
27
+ context: number;
28
+ /** Input cost, USD per 1M tokens. */
29
+ costIn: number;
30
+ /** Output cost, USD per 1M tokens. */
31
+ costOut: number;
32
+ /** Short human description. */
33
+ desc: string;
34
+ /** True for local/free providers (ollama, zero-cost). */
35
+ local: boolean;
36
+ }
37
+
38
+ /** Display + ordering metadata for a provider in setup/UI. */
39
+ export interface ProviderMeta {
40
+ id: string;
41
+ /** Human label shown in the setup wizard. */
42
+ name: string;
43
+ /** Env var that supplies the API key (when applicable). */
44
+ envVar?: string;
45
+ /** Sort order in the wizard. */
46
+ order: number;
47
+ }
48
+
49
+ /**
50
+ * Provider display metadata. Ordering matches the setup wizard.
51
+ * Provider *ids* match the keys used in config/models.yaml.
52
+ */
53
+ export const PROVIDER_META: Record<string, ProviderMeta> = {
54
+ deepseek: { id: "deepseek", name: "DeepSeek", envVar: "DEEPSEEK_API_KEY", order: 1 },
55
+ openai: { id: "openai", name: "OpenAI", envVar: "OPENAI_API_KEY", order: 2 },
56
+ anthropic: { id: "anthropic", name: "Anthropic", envVar: "ANTHROPIC_API_KEY", order: 3 },
57
+ google: { id: "google", name: "Google Gemini", envVar: "GEMINI_API_KEY", order: 4 },
58
+ groq: { id: "groq", name: "Groq", envVar: "GROQ_API_KEY", order: 5 },
59
+ openrouter: { id: "openrouter", name: "OpenRouter (多模型)", envVar: "OPENROUTER_API_KEY", order: 6 },
60
+ mistral: { id: "mistral", name: "Mistral", envVar: "MISTRAL_API_KEY", order: 7 },
61
+ xai: { id: "xai", name: "xAI (Grok)", envVar: "XAI_API_KEY", order: 8 },
62
+ ollama: { id: "ollama", name: "Ollama 本地", order: 9 },
63
+ };
64
+
65
+ /** Raw shape of an entry in config/models.yaml. */
66
+ interface RawModelEntry {
67
+ name: string;
68
+ context?: number;
69
+ cost_in?: number;
70
+ cost_out?: number;
71
+ desc?: string;
72
+ }
73
+
74
+ let catalogCache: Map<string, ModelInfo[]> | null = null;
75
+
76
+ function readModelsYaml(): Record<string, RawModelEntry[]> {
77
+ const modelPath = path.join(CONFIG_DIR, "models.yaml");
78
+ try {
79
+ if (!fs.existsSync(modelPath)) {
80
+ log.warn("models_yaml_missing", { path: modelPath });
81
+ return {};
82
+ }
83
+ const data = yaml.parse(fs.readFileSync(modelPath, "utf-8"));
84
+ return (data && typeof data === "object" ? data : {}) as Record<string, RawModelEntry[]>;
85
+ } catch (e) {
86
+ log.error("models_yaml_parse_failed", { error: String(e) });
87
+ return {};
88
+ }
89
+ }
90
+
91
+ /** Load (and cache) the full catalog as provider -> models. */
92
+ export function loadCatalog(): Map<string, ModelInfo[]> {
93
+ if (catalogCache) return catalogCache;
94
+
95
+ const raw = readModelsYaml();
96
+ const catalog = new Map<string, ModelInfo[]>();
97
+
98
+ for (const [provider, entries] of Object.entries(raw)) {
99
+ if (!Array.isArray(entries)) continue;
100
+ const models: ModelInfo[] = [];
101
+ for (const e of entries) {
102
+ if (!e || typeof e.name !== "string") continue;
103
+ const costIn = e.cost_in ?? 0;
104
+ const costOut = e.cost_out ?? 0;
105
+ models.push({
106
+ id: e.name,
107
+ provider,
108
+ context: e.context ?? 0,
109
+ costIn,
110
+ costOut,
111
+ desc: e.desc ?? "",
112
+ local: provider === "ollama" || (costIn === 0 && costOut === 0),
113
+ });
114
+ }
115
+ if (models.length > 0) catalog.set(provider, models);
116
+ }
117
+
118
+ catalogCache = catalog;
119
+ return catalog;
120
+ }
121
+
122
+ /** Clear the cache (used by tests). */
123
+ export function resetCatalogCache(): void {
124
+ catalogCache = null;
125
+ }
126
+
127
+ /** All provider ids present in the catalog, in wizard order. */
128
+ export function listProviders(): string[] {
129
+ const present = [...loadCatalog().keys()];
130
+ return present.sort((a, b) => (PROVIDER_META[a]?.order ?? 99) - (PROVIDER_META[b]?.order ?? 99));
131
+ }
132
+
133
+ /** Models for a provider (empty array if unknown). */
134
+ export function modelsFor(provider: string): ModelInfo[] {
135
+ return loadCatalog().get(provider) ?? [];
136
+ }
137
+
138
+ /** Flat list of every callable model across providers. */
139
+ export function allModels(): ModelInfo[] {
140
+ return [...loadCatalog().values()].flat();
141
+ }
142
+
143
+ /**
144
+ * Look up a model by id. Matching is tolerant of a "provider/" prefix
145
+ * (e.g. "openai/gpt-4o" resolves to the "gpt-4o" entry under openai).
146
+ */
147
+ export function getModelInfo(modelId: string): ModelInfo | null {
148
+ const all = allModels();
149
+ const exact = all.find((m) => m.id === modelId);
150
+ if (exact) return exact;
151
+ // tolerate provider/ prefix on either side
152
+ const stripped = modelId.includes("/") ? modelId.split("/").slice(1).join("/") : modelId;
153
+ return all.find((m) => m.id === stripped || m.id.split("/").slice(1).join("/") === modelId) ?? null;
154
+ }
155
+
156
+ /** Whether a model id is a known, callable model in the catalog. */
157
+ export function isKnownModel(modelId: string): boolean {
158
+ return getModelInfo(modelId) !== null;
159
+ }
160
+
161
+ /** Provider display label (falls back to the raw id). */
162
+ export function providerLabel(provider: string): string {
163
+ return PROVIDER_META[provider]?.name ?? provider;
164
+ }
165
+
166
+ /**
167
+ * Validate that a configured default model is callable. Returns a short
168
+ * list of suggested model ids when it is not, so callers can fail loudly
169
+ * instead of 404-ing at request time.
170
+ */
171
+ export function validateModel(modelId: string | undefined): { ok: boolean; suggestions: string[] } {
172
+ if (modelId && isKnownModel(modelId)) return { ok: true, suggestions: [] };
173
+ const suggestions = allModels()
174
+ .filter((m) => !m.local)
175
+ .slice(0, 6)
176
+ .map((m) => m.id);
177
+ return { ok: false, suggestions };
178
+ }