autosnippet 2.7.0 → 2.8.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/README.md +138 -66
- package/bin/api-server.js +5 -0
- package/bin/cli.js +26 -0
- package/bin/mcp-server.js +22 -0
- package/dashboard/dist/assets/{icons-Cq4-iQhP.js → icons-B_Xg4B-s.js} +61 -61
- package/dashboard/dist/assets/index-CkIih2CC.css +1 -0
- package/dashboard/dist/assets/index-Duc8Qk-c.js +197 -0
- package/dashboard/dist/index.html +3 -3
- package/lib/bootstrap.js +17 -0
- package/lib/cli/SetupService.js +53 -0
- package/lib/external/ai/providers/ClaudeProvider.js +12 -1
- package/lib/external/ai/providers/GoogleGeminiProvider.js +13 -1
- package/lib/external/ai/providers/OpenAiProvider.js +13 -3
- package/lib/external/mcp/McpServer.js +11 -4
- package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +194 -5
- package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +8 -10
- package/lib/external/mcp/handlers/bootstrap.js +8 -0
- package/lib/external/mcp/handlers/skill.js +202 -0
- package/lib/external/mcp/tools.js +54 -1
- package/lib/http/routes/ai.js +155 -1
- package/lib/infrastructure/config/Paths.js +3 -0
- package/lib/infrastructure/database/DatabaseConnection.js +6 -1
- package/lib/infrastructure/vector/JsonVectorAdapter.js +2 -0
- package/lib/service/automation/handlers/AlinkHandler.js +43 -4
- package/lib/service/candidate/CandidateFileWriter.js +4 -0
- package/lib/service/chat/AnalystAgent.js +37 -8
- package/lib/service/chat/CandidateGuardrail.js +3 -3
- package/lib/service/chat/ChatAgent.js +20 -1
- package/lib/service/chat/ConversationStore.js +3 -0
- package/lib/service/chat/HandoffProtocol.js +1 -0
- package/lib/service/chat/Memory.js +3 -0
- package/lib/service/chat/ProducerAgent.js +53 -0
- package/lib/service/chat/tools.js +13 -6
- package/lib/service/guard/ExclusionManager.js +2 -0
- package/lib/service/guard/RuleLearner.js +2 -0
- package/lib/service/quality/FeedbackCollector.js +2 -0
- package/lib/service/recipe/RecipeFileWriter.js +4 -0
- package/lib/service/recipe/RecipeStatsTracker.js +2 -0
- package/lib/service/skills/SignalCollector.js +2 -0
- package/lib/shared/PathGuard.js +314 -0
- package/package.json +1 -1
- package/resources/native-ui/combined-window.swift +494 -0
- package/dashboard/dist/assets/index-DBxH7pVn.css +0 -1
- package/dashboard/dist/assets/index-Dw2F6qAS.js +0 -197
|
@@ -14,6 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { getProjectSkillsPath } from '../../../infrastructure/config/Paths.js';
|
|
17
|
+
import pathGuard from '../../../shared/PathGuard.js';
|
|
17
18
|
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const SKILLS_DIR = path.resolve(__dirname, '../../../../skills');
|
|
@@ -282,6 +283,8 @@ export function createSkill(_ctx, args) {
|
|
|
282
283
|
|
|
283
284
|
// ── 写入 SKILL.md ──
|
|
284
285
|
try {
|
|
286
|
+
// 路径安全检查 — name 来自用户输入,可能含路径字符
|
|
287
|
+
pathGuard.assertProjectWriteSafe(skillDir);
|
|
285
288
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
286
289
|
|
|
287
290
|
// 自动推断 title: 优先使用传入参数,否则从 content 的第一个 # heading 提取
|
|
@@ -385,6 +388,7 @@ function _regenerateEditorIndex() {
|
|
|
385
388
|
].join('\n');
|
|
386
389
|
|
|
387
390
|
// 写入 .cursor/rules/
|
|
391
|
+
pathGuard.assertProjectWriteSafe(rulesDir);
|
|
388
392
|
fs.mkdirSync(rulesDir, { recursive: true });
|
|
389
393
|
const indexPath = path.join(rulesDir, 'autosnippet-skills.mdc');
|
|
390
394
|
fs.writeFileSync(indexPath, mdcContent, 'utf8');
|
|
@@ -395,6 +399,204 @@ function _regenerateEditorIndex() {
|
|
|
395
399
|
}
|
|
396
400
|
}
|
|
397
401
|
|
|
402
|
+
// ═══════════════════════════════════════════════════════════
|
|
403
|
+
// Handler: deleteSkill
|
|
404
|
+
// ═══════════════════════════════════════════════════════════
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 删除项目级 Skill — 移除 {projectRoot}/AutoSnippet/skills/<name>/ 整个目录
|
|
408
|
+
* 内置 Skill 不可删除。删除后自动 regenerate 编辑器索引。
|
|
409
|
+
*
|
|
410
|
+
* @param {object} _ctx MCP context
|
|
411
|
+
* @param {object} args { name: string }
|
|
412
|
+
* @returns {string} JSON envelope
|
|
413
|
+
*/
|
|
414
|
+
export function deleteSkill(_ctx, args) {
|
|
415
|
+
const { name } = args || {};
|
|
416
|
+
|
|
417
|
+
if (!name) {
|
|
418
|
+
return JSON.stringify({
|
|
419
|
+
success: false,
|
|
420
|
+
error: { code: 'MISSING_PARAM', message: 'name is required' },
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 不允许删除内置 Skill
|
|
425
|
+
const builtinSkillPath = path.join(SKILLS_DIR, name);
|
|
426
|
+
if (fs.existsSync(builtinSkillPath)) {
|
|
427
|
+
return JSON.stringify({
|
|
428
|
+
success: false,
|
|
429
|
+
error: {
|
|
430
|
+
code: 'BUILTIN_PROTECTED',
|
|
431
|
+
message: `"${name}" is a built-in Skill and cannot be deleted.`,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 检查项目级 Skill 是否存在
|
|
437
|
+
const projectSkillsDir = _getProjectSkillsDir();
|
|
438
|
+
const skillDir = path.join(projectSkillsDir, name);
|
|
439
|
+
if (!fs.existsSync(skillDir)) {
|
|
440
|
+
return JSON.stringify({
|
|
441
|
+
success: false,
|
|
442
|
+
error: {
|
|
443
|
+
code: 'SKILL_NOT_FOUND',
|
|
444
|
+
message: `Project skill "${name}" not found.`,
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── 路径安全检查 ──
|
|
450
|
+
try {
|
|
451
|
+
pathGuard.assertProjectWriteSafe(skillDir);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return JSON.stringify({
|
|
454
|
+
success: false,
|
|
455
|
+
error: { code: 'PATH_GUARD', message: err.message },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── 删除目录 ──
|
|
460
|
+
try {
|
|
461
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
462
|
+
} catch (err) {
|
|
463
|
+
return JSON.stringify({
|
|
464
|
+
success: false,
|
|
465
|
+
error: { code: 'DELETE_ERROR', message: `Failed to delete skill: ${err.message}` },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── regenerate 编辑器索引 ──
|
|
470
|
+
const indexResult = _regenerateEditorIndex();
|
|
471
|
+
|
|
472
|
+
return JSON.stringify({
|
|
473
|
+
success: true,
|
|
474
|
+
data: {
|
|
475
|
+
skillName: name,
|
|
476
|
+
deleted: true,
|
|
477
|
+
editorIndex: indexResult,
|
|
478
|
+
hint: `Skill "${name}" deleted successfully.`,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ═══════════════════════════════════════════════════════════
|
|
484
|
+
// Handler: updateSkill
|
|
485
|
+
// ═══════════════════════════════════════════════════════════
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 更新项目级 Skill — 修改 description 和/或 content
|
|
489
|
+
* 内置 Skill 不可更新。更新后自动 regenerate 编辑器索引。
|
|
490
|
+
*
|
|
491
|
+
* @param {object} _ctx MCP context
|
|
492
|
+
* @param {object} args { name, description?, content? }
|
|
493
|
+
* @returns {string} JSON envelope
|
|
494
|
+
*/
|
|
495
|
+
export function updateSkill(_ctx, args) {
|
|
496
|
+
const { name, description, content } = args || {};
|
|
497
|
+
|
|
498
|
+
if (!name) {
|
|
499
|
+
return JSON.stringify({
|
|
500
|
+
success: false,
|
|
501
|
+
error: { code: 'MISSING_PARAM', message: 'name is required' },
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!description && !content) {
|
|
506
|
+
return JSON.stringify({
|
|
507
|
+
success: false,
|
|
508
|
+
error: { code: 'NOTHING_TO_UPDATE', message: 'At least one of description or content must be provided.' },
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 不允许更新内置 Skill
|
|
513
|
+
const builtinSkillPath = path.join(SKILLS_DIR, name, 'SKILL.md');
|
|
514
|
+
if (fs.existsSync(builtinSkillPath)) {
|
|
515
|
+
return JSON.stringify({
|
|
516
|
+
success: false,
|
|
517
|
+
error: {
|
|
518
|
+
code: 'BUILTIN_PROTECTED',
|
|
519
|
+
message: `"${name}" is a built-in Skill and cannot be updated. Fork it as a project skill instead.`,
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 检查项目级 Skill 是否存在
|
|
525
|
+
const projectSkillsDir = _getProjectSkillsDir();
|
|
526
|
+
const skillPath = path.join(projectSkillsDir, name, 'SKILL.md');
|
|
527
|
+
if (!fs.existsSync(skillPath)) {
|
|
528
|
+
return JSON.stringify({
|
|
529
|
+
success: false,
|
|
530
|
+
error: {
|
|
531
|
+
code: 'SKILL_NOT_FOUND',
|
|
532
|
+
message: `Project skill "${name}" not found. Use autosnippet_create_skill to create it first.`,
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
// ── 读取现有文件 ──
|
|
539
|
+
const existing = fs.readFileSync(skillPath, 'utf8');
|
|
540
|
+
|
|
541
|
+
// 解析现有 frontmatter
|
|
542
|
+
const fmMatch = existing.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
543
|
+
let oldFm = '';
|
|
544
|
+
let oldBody = existing;
|
|
545
|
+
if (fmMatch) {
|
|
546
|
+
oldFm = fmMatch[1];
|
|
547
|
+
oldBody = fmMatch[2];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 解析已有字段
|
|
551
|
+
const getField = (fm, key) => {
|
|
552
|
+
const m = fm.match(new RegExp(`^${key}:\\s*(.+?)$`, 'm'));
|
|
553
|
+
return m ? m[1].trim() : null;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const newDesc = description || getField(oldFm, 'description') || name;
|
|
557
|
+
const newBody = content !== undefined && content !== null ? content : oldBody;
|
|
558
|
+
|
|
559
|
+
// 保留原有字段
|
|
560
|
+
const createdBy = getField(oldFm, 'createdBy') || 'external-ai';
|
|
561
|
+
const createdAt = getField(oldFm, 'createdAt') || new Date().toISOString();
|
|
562
|
+
const title = getField(oldFm, 'title');
|
|
563
|
+
|
|
564
|
+
// 重建 frontmatter
|
|
565
|
+
const fmLines = ['---', `name: ${name}`];
|
|
566
|
+
if (title) fmLines.push(`title: ${title}`);
|
|
567
|
+
fmLines.push(
|
|
568
|
+
`description: ${newDesc}`,
|
|
569
|
+
`createdBy: ${createdBy}`,
|
|
570
|
+
`createdAt: ${createdAt}`,
|
|
571
|
+
`updatedAt: ${new Date().toISOString()}`,
|
|
572
|
+
'---',
|
|
573
|
+
'',
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
pathGuard.assertProjectWriteSafe(path.join(projectSkillsDir, name));
|
|
577
|
+
fs.writeFileSync(skillPath, fmLines.join('\n') + newBody, 'utf8');
|
|
578
|
+
} catch (err) {
|
|
579
|
+
return JSON.stringify({
|
|
580
|
+
success: false,
|
|
581
|
+
error: { code: 'UPDATE_ERROR', message: `Failed to update skill: ${err.message}` },
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── regenerate 编辑器索引 ──
|
|
586
|
+
const indexResult = _regenerateEditorIndex();
|
|
587
|
+
|
|
588
|
+
return JSON.stringify({
|
|
589
|
+
success: true,
|
|
590
|
+
data: {
|
|
591
|
+
skillName: name,
|
|
592
|
+
updated: true,
|
|
593
|
+
fieldsUpdated: [description ? 'description' : null, content ? 'content' : null].filter(Boolean),
|
|
594
|
+
editorIndex: indexResult,
|
|
595
|
+
hint: `Skill "${name}" updated. Use autosnippet_load_skill to verify content.`,
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
398
600
|
// ═══════════════════════════════════════════════════════════
|
|
399
601
|
// Handler: suggestSkills
|
|
400
602
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP 工具定义(
|
|
2
|
+
* MCP 工具定义(38 个)+ Gateway 映射
|
|
3
3
|
*
|
|
4
4
|
* 只包含 JSON Schema 级别的声明,不含任何业务逻辑。
|
|
5
5
|
*/
|
|
@@ -18,6 +18,8 @@ export const TOOL_GATEWAY_MAP = {
|
|
|
18
18
|
autosnippet_bootstrap_knowledge: { action: 'knowledge:bootstrap', resource: 'knowledge' },
|
|
19
19
|
autosnippet_bootstrap_refine: { action: 'candidate:update', resource: 'candidates' },
|
|
20
20
|
autosnippet_create_skill: { action: 'create:skills', resource: 'skills' },
|
|
21
|
+
autosnippet_delete_skill: { action: 'delete:skills', resource: 'skills' },
|
|
22
|
+
autosnippet_update_skill: { action: 'update:skills', resource: 'skills' },
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export const TOOLS = [
|
|
@@ -684,6 +686,57 @@ export const TOOLS = [
|
|
|
684
686
|
' • 候选被大量驳回时',
|
|
685
687
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
686
688
|
},
|
|
689
|
+
// 37. 删除项目级 Skill
|
|
690
|
+
{
|
|
691
|
+
name: 'autosnippet_delete_skill',
|
|
692
|
+
description:
|
|
693
|
+
'删除一个项目级 Skill 及其目录。\n' +
|
|
694
|
+
'⚠️ 内置 Skill 不可删除。删除后自动更新编辑器索引。\n' +
|
|
695
|
+
'\n' +
|
|
696
|
+
'使用场景:\n' +
|
|
697
|
+
' • 清理不再需要的自定义 Skill\n' +
|
|
698
|
+
' • 移除过时或错误的操作指南',
|
|
699
|
+
inputSchema: {
|
|
700
|
+
type: 'object',
|
|
701
|
+
properties: {
|
|
702
|
+
name: {
|
|
703
|
+
type: 'string',
|
|
704
|
+
description: 'Skill 名称(如 my-auth-guide)',
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
required: ['name'],
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
// 38. 更新项目级 Skill
|
|
711
|
+
{
|
|
712
|
+
name: 'autosnippet_update_skill',
|
|
713
|
+
description:
|
|
714
|
+
'更新已存在的项目级 Skill 的描述或内容。\n' +
|
|
715
|
+
'⚠️ 内置 Skill 不可更新。更新后自动刷新编辑器索引。\n' +
|
|
716
|
+
'\n' +
|
|
717
|
+
'使用场景:\n' +
|
|
718
|
+
' • 迭代改进已有 Skill 的操作指南\n' +
|
|
719
|
+
' • 更新过时的最佳实践内容\n' +
|
|
720
|
+
' • 修正 Skill 描述或补充新章节',
|
|
721
|
+
inputSchema: {
|
|
722
|
+
type: 'object',
|
|
723
|
+
properties: {
|
|
724
|
+
name: {
|
|
725
|
+
type: 'string',
|
|
726
|
+
description: 'Skill 名称(必须已存在于项目级 Skills 中)',
|
|
727
|
+
},
|
|
728
|
+
description: {
|
|
729
|
+
type: 'string',
|
|
730
|
+
description: '新的一句话描述(可选,不传则保持原值)',
|
|
731
|
+
},
|
|
732
|
+
content: {
|
|
733
|
+
type: 'string',
|
|
734
|
+
description: '新的正文内容(Markdown 格式,不含 frontmatter)。不传则保持原值',
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
required: ['name'],
|
|
738
|
+
},
|
|
739
|
+
},
|
|
687
740
|
// 36. ② 内容润色:Bootstrap 候选 AI 精炼(Phase 6)
|
|
688
741
|
{
|
|
689
742
|
name: 'autosnippet_bootstrap_refine',
|
package/lib/http/routes/ai.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI API 路由
|
|
3
|
-
* AI
|
|
3
|
+
* AI 提供商管理、摘要、翻译、对话、.env LLM 配置
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import express from 'express';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
7
9
|
import { asyncHandler } from '../middleware/errorHandler.js';
|
|
8
10
|
import { getServiceContainer } from '../../injection/ServiceContainer.js';
|
|
9
11
|
import { createProvider } from '../../external/ai/AiFactory.js';
|
|
@@ -244,4 +246,156 @@ router.post('/format-usage-guide', asyncHandler(async (req, res) => {
|
|
|
244
246
|
res.json({ success: true, data: { formatted } });
|
|
245
247
|
}));
|
|
246
248
|
|
|
249
|
+
// ═══════════════════════════════════════════════════════
|
|
250
|
+
// .env LLM 配置读写
|
|
251
|
+
// ═══════════════════════════════════════════════════════
|
|
252
|
+
|
|
253
|
+
/** 获取用户项目目录下 .env 的路径 */
|
|
254
|
+
function _getProjectEnvPath() {
|
|
255
|
+
const container = getServiceContainer();
|
|
256
|
+
const projectRoot = container.singletons?._projectRoot || process.env.ASD_PROJECT_DIR || process.cwd();
|
|
257
|
+
return join(projectRoot, '.env');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** LLM 相关的 env 变量名 → 标签映射 */
|
|
261
|
+
const LLM_ENV_KEYS = [
|
|
262
|
+
'ASD_AI_PROVIDER',
|
|
263
|
+
'ASD_AI_MODEL',
|
|
264
|
+
'ASD_GOOGLE_API_KEY',
|
|
265
|
+
'ASD_OPENAI_API_KEY',
|
|
266
|
+
'ASD_CLAUDE_API_KEY',
|
|
267
|
+
'ASD_DEEPSEEK_API_KEY',
|
|
268
|
+
'ASD_AI_PROXY',
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 解析 .env 内容为 key-value(仅提取 LLM 相关变量)
|
|
273
|
+
* 返回 { vars, hasEnvFile, llmReady }
|
|
274
|
+
* llmReady: provider + 至少一个对应 API Key 已配置
|
|
275
|
+
*/
|
|
276
|
+
function parseLlmEnv(envPath) {
|
|
277
|
+
if (!existsSync(envPath)) {
|
|
278
|
+
return { vars: {}, hasEnvFile: false, llmReady: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const raw = readFileSync(envPath, 'utf8');
|
|
282
|
+
const vars = {};
|
|
283
|
+
|
|
284
|
+
for (const line of raw.split('\n')) {
|
|
285
|
+
const trimmed = line.trim();
|
|
286
|
+
// 跳过注释和空行
|
|
287
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
288
|
+
const eqIdx = trimmed.indexOf('=');
|
|
289
|
+
if (eqIdx === -1) continue;
|
|
290
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
291
|
+
const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
292
|
+
if (LLM_ENV_KEYS.includes(key)) {
|
|
293
|
+
vars[key] = val;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 判断 LLM 是否可用:有 provider + 对应的 API Key
|
|
298
|
+
const provider = vars.ASD_AI_PROVIDER || '';
|
|
299
|
+
const keyMap = {
|
|
300
|
+
google: 'ASD_GOOGLE_API_KEY',
|
|
301
|
+
openai: 'ASD_OPENAI_API_KEY',
|
|
302
|
+
claude: 'ASD_CLAUDE_API_KEY',
|
|
303
|
+
deepseek: 'ASD_DEEPSEEK_API_KEY',
|
|
304
|
+
ollama: '', // ollama 不需要 key
|
|
305
|
+
mock: '', // mock 不需要 key
|
|
306
|
+
};
|
|
307
|
+
const neededKey = keyMap[provider] || '';
|
|
308
|
+
const llmReady = !!provider && (!neededKey || !!vars[neededKey]);
|
|
309
|
+
|
|
310
|
+
return { vars, hasEnvFile: true, llmReady };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* GET /api/v1/ai/env-config
|
|
315
|
+
* 读取用户项目 .env 中的 LLM 配置
|
|
316
|
+
*/
|
|
317
|
+
router.get('/env-config', asyncHandler(async (req, res) => {
|
|
318
|
+
const envPath = _getProjectEnvPath();
|
|
319
|
+
const result = parseLlmEnv(envPath);
|
|
320
|
+
res.json({ success: true, data: result });
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* POST /api/v1/ai/env-config
|
|
325
|
+
* 写入 / 更新用户项目 .env 中的 LLM 配置
|
|
326
|
+
*
|
|
327
|
+
* Body: { provider, model, apiKey, proxy? }
|
|
328
|
+
*/
|
|
329
|
+
router.post('/env-config', asyncHandler(async (req, res) => {
|
|
330
|
+
const { provider, model, apiKey, proxy } = req.body;
|
|
331
|
+
if (!provider || typeof provider !== 'string') {
|
|
332
|
+
throw new ValidationError('provider is required');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const envPath = _getProjectEnvPath();
|
|
336
|
+
let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
337
|
+
|
|
338
|
+
// 构建 key-value 更新列表
|
|
339
|
+
const updates = {
|
|
340
|
+
ASD_AI_PROVIDER: provider,
|
|
341
|
+
};
|
|
342
|
+
if (model) updates.ASD_AI_MODEL = model;
|
|
343
|
+
if (proxy) updates.ASD_AI_PROXY = proxy;
|
|
344
|
+
|
|
345
|
+
// 根据 provider 决定写入哪个 API Key 变量
|
|
346
|
+
const providerKeyMap = {
|
|
347
|
+
google: 'ASD_GOOGLE_API_KEY',
|
|
348
|
+
openai: 'ASD_OPENAI_API_KEY',
|
|
349
|
+
claude: 'ASD_CLAUDE_API_KEY',
|
|
350
|
+
deepseek: 'ASD_DEEPSEEK_API_KEY',
|
|
351
|
+
};
|
|
352
|
+
const keyName = providerKeyMap[provider];
|
|
353
|
+
if (keyName && apiKey) {
|
|
354
|
+
updates[keyName] = apiKey;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 逐条合并到 .env 内容
|
|
358
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
359
|
+
// 匹配已有行(包括被注释的行)
|
|
360
|
+
const activeRe = new RegExp(`^${k}\\s*=.*$`, 'm');
|
|
361
|
+
const commentedRe = new RegExp(`^#\\s*${k}\\s*=.*$`, 'm');
|
|
362
|
+
|
|
363
|
+
if (activeRe.test(content)) {
|
|
364
|
+
// 替换已有活动行
|
|
365
|
+
content = content.replace(activeRe, `${k}=${v}`);
|
|
366
|
+
} else if (commentedRe.test(content)) {
|
|
367
|
+
// 取消注释并赋值
|
|
368
|
+
content = content.replace(commentedRe, `${k}=${v}`);
|
|
369
|
+
} else {
|
|
370
|
+
// 追加到末尾
|
|
371
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
372
|
+
content += `${k}=${v}\n`;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
writeFileSync(envPath, content);
|
|
377
|
+
logger.info('LLM env config updated', { provider, model });
|
|
378
|
+
|
|
379
|
+
// 同步到当前进程环境变量(热生效)
|
|
380
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
381
|
+
process.env[k] = v;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 尝试热切换 AI Provider
|
|
385
|
+
try {
|
|
386
|
+
const newProvider = createProvider({
|
|
387
|
+
provider: provider.toLowerCase(),
|
|
388
|
+
model: model || undefined,
|
|
389
|
+
});
|
|
390
|
+
const container = getServiceContainer();
|
|
391
|
+
container.singletons.aiProvider = newProvider;
|
|
392
|
+
logger.info('AI provider hot-swapped after env update', { provider, model: newProvider.model });
|
|
393
|
+
} catch (err) {
|
|
394
|
+
logger.debug('Hot-swap AI provider failed (will take effect on restart)', { error: err.message });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const result = parseLlmEnv(envPath);
|
|
398
|
+
res.json({ success: true, data: result });
|
|
399
|
+
}));
|
|
400
|
+
|
|
247
401
|
export default router;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import pathGuard from '../../shared/PathGuard.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Paths — 项目路径解析工具
|
|
@@ -14,6 +15,8 @@ const USER_HOME = process.env.HOME || process.env.USERPROFILE || '';
|
|
|
14
15
|
/** 确保目录存在(静默处理异常) */
|
|
15
16
|
function ensureDir(dirPath) {
|
|
16
17
|
try {
|
|
18
|
+
// 双层路径安全检查 — 阻止在项目允许范围外创建文件夹
|
|
19
|
+
pathGuard.assertProjectWriteSafe(dirPath);
|
|
17
20
|
if (!fs.existsSync(dirPath)) {
|
|
18
21
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
19
22
|
}
|
|
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
+
import pathGuard from '../../shared/PathGuard.js';
|
|
5
6
|
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
8
|
const __dirname = path.dirname(__filename);
|
|
@@ -20,9 +21,13 @@ export class DatabaseConnection {
|
|
|
20
21
|
*/
|
|
21
22
|
async connect() {
|
|
22
23
|
const dbPath = this.config.path;
|
|
24
|
+
|
|
25
|
+
// 路径安全检查 — 防止 DB 文件创建到项目允许范围外
|
|
26
|
+
const resolvedDbPath = path.resolve(dbPath);
|
|
27
|
+
pathGuard.assertProjectWriteSafe(resolvedDbPath);
|
|
23
28
|
|
|
24
29
|
// 确保数据目录存在
|
|
25
|
-
const dbDir = path.dirname(
|
|
30
|
+
const dbDir = path.dirname(resolvedDbPath);
|
|
26
31
|
if (!fs.existsSync(dbDir)) {
|
|
27
32
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
28
33
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { VectorStore } from './VectorStore.js';
|
|
8
8
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
9
9
|
import { join, dirname } from 'node:path';
|
|
10
|
+
import pathGuard from '../../shared/PathGuard.js';
|
|
10
11
|
|
|
11
12
|
export class JsonVectorAdapter extends VectorStore {
|
|
12
13
|
#indexPath;
|
|
@@ -238,6 +239,7 @@ export class JsonVectorAdapter extends VectorStore {
|
|
|
238
239
|
if (!this.#dirty) return;
|
|
239
240
|
try {
|
|
240
241
|
const dir = dirname(this.#indexPath);
|
|
242
|
+
pathGuard.assertProjectWriteSafe(dir);
|
|
241
243
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
242
244
|
const items = [...this.#data.values()];
|
|
243
245
|
writeFileSync(this.#indexPath, JSON.stringify(items, null, 2));
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AlinkHandler — 处理 alink 指令
|
|
3
|
+
*
|
|
4
|
+
* 解析编辑器中的 alink 触发行,提取 completionKey,
|
|
5
|
+
* 通过数据库查找匹配的 Recipe,打开 Dashboard 详情页。
|
|
3
6
|
*/
|
|
4
7
|
|
|
8
|
+
import { getServiceContainer } from '../../../injection/ServiceContainer.js';
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* @param {string} alinkLine
|
|
7
12
|
*/
|
|
@@ -19,11 +24,45 @@ export async function handleAlink(alinkLine) {
|
|
|
19
24
|
|
|
20
25
|
if (completionKey != null) {
|
|
21
26
|
try {
|
|
27
|
+
// 从 DI 容器获取数据库实例,查找匹配 trigger 的 Recipe
|
|
28
|
+
const container = getServiceContainer();
|
|
29
|
+
const db = container.get('database');
|
|
30
|
+
|
|
31
|
+
let recipeId = null;
|
|
32
|
+
if (db) {
|
|
33
|
+
try {
|
|
34
|
+
const row = db.prepare(
|
|
35
|
+
'SELECT id FROM recipes WHERE trigger = ? LIMIT 1',
|
|
36
|
+
).get(completionKey);
|
|
37
|
+
if (row) recipeId = row.id;
|
|
38
|
+
} catch {
|
|
39
|
+
// DB 查询失败时回退到搜索
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 若精确匹配失败,尝试模糊搜索
|
|
43
|
+
if (!recipeId) {
|
|
44
|
+
try {
|
|
45
|
+
const escaped = completionKey.replace(/[%_\\]/g, ch => `\\${ch}`);
|
|
46
|
+
const row = db.prepare(
|
|
47
|
+
"SELECT id FROM recipes WHERE trigger LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\' LIMIT 1",
|
|
48
|
+
).get(`%${escaped}%`, `%${escaped}%`);
|
|
49
|
+
if (row) recipeId = row.id;
|
|
50
|
+
} catch { /* silent */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 构建 Dashboard URL 并打开
|
|
55
|
+
const port = process.env.ASD_DASHBOARD_PORT || 3000;
|
|
56
|
+
const host = process.env.ASD_DASHBOARD_HOST || 'localhost';
|
|
57
|
+
const url = recipeId
|
|
58
|
+
? `http://${host}:${port}/#/recipes/${recipeId}`
|
|
59
|
+
: `http://${host}:${port}/#/search?q=${encodeURIComponent(completionKey)}`;
|
|
60
|
+
|
|
22
61
|
const open = (await import('open')).default;
|
|
23
|
-
|
|
24
|
-
console.log(`[alink] completionKey=${completionKey}
|
|
25
|
-
} catch {
|
|
26
|
-
|
|
62
|
+
await open(url);
|
|
63
|
+
console.log(`[alink] completionKey=${completionKey} → ${url}`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.warn(`[alink] Failed to open link: ${err.message}`);
|
|
27
66
|
}
|
|
28
67
|
}
|
|
29
68
|
}
|
|
@@ -19,6 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { CANDIDATES_DIR } from '../../infrastructure/config/Defaults.js';
|
|
21
21
|
import Logger from '../../infrastructure/logging/Logger.js';
|
|
22
|
+
import pathGuard from '../../shared/PathGuard.js';
|
|
22
23
|
|
|
23
24
|
export { CANDIDATES_DIR };
|
|
24
25
|
|
|
@@ -175,6 +176,9 @@ export class CandidateFileWriter {
|
|
|
175
176
|
const category = (candidate.category || 'general').toLowerCase();
|
|
176
177
|
const categoryDir = path.join(this.candidatesDir, category);
|
|
177
178
|
|
|
179
|
+
// 路径安全检查 — 阻止 category 含 ../ 导致路径逃逸
|
|
180
|
+
pathGuard.assertProjectWriteSafe(categoryDir);
|
|
181
|
+
|
|
178
182
|
if (!fs.existsSync(categoryDir)) {
|
|
179
183
|
fs.mkdirSync(categoryDir, { recursive: true });
|
|
180
184
|
}
|