deel-local-cli 0.5.0 → 0.9.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.en.md +738 -132
- package/README.md +742 -147
- package/bin/deel.js +96 -4
- package/package.json +4 -3
- package/src/agent/compact.js +138 -0
- package/src/agent/effort.js +139 -0
- package/src/agent/loop.js +186 -60
- package/src/agent/modes.js +252 -0
- package/src/agent/route.js +156 -0
- package/src/agent/session.js +28 -3
- package/src/agent/sessionui.js +59 -0
- package/src/agent/store.js +193 -0
- package/src/backend/adapter.js +12 -2
- package/src/backend/ctxsize.js +174 -0
- package/src/backend/http.js +44 -3
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +256 -0
- package/src/backend/scanui.js +147 -0
- package/src/commands.js +658 -33
- package/src/config.js +19 -6
- package/src/pack/selfpack.js +248 -0
- package/src/pack/tar.js +69 -0
- package/src/pack/zip.js +217 -0
- package/src/plugins/manage.js +272 -0
- package/src/repl.js +297 -40
- package/src/report.js +1 -1
- package/src/safety/network.js +95 -0
- package/src/safety/undo.js +46 -1
- package/src/setup.js +4 -0
- package/src/tools/encoding.js +333 -0
- package/src/tools/excel-com.js +254 -0
- package/src/tools/excel.js +118 -0
- package/src/tools/fsutil.js +47 -4
- package/src/tools/index.js +128 -14
- package/src/tools/todo.js +92 -0
- package/src/tools/webfetch.js +110 -0
- package/src/tools/xlsx.js +319 -0
- package/src/ui/ansi.js +97 -5
- package/src/ui/level.js +106 -0
- package/src/ui/prompt.js +34 -0
- package/src/ui/status.js +161 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 사용자가 무엇을 시켰는지 보고 알맞은 작업 모드를 고른다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 규칙으로 하나:
|
|
4
|
+
// 모델에게 "이건 무슨 일이냐" 고 한 번 더 물을 수도 있다. 그런데 그러면
|
|
5
|
+
// 한마디 할 때마다 왕복이 하나 늘고, 작은 로컬 모델은 이런 분류를 잘 못한다.
|
|
6
|
+
// 느려지고 틀리기까지 하면 자동으로 하는 의미가 없다.
|
|
7
|
+
//
|
|
8
|
+
// 그래서 낱말로 고른다. 대신 두 가지를 지킨다.
|
|
9
|
+
// 1) 애매하면 안 바꾼다. 종합 모드에 그대로 둔다.
|
|
10
|
+
// 잘못 바꾸는 것보다 안 바꾸는 편이 낫다 — 특히 읽기 전용 모드로
|
|
11
|
+
// 잘못 보내면 사용자는 "왜 파일을 안 고쳐?" 하고 막힌다.
|
|
12
|
+
// 2) 왜 그렇게 봤는지 남긴다. 화면에 그 이유가 같이 뜬다.
|
|
13
|
+
//
|
|
14
|
+
// 사용자가 직접 모드를 고르면 이건 아예 안 돈다. 사람이 고른 것을 뒤집지 않는다.
|
|
15
|
+
|
|
16
|
+
// 낱말과 점수. 큰 점수일수록 그 모드라는 증거가 강하다는 뜻이다.
|
|
17
|
+
//
|
|
18
|
+
// 한국어를 먼저 두되 영어도 같이 본다 — 오류 메시지를 그대로 붙여넣는 일이 흔하다.
|
|
19
|
+
const 표 = {
|
|
20
|
+
debug: [
|
|
21
|
+
// 고장은 신호가 뚜렷하다. 그리고 디버그는 파일을 고칠 수 있으므로
|
|
22
|
+
// 잘못 골라도 막히지 않는다. 그래서 세게 준다.
|
|
23
|
+
[/왜\s*안\s*(되|돼|됨)/, 5], [/안\s*(되는|돼는|됩니다|되네|돼네|돼요|되요)/, 4],
|
|
24
|
+
[/작동\s*안/, 4], [/동작\s*안/, 4],
|
|
25
|
+
[/에러|오류|예외|익셉션/, 4], [/버그/, 4],
|
|
26
|
+
[/실패/, 3], [/터(져|졌|진다)/, 3], [/죽(어|었|는다|네)/, 3],
|
|
27
|
+
[/멈(춰|췄|춘다|추)/, 3], [/먹통/, 3], [/이상(해|하다|한데)/, 3],
|
|
28
|
+
[/재현/, 3], [/스택\s*트레이스|stack\s*trace|traceback/i, 4],
|
|
29
|
+
// 'TypeError' 처럼 앞에 글자가 붙어 오는 일이 훨씬 흔하다.
|
|
30
|
+
// \berror\b 로 잡으면 정작 붙여넣은 진짜 오류 메시지를 놓친다.
|
|
31
|
+
[/error/i, 3], [/\bexception\b|\bcrash|\bfail(s|ed|ing)?\b|\bbroken\b/i, 3],
|
|
32
|
+
[/고장/, 3], [/왜\s*이(래|러|렇게\s*나)/, 2],
|
|
33
|
+
[/왜\s*(못|안)/, 3], [/뭐\s*때문(이|에)/, 3],
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
plan: [
|
|
37
|
+
// '먼저' 와 붙어야 계획이다. 그냥 '어떻게' 는 설명일 수도 있다.
|
|
38
|
+
[/계획/, 5], [/플랜|\bplan\b/i, 5],
|
|
39
|
+
[/먼저\s*(정리|생각|살펴|파악|검토|짜|세)/, 4],
|
|
40
|
+
[/어떤\s*순서/, 4], [/순서(를|대로)?\s*(정|짜|잡)/, 3],
|
|
41
|
+
[/단계(를|로)?\s*(나눠|쪼개|정리)/, 3],
|
|
42
|
+
[/로드맵|\broadmap\b/i, 5],
|
|
43
|
+
[/방향(을|만)?\s*(잡|정)/, 3],
|
|
44
|
+
[/하기\s*전에\s*(정리|살펴|검토)/, 3],
|
|
45
|
+
[/무엇부터|뭐부터/, 3],
|
|
46
|
+
],
|
|
47
|
+
|
|
48
|
+
architect: [
|
|
49
|
+
[/설계/, 5], [/아키텍(처|쳐)|architecture/i, 5],
|
|
50
|
+
[/구조(를|가|는)?\s*(어떻게|바꾸|잡|짜|개선)/, 5],
|
|
51
|
+
[/어떻게\s*(나누|쪼개|짜)/, 5],
|
|
52
|
+
[/모듈(로|을)?\s*(나누|분리)/, 3],
|
|
53
|
+
[/의존(성|\s*방향)/, 3], [/레이어|계층/, 3],
|
|
54
|
+
[/어느\s*(쪽|것)이\s*(나을|좋을)/, 3], [/선택지|대안/, 3],
|
|
55
|
+
[/패턴(을|이)?\s*(쓰|적용|고르)/, 2],
|
|
56
|
+
[/\bdesign\b|\bstructure\b|\brefactor(ing)?\s*(방향|plan)/i, 3],
|
|
57
|
+
],
|
|
58
|
+
|
|
59
|
+
ask: [
|
|
60
|
+
// 설명은 아무것도 안 바꾸는 모드라 잘못 고르면 답답해진다. 신호를 엄격히 본다.
|
|
61
|
+
[/뭐(야|예요|입니까|지)\s*\??$/m, 5], [/무엇(인가|입니까|이야)/, 5],
|
|
62
|
+
[/설명(해|좀|을)/, 5], [/알려\s*(줘|주세요|줄래)/, 3],
|
|
63
|
+
[/어떻게\s*(동작|작동|돌아가)/, 5],
|
|
64
|
+
[/무슨\s*(뜻|의미)|의미(가|는)\s*뭐/, 5],
|
|
65
|
+
[/차이(가|는|점)/, 3],
|
|
66
|
+
[/궁금/, 3], [/왜\s*이렇게\s*(만들|짰|되어)/, 3],
|
|
67
|
+
[/\bwhat\s+is\b|\bexplain\b|\bhow\s+does\b/i, 4],
|
|
68
|
+
[/읽어(만)?\s*보고\s*(설명|알려)/, 4],
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
orchestrator: [
|
|
72
|
+
// 총괄의 신호는 낱말 하나가 아니라 '넓이' 다. 그래서 하나하나는 약하게 두고
|
|
73
|
+
// 여럿이 겹칠 때 문턱을 넘게 한다 — 실제로 큰 일에는 이런 말이 같이 나온다.
|
|
74
|
+
// "전체 로그 형식을 다 통일해줘" 는 '전체' 와 '통일' 이 겹쳐야 총괄이 된다.
|
|
75
|
+
[/전부|모두|싹\s*다|통째로/, 3],
|
|
76
|
+
[/전체/, 3],
|
|
77
|
+
[/하나씩/, 3],
|
|
78
|
+
[/끝까지/, 3], [/마무리까지/, 3],
|
|
79
|
+
[/여러\s*(개|군데|파일|단계|곳)/, 3],
|
|
80
|
+
[/순서대로/, 3],
|
|
81
|
+
[/통일(해|시켜|좀)|일괄/, 2],
|
|
82
|
+
[/흩어진|여기저기/, 2],
|
|
83
|
+
[/\bend[- ]?to[- ]?end\b/i, 3],
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
code: [
|
|
87
|
+
[/고쳐|고쳐줘|수정(해|좀)/, 4],
|
|
88
|
+
[/만들어|추가(해|좀)|넣어(줘|주세요)/, 4],
|
|
89
|
+
[/구현(해|좀)/, 5],
|
|
90
|
+
[/바꿔(줘|주세요)?|변경(해|좀)/, 3],
|
|
91
|
+
[/지워(줘)?|삭제(해|좀)/, 3],
|
|
92
|
+
[/이름\s*바꿔|리네임|rename/i, 3],
|
|
93
|
+
[/옮겨(줘)?/, 2],
|
|
94
|
+
[/리팩터(링)?(해|좀)/, 3],
|
|
95
|
+
[/\bimplement\b|\badd\b|\bfix\b|\bwrite\b/i, 2],
|
|
96
|
+
[/써(줘|주세요)/, 3],
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// 이만큼은 되어야 바꾼다. 못 미치면 종합 모드 그대로 둔다.
|
|
101
|
+
//
|
|
102
|
+
// 읽기 전용 모드는 문턱을 높인다. 잘못 보내면 파일을 못 고쳐서 사용자가 막힌다.
|
|
103
|
+
// 파일을 고칠 수 있는 모드는 잘못 골라도 일이 막히지는 않으므로 낮게 둔다.
|
|
104
|
+
const 문턱 = {
|
|
105
|
+
debug: 3,
|
|
106
|
+
code: 3,
|
|
107
|
+
orchestrator: 4,
|
|
108
|
+
plan: 5,
|
|
109
|
+
architect: 5,
|
|
110
|
+
ask: 5,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 이 한마디가 무슨 일인지 고른다.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text 사용자가 친 말
|
|
117
|
+
* @returns {{ mode: string|null, score: number, why: string, 점수들: object }}
|
|
118
|
+
* mode 가 null 이면 '모르겠다' 는 뜻이다 — 종합 모드에 그대로 둔다.
|
|
119
|
+
*/
|
|
120
|
+
export function route(text) {
|
|
121
|
+
const s = String(text ?? '');
|
|
122
|
+
const 점수들 = {};
|
|
123
|
+
const 근거 = {};
|
|
124
|
+
|
|
125
|
+
for (const [mode, 규칙들] of Object.entries(표)) {
|
|
126
|
+
let 합 = 0;
|
|
127
|
+
const 걸린것 = [];
|
|
128
|
+
for (const [re, 점] of 규칙들) {
|
|
129
|
+
const m = s.match(re);
|
|
130
|
+
if (!m) continue;
|
|
131
|
+
합 += 점;
|
|
132
|
+
걸린것.push(m[0].trim());
|
|
133
|
+
}
|
|
134
|
+
점수들[mode] = 합;
|
|
135
|
+
근거[mode] = 걸린것;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const 순위 = Object.entries(점수들).sort((a, b) => b[1] - a[1]);
|
|
139
|
+
const [으뜸, 점수] = 순위[0] ?? [null, 0];
|
|
140
|
+
if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
|
|
141
|
+
return { mode: null, score: 점수, why: '무슨 일인지 뚜렷하지 않음', 점수들 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
|
|
145
|
+
const 둘째 = 순위[1]?.[1] ?? 0;
|
|
146
|
+
if (점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
|
|
147
|
+
return { mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`, 점수들 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
mode: 으뜸,
|
|
152
|
+
score: 점수,
|
|
153
|
+
why: 근거[으뜸].slice(0, 2).map((x) => `'${x}'`).join(', '),
|
|
154
|
+
점수들,
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/agent/session.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
|
|
2
2
|
import { readFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { get as workMode, DEFAULT as WORK_DEFAULT } from './modes.js';
|
|
5
|
+
import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
|
|
4
6
|
|
|
5
7
|
// 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
|
|
6
8
|
// 한글은 글자당 약 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
|
|
@@ -25,11 +27,19 @@ const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드
|
|
|
25
27
|
- 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.`;
|
|
26
28
|
|
|
27
29
|
export class Session {
|
|
28
|
-
constructor(conn, { root, mode = 'auto', think = 'medium', maxSteps = 24 } = {}) {
|
|
30
|
+
constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = 24 } = {}) {
|
|
29
31
|
this.conn = conn;
|
|
30
32
|
this.root = root;
|
|
31
|
-
this.mode = mode;
|
|
32
|
-
this.
|
|
33
|
+
this.mode = mode; // 승인 정책 — 얼마나 물어보나 (auto/confirm/strict)
|
|
34
|
+
this.work = work ?? WORK_DEFAULT; // 작업 모드 — 무슨 일을 하는 중인가 (modes.js)
|
|
35
|
+
// 이번 한마디에만 쓸 모드. 종합 모드일 때 요청을 보고 골라 넣는다 (agent/route.js).
|
|
36
|
+
// 기본 모드(this.work)는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
|
|
37
|
+
this.routed = null;
|
|
38
|
+
// 사용자 수준 — 화면에 무엇을 내놓을지만 정한다. 안전 장치는 안 바꾼다 (ui/level.js)
|
|
39
|
+
this.level = normLevel(level) ?? LEVEL_DEFAULT;
|
|
40
|
+
this.think = think; // 기준 강도
|
|
41
|
+
this.effort = effort; // 그 강도를 단계별로 어떻게 나눌지 (effort.js)
|
|
42
|
+
this.web = web; // 웹 읽기 도구를 줄지 (오프라인이면 무조건 안 준다)
|
|
33
43
|
this.maxSteps = maxSteps;
|
|
34
44
|
this.messages = [];
|
|
35
45
|
this.filesRead = new Map(); // 경로 → 추정 토큰
|
|
@@ -53,9 +63,24 @@ export class Session {
|
|
|
53
63
|
return null;
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
/**
|
|
67
|
+
* 지금 이 순간 실제로 쓰는 작업 모드.
|
|
68
|
+
*
|
|
69
|
+
* 종합 모드에서는 한마디마다 골라 넣은 것(routed)이 있고, 그때는 그것이 답이다.
|
|
70
|
+
* 직접 고른 모드가 있으면 routed 는 비어 있으므로 기본 모드가 그대로 답이 된다.
|
|
71
|
+
* 도구·추론·프롬프트가 전부 이 값을 봐야 한다. 하나라도 빠뜨리면 어긋난다.
|
|
72
|
+
*/
|
|
73
|
+
effectiveWork() {
|
|
74
|
+
return this.routed ?? this.work;
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
systemPrompt() {
|
|
57
78
|
const parts = [BASE_RULES];
|
|
58
79
|
parts.push(`\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
|
|
80
|
+
|
|
81
|
+
// 지금 무슨 일을 하는 중인지. 도구 목록도 이 모드에 맞춰 이미 걸러져 있다.
|
|
82
|
+
const w = workMode(this.effectiveWork());
|
|
83
|
+
parts.push(`\n--- 지금 모드: ${w.name} (${w.en}) ---\n${w.say}`);
|
|
59
84
|
if (this.rules) parts.push(`\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
|
|
60
85
|
const listed = this.listedSkills();
|
|
61
86
|
if (listed.length) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// deel sessions — 이 폴더에 남아 있는 대화 목록.
|
|
2
|
+
import { c, say, rule, pad, mark, clip, width } from '../ui/ansi.js';
|
|
3
|
+
import { list, remove, sessionsDir } from './store.js';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// "3분 전", "어제" 처럼. 목록에서는 절대 시각보다 이쪽이 눈에 잘 들어온다.
|
|
7
|
+
function 언제(d) {
|
|
8
|
+
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
|
9
|
+
if (s < 60) return '방금';
|
|
10
|
+
if (s < 3600) return `${Math.floor(s / 60)}분 전`;
|
|
11
|
+
if (s < 86400) return `${Math.floor(s / 3600)}시간 전`;
|
|
12
|
+
if (s < 172800) return '어제';
|
|
13
|
+
if (s < 604800) return `${Math.floor(s / 86400)}일 전`;
|
|
14
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function runSessions(flags = {}) {
|
|
18
|
+
const root = flags.root ? String(flags.root) : process.cwd();
|
|
19
|
+
|
|
20
|
+
if (flags.rm || flags.delete) {
|
|
21
|
+
const id = String(flags.rm ?? flags.delete);
|
|
22
|
+
const r = remove(root, id);
|
|
23
|
+
say('');
|
|
24
|
+
say(r.error ? ` ${mark.no} ${r.error}` : ` ${mark.ok} 지웠습니다: ${c.bold(r.removed)}`);
|
|
25
|
+
say('');
|
|
26
|
+
return r.error ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rows = list(root, { limit: flags.all ? 1000 : 20 });
|
|
30
|
+
say('');
|
|
31
|
+
rule('이 폴더의 대화', 78);
|
|
32
|
+
say(` ${c.gray(root)}`);
|
|
33
|
+
say('');
|
|
34
|
+
|
|
35
|
+
if (!rows.length) {
|
|
36
|
+
say(` ${c.gray('남아 있는 대화가 없습니다.')}`);
|
|
37
|
+
if (!existsSync(sessionsDir(root))) {
|
|
38
|
+
say(` ${c.gray('이 폴더에서 deel 을 한 번 쓰면 여기에 쌓입니다.')}`);
|
|
39
|
+
}
|
|
40
|
+
say('');
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const wId = Math.max(...rows.map((r) => width(r.id)));
|
|
45
|
+
for (const [i, r] of rows.entries()) {
|
|
46
|
+
const 최근 = i === 0 ? c.hgreen('●') : c.gray('·');
|
|
47
|
+
say(` ${최근} ${c.bold(pad(r.id, wId))} ${c.gray(pad(언제(r.at), 9))}`
|
|
48
|
+
+ `${c.gray(pad(`${r.turns}턴`, 6, 'right'))} ${c.gray(pad(clip(r.model, 22), 24))}`);
|
|
49
|
+
say(` ${c.gray(' ')}${clip(r.first, 68)}`);
|
|
50
|
+
}
|
|
51
|
+
say('');
|
|
52
|
+
say(` ${c.gray('가장 최근 것 이어하기')} ${c.cyan('deel --continue')}`);
|
|
53
|
+
say(` ${c.gray('골라서 이어하기')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
|
|
54
|
+
say(` ${c.gray('하나 지우기')} ${c.cyan(`deel sessions --rm ${rows.at(-1).id}`)}`);
|
|
55
|
+
say('');
|
|
56
|
+
say(` ${c.gray(`저장 위치: ${sessionsDir(root)} (.gitignore 에 들어 있어 깃에 안 올라갑니다)`)}`);
|
|
57
|
+
say('');
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// 대화를 파일에 남겨 두고, 껐다 켜도 이어서 하게 한다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가:
|
|
4
|
+
// 터미널을 실수로 닫거나, 컴퓨터가 재부팅되거나, 긴 작업 중 자리를 뜨는 일은 늘 있다.
|
|
5
|
+
// 그때 대화가 통째로 날아가면 모델이 알아낸 것·정한 것이 전부 사라진다.
|
|
6
|
+
// 다시 설명하는 비용이 실제 작업보다 큰 경우도 많다.
|
|
7
|
+
//
|
|
8
|
+
// 어디에 두나:
|
|
9
|
+
// 작업 폴더의 .deel/sessions/<id>.jsonl — 그 폴더의 일은 그 폴더에 남긴다.
|
|
10
|
+
// .gitignore 에 .deel/ 이 들어 있어 깃에 올라가지 않는다.
|
|
11
|
+
//
|
|
12
|
+
// 왜 jsonl 인가:
|
|
13
|
+
// 한 줄에 한 메시지씩 이어 붙이기만 하면 된다. 도중에 프로그램이 죽어도
|
|
14
|
+
// 마지막 줄까지는 성하다. 통째로 다시 쓰는 방식이면 그 순간 파일이 깨진다.
|
|
15
|
+
import {
|
|
16
|
+
existsSync, mkdirSync, readdirSync, readFileSync,
|
|
17
|
+
appendFileSync, writeFileSync, statSync, rmSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const sessionsDir = (root) => join(root, '.deel', 'sessions');
|
|
22
|
+
|
|
23
|
+
// 사람이 읽을 수 있고 정렬하면 시간순인 id.
|
|
24
|
+
export function newId(at = new Date()) {
|
|
25
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
26
|
+
return `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}`
|
|
27
|
+
+ `-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 아직 안 쓰인 id 를 고른다.
|
|
32
|
+
*
|
|
33
|
+
* id 가 초 단위라서, 창을 두 개 거의 동시에 열면 같은 id 가 나온다.
|
|
34
|
+
* 그러면 서로 다른 두 대화가 한 파일에 섞여 들어가고 되살릴 때 뒤엉킨다.
|
|
35
|
+
* 이미 있는 이름이면 뒤에 번호를 붙여 피한다.
|
|
36
|
+
*/
|
|
37
|
+
export function freeId(dir, at = new Date()) {
|
|
38
|
+
const base = newId(at);
|
|
39
|
+
if (!existsSync(join(dir, `${base}.jsonl`))) return base;
|
|
40
|
+
for (let n = 2; n < 1000; n++) {
|
|
41
|
+
const id = `${base}-${n}`;
|
|
42
|
+
if (!existsSync(join(dir, `${id}.jsonl`))) return id;
|
|
43
|
+
}
|
|
44
|
+
return `${base}-${process.pid}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Store {
|
|
48
|
+
constructor(root, id = null) {
|
|
49
|
+
this.root = root;
|
|
50
|
+
this.dir = sessionsDir(root);
|
|
51
|
+
this.auto = id == null; // 우리가 지은 이름인가 (그러면 겹칠 때 바꿔도 된다)
|
|
52
|
+
this.#use(id ?? freeId(this.dir));
|
|
53
|
+
this.opened = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#use(id) {
|
|
57
|
+
this.id = id;
|
|
58
|
+
this.file = join(this.dir, `${id}.jsonl`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#open() {
|
|
62
|
+
if (this.opened) return;
|
|
63
|
+
mkdirSync(this.dir, { recursive: true });
|
|
64
|
+
this.opened = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 첫 줄은 머리글 — 어떤 모델·어떤 폴더에서 한 대화인지.
|
|
69
|
+
*
|
|
70
|
+
* 'wx' 로 만든다: 이미 있으면 실패한다. 이름을 확인하고 만드는 사이에
|
|
71
|
+
* 다른 창이 같은 이름을 채 갔을 수 있어서, 그때는 다른 이름으로 옮겨 간다.
|
|
72
|
+
* 사용자가 --resume 으로 이름을 준 경우에는 옮기지 않고 그 파일에 이어 쓴다.
|
|
73
|
+
*/
|
|
74
|
+
begin(meta) {
|
|
75
|
+
this.#open();
|
|
76
|
+
const head = JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta }) + '\n';
|
|
77
|
+
for (let tries = 0; tries < 50; tries++) {
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(this.file, head, { encoding: 'utf8', flag: 'wx' });
|
|
80
|
+
return this;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err?.code !== 'EEXIST') return this; // 못 적어도 대화는 계속돼야 한다
|
|
83
|
+
if (!this.auto) return this; // 이어쓰기 — 이미 있는 게 맞다
|
|
84
|
+
this.#use(freeId(this.dir)); // 누가 채 갔다. 옆자리로.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#write(obj) {
|
|
91
|
+
try { appendFileSync(this.file, JSON.stringify(obj) + '\n', 'utf8'); }
|
|
92
|
+
catch { /* 기록에 실패해도 대화는 계속돼야 한다 */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 메시지 하나를 덧붙인다. 대화가 진행되는 대로 즉시 남긴다.
|
|
96
|
+
append(msg) {
|
|
97
|
+
this.#open();
|
|
98
|
+
this.#write({ t: 'msg', m: msg });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 압축이 일어나면 이력이 통째로 바뀐다. 그때는 새로 적는다.
|
|
102
|
+
replace(messages, note = '압축') {
|
|
103
|
+
this.#open();
|
|
104
|
+
const meta = this.readMeta() ?? {};
|
|
105
|
+
const lines = [JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta })];
|
|
106
|
+
lines.push(JSON.stringify({ t: 'note', at: new Date().toISOString(), note }));
|
|
107
|
+
for (const m of messages) lines.push(JSON.stringify({ t: 'msg', m }));
|
|
108
|
+
try { writeFileSync(this.file, lines.join('\n') + '\n', 'utf8'); } catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
readMeta() {
|
|
112
|
+
if (!existsSync(this.file)) return null;
|
|
113
|
+
try {
|
|
114
|
+
const first = readFileSync(this.file, 'utf8').split('\n', 1)[0];
|
|
115
|
+
const j = JSON.parse(first);
|
|
116
|
+
return j.t === 'meta' ? j : null;
|
|
117
|
+
} catch { return null; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 저장된 대화를 읽어 온다. 깨진 줄은 건너뛴다 — 도중에 죽었을 수 있다.
|
|
121
|
+
load() {
|
|
122
|
+
if (!existsSync(this.file)) return { meta: null, messages: [] };
|
|
123
|
+
let meta = null;
|
|
124
|
+
const messages = [];
|
|
125
|
+
for (const line of readFileSync(this.file, 'utf8').split('\n')) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
let j;
|
|
128
|
+
try { j = JSON.parse(line); } catch { continue; }
|
|
129
|
+
if (j.t === 'meta') meta = j;
|
|
130
|
+
else if (j.t === 'msg' && j.m) messages.push(j.m);
|
|
131
|
+
}
|
|
132
|
+
return { meta, messages };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 사람에게 보여줄 첫 마디. 이게 목록에서 대화를 알아보는 유일한 단서다.
|
|
137
|
+
function firstAsk(messages) {
|
|
138
|
+
const m = messages.find((x) => x.role === 'user' && typeof x.content === 'string'
|
|
139
|
+
&& x.content.trim() && !x.content.startsWith('['));
|
|
140
|
+
return m ? m.content.replace(/\s+/g, ' ').trim() : '(빈 대화)';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 이 폴더에 남아 있는 대화 목록. 최근 것이 위로.
|
|
145
|
+
*/
|
|
146
|
+
export function list(root, { limit = 20 } = {}) {
|
|
147
|
+
const dir = sessionsDir(root);
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const name of readdirSync(dir)) {
|
|
151
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
152
|
+
const id = name.slice(0, -6);
|
|
153
|
+
const file = join(dir, name);
|
|
154
|
+
let st;
|
|
155
|
+
try { st = statSync(file); } catch { continue; }
|
|
156
|
+
const { meta, messages } = new Store(root, id).load();
|
|
157
|
+
if (!messages.length) continue;
|
|
158
|
+
out.push({
|
|
159
|
+
id,
|
|
160
|
+
file,
|
|
161
|
+
at: st.mtime,
|
|
162
|
+
bytes: st.size,
|
|
163
|
+
model: meta?.model ?? '?',
|
|
164
|
+
turns: messages.filter((m) => m.role === 'user').length,
|
|
165
|
+
messages: messages.length,
|
|
166
|
+
first: firstAsk(messages),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out.sort((a, b) => b.at - a.at).slice(0, limit);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function remove(root, id) {
|
|
173
|
+
const f = join(sessionsDir(root), `${id}.jsonl`);
|
|
174
|
+
if (!existsSync(f)) return { error: `그런 대화가 없습니다: ${id}` };
|
|
175
|
+
rmSync(f, { force: true });
|
|
176
|
+
return { removed: id };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 가장 최근 대화. --continue 가 집어 오는 것. */
|
|
180
|
+
export function latest(root) {
|
|
181
|
+
return list(root, { limit: 1 })[0] ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 오래된 것은 정리한다. 안 그러면 폴더가 끝없이 자란다.
|
|
186
|
+
* 최근 keep 개는 무조건 남기고, 그보다 오래되고 days 를 넘긴 것만 지운다.
|
|
187
|
+
*/
|
|
188
|
+
export function prune(root, { keep = 30, days = 30 } = {}) {
|
|
189
|
+
const all = list(root, { limit: 1000 });
|
|
190
|
+
const 자를것 = all.slice(keep).filter((s) => (Date.now() - s.at.getTime()) > days * 86400000);
|
|
191
|
+
for (const s of 자를것) { try { rmSync(s.file, { force: true }); } catch {} }
|
|
192
|
+
return 자를것.length;
|
|
193
|
+
}
|
package/src/backend/adapter.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
|
|
2
2
|
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
-
import { req, headersFor, serverMessage } from './http.js';
|
|
3
|
+
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
4
4
|
|
|
5
5
|
export function endpoint(shape) {
|
|
6
6
|
return shape === 'ollama' ? '/api/chat' : '/chat/completions';
|
|
@@ -88,6 +88,7 @@ export async function chat(conn, opts) {
|
|
|
88
88
|
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
89
89
|
body,
|
|
90
90
|
timeout: opts.timeout ?? 300000,
|
|
91
|
+
signal: opts.signal ?? null,
|
|
91
92
|
});
|
|
92
93
|
if (!r.ok) throw new Error(serverMessage(r));
|
|
93
94
|
return extractMessage(conn.kind, r.json);
|
|
@@ -102,6 +103,7 @@ export async function* chatStream(conn, opts) {
|
|
|
102
103
|
body,
|
|
103
104
|
timeout: opts.timeout ?? 300000,
|
|
104
105
|
stream: true,
|
|
106
|
+
signal: opts.signal ?? null,
|
|
105
107
|
});
|
|
106
108
|
if (!r.ok || !r.res?.body) throw new Error(r.error ?? `HTTP ${r.status}`);
|
|
107
109
|
|
|
@@ -111,7 +113,15 @@ export async function* chatStream(conn, opts) {
|
|
|
111
113
|
let buf = '';
|
|
112
114
|
|
|
113
115
|
while (true) {
|
|
114
|
-
|
|
116
|
+
// 사용자가 끊었으면 흘러오는 것을 더 받지 않는다. 읽던 연결도 닫는다.
|
|
117
|
+
if (opts.signal?.aborted) {
|
|
118
|
+
try { await reader.cancel(); } catch {}
|
|
119
|
+
throw new Aborted();
|
|
120
|
+
}
|
|
121
|
+
let done;
|
|
122
|
+
let value;
|
|
123
|
+
try { ({ done, value } = await reader.read()); }
|
|
124
|
+
catch (err) { if (opts.signal?.aborted) throw new Aborted(); throw err; }
|
|
115
125
|
if (done) break;
|
|
116
126
|
buf += dec.decode(value, { stream: true });
|
|
117
127
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// 모델이 한 번에 받아 줄 수 있는 길이(컨텍스트)를 서버에서 알아낸다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 파일을 따로 뒀는가:
|
|
4
|
+
//
|
|
5
|
+
// 이 숫자 하나가 프로그램 전체를 좌우한다. 이게 작으면 파일을 몇 개 못 읽히고,
|
|
6
|
+
// 대화가 금방 접히고, 한 번에 쓸 수 있는 답 길이도 같이 줄어든다(effort.js 가
|
|
7
|
+
// 이 값에서 상한을 계산한다). 예전에는 못 알아내면 32768 로 뒀는데, 요즘
|
|
8
|
+
// 로컬 모델은 262,144 · 655,360 처럼 훨씬 크다. 32k 로 깔고 앉으면 모델이
|
|
9
|
+
// 가진 것의 5% 만 쓰는 셈이다.
|
|
10
|
+
//
|
|
11
|
+
// 문제는 서버마다 이 숫자를 다른 이름, 다른 자리에 둔다는 것이다. 그래서
|
|
12
|
+
// 한 군데만 보지 않고 아는 자리를 전부 훑는다. 한 번에 다 못 찾아도
|
|
13
|
+
// 부분만 찾으면 그걸 쓴다.
|
|
14
|
+
//
|
|
15
|
+
// OpenAI 호환 /v1/models/{id} 또는 /v1/models 목록 안의 그 항목
|
|
16
|
+
// LM Studio /api/v0/models (max_context_length · loaded_context_length)
|
|
17
|
+
// llama.cpp /props (n_ctx)
|
|
18
|
+
// vLLM /v1/models (max_model_len)
|
|
19
|
+
// Ollama /api/show (….context_length)
|
|
20
|
+
//
|
|
21
|
+
// '올린 길이' 와 '모델 최대' 를 구분한다. LM Studio 는 모델이 655,360 까지
|
|
22
|
+
// 되더라도 8,192 로 올려 둘 수 있다. 그 상태에서 655,360 을 믿고 보내면
|
|
23
|
+
// 서버가 거절한다. 그래서 실제로 쓸 값은 '올린 길이' 로 잡고, '모델 최대' 는
|
|
24
|
+
// 따로 알려 준다 — 사용자가 서버에서 더 올린 다음 /ctx 로 맞출 수 있게.
|
|
25
|
+
import { req, headersFor } from './http.js';
|
|
26
|
+
|
|
27
|
+
// 이 이름들 중 하나면 컨텍스트 길이다. 순서가 곧 우선순위다.
|
|
28
|
+
const 최대이름 = [
|
|
29
|
+
'max_context_length', // LM Studio
|
|
30
|
+
'context_window', // 일부 게이트웨이
|
|
31
|
+
'context_length', // llama.cpp·HF 계열
|
|
32
|
+
'max_model_len', // vLLM
|
|
33
|
+
'max_input_tokens', // LiteLLM·OpenRouter 계열
|
|
34
|
+
'n_ctx', // llama.cpp
|
|
35
|
+
'max_position_embeddings', // 모델 config 원본
|
|
36
|
+
'max_sequence_length',
|
|
37
|
+
'max_seq_len',
|
|
38
|
+
];
|
|
39
|
+
const 올린이름 = ['loaded_context_length', 'n_ctx', 'context_length'];
|
|
40
|
+
|
|
41
|
+
// 사람이 알아볼 수 없는 값은 안 받는다. 512 미만은 오독, 1000만 초과는 단위 착각.
|
|
42
|
+
const 최소 = 512;
|
|
43
|
+
const 최대허용 = 10_000_000;
|
|
44
|
+
|
|
45
|
+
function 성한수(v) {
|
|
46
|
+
const n = Number(v);
|
|
47
|
+
if (!Number.isFinite(n)) return null;
|
|
48
|
+
const i = Math.floor(n);
|
|
49
|
+
return i >= 최소 && i <= 최대허용 ? i : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 객체 어디에 박혀 있어도 찾는다.
|
|
54
|
+
*
|
|
55
|
+
* 서버가 { data: { meta: { config: { max_position_embeddings: 655360 } } } } 처럼
|
|
56
|
+
* 깊이 넣어 두는 일이 흔하다. 그래서 이름으로 훑는다. 다만 아무 데나 파고들면
|
|
57
|
+
* 엉뚱한 숫자를 집으므로 깊이를 5 로 막는다.
|
|
58
|
+
*/
|
|
59
|
+
function 파보기(obj, 이름들, depth = 0) {
|
|
60
|
+
if (!obj || typeof obj !== 'object' || depth > 5) return null;
|
|
61
|
+
for (const key of 이름들) {
|
|
62
|
+
const v = 성한수(obj[key]);
|
|
63
|
+
if (v) return v;
|
|
64
|
+
}
|
|
65
|
+
// 이름이 정확히 안 맞으면 '…context_length' 처럼 끝나는 것도 본다 (Ollama).
|
|
66
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
67
|
+
if (/(^|[._])(context_length|context_window|n_ctx)$/i.test(k)) {
|
|
68
|
+
const n = 성한수(v);
|
|
69
|
+
if (n) return n;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const v of Object.values(obj)) {
|
|
73
|
+
if (v && typeof v === 'object') {
|
|
74
|
+
const found = 파보기(v, 이름들, depth + 1);
|
|
75
|
+
if (found) return found;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 목록 응답에서 이 모델 항목만 골라낸다.
|
|
82
|
+
function 목록에서찾기(json, model) {
|
|
83
|
+
const list = json?.data ?? json?.models ?? (Array.isArray(json) ? json : []);
|
|
84
|
+
if (!Array.isArray(list)) return null;
|
|
85
|
+
return list.find((m) => (m?.id ?? m?.name ?? m?.model) === model) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 서버에 물어 컨텍스트 길이를 알아낸다.
|
|
90
|
+
*
|
|
91
|
+
* @param {{kind:string, base:string, auth:string, key:string, model:string}} conn
|
|
92
|
+
* @returns {Promise<{value:number|null, max:number|null, loaded:number|null, source:string|null, tried:Array}>}
|
|
93
|
+
*/
|
|
94
|
+
export async function probeCtx(conn, { timeout = 12000 } = {}) {
|
|
95
|
+
const H = () => headersFor(conn.auth, conn.key);
|
|
96
|
+
const base = String(conn.base ?? '').replace(/\/+$/, '');
|
|
97
|
+
const origin = base.replace(/\/v\d+$/, '').replace(/\/api$/, '');
|
|
98
|
+
const model = conn.model;
|
|
99
|
+
const tried = [];
|
|
100
|
+
let max = null;
|
|
101
|
+
let loaded = null;
|
|
102
|
+
let source = null;
|
|
103
|
+
|
|
104
|
+
const 본다 = async (label, url, opts = {}) => {
|
|
105
|
+
if (max && loaded) return null;
|
|
106
|
+
try {
|
|
107
|
+
const r = await req(url, { headers: H(), timeout, ...opts });
|
|
108
|
+
tried.push({ label, url, status: r.status ?? 0, ok: !!r.ok });
|
|
109
|
+
return r.ok ? r.json : null;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
tried.push({ label, url, status: 0, ok: false, why: String(err?.message ?? err) });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const 담기 = (json, label, { 목록 = false } = {}) => {
|
|
117
|
+
if (!json) return;
|
|
118
|
+
const 대상 = 목록 ? 목록에서찾기(json, model) : json;
|
|
119
|
+
if (!대상) return;
|
|
120
|
+
const m = 파보기(대상, 최대이름);
|
|
121
|
+
const l = 파보기(대상, 올린이름);
|
|
122
|
+
if (m && !max) { max = m; source = label; }
|
|
123
|
+
if (l && !loaded) loaded = l;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (conn.kind === 'ollama') {
|
|
127
|
+
담기(await 본다('Ollama /api/show', `${origin}/api/show`, { method: 'POST', body: { model } }), 'Ollama 모델 정보');
|
|
128
|
+
} else {
|
|
129
|
+
// 1) 이 모델만 콕 집어 묻는다. 있으면 가장 정확하다.
|
|
130
|
+
담기(await 본다('모델 상세', `${base}/models/${encodeURIComponent(model)}`), '모델 상세');
|
|
131
|
+
|
|
132
|
+
// 2) LM Studio 는 자기 규격에만 '올린 길이' 를 준다. 이게 실제로 쓸 값이다.
|
|
133
|
+
담기(await 본다('LM Studio 상세', `${origin}/api/v0/models/${encodeURIComponent(model)}`), 'LM Studio');
|
|
134
|
+
담기(await 본다('LM Studio 목록', `${origin}/api/v0/models`), 'LM Studio', { 목록: true });
|
|
135
|
+
|
|
136
|
+
// 3) 목록 응답 안에 들어 있는 경우 (vLLM 의 max_model_len 이 여기 있다)
|
|
137
|
+
담기(await 본다('모델 목록', `${base}/models`), '모델 목록', { 목록: true });
|
|
138
|
+
|
|
139
|
+
// 4) llama.cpp 서버는 /props 로만 알려 준다.
|
|
140
|
+
const props = await 본다('llama.cpp /props', `${origin}/props`);
|
|
141
|
+
if (props) {
|
|
142
|
+
const n = 파보기(props, ['n_ctx', 'context_length']);
|
|
143
|
+
if (n) { if (!max) { max = n; source = 'llama.cpp'; } if (!loaded) loaded = n; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 실제로 쓸 값: 올려 둔 길이가 있으면 그것. 없으면 모델 최대.
|
|
148
|
+
const value = loaded ?? max ?? null;
|
|
149
|
+
return { value, max, loaded, source, tried };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* "655360" · "655k" · "128K" · "1m" 을 숫자로. 못 읽으면 null.
|
|
154
|
+
*
|
|
155
|
+
* k/m 을 받는 이유는 단순하다 — 655360 을 손으로 치면 자릿수를 틀린다.
|
|
156
|
+
*/
|
|
157
|
+
export function parseSize(text) {
|
|
158
|
+
const s = String(text ?? '').trim().replace(/[,_\s]/g, '').toLowerCase();
|
|
159
|
+
const m = /^(\d+(?:\.\d+)?)([km])?$/.exec(s);
|
|
160
|
+
if (!m) return null;
|
|
161
|
+
const mult = m[2] === 'm' ? 1024 * 1024 : m[2] === 'k' ? 1024 : 1;
|
|
162
|
+
return 성한수(Number(m[1]) * mult);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 33k · 655k · 1.0M — 화면에 넣을 짧은 표기 */
|
|
166
|
+
export function fmtSize(n) {
|
|
167
|
+
const v = Number(n) || 0;
|
|
168
|
+
if (v < 1000) return String(v);
|
|
169
|
+
if (v < 1024 * 1024) return Math.round(v / 1024) + 'k';
|
|
170
|
+
return (v / (1024 * 1024)).toFixed(1) + 'M';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 서버가 끝내 안 알려 줄 때 쓰는 값. 옛날 32768 보다는 요즘 기본에 가깝다. */
|
|
174
|
+
export const 기본값 = 32768;
|