deel-local-cli 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +246 -0
- package/README.md +322 -0
- package/bin/deel.js +94 -0
- package/package.json +52 -0
- package/src/agent/loop.js +125 -0
- package/src/agent/session.js +136 -0
- package/src/backend/adapter.js +173 -0
- package/src/backend/detect.js +75 -0
- package/src/backend/http.js +60 -0
- package/src/backend/probe.js +355 -0
- package/src/commands.js +327 -0
- package/src/config.js +61 -0
- package/src/repl.js +222 -0
- package/src/report.js +112 -0
- package/src/safety/audit.js +40 -0
- package/src/safety/guard.js +56 -0
- package/src/safety/undo.js +78 -0
- package/src/setup.js +172 -0
- package/src/skills/discover.js +219 -0
- package/src/tools/edit-match.js +146 -0
- package/src/tools/fsutil.js +90 -0
- package/src/tools/index.js +317 -0
- package/src/ui/ansi.js +75 -0
- package/src/ui/prompt.js +76 -0
- package/src/ui/spinner.js +32 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deel-local-cli",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"deel": "bin/deel.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.en.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"cli",
|
|
22
|
+
"coding-agent",
|
|
23
|
+
"local-llm",
|
|
24
|
+
"ollama",
|
|
25
|
+
"lm-studio",
|
|
26
|
+
"openai-compatible",
|
|
27
|
+
"offline",
|
|
28
|
+
"air-gapped",
|
|
29
|
+
"zero-dependency",
|
|
30
|
+
"agent"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/jysvai/deel-local-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/jysvai/deel-local-cli/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/jysvai/deel-local-cli#readme",
|
|
40
|
+
"scripts": {
|
|
41
|
+
"start": "node bin/deel.js",
|
|
42
|
+
"setup": "node bin/deel.js setup",
|
|
43
|
+
"diagnose": "node bin/deel.js diagnose",
|
|
44
|
+
"chat": "node bin/deel.js",
|
|
45
|
+
"test": "node test/smoke.js && node test/loop.test.js && node test/edit-bench.js",
|
|
46
|
+
"bench": "node test/edit-bench.js",
|
|
47
|
+
"demo": "node test/demo.js",
|
|
48
|
+
"check": "node --check bin/deel.js && node --check src/repl.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/backend/adapter.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/skills/discover.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/ui/ansi.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && echo OK"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {},
|
|
51
|
+
"devDependencies": {}
|
|
52
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
|
|
2
|
+
// 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
|
|
3
|
+
import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
|
|
4
|
+
import { toolSchemas, runTool, TOOLS } from '../tools/index.js';
|
|
5
|
+
import { isMutating } from '../safety/guard.js';
|
|
6
|
+
|
|
7
|
+
// think 값을 규격에 맞게. 'off' 는 사고를 끈다.
|
|
8
|
+
function thinkFor(conn, level) {
|
|
9
|
+
if (level === 'off') return conn.kind === 'ollama' ? false : undefined;
|
|
10
|
+
return level;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function* run(session, ctx, userText) {
|
|
14
|
+
session.push({ role: 'user', content: userText });
|
|
15
|
+
ctx.audit.turn(userText);
|
|
16
|
+
ctx.history.nextTurn();
|
|
17
|
+
|
|
18
|
+
const conn = session.conn;
|
|
19
|
+
const tools = toolSchemas(null, { hasSkills: (session.skills?.length ?? 0) > 0 });
|
|
20
|
+
const attempted = new Set(); // 같은 변경성 명령을 두 번 실행하지 않기 위한 기록
|
|
21
|
+
let steps = 0;
|
|
22
|
+
|
|
23
|
+
while (steps < session.maxSteps) {
|
|
24
|
+
steps++;
|
|
25
|
+
const opts = {
|
|
26
|
+
messages: session.wire(),
|
|
27
|
+
tools,
|
|
28
|
+
think: thinkFor(conn, session.think),
|
|
29
|
+
maxTokens: 4096,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let msg;
|
|
33
|
+
try {
|
|
34
|
+
if (conn.streaming) {
|
|
35
|
+
for await (const ev of chatStream(conn, opts)) {
|
|
36
|
+
if (ev.type === 'done') msg = ev.message;
|
|
37
|
+
else yield ev;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
yield { type: 'waiting' };
|
|
41
|
+
msg = await chat(conn, opts);
|
|
42
|
+
if (msg.thinking) yield { type: 'thinking', text: msg.thinking };
|
|
43
|
+
if (msg.content) yield { type: 'content', text: msg.content };
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
yield { type: 'error', text: err.message };
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
session.usage.in += msg.usage?.in ?? 0;
|
|
51
|
+
session.usage.out += msg.usage?.out ?? 0;
|
|
52
|
+
session.usage.calls++;
|
|
53
|
+
|
|
54
|
+
session.push(assistantMessage(conn.kind, msg));
|
|
55
|
+
|
|
56
|
+
if (!msg.toolCalls?.length) {
|
|
57
|
+
yield { type: 'done', steps, text: msg.content };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 도구를 순서대로 실행한다.
|
|
62
|
+
for (const call of msg.toolCalls) {
|
|
63
|
+
if (!TOOLS[call.name]) {
|
|
64
|
+
session.push(toolMessage(conn.kind, {
|
|
65
|
+
callId: call.id, name: call.name,
|
|
66
|
+
content: `모르는 도구입니다. 쓸 수 있는 것: ${Object.keys(TOOLS).join(', ')}`,
|
|
67
|
+
}));
|
|
68
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: '모르는 도구' } };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 변경성 명령은 실패해도 다시 실행하지 않는다 — 두 번 돌면 사고다.
|
|
73
|
+
if (call.name === 'Bash' && isMutating(call.args?.command)) {
|
|
74
|
+
const key = String(call.args.command).trim();
|
|
75
|
+
if (attempted.has(key)) {
|
|
76
|
+
const note = '같은 변경성 명령을 다시 실행하지 않습니다. 두 번 실행되면 사고가 납니다.';
|
|
77
|
+
session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
|
|
78
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: note } };
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
attempted.add(key);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
|
|
85
|
+
const needsOk = session.mode === 'strict'
|
|
86
|
+
? ['Write', 'Edit', 'Bash'].includes(call.name)
|
|
87
|
+
: session.mode === 'confirm'
|
|
88
|
+
? (call.name === 'Bash' && isMutating(call.args?.command))
|
|
89
|
+
: false;
|
|
90
|
+
if (needsOk && ctx.confirm) {
|
|
91
|
+
const ok = await ctx.confirm(call.name, call.args);
|
|
92
|
+
if (!ok) {
|
|
93
|
+
const note = '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.';
|
|
94
|
+
session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
|
|
95
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: '거부됨' } };
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
yield { type: 'tool_start', name: call.name, args: call.args };
|
|
101
|
+
const t0 = Date.now();
|
|
102
|
+
const result = await runTool(call.name, call.args, ctx);
|
|
103
|
+
const ms = Date.now() - t0;
|
|
104
|
+
session.usage.ms += ms;
|
|
105
|
+
|
|
106
|
+
if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
|
|
107
|
+
|
|
108
|
+
session.push(toolMessage(conn.kind, {
|
|
109
|
+
callId: call.id,
|
|
110
|
+
name: call.name,
|
|
111
|
+
content: result.error ? `오류: ${result.error}` : result.content ?? '',
|
|
112
|
+
}));
|
|
113
|
+
yield { type: 'tool', name: call.name, args: call.args, result, ms };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 컨텍스트가 차오르면 오래된 대화를 줄인다.
|
|
117
|
+
const b = session.breakdown();
|
|
118
|
+
if (b.used > b.total * 0.8) {
|
|
119
|
+
const dropped = session.trim();
|
|
120
|
+
if (dropped) yield { type: 'trimmed', dropped };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
yield { type: 'limit', steps };
|
|
125
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
|
|
6
|
+
// 한글은 글자당 약 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
|
|
7
|
+
export function estimateTokens(text) {
|
|
8
|
+
const s = String(text ?? '');
|
|
9
|
+
let cjk = 0;
|
|
10
|
+
for (const ch of s) {
|
|
11
|
+
const cp = ch.codePointAt(0);
|
|
12
|
+
if ((cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x4e00 && cp <= 0x9fff)) cjk++;
|
|
13
|
+
}
|
|
14
|
+
const rest = s.length - cjk;
|
|
15
|
+
return Math.ceil(cjk + rest / 3.6);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드를 읽고 고치는 도구다.
|
|
19
|
+
|
|
20
|
+
원칙:
|
|
21
|
+
- 추측하지 말고 도구로 확인한다. 파일을 고치기 전에는 반드시 Read 로 읽는다.
|
|
22
|
+
- Edit 의 old_string 은 공백과 들여쓰기까지 파일과 정확히 같아야 한다. 짧게 자르지 말고 앞뒤로 넉넉히 포함한다.
|
|
23
|
+
- 한 번에 하나씩 고치고, 고친 뒤에는 무엇을 왜 고쳤는지 한 줄로 말한다.
|
|
24
|
+
- 명령 실행이 필요하면 Bash 를 쓴다. 되돌릴 수 없는 명령은 막히니 다른 방법을 찾는다.
|
|
25
|
+
- 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.`;
|
|
26
|
+
|
|
27
|
+
export class Session {
|
|
28
|
+
constructor(conn, { root, mode = 'auto', think = 'medium', maxSteps = 24 } = {}) {
|
|
29
|
+
this.conn = conn;
|
|
30
|
+
this.root = root;
|
|
31
|
+
this.mode = mode;
|
|
32
|
+
this.think = think;
|
|
33
|
+
this.maxSteps = maxSteps;
|
|
34
|
+
this.messages = [];
|
|
35
|
+
this.filesRead = new Map(); // 경로 → 추정 토큰
|
|
36
|
+
this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
|
|
37
|
+
this.commands = [];
|
|
38
|
+
this.plugins = [];
|
|
39
|
+
this.maxSkillsListed = 40; // 프롬프트에 올릴 최대 개수
|
|
40
|
+
this.maxSkillDesc = 140; // 설명 한 줄 최대 길이
|
|
41
|
+
this.usage = { in: 0, out: 0, calls: 0, ms: 0 };
|
|
42
|
+
this.startedAt = Date.now();
|
|
43
|
+
this.rules = this.#loadRules();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#loadRules() {
|
|
47
|
+
for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
48
|
+
const p = join(this.root, name);
|
|
49
|
+
if (existsSync(p)) {
|
|
50
|
+
try { return { name, text: readFileSync(p, 'utf8').slice(0, 20000) }; } catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
systemPrompt() {
|
|
57
|
+
const parts = [BASE_RULES];
|
|
58
|
+
parts.push(`\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
|
|
59
|
+
if (this.rules) parts.push(`\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
|
|
60
|
+
const listed = this.listedSkills();
|
|
61
|
+
if (listed.length) {
|
|
62
|
+
parts.push(
|
|
63
|
+
'\n--- 쓸 수 있는 스킬 ---\n' +
|
|
64
|
+
'필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n' +
|
|
65
|
+
listed.map((s) => `- ${s.name}: ${s.description.slice(0, this.maxSkillDesc)}`).join('\n')
|
|
66
|
+
);
|
|
67
|
+
const rest = this.skills.filter((s) => s.enabled).length - listed.length;
|
|
68
|
+
if (rest > 0) parts.push(`(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
|
|
69
|
+
}
|
|
70
|
+
return parts.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 프롬프트에 실제로 올릴 스킬: 가까운 자리(프로젝트 > 사용자 > 플러그인) 순으로 상한까지.
|
|
74
|
+
listedSkills() {
|
|
75
|
+
const rank = { project: 0, user: 1, plugin: 2 };
|
|
76
|
+
return this.skills
|
|
77
|
+
.filter((s) => s.enabled)
|
|
78
|
+
.slice()
|
|
79
|
+
.sort((a, b) => (rank[a.source] ?? 3) - (rank[b.source] ?? 3))
|
|
80
|
+
.slice(0, this.maxSkillsListed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
push(msg) { this.messages.push(msg); return this; }
|
|
84
|
+
clear() { this.messages = []; this.filesRead.clear(); return this; }
|
|
85
|
+
|
|
86
|
+
noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
|
|
87
|
+
|
|
88
|
+
// 모델에 실제로 보낼 배열.
|
|
89
|
+
wire() {
|
|
90
|
+
return [{ role: 'system', content: this.systemPrompt() }, ...this.messages];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// /context 가 그릴 내역.
|
|
94
|
+
breakdown() {
|
|
95
|
+
const sys = estimateTokens(BASE_RULES) + estimateTokens(`작업 폴더: ${this.root}`);
|
|
96
|
+
const rules = this.rules ? estimateTokens(this.rules.text) : 0;
|
|
97
|
+
const listed = this.listedSkills();
|
|
98
|
+
const skills = listed.length
|
|
99
|
+
? estimateTokens(listed.map((s) => `${s.name}: ${s.description.slice(0, this.maxSkillDesc)}`).join('\n'))
|
|
100
|
+
: 0;
|
|
101
|
+
|
|
102
|
+
let history = 0;
|
|
103
|
+
let files = 0;
|
|
104
|
+
for (const m of this.messages) {
|
|
105
|
+
const t = estimateTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''))
|
|
106
|
+
+ estimateTokens(JSON.stringify(m.tool_calls ?? ''));
|
|
107
|
+
if (m.role === 'tool') files += t; else history += t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const rows = [
|
|
111
|
+
{ label: '시스템 프롬프트', n: sys },
|
|
112
|
+
{ label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
|
|
113
|
+
{ label: `스킬 목록 (${listed.length}/${this.skills.length}개)`, n: skills },
|
|
114
|
+
{ label: '대화 이력', n: history },
|
|
115
|
+
{ label: `도구 결과 (파일 ${this.filesRead.size}개)`, n: files },
|
|
116
|
+
];
|
|
117
|
+
const used = rows.reduce((a, r) => a + r.n, 0);
|
|
118
|
+
const total = this.conn.ctx ?? 32768;
|
|
119
|
+
return { rows, used, total, left: Math.max(0, total - used) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 오래된 대화를 잘라낸다. 앞의 2턴과 최근 절반만 남긴다.
|
|
123
|
+
trim() {
|
|
124
|
+
if (this.messages.length < 12) return 0;
|
|
125
|
+
const keepHead = 2;
|
|
126
|
+
const keepTail = Math.floor(this.messages.length / 2);
|
|
127
|
+
const dropped = this.messages.length - keepHead - keepTail;
|
|
128
|
+
if (dropped <= 0) return 0;
|
|
129
|
+
this.messages = [
|
|
130
|
+
...this.messages.slice(0, keepHead),
|
|
131
|
+
{ role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
|
|
132
|
+
...this.messages.slice(-keepTail),
|
|
133
|
+
];
|
|
134
|
+
return dropped;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
|
|
2
|
+
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
+
import { req, headersFor, serverMessage } from './http.js';
|
|
4
|
+
|
|
5
|
+
export function endpoint(shape) {
|
|
6
|
+
return shape === 'ollama' ? '/api/chat' : '/chat/completions';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096 }) {
|
|
10
|
+
if (shape === 'ollama') {
|
|
11
|
+
const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
|
|
12
|
+
if (tools?.length) body.tools = tools;
|
|
13
|
+
if (json) body.format = json;
|
|
14
|
+
if (think !== undefined) body.think = think;
|
|
15
|
+
return body;
|
|
16
|
+
}
|
|
17
|
+
const body = { model, messages, stream: !!stream, max_tokens: maxTokens };
|
|
18
|
+
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
|
|
19
|
+
if (json) {
|
|
20
|
+
body.response_format = { type: 'json_schema', json_schema: { name: 'out', schema: json, strict: true } };
|
|
21
|
+
}
|
|
22
|
+
if (think !== undefined && think !== false) body.reasoning_effort = think;
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractMessage(shape, json) {
|
|
27
|
+
if (shape === 'ollama') {
|
|
28
|
+
const m = json?.message ?? {};
|
|
29
|
+
return {
|
|
30
|
+
content: m.content ?? '',
|
|
31
|
+
thinking: m.thinking ?? '',
|
|
32
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
33
|
+
usage: { in: json?.prompt_eval_count ?? 0, out: json?.eval_count ?? 0 },
|
|
34
|
+
stopped: json?.done_reason ?? null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const m = json?.choices?.[0]?.message ?? {};
|
|
38
|
+
return {
|
|
39
|
+
content: m.content ?? '',
|
|
40
|
+
thinking: m.reasoning_content ?? '',
|
|
41
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
42
|
+
usage: { in: json?.usage?.prompt_tokens ?? 0, out: json?.usage?.completion_tokens ?? 0 },
|
|
43
|
+
stopped: json?.choices?.[0]?.finish_reason ?? null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 도구 호출을 한 가지 모양으로 맞춘다: { id, name, args(객체) }
|
|
48
|
+
function normalizeCalls(list) {
|
|
49
|
+
return list.map((tc, i) => {
|
|
50
|
+
const fn = tc.function ?? tc;
|
|
51
|
+
let args = fn.arguments ?? fn.args ?? {};
|
|
52
|
+
if (typeof args === 'string') {
|
|
53
|
+
try { args = JSON.parse(args); } catch { args = { _raw: args }; }
|
|
54
|
+
}
|
|
55
|
+
return { id: tc.id ?? `call_${i + 1}`, name: fn.name, args };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
|
|
60
|
+
export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
|
|
61
|
+
if (shape === 'ollama') {
|
|
62
|
+
const m = { role: 'assistant', content };
|
|
63
|
+
if (thinking) m.thinking = thinking;
|
|
64
|
+
if (toolCalls.length) m.tool_calls = toolCalls.map((t) => ({ function: { name: t.name, arguments: t.args } }));
|
|
65
|
+
return m;
|
|
66
|
+
}
|
|
67
|
+
const m = { role: 'assistant', content: content || null };
|
|
68
|
+
if (toolCalls.length) {
|
|
69
|
+
m.tool_calls = toolCalls.map((t) => ({
|
|
70
|
+
id: t.id, type: 'function',
|
|
71
|
+
function: { name: t.name, arguments: JSON.stringify(t.args) },
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
return m;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function toolMessage(shape, { callId, name, content }) {
|
|
78
|
+
return shape === 'ollama'
|
|
79
|
+
? { role: 'tool', tool_name: name, content: String(content) }
|
|
80
|
+
: { role: 'tool', tool_call_id: callId, content: String(content) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 한 번에 받기.
|
|
84
|
+
export async function chat(conn, opts) {
|
|
85
|
+
const body = buildBody(conn.kind, { model: conn.model, ...opts });
|
|
86
|
+
const r = await req(`${conn.base}${endpoint(conn.kind)}`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
89
|
+
body,
|
|
90
|
+
timeout: opts.timeout ?? 300000,
|
|
91
|
+
});
|
|
92
|
+
if (!r.ok) throw new Error(serverMessage(r));
|
|
93
|
+
return extractMessage(conn.kind, r.json);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 흘려 받기. { type:'thinking'|'content', text } 를 내보내고 마지막에 { type:'done', message } 를 준다.
|
|
97
|
+
export async function* chatStream(conn, opts) {
|
|
98
|
+
const body = buildBody(conn.kind, { model: conn.model, ...opts, stream: true });
|
|
99
|
+
const r = await req(`${conn.base}${endpoint(conn.kind)}`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
102
|
+
body,
|
|
103
|
+
timeout: opts.timeout ?? 300000,
|
|
104
|
+
stream: true,
|
|
105
|
+
});
|
|
106
|
+
if (!r.ok || !r.res?.body) throw new Error(r.error ?? `HTTP ${r.status}`);
|
|
107
|
+
|
|
108
|
+
const acc = { content: '', thinking: '', toolCalls: [], usage: { in: 0, out: 0 }, stopped: null };
|
|
109
|
+
const reader = r.res.body.getReader();
|
|
110
|
+
const dec = new TextDecoder();
|
|
111
|
+
let buf = '';
|
|
112
|
+
|
|
113
|
+
while (true) {
|
|
114
|
+
const { done, value } = await reader.read();
|
|
115
|
+
if (done) break;
|
|
116
|
+
buf += dec.decode(value, { stream: true });
|
|
117
|
+
|
|
118
|
+
// OpenAI 는 SSE(data: ...), Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
|
|
119
|
+
let nl;
|
|
120
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
121
|
+
const line = buf.slice(0, nl).trim();
|
|
122
|
+
buf = buf.slice(nl + 1);
|
|
123
|
+
if (!line) continue;
|
|
124
|
+
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
|
125
|
+
if (payload === '[DONE]') continue;
|
|
126
|
+
let obj;
|
|
127
|
+
try { obj = JSON.parse(payload); } catch { continue; }
|
|
128
|
+
for (const ev of absorb(conn.kind, obj, acc)) yield ev;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
yield { type: 'done', message: acc };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
135
|
+
function absorb(shape, obj, acc) {
|
|
136
|
+
const out = [];
|
|
137
|
+
if (shape === 'ollama') {
|
|
138
|
+
const m = obj.message ?? {};
|
|
139
|
+
if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
|
|
140
|
+
if (m.content) { acc.content += m.content; out.push({ type: 'content', text: m.content }); }
|
|
141
|
+
if (m.tool_calls?.length) acc.toolCalls.push(...normalizeCalls(m.tool_calls));
|
|
142
|
+
if (obj.done) {
|
|
143
|
+
acc.usage = { in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
|
|
144
|
+
acc.stopped = obj.done_reason ?? 'stop';
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
const d = obj.choices?.[0]?.delta ?? {};
|
|
149
|
+
if (d.reasoning_content) { acc.thinking += d.reasoning_content; out.push({ type: 'thinking', text: d.reasoning_content }); }
|
|
150
|
+
if (d.content) { acc.content += d.content; out.push({ type: 'content', text: d.content }); }
|
|
151
|
+
if (d.tool_calls?.length) mergeDeltaCalls(acc, d.tool_calls);
|
|
152
|
+
if (obj.usage) acc.usage = { in: obj.usage.prompt_tokens ?? 0, out: obj.usage.completion_tokens ?? 0 };
|
|
153
|
+
const fin = obj.choices?.[0]?.finish_reason;
|
|
154
|
+
if (fin) acc.stopped = fin;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
|
|
159
|
+
function mergeDeltaCalls(acc, deltas) {
|
|
160
|
+
acc._raw ??= [];
|
|
161
|
+
for (const d of deltas) {
|
|
162
|
+
const i = d.index ?? 0;
|
|
163
|
+
acc._raw[i] ??= { id: d.id, name: '', args: '' };
|
|
164
|
+
if (d.id) acc._raw[i].id = d.id;
|
|
165
|
+
if (d.function?.name) acc._raw[i].name += d.function.name;
|
|
166
|
+
if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
|
|
167
|
+
}
|
|
168
|
+
acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
|
|
169
|
+
let args = {};
|
|
170
|
+
try { args = c.args ? JSON.parse(c.args) : {}; } catch { args = { _raw: c.args }; }
|
|
171
|
+
return { id: c.id ?? `call_${i + 1}`, name: c.name, args };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 주소만 받아서 "이 서버가 무슨 규격이고 인증을 어떻게 받는지" 알아낸다.
|
|
2
|
+
import { req, headersFor, AUTH_STYLES, serverMessage } from './http.js';
|
|
3
|
+
|
|
4
|
+
// 사람이 대충 적은 주소를 시도해볼 후보들로 넓힌다.
|
|
5
|
+
export function candidates(input) {
|
|
6
|
+
let u = String(input).trim().replace(/\s+/g, '');
|
|
7
|
+
if (!/^https?:\/\//i.test(u)) u = 'http://' + u;
|
|
8
|
+
u = u.replace(/\/+$/, '');
|
|
9
|
+
const out = [];
|
|
10
|
+
const push = (x) => { if (x && !out.includes(x)) out.push(x); };
|
|
11
|
+
|
|
12
|
+
if (/\/v\d+$/.test(u)) push(u); // .../v1 을 직접 준 경우
|
|
13
|
+
else { push(u + '/v1'); push(u); push(u + '/openai/v1'); }
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// OpenAI 호환인지 확인 (GET {base}/models)
|
|
18
|
+
async function tryOpenAI(base, key) {
|
|
19
|
+
for (const style of AUTH_STYLES) {
|
|
20
|
+
if (style.id !== 'none' && !key) continue;
|
|
21
|
+
if (style.id === 'none' && key) { /* 키를 줬어도 인증 없는 서버일 수 있으니 마지막에 본다 */ }
|
|
22
|
+
const r = await req(`${base}/models`, { headers: headersFor(style.id, key), timeout: 12000 });
|
|
23
|
+
if (r.ok && r.json) {
|
|
24
|
+
const list = r.json.data ?? r.json.models ?? [];
|
|
25
|
+
if (Array.isArray(list)) {
|
|
26
|
+
return { kind: 'openai', base, auth: style.id, models: normalizeModels(list), ms: r.ms };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 401/403 이면 규격은 맞고 인증만 틀린 것 — 다음 방식으로 계속.
|
|
30
|
+
if (r.status && ![401, 403, 0, 404].includes(r.status)) {
|
|
31
|
+
return { kind: 'openai', base, auth: style.id, models: [], ms: r.ms, warn: serverMessage(r) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Ollama 자체 규격인지 확인
|
|
38
|
+
async function tryOllama(origin) {
|
|
39
|
+
const v = await req(`${origin}/api/version`, { timeout: 8000 });
|
|
40
|
+
if (!v.ok || !v.json?.version) return null;
|
|
41
|
+
const tags = await req(`${origin}/api/tags`, { timeout: 12000 });
|
|
42
|
+
const models = (tags.json?.models ?? []).map((m) => ({
|
|
43
|
+
id: m.name ?? m.model,
|
|
44
|
+
note: m.details?.parameter_size ? `${m.details.parameter_size} · ${fmtSize(m.size)}` : fmtSize(m.size),
|
|
45
|
+
}));
|
|
46
|
+
return { kind: 'ollama', base: origin, auth: 'none', models, version: v.json.version, ms: v.ms };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fmtSize(bytes) {
|
|
50
|
+
if (!bytes) return '';
|
|
51
|
+
const gb = bytes / 1024 ** 3;
|
|
52
|
+
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${Math.round(bytes / 1024 ** 2)}MB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeModels(list) {
|
|
56
|
+
return list
|
|
57
|
+
.map((m) => (typeof m === 'string' ? { id: m } : { id: m.id ?? m.name ?? m.model, note: m.owned_by ?? '' }))
|
|
58
|
+
.filter((m) => m.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function detect(input, key) {
|
|
62
|
+
const tried = [];
|
|
63
|
+
const origin = String(input).trim().replace(/\/+$/, '').replace(/\/v\d+$/, '');
|
|
64
|
+
|
|
65
|
+
// Ollama 를 먼저 본다 — 로컬이면 대개 이쪽이고 확인이 빠르다.
|
|
66
|
+
const oll = await tryOllama(/^https?:\/\//i.test(origin) ? origin : 'http://' + origin);
|
|
67
|
+
if (oll) return { ...oll, tried };
|
|
68
|
+
|
|
69
|
+
for (const base of candidates(input)) {
|
|
70
|
+
tried.push(base);
|
|
71
|
+
const hit = await tryOpenAI(base, key);
|
|
72
|
+
if (hit) return { ...hit, tried };
|
|
73
|
+
}
|
|
74
|
+
return { kind: null, tried };
|
|
75
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// HTTP 한 겹. 시간 제한과 오류 정규화만 담당한다.
|
|
2
|
+
|
|
3
|
+
export const AUTH_STYLES = [
|
|
4
|
+
{ id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
|
|
5
|
+
{ id: 'x-api-key', label: 'x-api-key', apply: (h, k) => { h['x-api-key'] = k; } },
|
|
6
|
+
{ id: 'api-key', label: 'api-key (Azure 계열)', apply: (h, k) => { h['api-key'] = k; } },
|
|
7
|
+
{ id: 'none', label: '인증 없음', apply: () => {} },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function headersFor(authStyle, key, extra = {}) {
|
|
11
|
+
const h = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra };
|
|
12
|
+
const style = AUTH_STYLES.find((s) => s.id === authStyle) ?? AUTH_STYLES[0];
|
|
13
|
+
if (key) style.apply(h, key);
|
|
14
|
+
return h;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false } = {}) {
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
method,
|
|
22
|
+
headers,
|
|
23
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.timeout(timeout),
|
|
25
|
+
redirect: 'follow',
|
|
26
|
+
});
|
|
27
|
+
const ms = Date.now() - started;
|
|
28
|
+
if (stream) return { ok: res.ok, status: res.status, res, ms };
|
|
29
|
+
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let json = null;
|
|
32
|
+
try { json = JSON.parse(text); } catch {}
|
|
33
|
+
return { ok: res.ok, status: res.status, json, text, ms, headers: res.headers };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const ms = Date.now() - started;
|
|
36
|
+
return { ok: false, status: 0, error: normalizeError(err), ms };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeError(err) {
|
|
41
|
+
const m = String(err?.message ?? err);
|
|
42
|
+
if (err?.name === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
|
|
43
|
+
if (/ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 수 없습니다 (DNS)';
|
|
44
|
+
if (/ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
|
|
45
|
+
if (/ECONNRESET/i.test(m)) return '연결이 끊겼습니다';
|
|
46
|
+
if (/certificate|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(m)) return '인증서 문제 — 사내 인증서라면 NODE_EXTRA_CA_CERTS 가 필요합니다';
|
|
47
|
+
if (/fetch failed/i.test(m)) return '연결 실패 — 주소·포트·프록시를 확인하세요';
|
|
48
|
+
return m;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 서버가 준 오류 본문에서 사람이 읽을 문장만 뽑는다.
|
|
52
|
+
export function serverMessage(r) {
|
|
53
|
+
if (r.error) return r.error;
|
|
54
|
+
const j = r.json;
|
|
55
|
+
const cand = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail;
|
|
56
|
+
if (typeof cand === 'string') return cand;
|
|
57
|
+
if (cand) return JSON.stringify(cand).slice(0, 200);
|
|
58
|
+
if (r.text) return String(r.text).replace(/\s+/g, ' ').slice(0, 200);
|
|
59
|
+
return `HTTP ${r.status}`;
|
|
60
|
+
}
|