autosnippet 2.5.0 → 2.7.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 (72) hide show
  1. package/bin/cli.js +35 -0
  2. package/dashboard/dist/assets/{icons-Dtm0E6DS.js → icons-Cq4-iQhP.js} +152 -87
  3. package/dashboard/dist/assets/index-DBxH7pVn.css +1 -0
  4. package/dashboard/dist/assets/index-Dw2F6qAS.js +197 -0
  5. package/dashboard/dist/assets/{react-markdown-CWxUbOf4.js → react-markdown-BA6FB2NP.js} +1 -1
  6. package/dashboard/dist/assets/{syntax-highlighter-CJ2drQQb.js → syntax-highlighter-CVLHn9O5.js} +1 -1
  7. package/dashboard/dist/assets/{vendor-f83ah6cm.js → vendor-BotF760a.js} +61 -61
  8. package/dashboard/dist/index.html +6 -6
  9. package/lib/bootstrap.js +1 -1
  10. package/lib/cli/SetupService.js +33 -8
  11. package/lib/cli/UpgradeService.js +139 -2
  12. package/lib/core/ast/ProjectGraph.js +599 -0
  13. package/lib/core/gateway/Gateway.js +19 -4
  14. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  15. package/lib/domain/recipe/Recipe.js +3 -0
  16. package/lib/external/ai/AiProvider.js +117 -10
  17. package/lib/external/ai/providers/ClaudeProvider.js +197 -0
  18. package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
  19. package/lib/external/ai/providers/OpenAiProvider.js +131 -0
  20. package/lib/external/mcp/McpServer.js +2 -1
  21. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  22. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
  23. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
  24. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  25. package/lib/external/mcp/handlers/bootstrap.js +151 -1634
  26. package/lib/external/mcp/handlers/browse.js +1 -1
  27. package/lib/external/mcp/handlers/candidate.js +1 -33
  28. package/lib/external/mcp/handlers/skill.js +126 -31
  29. package/lib/external/mcp/tools.js +25 -3
  30. package/lib/http/middleware/requestLogger.js +23 -4
  31. package/lib/http/routes/ai.js +3 -1
  32. package/lib/http/routes/auth.js +3 -2
  33. package/lib/http/routes/candidates.js +49 -25
  34. package/lib/http/routes/commands.js +0 -8
  35. package/lib/http/routes/guardRules.js +1 -16
  36. package/lib/http/routes/recipes.js +4 -17
  37. package/lib/http/routes/search.js +16 -22
  38. package/lib/http/routes/skills.js +40 -3
  39. package/lib/http/routes/snippets.js +0 -33
  40. package/lib/http/routes/spm.js +37 -63
  41. package/lib/http/utils/routeHelpers.js +31 -0
  42. package/lib/infrastructure/audit/AuditStore.js +18 -0
  43. package/lib/infrastructure/config/Paths.js +9 -0
  44. package/lib/infrastructure/logging/Logger.js +86 -3
  45. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  46. package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
  47. package/lib/injection/ServiceContainer.js +62 -3
  48. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  49. package/lib/service/candidate/CandidateFileWriter.js +68 -27
  50. package/lib/service/candidate/CandidateService.js +156 -10
  51. package/lib/service/chat/AnalystAgent.js +216 -0
  52. package/lib/service/chat/CandidateGuardrail.js +134 -0
  53. package/lib/service/chat/ChatAgent.js +1272 -155
  54. package/lib/service/chat/ContextWindow.js +730 -0
  55. package/lib/service/chat/ConversationStore.js +377 -0
  56. package/lib/service/chat/HandoffProtocol.js +180 -0
  57. package/lib/service/chat/Memory.js +40 -10
  58. package/lib/service/chat/ProducerAgent.js +240 -0
  59. package/lib/service/chat/ToolRegistry.js +149 -5
  60. package/lib/service/chat/tools.js +1493 -60
  61. package/lib/service/recipe/RecipeFileWriter.js +12 -1
  62. package/lib/service/skills/EventAggregator.js +187 -0
  63. package/lib/service/skills/SignalCollector.js +549 -0
  64. package/lib/service/skills/SkillAdvisor.js +324 -0
  65. package/lib/service/skills/SkillHooks.js +13 -5
  66. package/lib/service/spm/SpmService.js +2 -2
  67. package/package.json +1 -1
  68. package/templates/copilot-instructions.md +20 -3
  69. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  70. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  71. package/dashboard/dist/assets/index-B7VpZOCz.css +0 -1
  72. package/dashboard/dist/assets/index-D87IZTmZ.js +0 -187
