@ghenya/clinn 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Src/llm.js ADDED
@@ -0,0 +1,195 @@
1
+ const https = require("https");
2
+
3
+ class LLMClient {
4
+ constructor(config) {
5
+ this.apiKey = config.apiKey;
6
+ this.baseURL = config.baseURL;
7
+ this.model = config.model;
8
+ this.maxTokens = config.maxTokens;
9
+ this.temperature = config.temperature;
10
+ this.topP = config.topP;
11
+ this.totalPromptTokens = 0;
12
+ this.totalCompletionTokens = 0;
13
+ }
14
+
15
+ getUsage() {
16
+ return {
17
+ prompt: this.totalPromptTokens,
18
+ completion: this.totalCompletionTokens,
19
+ };
20
+ }
21
+
22
+ resetUsage() {
23
+ this.totalPromptTokens = 0;
24
+ this.totalCompletionTokens = 0;
25
+ }
26
+
27
+ async chat(messages, tools = null) {
28
+ const body = {
29
+ model: this.model,
30
+ messages,
31
+ max_tokens: this.maxTokens,
32
+ temperature: this.temperature,
33
+ top_p: this.topP,
34
+ stream: false,
35
+ };
36
+
37
+ if (tools && tools.length > 0) {
38
+ body.tools = tools;
39
+ body.tool_choice = "auto";
40
+ }
41
+
42
+ const result = await this._request("/chat/completions", JSON.stringify(body));
43
+ this._accumulateUsage(result.usage);
44
+ return result;
45
+ }
46
+
47
+ async chatStream(messages, onToken, tools = null) {
48
+ const body = {
49
+ model: this.model,
50
+ messages,
51
+ max_tokens: this.maxTokens,
52
+ temperature: this.temperature,
53
+ top_p: this.topP,
54
+ stream: true,
55
+ stream_options: { include_usage: true },
56
+ };
57
+
58
+ if (tools && tools.length > 0) {
59
+ body.tools = tools;
60
+ body.tool_choice = "auto";
61
+ }
62
+
63
+ return this._requestStream("/chat/completions", JSON.stringify(body), onToken);
64
+ }
65
+
66
+ _accumulateUsage(usage) {
67
+ if (!usage) return;
68
+ if (usage.prompt_tokens) this.totalPromptTokens += usage.prompt_tokens;
69
+ if (usage.completion_tokens) this.totalCompletionTokens += usage.completion_tokens;
70
+ }
71
+
72
+ _request(path, body) {
73
+ const url = new URL(path, this.baseURL);
74
+
75
+ const options = {
76
+ hostname: url.hostname,
77
+ port: url.port || 443,
78
+ path: url.pathname,
79
+ method: "POST",
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ Authorization: `Bearer ${this.apiKey}`,
83
+ },
84
+ };
85
+
86
+ return new Promise((resolve, reject) => {
87
+ const req = https.request(options, (res) => {
88
+ let data = "";
89
+ res.on("data", (chunk) => (data += chunk));
90
+ res.on("end", () => {
91
+ try {
92
+ const json = JSON.parse(data);
93
+ if (json.error) {
94
+ reject(new Error(json.error.message));
95
+ } else {
96
+ resolve(json);
97
+ }
98
+ } catch (e) {
99
+ reject(new Error(`parse error: ${data.slice(0, 200)}`));
100
+ }
101
+ });
102
+ });
103
+
104
+ req.on("error", reject);
105
+ req.write(body);
106
+ req.end();
107
+ });
108
+ }
109
+
110
+ _requestStream(path, body, onToken) {
111
+ const url = new URL(path, this.baseURL);
112
+
113
+ const options = {
114
+ hostname: url.hostname,
115
+ port: url.port || 443,
116
+ path: url.pathname,
117
+ method: "POST",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ Authorization: `Bearer ${this.apiKey}`,
121
+ },
122
+ };
123
+
124
+ return new Promise((resolve, reject) => {
125
+ const req = https.request(options, (res) => {
126
+ let buffer = "";
127
+ const toolCallsMap = {};
128
+ let content = "";
129
+ const self = this;
130
+
131
+ res.on("data", (chunk) => {
132
+ buffer += chunk.toString();
133
+ const lines = buffer.split("\n");
134
+ buffer = lines.pop() || "";
135
+
136
+ for (const line of lines) {
137
+ const trimmed = line.trim();
138
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
139
+ const jsonStr = trimmed.slice(6);
140
+ if (jsonStr === "[DONE]") continue;
141
+
142
+ try {
143
+ const json = JSON.parse(jsonStr);
144
+ const delta = json.choices?.[0]?.delta;
145
+
146
+ if (delta?.content) {
147
+ content += delta.content;
148
+ if (onToken) onToken(delta.content);
149
+ }
150
+
151
+ if (delta?.tool_calls) {
152
+ for (const tc of delta.tool_calls) {
153
+ const idx = tc.index;
154
+ if (!toolCallsMap[idx]) {
155
+ toolCallsMap[idx] = {
156
+ id: tc.id || "",
157
+ type: "function",
158
+ function: { name: "", arguments: "" },
159
+ };
160
+ }
161
+ if (tc.id) toolCallsMap[idx].id = tc.id;
162
+ if (tc.function?.name) toolCallsMap[idx].function.name += tc.function.name;
163
+ if (tc.function?.arguments) toolCallsMap[idx].function.arguments += tc.function.arguments;
164
+ }
165
+ }
166
+
167
+ if (json.usage) {
168
+ self._accumulateUsage(json.usage);
169
+ }
170
+ } catch (_) {}
171
+ }
172
+ });
173
+
174
+ res.on("end", () => {
175
+ const toolCalls = Object.keys(toolCallsMap)
176
+ .sort((a, b) => a - b)
177
+ .map((k) => toolCallsMap[k]);
178
+
179
+ resolve({
180
+ content: content || null,
181
+ toolCalls: toolCalls.length > 0 ? toolCalls : null,
182
+ });
183
+ });
184
+
185
+ res.on("error", reject);
186
+ });
187
+
188
+ req.on("error", reject);
189
+ req.write(body);
190
+ req.end();
191
+ });
192
+ }
193
+ }
194
+
195
+ module.exports = LLMClient;
@@ -0,0 +1,133 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ let _puppeteer = null;
5
+ let _chromePath = null;
6
+
7
+ function _getChromePath() {
8
+ if (_chromePath) return _chromePath;
9
+ const candidates = [
10
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
11
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
12
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
13
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
14
+ "/usr/bin/google-chrome",
15
+ "/usr/bin/chromium-browser",
16
+ "/usr/bin/chromium",
17
+ ];
18
+ for (const c of candidates) {
19
+ if (fs.existsSync(c)) { _chromePath = c; return c; }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function _getPuppeteer() {
25
+ if (_puppeteer) return _puppeteer;
26
+ try {
27
+ _puppeteer = require("puppeteer-core");
28
+ return _puppeteer;
29
+ } catch (_) {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ let _browserPromise = null;
35
+ let _browser = null;
36
+
37
+ async function _ensureBrowser() {
38
+ if (_browser && _browser.isConnected()) return _browser;
39
+ const pp = _getPuppeteer();
40
+ if (!pp) throw new Error("puppeteer-core 未安装, 请运行 npm install puppeteer-core");
41
+ const chromePath = _getChromePath();
42
+ if (!chromePath) throw new Error("未找到 Chrome/Chromium 浏览器");
43
+ if (!_browserPromise) {
44
+ _browserPromise = pp.launch({
45
+ executablePath: chromePath,
46
+ headless: "new",
47
+ args: [
48
+ "--no-sandbox",
49
+ "--disable-setuid-sandbox",
50
+ "--disable-dev-shm-usage",
51
+ "--disable-gpu",
52
+ "--disable-web-security",
53
+ "--disable-features=IsolateOrigins,site-per-process",
54
+ ],
55
+ });
56
+ }
57
+ _browser = await _browserPromise;
58
+ return _browser;
59
+ }
60
+
61
+ async function _extractText(page) {
62
+ return page.evaluate(() => {
63
+ document.querySelectorAll("script, style, noscript, iframe, nav, footer, header, aside").forEach((el) => el.remove());
64
+ return (document.body?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
65
+ });
66
+ }
67
+
68
+ const browsePageTool = {
69
+ name: "browse_page",
70
+ description: "用真实浏览器(Chrome无头模式)打开网页并提取文字内容. 能绕过反爬、执行JS渲染的SPA页面",
71
+ parameters: {
72
+ url: { type: "string", required: true, description: "要打开的URL" },
73
+ waitMs: { type: "number", required: false, description: "页面加载后额外等待毫秒, 默认2000" },
74
+ extractLinks: { type: "boolean", required: false, description: "是否同时提取链接, 默认false" },
75
+ },
76
+ execute: async ({ url, waitMs, extractLinks }) => {
77
+ const start = Date.now();
78
+ let page = null;
79
+ try {
80
+ const browser = await _ensureBrowser();
81
+ page = await browser.newPage();
82
+ await page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
83
+ await page.setViewport({ width: 1280, height: 800 });
84
+ await page.setRequestInterception(true);
85
+ const blockedTypes = new Set(["image", "media", "font", "stylesheet"]);
86
+ page.on("request", (req) => {
87
+ if (blockedTypes.has(req.resourceType())) req.abort();
88
+ else req.continue();
89
+ });
90
+
91
+ const wait = waitMs != null ? waitMs : 2000;
92
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
93
+ await new Promise((r) => setTimeout(r, wait));
94
+
95
+ const title = await page.title();
96
+ const text = await _extractText(page);
97
+ const ms = Date.now() - start;
98
+
99
+ let result = `[浏览器 ${ms}ms] ${title}\n\n${text.slice(0, 4000)}`;
100
+
101
+ if (extractLinks) {
102
+ const links = await page.evaluate(() =>
103
+ [...document.querySelectorAll("a[href]")].map((a) => a.href).filter((h) => h.startsWith("http"))
104
+ );
105
+ const unique = [...new Set(links)].slice(0, 30);
106
+ result += `\n\n[链接 ${unique.length} 个]\n${unique.join("\n")}`;
107
+ }
108
+
109
+ await page.close();
110
+ return result;
111
+ } catch (e) {
112
+ if (page) await page.close().catch(() => {});
113
+ return `[浏览器失败 ${Date.now() - start}ms] ${e.message}`;
114
+ }
115
+ },
116
+ };
117
+
118
+ const browsePageTextTool = {
119
+ name: "browse_page_text",
120
+ description: "用浏览器打开网页, 只提取主体文字(自动过滤导航/页脚/脚本), 返回干净文本",
121
+ parameters: {
122
+ url: { type: "string", required: true, description: "要打开的URL" },
123
+ waitMs: { type: "number", required: false, description: "额外等待毫秒, 默认2000" },
124
+ },
125
+ execute: async ({ url, waitMs }) => {
126
+ return browsePageTool.execute({ url, waitMs, extractLinks: false });
127
+ },
128
+ };
129
+
130
+ module.exports = {
131
+ browse_page: browsePageTool,
132
+ browse_page_text: browsePageTextTool,
133
+ };
File without changes
@@ -0,0 +1,93 @@
1
+ const fs = require("fs");
2
+
3
+ const editLinesTool = {
4
+ name: "edit_lines",
5
+ description: "按行号编辑文件: insert_before/insert_after/replace/delete",
6
+ dangerous: true,
7
+ parameters: {
8
+ filePath: { type: "string", required: true, description: "文件路径" },
9
+ operation: { type: "string", required: true, description: "insert_before/insert_after/replace/delete" },
10
+ lineNumber: { type: "number", required: true, description: "目标行号(从1开始)" },
11
+ content: { type: "string", required: false, description: "新内容(insert/replace时必填, 支持\\n多行)" },
12
+ count: { type: "number", required: false, description: "delete时删除的行数, 默认1" },
13
+ },
14
+ execute: async ({ filePath, operation, lineNumber, content, count }) => {
15
+ if (!fs.existsSync(filePath)) return `[不存在] ${filePath}`;
16
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
17
+ const idx = lineNumber - 1;
18
+ if (idx < 0 || idx > lines.length) return `[越界] 行号 ${lineNumber}, 文件共 ${lines.length} 行`;
19
+
20
+ switch (operation) {
21
+ case "insert_before": lines.splice(idx, 0, ...(content || "").split("\n")); break;
22
+ case "insert_after": lines.splice(idx + 1, 0, ...(content || "").split("\n")); break;
23
+ case "replace": lines.splice(idx, 1, ...(content || "").split("\n")); break;
24
+ case "delete": lines.splice(idx, count || 1); break;
25
+ default: return `[错误] 未知操作: ${operation}, 可用: insert_before/insert_after/replace/delete`;
26
+ }
27
+ fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
28
+ return `[OK] ${operation} 行${lineNumber}, 文件现共 ${lines.length} 行`;
29
+ },
30
+ };
31
+
32
+ const readLinesTool = {
33
+ name: "read_lines",
34
+ description: "读取文件指定行范围, 带行号",
35
+ parameters: {
36
+ filePath: { type: "string", required: true, description: "文件路径" },
37
+ startLine: { type: "number", required: true, description: "起始行号(从1开始)" },
38
+ endLine: { type: "number", required: false, description: "结束行号, 默认到文件末尾" },
39
+ },
40
+ execute: async ({ filePath, startLine, endLine }) => {
41
+ if (!fs.existsSync(filePath)) return `[不存在] ${filePath}`;
42
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
43
+ const s = Math.max(1, startLine) - 1;
44
+ const e = endLine ? Math.min(lines.length, endLine) : lines.length;
45
+ if (s >= lines.length) return `[越界] 起始 ${startLine}, 共 ${lines.length} 行`;
46
+ return lines.slice(s, e).map((l, i) => `${s + i + 1}| ${l}`).join("\n");
47
+ },
48
+ };
49
+
50
+ const searchInRangeTool = {
51
+ name: "search_in_range",
52
+ description: "在指定文件的行范围内搜索关键词, 返回匹配行+行号. 行列级别的精确搜索",
53
+ parameters: {
54
+ filePath: { type: "string", required: true, description: "文件路径" },
55
+ pattern: { type: "string", required: true, description: "搜索关键词或正则" },
56
+ startLine: { type: "number", required: false, description: "起始行号, 默认1" },
57
+ endLine: { type: "number", required: false, description: "结束行号, 默认文件末尾" },
58
+ ignoreCase: { type: "boolean", required: false, description: "忽略大小写, 默认true" },
59
+ maxResults: { type: "number", required: false, description: "最大结果, 默认30" },
60
+ },
61
+ execute: async ({ filePath, pattern, startLine, endLine, ignoreCase, maxResults }) => {
62
+ if (!fs.existsSync(filePath)) return `[不存在] ${filePath}`;
63
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
64
+ const s = Math.max(1, (startLine || 1)) - 1;
65
+ const e = endLine ? Math.min(lines.length, endLine) : lines.length;
66
+ if (s >= lines.length) return `[越界] 起始 ${startLine || 1}, 共 ${lines.length} 行`;
67
+
68
+ const max = maxResults || 30;
69
+ const ic = ignoreCase !== false;
70
+ const target = ic ? pattern.toLowerCase() : pattern;
71
+ const results = [];
72
+
73
+ for (let i = s; i < e && results.length < max; i++) {
74
+ const line = lines[i];
75
+ const cmp = ic ? line.toLowerCase() : line;
76
+ if (cmp.includes(target)) {
77
+ const col = cmp.indexOf(target) + 1;
78
+ results.push(`${i + 1}:${col}| ${line}`);
79
+ }
80
+ }
81
+
82
+ if (results.length === 0) {
83
+ return `[无匹配] "${pattern}" 在 ${filePath} 第${s + 1}-${e}行 (共${e - s}行)`;
84
+ }
85
+ return `[${results.length} 处匹配 行${s + 1}-${e}]\n${results.join("\n")}`;
86
+ },
87
+ };
88
+
89
+ module.exports = {
90
+ edit_lines: editLinesTool,
91
+ read_lines: readLinesTool,
92
+ search_in_range: searchInRangeTool,
93
+ };