pleumrouter 0.3.1 → 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/README.md +7 -5
- package/bin/pleum.mjs +54 -37
- package/package.json +1 -1
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +10 -7
- package/src/config.mjs +13 -8
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +25 -99
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +18 -16
- package/src/locales/es.mjs +218 -0
- package/src/locales/ja.mjs +218 -0
- package/src/locales/ko.mjs +281 -0
- package/src/locales/zh.mjs +218 -0
- package/src/login.mjs +16 -15
- package/src/menu.mjs +129 -147
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +83 -23
- package/src/run.mjs +44 -41
- package/src/settings.mjs +168 -0
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
package/src/menu.mjs
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
// Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
|
|
2
2
|
// 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
|
|
3
3
|
// · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
|
|
4
|
-
// · "더 보기…"는 커서가 닿는 순간 그 자리에서 펼쳐지고, pinned 구간으로 올라가면 접힘
|
|
5
4
|
// · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
|
|
6
5
|
// · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
|
|
7
6
|
// · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
|
|
8
|
-
// ·
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// · 인라인 렌더라 프레임 상단 행이 가변 — 렌더마다 DSR \x1b[6n으로 끝 행을 얻어 좌표 매핑
|
|
7
|
+
// · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
|
|
8
|
+
// 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
|
|
9
|
+
// 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
|
|
12
10
|
|
|
13
11
|
import { readFileSync } from "node:fs";
|
|
14
12
|
import { spawn } from "node:child_process";
|
|
15
13
|
import { createInterface } from "node:readline";
|
|
16
14
|
import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
|
|
17
|
-
import { keyFeeder, select
|
|
15
|
+
import { keyFeeder, select } from "./interactive.mjs";
|
|
18
16
|
import { readConfig, writeConfig } from "./config.mjs";
|
|
19
|
-
import { RECIPES } from "./recipes.mjs";
|
|
17
|
+
import { RECIPES, MANUAL } from "./recipes.mjs";
|
|
20
18
|
import { onPath, launch } from "./launch.mjs";
|
|
21
19
|
import { fetchModels, matchModels, listModels } from "./models.mjs";
|
|
22
20
|
import { run } from "./run.mjs";
|
|
@@ -25,57 +23,81 @@ import { top } from "./top.mjs";
|
|
|
25
23
|
import { usage } from "./usage.mjs";
|
|
26
24
|
import { balance } from "./balance.mjs";
|
|
27
25
|
import { health } from "./health.mjs";
|
|
26
|
+
import { runSettings } from "./settings.mjs";
|
|
27
|
+
import { t } from "./i18n.mjs";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
// 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
|
|
30
|
+
// 번역이 확정돼, 같은 프로세스 안에서 언어를 바꿔도(Settings) 이 설명들만 예전 언어로 남는다.
|
|
31
|
+
// 실제 번역은 소비 지점(buildAgentItems)에서 매번 t()를 호출해 얻는다.
|
|
32
|
+
const AGENT_DESC = {
|
|
33
|
+
claude: "Anthropic's coding agent — supports subagents",
|
|
34
|
+
codex: "OpenAI's coding agent",
|
|
35
|
+
aider: "Terminal pair programming",
|
|
36
|
+
goose: "Block's local-first agent",
|
|
37
|
+
openhands: "Autonomous dev agent",
|
|
38
|
+
opencode: "Multi-provider terminal coding agent",
|
|
39
|
+
omp: "Bun-based coding agent — Claude routing only",
|
|
40
|
+
hermes: "Nous Research's general-purpose agent",
|
|
41
|
+
qwen: "Alibaba's terminal coding agent",
|
|
42
|
+
copilot: "GitHub's terminal coding agent",
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
|
|
38
46
|
|
|
39
|
-
// 메뉴 항목
|
|
40
|
-
export function buildItems({ loggedIn,
|
|
47
|
+
// 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
|
|
48
|
+
export function buildItems({ loggedIn, savedModels = {} }) {
|
|
41
49
|
const items = [];
|
|
42
|
-
if (!loggedIn) items.push({ id: "login", title: "
|
|
50
|
+
if (!loggedIn) items.push({ id: "login", title: t("Log in"), desc: t("Log in via browser and save the API key") });
|
|
43
51
|
items.push({
|
|
44
52
|
id: "chat",
|
|
45
53
|
tool: "chat",
|
|
46
|
-
title: "
|
|
47
|
-
desc:
|
|
54
|
+
title: t("Chat with a model"),
|
|
55
|
+
desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
|
|
48
56
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
|
|
58
|
+
items.push(
|
|
59
|
+
{ id: "models", title: t("Model list"), desc: t("All models — price and context included") },
|
|
60
|
+
{ id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
|
|
61
|
+
{ id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
|
|
62
|
+
{ id: "balance", title: t("Balance"), desc: t("Check your credit balance") },
|
|
63
|
+
{ id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
|
|
64
|
+
{ id: "settings", title: t("Settings"), desc: t("Language, default models, account, updates") },
|
|
65
|
+
);
|
|
66
|
+
return items;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 에이전트 서브메뉴 항목(순수 함수 — 테스트 대상). RECIPES(자동 실행) + MANUAL(설정 안내만) 전부 포함.
|
|
70
|
+
export function buildAgentItems({ installed, savedModels = {} }) {
|
|
71
|
+
const items = [];
|
|
72
|
+
for (const [tool, r] of Object.entries(RECIPES)) {
|
|
52
73
|
items.push({
|
|
53
|
-
id: `launch:${
|
|
54
|
-
tool
|
|
55
|
-
title: `${r.label}
|
|
56
|
-
desc: installed[
|
|
57
|
-
? `${
|
|
74
|
+
id: `launch:${tool}`,
|
|
75
|
+
tool,
|
|
76
|
+
title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
|
|
77
|
+
desc: installed[tool]
|
|
78
|
+
? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
|
|
58
79
|
: autoInstallable(r)
|
|
59
|
-
?
|
|
60
|
-
:
|
|
80
|
+
? `${t("Press Enter to install now")} — ${r.install}`
|
|
81
|
+
: `${t("Manual install:")} ${hyperlink(r.install)}`,
|
|
61
82
|
});
|
|
62
83
|
}
|
|
63
|
-
|
|
64
|
-
items.push(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
for (const [tool, m] of Object.entries(MANUAL)) {
|
|
85
|
+
items.push({
|
|
86
|
+
id: `launch:${tool}`,
|
|
87
|
+
tool,
|
|
88
|
+
manual: true,
|
|
89
|
+
title: `${m.label} (${t("setup required")})`,
|
|
90
|
+
desc: installed[tool]
|
|
91
|
+
? t("Installed — press Enter for the {file} setup snippet", { file: m.file })
|
|
92
|
+
: `${t("✗ not installed —")} ${m.install}`,
|
|
93
|
+
});
|
|
73
94
|
}
|
|
74
95
|
return items;
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
// 항목별 기본 모델 저장(launch_models). 로그인 전에도 동작 — /v1/models는 공개.
|
|
78
|
-
|
|
99
|
+
// settings.mjs의 "Default models" 화면도 재사용(순환 import — 아래 runList 주석 참고).
|
|
100
|
+
export function saveModel(tool, id) {
|
|
79
101
|
const cfg = readConfig() ?? {};
|
|
80
102
|
cfg.launch_models = { ...cfg.launch_models, [tool]: id };
|
|
81
103
|
writeConfig(cfg);
|
|
@@ -93,11 +115,11 @@ async function pickModelFor(item) {
|
|
|
93
115
|
try {
|
|
94
116
|
models = await fetchModels();
|
|
95
117
|
} catch (e) {
|
|
96
|
-
console.log(`${style.red("✗")}
|
|
118
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
97
119
|
return undefined;
|
|
98
120
|
}
|
|
99
121
|
const m = await select(models, {
|
|
100
|
-
message: `${item.title
|
|
122
|
+
message: `${item.title} — ${t("select a model")}`,
|
|
101
123
|
formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
|
|
102
124
|
matches: matchModels,
|
|
103
125
|
limit: 12,
|
|
@@ -108,14 +130,14 @@ async function pickModelFor(item) {
|
|
|
108
130
|
// 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
|
|
109
131
|
async function ensureInstalled(r) {
|
|
110
132
|
if (!autoInstallable(r)) {
|
|
111
|
-
console.log(`${
|
|
133
|
+
console.log(`${t("{label} needs manual installation:", { label: r.label })}\n ${style.bold(hyperlink(r.install))}`);
|
|
112
134
|
return false;
|
|
113
135
|
}
|
|
114
136
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
-
const ans = await new Promise((res) => rl.question(
|
|
137
|
+
const ans = await new Promise((res) => rl.question(t("{label} isn't installed. Install it now? [Y/n] ", { label: r.label }), res));
|
|
116
138
|
rl.close();
|
|
117
139
|
if (/^n/i.test(ans.trim())) {
|
|
118
|
-
console.log(
|
|
140
|
+
console.log(t("Install canceled — manual install: {install}", { install: r.install }));
|
|
119
141
|
return false;
|
|
120
142
|
}
|
|
121
143
|
console.log(style.dim(`$ ${r.install}`));
|
|
@@ -125,59 +147,26 @@ async function ensureInstalled(r) {
|
|
|
125
147
|
c.on("error", () => res(1));
|
|
126
148
|
});
|
|
127
149
|
if (code !== 0) {
|
|
128
|
-
console.log(`${style.red("✗")}
|
|
150
|
+
console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
|
|
129
151
|
return false;
|
|
130
152
|
}
|
|
131
|
-
console.log(`${style.green("✓")} ${r.label}
|
|
153
|
+
console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
|
|
132
154
|
return true;
|
|
133
155
|
}
|
|
134
156
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 화살표+마우스 메뉴 루프 — {item, model?} 또는 null(Esc) 반환. 비TTY면 undefined.
|
|
145
|
-
async function menuSelect() {
|
|
157
|
+
// 화살표 메뉴 루프(키보드 전용) — {item, model?} · "quit"(ctrl+c) · null(esc/q) 반환. 비TTY면 undefined.
|
|
158
|
+
// 메인 메뉴·에이전트 서브메뉴·Settings 서브메뉴(settings.mjs)가 공유하는 렌더 루프 —
|
|
159
|
+
// header/footer/초기 커서만 다르다. settings.mjs가 이 함수를 import하고 menu.mjs가
|
|
160
|
+
// settings.mjs의 runSettings를 import하는 순환 참조가 생기지만, 둘 다 모듈 최상위가 아니라
|
|
161
|
+
// 사용자가 실제로 메뉴를 조작하는 async 함수 안에서만 서로를 참조하므로 안전하다
|
|
162
|
+
// (모듈 최상위에서 즉시 호출하면 "Cannot access before initialization"이 난다 — 이 제약 유지 필수).
|
|
163
|
+
export async function runList(items, { header, footer, initialCursor = 0 } = {}) {
|
|
146
164
|
const input = process.stdin;
|
|
147
165
|
const output = process.stdout;
|
|
148
166
|
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
|
|
149
167
|
|
|
150
|
-
|
|
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
|
-
|
|
168
|
+
let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
|
|
178
169
|
let lastRows = 0;
|
|
179
|
-
let lastLines = [];
|
|
180
|
-
let cprRow = 0;
|
|
181
170
|
|
|
182
171
|
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
183
172
|
const erase = () => {
|
|
@@ -187,7 +176,7 @@ async function menuSelect() {
|
|
|
187
176
|
};
|
|
188
177
|
const render = () => {
|
|
189
178
|
const lines = [
|
|
190
|
-
|
|
179
|
+
header,
|
|
191
180
|
"",
|
|
192
181
|
...items.flatMap((it, i) =>
|
|
193
182
|
i === cursor
|
|
@@ -195,38 +184,20 @@ async function menuSelect() {
|
|
|
195
184
|
: [` ${it.title}`, ` ${style.dim(it.desc)}`],
|
|
196
185
|
),
|
|
197
186
|
"",
|
|
198
|
-
style.dim(
|
|
187
|
+
style.dim(footer),
|
|
199
188
|
];
|
|
200
189
|
erase();
|
|
201
190
|
output.write(lines.join("\n"));
|
|
202
191
|
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
203
|
-
lastLines = lines;
|
|
204
|
-
output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
|
|
205
192
|
};
|
|
206
|
-
|
|
207
|
-
|
|
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;
|
|
193
|
+
const move = (dir) => {
|
|
194
|
+
cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
|
|
221
195
|
};
|
|
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
196
|
|
|
225
197
|
return new Promise((resolve) => {
|
|
226
198
|
const suspend = () => {
|
|
227
199
|
input.off("data", onData);
|
|
228
200
|
input.setRawMode(false);
|
|
229
|
-
setMouse(output, false);
|
|
230
201
|
output.write("\x1b[?25h");
|
|
231
202
|
erase();
|
|
232
203
|
};
|
|
@@ -234,7 +205,6 @@ async function menuSelect() {
|
|
|
234
205
|
input.setRawMode(true);
|
|
235
206
|
input.resume();
|
|
236
207
|
output.write("\x1b[?25l");
|
|
237
|
-
setMouse(output, true);
|
|
238
208
|
input.on("data", onData);
|
|
239
209
|
render();
|
|
240
210
|
};
|
|
@@ -243,7 +213,7 @@ async function menuSelect() {
|
|
|
243
213
|
input.pause();
|
|
244
214
|
resolve(value);
|
|
245
215
|
};
|
|
246
|
-
// 모델 셀렉터는 자체 raw-mode
|
|
216
|
+
// 모델 셀렉터는 자체 raw-mode 관리 — 메뉴 입력을 내려놓고 넘긴다.
|
|
247
217
|
// 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
|
|
248
218
|
const configure = async (it) => {
|
|
249
219
|
suspend();
|
|
@@ -258,42 +228,18 @@ async function menuSelect() {
|
|
|
258
228
|
|
|
259
229
|
const feed = keyFeeder();
|
|
260
230
|
const onData = async (chunk) => {
|
|
261
|
-
let dirty = false;
|
|
231
|
+
let dirty = false;
|
|
262
232
|
for (const key of feed(chunk)) {
|
|
263
|
-
if (key.type === "ctrlc"
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
if (key.type === "cpr") {
|
|
267
|
-
cprRow = key.row;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
233
|
+
if (key.type === "ctrlc") return finish("quit"); // 어느 메뉴에 있든 즉시 전체 종료
|
|
234
|
+
if (key.type === "escape" || (key.type === "char" && key.value === "q")) return finish(null); // 서브메뉴면 상위로, 최상위면 종료
|
|
270
235
|
if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
|
|
271
236
|
return finish({ item: items[cursor] });
|
|
272
237
|
}
|
|
273
|
-
if (key.type === "
|
|
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);
|
|
238
|
+
if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
|
|
293
239
|
else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
|
|
294
240
|
else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
|
|
295
241
|
const it = items[cursor];
|
|
296
|
-
if (!it.tool) continue;
|
|
242
|
+
if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
|
|
297
243
|
return void (await configure(it));
|
|
298
244
|
} else continue;
|
|
299
245
|
dirty = true;
|
|
@@ -305,19 +251,42 @@ async function menuSelect() {
|
|
|
305
251
|
input.setRawMode(true);
|
|
306
252
|
input.resume();
|
|
307
253
|
output.write("\x1b[?25l");
|
|
308
|
-
setMouse(output, true);
|
|
309
254
|
input.on("data", onData);
|
|
310
255
|
render();
|
|
311
256
|
});
|
|
312
257
|
}
|
|
313
258
|
|
|
259
|
+
async function menuSelect() {
|
|
260
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
261
|
+
const cfg = readConfig();
|
|
262
|
+
const items = buildItems({ loggedIn: Boolean(cfg?.key), savedModels: cfg?.launch_models ?? {} });
|
|
263
|
+
const last = cfg?.last_selection;
|
|
264
|
+
const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
|
|
265
|
+
return runList(items, {
|
|
266
|
+
header: `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
|
|
267
|
+
footer: t("↑/↓ move • enter run • → set model • esc quit"),
|
|
268
|
+
initialCursor,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function agentSelect() {
|
|
273
|
+
const installed = Object.fromEntries(
|
|
274
|
+
[...Object.entries(RECIPES), ...Object.entries(MANUAL)].map(([tool, r]) => [tool, onPath(r.bin)]),
|
|
275
|
+
);
|
|
276
|
+
const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
|
|
277
|
+
return runList(items, {
|
|
278
|
+
header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
|
|
279
|
+
footer: t("↑/↓ move • enter run • → set model • esc back"),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
314
283
|
// bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
|
|
315
|
-
// ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·
|
|
284
|
+
// ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
|
|
316
285
|
export async function mainMenu() {
|
|
317
286
|
for (;;) {
|
|
318
287
|
const picked = await menuSelect();
|
|
319
288
|
if (picked === undefined) return false; // 비TTY
|
|
320
|
-
if (picked === null) return true; //
|
|
289
|
+
if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
|
|
321
290
|
|
|
322
291
|
const { item, model } = picked;
|
|
323
292
|
if (item.id !== "login") saveLastSelection(item.id);
|
|
@@ -348,10 +317,23 @@ export async function mainMenu() {
|
|
|
348
317
|
case "health":
|
|
349
318
|
await health();
|
|
350
319
|
continue;
|
|
351
|
-
|
|
352
|
-
const r =
|
|
353
|
-
if (
|
|
354
|
-
|
|
320
|
+
case "settings": {
|
|
321
|
+
const r = await runSettings();
|
|
322
|
+
if (r === "quit") return true; // ctrl+c 또는 재설치(upgrade) 시작 — 전체 종료
|
|
323
|
+
continue; // esc/q — 메인 메뉴로 복귀
|
|
324
|
+
}
|
|
325
|
+
case "agents": {
|
|
326
|
+
const agentPicked = await agentSelect();
|
|
327
|
+
if (agentPicked === "quit") return true; // 서브메뉴에서 ctrl+c → 전체 종료
|
|
328
|
+
if (!agentPicked) continue; // esc/q — 메인 메뉴로 복귀
|
|
329
|
+
const { item: agentItem, model: agentModel } = agentPicked;
|
|
330
|
+
if (agentModel && !agentItem.manual) saveModel(agentItem.tool, agentModel);
|
|
331
|
+
if (!agentItem.manual) {
|
|
332
|
+
const r = RECIPES[agentItem.tool];
|
|
333
|
+
if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
|
|
334
|
+
}
|
|
335
|
+
const agentSaved = readConfig()?.launch_models ?? {};
|
|
336
|
+
await launch(agentItem.tool, agentModel ?? agentSaved[agentItem.tool], [], { exit: false });
|
|
355
337
|
console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
|
|
356
338
|
continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
|
|
357
339
|
}
|
package/src/models.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
2
|
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
3
|
+
import { t } from "./i18n.mjs";
|
|
3
4
|
|
|
4
5
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
5
6
|
export async function fetchModels() {
|
|
6
7
|
const res = await fetch(`${V1}/models`);
|
|
7
|
-
if (!res.ok) throw new Error(
|
|
8
|
+
if (!res.ok) throw new Error(t("Failed to fetch model list ({status})", { status: res.status }));
|
|
8
9
|
const data = await res.json();
|
|
9
10
|
return data.data ?? [];
|
|
10
11
|
}
|
|
@@ -84,7 +85,7 @@ export async function listModels(rest) {
|
|
|
84
85
|
: { json: false, info: false, query: rest };
|
|
85
86
|
if (info) {
|
|
86
87
|
if (!query) {
|
|
87
|
-
console.error("
|
|
88
|
+
console.error(t("Usage: pleum models <model-id> --info"));
|
|
88
89
|
process.exit(1);
|
|
89
90
|
}
|
|
90
91
|
return showModelInfo(query, { json });
|
|
@@ -96,19 +97,19 @@ export async function listModels(rest) {
|
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
99
|
if (!rows.length) {
|
|
99
|
-
console.log("
|
|
100
|
+
console.log(t("No matching models"));
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
console.log(formatTable(rows));
|
|
103
|
-
console.log(style.dim(`\n${rows.length} models ·
|
|
104
|
+
console.log(style.dim(`\n${rows.length} models · ${t("price = per 1M tokens (display rate; actual billing may vary slightly with the intraday rate)")}`));
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
|
|
107
108
|
// id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
|
|
108
109
|
export async function fetchModelDetail(id) {
|
|
109
110
|
const res = await fetch(`${V1}/models/${id}`);
|
|
110
|
-
if (res.status === 404) throw new Error(
|
|
111
|
-
if (!res.ok) throw new Error(
|
|
111
|
+
if (res.status === 404) throw new Error(t("Model not found: {id} (list: pleum models)", { id }));
|
|
112
|
+
if (!res.ok) throw new Error(t("Failed to fetch model detail ({status})", { status: res.status }));
|
|
112
113
|
return res.json();
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -125,16 +126,16 @@ export function renderModelInfo(m, styler = style) {
|
|
|
125
126
|
["modality", m.modality || "text"],
|
|
126
127
|
["context", fmtCtx(m.context_length || m.context_window)],
|
|
127
128
|
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
128
|
-
["
|
|
129
|
+
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent ?? 0 }))}`],
|
|
129
130
|
];
|
|
130
|
-
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "
|
|
131
|
+
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? t("forced (can't be turned off)") : t("supported")]);
|
|
131
132
|
for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
|
|
132
133
|
|
|
133
134
|
const u = m.usage;
|
|
134
135
|
if (u) {
|
|
135
136
|
lines.push(
|
|
136
|
-
`${styler.dim(padDisplay("
|
|
137
|
-
styler.dim(` (
|
|
137
|
+
`${styler.dim(padDisplay(t("usage"), 10))} ${t("weekly")} ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
|
|
138
|
+
styler.dim(` (${t("total")} ${fmtNum(u.total_calls)} calls)`),
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -161,7 +162,7 @@ export function renderModelInfo(m, styler = style) {
|
|
|
161
162
|
const sibs = (m.siblings || []).map((s) => s.id);
|
|
162
163
|
if (sibs.length) {
|
|
163
164
|
lines.push("");
|
|
164
|
-
lines.push(`${styler.dim("
|
|
165
|
+
lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
|
|
165
166
|
}
|
|
166
167
|
return lines.join("\n");
|
|
167
168
|
}
|