aws-runtime-bridge 1.9.22 → 1.9.31
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/dist/adapter/AcodeSdkAdapter.d.ts +3 -0
- package/dist/adapter/AcodeSdkAdapter.d.ts.map +1 -1
- package/dist/adapter/AcodeSdkAdapter.js +9 -0
- package/dist/adapter/OpencodeSdkAdapter.d.ts.map +1 -1
- package/dist/adapter/OpencodeSdkAdapter.js +11 -3
- package/dist/adapter/types.d.ts +6 -0
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/routes/git.d.ts +42 -2
- package/dist/routes/git.d.ts.map +1 -1
- package/dist/routes/git.js +580 -61
- package/dist/routes/git.test.js +171 -6
- package/dist/routes/terminal.d.ts.map +1 -1
- package/dist/routes/terminal.js +175 -0
- package/package/acode/dist/runtime.d.ts.map +1 -1
- package/package/acode/dist/runtime.js +19 -5
- package/package/acode/dist/types.d.ts +6 -0
- package/package/acode/dist/types.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/context-manager.test.js +4 -1
- package/package/aws-client-agent-mcp/dist/context-manager.test.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/mcp-server.d.ts +70 -6
- package/package/aws-client-agent-mcp/dist/mcp-server.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/mcp-server.js +395 -105
- package/package/aws-client-agent-mcp/dist/mcp-server.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/mcp-server.test.js +12 -3
- package/package/aws-client-agent-mcp/dist/mcp-server.test.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/mcp-tools.d.ts +17 -1
- package/package/aws-client-agent-mcp/dist/mcp-tools.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/mcp-tools.js +44 -8
- package/package/aws-client-agent-mcp/dist/mcp-tools.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/memory-store.d.ts +52 -2
- package/package/aws-client-agent-mcp/dist/memory-store.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/memory-store.js +126 -14
- package/package/aws-client-agent-mcp/dist/memory-store.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/memory-store.test.js +82 -5
- package/package/aws-client-agent-mcp/dist/memory-store.test.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/memory-tools.d.ts +13 -1
- package/package/aws-client-agent-mcp/dist/memory-tools.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/memory-tools.js +45 -11
- package/package/aws-client-agent-mcp/dist/memory-tools.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/server-memory-store.d.ts +10 -1
- package/package/aws-client-agent-mcp/dist/server-memory-store.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/server-memory-store.js +21 -4
- package/package/aws-client-agent-mcp/dist/server-memory-store.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/status-reporter.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/status-reporter.js +0 -1
- package/package/aws-client-agent-mcp/dist/status-reporter.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/types.d.ts +9 -0
- package/package/aws-client-agent-mcp/dist/types.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/routes/git.js
CHANGED
|
@@ -1171,30 +1171,257 @@ gitRouter.post('/git/diff-file', validateToken, async (req, res) => {
|
|
|
1171
1171
|
}
|
|
1172
1172
|
});
|
|
1173
1173
|
/**
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1174
|
+
* 提交信息生成器的 system prompt。
|
|
1175
|
+
*
|
|
1176
|
+
* 设计要点:
|
|
1177
|
+
* 1. 角色与硬约束(STRICTLY / NEVER / MUST)放在最前,降低被忽略的概率。
|
|
1178
|
+
* 2. few-shot 示例用 `<example>...</example>` 包裹,显式标明"输出只能是 message 行"。
|
|
1179
|
+
* 3. description 使用中文(与项目语言一致),首行 type(scope) 用英文。
|
|
1180
|
+
* 4. 显式禁止 AI 解释自己"在做什么",这正是截图中出现"我们被问到..."问题的根因。
|
|
1176
1181
|
*/
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1182
|
+
const COMMIT_MESSAGE_SYSTEM_PROMPT = [
|
|
1183
|
+
'STRICTLY act as a Git commit message generator. NEVER explain, narrate, or describe your task.',
|
|
1184
|
+
'NEVER start your reply with phrases like "我们被问到", "以下是", "好的", "Sure", "Here is", "I will", "I can", "Let me", "根据", "下面".',
|
|
1185
|
+
'NEVER include any meta-language or thinking out loud. Output ONLY the commit message text.',
|
|
1186
|
+
'',
|
|
1187
|
+
'Format (Conventional Commits, 1 line, optional body):',
|
|
1188
|
+
' <type>(<scope>): <description>',
|
|
1189
|
+
'',
|
|
1190
|
+
'Allowed types: feat, fix, refactor, test, docs, style, chore, perf, ci, build, revert.',
|
|
1191
|
+
'First line MUST be <= 72 characters. <description> MUST be in Chinese.',
|
|
1192
|
+
'Add a body (separated by a blank line) ONLY when the change needs more context.',
|
|
1193
|
+
].join('\n');
|
|
1194
|
+
/**
|
|
1195
|
+
* 用户提示词:直接给出 diff,要求"输出"提交信息,不引导模型解释任务。
|
|
1196
|
+
*/
|
|
1197
|
+
function buildCommitMessageUserPrompt(diffContent) {
|
|
1198
|
+
return [
|
|
1199
|
+
'请直接输出上述格式的提交信息,不要任何额外文字。',
|
|
1200
|
+
'以下是暂存区 diff:',
|
|
1181
1201
|
'',
|
|
1182
|
-
|
|
1183
|
-
'- Use conventional commit format: type(scope): description',
|
|
1184
|
-
'- Types: feat, fix, refactor, test, docs, style, chore, perf, ci, build, revert',
|
|
1185
|
-
'- Keep the first line under 72 characters',
|
|
1186
|
-
'- Include a brief body only if the change needs explanation',
|
|
1187
|
-
'- Write in Chinese for the description part',
|
|
1188
|
-
'- Respond with ONLY the commit message, no explanations',
|
|
1202
|
+
diffContent,
|
|
1189
1203
|
].join('\n');
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* 识别 conventional commit 消息开头的 type 模式: type(scope)?: description。
|
|
1207
|
+
* 这是 sanitizeCommitMessage 的主策略 — 只要能定位到真正的 type,就直接截断,
|
|
1208
|
+
* 避免 AI 元描述前缀(如"我们被问到..."/"以下是...")污染首行。
|
|
1209
|
+
*/
|
|
1210
|
+
const COMMIT_TYPE_PATTERN = /\b(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(?:\s*\([^)]*\))?\s*:/i;
|
|
1211
|
+
/**
|
|
1212
|
+
* 宽松的 commit type 模式,用于识别不包含 scope 的 commit 消息。
|
|
1213
|
+
* 例如 "fix: 修复登录问题" 或 "feat: 添加新功能"
|
|
1214
|
+
*/
|
|
1215
|
+
const COMMIT_TYPE_LOOSE_PATTERN = /^(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)\s*:/i;
|
|
1216
|
+
/**
|
|
1217
|
+
* 判断文本首行是否看起来像有效的 commit 消息(有实际内容,不是纯元描述)。
|
|
1218
|
+
* 有效特征:包含中文/英文描述、不是纯噪声词汇、长度合理
|
|
1219
|
+
*/
|
|
1220
|
+
function looksLikeValidCommitMessage(text) {
|
|
1221
|
+
const firstLine = text.split('\n')[0]?.trim() ?? '';
|
|
1222
|
+
if (!firstLine || firstLine.length < 3) {
|
|
1223
|
+
return false;
|
|
1224
|
+
}
|
|
1225
|
+
// 检查是否包含有意义的描述(至少 2 个中文字符或 2 个英文字母序列)
|
|
1226
|
+
const chineseChars = (firstLine.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
1227
|
+
const englishWords = (firstLine.match(/[a-zA-Z]{2,}/g) || []).length;
|
|
1228
|
+
// 无中文且无英文单词时,如果首行只有符号/数字,判定无效
|
|
1229
|
+
if (chineseChars === 0 && englishWords === 0) {
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
// 排除明显是元描述的前缀(还没被剥离干净的)
|
|
1233
|
+
const metaPrefixes = [
|
|
1234
|
+
/^以下是/,
|
|
1235
|
+
/^上面是/,
|
|
1236
|
+
/^生成的/,
|
|
1237
|
+
/^这是/,
|
|
1238
|
+
/^下面/,
|
|
1239
|
+
/^(?:Here|This|This is|That is)/i,
|
|
1240
|
+
];
|
|
1241
|
+
for (const prefix of metaPrefixes) {
|
|
1242
|
+
if (prefix.test(firstLine)) {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return chineseChars >= 2 || englishWords >= 2;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* 已知会污染输出的"AI 元描述"前缀(pattern 列表),作为主策略失败时的兜底。
|
|
1250
|
+
* 当 AI 输出不包含可识别的 commit type(例如只输出元描述)时,按列表依次剥离。
|
|
1251
|
+
*/
|
|
1252
|
+
const COMMIT_NOISE_PREFIX_PATTERNS = [
|
|
1253
|
+
// 中文: "我们被问到..." / "我需要..." / "我将先..." / "我们先..."
|
|
1254
|
+
// 注意: 字符集排除 `.` `:` 全角 `:` 换行,但允许 `,` 和中文 `,`,
|
|
1255
|
+
// 这样 "我们被问到根据 diff 生成提交信息,好的以下是:" 这种带逗号前缀的也能被剥光
|
|
1256
|
+
/^\s*(?:我们|我)\s*(?:被\s*)?(?:问[到过]?|需要|要|将|来|先)[^.:\n]{0,60}?(?:提交信息|commit\s*message|[::,,。])/i,
|
|
1257
|
+
// 中文: "好的, ..." / "好: ..." / "好的以下是:"
|
|
1258
|
+
/^\s*(?:好的|好)\s*[,,::]/i,
|
|
1259
|
+
// 中文: "好的以下是..." / "好以下是..." — 前一条 pattern 剥离后残留
|
|
1260
|
+
/^\s*(?:好的|好)\s*(?:以下是|上面是|提供|生成|给出)[^.:\n]{0,20}?[::]?/i,
|
|
1261
|
+
// 中文: "是:" / "是:" — 前一条 pattern 剥离 "提从信息" 后残留的 "是:"
|
|
1262
|
+
/^\s*是\s*[::]/i,
|
|
1263
|
+
// 英文: "Sure, ..." / "Here is..." / "I will..." / "Let me..." / "Certainly:" / "Of course:"
|
|
1264
|
+
/^\s*(?:Sure|Here(?:'s|\s+is)|We(?:'re| are)|I\s+(?:will|can|'ll)|Let\s+me|Certainly|Of\s+course)[^.:\n]{0,40}?[,,::.]/i,
|
|
1265
|
+
// 中文: "根据您/你提供的 diff, " / "根据上述 diff" / "根据所给 diff"
|
|
1266
|
+
/^\s*根据\s*(?:您|你|您所|你所|所)?\s*(?:提供|上述|给[我您你])?\s*(?:的)?\s*diff\s*[,,::]?/i,
|
|
1267
|
+
// 中文: "以下是xxx提交信息: " / "下面是xxx提交信息," / "以下是符合 conventional commit 规范的提交信息: "
|
|
1268
|
+
/^\s*(?:以下|下面)[^,,::\n]{0,30}?提交信息\s*[,,::]?/i,
|
|
1269
|
+
// 中文: "以下是:" / "下面是:" — 不带"提交信息"的简化版,剥离前一条未命中的残留
|
|
1270
|
+
/^\s*(?:以下|下面)\s*是\s*[,,::]/i,
|
|
1271
|
+
// 中文: "生成xxx提交信息: " / "生成一条 conventional commit 提交信息,"
|
|
1272
|
+
/^\s*生成\s*(?:一条|一个|了)?\s*(?:符合|新的)?\s*(?:conventional\s*commit|git)?\s*提交信息\s*[,,::]?/i,
|
|
1273
|
+
// 开头 markdown 代码块 fence
|
|
1274
|
+
/^\s*```(?:text|markdown|bash|shell)?\s*\n?/i,
|
|
1275
|
+
];
|
|
1276
|
+
/**
|
|
1277
|
+
* 清洗 AI 返回的提交信息,去掉元描述/代码块/超长首行等噪声。
|
|
1278
|
+
*
|
|
1279
|
+
* 主流程:
|
|
1280
|
+
* 1. 主策略: 查找最早的 conventional commit type 模式(type(scope)?:),直接截取
|
|
1281
|
+
* 这一段之前的元描述全部丢弃。位置限定在 80 字符内,避免把 body 中提到的
|
|
1282
|
+
* "feat" 等字眼误当作开头。
|
|
1283
|
+
* 2. 兜底: 主策略未命中时,反复剥离已知元描述前缀 pattern(最多 5 次)
|
|
1284
|
+
* 3. 截断到首个空行,丢弃 body 中混入的元描述
|
|
1285
|
+
* 4. 去掉尾部 markdown 代码块
|
|
1286
|
+
* 5. 首行 > 100 字符视为模型仍在解释,只保留首行
|
|
1287
|
+
* 6. 最终为空时返回 "chore: update staged changes" 兜底
|
|
1288
|
+
*/
|
|
1289
|
+
export function sanitizeCommitMessage(raw) {
|
|
1290
|
+
if (!raw) {
|
|
1291
|
+
logger.info(`[Git/sanitize] 输入为空, 返回 fallback`);
|
|
1292
|
+
return 'chore: update staged changes';
|
|
1293
|
+
}
|
|
1294
|
+
let text = raw.replace(/\r\n/g, '\n').trim();
|
|
1295
|
+
const rawPreview = JSON.stringify(raw.substring(0, 120));
|
|
1296
|
+
// 1. 主策略: 定位 commit type 模式并截断
|
|
1297
|
+
const typeMatch = text.match(COMMIT_TYPE_PATTERN);
|
|
1298
|
+
if (typeMatch && typeMatch.index !== undefined && typeMatch.index <= 80) {
|
|
1299
|
+
text = text.slice(typeMatch.index);
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
// 2. 兜底: 反复剥离元描述前缀
|
|
1303
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1304
|
+
const before = text;
|
|
1305
|
+
for (const pattern of COMMIT_NOISE_PREFIX_PATTERNS) {
|
|
1306
|
+
text = text.replace(pattern, '');
|
|
1307
|
+
}
|
|
1308
|
+
text = text.replace(/^[\s*\-`,,']+/, '').trim();
|
|
1309
|
+
if (text === before)
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
// 2.5 兜底强化: 剥离后检查是否还有有效内容
|
|
1313
|
+
const firstLineAfterStrip = text.split('\n')[0]?.trim() ?? '';
|
|
1314
|
+
// 条件 A: 文本中包含 commit type(严格模式),保留
|
|
1315
|
+
// 条件 B: 文本中包含 commit type(宽松模式,如 "fix: xxx" 无 scope),保留
|
|
1316
|
+
// 条件 C: 文本首行看起来像有效的 commit 消息且有实际描述内容,保留
|
|
1317
|
+
// 条件 D: 首行很长(> 100 字符)可能是模型在解释,保留首行
|
|
1318
|
+
const hasStrictType = !!text.match(COMMIT_TYPE_PATTERN);
|
|
1319
|
+
const hasLooseType = !!text.match(COMMIT_TYPE_LOOSE_PATTERN);
|
|
1320
|
+
const looksValid = looksLikeValidCommitMessage(text);
|
|
1321
|
+
const firstLineTooLong = firstLineAfterStrip.length > 100;
|
|
1322
|
+
if (!hasStrictType && !hasLooseType && !looksValid && !firstLineTooLong) {
|
|
1323
|
+
logger.info(`[Git/sanitize] 剥离后无有效内容, 返回 fallback. raw=${rawPreview}, afterStrip=${JSON.stringify(text.substring(0, 100))}`);
|
|
1324
|
+
return 'chore: update staged changes';
|
|
1325
|
+
}
|
|
1326
|
+
// 如果剥离后有有效内容但没有 strict commit type,尝试提取首行
|
|
1327
|
+
if (!hasStrictType && (hasLooseType || looksValid || firstLineTooLong)) {
|
|
1328
|
+
text = firstLineAfterStrip || text;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// 3. 截断到首个空行
|
|
1332
|
+
const firstBlankIndex = text.search(/\n\s*\n/);
|
|
1333
|
+
if (firstBlankIndex > 0) {
|
|
1334
|
+
text = text.slice(0, firstBlankIndex);
|
|
1335
|
+
}
|
|
1336
|
+
// 4. 去掉尾部 markdown 代码块 fence
|
|
1337
|
+
text = text.replace(/\n+\s*```\s*$/g, '').trim();
|
|
1338
|
+
if (!text) {
|
|
1339
|
+
logger.info(`[Git/sanitize] 截断后为空, 返回 fallback. raw=${rawPreview}`);
|
|
1340
|
+
return 'chore: update staged changes';
|
|
1341
|
+
}
|
|
1342
|
+
// 5. 首行超过 100 字符极可能是模型仍在解释,只保留首行
|
|
1343
|
+
const firstLine = text.split('\n')[0]?.trim() ?? '';
|
|
1344
|
+
if (firstLine.length > 100) {
|
|
1345
|
+
logger.info(`[Git/sanitize] 首行超过 100 字符, 仅保留首行. raw=${rawPreview}, firstLine=${JSON.stringify(firstLine.substring(0, 100))}`);
|
|
1346
|
+
return firstLine;
|
|
1347
|
+
}
|
|
1348
|
+
logger.info(`[Git/sanitize] 结果: ${JSON.stringify(text.substring(0, 120))} (from raw=${rawPreview})`);
|
|
1349
|
+
return text;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* 从 OpenAI-compatible SSE chunk 文本中提取增量文本。
|
|
1353
|
+
* 处理:空行 / 缺少 data 字段 / [DONE] 哨兵 / 非 JSON 行的容错。
|
|
1354
|
+
*/
|
|
1355
|
+
export function extractOpenAiStreamDelta(chunkText) {
|
|
1356
|
+
const lines = chunkText.split(/\r?\n/);
|
|
1357
|
+
let buffer = '';
|
|
1358
|
+
for (const rawLine of lines) {
|
|
1359
|
+
const line = rawLine.trim();
|
|
1360
|
+
if (!line.startsWith('data:'))
|
|
1361
|
+
continue;
|
|
1362
|
+
const data = line.slice(5).trim();
|
|
1363
|
+
if (!data || data === '[DONE]')
|
|
1364
|
+
continue;
|
|
1365
|
+
try {
|
|
1366
|
+
const parsed = JSON.parse(data);
|
|
1367
|
+
const delta = parsed?.choices?.[0]?.delta?.content;
|
|
1368
|
+
if (typeof delta === 'string' && delta.length > 0) {
|
|
1369
|
+
buffer += delta;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
// 忽略单行解析失败,继续读后续 chunk
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return buffer;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* 调试级: 记录 AI stream 的前 N 个 chunk 的完整 JSON 结构,用于诊断
|
|
1380
|
+
* 模型返回格式与标准 OpenAI 格式不同导致 delta 无法提取的问题。
|
|
1381
|
+
*/
|
|
1382
|
+
export function debugAiStreamChunk(chunkText, index) {
|
|
1383
|
+
if (index > 5)
|
|
1384
|
+
return; // 只记前 5 个
|
|
1385
|
+
const lines = chunkText.split(/\r?\n/);
|
|
1386
|
+
for (const rawLine of lines) {
|
|
1387
|
+
const line = rawLine.trim();
|
|
1388
|
+
if (!line.startsWith('data:'))
|
|
1389
|
+
continue;
|
|
1390
|
+
const data = line.slice(5).trim();
|
|
1391
|
+
if (!data || data === '[DONE]')
|
|
1392
|
+
continue;
|
|
1393
|
+
try {
|
|
1394
|
+
const parsed = JSON.parse(data);
|
|
1395
|
+
const keys = Object.keys(parsed);
|
|
1396
|
+
const choices = parsed?.choices;
|
|
1397
|
+
const firstChoice = Array.isArray(choices) ? choices[0] : null;
|
|
1398
|
+
const deltaKeys = firstChoice?.delta ? Object.keys(firstChoice.delta) : null;
|
|
1399
|
+
const deltaContent = firstChoice?.delta?.content;
|
|
1400
|
+
logger.info(`[Git/extract] chunk#${index} structure: topKeys=${JSON.stringify(keys)}, ` +
|
|
1401
|
+
`choicesLen=${Array.isArray(choices) ? choices.length : typeof choices}, ` +
|
|
1402
|
+
`deltaKeys=${JSON.stringify(deltaKeys)}, content=${JSON.stringify(deltaContent?.substring(0, 50))}, ` +
|
|
1403
|
+
`finishReason=${JSON.stringify(firstChoice?.finish_reason)}`);
|
|
1404
|
+
}
|
|
1405
|
+
catch { /* ignore */ }
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* 调用 OpenAI-compatible API(非流式)生成 Git 提交信息。
|
|
1410
|
+
*
|
|
1411
|
+
* 主流程:组装 prompt → 调 chat/completions → 清洗回复 → 返回。
|
|
1412
|
+
* 保留此接口为同步路径的兜底(测试 / 旧调用方),新场景请优先使用 `streamAiForCommitMessage`。
|
|
1413
|
+
*/
|
|
1414
|
+
export async function callAiForCommitMessage(aiBaseUrl, aiApiKey, aiModel, diffContent) {
|
|
1415
|
+
const url = `${aiBaseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
1190
1416
|
const response = await axios.post(url, {
|
|
1191
1417
|
model: aiModel,
|
|
1418
|
+
stream: false,
|
|
1192
1419
|
messages: [
|
|
1193
|
-
{ role: 'system', content:
|
|
1194
|
-
{ role: 'user', content:
|
|
1420
|
+
{ role: 'system', content: COMMIT_MESSAGE_SYSTEM_PROMPT },
|
|
1421
|
+
{ role: 'user', content: buildCommitMessageUserPrompt(diffContent) },
|
|
1195
1422
|
],
|
|
1196
|
-
max_tokens:
|
|
1197
|
-
temperature: 0.
|
|
1423
|
+
max_tokens: 200,
|
|
1424
|
+
temperature: 0.2,
|
|
1198
1425
|
}, {
|
|
1199
1426
|
headers: {
|
|
1200
1427
|
'Content-Type': 'application/json',
|
|
@@ -1204,16 +1431,192 @@ export async function callAiForCommitMessage(aiBaseUrl, aiApiKey, aiModel, diffC
|
|
|
1204
1431
|
});
|
|
1205
1432
|
const data = response.data;
|
|
1206
1433
|
const content = data?.choices?.[0]?.message?.content?.trim() || '';
|
|
1207
|
-
return content
|
|
1434
|
+
return sanitizeCommitMessage(content);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* 流式调用 OpenAI-compatible API 生成 Git 提交信息。
|
|
1438
|
+
*
|
|
1439
|
+
* 主流程:
|
|
1440
|
+
* 1. 发起 stream:true 的 chat/completions 请求
|
|
1441
|
+
* 2. 逐 chunk 解析 SSE,把 delta 文本回调给调用方(`onDelta`)
|
|
1442
|
+
* 3. 全部读取完成后,把累积内容交给 `sanitizeCommitMessage` 清洗
|
|
1443
|
+
* 4. 返回清洗后的最终提交信息
|
|
1444
|
+
*
|
|
1445
|
+
* 入参:
|
|
1446
|
+
* - onDelta: 每次收到增量时调用,用于把"打字机"效果推给上层
|
|
1447
|
+
* - abortSignal: 调用方可中止请求(用户取消时使用)
|
|
1448
|
+
*/
|
|
1449
|
+
export async function streamAiForCommitMessage(aiBaseUrl, aiApiKey, aiModel, diffContent, onDelta, abortSignal) {
|
|
1450
|
+
const url = `${aiBaseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
1451
|
+
const response = await axios.post(url, {
|
|
1452
|
+
model: aiModel,
|
|
1453
|
+
stream: true,
|
|
1454
|
+
messages: [
|
|
1455
|
+
{ role: 'system', content: COMMIT_MESSAGE_SYSTEM_PROMPT },
|
|
1456
|
+
{ role: 'user', content: buildCommitMessageUserPrompt(diffContent) },
|
|
1457
|
+
],
|
|
1458
|
+
max_tokens: 200,
|
|
1459
|
+
temperature: 0.2,
|
|
1460
|
+
}, {
|
|
1461
|
+
headers: {
|
|
1462
|
+
'Content-Type': 'application/json',
|
|
1463
|
+
'Authorization': `Bearer ${aiApiKey}`,
|
|
1464
|
+
'Accept': 'text/event-stream',
|
|
1465
|
+
},
|
|
1466
|
+
timeout: Number(process.env.AWS_AI_STREAM_TIMEOUT_MS) || 60000,
|
|
1467
|
+
responseType: 'stream',
|
|
1468
|
+
signal: abortSignal,
|
|
1469
|
+
});
|
|
1470
|
+
const stream = response.data;
|
|
1471
|
+
let buffer = '';
|
|
1472
|
+
let accumulated = '';
|
|
1473
|
+
let pendingData = '';
|
|
1474
|
+
let onDataChunkCount = 0;
|
|
1475
|
+
let onDataDeltaCount = 0;
|
|
1476
|
+
logger.info(`[Git/streamAi] AI 流开始: aiModel=${aiModel}`);
|
|
1477
|
+
return await new Promise((resolve, reject) => {
|
|
1478
|
+
const finish = (value) => {
|
|
1479
|
+
stream.removeAllListeners('data');
|
|
1480
|
+
stream.removeAllListeners('end');
|
|
1481
|
+
stream.removeAllListeners('error');
|
|
1482
|
+
stream.removeAllListeners('aborted');
|
|
1483
|
+
logger.info(`[Git/streamAi] AI 流结束: totalChunks=${onDataChunkCount}, totalDeltas=${onDataDeltaCount}, bufferLength=${buffer.length}, sanitized=${JSON.stringify(value).substring(0, 200)}`);
|
|
1484
|
+
resolve(value);
|
|
1485
|
+
};
|
|
1486
|
+
stream.on('data', (chunk) => {
|
|
1487
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
1488
|
+
onDataChunkCount++;
|
|
1489
|
+
pendingData += text;
|
|
1490
|
+
if (onDataChunkCount <= 3) {
|
|
1491
|
+
logger.info(`[Git/streamAi] chunk#${onDataChunkCount}: length=${text.length}, preview=${JSON.stringify(text.substring(0, 80))}`);
|
|
1492
|
+
}
|
|
1493
|
+
// 调试: 记录前 5 个 chunk 的完整 JSON 结构
|
|
1494
|
+
debugAiStreamChunk(text, onDataChunkCount);
|
|
1495
|
+
// SSE 事件以 \n\n 分隔,逐事件解析
|
|
1496
|
+
const events = pendingData.split(/\n\n/);
|
|
1497
|
+
pendingData = events.pop() ?? '';
|
|
1498
|
+
for (const eventText of events) {
|
|
1499
|
+
const delta = extractOpenAiStreamDelta(eventText);
|
|
1500
|
+
if (delta) {
|
|
1501
|
+
onDataDeltaCount++;
|
|
1502
|
+
buffer += delta;
|
|
1503
|
+
accumulated = sanitizeCommitMessage(buffer);
|
|
1504
|
+
onDelta(delta, accumulated);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
stream.on('end', () => {
|
|
1509
|
+
// 处理尾部可能残留的最后一个事件
|
|
1510
|
+
if (pendingData.trim()) {
|
|
1511
|
+
const delta = extractOpenAiStreamDelta(pendingData);
|
|
1512
|
+
if (delta) {
|
|
1513
|
+
logger.info(`[Git/streamAi] 尾部残留 delta: length=${delta.length}`);
|
|
1514
|
+
buffer += delta;
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
logger.info(`[Git/streamAi] 尾部残留无 delta, pendingData=${JSON.stringify(pendingData.substring(0, 100))}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
finish(sanitizeCommitMessage(buffer));
|
|
1521
|
+
});
|
|
1522
|
+
stream.on('error', (err) => {
|
|
1523
|
+
logger.error(`[Git/streamAi] 流错误: ${err.message}`);
|
|
1524
|
+
reject(err);
|
|
1525
|
+
});
|
|
1526
|
+
stream.on('aborted', () => {
|
|
1527
|
+
logger.info(`[Git/streamAi] 流被中止, bufferLength=${buffer.length}`);
|
|
1528
|
+
finish(sanitizeCommitMessage(buffer));
|
|
1529
|
+
});
|
|
1530
|
+
});
|
|
1208
1531
|
}
|
|
1209
1532
|
/**
|
|
1210
|
-
*
|
|
1533
|
+
* 把 git diff --cached 内容聚合成单段文本,限制长度防止超 token。
|
|
1534
|
+
* 供同步和流式两个生成 commit 消息的路由共用。
|
|
1211
1535
|
*
|
|
1212
|
-
*
|
|
1213
|
-
* 1.
|
|
1214
|
-
* 2.
|
|
1215
|
-
* 3.
|
|
1216
|
-
*
|
|
1536
|
+
* 主流程:
|
|
1537
|
+
* 1. 对每个 stagedFile 调用 git diff --cached 收集单文件 diff
|
|
1538
|
+
* 2. 若逐文件均无 diff(可能因路径规整失败),回落到全量 git diff --cached
|
|
1539
|
+
* 3. 拼接并截断(> 15000 字符追加截断标记)
|
|
1540
|
+
*
|
|
1541
|
+
* 返回: { truncatedDiff: string } 表示收集成功,空字符串表示暂存区无变更。
|
|
1542
|
+
*/
|
|
1543
|
+
async function collectStagedDiffForAi(repositoryPath, workspacePath, stagedFiles) {
|
|
1544
|
+
const context = await resolveGitRepositoryContext(String(repositoryPath || workspacePath).trim());
|
|
1545
|
+
if (!context.hasRepository || !context.repositoryRootPath) {
|
|
1546
|
+
logger.warn(`[Git/collectDiff] 不在 Git 仓库中: workspacePath=${workspacePath}`);
|
|
1547
|
+
return { truncatedDiff: '', error: { status: 400, message: 'workspace is not inside a git repository' } };
|
|
1548
|
+
}
|
|
1549
|
+
const filePaths = stagedFiles
|
|
1550
|
+
.map(f => f.path)
|
|
1551
|
+
.filter(Boolean);
|
|
1552
|
+
logger.info(`[Git/collectDiff] 开始收集 ${filePaths.length} 个暂存文件差异: ${filePaths.join(', ').substring(0, 200)}`);
|
|
1553
|
+
const diffParts = [];
|
|
1554
|
+
for (const filePath of filePaths) {
|
|
1555
|
+
const repositoryRelativePath = resolveRepositoryRelativeFilePath(filePath, context);
|
|
1556
|
+
const result = await execGitCommand(context.repositoryRootPath, [
|
|
1557
|
+
'diff', '--cached', '--', repositoryRelativePath,
|
|
1558
|
+
]);
|
|
1559
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1560
|
+
diffParts.push(`--- ${filePath} ---\n${result.stdout.trim()}`);
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
logger.info(`[Git/collectDiff] 文件无暂存差异或获取失败: ${filePath}, exitCode=${result.exitCode}, stdout.length=${result.stdout.length}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
if (diffParts.length === 0) {
|
|
1567
|
+
// Fallback: 尝试获取所有暂存区变更
|
|
1568
|
+
logger.info(`[Git/collectDiff] 逐文件 diff 为空, 回退到全量 diff --cached`);
|
|
1569
|
+
const result = await execGitCommand(context.repositoryRootPath, [
|
|
1570
|
+
'diff', '--cached',
|
|
1571
|
+
]);
|
|
1572
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1573
|
+
diffParts.push(result.stdout.trim());
|
|
1574
|
+
logger.info(`[Git/collectDiff] 全量 diff 获取成功: length=${result.stdout.trim().length}`);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
logger.info(`[Git/collectDiff] 全量 diff 也为空, exitCode=${result.exitCode}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (diffParts.length === 0) {
|
|
1581
|
+
logger.info(`[Git/collectDiff] 暂存区没有差异内容 (diffParts 为空)`);
|
|
1582
|
+
return { truncatedDiff: '' };
|
|
1583
|
+
}
|
|
1584
|
+
const diffContent = diffParts.join('\n\n');
|
|
1585
|
+
const maxDiffLength = 15000;
|
|
1586
|
+
const truncatedDiff = diffContent.length > maxDiffLength
|
|
1587
|
+
? `${diffContent.slice(0, maxDiffLength)}\n...(diff truncated due to length)`
|
|
1588
|
+
: diffContent;
|
|
1589
|
+
logger.info(`[Git/collectDiff] 收集完成: diffContent.length=${diffContent.length}, truncated=${truncatedDiff.length}`);
|
|
1590
|
+
return { truncatedDiff };
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* 写入一条 SSE 事件到响应流,失败时吞掉(连接已关闭时不再报错)。
|
|
1594
|
+
*
|
|
1595
|
+
* SSE 要求每条事件数据实时推送到客户端,不能依赖 Node.js 内部流缓冲。
|
|
1596
|
+
* post-flush 保障:
|
|
1597
|
+
* - res.flushHeaders() 已预先发送响应头,后续 write 直接走 socket
|
|
1598
|
+
* - socket.setNoDelay(true) 已关闭 Nagle 算法,每次 write 立即经内核发出
|
|
1599
|
+
* - 额外的 res.write('') + res.flushHeaders() 兜底 flush,
|
|
1600
|
+
* 兼容部分 Node.js 版本/补丁对 _send 的缓冲行为
|
|
1601
|
+
*/
|
|
1602
|
+
function writeSseEvent(res, event, data) {
|
|
1603
|
+
try {
|
|
1604
|
+
res.write(`event: ${event}\n`);
|
|
1605
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1606
|
+
// 不调用 res.flushHeaders(): 首次调用已在 SSE 路由开头完成 (L2062),
|
|
1607
|
+
// 后续调用为 no-op。实时性依赖 socket.setNoDelay(true) + 内核 TCP_NODELAY。
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
// 连接已断开,忽略
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* 根据暂存区文件列表和 AI 源生成提交信息(同步路径,保留作为旧客户端兜底)。
|
|
1615
|
+
*
|
|
1616
|
+
* 主干流程:
|
|
1617
|
+
* 1. 收集暂存区 diff 内容(由 collectStagedDiffForAi 完成)
|
|
1618
|
+
* 2. 调用非流式 AI 接口生成完整提交信息
|
|
1619
|
+
* 3. 返回清洗后的文本
|
|
1217
1620
|
*
|
|
1218
1621
|
* POST /runtime/git/generate-commit-message
|
|
1219
1622
|
*/
|
|
@@ -1232,51 +1635,137 @@ gitRouter.post('/git/generate-commit-message', validateToken, async (req, res) =
|
|
|
1232
1635
|
return;
|
|
1233
1636
|
}
|
|
1234
1637
|
try {
|
|
1235
|
-
const
|
|
1236
|
-
if (
|
|
1237
|
-
res.status(
|
|
1638
|
+
const { truncatedDiff, error } = await collectStagedDiffForAi(String(repositoryPath || ''), String(workspacePath), stagedFiles);
|
|
1639
|
+
if (error) {
|
|
1640
|
+
res.status(error.status).json({ error: error.message });
|
|
1238
1641
|
return;
|
|
1239
1642
|
}
|
|
1240
|
-
|
|
1241
|
-
const filePaths = stagedFiles
|
|
1242
|
-
.map(f => f.path)
|
|
1243
|
-
.filter(Boolean);
|
|
1244
|
-
const diffParts = [];
|
|
1245
|
-
for (const filePath of filePaths) {
|
|
1246
|
-
const repositoryRelativePath = resolveRepositoryRelativeFilePath(filePath, context);
|
|
1247
|
-
const result = await execGitCommand(context.repositoryRootPath, [
|
|
1248
|
-
'diff', '--cached', '--', repositoryRelativePath,
|
|
1249
|
-
]);
|
|
1250
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1251
|
-
diffParts.push(`--- ${filePath} ---\n${result.stdout.trim()}`);
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
if (diffParts.length === 0) {
|
|
1255
|
-
// Fallback: 尝试获取所有暂存区变更
|
|
1256
|
-
const result = await execGitCommand(context.repositoryRootPath, [
|
|
1257
|
-
'diff', '--cached',
|
|
1258
|
-
]);
|
|
1259
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1260
|
-
diffParts.push(result.stdout.trim());
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
if (diffParts.length === 0) {
|
|
1643
|
+
if (!truncatedDiff) {
|
|
1264
1644
|
res.json({ message: '暂存区没有变更内容' });
|
|
1265
1645
|
return;
|
|
1266
1646
|
}
|
|
1267
|
-
const diffContent = diffParts.join('\n\n');
|
|
1268
|
-
// 限制 diff 长度,防止超过 token 限制
|
|
1269
|
-
const maxDiffLength = 15000;
|
|
1270
|
-
const truncatedDiff = diffContent.length > maxDiffLength
|
|
1271
|
-
? diffContent.slice(0, maxDiffLength) + '\n...(diff truncated due to length)'
|
|
1272
|
-
: diffContent;
|
|
1273
1647
|
const generatedMessage = await callAiForCommitMessage(aiBaseUrl, aiApiKey, aiModel, truncatedDiff);
|
|
1274
1648
|
res.json({ message: generatedMessage });
|
|
1275
1649
|
}
|
|
1276
|
-
catch (
|
|
1277
|
-
const
|
|
1278
|
-
logger.error(`[Git] generate commit message failed: ${
|
|
1279
|
-
res.status(500).json({ error:
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
const error = err;
|
|
1652
|
+
logger.error(`[Git] generate commit message failed: ${error.message}`);
|
|
1653
|
+
res.status(500).json({ error: error.message || 'generate commit message failed' });
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
/**
|
|
1657
|
+
* 流式 SSE 版本:把 AI 生成过程以 delta 事件持续推给前端,实现打字机效果。
|
|
1658
|
+
*
|
|
1659
|
+
* 主干流程:
|
|
1660
|
+
* 1. 收集暂存区 diff(共用 collectStagedDiffForAi)
|
|
1661
|
+
* 2. 设置 SSE 响应头(res.writeHead + chunked transfer)
|
|
1662
|
+
* 3. 调 streamAiForCommitMessage,每收到一段 delta 立刻 write 一条 SSE 事件
|
|
1663
|
+
* delta 事件 data 包含 contentDelta(原始增量)与 message(经过 sanitize 的累计内容)
|
|
1664
|
+
* 4. AI 完成后写一条 done 事件,data 包含最终 message
|
|
1665
|
+
* 5. 任何异常写一条 error 事件,前端据此退出"生成中"状态
|
|
1666
|
+
*
|
|
1667
|
+
* POST /runtime/git/generate-commit-message-stream
|
|
1668
|
+
*/
|
|
1669
|
+
gitRouter.post('/git/generate-commit-message-stream', validateToken, async (req, res) => {
|
|
1670
|
+
const { workspacePath, repositoryPath, stagedFiles, aiBaseUrl, aiApiKey, aiModel } = req.body || {};
|
|
1671
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
1672
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
if (!Array.isArray(stagedFiles) || stagedFiles.length === 0) {
|
|
1676
|
+
res.status(400).json({ error: 'stagedFiles is required and must be a non-empty array' });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (!aiBaseUrl || !String(aiBaseUrl).trim() || !aiApiKey || !String(aiApiKey).trim() || !aiModel || !String(aiModel).trim()) {
|
|
1680
|
+
res.status(400).json({ error: 'aiBaseUrl, aiApiKey and aiModel are required' });
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
logger.info(`[Git/SSE] 收到 generate-commit-message-stream 请求: workspacePath=${String(workspacePath || '').substring(0, 40)}, ` +
|
|
1684
|
+
`stagedFiles=${stagedFiles?.length ?? 0}, aiModel=${aiModel}, aiBaseUrl=${aiBaseUrl}`);
|
|
1685
|
+
// 设置 SSE 响应头(必须在 write 任何数据前完成)
|
|
1686
|
+
res.status(200);
|
|
1687
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
1688
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
1689
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1690
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1691
|
+
// 禁用 Nagle 算法: SSE 每条数据都很小(< 1460 bytes),默认 TCP_NODELAY=false
|
|
1692
|
+
// 会导致内核延迟发送,将多个小数据包合并后再发出,造成前端 SSE 事件接收延迟甚至超时。
|
|
1693
|
+
// 设置 true 后每次 write 立即经内核发送,不等待合并,确保事件实时到达。
|
|
1694
|
+
req.socket?.setNoDelay(true);
|
|
1695
|
+
res.flushHeaders?.();
|
|
1696
|
+
// 客户端中断时主动中止上游 AI 请求
|
|
1697
|
+
let aborted = false;
|
|
1698
|
+
let abortController = null;
|
|
1699
|
+
let responseFinished = false;
|
|
1700
|
+
const sseEventCount = { delta: 0 };
|
|
1701
|
+
const requestPeer = `${req.ip}:${req.socket?.remotePort ?? '?'}`;
|
|
1702
|
+
req.on('close', () => {
|
|
1703
|
+
if (!aborted) {
|
|
1704
|
+
aborted = true;
|
|
1705
|
+
// 即使连接断开,也不主动 abort AI 请求 — 让 AI 调用自然完成,
|
|
1706
|
+
// 之后同步 API 兜底时可能命中缓存(某些 AI provider 有结果缓存)。
|
|
1707
|
+
// 但若 abortController 已在后续创建,仍可安全终止。
|
|
1708
|
+
logger.info(`[Git/SSE] 客户端断开连接: workspacePath=${String(workspacePath || '').substring(0, 40)}, deltaEventsSent=${sseEventCount.delta}, peer=${requestPeer}`);
|
|
1709
|
+
}
|
|
1710
|
+
});
|
|
1711
|
+
// 发送一条 init SSE 事件, 确保响应体已有数据输出。
|
|
1712
|
+
// 目的: 让 Java 后端确认连接活跃,
|
|
1713
|
+
// 避免 HTTP 客户端在收到 SSE 响应头后、collectDiff/AI 输出前因
|
|
1714
|
+
// 无数据到达而关闭连接。
|
|
1715
|
+
writeSseEvent(res, 'init', { status: 'generating' });
|
|
1716
|
+
try {
|
|
1717
|
+
const { truncatedDiff, error } = await collectStagedDiffForAi(String(repositoryPath || ''), String(workspacePath), stagedFiles);
|
|
1718
|
+
if (error) {
|
|
1719
|
+
logger.warn(`[Git/SSE] collectStagedDiffForAi 返回错误: ${error.message}`);
|
|
1720
|
+
writeSseEvent(res, 'error', { message: error.message });
|
|
1721
|
+
res.end();
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (!truncatedDiff) {
|
|
1725
|
+
logger.info(`[Git/SSE] 暂存区没有变更内容, 直接返回 done`);
|
|
1726
|
+
writeSseEvent(res, 'delta', { contentDelta: '', message: '暂存区没有变更内容' });
|
|
1727
|
+
writeSseEvent(res, 'done', { message: '暂存区没有变更内容' });
|
|
1728
|
+
res.end();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
logger.info(`[Git/SSE] 收集到 diff: truncatedDiff.length=${truncatedDiff.length}`);
|
|
1732
|
+
if (aborted) {
|
|
1733
|
+
// init 事件已在 collectDiff 前发出, 但客户端在此期间断开了连接。
|
|
1734
|
+
// 不跳过早退: 因为 writeSseEvent 的 try/catch 会吞掉已断开连接的写入错误,
|
|
1735
|
+
// 继续执行 streamAiForCommitMessage 不会造成额外负面影响。
|
|
1736
|
+
// 让 AI 调用完成并尝试发送 done 事件, 这样如果客户端重连或中间代理恢复,
|
|
1737
|
+
// 仍有可能收到完整的提交信息。
|
|
1738
|
+
logger.info(`[Git/SSE] 客户端在 collectDiff 期间断开, 但仍尝试 AI 调用`);
|
|
1739
|
+
}
|
|
1740
|
+
logger.info(`[Git/SSE] 开始调用 AI streamAiForCommitMessage: aiModel=${aiModel}`);
|
|
1741
|
+
// 在 AI 调用前才创建 AbortController, 避免 req.on('close') 在 collectDiff 期间
|
|
1742
|
+
// 提前 abort 尚未开始的 AI 请求
|
|
1743
|
+
abortController = new AbortController();
|
|
1744
|
+
let lastEmitted = '';
|
|
1745
|
+
const finalMessage = await streamAiForCommitMessage(aiBaseUrl, aiApiKey, aiModel, truncatedDiff, (delta, accumulated) => {
|
|
1746
|
+
sseEventCount.delta++;
|
|
1747
|
+
// 仅在 sanitize 后的内容相对上次发送有变化时再推送,避免重复渲染
|
|
1748
|
+
if (accumulated !== lastEmitted) {
|
|
1749
|
+
lastEmitted = accumulated;
|
|
1750
|
+
writeSseEvent(res, 'delta', { contentDelta: delta, message: accumulated });
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
writeSseEvent(res, 'delta', { contentDelta: delta, message: accumulated });
|
|
1754
|
+
}
|
|
1755
|
+
}, abortController.signal);
|
|
1756
|
+
logger.info(`[Git/SSE] AI streamAiForCommitMessage 完成: finalMessage=${JSON.stringify(finalMessage).substring(0, 200)}, deltaEventsSent=${sseEventCount.delta}`);
|
|
1757
|
+
if (!aborted) {
|
|
1758
|
+
writeSseEvent(res, 'done', { message: finalMessage });
|
|
1759
|
+
}
|
|
1760
|
+
res.end();
|
|
1761
|
+
}
|
|
1762
|
+
catch (err) {
|
|
1763
|
+
const error = err;
|
|
1764
|
+
logger.error(`[Git/SSE] stream commit message failed: ${error.message}${error.stack ? '\n' + error.stack : ''}`);
|
|
1765
|
+
if (!aborted) {
|
|
1766
|
+
writeSseEvent(res, 'error', { message: error.message || 'generate commit message failed' });
|
|
1767
|
+
}
|
|
1768
|
+
res.end();
|
|
1280
1769
|
}
|
|
1281
1770
|
});
|
|
1282
1771
|
/**
|
|
@@ -1379,3 +1868,33 @@ gitRouter.post('/git/restore-files', validateToken, async (req, res) => {
|
|
|
1379
1868
|
res.status(500).json({ error: err.message || 'git restore files failed' });
|
|
1380
1869
|
}
|
|
1381
1870
|
});
|
|
1871
|
+
/**
|
|
1872
|
+
* 获取暂存区文件差异内容(纯 diff 文本,不调用 AI)。
|
|
1873
|
+
* 供 Java 后端直接调用 AI 时使用,避免 AI API 密钥经由 bridge 转发。
|
|
1874
|
+
*
|
|
1875
|
+
* POST /runtime/git/diff-content
|
|
1876
|
+
*/
|
|
1877
|
+
gitRouter.post('/git/diff-content', validateToken, async (req, res) => {
|
|
1878
|
+
const { workspacePath, repositoryPath, stagedFiles } = req.body || {};
|
|
1879
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
1880
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (!Array.isArray(stagedFiles) || stagedFiles.length === 0) {
|
|
1884
|
+
res.status(400).json({ error: 'stagedFiles is required and must be a non-empty array' });
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
const { truncatedDiff, error } = await collectStagedDiffForAi(String(repositoryPath || ''), String(workspacePath), stagedFiles);
|
|
1889
|
+
if (error) {
|
|
1890
|
+
res.status(error.status).json({ error: error.message });
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
res.json({ ok: true, diff: truncatedDiff || '' });
|
|
1894
|
+
}
|
|
1895
|
+
catch (err) {
|
|
1896
|
+
const error = err;
|
|
1897
|
+
logger.error(`[Git/diff-content] 获取暂存区差异失败: ${error.message}`);
|
|
1898
|
+
res.status(500).json({ error: error.message || 'get diff content failed' });
|
|
1899
|
+
}
|
|
1900
|
+
});
|