devassist-agent 1.0.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.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- package/tests/test-new-rules.js +157 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Adapter - unified interface for multiple LLM providers.
|
|
3
|
+
*
|
|
4
|
+
* Core principle: "Rules engine first, AI second."
|
|
5
|
+
* The AI adapter is only called when the rule engine flags a "suspect" finding
|
|
6
|
+
* that needs deeper analysis. This saves tokens and keeps latency low.
|
|
7
|
+
*
|
|
8
|
+
* Supported providers:
|
|
9
|
+
* - qwen (通义千问, Alibaba)
|
|
10
|
+
* - openai (GPT, OpenAI)
|
|
11
|
+
* - claude (Claude, Anthropic)
|
|
12
|
+
* - hunyuan (混元, Tencent)
|
|
13
|
+
*
|
|
14
|
+
* Degradation strategy: if AI is unreachable, return rule engine results as-is
|
|
15
|
+
* with a flag "ai_analysis_unavailable: true".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { Logger } = require('../core/logger');
|
|
19
|
+
const log = new Logger('AIAdapter');
|
|
20
|
+
|
|
21
|
+
// Provider registry
|
|
22
|
+
const providers = {};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register a provider implementation.
|
|
26
|
+
* Each provider must implement:
|
|
27
|
+
* async chat(messages, options) -> { content, usage, model }
|
|
28
|
+
*/
|
|
29
|
+
function registerProvider(name, impl) {
|
|
30
|
+
providers[name] = impl;
|
|
31
|
+
log.info(`已注册 AI provider:${name}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get a provider by name.
|
|
36
|
+
*/
|
|
37
|
+
function getProvider(name) {
|
|
38
|
+
return providers[name];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List registered providers.
|
|
43
|
+
*/
|
|
44
|
+
function listProviders() {
|
|
45
|
+
return Object.keys(providers);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Unified AI analysis interface.
|
|
50
|
+
* Takes findings from the rule engine and asks the AI for deeper analysis.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} config - { provider, apiKey, model, baseUrl }
|
|
53
|
+
* @param {Array} findings - rule engine findings to analyze
|
|
54
|
+
* @param {object} context - { files, conventions, projectInfo }
|
|
55
|
+
* @returns {Promise<Array>} - findings enriched with AI analysis
|
|
56
|
+
*/
|
|
57
|
+
async function analyze(config, findings, context) {
|
|
58
|
+
if (!config || !config.provider || !providers[config.provider]) {
|
|
59
|
+
log.warn('未配置 AI provider——仅返回规则引擎结果');
|
|
60
|
+
return findings.map(f => ({ ...f, aiAnalysis: null, aiAnalysisUnavailable: true }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const provider = providers[config.provider];
|
|
64
|
+
|
|
65
|
+
// Only send findings that are 'warn' or 'block' to AI (save tokens)
|
|
66
|
+
const suspectFindings = findings.filter(f => f.severity === 'block' || f.severity === 'warn');
|
|
67
|
+
|
|
68
|
+
if (suspectFindings.length === 0) {
|
|
69
|
+
log.info('无疑似问题需要发送给 AI');
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Batch processing: split into chunks to avoid timeout on large finding sets
|
|
74
|
+
const BATCH_SIZE = 20;
|
|
75
|
+
const batches = [];
|
|
76
|
+
for (let i = 0; i < suspectFindings.length; i += BATCH_SIZE) {
|
|
77
|
+
batches.push(suspectFindings.slice(i, i + BATCH_SIZE));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.info(`发送 ${suspectFindings.length} 个发现给 ${config.provider}(${config.model || '默认模型'}),分 ${batches.length} 批`);
|
|
81
|
+
|
|
82
|
+
let allAIAnalysis = [];
|
|
83
|
+
let lastModel = config.model || 'unknown';
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < batches.length; i++) {
|
|
86
|
+
const { systemPrompt, userPrompt } = buildPrompt(batches[i], context);
|
|
87
|
+
const messages = [
|
|
88
|
+
{ role: 'system', content: systemPrompt },
|
|
89
|
+
{ role: 'user', content: userPrompt },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = await provider.chat(messages, {
|
|
94
|
+
apiKey: config.apiKey,
|
|
95
|
+
model: config.model,
|
|
96
|
+
baseUrl: config.baseUrl,
|
|
97
|
+
temperature: 0.1,
|
|
98
|
+
maxTokens: 4000,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const batchAnalysis = parseAIResponse(result.content);
|
|
102
|
+
allAIAnalysis = allAIAnalysis.concat(batchAnalysis);
|
|
103
|
+
lastModel = result.model;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log.error(`AI 分析失败(批次 ${i + 1}/${batches.length}):${err.message}`);
|
|
106
|
+
// Continue with remaining batches if one fails
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (allAIAnalysis.length === 0) {
|
|
111
|
+
// All batches failed
|
|
112
|
+
return findings.map(f => ({
|
|
113
|
+
...f,
|
|
114
|
+
aiAnalysis: null,
|
|
115
|
+
aiAnalysisUnavailable: true,
|
|
116
|
+
aiError: '所有批次分析均失败',
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Merge AI analysis back into findings
|
|
121
|
+
// Use a multi-strategy matching approach for better accuracy
|
|
122
|
+
return findings.map(f => {
|
|
123
|
+
// Strategy 1: Match by ruleId + file path
|
|
124
|
+
let aiMatch = allAIAnalysis.find(a =>
|
|
125
|
+
a.ruleId === f.ruleId && a.file && f.file &&
|
|
126
|
+
f.file.includes(a.file) || (a.file && a.file.includes(f.file.split(/[\\\/]/).pop()))
|
|
127
|
+
);
|
|
128
|
+
// Strategy 2: Match by ruleId only (if unique in this batch)
|
|
129
|
+
if (!aiMatch) {
|
|
130
|
+
const sameRule = allAIAnalysis.filter(a => a.ruleId === f.ruleId);
|
|
131
|
+
if (sameRule.length === 1) {
|
|
132
|
+
aiMatch = sameRule[0];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Strategy 3: Match by ruleId + partial message
|
|
136
|
+
if (!aiMatch && f.ruleId) {
|
|
137
|
+
aiMatch = allAIAnalysis.find(a =>
|
|
138
|
+
a.ruleId === f.ruleId && a.rootCause &&
|
|
139
|
+
(a.rootCause.includes(f.file?.split(/[\\\/]/).pop() || '') ||
|
|
140
|
+
a.rootCause.includes(String(f.line || '')))
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...f,
|
|
145
|
+
aiAnalysis: aiMatch || null,
|
|
146
|
+
aiModel: lastModel,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build the system and user prompts for AI analysis.
|
|
153
|
+
*/
|
|
154
|
+
function buildPrompt(findings, context) {
|
|
155
|
+
const systemPrompt = `你是一个集成在开发护栏工具(DevAssist)中的代码分析专家。
|
|
156
|
+
你的职责是分析静态规则引擎发现的代码问题,给出精准的根因分析和修复建议。
|
|
157
|
+
|
|
158
|
+
## 输出格式(严格)
|
|
159
|
+
|
|
160
|
+
以 JSON 数组格式回复,每个发现对应一个对象。不要输出 JSON 以外的文字:
|
|
161
|
+
[
|
|
162
|
+
{
|
|
163
|
+
"ruleId": "<必须与输入中的 ruleId 完全一致>",
|
|
164
|
+
"rootCause": "<2-4句根因分析,必须具体到当前代码,不能是通用模板>",
|
|
165
|
+
"isFalsePositive": false,
|
|
166
|
+
"falsePositiveReason": "<如果是误报,说明原因;如果不是误报,留空>",
|
|
167
|
+
"fix": "<具体修复步骤,含函数名/变量名>",
|
|
168
|
+
"codeExample": "<修复后的代码片段,可选>",
|
|
169
|
+
"confidence": 0.85,
|
|
170
|
+
"category": "<bug|security|performance|convention|maintainability>"
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
## 根因分析规则
|
|
175
|
+
|
|
176
|
+
分析必须针对当前代码的具体上下文,禁止通用模板回复。按以下框架分析:
|
|
177
|
+
|
|
178
|
+
1. **是什么**:代码做了什么导致触发规则?引用具体的代码行/变量名
|
|
179
|
+
2. **为什么错**:这个问题会导致什么后果?(崩溃/安全漏洞/性能退化/维护困难)
|
|
180
|
+
3. **AI Agent 视角**:如果这是 AI Agent(Cursor/Trae/WorkBuddy)生成的代码,最可能的原因是什么?
|
|
181
|
+
- AI 常见错误模式:局部编辑不看全局、约定漂移、过度工程化、幻觉 API、忽略已有代码直接覆盖
|
|
182
|
+
4. **修复方向**:最小改动原则——不引入新的复杂度
|
|
183
|
+
|
|
184
|
+
## 误报判定标准
|
|
185
|
+
|
|
186
|
+
以下情况判定为误报(isFalsePositive: true):
|
|
187
|
+
- 规则引擎自身的测试代码(test/ 目录、测试 fixture 中的故意错误)
|
|
188
|
+
- 代码中的字符串/注释/正则表达式被误匹配(不是真正的代码执行)
|
|
189
|
+
- 已有保护措施但规则引擎未检测到(如上层有 try/catch、已有空值检查)
|
|
190
|
+
- 代码库基础设施代码(如 Logger、配置加载器)中的合理模式
|
|
191
|
+
|
|
192
|
+
以下情况**不是**误报:
|
|
193
|
+
- 真实的变量/函数名冲突
|
|
194
|
+
- 真实的缺失保护(即使当前不会触发,但属于防御性编程缺失)
|
|
195
|
+
- 真实的约定违规(项目中确实存在多种不一致的模式)
|
|
196
|
+
|
|
197
|
+
## 项目背景
|
|
198
|
+
|
|
199
|
+
- 项目使用 AI Agent(Cursor/Trae/WorkBuddy)辅助开发
|
|
200
|
+
- 常见问题模式:AI 覆盖大文件丢失既有逻辑、约定漂移(命名/格式不统一)、局部优化破坏全局一致性
|
|
201
|
+
- 分析要具体、有针对性,直接引用代码中的变量名和函数名`;
|
|
202
|
+
|
|
203
|
+
let userPrompt = `请分析以下 ${findings.length} 个发现:\n\n`;
|
|
204
|
+
|
|
205
|
+
// Add convention context if available
|
|
206
|
+
if (context && context.conventions && context.conventions.length > 0) {
|
|
207
|
+
userPrompt += `## 项目约定\n`;
|
|
208
|
+
for (const c of context.conventions.slice(0, 10)) {
|
|
209
|
+
userPrompt += `- ${c.id || c.rule}: ${c.rule || c.description || ''}\n`;
|
|
210
|
+
}
|
|
211
|
+
userPrompt += '\n';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Add project info if available
|
|
215
|
+
if (context && context.projectInfo && context.projectInfo.root) {
|
|
216
|
+
userPrompt += `## 项目信息\n- 项目根目录: ${context.projectInfo.root}\n\n`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add findings (with code context for deeper analysis)
|
|
220
|
+
findings.forEach((f, i) => {
|
|
221
|
+
userPrompt += `### 发现 ${i + 1}\n`;
|
|
222
|
+
userPrompt += `- 规则ID: ${f.ruleId || '(无)'}\n`;
|
|
223
|
+
userPrompt += `- 严重级别: ${f.severity}\n`;
|
|
224
|
+
userPrompt += `- 类别: ${f.category || '(未分类)'}\n`;
|
|
225
|
+
userPrompt += `- 文件: ${f.file}\n`;
|
|
226
|
+
if (f.line) userPrompt += `- 行号: ${f.line}\n`;
|
|
227
|
+
userPrompt += `- 问题描述: ${f.message}\n`;
|
|
228
|
+
if (f.suggestion) userPrompt += `- 规则建议: ${f.suggestion}\n`;
|
|
229
|
+
if (f.context) userPrompt += `- 规则上下文: ${JSON.stringify(f.context)}\n`;
|
|
230
|
+
// Include actual code context so AI can see the problem
|
|
231
|
+
if (f.codeContext) {
|
|
232
|
+
userPrompt += `\n代码上下文:\n\`\`\`\n${f.codeContext}\n\`\`\`\n`;
|
|
233
|
+
}
|
|
234
|
+
userPrompt += '\n';
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
userPrompt += `请严格按照 JSON 数组格式回复,确保每个发现的 ruleId 与上述输入一致。`;
|
|
238
|
+
|
|
239
|
+
return { systemPrompt, userPrompt };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Parse AI response into structured analysis objects.
|
|
244
|
+
* Handles common LLM response formats: JSON arrays, markdown-wrapped JSON,
|
|
245
|
+
* and partial/truncated JSON.
|
|
246
|
+
*/
|
|
247
|
+
function parseAIResponse(content) {
|
|
248
|
+
// Strategy 1: Direct JSON parse
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(content);
|
|
251
|
+
if (Array.isArray(parsed)) return parsed;
|
|
252
|
+
if (parsed && typeof parsed === 'object') return [parsed];
|
|
253
|
+
} catch (_) {}
|
|
254
|
+
|
|
255
|
+
// Strategy 2: Extract JSON array from markdown code blocks or mixed text
|
|
256
|
+
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
|
257
|
+
if (jsonMatch) {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(jsonMatch[0]);
|
|
260
|
+
} catch (_) {
|
|
261
|
+
// Strategy 3: Try to fix truncated JSON array
|
|
262
|
+
// Find all complete objects in the array
|
|
263
|
+
const objects = [];
|
|
264
|
+
const objRegex = /\{[^{}]*\}/g;
|
|
265
|
+
let objMatch;
|
|
266
|
+
while ((objMatch = objRegex.exec(jsonMatch[0])) !== null) {
|
|
267
|
+
try {
|
|
268
|
+
objects.push(JSON.parse(objMatch[0]));
|
|
269
|
+
} catch (_) {}
|
|
270
|
+
}
|
|
271
|
+
if (objects.length > 0) return objects;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Strategy 4: Extract individual JSON objects from text
|
|
276
|
+
const objRegex2 = /\{[^{}]*\}/g;
|
|
277
|
+
const objects2 = [];
|
|
278
|
+
let objMatch2;
|
|
279
|
+
while ((objMatch2 = objRegex2.exec(content)) !== null) {
|
|
280
|
+
try {
|
|
281
|
+
const obj = JSON.parse(objMatch2[0]);
|
|
282
|
+
if (obj.ruleId || obj.rootCause || obj.fix) {
|
|
283
|
+
objects2.push(obj);
|
|
284
|
+
}
|
|
285
|
+
} catch (_) {}
|
|
286
|
+
}
|
|
287
|
+
if (objects2.length > 0) return objects2;
|
|
288
|
+
|
|
289
|
+
// Strategy 5: Fallback - treat as plain text analysis
|
|
290
|
+
return [{
|
|
291
|
+
ruleId: '*',
|
|
292
|
+
rootCause: content.substring(0, 500),
|
|
293
|
+
isFalsePositive: false,
|
|
294
|
+
fix: '参见上方根因分析',
|
|
295
|
+
confidence: 0.3,
|
|
296
|
+
}];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Multi-engine analysis.
|
|
301
|
+
* Runs N providers in parallel and returns findings enriched with all analyses.
|
|
302
|
+
*
|
|
303
|
+
* Each engine config: { provider, apiKey, model, baseUrl, name?, id? }
|
|
304
|
+
*
|
|
305
|
+
* Finding enrichment:
|
|
306
|
+
* f.aiAnalyses = [
|
|
307
|
+
* { engineId, engineName, provider, model, analysis, available, error },
|
|
308
|
+
* ...
|
|
309
|
+
* ]
|
|
310
|
+
*
|
|
311
|
+
* For backward compatibility with 2-engine code:
|
|
312
|
+
* f.aiAnalysis = aiAnalyses[0]?.analysis
|
|
313
|
+
* f.aiAnalysis2 = aiAnalyses[1]?.analysis
|
|
314
|
+
* f.aiAnalysisUnavailable = !aiAnalyses[0]?.available
|
|
315
|
+
* f.aiAnalysis2Unavailable = !aiAnalyses[1]?.available
|
|
316
|
+
*
|
|
317
|
+
* @param {Array} engineConfigs - array of { provider, apiKey, model, baseUrl, name?, id? }
|
|
318
|
+
* @param {Array} findings - rule engine findings to analyze
|
|
319
|
+
* @param {object} context - { files, conventions, projectInfo }
|
|
320
|
+
* @returns {Promise<Array>} - findings enriched with aiAnalyses array
|
|
321
|
+
*/
|
|
322
|
+
async function multiAnalyze(engineConfigs, findings, context) {
|
|
323
|
+
if (!engineConfigs || engineConfigs.length === 0) {
|
|
324
|
+
log.warn('未配置任何 AI 引擎');
|
|
325
|
+
return findings.map(f => ({ ...f, aiAnalyses: [], aiAnalysis: null, aiAnalysisUnavailable: true }));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Deduplicate by provider+model (avoid running same engine twice)
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
const engines = engineConfigs.filter(cfg => {
|
|
331
|
+
if (!cfg || !cfg.provider || !providers[cfg.provider]) {
|
|
332
|
+
log.warn(`跳过未注册的 provider: ${cfg?.provider}`);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
const key = `${cfg.provider}:${cfg.model || ''}`;
|
|
336
|
+
if (seen.has(key)) return false;
|
|
337
|
+
seen.add(key);
|
|
338
|
+
return true;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (engines.length === 0) {
|
|
342
|
+
log.warn('无有效的 AI 引擎');
|
|
343
|
+
return findings.map(f => ({ ...f, aiAnalyses: [], aiAnalysis: null, aiAnalysisUnavailable: true }));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
log.info(`多引擎分析:${engines.length} 个引擎并行(${engines.map(e => e.provider).join(', ')})`);
|
|
347
|
+
|
|
348
|
+
// Run all engines in parallel
|
|
349
|
+
const results = await Promise.allSettled(
|
|
350
|
+
engines.map(cfg => analyze(cfg, findings, context))
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Build per-engine result maps: key -> analysis
|
|
354
|
+
const engineMaps = results.map((result, idx) => {
|
|
355
|
+
const engine = engines[idx];
|
|
356
|
+
const engineId = engine.id || engine.provider;
|
|
357
|
+
const engineName = engine.name || engine.provider;
|
|
358
|
+
const map = new Map();
|
|
359
|
+
|
|
360
|
+
if (result.status === 'fulfilled') {
|
|
361
|
+
for (const f of result.value) {
|
|
362
|
+
const key = `${f.file}:${f.line}:${f.ruleId}`;
|
|
363
|
+
map.set(key, {
|
|
364
|
+
analysis: f.aiAnalysis || null,
|
|
365
|
+
available: !f.aiAnalysisUnavailable,
|
|
366
|
+
error: f.aiError || null,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
log.error(`引擎 [${engine.provider}] 分析失败:${result.reason?.message}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { engineId, engineName, provider: engine.provider, model: engine.model, map, failed: result.status !== 'fulfilled', error: result.reason?.message };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Merge all engine results into findings
|
|
377
|
+
return findings.map(f => {
|
|
378
|
+
const key = `${f.file}:${f.line}:${f.ruleId}`;
|
|
379
|
+
const aiAnalyses = engineMaps.map(em => {
|
|
380
|
+
const entry = em.map.get(key);
|
|
381
|
+
return {
|
|
382
|
+
engineId: em.engineId,
|
|
383
|
+
engineName: em.engineName,
|
|
384
|
+
provider: em.provider,
|
|
385
|
+
model: em.model,
|
|
386
|
+
analysis: entry?.analysis || null,
|
|
387
|
+
available: entry ? entry.available : !em.failed,
|
|
388
|
+
error: entry?.error || (em.failed ? em.error : null),
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Backward compatibility: populate legacy fields
|
|
393
|
+
const primary = aiAnalyses[0];
|
|
394
|
+
const secondary = aiAnalyses[1];
|
|
395
|
+
const enriched = {
|
|
396
|
+
...f,
|
|
397
|
+
aiAnalyses,
|
|
398
|
+
aiAnalysis: primary?.analysis || null,
|
|
399
|
+
aiAnalysisUnavailable: primary ? !primary.available : true,
|
|
400
|
+
aiError: primary?.error || null,
|
|
401
|
+
};
|
|
402
|
+
if (secondary) {
|
|
403
|
+
enriched.aiAnalysis2 = secondary.analysis || null;
|
|
404
|
+
enriched.aiAnalysis2Unavailable = !secondary.available;
|
|
405
|
+
enriched.aiError2 = secondary.error || null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return enriched;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Dual-engine comparison analysis (backward compatibility wrapper).
|
|
414
|
+
* Delegates to multiAnalyze with 2 engine configs.
|
|
415
|
+
*/
|
|
416
|
+
async function compareAnalyze(primaryConfig, secondaryConfig, findings, context) {
|
|
417
|
+
return multiAnalyze([primaryConfig, secondaryConfig], findings, context);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Ask a free-form question to the AI.
|
|
422
|
+
* Used for the "smart Q&A" feature.
|
|
423
|
+
*/
|
|
424
|
+
async function ask(config, question, context) {
|
|
425
|
+
if (!config || !config.provider || !providers[config.provider]) {
|
|
426
|
+
return { content: 'AI provider 尚未配置。请在 .devassist/ai-config.json 中设置。', unavailable: true };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const provider = providers[config.provider];
|
|
430
|
+
const systemPrompt = `你是一个项目开发助手,服务于使用 AI Agent 进行开发的项目。
|
|
431
|
+
回答关于代码、架构和最佳实践的问题。
|
|
432
|
+
回答要简洁、针对当前项目。${context?.conventions ? '\n\n项目约定:\n' + context.conventions : ''}`;
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const result = await provider.chat(
|
|
436
|
+
[
|
|
437
|
+
{ role: 'system', content: systemPrompt },
|
|
438
|
+
{ role: 'user', content: question },
|
|
439
|
+
],
|
|
440
|
+
{
|
|
441
|
+
apiKey: config.apiKey,
|
|
442
|
+
model: config.model,
|
|
443
|
+
baseUrl: config.baseUrl,
|
|
444
|
+
temperature: 0.3,
|
|
445
|
+
maxTokens: 1500,
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
return { content: result.content, model: result.model };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
log.error(`AI 问答失败:${err.message}`);
|
|
451
|
+
return { content: `AI 请求失败:${err.message}`, error: true };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = {
|
|
456
|
+
registerProvider,
|
|
457
|
+
getProvider,
|
|
458
|
+
listProviders,
|
|
459
|
+
analyze,
|
|
460
|
+
multiAnalyze,
|
|
461
|
+
compareAnalyze,
|
|
462
|
+
ask,
|
|
463
|
+
buildPrompt,
|
|
464
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude provider (Anthropic)
|
|
3
|
+
*
|
|
4
|
+
* API: https://docs.anthropic.com/en/api/messages
|
|
5
|
+
* Uses the Anthropic Messages API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { URL } = require('url');
|
|
11
|
+
|
|
12
|
+
async function chat(messages, options) {
|
|
13
|
+
const baseUrl = options.baseUrl || 'https://api.anthropic.com';
|
|
14
|
+
const model = options.model || 'claude-sonnet-4-20250514';
|
|
15
|
+
const apiKey = options.apiKey;
|
|
16
|
+
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new Error('Anthropic API key required. Set ANTHROPIC_API_KEY or configure in ai-config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const url = new URL(`${baseUrl}/v1/messages`);
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
model,
|
|
24
|
+
messages: messages.map(m => ({
|
|
25
|
+
role: m.role === 'system' ? 'user' : m.role,
|
|
26
|
+
content: m.content,
|
|
27
|
+
})),
|
|
28
|
+
// Claude uses system as a top-level param
|
|
29
|
+
system: messages.find(m => m.role === 'system')?.content,
|
|
30
|
+
max_tokens: options.maxTokens || 2000,
|
|
31
|
+
temperature: options.temperature ?? 0.1,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const response = await request(url, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'x-api-key': apiKey,
|
|
39
|
+
'anthropic-version': '2023-06-01',
|
|
40
|
+
},
|
|
41
|
+
body,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const data = JSON.parse(response.body);
|
|
45
|
+
return {
|
|
46
|
+
content: data.content?.[0]?.text || '',
|
|
47
|
+
usage: data.usage || {},
|
|
48
|
+
model: data.model || model,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function request(url, options) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
55
|
+
const req = lib.request(url, {
|
|
56
|
+
method: options.method || 'GET',
|
|
57
|
+
headers: options.headers || {},
|
|
58
|
+
}, (res) => {
|
|
59
|
+
let body = '';
|
|
60
|
+
res.on('data', (chunk) => body += chunk);
|
|
61
|
+
res.on('end', () => {
|
|
62
|
+
if (res.statusCode >= 400) {
|
|
63
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.substring(0, 500)}`));
|
|
64
|
+
} else {
|
|
65
|
+
resolve({ statusCode: res.statusCode, body });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
req.setTimeout(30000, () => {
|
|
72
|
+
req.destroy(new Error('Request timeout (30s)'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (options.body) req.write(options.body);
|
|
76
|
+
req.end();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { chat, name: 'claude' };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 腾讯混元 provider (Tencent Hunyuan via TokenHub)
|
|
3
|
+
*
|
|
4
|
+
* 混元已迁移至 TokenHub 平台,提供 OpenAI 兼容接口。
|
|
5
|
+
* 文档: https://cloud.tencent.com/document/product/266/115968
|
|
6
|
+
*
|
|
7
|
+
* 配置:
|
|
8
|
+
* baseUrl: https://tokenhub.tencentmaas.com/v1 (TokenHub 境内)
|
|
9
|
+
* model: hy3-preview | hunyuan-2.0-instruct-20251111 | hunyuan-role-latest 等
|
|
10
|
+
* apiKey: TokenHub API Key (sk-xxx,在 TokenHub 控制台创建)
|
|
11
|
+
*
|
|
12
|
+
* 注意:原混元 endpoint https://api.hunyuan.cloud.tencent.com/v1 已逐步停用,
|
|
13
|
+
* 新创建的 Key(ak-前缀)只能在 TokenHub endpoint 使用。
|
|
14
|
+
*
|
|
15
|
+
* 环境变量: HUNYUAN_API_KEY
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const https = require('https');
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const { URL } = require('url');
|
|
21
|
+
|
|
22
|
+
async function chat(messages, options) {
|
|
23
|
+
const baseUrl = options.baseUrl || 'https://tokenhub.tencentmaas.com/v1';
|
|
24
|
+
const model = options.model || 'hy3-preview';
|
|
25
|
+
const apiKey = options.apiKey;
|
|
26
|
+
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
throw new Error('混元 API密钥未配置。请设置 HUNYUAN_API_KEY 环境变量,或在 ai-config.json 中填写 apiKey');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = new URL(`${baseUrl}/chat/completions`);
|
|
32
|
+
const body = JSON.stringify({
|
|
33
|
+
model,
|
|
34
|
+
messages,
|
|
35
|
+
temperature: options.temperature ?? 0.1,
|
|
36
|
+
max_tokens: options.maxTokens || 2000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const response = await request(url, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
44
|
+
},
|
|
45
|
+
body,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const data = JSON.parse(response.body);
|
|
49
|
+
return {
|
|
50
|
+
content: data.choices?.[0]?.message?.content || '',
|
|
51
|
+
usage: data.usage || {},
|
|
52
|
+
model: data.model || model,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 通用 HTTP 请求(零外部依赖,使用内置 http/https 模块)
|
|
58
|
+
*/
|
|
59
|
+
function request(url, options) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
62
|
+
const req = lib.request(url, {
|
|
63
|
+
method: options.method || 'GET',
|
|
64
|
+
headers: options.headers || {},
|
|
65
|
+
}, (res) => {
|
|
66
|
+
let body = '';
|
|
67
|
+
res.on('data', (chunk) => body += chunk);
|
|
68
|
+
res.on('end', () => {
|
|
69
|
+
if (res.statusCode >= 400) {
|
|
70
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.substring(0, 500)}`));
|
|
71
|
+
} else {
|
|
72
|
+
resolve({ statusCode: res.statusCode, body });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
req.setTimeout(90000, () => {
|
|
79
|
+
req.destroy(new Error('请求超时 (90s)'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (options.body) req.write(options.body);
|
|
83
|
+
req.end();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { chat, name: 'hunyuan' };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI provider (GPT-4, GPT-3.5, etc.)
|
|
3
|
+
*
|
|
4
|
+
* API: https://platform.openai.com/docs/api-reference/chat
|
|
5
|
+
* Uses the standard OpenAI chat completions API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { URL } = require('url');
|
|
11
|
+
|
|
12
|
+
async function chat(messages, options) {
|
|
13
|
+
const baseUrl = options.baseUrl || 'https://api.openai.com';
|
|
14
|
+
const model = options.model || 'gpt-4o-mini';
|
|
15
|
+
const apiKey = options.apiKey;
|
|
16
|
+
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new Error('OpenAI API key required. Set OPENAI_API_KEY or configure in ai-config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const url = new URL(`${baseUrl}/v1/chat/completions`);
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
model,
|
|
24
|
+
messages,
|
|
25
|
+
temperature: options.temperature ?? 0.1,
|
|
26
|
+
max_tokens: options.maxTokens || 2000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const response = await request(url, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
body,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const data = JSON.parse(response.body);
|
|
39
|
+
return {
|
|
40
|
+
content: data.choices?.[0]?.message?.content || '',
|
|
41
|
+
usage: data.usage || {},
|
|
42
|
+
model: data.model || model,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function request(url, options) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
49
|
+
const req = lib.request(url, {
|
|
50
|
+
method: options.method || 'GET',
|
|
51
|
+
headers: options.headers || {},
|
|
52
|
+
}, (res) => {
|
|
53
|
+
let body = '';
|
|
54
|
+
res.on('data', (chunk) => body += chunk);
|
|
55
|
+
res.on('end', () => {
|
|
56
|
+
if (res.statusCode >= 400) {
|
|
57
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.substring(0, 500)}`));
|
|
58
|
+
} else {
|
|
59
|
+
resolve({ statusCode: res.statusCode, body });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
req.setTimeout(30000, () => {
|
|
66
|
+
req.destroy(new Error('Request timeout (30s)'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (options.body) req.write(options.body);
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { chat, name: 'openai' };
|