@@ -11,6 +11,13 @@ export class AiProvider {
11
11
  this.timeout = config.timeout || 300_000; // 5min
12
12
  this.maxRetries = config.maxRetries || 3;
13
13
  this.name = 'abstract';
14
+
15
+ // ── CircuitBreaker 状态 ──
16
+ this._circuitState = 'CLOSED'; // 'CLOSED' | 'OPEN' | 'HALF_OPEN'
17
+ this._circuitFailures = 0; // 连续失败计数
18
+ this._circuitThreshold = config.circuitThreshold || 5; // 触发熔断的连续失败次数
19
+ this._circuitOpenedAt = 0; // 熔断打开时间
20
+ this._circuitCooldownMs = 30_000; // 初始冷却 30 秒
14
21
  }
15
22
 
16
23
  /**
@@ -59,6 +66,53 @@ export class AiProvider {
59
66
  return true;
60
67
  }
61
68
 
69
+ /**
70
+ * 是否支持原生结构化函数调用(非文本解析)
71
+ * 子类(如 GoogleGeminiProvider)覆盖返回 true
72
+ * @returns {boolean}
73
+ */
74
+ get supportsNativeToolCalling() {
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * 带工具声明的结构化对话 — 原生函数调用 API
80
+ *
81
+ * 支持原生函数调用的 Provider(Gemini / OpenAI / Claude)覆盖此方法,
82
+ * 返回结构化 functionCall 而非文本,ChatAgent 据此跳过正则解析。
83
+ *
84
+ * 默认实现降级为 chat(),由 ChatAgent 进行文本解析。
85
+ *
86
+ * 统一消息格式 (Provider-Agnostic):
87
+ * - { role: 'user', content: 'text' }
88
+ * - { role: 'assistant', content: 'text or null', toolCalls: [{id, name, args}] }
89
+ * - { role: 'tool', toolCallId: 'id', name: 'tool_name', content: 'result string' }
90
+ *
91
+ * @param {string} prompt — 用户消息(仅在 messages 为空时使用)
92
+ * @param {object} opts
93
+ * @param {Array} opts.messages — 统一格式消息历史
94
+ * @param {Array} opts.toolSchemas — [{name, description, parameters}]
95
+ * @param {string} opts.toolChoice — 'auto' | 'required' | 'none'
96
+ * @param {string} [opts.systemPrompt] — 系统指令
97
+ * @param {number} [opts.temperature=0.7]
98
+ * @param {number} [opts.maxTokens=8192]
99
+ * @returns {Promise<{text: string|null, functionCalls: Array<{id: string, name: string, args: object}>|null}>}
100
+ */
101
+ async chatWithTools(prompt, opts = {}) {
102
+ // 默认降级: 忽略 tools/toolChoice,走纯文本 chat()
103
+ const messages = opts.messages || [];
104
+ const history = messages
105
+ .filter(m => m.role === 'user' || m.role === 'assistant')
106
+ .map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content || '' }));
107
+ const text = await this.chat(prompt, {
108
+ history,
109
+ systemPrompt: opts.systemPrompt,
110
+ temperature: opts.temperature,
111
+ maxTokens: opts.maxTokens,
112
+ });
113
+ return { text, functionCalls: null };
114
+ }
115
+
62
116
  /**
63
117
  * 从源码文件批量提取 Recipe 结构(AI 驱动)
64
118
  * 默认实现使用 chat() + 标准提示词;子类可覆盖以使用专用 API
@@ -365,7 +419,7 @@ Do NOT overwrite fields that are already filled (listed under "Already filled").
365
419
  # Fields to Fill (only if missing)
366
420
 
367
421
  1. **rationale** (string): Why this pattern exists; what design intent or problem it solves. 2-3 sentences.
368
- 2. **knowledgeType** (string): One of: "code-standard", "code-pattern", "architecture", "best-practice", "code-relation", "inheritance", "call-chain", "data-flow", "module-dependency", "boundary-constraint", "code-style", "solution".
422
+ 2. **knowledgeType** (string): One of: "code-standard", "code-pattern", "architecture", "best-practice", "code-relation", "inheritance", "call-chain", "data-flow", "module-dependency", "boundary-constraint", "code-style", "solution", "anti-pattern".
369
423
  3. **complexity** (string): "beginner" | "intermediate" | "advanced". Evaluate usage difficulty.
370
424
  4. **scope** (string): "universal" (reusable anywhere) | "project-specific" (specific to this project) | "target-specific" (specific to one module/target).
371
425
  5. **steps** (array): Implementation steps. Each: { "title": "Step N title", "description": "What to do", "code": "optional code" }.
@@ -560,23 +614,76 @@ ${items}`;
560
614
  }
561
615
 
562
616
  /**
563
- * 指数退避重试
617
+ * 指数退避重试 + 熔断器(受 Cline 三级错误恢复启发)
618
+ *
619
+ * 熔断器三态:
620
+ * CLOSED — 正常工作,计数连续失败
621
+ * OPEN — 连续 N 次失败,直接拒绝请求(快速失败),持续 cooldownMs
622
+ * HALF_OPEN — 冷却期后尝试一次,成功则恢复,失败则重新 OPEN
623
+ *
624
+ * 这避免了 AI 服务宕机时无意义的重试风暴。
564
625
  */
