@code4bug/jarvis-agent 1.0.2
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 +227 -0
- package/dist/agents/code-reviewer.md +69 -0
- package/dist/agents/dba.md +68 -0
- package/dist/agents/finance-advisor.md +81 -0
- package/dist/agents/index.d.ts +31 -0
- package/dist/agents/index.js +86 -0
- package/dist/agents/jarvis.md +95 -0
- package/dist/agents/stock-trader.md +81 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +9 -0
- package/dist/commands/index.d.ts +19 -0
- package/dist/commands/index.js +79 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +283 -0
- package/dist/components/DangerConfirm.d.ts +17 -0
- package/dist/components/DangerConfirm.js +50 -0
- package/dist/components/MarkdownText.d.ts +12 -0
- package/dist/components/MarkdownText.js +166 -0
- package/dist/components/MessageItem.d.ts +8 -0
- package/dist/components/MessageItem.js +78 -0
- package/dist/components/MultilineInput.d.ts +34 -0
- package/dist/components/MultilineInput.js +437 -0
- package/dist/components/SlashCommandMenu.d.ts +16 -0
- package/dist/components/SlashCommandMenu.js +43 -0
- package/dist/components/StatusBar.d.ts +8 -0
- package/dist/components/StatusBar.js +26 -0
- package/dist/components/StreamingText.d.ts +6 -0
- package/dist/components/StreamingText.js +10 -0
- package/dist/components/WelcomeHeader.d.ts +6 -0
- package/dist/components/WelcomeHeader.js +25 -0
- package/dist/config/agentState.d.ts +16 -0
- package/dist/config/agentState.js +65 -0
- package/dist/config/constants.d.ts +25 -0
- package/dist/config/constants.js +67 -0
- package/dist/config/loader.d.ts +30 -0
- package/dist/config/loader.js +64 -0
- package/dist/config/systemInfo.d.ts +12 -0
- package/dist/config/systemInfo.js +95 -0
- package/dist/core/QueryEngine.d.ts +52 -0
- package/dist/core/QueryEngine.js +246 -0
- package/dist/core/hint.d.ts +14 -0
- package/dist/core/hint.js +279 -0
- package/dist/core/query.d.ts +24 -0
- package/dist/core/query.js +245 -0
- package/dist/core/safeguard.d.ts +96 -0
- package/dist/core/safeguard.js +236 -0
- package/dist/hooks/useFocus.d.ts +12 -0
- package/dist/hooks/useFocus.js +35 -0
- package/dist/hooks/useInputHistory.d.ts +14 -0
- package/dist/hooks/useInputHistory.js +102 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/screens/repl.d.ts +1 -0
- package/dist/screens/repl.js +842 -0
- package/dist/services/api/llm.d.ts +27 -0
- package/dist/services/api/llm.js +314 -0
- package/dist/services/api/mock.d.ts +9 -0
- package/dist/services/api/mock.js +102 -0
- package/dist/skills/index.d.ts +23 -0
- package/dist/skills/index.js +232 -0
- package/dist/skills/loader.d.ts +45 -0
- package/dist/skills/loader.js +108 -0
- package/dist/tools/createSkill.d.ts +8 -0
- package/dist/tools/createSkill.js +255 -0
- package/dist/tools/index.d.ts +16 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/listDirectory.d.ts +2 -0
- package/dist/tools/listDirectory.js +20 -0
- package/dist/tools/readFile.d.ts +2 -0
- package/dist/tools/readFile.js +17 -0
- package/dist/tools/runCommand.d.ts +2 -0
- package/dist/tools/runCommand.js +69 -0
- package/dist/tools/searchFiles.d.ts +2 -0
- package/dist/tools/searchFiles.js +45 -0
- package/dist/tools/writeFile.d.ts +2 -0
- package/dist/tools/writeFile.js +42 -0
- package/dist/types/index.d.ts +86 -0
- package/dist/types/index.js +2 -0
- package/package.json +55 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 智能提示生成器
|
|
3
|
+
*
|
|
4
|
+
* 根据当前激活的智能体角色 + 项目上下文,调用 LLM 生成一条
|
|
5
|
+
* 符合角色设定且贴合当前项目的输入提示信息。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { getAgent } from '../agents/index.js';
|
|
10
|
+
import { DEFAULT_AGENT } from '../config/constants.js';
|
|
11
|
+
import { loadConfig, getActiveModel } from '../config/loader.js';
|
|
12
|
+
import { getDefaultConfig } from '../services/api/llm.js';
|
|
13
|
+
/** 安全读取 JSON 文件 */
|
|
14
|
+
function readJsonSafe(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
if (!fs.existsSync(filePath))
|
|
17
|
+
return null;
|
|
18
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** 采集顶层目录结构(只取第一层,排除 node_modules / .git 等) */
|
|
25
|
+
function getTopLevelStructure(cwd) {
|
|
26
|
+
const IGNORE = new Set(['node_modules', '.git', '.DS_Store', 'dist', 'build', '.sessions', '__pycache__', '.venv', 'venv']);
|
|
27
|
+
try {
|
|
28
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
29
|
+
const items = entries
|
|
30
|
+
.filter((e) => !IGNORE.has(e.name))
|
|
31
|
+
.slice(0, 20) // 最多 20 条
|
|
32
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
33
|
+
return items.join(', ');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 检测技术栈 */
|
|
40
|
+
function detectTechStack(cwd) {
|
|
41
|
+
const tags = [];
|
|
42
|
+
const exists = (f) => fs.existsSync(path.join(cwd, f));
|
|
43
|
+
// JavaScript / TypeScript 生态
|
|
44
|
+
if (exists('package.json')) {
|
|
45
|
+
tags.push('Node.js');
|
|
46
|
+
const pkg = readJsonSafe(path.join(cwd, 'package.json'));
|
|
47
|
+
const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
48
|
+
if (allDeps?.react)
|
|
49
|
+
tags.push('React');
|
|
50
|
+
if (allDeps?.vue)
|
|
51
|
+
tags.push('Vue');
|
|
52
|
+
if (allDeps?.next)
|
|
53
|
+
tags.push('Next.js');
|
|
54
|
+
if (allDeps?.express)
|
|
55
|
+
tags.push('Express');
|
|
56
|
+
if (allDeps?.nestjs || allDeps?.['@nestjs/core'])
|
|
57
|
+
tags.push('NestJS');
|
|
58
|
+
}
|
|
59
|
+
if (exists('tsconfig.json'))
|
|
60
|
+
tags.push('TypeScript');
|
|
61
|
+
// Python 生态
|
|
62
|
+
if (exists('requirements.txt') || exists('pyproject.toml') || exists('setup.py')) {
|
|
63
|
+
tags.push('Python');
|
|
64
|
+
if (exists('manage.py'))
|
|
65
|
+
tags.push('Django');
|
|
66
|
+
if (exists('app.py') || exists('wsgi.py'))
|
|
67
|
+
tags.push('Flask');
|
|
68
|
+
}
|
|
69
|
+
// Java 生态
|
|
70
|
+
if (exists('pom.xml'))
|
|
71
|
+
tags.push('Java', 'Maven');
|
|
72
|
+
if (exists('build.gradle') || exists('build.gradle.kts'))
|
|
73
|
+
tags.push('Java', 'Gradle');
|
|
74
|
+
// Go
|
|
75
|
+
if (exists('go.mod'))
|
|
76
|
+
tags.push('Go');
|
|
77
|
+
// Rust
|
|
78
|
+
if (exists('Cargo.toml'))
|
|
79
|
+
tags.push('Rust');
|
|
80
|
+
// Docker / DevOps
|
|
81
|
+
if (exists('Dockerfile') || exists('docker-compose.yml') || exists('docker-compose.yaml'))
|
|
82
|
+
tags.push('Docker');
|
|
83
|
+
if (exists('.github/workflows'))
|
|
84
|
+
tags.push('GitHub Actions');
|
|
85
|
+
return [...new Set(tags)];
|
|
86
|
+
}
|
|
87
|
+
/** 检测是否包含数据库相关内容 */
|
|
88
|
+
function detectDatabase(cwd) {
|
|
89
|
+
const markers = [
|
|
90
|
+
'prisma', 'migrations', 'schema.prisma',
|
|
91
|
+
'knexfile.js', 'knexfile.ts', 'ormconfig.ts', 'ormconfig.json',
|
|
92
|
+
'sequelize.config.js', 'database.yml', 'db',
|
|
93
|
+
'sql', 'flyway', 'liquibase',
|
|
94
|
+
];
|
|
95
|
+
try {
|
|
96
|
+
const entries = fs.readdirSync(cwd).map((e) => e.toLowerCase());
|
|
97
|
+
return markers.some((m) => entries.includes(m));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** 检测是否包含财务/金融相关内容 */
|
|
104
|
+
function detectFinance(cwd) {
|
|
105
|
+
const markers = ['finance', 'accounting', 'ledger', 'invoice', 'billing', 'payment'];
|
|
106
|
+
try {
|
|
107
|
+
const entries = fs.readdirSync(cwd).map((e) => e.toLowerCase());
|
|
108
|
+
return markers.some((m) => entries.some((e) => e.includes(m)));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** 采集当前项目上下文 */
|
|
115
|
+
function collectProjectContext() {
|
|
116
|
+
const cwd = process.cwd();
|
|
117
|
+
const pkg = readJsonSafe(path.join(cwd, 'package.json'));
|
|
118
|
+
return {
|
|
119
|
+
projectName: pkg?.name ?? path.basename(cwd),
|
|
120
|
+
description: pkg?.description ?? '',
|
|
121
|
+
techStack: detectTechStack(cwd),
|
|
122
|
+
topLevelStructure: getTopLevelStructure(cwd),
|
|
123
|
+
hasDatabase: detectDatabase(cwd),
|
|
124
|
+
hasFinance: detectFinance(cwd),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** 将 ProjectContext 格式化为 prompt 片段 */
|
|
128
|
+
function formatContextForPrompt(ctx) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push(`项目名称: ${ctx.projectName}`);
|
|
131
|
+
if (ctx.description)
|
|
132
|
+
lines.push(`项目描述: ${ctx.description}`);
|
|
133
|
+
if (ctx.techStack.length > 0)
|
|
134
|
+
lines.push(`技术栈: ${ctx.techStack.join(', ')}`);
|
|
135
|
+
if (ctx.topLevelStructure)
|
|
136
|
+
lines.push(`目录概览: ${ctx.topLevelStructure}`);
|
|
137
|
+
if (ctx.hasDatabase)
|
|
138
|
+
lines.push('特征: 项目包含数据库相关文件');
|
|
139
|
+
if (ctx.hasFinance)
|
|
140
|
+
lines.push('特征: 项目包含财务/金融相关文件');
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
// ===== 兜底提示 =====
|
|
144
|
+
/** 默认提示(LLM 不可用时的兜底) */
|
|
145
|
+
const FALLBACK_HINTS = {
|
|
146
|
+
jarvis: 'Try "create a util logging.py that..."',
|
|
147
|
+
codereviewer: 'Try "review this function for potential issues..."',
|
|
148
|
+
dba: 'Try "optimize this slow SQL query..."',
|
|
149
|
+
financeadvisor: 'Try "分析这份利润表的关键指标..."',
|
|
150
|
+
stocktrader: 'Try "分析一下这只股票的趋势走势..."',
|
|
151
|
+
};
|
|
152
|
+
const DEFAULT_HINT = 'Try "create a util logging.py that..."';
|
|
153
|
+
/** 获取角色对应的静态兜底提示(同步,可用于初始 placeholder) */
|
|
154
|
+
export function getFallbackHint(agentName) {
|
|
155
|
+
if (agentName) {
|
|
156
|
+
return FALLBACK_HINTS[agentName.toLowerCase()] ?? DEFAULT_HINT;
|
|
157
|
+
}
|
|
158
|
+
const agent = getAgent(DEFAULT_AGENT);
|
|
159
|
+
const name = agent?.meta.name ?? 'Jarvis';
|
|
160
|
+
return FALLBACK_HINTS[name.toLowerCase()] ?? DEFAULT_HINT;
|
|
161
|
+
}
|
|
162
|
+
// ===== 格式清理 =====
|
|
163
|
+
/** 将 LLM 返回的文本统一为 Try "..." 格式 */
|
|
164
|
+
function normalizeHint(raw) {
|
|
165
|
+
let hint = raw.trim();
|
|
166
|
+
// 已经是标准格式
|
|
167
|
+
if (/^Try\s+".*"$/.test(hint))
|
|
168
|
+
return hint;
|
|
169
|
+
// Try "... 但没闭合
|
|
170
|
+
if (/^Try\s+"/.test(hint))
|
|
171
|
+
return hint.endsWith('"') ? hint : hint + '"';
|
|
172
|
+
// 去掉可能的引号包裹
|
|
173
|
+
hint = hint.replace(/^["'「]|["'」]$/g, '').trim();
|
|
174
|
+
return `Try "${hint}"`;
|
|
175
|
+
}
|
|
176
|
+
// ===== 主函数 =====
|
|
177
|
+
/**
|
|
178
|
+
* 调用 LLM 生成一条符合当前角色 + 项目上下文的输入提示
|
|
179
|
+
*
|
|
180
|
+
* @returns 生成的提示文本,失败时返回静态兜底
|
|
181
|
+
*/
|
|
182
|
+
export async function generateAgentHint() {
|
|
183
|
+
const agent = getAgent(DEFAULT_AGENT);
|
|
184
|
+
const agentName = agent?.meta.name ?? 'Jarvis';
|
|
185
|
+
const fallback = getFallbackHint(agentName);
|
|
186
|
+
// 检查 LLM 是否可用
|
|
187
|
+
const config = loadConfig();
|
|
188
|
+
const activeModel = getActiveModel(config);
|
|
189
|
+
if (!activeModel || !activeModel.api_key) {
|
|
190
|
+
return fallback;
|
|
191
|
+
}
|
|
192
|
+
// 采集项目上下文
|
|
193
|
+
const ctx = collectProjectContext();
|
|
194
|
+
const contextBlock = formatContextForPrompt(ctx);
|
|
195
|
+
const llmConfig = getDefaultConfig();
|
|
196
|
+
const systemMsg = `你是一个提示词生成器。根据以下智能体角色信息和项目上下文,生成一条简短的示例输入提示,让用户知道可以问什么。
|
|
197
|
+
|
|
198
|
+
## 角色信息
|
|
199
|
+
角色名称: ${agentName}
|
|
200
|
+
角色描述: ${agent?.meta.description ?? '通用助手'}
|
|
201
|
+
角色氛围: ${agent?.meta.vibe ?? ''}
|
|
202
|
+
|
|
203
|
+
## 当前项目上下文
|
|
204
|
+
${contextBlock}
|
|
205
|
+
|
|
206
|
+
## 要求
|
|
207
|
+
1. 只输出一条提示文本,不要任何解释或前缀
|
|
208
|
+
2. 格式为: Try "具体的示例问题..."
|
|
209
|
+
3. 示例问题要具体、实用,体现角色的核心能力
|
|
210
|
+
4. 必须结合当前项目上下文(技术栈、项目类型等),让提示与用户正在做的事情相关
|
|
211
|
+
5. 长度控制在 50 个字符以内(Try "" 内的部分)
|
|
212
|
+
6. 语言与角色描述保持一致`;
|
|
213
|
+
// 非流式请求,关闭思考模式以加速响应
|
|
214
|
+
async function doRequest(timeoutMs) {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
217
|
+
try {
|
|
218
|
+
const url = llmConfig.baseUrl || 'https://api.openai.com/v1/chat/completions';
|
|
219
|
+
const requestBody = {
|
|
220
|
+
model: llmConfig.model,
|
|
221
|
+
messages: [
|
|
222
|
+
{ role: 'system', content: systemMsg },
|
|
223
|
+
{ role: 'user', content: '请生成一条输入提示。' },
|
|
224
|
+
],
|
|
225
|
+
max_tokens: 100,
|
|
226
|
+
temperature: 0.8,
|
|
227
|
+
stream: false,
|
|
228
|
+
// GLM-4.7 / GLM-5 关闭深度思考(Z.AI 官方参数)
|
|
229
|
+
thinking: { type: 'disabled' },
|
|
230
|
+
// Ollama 原生关闭 thinking 模式
|
|
231
|
+
think: false,
|
|
232
|
+
// OpenAI 兼容格式关闭 thinking(Qwen 等)
|
|
233
|
+
chat_template_kwargs: { enable_thinking: false },
|
|
234
|
+
};
|
|
235
|
+
const response = await fetch(url, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
Authorization: `Bearer ${llmConfig.apiKey}`,
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify(requestBody),
|
|
242
|
+
signal: controller.signal,
|
|
243
|
+
});
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const errBody = await response.text().catch(() => '');
|
|
247
|
+
console.error(`[hint] API 错误 ${response.status}: ${errBody.slice(0, 200)}`);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const data = await response.json();
|
|
251
|
+
// 兼容不同 API 的响应格式
|
|
252
|
+
const message = data.choices?.[0]?.message;
|
|
253
|
+
const text = message?.content?.trim() ||
|
|
254
|
+
// 部分模型(如 Ollama Gemma4 thinking 模式)内容在 reasoning 字段
|
|
255
|
+
message?.reasoning_content?.trim() ||
|
|
256
|
+
message?.reasoning?.trim() ||
|
|
257
|
+
data.result?.trim() ||
|
|
258
|
+
data.output?.text?.trim() ||
|
|
259
|
+
'';
|
|
260
|
+
if (!text) {
|
|
261
|
+
console.error('[hint] LLM 返回为空,原始响应:', JSON.stringify(data).slice(0, 300));
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
console.error(`[hint] 请求失败 (timeout=${timeoutMs}ms):`, err?.message ?? err);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// 本地模型推理慢,给更长超时;云端 API 保持短超时
|
|
273
|
+
const isLocal = /localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\./.test(llmConfig.baseUrl ?? '');
|
|
274
|
+
const timeoutMs = isLocal ? 30000 : 5000;
|
|
275
|
+
const text = await doRequest(timeoutMs);
|
|
276
|
+
if (!text)
|
|
277
|
+
return fallback;
|
|
278
|
+
return normalizeHint(text);
|
|
279
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Message, LoopState, Tool, LLMService, TranscriptMessage } from '../types/index.js';
|
|
2
|
+
/** 危险命令确认结果 */
|
|
3
|
+
export type DangerConfirmResult = 'once' | 'always' | 'cancel';
|
|
4
|
+
export interface QueryCallbacks {
|
|
5
|
+
onMessage: (msg: Message) => void;
|
|
6
|
+
onUpdateMessage: (id: string, updates: Partial<Message>) => void;
|
|
7
|
+
onStreamText: (text: string) => void;
|
|
8
|
+
/** 清空流式文本(每轮迭代结束时调用,避免 streamText 被工具消息挤到底部) */
|
|
9
|
+
onClearStreamText?: () => void;
|
|
10
|
+
onLoopStateChange: (state: LoopState) => void;
|
|
11
|
+
/**
|
|
12
|
+
* 危险命令确认回调 — 返回用户选择
|
|
13
|
+
* @param command 被拦截的命令
|
|
14
|
+
* @param reason 风险说明
|
|
15
|
+
* @param ruleName 匹配的规则名
|
|
16
|
+
*/
|
|
17
|
+
onConfirmDangerousCommand?: (command: string, reason: string, ruleName: string) => Promise<DangerConfirmResult>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 单轮 Agentic Loop:推理 → 工具调用 → 循环
|
|
21
|
+
*/
|
|
22
|
+
export declare function executeQuery(userInput: string, transcript: TranscriptMessage[], _tools: Tool[], service: LLMService, callbacks: QueryCallbacks, abortSignal: {
|
|
23
|
+
aborted: boolean;
|
|
24
|
+
}): Promise<TranscriptMessage[]>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid';
|
|
2
|
+
import { findToolMerged as findTool } from '../tools/index.js';
|
|
3
|
+
import { MAX_ITERATIONS } from '../config/constants.js';
|
|
4
|
+
import { sanitizeOutput, validateCommand, authorizeCommand, authorizeRule } from './safeguard.js';
|
|
5
|
+
/**
|
|
6
|
+
* 单轮 Agentic Loop:推理 → 工具调用 → 循环
|
|
7
|
+
*/
|
|
8
|
+
export async function executeQuery(userInput, transcript, _tools, service, callbacks, abortSignal) {
|
|
9
|
+
const localTranscript = [...transcript];
|
|
10
|
+
localTranscript.push({ role: 'user', content: userInput });
|
|
11
|
+
const loopState = {
|
|
12
|
+
iteration: 0,
|
|
13
|
+
maxIterations: MAX_ITERATIONS,
|
|
14
|
+
isRunning: true,
|
|
15
|
+
aborted: false,
|
|
16
|
+
};
|
|
17
|
+
callbacks.onLoopStateChange({ ...loopState });
|
|
18
|
+
while (loopState.iteration < MAX_ITERATIONS && !abortSignal.aborted) {
|
|
19
|
+
loopState.iteration++;
|
|
20
|
+
callbacks.onLoopStateChange({ ...loopState });
|
|
21
|
+
const result = await runOneIteration(localTranscript, _tools, service, callbacks, abortSignal);
|
|
22
|
+
// 添加推理消息
|
|
23
|
+
if (result.text) {
|
|
24
|
+
const blocks = [{ type: 'text', text: result.text }];
|
|
25
|
+
if (result.toolCall) {
|
|
26
|
+
blocks.push({
|
|
27
|
+
type: 'tool_use',
|
|
28
|
+
id: result.toolCall.id,
|
|
29
|
+
name: result.toolCall.name,
|
|
30
|
+
input: result.toolCall.input,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
localTranscript.push({ role: 'assistant', content: blocks });
|
|
34
|
+
}
|
|
35
|
+
else if (result.toolCall) {
|
|
36
|
+
localTranscript.push({
|
|
37
|
+
role: 'assistant',
|
|
38
|
+
content: [{
|
|
39
|
+
type: 'tool_use',
|
|
40
|
+
id: result.toolCall.id,
|
|
41
|
+
name: result.toolCall.name,
|
|
42
|
+
input: result.toolCall.input,
|
|
43
|
+
}],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// 无工具调用 → 结束
|
|
47
|
+
if (!result.toolCall)
|
|
48
|
+
break;
|
|
49
|
+
if (abortSignal.aborted)
|
|
50
|
+
break;
|
|
51
|
+
// 执行工具
|
|
52
|
+
const tc = result.toolCall;
|
|
53
|
+
const toolResult = await executeTool(tc, callbacks);
|
|
54
|
+
localTranscript.push({
|
|
55
|
+
role: 'tool_result',
|
|
56
|
+
toolUseId: tc.id,
|
|
57
|
+
content: toolResult.content,
|
|
58
|
+
});
|
|
59
|
+
if (toolResult.isError)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
loopState.isRunning = false;
|
|
63
|
+
loopState.aborted = abortSignal.aborted;
|
|
64
|
+
callbacks.onLoopStateChange({ ...loopState });
|
|
65
|
+
// 如果被用户中断,在 transcript 中追加中断标记,让 LLM 知道上轮回复未完成
|
|
66
|
+
if (abortSignal.aborted) {
|
|
67
|
+
localTranscript.push({
|
|
68
|
+
role: 'user',
|
|
69
|
+
content: '[系统提示] 用户中断了上一轮回复(按下 ESC)。上一条助手消息可能不完整,请在后续回复中注意这一点。',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return localTranscript;
|
|
73
|
+
}
|
|
74
|
+
/** 执行一次 LLM 调用 */
|
|
75
|
+
async function runOneIteration(transcript, tools, service, callbacks, abortSignal) {
|
|
76
|
+
const startTime = Date.now();
|
|
77
|
+
let accumulatedText = '';
|
|
78
|
+
let accumulatedThinking = '';
|
|
79
|
+
let toolCall = null;
|
|
80
|
+
let tokenCount = 0;
|
|
81
|
+
let firstTokenTime = null;
|
|
82
|
+
const thinkingId = uuid();
|
|
83
|
+
callbacks.onMessage({
|
|
84
|
+
id: thinkingId,
|
|
85
|
+
type: 'thinking',
|
|
86
|
+
status: 'pending',
|
|
87
|
+
content: '思考中...',
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
});
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
service
|
|
92
|
+
.streamMessage(transcript, tools, {
|
|
93
|
+
onThinking: (text) => {
|
|
94
|
+
if (abortSignal.aborted)
|
|
95
|
+
return;
|
|
96
|
+
accumulatedThinking += text;
|
|
97
|
+
// 实时更新 thinking 消息内容,让用户看到思考过程
|
|
98
|
+
callbacks.onUpdateMessage(thinkingId, { content: accumulatedThinking });
|
|
99
|
+
},
|
|
100
|
+
onText: (text) => {
|
|
101
|
+
if (abortSignal.aborted) {
|
|
102
|
+
resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (firstTokenTime === null) {
|
|
106
|
+
firstTokenTime = Date.now();
|
|
107
|
+
// 收到首 token,将 thinking 消息标记为完成(保留 think 内容)
|
|
108
|
+
callbacks.onUpdateMessage(thinkingId, {
|
|
109
|
+
status: 'success',
|
|
110
|
+
content: accumulatedThinking ? '思考完成' : '',
|
|
111
|
+
think: accumulatedThinking || undefined,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
tokenCount++;
|
|
115
|
+
accumulatedText += text;
|
|
116
|
+
callbacks.onStreamText(text);
|
|
117
|
+
},
|
|
118
|
+
onToolUse: (id, name, input) => {
|
|
119
|
+
if (abortSignal.aborted) {
|
|
120
|
+
resolve();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
toolCall = { id, name, input };
|
|
124
|
+
resolve();
|
|
125
|
+
},
|
|
126
|
+
onComplete: () => resolve(),
|
|
127
|
+
onError: (err) => reject(err),
|
|
128
|
+
}, abortSignal)
|
|
129
|
+
.catch(reject);
|
|
130
|
+
});
|
|
131
|
+
const duration = Date.now() - startTime;
|
|
132
|
+
const firstTokenLatency = firstTokenTime !== null ? firstTokenTime - startTime : 0;
|
|
133
|
+
const durationSec = duration / 1000;
|
|
134
|
+
const tokensPerSecond = durationSec > 0 ? tokenCount / durationSec : 0;
|
|
135
|
+
// 最终更新 thinking 消息状态
|
|
136
|
+
callbacks.onUpdateMessage(thinkingId, {
|
|
137
|
+
status: 'success',
|
|
138
|
+
content: accumulatedThinking ? '思考完成' : '',
|
|
139
|
+
think: accumulatedThinking || undefined,
|
|
140
|
+
duration,
|
|
141
|
+
});
|
|
142
|
+
if (accumulatedText) {
|
|
143
|
+
const isAborted = abortSignal.aborted;
|
|
144
|
+
// 先清空流式文本,再 push reasoning 消息,避免 streamText 被后续工具消息挤到底部
|
|
145
|
+
callbacks.onClearStreamText?.();
|
|
146
|
+
callbacks.onMessage({
|
|
147
|
+
id: uuid(),
|
|
148
|
+
type: 'reasoning',
|
|
149
|
+
status: isAborted ? 'aborted' : 'success',
|
|
150
|
+
content: accumulatedText,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
duration,
|
|
153
|
+
tokenCount,
|
|
154
|
+
firstTokenLatency,
|
|
155
|
+
tokensPerSecond,
|
|
156
|
+
...(isAborted ? { abortHint: '推理已中断(ESC)' } : {}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { text: accumulatedText, toolCall, duration, tokenCount, firstTokenLatency, tokensPerSecond };
|
|
160
|
+
}
|
|
161
|
+
/** 执行工具并返回结果 */
|
|
162
|
+
async function executeTool(tc, callbacks) {
|
|
163
|
+
const toolExecId = uuid();
|
|
164
|
+
// 对 Bash 工具使用更直观的显示格式
|
|
165
|
+
const displayContent = tc.name === 'Bash' && tc.input.command
|
|
166
|
+
? `Bash(${tc.input.command})`
|
|
167
|
+
: `调用工具: ${tc.name}`;
|
|
168
|
+
callbacks.onMessage({
|
|
169
|
+
id: toolExecId,
|
|
170
|
+
type: 'tool_exec',
|
|
171
|
+
status: 'pending',
|
|
172
|
+
content: displayContent,
|
|
173
|
+
timestamp: Date.now(),
|
|
174
|
+
toolName: tc.name,
|
|
175
|
+
toolArgs: tc.input,
|
|
176
|
+
});
|
|
177
|
+
// ===== 安全围栏:Bash 命令拦截 + 交互式确认 =====
|
|
178
|
+
if (tc.name === 'Bash' && tc.input.command) {
|
|
179
|
+
const command = tc.input.command;
|
|
180
|
+
const check = validateCommand(command);
|
|
181
|
+
if (!check.allowed) {
|
|
182
|
+
if (!check.canOverride) {
|
|
183
|
+
// critical 级别:直接禁止
|
|
184
|
+
const errMsg = `${check.reason}\n🚫 该命令已被永久禁止,无法通过授权绕过。\n命令: ${command}`;
|
|
185
|
+
callbacks.onUpdateMessage(toolExecId, { status: 'error', content: errMsg, toolResult: errMsg });
|
|
186
|
+
return { content: `错误: ${errMsg}`, isError: true };
|
|
187
|
+
}
|
|
188
|
+
// high 级别:弹出交互式确认
|
|
189
|
+
if (callbacks.onConfirmDangerousCommand) {
|
|
190
|
+
const ruleName = check.rule?.name ?? 'unknown';
|
|
191
|
+
const reason = check.reason ?? '未知风险';
|
|
192
|
+
const userChoice = await callbacks.onConfirmDangerousCommand(command, reason, ruleName);
|
|
193
|
+
if (userChoice === 'cancel') {
|
|
194
|
+
const cancelMsg = `⛔ 用户取消执行危险命令: ${command}`;
|
|
195
|
+
callbacks.onUpdateMessage(toolExecId, { status: 'error', content: cancelMsg, toolResult: cancelMsg });
|
|
196
|
+
return { content: cancelMsg, isError: true };
|
|
197
|
+
}
|
|
198
|
+
// 根据用户选择授权
|
|
199
|
+
if (userChoice === 'once') {
|
|
200
|
+
authorizeCommand(command, 'once', ruleName);
|
|
201
|
+
}
|
|
202
|
+
else if (userChoice === 'always') {
|
|
203
|
+
authorizeRule(ruleName, 'always');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// 没有确认回调,直接拒绝
|
|
208
|
+
const errMsg = `${check.reason}\n命令: ${command}`;
|
|
209
|
+
callbacks.onUpdateMessage(toolExecId, { status: 'error', content: errMsg, toolResult: errMsg });
|
|
210
|
+
return { content: `错误: ${errMsg}`, isError: true };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const tool = findTool(tc.name);
|
|
215
|
+
if (!tool) {
|
|
216
|
+
const errMsg = `未知工具: ${tc.name}`;
|
|
217
|
+
callbacks.onUpdateMessage(toolExecId, { status: 'error', content: errMsg });
|
|
218
|
+
return { content: errMsg, isError: true };
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const start = Date.now();
|
|
222
|
+
const result = await tool.execute(tc.input);
|
|
223
|
+
// 对工具输出统一脱敏
|
|
224
|
+
const safeResult = sanitizeOutput(result);
|
|
225
|
+
const doneContent = tc.name === 'Bash' && tc.input.command
|
|
226
|
+
? `Bash(${tc.input.command}) 执行完成`
|
|
227
|
+
: `工具 ${tc.name} 执行完成`;
|
|
228
|
+
callbacks.onUpdateMessage(toolExecId, {
|
|
229
|
+
status: 'success',
|
|
230
|
+
content: doneContent,
|
|
231
|
+
toolResult: safeResult,
|
|
232
|
+
duration: Date.now() - start,
|
|
233
|
+
});
|
|
234
|
+
return { content: safeResult, isError: false };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const errMsg = err.message || '工具执行失败';
|
|
238
|
+
callbacks.onUpdateMessage(toolExecId, {
|
|
239
|
+
status: 'error',
|
|
240
|
+
content: errMsg,
|
|
241
|
+
toolResult: errMsg,
|
|
242
|
+
});
|
|
243
|
+
return { content: `错误: ${errMsg}`, isError: false };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一安全围栏 — 危险命令拦截 + 敏感信息保护 + 双模式授权
|
|
3
|
+
*
|
|
4
|
+
* 授权模式:
|
|
5
|
+
* 1. 临时授权(once) — 仅当前会话有效,会话重置后失效
|
|
6
|
+
* 2. 持久授权(always)— 写入 ~/.jarvis/.permissions.json,跨会话永久生效
|
|
7
|
+
*
|
|
8
|
+
* critical 级别命令不可授权,high 级别支持两种授权模式。
|
|
9
|
+
*/
|
|
10
|
+
export interface DangerRule {
|
|
11
|
+
/** 规则名称(唯一标识) */
|
|
12
|
+
name: string;
|
|
13
|
+
/** 匹配正则 */
|
|
14
|
+
pattern: RegExp;
|
|
15
|
+
/** 风险等级: high 需要用户确认, critical 直接禁止 */
|
|
16
|
+
level: 'high' | 'critical';
|
|
17
|
+
/** 风险说明 */
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
/** 危险命令规则表 */
|
|
21
|
+
export declare const DANGER_RULES: DangerRule[];
|
|
22
|
+
export interface SensitivePattern {
|
|
23
|
+
name: string;
|
|
24
|
+
pattern: RegExp;
|
|
25
|
+
replacement: string;
|
|
26
|
+
}
|
|
27
|
+
/** 敏感信息匹配规则 — 用于输出脱敏 */
|
|
28
|
+
export declare const SENSITIVE_PATTERNS: SensitivePattern[];
|
|
29
|
+
/** 授权模式 */
|
|
30
|
+
export type AuthMode = 'once' | 'always';
|
|
31
|
+
/** 持久化授权记录 */
|
|
32
|
+
export interface PermissionEntry {
|
|
33
|
+
/** 授权的规则名称(对应 DangerRule.name) */
|
|
34
|
+
ruleName: string;
|
|
35
|
+
/** 授权的具体命令(可选,为空表示授权整个规则) */
|
|
36
|
+
command?: string;
|
|
37
|
+
/** 授权时间 */
|
|
38
|
+
grantedAt: string;
|
|
39
|
+
/** 备注 */
|
|
40
|
+
note?: string;
|
|
41
|
+
}
|
|
42
|
+
/** .permissions.json 文件结构 */
|
|
43
|
+
export interface PermissionsFile {
|
|
44
|
+
/** 文件说明 */
|
|
45
|
+
_comment: string;
|
|
46
|
+
/** 按规则名称授权(授权整类命令) */
|
|
47
|
+
rules: string[];
|
|
48
|
+
/** 按具体命令授权 */
|
|
49
|
+
commands: PermissionEntry[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 授权命令
|
|
53
|
+
* @param command 具体命令字符串
|
|
54
|
+
* @param mode 'once' 仅本次会话 | 'always' 持久化到文件
|
|
55
|
+
* @param ruleName 可选,关联的规则名称
|
|
56
|
+
*/
|
|
57
|
+
export declare function authorizeCommand(command: string, mode?: AuthMode, ruleName?: string): void;
|
|
58
|
+
/**
|
|
59
|
+
* 按规则名称授权(整类命令放行)
|
|
60
|
+
* @param ruleName 规则名称(对应 DangerRule.name)
|
|
61
|
+
* @param mode 'once' 仅本次会话 | 'always' 持久化到文件
|
|
62
|
+
*/
|
|
63
|
+
export declare function authorizeRule(ruleName: string, mode?: AuthMode): void;
|
|
64
|
+
/**
|
|
65
|
+
* 撤销持久化授权
|
|
66
|
+
* @param target 规则名称或具体命令
|
|
67
|
+
*/
|
|
68
|
+
export declare function revokeAuthorization(target: string): boolean;
|
|
69
|
+
/** 列出所有持久化授权 */
|
|
70
|
+
export declare function listPermanentAuthorizations(): PermissionsFile;
|
|
71
|
+
/** 检查命令是否已授权(临时 + 持久化) */
|
|
72
|
+
export declare function isAuthorized(command: string, ruleName?: string): boolean;
|
|
73
|
+
/** 清空会话级临时授权(会话重置时调用,不影响持久化授权) */
|
|
74
|
+
export declare function clearAuthorizations(): void;
|
|
75
|
+
export interface SafeguardResult {
|
|
76
|
+
/** 是否允许执行 */
|
|
77
|
+
allowed: boolean;
|
|
78
|
+
/** 拒绝原因 */
|
|
79
|
+
reason?: string;
|
|
80
|
+
/** 匹配到的规则 */
|
|
81
|
+
rule?: DangerRule;
|
|
82
|
+
/** 是否可通过用户确认放行 */
|
|
83
|
+
canOverride?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 校验命令是否安全
|
|
87
|
+
*/
|
|
88
|
+
export declare function validateCommand(command: string): SafeguardResult;
|
|
89
|
+
/**
|
|
90
|
+
* 对输出文本进行敏感信息脱敏
|
|
91
|
+
*/
|
|
92
|
+
export declare function sanitizeOutput(text: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* 检查文件写入内容是否包含硬编码敏感信息
|
|
95
|
+
*/
|
|
96
|
+
export declare function detectSensitiveContent(content: string): string[];
|