pleumrouter 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/bin/pleum.mjs +24 -1
- package/package.json +1 -1
- package/src/ansi.mjs +102 -0
- package/src/balance.mjs +12 -5
- package/src/completion.mjs +1 -1
- package/src/health.mjs +55 -0
- package/src/interactive.mjs +252 -0
- package/src/launch.mjs +24 -16
- package/src/markdown.mjs +91 -0
- package/src/menu.mjs +360 -0
- package/src/models.mjs +102 -11
- package/src/run.mjs +184 -75
- package/src/top.mjs +72 -0
- package/src/usage.mjs +69 -0
package/src/markdown.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// 스트리밍 마크다운 렌더러 — 라인 플로우(풀스크린 아님).
|
|
2
|
+
// 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
|
|
3
|
+
// 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
|
|
4
|
+
|
|
5
|
+
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
// ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
|
|
8
|
+
export function detectFence(line) {
|
|
9
|
+
const m = /^\s*```(\S*)\s*$/.exec(line);
|
|
10
|
+
return m ? { lang: m[1] } : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
|
|
14
|
+
// 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
|
|
15
|
+
export function renderInline(text, styler = style) {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
|
|
18
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
|
|
19
|
+
.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
|
|
23
|
+
export function renderLine(line, state = { inFence: false }, styler = style) {
|
|
24
|
+
const fence = detectFence(line);
|
|
25
|
+
if (fence) {
|
|
26
|
+
return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
|
|
27
|
+
}
|
|
28
|
+
if (state.inFence) return styler.gray("│ ") + line; // 펜스 안은 마크다운 해석 안 함
|
|
29
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
30
|
+
if (h) return styler.bold(styler.plum(h[2]));
|
|
31
|
+
const q = /^\s*>\s?(.*)$/.exec(line);
|
|
32
|
+
if (q) return styler.gray("┃ ") + styler.dim(renderInline(q[1], styler));
|
|
33
|
+
const li = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line);
|
|
34
|
+
if (li) {
|
|
35
|
+
const marker = /^[-*+]$/.test(li[2]) ? "•" : li[2];
|
|
36
|
+
return `${li[1]}${styler.plum(marker)} ${renderInline(li[3], styler)}`;
|
|
37
|
+
}
|
|
38
|
+
return renderInline(line, styler);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 스트리밍 렌더러 팩토리. write/styler/interactive 주입 가능(테스트용).
|
|
42
|
+
export function createMarkdownStream({
|
|
43
|
+
write = (s) => process.stdout.write(s),
|
|
44
|
+
styler = style,
|
|
45
|
+
interactive = process.stdout.isTTY,
|
|
46
|
+
prefix = "",
|
|
47
|
+
} = {}) {
|
|
48
|
+
let buf = "";
|
|
49
|
+
let inFence = false;
|
|
50
|
+
let firstLine = true; // prefix는 첫 줄에만
|
|
51
|
+
let previewRows = 0; // 직전 미완성 줄 렌더가 점유한 시각적 행 수(soft-wrap 포함)
|
|
52
|
+
|
|
53
|
+
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
54
|
+
|
|
55
|
+
const erasePreview = () => {
|
|
56
|
+
if (!previewRows) return;
|
|
57
|
+
write("\r" + (previewRows > 1 ? `\x1b[${previewRows - 1}A` : "") + "\x1b[0J");
|
|
58
|
+
previewRows = 0;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const commit = (line) => {
|
|
62
|
+
const rendered = (firstLine ? prefix : "") + renderLine(line, { inFence }, styler);
|
|
63
|
+
if (detectFence(line)) inFence = !inFence;
|
|
64
|
+
write(rendered + "\n");
|
|
65
|
+
firstLine = false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
push(delta) {
|
|
70
|
+
if (!interactive) return void write(delta);
|
|
71
|
+
buf += delta;
|
|
72
|
+
const lines = buf.split("\n");
|
|
73
|
+
buf = lines.pop() ?? "";
|
|
74
|
+
if (lines.length || buf) erasePreview();
|
|
75
|
+
for (const line of lines) commit(line);
|
|
76
|
+
if (buf) {
|
|
77
|
+
const rendered = (firstLine ? prefix : "") + renderLine(buf, { inFence }, styler);
|
|
78
|
+
write(rendered);
|
|
79
|
+
previewRows = rowsFor(rendered);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
finish() {
|
|
83
|
+
if (!interactive) return void write("\n"); // 기존 파이프 동작 유지(항상 개행 종결)
|
|
84
|
+
erasePreview();
|
|
85
|
+
if (buf) {
|
|
86
|
+
commit(buf);
|
|
87
|
+
buf = "";
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/menu.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
|
|
2
|
+
// 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
|
|
3
|
+
// · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
|
|
4
|
+
// · "더 보기…"는 커서가 닿는 순간 그 자리에서 펼쳐지고, pinned 구간으로 올라가면 접힘
|
|
5
|
+
// · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
|
|
6
|
+
// · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
|
|
7
|
+
// · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
|
|
8
|
+
// · 키: ↑/k ↓/j · enter/space 실행 · →/l 모델 설정 · esc/q/ctrl+c 종료
|
|
9
|
+
// 마우스(ollama엔 없음 — SGR 1002/1003/1006 직접 구현):
|
|
10
|
+
// · 호버=하이라이트 · 좌클릭=바로 실행 · 우클릭=모델 설정 · 휠=이동
|
|
11
|
+
// · 인라인 렌더라 프레임 상단 행이 가변 — 렌더마다 DSR \x1b[6n으로 끝 행을 얻어 좌표 매핑
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
|
|
17
|
+
import { keyFeeder, select, setMouse, lineIndexAt } from "./interactive.mjs";
|
|
18
|
+
import { readConfig, writeConfig } from "./config.mjs";
|
|
19
|
+
import { RECIPES } from "./recipes.mjs";
|
|
20
|
+
import { onPath, launch } from "./launch.mjs";
|
|
21
|
+
import { fetchModels, matchModels, listModels } from "./models.mjs";
|
|
22
|
+
import { run } from "./run.mjs";
|
|
23
|
+
import { login } from "./login.mjs";
|
|
24
|
+
import { top } from "./top.mjs";
|
|
25
|
+
import { usage } from "./usage.mjs";
|
|
26
|
+
import { balance } from "./balance.mjs";
|
|
27
|
+
import { health } from "./health.mjs";
|
|
28
|
+
|
|
29
|
+
const TOOL_DESC = {
|
|
30
|
+
claude: "Anthropic 코딩 에이전트 — 서브에이전트 지원",
|
|
31
|
+
codex: "OpenAI 코딩 에이전트",
|
|
32
|
+
aider: "터미널 페어 프로그래밍",
|
|
33
|
+
goose: "Block의 로컬 우선 에이전트",
|
|
34
|
+
openhands: "자율 개발 에이전트",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
|
|
38
|
+
|
|
39
|
+
// 메뉴 항목 빌드(순수 함수 — 테스트 대상). tool이 있는 항목만 →(모델 설정) 가능.
|
|
40
|
+
export function buildItems({ loggedIn, installed, savedModels = {}, expanded = false }) {
|
|
41
|
+
const items = [];
|
|
42
|
+
if (!loggedIn) items.push({ id: "login", title: "로그인", desc: "브라우저로 로그인하고 API 키를 저장합니다" });
|
|
43
|
+
items.push({
|
|
44
|
+
id: "chat",
|
|
45
|
+
tool: "chat",
|
|
46
|
+
title: "모델과 채팅",
|
|
47
|
+
desc: `대화형 채팅을 시작합니다${savedModels.chat ? ` · 모델: ${savedModels.chat}` : ""}`,
|
|
48
|
+
});
|
|
49
|
+
const tools = expanded ? Object.keys(RECIPES) : ["claude", "codex"];
|
|
50
|
+
for (const t of tools) {
|
|
51
|
+
const r = RECIPES[t];
|
|
52
|
+
items.push({
|
|
53
|
+
id: `launch:${t}`,
|
|
54
|
+
tool: t,
|
|
55
|
+
title: `${r.label} 실행${installed[t] ? "" : autoInstallable(r) ? " (설치)" : " (미설치)"}`,
|
|
56
|
+
desc: installed[t]
|
|
57
|
+
? `${TOOL_DESC[t]}${savedModels[t] ? ` · 모델: ${savedModels[t]}` : ""}`
|
|
58
|
+
: autoInstallable(r)
|
|
59
|
+
? `Enter로 바로 설치 — ${r.install}`
|
|
60
|
+
: `수동 설치: ${hyperlink(r.install)}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (expanded) {
|
|
64
|
+
items.push(
|
|
65
|
+
{ id: "models", title: "모델 목록", desc: "전체 모델 — 가격·컨텍스트 포함" },
|
|
66
|
+
{ id: "top", title: "리더보드", desc: "모델 랭킹·제공자 점유율" },
|
|
67
|
+
{ id: "usage", title: "지출 리포트", desc: "최근 30일 내 지출" },
|
|
68
|
+
{ id: "balance", title: "잔액", desc: "크레딧 잔액 조회" },
|
|
69
|
+
{ id: "health", title: "상태 점검", desc: "게이트웨이 연결·로그인 상태" },
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
items.push({ id: "more", title: "더 보기…", desc: "다른 에이전트 · 모델 목록 · 잔액 · 리더보드" });
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 항목별 기본 모델 저장(launch_models). 로그인 전에도 동작 — /v1/models는 공개.
|
|
78
|
+
function saveModel(tool, id) {
|
|
79
|
+
const cfg = readConfig() ?? {};
|
|
80
|
+
cfg.launch_models = { ...cfg.launch_models, [tool]: id };
|
|
81
|
+
writeConfig(cfg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 마지막 메뉴 선택 저장 — 다음 실행 시 커서 복원(ollama last_selection과 동일).
|
|
85
|
+
function saveLastSelection(id) {
|
|
86
|
+
const cfg = readConfig() ?? {};
|
|
87
|
+
cfg.last_selection = id;
|
|
88
|
+
writeConfig(cfg);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pickModelFor(item) {
|
|
92
|
+
let models;
|
|
93
|
+
try {
|
|
94
|
+
models = await fetchModels();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.log(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const m = await select(models, {
|
|
100
|
+
message: `${item.title.replace(/\s+\((설치|미설치)\)/, "")} — 모델 선택`,
|
|
101
|
+
formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
|
|
102
|
+
matches: matchModels,
|
|
103
|
+
limit: 12,
|
|
104
|
+
});
|
|
105
|
+
return m?.id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
|
|
109
|
+
async function ensureInstalled(r) {
|
|
110
|
+
if (!autoInstallable(r)) {
|
|
111
|
+
console.log(`${r.label}는 수동 설치가 필요합니다:\n ${style.bold(hyperlink(r.install))}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
+
const ans = await new Promise((res) => rl.question(`${r.label}가 설치되어 있지 않습니다. 지금 설치할까요? [Y/n] `, res));
|
|
116
|
+
rl.close();
|
|
117
|
+
if (/^n/i.test(ans.trim())) {
|
|
118
|
+
console.log(`설치 취소 — 수동 설치: ${r.install}`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
console.log(style.dim(`$ ${r.install}`));
|
|
122
|
+
const code = await new Promise((res) => {
|
|
123
|
+
const c = spawn(r.install, { shell: true, stdio: "inherit" });
|
|
124
|
+
c.on("exit", (n) => res(n ?? 1));
|
|
125
|
+
c.on("error", () => res(1));
|
|
126
|
+
});
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
console.log(`${style.red("✗")} 설치 실패 (exit ${code})`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
console.log(`${style.green("✓")} ${r.label} 설치 완료`);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 클릭 y좌표 → 항목 인덱스(순수 함수 — 테스트 대상).
|
|
136
|
+
// 프레임 구조: [헤더, 공백, 항목별 2줄…, 공백, 푸터]. 항목 밖(헤더/공백/푸터)이면 -1.
|
|
137
|
+
export function itemIndexAt(y, cprRow, lines, itemCount, rowsFor) {
|
|
138
|
+
const li = lineIndexAt(y, cprRow, lines, rowsFor);
|
|
139
|
+
if (li < 2) return -1;
|
|
140
|
+
const idx = (li - 2) >> 1;
|
|
141
|
+
return idx < itemCount ? idx : -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 화살표+마우스 메뉴 루프 — {item, model?} 또는 null(Esc) 반환. 비TTY면 undefined.
|
|
145
|
+
async function menuSelect() {
|
|
146
|
+
const input = process.stdin;
|
|
147
|
+
const output = process.stdout;
|
|
148
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
|
|
149
|
+
|
|
150
|
+
const installed = Object.fromEntries(Object.entries(RECIPES).map(([t, r]) => [t, onPath(r.bin)]));
|
|
151
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
152
|
+
let expanded = false;
|
|
153
|
+
const rebuild = () => {
|
|
154
|
+
const cfg = readConfig();
|
|
155
|
+
return buildItems({ loggedIn: Boolean(cfg?.key), installed, savedModels: cfg?.launch_models ?? {}, expanded });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let items = rebuild();
|
|
159
|
+
const pinnedCount = items.length - 1; // "더 보기…" 자리 — 여기 닿으면 펼침, 위로 벗어나면 접힘
|
|
160
|
+
let cursor = 0;
|
|
161
|
+
|
|
162
|
+
// last_selection 커서 복원 — 더 보기 그룹에 있었으면 펼친 채 시작(ollama shouldExpandOthers).
|
|
163
|
+
const last = readConfig()?.last_selection;
|
|
164
|
+
if (last) {
|
|
165
|
+
let idx = items.findIndex((it) => it.id === last);
|
|
166
|
+
if (idx < 0) {
|
|
167
|
+
expanded = true;
|
|
168
|
+
items = rebuild();
|
|
169
|
+
idx = items.findIndex((it) => it.id === last);
|
|
170
|
+
if (idx < 0) {
|
|
171
|
+
expanded = false;
|
|
172
|
+
items = rebuild();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (idx >= 0) cursor = idx;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let lastRows = 0;
|
|
179
|
+
let lastLines = [];
|
|
180
|
+
let cprRow = 0;
|
|
181
|
+
|
|
182
|
+
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
183
|
+
const erase = () => {
|
|
184
|
+
if (!lastRows) return;
|
|
185
|
+
output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
|
|
186
|
+
lastRows = 0;
|
|
187
|
+
};
|
|
188
|
+
const render = () => {
|
|
189
|
+
const lines = [
|
|
190
|
+
`${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
|
|
191
|
+
"",
|
|
192
|
+
...items.flatMap((it, i) =>
|
|
193
|
+
i === cursor
|
|
194
|
+
? [`${style.plum("▸")} ${style.bold(it.title)}`, ` ${style.dim(it.desc)}`]
|
|
195
|
+
: [` ${it.title}`, ` ${style.dim(it.desc)}`],
|
|
196
|
+
),
|
|
197
|
+
"",
|
|
198
|
+
style.dim("↑/↓·휠 이동 • enter·클릭 실행 • →·우클릭 모델 설정 • esc 종료"),
|
|
199
|
+
];
|
|
200
|
+
erase();
|
|
201
|
+
output.write(lines.join("\n"));
|
|
202
|
+
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
203
|
+
lastLines = lines;
|
|
204
|
+
output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const moveTo = (next) => {
|
|
208
|
+
if (!expanded && next === pinnedCount) {
|
|
209
|
+
expanded = true; // "더 보기…"에 커서가 닿으면 그 자리에서 펼침
|
|
210
|
+
items = rebuild();
|
|
211
|
+
cursor = next;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (expanded && next < pinnedCount) {
|
|
215
|
+
expanded = false; // pinned 구간으로 돌아가면 접힘
|
|
216
|
+
items = rebuild();
|
|
217
|
+
cursor = next;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
cursor = next;
|
|
221
|
+
};
|
|
222
|
+
const move = (dir) => moveTo(Math.max(0, Math.min(items.length - 1, cursor + dir)));
|
|
223
|
+
const itemAt = (y) => itemIndexAt(y, cprRow, lastLines, items.length, rowsFor);
|
|
224
|
+
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
const suspend = () => {
|
|
227
|
+
input.off("data", onData);
|
|
228
|
+
input.setRawMode(false);
|
|
229
|
+
setMouse(output, false);
|
|
230
|
+
output.write("\x1b[?25h");
|
|
231
|
+
erase();
|
|
232
|
+
};
|
|
233
|
+
const resume = () => {
|
|
234
|
+
input.setRawMode(true);
|
|
235
|
+
input.resume();
|
|
236
|
+
output.write("\x1b[?25l");
|
|
237
|
+
setMouse(output, true);
|
|
238
|
+
input.on("data", onData);
|
|
239
|
+
render();
|
|
240
|
+
};
|
|
241
|
+
const finish = (value) => {
|
|
242
|
+
suspend();
|
|
243
|
+
input.pause();
|
|
244
|
+
resolve(value);
|
|
245
|
+
};
|
|
246
|
+
// 모델 셀렉터는 자체 raw-mode·마우스 관리 — 메뉴 입력을 내려놓고 넘긴다.
|
|
247
|
+
// 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
|
|
248
|
+
const configure = async (it) => {
|
|
249
|
+
suspend();
|
|
250
|
+
const picked = await pickModelFor(it);
|
|
251
|
+
if (picked) {
|
|
252
|
+
input.pause();
|
|
253
|
+
resolve({ item: it, model: picked });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
resume();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const feed = keyFeeder();
|
|
260
|
+
const onData = async (chunk) => {
|
|
261
|
+
let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
|
|
262
|
+
for (const key of feed(chunk)) {
|
|
263
|
+
if (key.type === "ctrlc" || key.type === "escape" || (key.type === "char" && key.value === "q")) {
|
|
264
|
+
return finish(null);
|
|
265
|
+
}
|
|
266
|
+
if (key.type === "cpr") {
|
|
267
|
+
cprRow = key.row;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
|
|
271
|
+
return finish({ item: items[cursor] });
|
|
272
|
+
}
|
|
273
|
+
if (key.type === "mouse") {
|
|
274
|
+
if (key.wheel === "up") move(-1);
|
|
275
|
+
else if (key.wheel === "down") move(1);
|
|
276
|
+
else if (key.motion) {
|
|
277
|
+
const idx = itemAt(key.y);
|
|
278
|
+
if (idx < 0 || idx === cursor) continue;
|
|
279
|
+
moveTo(idx);
|
|
280
|
+
} else if (!key.release && key.btn === 0) {
|
|
281
|
+
const idx = itemAt(key.y);
|
|
282
|
+
if (idx < 0) continue;
|
|
283
|
+
const wasMore = !expanded && items[idx]?.id === "more";
|
|
284
|
+
moveTo(idx);
|
|
285
|
+
if (!wasMore) return finish({ item: items[cursor] });
|
|
286
|
+
} else if (!key.release && key.btn === 2) {
|
|
287
|
+
const idx = itemAt(key.y);
|
|
288
|
+
if (idx < 0 || !items[idx].tool) continue;
|
|
289
|
+
moveTo(idx);
|
|
290
|
+
return void (await configure(items[cursor]));
|
|
291
|
+
} else continue;
|
|
292
|
+
} else if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
|
|
293
|
+
else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
|
|
294
|
+
else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
|
|
295
|
+
const it = items[cursor];
|
|
296
|
+
if (!it.tool) continue;
|
|
297
|
+
return void (await configure(it));
|
|
298
|
+
} else continue;
|
|
299
|
+
dirty = true;
|
|
300
|
+
}
|
|
301
|
+
if (dirty) render();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
input.setEncoding("utf8");
|
|
305
|
+
input.setRawMode(true);
|
|
306
|
+
input.resume();
|
|
307
|
+
output.write("\x1b[?25l");
|
|
308
|
+
setMouse(output, true);
|
|
309
|
+
input.on("data", onData);
|
|
310
|
+
render();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
|
|
315
|
+
// ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·Esc 종료 제외).
|
|
316
|
+
export async function mainMenu() {
|
|
317
|
+
for (;;) {
|
|
318
|
+
const picked = await menuSelect();
|
|
319
|
+
if (picked === undefined) return false; // 비TTY
|
|
320
|
+
if (picked === null) return true; // Esc — ollama처럼 조용히 종료
|
|
321
|
+
|
|
322
|
+
const { item, model } = picked;
|
|
323
|
+
if (item.id !== "login") saveLastSelection(item.id);
|
|
324
|
+
if (model && item.tool) saveModel(item.tool, model);
|
|
325
|
+
const saved = readConfig()?.launch_models ?? {};
|
|
326
|
+
|
|
327
|
+
switch (item.id) {
|
|
328
|
+
case "login":
|
|
329
|
+
await login();
|
|
330
|
+
continue; // 로그인 후 메뉴로 복귀
|
|
331
|
+
case "chat": {
|
|
332
|
+
const m = model ?? saved.chat;
|
|
333
|
+
await run(m ? [m] : []); // REPL 종료는 run 내부 process.exit — 메뉴 복귀 없음
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
case "models":
|
|
337
|
+
await listModels([]);
|
|
338
|
+
continue;
|
|
339
|
+
case "top":
|
|
340
|
+
await top([]);
|
|
341
|
+
continue;
|
|
342
|
+
case "usage":
|
|
343
|
+
await usage([]);
|
|
344
|
+
continue;
|
|
345
|
+
case "balance":
|
|
346
|
+
await balance();
|
|
347
|
+
continue;
|
|
348
|
+
case "health":
|
|
349
|
+
await health();
|
|
350
|
+
continue;
|
|
351
|
+
default: {
|
|
352
|
+
const r = RECIPES[item.tool];
|
|
353
|
+
if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
|
|
354
|
+
await launch(item.tool, model ?? saved[item.tool], [], { exit: false });
|
|
355
|
+
console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
|
|
356
|
+
continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
package/src/models.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
|
+
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
2
3
|
|
|
3
4
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
4
5
|
export async function fetchModels() {
|
|
@@ -8,6 +9,15 @@ export async function fetchModels() {
|
|
|
8
9
|
return data.data ?? [];
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// id/표시명 부분일치 필터 — run 피커·REPL /models·목록이 공유하는 단일 구현.
|
|
13
|
+
export function matchModels(models, query) {
|
|
14
|
+
const q = (query || "").toLowerCase();
|
|
15
|
+
if (!q) return models;
|
|
16
|
+
return models.filter(
|
|
17
|
+
(m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
// 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
|
|
12
22
|
export function fmtCtx(n) {
|
|
13
23
|
if (!n) return "—";
|
|
@@ -32,7 +42,8 @@ export function fmtPrice(model) {
|
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
// 정렬된 컬럼 테이블 — 라우터의 강점(수백 모델·가격·컨텍스트)을 한눈에.
|
|
35
|
-
|
|
45
|
+
// 패딩은 표시 폭 기준(padDisplay) — 색·전각 문자가 섞여도 컬럼이 맞는다.
|
|
46
|
+
export function formatTable(models, styler = style) {
|
|
36
47
|
const headers = ["MODEL", "PROVIDER", "MODAL", "CTX", "IN/1M", "OUT/1M"];
|
|
37
48
|
const rows = models.map((m) => {
|
|
38
49
|
const [pin, pout] = fmtPrice(m);
|
|
@@ -46,29 +57,40 @@ export function formatTable(models) {
|
|
|
46
57
|
];
|
|
47
58
|
});
|
|
48
59
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
49
|
-
const
|
|
50
|
-
|
|
60
|
+
const fmtRow = (r, paint) =>
|
|
61
|
+
r.map((c, i) => paint(padDisplay(c, widths[i]), i)).join(" ").trimEnd();
|
|
62
|
+
const head = fmtRow(headers, (c) => styler.dim(c));
|
|
63
|
+
const body = rows.map((r) => fmtRow(r, (c, i) => (i === 0 ? styler.plum(c) : c)));
|
|
64
|
+
return [head, ...body].join("\n");
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
// `pleum models [query] [--json]` 인자 파싱.
|
|
67
|
+
// `pleum models [query] [--json] [--info]` 인자 파싱.
|
|
54
68
|
export function parseModelsArgs(rest = []) {
|
|
55
69
|
let json = false;
|
|
70
|
+
let info = false;
|
|
56
71
|
const pos = [];
|
|
57
72
|
for (const a of rest) {
|
|
58
73
|
if (a === "--json") json = true;
|
|
74
|
+
else if (a === "--info" || a === "-i") info = true;
|
|
59
75
|
else pos.push(a);
|
|
60
76
|
}
|
|
61
|
-
return { json, query: pos[0] };
|
|
77
|
+
return { json, info, query: pos[0] };
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
export async function listModels(rest) {
|
|
65
81
|
// 하위호환: 문자열 하나가 와도 query로 처리.
|
|
66
|
-
const { json, query } = Array.isArray(rest)
|
|
82
|
+
const { json, info, query } = Array.isArray(rest)
|
|
83
|
+
? parseModelsArgs(rest)
|
|
84
|
+
: { json: false, info: false, query: rest };
|
|
85
|
+
if (info) {
|
|
86
|
+
if (!query) {
|
|
87
|
+
console.error("사용법: pleum models <model-id> --info");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
return showModelInfo(query, { json });
|
|
91
|
+
}
|
|
67
92
|
const models = await fetchModels();
|
|
68
|
-
const
|
|
69
|
-
const rows = models.filter(
|
|
70
|
-
(m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
71
|
-
);
|
|
93
|
+
const rows = matchModels(models, query);
|
|
72
94
|
if (json) {
|
|
73
95
|
console.log(JSON.stringify(rows, null, 2));
|
|
74
96
|
return;
|
|
@@ -78,5 +100,74 @@ export async function listModels(rest) {
|
|
|
78
100
|
return;
|
|
79
101
|
}
|
|
80
102
|
console.log(formatTable(rows));
|
|
81
|
-
console.log(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`);
|
|
103
|
+
console.log(style.dim(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
|
|
107
|
+
// id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
|
|
108
|
+
export async function fetchModelDetail(id) {
|
|
109
|
+
const res = await fetch(`${V1}/models/${id}`);
|
|
110
|
+
if (res.status === 404) throw new Error(`모델을 찾을 수 없음: ${id} (목록: pleum models)`);
|
|
111
|
+
if (!res.ok) throw new Error(`모델 상세 조회 실패 (${res.status})`);
|
|
112
|
+
return res.json();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
|
|
116
|
+
|
|
117
|
+
// 상세 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
118
|
+
export function renderModelInfo(m, styler = style) {
|
|
119
|
+
const [pin, pout] = fmtPrice(m);
|
|
120
|
+
const lines = [];
|
|
121
|
+
lines.push(`${styler.bold(styler.plum(m.id))}${m.display_name ? ` ${styler.dim(m.display_name)}` : ""}`);
|
|
122
|
+
lines.push(divider(undefined, styler));
|
|
123
|
+
const facts = [
|
|
124
|
+
["maker", m.maker || "—"],
|
|
125
|
+
["modality", m.modality || "text"],
|
|
126
|
+
["context", fmtCtx(m.context_length || m.context_window)],
|
|
127
|
+
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
128
|
+
["가격/1M", `입력 ${pin} · 출력 ${pout} ${styler.dim(`(마크업 ${m.effective_markup_percent ?? 0}% 포함)`)}`],
|
|
129
|
+
];
|
|
130
|
+
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "강제(끌 수 없음)" : "지원"]);
|
|
131
|
+
for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
|
|
132
|
+
|
|
133
|
+
const u = m.usage;
|
|
134
|
+
if (u) {
|
|
135
|
+
lines.push(
|
|
136
|
+
`${styler.dim(padDisplay("사용량", 10))} 주간 ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
|
|
137
|
+
styler.dim(` (누적 ${fmtNum(u.total_calls)} calls)`),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const provs = m.providers || [];
|
|
142
|
+
if (provs.length) {
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push(styler.dim("PROVIDER IN/1M OUT/1M CTX LATENCY TPS UPTIME"));
|
|
145
|
+
for (const p of provs) {
|
|
146
|
+
const [i1m, o1m] = fmtPrice(p);
|
|
147
|
+
lines.push(
|
|
148
|
+
[
|
|
149
|
+
styler.plum(padDisplay(p.provider, 12)),
|
|
150
|
+
padDisplay(i1m, 10),
|
|
151
|
+
padDisplay(o1m, 10),
|
|
152
|
+
padDisplay(fmtCtx(p.context_window), 5),
|
|
153
|
+
padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
|
|
154
|
+
padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
|
|
155
|
+
p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
|
|
156
|
+
].join(" "),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sibs = (m.siblings || []).map((s) => s.id);
|
|
162
|
+
if (sibs.length) {
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push(`${styler.dim("같은 제공자:")} ${sibs.join(styler.dim(" · "))}`);
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function showModelInfo(id, { json = false } = {}) {
|
|
170
|
+
const detail = await fetchModelDetail(id);
|
|
171
|
+
if (json) return void console.log(JSON.stringify(detail, null, 2));
|
|
172
|
+
console.log(renderModelInfo(detail));
|
|
82
173
|
}
|