565
626
  async _withRetry(fn, retries = this.maxRetries, baseDelay = 2000) {
627
+ // ── 熔断器检查 ──
628
+ if (this._circuitState === 'OPEN') {
629
+ const elapsed = Date.now() - (this._circuitOpenedAt || 0);
630
+ if (elapsed < (this._circuitCooldownMs || 30000)) {
631
+ const err = new Error(`AI 服务熔断中 (连续 ${this._circuitFailures} 次失败),${Math.ceil(((this._circuitCooldownMs || 30000) - elapsed) / 1000)}s 后恢复`);
632
+ err.code = 'CIRCUIT_OPEN';
633
+ throw err;
634
+ }
635
+ // 冷却期结束 → HALF_OPEN
636
+ this._circuitState = 'HALF_OPEN';
637
+ }
638
+
566
639
  for (let attempt = 0; attempt <= retries; attempt++) {
567
640
  try {
568
- return await fn();
641
+ const result = await fn();
642
+ // 成功 → 完全重置熔断器(包括冷却时间)
643
+ this._circuitFailures = 0;
644
+ this._circuitState = 'CLOSED';
645
+ this._circuitCooldownMs = 30_000; // 重置冷却时间
646
+ return result;
569
647
  } catch (err) {
570
- // 连接超时提示代理配置
571
- if (err.cause?.code === 'UND_ERR_CONNECT_TIMEOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT') {
572
- const hasProxy = process.env.ASD_AI_PROXY || process.env.HTTPS_PROXY || process.env.ALL_PROXY;
573
- if (!hasProxy) {
574
- err.message += ' — 💡 可能需要配置代理: export HTTPS_PROXY=http://127.0.0.1:7890';
648
+ // ── 综合判断是否为可重试的网络/服务端错误 ──
649
+ const causeCode = err.cause?.code || '';
650
+ // 网络级错误:无 HTTP status,底层连接失败
651
+ const isNetworkError = !err.status && (
652
+ err.message === 'fetch failed'
653
+ || err.code === 'ECONNRESET' || causeCode === 'ECONNRESET'
654
+ || err.code === 'ECONNREFUSED' || causeCode === 'ECONNREFUSED'
655
+ || err.code === 'ENOTFOUND' || causeCode === 'ENOTFOUND'
656
+ || err.code === 'ECONNABORTED' || causeCode === 'ECONNABORTED'
657
+ || err.code === 'ETIMEDOUT' || causeCode === 'ETIMEDOUT'
658
+ || err.code === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_CONNECT_TIMEOUT'
659
+ || err.code === 'UND_ERR_SOCKET' || causeCode === 'UND_ERR_SOCKET'
660
+ );
661
+ const isRetryable = err.status === 429 || err.status >= 500 || isNetworkError;
662
+
663
+ // 首次失败记录详细诊断(含 cause)
664
+ if (attempt === 0 && (isNetworkError || err.cause)) {
665
+ this._log?.('warn', `[_withRetry] ${err.message} — cause: ${err.cause?.message || causeCode || 'unknown'}`);
666
+ }
667
+
668
+ if (attempt >= retries || !isRetryable) {
669
+ // 只有服务端错误 / 网络错误才累计熔断计数
670
+ // 客户端错误 (4xx 非 429) 不应触发熔断 — 那是请求本身的问题
671
+ const isServerError = isNetworkError || err.status === 429 || err.status >= 500 || !err.status;
672
+ if (isServerError) {
673
+ this._circuitFailures = (this._circuitFailures || 0) + 1;
674
+ if (this._circuitFailures >= (this._circuitThreshold || 5)) {
675
+ this._circuitState = 'OPEN';
676
+ this._circuitOpenedAt = Date.now();
677
+ // 先用当前冷却值,再递增给下次: 30s → 60s → 120s(最大 5 分钟)
678
+ const cooldown = this._circuitCooldownMs || 30_000;
679
+ this._log?.('warn', `[CircuitBreaker] OPEN — ${this._circuitFailures} consecutive failures, cooldown ${cooldown / 1000}s`);
680
+ this._circuitCooldownMs = Math.min(cooldown * 2, 300_000);
681
+ }
575
682
  }
683
+ throw err;
576
684
  }
577
- const isRetryable = err.status === 429 || err.status === 503 || err.code === 'ECONNRESET';
578
- if (attempt >= retries || !isRetryable) throw err;
579
685
  const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
686
+ this._log?.('info', `[_withRetry] attempt ${attempt + 1} failed (${err.message}), retrying in ${Math.round(delay / 1000)}s…`);
580
687
  await new Promise(r => setTimeout(r, delay));
581
688
  }
582
689
  }
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * ClaudeProvider - Anthropic Claude AI 提供商
3
+ *
4
+ * v2: 支持原生 Function Calling(结构化工具调用)
5
+ * - 使用 Anthropic Messages API 的 tools + tool_choice 参数
6
+ * - 响应中的 tool_use content blocks → 结构化 functionCall
7
+ * - tool_result content blocks 用于回传工具执行结果
3
8
  */
4
9
 
5
10
  import { AiProvider } from '../AiProvider.js';
@@ -18,6 +23,13 @@ export class ClaudeProvider extends AiProvider {
18
23
  this.logger = Logger.getInstance();
19
24
  }
20
25
 
26
+ /**
27
+ * 是否支持原生结构化函数调用
28
+ */
29
+ get supportsNativeToolCalling() {
30
+ return true;
31
+ }
32
+
21
33
  async chat(prompt, context = {}) {
22
34
  const { history = [], temperature = 0.7, maxTokens = 4096 } = context;
23
35
  const messages = [];
@@ -39,6 +51,191 @@ export class ClaudeProvider extends AiProvider {
39
51
  return textBlock?.text || '';
40
52
  }
41
53
 
54
+ /**
55
+ * 带工具声明的结构化对话 — Anthropic Messages API Tool Use
56
+ *
57
+ * 接受统一消息格式,内部转换为 Anthropic Messages 格式。
58
+ *
59
+ * Anthropic 特殊之处:
60
+ * - system prompt 是顶层 `system` 字段(非 message)
61
+ * - assistant 消息的 content 是 content blocks 数组
62
+ * - 工具结果通过 user 消息中的 tool_result blocks 传递
63
+ * - tool_choice: {type: 'auto'|'any'|'tool'}(无 'none',不传 tools 即可)
64
+ *
65
+ * @param {string} prompt — fallback prompt
66
+ * @param {object} opts — 统一参数
67
+ * @returns {Promise<{text: string|null, functionCalls: Array<{id, name, args}>|null}>}
68
+ */
69
+ async chatWithTools(prompt, opts = {}) {
70
+ const {
71
+ messages: unifiedMessages,
72
+ toolSchemas,
73
+ toolChoice = 'auto',
74
+ systemPrompt,
75
+ temperature = 0.7,
76
+ maxTokens = 4096,
77
+ } = opts;
78
+
79
+ // 统一消息 → Anthropic Messages 格式
80
+ const srcMessages = unifiedMessages?.length > 0
81
+ ? unifiedMessages
82
+ : [{ role: 'user', content: prompt }];
83
+
84
+ const messages = this.#convertMessages(srcMessages);
85
+
86
+ const body = {
87
+ model: this.model,
88
+ messages,
89
+ max_tokens: maxTokens,
90
+ temperature,
91
+ };
92
+
93
+ // system prompt → 顶层字段
94
+ if (systemPrompt) {
95
+ body.system = systemPrompt;
96
+ }
97
+
98
+ // 工具声明 + tool_choice
99
+ // toolChoice='none' 时不传 tools(Anthropic 没有 'none' tool_choice)
100
+ if (toolChoice !== 'none' && toolSchemas?.length > 0) {
101
+ body.tools = toolSchemas.map(s => ({
102
+ name: s.name,
103
+ description: s.description || '',
104
+ input_schema: s.parameters || { type: 'object', properties: {} },
105
+ }));
106
+
107
+ if (toolChoice === 'required') {
108
+ body.tool_choice = { type: 'any' };
109
+ } else {
110
+ body.tool_choice = { type: 'auto' };
111
+ }
112
+ }
113
+
114
+ const data = await this._post(`${CLAUDE_BASE}/messages`, body);
115
+ return this.#parseToolResponse(data);
116
+ }
117
+
118
+ // ─── 内部转换方法 ──────────────────────
119
+
120
+ /**
121
+ * 统一消息格式 → Anthropic Messages 格式
122
+ *
123
+ * - user → {role: 'user', content: 'text'}
124
+ * - assistant → {role: 'assistant', content: [{type:'text'}, {type:'tool_use'}...]}
125
+ * - tool → grouped into {role: 'user', content: [{type:'tool_result'}...]}
126
+ *
127
+ * Anthropic 要求消息交替 user/assistant。连续 tool results 合并为一个 user 消息。
128
+ * 连续同角色消息(如 L2/L3 压缩后的摘要)自动合并以避免 400 错误。
129
+ */
130
+ #convertMessages(messages) {
131
+ const result = [];
132
+
133
+ /**
134
+ * 推入 result,如果上一个 entry 同角色则合并 content
135
+ */
136
+ const pushOrMerge = (entry) => {
137
+ const last = result[result.length - 1];
138
+ if (last && last.role === entry.role) {
139
+ // Anthropic content 可以是 string 或 array
140
+ const lastContent = Array.isArray(last.content)
141
+ ? last.content
142
+ : [{ type: 'text', text: last.content || '' }];
143
+ const newContent = Array.isArray(entry.content)
144
+ ? entry.content
145
+ : [{ type: 'text', text: entry.content || '' }];
146
+ last.content = [...lastContent, ...newContent];
147
+ } else {
148
+ result.push(entry);
149
+ }
150
+ };
151
+
152
+ let i = 0;
153
+
154
+ while (i < messages.length) {
155
+ const msg = messages[i];
156
+
157
+ if (msg.role === 'user') {
158
+ pushOrMerge({ role: 'user', content: msg.content || '' });
159
+ i++;
160
+ } else if (msg.role === 'assistant') {
161
+ const content = [];
162
+ if (msg.content) content.push({ type: 'text', text: msg.content });
163
+ if (msg.toolCalls?.length > 0) {
164
+ for (const tc of msg.toolCalls) {
165
+ content.push({
166
+ type: 'tool_use',
167
+ id: tc.id,
168
+ name: tc.name,
169
+ input: tc.args || {},
170
+ });
171
+ }
172
+ }
173
+ pushOrMerge({
174
+ role: 'assistant',
175
+ content: content.length > 0 ? content : [{ type: 'text', text: '' }],
176
+ });
177
+ i++;
178
+ } else if (msg.role === 'tool') {
179
+ // 收集连续 tool results → 合并为一个 user 消息
180
+ const toolResults = [];
181
+ while (i < messages.length && messages[i].role === 'tool') {
182
+ toolResults.push({
183
+ type: 'tool_result',
184
+ tool_use_id: messages[i].toolCallId,
185
+ content: messages[i].content || '',
186
+ });
187
+ i++;
188
+ }
189
+ pushOrMerge({ role: 'user', content: toolResults });
190
+ } else {
191
+ i++; // skip unknown roles
192
+ }
193
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ /**
199
+ * 解析 Anthropic Messages API 响应 — 提取 tool_use 或 text
200
+ *
201
+ * Anthropic 返回格式:
202
+ * content[]: { type: 'text', text } | { type: 'tool_use', id, name, input }
203
+ * stop_reason: 'end_turn' | 'tool_use' | 'max_tokens'
204
+ */
205
+ #parseToolResponse(data) {
206
+ if (!data?.content?.length) {
207
+ return { text: '', functionCalls: null };
208
+ }
209
+
210
+ const functionCalls = [];
211
+ const textParts = [];
212
+
213
+ for (const block of data.content) {
214
+ if (block.type === 'tool_use') {
215
+ functionCalls.push({
216
+ id: block.id,
217
+ name: block.name,
218
+ args: block.input || {},
219
+ });
220
+ } else if (block.type === 'text') {
221
+ textParts.push(block.text);
222
+ }
223
+ }
224
+
225
+ if (functionCalls.length > 0) {
226
+ this.logger.debug(`[Claude] native function calls: ${functionCalls.map(fc => fc.name).join(', ')}`);
227
+ return {
228
+ text: textParts.length > 0 ? textParts.join('\n') : null,
229
+ functionCalls,
230
+ };
231
+ }
232
+
233
+ return {
234
+ text: textParts.join('\n'),
235
+ functionCalls: null,
236
+ };
237
+ }
238
+
42
239
  async summarize(code) {
43
240
  const prompt = `请对以下代码生成结构化摘要,返回 JSON 格式 {title, description, language, patterns: [], keyAPIs: []}:\n\n${code}`;
44
241
  const text = await this.chat(prompt, { temperature: 0.3 });
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * GoogleGeminiProvider - Google Gemini AI 提供商
3
3
  * 直接调用 REST API(不依赖 SDK)
4
+ *
5
+ * v3: 统一消息格式 — chatWithTools() 接受 Provider-Agnostic 消息
6
+ * 内部自动转换为 Gemini 原生 contents / functionDeclarations 格式
7
+ * 支持 toolChoice: 'auto' | 'required' | 'none'
4
8
  */
5
9
 
6
10
  import { AiProvider } from '../AiProvider.js';
@@ -18,9 +22,16 @@ export class GoogleGeminiProvider extends AiProvider {
18
22
  this.logger = Logger.getInstance();
19
23
  }
20
24
 
25
+ /**
26
+ * 是否支持原生结构化函数调用
27
+ */
28
+ get supportsNativeToolCalling() {
29
+ return true;
30
+ }
31
+
21
32
  async chat(prompt, context = {}) {
22
33
  return this._withRetry(async () => {
23
- const { history = [], temperature = 0.7, maxTokens = 8192 } = context;
34
+ const { history = [], temperature = 0.7, maxTokens = 8192, systemPrompt } = context;
24
35
  const contents = [];
25
36
 
26
37
  for (const h of history) {
@@ -36,12 +47,235 @@ export class GoogleGeminiProvider extends AiProvider {
36
47
  },
37
48
  };
38
49
 
50
+ // systemInstruction 支持(chat 也可用 systemPrompt)
51
+ if (systemPrompt) {
52
+ body.systemInstruction = { parts: [{ text: systemPrompt }] };
53
+ }
54
+
39
55
  const url = `${GEMINI_BASE}/models/${this.model}:generateContent?key=${this.apiKey}`;
40
56
  const data = await this._post(url, body);
41
57
  return data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
42
58
  });
43
59
  }
44
60
 
61
+ /**
62
+ * 带工具声明的结构化对话 — Gemini 原生 Function Calling
63
+ *
64
+ * 接受统一消息格式,内部转换为 Gemini 原生 contents 格式。
65
+ *
66
+ * @param {string} prompt — 未使用 messages 时的 fallback prompt
67
+ * @param {object} opts
68
+ * @param {Array} opts.messages — 统一格式消息
69
+ * @param {Array} opts.toolSchemas — [{name, description, parameters}]
70
+ * @param {string} opts.toolChoice — 'auto' | 'required' | 'none'
71
+ * @param {string} [opts.systemPrompt]
72
+ * @param {number} [opts.temperature=0.7]
73
+ * @param {number} [opts.maxTokens=8192]
74
+ * @returns {Promise<{text: string|null, functionCalls: Array<{id, name, args}>|null}>}
75
+ */
76
+ async chatWithTools(prompt, opts = {}) {
77
+ return this._withRetry(async () => {
78
+ const {
79
+ messages,
80
+ toolSchemas,
81
+ toolChoice = 'auto',
82
+ systemPrompt,
83
+ temperature = 0.7,
84
+ maxTokens = 8192,
85
+ } = opts;
86
+
87
+ // 统一消息 → Gemini contents
88
+ const contents = messages?.length > 0
89
+ ? this.#convertMessages(messages)
90
+ : [{ role: 'user', parts: [{ text: prompt }] }];
91
+
92
+ const body = {
93
+ contents,
94
+ generationConfig: {
95
+ temperature,
96
+ maxOutputTokens: maxTokens,
97
+ },
98
+ };
99
+
100
+ // 工具声明: 标准 schema → Gemini functionDeclarations
101
+ if (toolSchemas?.length > 0) {
102
+ body.tools = [{
103
+ functionDeclarations: toolSchemas.map(s => this.#toFunctionDeclaration(s)),
104
+ }];
105
+ }
106
+
107
+ // toolChoice → Gemini mode
108
+ body.toolConfig = {
109
+ functionCallingConfig: { mode: this.#toGeminiMode(toolChoice) },
110
+ };
111
+
112
+ // 系统指令
113
+ if (systemPrompt) {
114
+ body.systemInstruction = { parts: [{ text: systemPrompt }] };
115
+ }
116
+
117
+ const url = `${GEMINI_BASE}/models/${this.model}:generateContent?key=${this.apiKey}`;
118
+ const data = await this._post(url, body);
119
+
120
+ return this.#parseToolResponse(data);
121
+ });
122
+ }
123
+
124
+ // ─── 内部转换方法 ──────────────────────
125
+
126
+ /**
127
+ * 统一消息格式 → Gemini contents
128
+ * - user → {role: 'user', parts: [{text}]}
129
+ * - assistant → {role: 'model', parts: [{text}, {functionCall}...]}
130
+ * - tool → grouped into {role: 'user', parts: [{functionResponse}...]}
131
+ *
132
+ * Gemini 要求严格交替 user/model 角色。
133
+ * 连续同角色消息(如 L2/L3 压缩后的摘要)自动合并 parts 以避免 400 错误。
134
+ */
135
+ #convertMessages(messages) {
136
+ const contents = [];
137
+ let pendingToolResults = [];
138
+
139
+ /**
140
+ * 推入 contents,如果上一个 entry 同角色则合并 parts
141
+ */
142
+ const pushOrMerge = (entry) => {
143
+ const last = contents[contents.length - 1];
144
+ if (last && last.role === entry.role) {
145
+ last.parts.push(...entry.parts);
146
+ } else {
147
+ contents.push(entry);
148
+ }
149
+ };
150
+
151
+ for (const msg of messages) {
152
+ if (msg.role === 'tool') {
153
+ // 收集连续 tool results → 将在下一个非 tool 消息前或末尾 flush
154
+ pendingToolResults.push({
155
+ functionResponse: {
156
+ name: msg.name,
157
+ response: { result: msg.content || '' },
158
+ },
159
+ });
160
+ continue;
161
+ }
162
+
163
+ // Flush pending tool results before non-tool message
164
+ if (pendingToolResults.length > 0) {
165
+ pushOrMerge({ role: 'user', parts: pendingToolResults });
166
+ pendingToolResults = [];
167
+ }
168
+
169
+ if (msg.role === 'user') {
170
+ pushOrMerge({ role: 'user', parts: [{ text: msg.content || '' }] });
171
+ } else if (msg.role === 'assistant') {
172
+ const parts = [];
173
+ if (msg.content) parts.push({ text: msg.content });
174
+ if (msg.toolCalls?.length > 0) {
175
+ for (const tc of msg.toolCalls) {
176
+ parts.push({ functionCall: { name: tc.name, args: tc.args || {} } });
177
+ }
178
+ }
179
+ if (parts.length > 0) pushOrMerge({ role: 'model', parts });
180
+ }
181
+ }
182
+
183
+ // Flush remaining tool results
184
+ if (pendingToolResults.length > 0) {
185
+ pushOrMerge({ role: 'user', parts: pendingToolResults });
186
+ }
187
+
188
+ return contents;
189
+ }
190
+
191
+ /**
192
+ * toolChoice → Gemini mode
193
+ */
194
+ #toGeminiMode(toolChoice) {
195
+ switch (toolChoice) {
196
+ case 'required': return 'ANY';
197
+ case 'none': return 'NONE';
198
+ default: return 'AUTO';
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 标准 tool schema → Gemini functionDeclaration
204
+ */
205
+ #toFunctionDeclaration(schema) {
206
+ return {
207
+ name: schema.name,
208
+ description: schema.description || '',
209
+ parameters: this.#sanitizeSchemaForGemini(schema.parameters),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * 清理 JSON Schema 使之兼容 Gemini API 的 OpenAPI 子集
215
+ */
216
+ #sanitizeSchemaForGemini(schema) {
217
+ if (!schema || typeof schema !== 'object') {
218
+ return { type: 'object', properties: {} };
219
+ }
220
+
221
+ const cleaned = { ...schema };
222
+ if (!cleaned.type) cleaned.type = 'object';
223
+
224
+ if (cleaned.properties) {
225
+ const props = {};
226
+ for (const [key, val] of Object.entries(cleaned.properties)) {
227
+ const prop = { ...val };
228
+ delete prop.default;
229
+ delete prop.examples;
230
+ if (!prop.type) prop.type = 'string';
231
+ props[key] = prop;
232
+ }
233
+ cleaned.properties = props;
234
+ }
235
+
236
+ return cleaned;
237
+ }
238
+
239
+ /**
240
+ * 解析 Gemini API 响应 — 提取 functionCall 或 text
241
+ * 返回统一格式(含生成的 id)
242
+ */
243
+ #parseToolResponse(data) {
244
+ const content = data?.candidates?.[0]?.content;
245
+ if (!content || !content.parts || content.parts.length === 0) {
246
+ return { text: '', functionCalls: null };
247
+ }
248
+
249
+ const functionCalls = [];
250
+ const textParts = [];
251
+ let fcIndex = 0;
252
+
253
+ for (const part of content.parts) {
254
+ if (part.functionCall) {
255
+ functionCalls.push({
256
+ id: `gemini_fc_${Date.now()}_${fcIndex++}`,
257
+ name: part.functionCall.name,
258
+ args: part.functionCall.args || {},
259
+ });
260
+ } else if (part.text) {
261
+ textParts.push(part.text);
262
+ }
263
+ }
264
+
265
+ if (functionCalls.length > 0) {
266
+ this.logger.debug(`[GeminiProvider] native function calls: ${functionCalls.map(fc => fc.name).join(', ')}`);
267
+ return {
268
+ text: textParts.length > 0 ? textParts.join('\n') : null,
269
+ functionCalls,
270
+ };
271
+ }
272
+
273
+ return {
274
+ text: textParts.join('\n'),
275
+ functionCalls: null,
276
+ };
277
+ }
278
+
45
279
  async summarize(code) {
46
280
  const prompt = `请对以下代码生成结构化摘要,返回 JSON 格式 {title, description, language, patterns: [], keyAPIs: []}:\n\n${code}`;
47
281
  const text = await this.chat(prompt, { temperature: 0.3 });