pleumrouter 0.5.5 → 0.5.6
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 +3 -3
- package/bin/pleum.mjs +3 -2
- package/package.json +1 -1
- package/src/ansi.mjs +12 -0
- package/src/config.mjs +3 -2
- package/src/launch.mjs +66 -7
- package/src/locales/es.mjs +2 -2
- package/src/locales/ja.mjs +2 -2
- package/src/locales/ko.mjs +3 -3
- package/src/locales/zh.mjs +2 -2
- package/src/login.mjs +18 -8
- package/src/markdown.mjs +5 -2
- package/src/menu.mjs +4 -4
- package/src/models.mjs +27 -13
- package/src/recipes.mjs +20 -2
- package/src/run.mjs +134 -48
- package/src/settings.mjs +4 -4
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
|
|
|
33
33
|
| `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
|
|
34
34
|
| `pleum logout` | 저장된 API 키만 삭제(언어·기본 모델 등 나머지 설정은 유지) |
|
|
35
35
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
36
|
-
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
|
|
36
|
+
| `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
|
|
37
37
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
38
38
|
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
|
39
39
|
| `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
|
|
@@ -64,7 +64,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
|
|
|
64
64
|
- 응답 후 비용이 현지 통화(한국 ₩)로 표시(스트리밍 마지막 청크 기준)
|
|
65
65
|
- **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
|
|
66
66
|
- 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
|
|
67
|
-
- 이미지:
|
|
67
|
+
- 이미지: 명시적으로 첨부 — REPL에서는 `이 그림 설명해줘 @./shot.png`, 1회 실행에서는 `--attach ./shot.png`. 일반 프롬프트의 `./shot.png` 텍스트는 업로드되지 않는다. 첨부는 일반 파일·이미지 magic bytes·5 MiB/장(총 10 MiB)을 확인하고 심볼릭 링크는 거부한다.
|
|
68
68
|
- `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
|
|
69
69
|
- 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
|
|
70
70
|
|
|
@@ -78,7 +78,7 @@ env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바
|
|
|
78
78
|
|
|
79
79
|
## 환경변수
|
|
80
80
|
|
|
81
|
-
- `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://
|
|
81
|
+
- `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://apirouter.pleum.ai`). 셀프호스트/스테이징용.
|
|
82
82
|
|
|
83
83
|
## 개발
|
|
84
84
|
|
package/bin/pleum.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { mainMenu } from "../src/menu.mjs";
|
|
|
15
15
|
import { lang } from "../src/lang.mjs";
|
|
16
16
|
import { RECIPES } from "../src/recipes.mjs";
|
|
17
17
|
import { t } from "../src/i18n.mjs";
|
|
18
|
+
import { sanitizeTerminalText } from "../src/ansi.mjs";
|
|
18
19
|
|
|
19
20
|
// 명령 구문(왼쪽)은 로케일과 무관 — 설명(오른쪽)만 번역, 폭은 가장 긴 구문 기준으로 계산.
|
|
20
21
|
function buildHelp() {
|
|
@@ -26,7 +27,7 @@ function buildHelp() {
|
|
|
26
27
|
["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
|
|
27
28
|
["", `(${Object.keys(RECIPES).join(" · ")})`],
|
|
28
29
|
["", t("omit the tool to see the supported list and install status")],
|
|
29
|
-
['pleum run [model] ["prompt"]', t("Terminal chat (REPL) / one-shot / pipe")],
|
|
30
|
+
['pleum run [model] ["prompt"] [--attach image]', t("Terminal chat (REPL) / one-shot / pipe")],
|
|
30
31
|
["", t("omit the model for an arrow-key picker; /help lists commands")],
|
|
31
32
|
["pleum models [query] [--json]", t("Model list (price, context included)")],
|
|
32
33
|
["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
|
|
@@ -146,6 +147,6 @@ try {
|
|
|
146
147
|
process.exit(1);
|
|
147
148
|
}
|
|
148
149
|
} catch (e) {
|
|
149
|
-
console.error(`✗ ${e.message}`);
|
|
150
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
150
151
|
process.exit(1);
|
|
151
152
|
}
|
package/package.json
CHANGED
package/src/ansi.mjs
CHANGED
|
@@ -55,6 +55,18 @@ export function stripAnsi(s) {
|
|
|
55
55
|
.replace(/\x1b\[[0-9;<]*[A-Za-z]/g, "");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// 원격 응답/메타데이터는 신뢰하지 않는다. 터미널 상태를 바꾸는 ESC·C0/C1 제어문자는
|
|
59
|
+
// 제거하되, 텍스트 스트리밍에 필요한 LF와 TAB은 그대로 둔다. CSI/OSC 등의 본문까지
|
|
60
|
+
// 먼저 제거해 ESC만 떼었을 때 남는 `[2J` 같은 노이즈도 남기지 않는다.
|
|
61
|
+
export function sanitizeTerminalText(value) {
|
|
62
|
+
return String(value ?? "")
|
|
63
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") // OSC (title, hyperlink, clipboard…)
|
|
64
|
+
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "") // DCS, SOS, PM, APC
|
|
65
|
+
.replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") // CSI + C1 CSI
|
|
66
|
+
.replace(/\x1b(?:[()][0-2A-Z0-9]|[=>][0-?]*|.)/g, "") // remaining ESC sequences
|
|
67
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// OSC 8 클릭 가능한 하이퍼링크. 무색(파이프·NO_COLOR·dumb)이면 텍스트만.
|
|
59
71
|
export function hyperlink(url, text = url, level = style.level) {
|
|
60
72
|
return level ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
|
package/src/config.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { t } from "./i18n.mjs";
|
|
5
5
|
|
|
6
|
-
// 공개 게이트웨이
|
|
7
|
-
|
|
6
|
+
// 공개 게이트웨이 루트(canonical API). 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
|
|
7
|
+
// Compat: https://router.pleum.ai 도 180일+ 유지(프론트 /v1 rewrite). 신규 기본은 apirouter.
|
|
8
|
+
export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://apirouter.pleum.ai")
|
|
8
9
|
.replace(/\/+$/, "")
|
|
9
10
|
.replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
|
|
10
11
|
// OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
|
package/src/launch.mjs
CHANGED
|
@@ -1,19 +1,69 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, delimiter } from "node:path";
|
|
4
|
+
import { join, delimiter, dirname, resolve } from "node:path";
|
|
5
5
|
import { requireKey } from "./config.mjs";
|
|
6
6
|
import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
|
|
7
7
|
import { t } from "./i18n.mjs";
|
|
8
|
+
import { sanitizeTerminalText } from "./ansi.mjs";
|
|
8
9
|
|
|
9
10
|
// PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
|
|
10
11
|
export function onPath(bin) {
|
|
11
|
-
const exts = process.platform === "win32" ? [".
|
|
12
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
12
13
|
return (process.env.PATH || "")
|
|
13
14
|
.split(delimiter)
|
|
14
15
|
.some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Windows는 .cmd/.bat 파일을 셸 없이 직접 실행할 수 없다. npm이 만드는 표준 .cmd
|
|
19
|
+
// shim은 실제 Node 엔트리포인트를 가리키므로 그 JS 파일을 현재 Node로 직접 실행한다.
|
|
20
|
+
// 임의의 batch script는 cmd.exe로 넘기지 않고 명시적으로 거부한다. 그래야 --model과
|
|
21
|
+
// `--` 패스스루 인자가 shell 문법으로 재해석될 수 없다.
|
|
22
|
+
function windowsCommandCandidates(bin, path, pathDelimiter) {
|
|
23
|
+
const hasPath = /[\\/]/.test(bin);
|
|
24
|
+
const hasExtension = /\.[^\\/]+$/.test(bin);
|
|
25
|
+
const names = hasExtension ? [bin] : [`${bin}.exe`, `${bin}.cmd`, `${bin}.bat`, bin];
|
|
26
|
+
const dirs = hasPath ? [""] : path.split(pathDelimiter).filter(Boolean);
|
|
27
|
+
return dirs.flatMap((dir) => names.map((name) => (dir ? join(dir, name) : name)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nodeEntryFromWindowsShim(script, { read = readFileSync, exists = existsSync } = {}) {
|
|
31
|
+
let source;
|
|
32
|
+
try {
|
|
33
|
+
source = String(read(script, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// npm cmd-shim uses either %dp0% or %~dp0 followed by the package's JS entry.
|
|
38
|
+
const match = /"(?:%~dp0|%dp0%)[\\/]+([^"\r\n]+?\.(?:[cm]?js))"/i.exec(source);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const entry = resolve(dirname(script), match[1].replace(/[\\/]+/g, "/"));
|
|
41
|
+
return exists(entry) ? entry : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 플랫폼·filesystem 주입으로 Windows 경로 정책을 Linux/macOS에서도 회귀 테스트한다.
|
|
45
|
+
export function resolveLaunchCommand(bin, args, {
|
|
46
|
+
platform = process.platform,
|
|
47
|
+
path = process.env.PATH || "",
|
|
48
|
+
delimiter: pathDelimiter = delimiter,
|
|
49
|
+
exists = existsSync,
|
|
50
|
+
read = readFileSync,
|
|
51
|
+
node = process.execPath,
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (platform !== "win32") return { command: bin, args };
|
|
54
|
+
const found = windowsCommandCandidates(bin, path, pathDelimiter).find((candidate) => exists(candidate));
|
|
55
|
+
if (!found) return { command: bin, args };
|
|
56
|
+
if (!/\.(?:cmd|bat)$/i.test(found)) return { command: found, args };
|
|
57
|
+
|
|
58
|
+
const entry = nodeEntryFromWindowsShim(found, { read, exists });
|
|
59
|
+
if (!entry) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Windows command script '${bin}' cannot be launched safely. Install an .exe or a standard npm Node shim.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { command: node, args: [entry, ...args] };
|
|
65
|
+
}
|
|
66
|
+
|
|
17
67
|
// `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
|
|
18
68
|
export function launchHelp() {
|
|
19
69
|
console.log(t("Usage: pleum launch <tool> [--model id] [-- tool args]\n"));
|
|
@@ -60,13 +110,22 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
60
110
|
|
|
61
111
|
const args = [...r.args(model), ...passthrough];
|
|
62
112
|
if (r.note) console.log(`ℹ ${t(r.note)}`);
|
|
63
|
-
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
|
|
113
|
+
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: sanitizeTerminalText(`${r.bin} ${args.join(" ")}`) })}\n`);
|
|
114
|
+
|
|
115
|
+
let command;
|
|
116
|
+
try {
|
|
117
|
+
command = resolveLaunchCommand(r.bin, args);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
120
|
+
if (exit) process.exit(1);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
64
123
|
|
|
65
124
|
return await new Promise((resolve) => {
|
|
66
|
-
const child = spawn(
|
|
125
|
+
const child = spawn(command.command, command.args, {
|
|
67
126
|
env,
|
|
68
127
|
stdio: "inherit",
|
|
69
|
-
shell:
|
|
128
|
+
shell: false,
|
|
70
129
|
});
|
|
71
130
|
child.on("error", (e) => {
|
|
72
131
|
if (e.code === "ENOENT") {
|
|
@@ -74,7 +133,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
74
133
|
if (exit) process.exit(127);
|
|
75
134
|
return resolve(127);
|
|
76
135
|
}
|
|
77
|
-
console.error(`✗ ${e.message}`);
|
|
136
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
78
137
|
if (exit) process.exit(1);
|
|
79
138
|
resolve(1);
|
|
80
139
|
});
|
package/src/locales/es.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const ES = {
|
|
|
12
12
|
"Account": "Cuenta",
|
|
13
13
|
"All models — price and context included": "Todos los modelos — precio y contexto incluidos",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Agente de codificación de Anthropic — admite subagentes",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "Adjuntando y subiendo {n} imagen(es) local(es) seleccionada(s) explícitamente.",
|
|
16
16
|
"Auto-run:": "Ejecución automática:",
|
|
17
17
|
"Autonomous dev agent": "Agente de desarrollo autónomo",
|
|
18
18
|
"Multi-provider terminal coding agent": "Agente de codificación de terminal multi-proveedor",
|
|
@@ -32,7 +32,7 @@ export const ES = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "Elige un número, o busca (Enter para cancelar): ",
|
|
33
33
|
"Choose a shell": "Elegir una shell",
|
|
34
34
|
"Code": "Código",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "Comandos:\n /help, /? esta ayuda\n /clear reinicia el contexto de la conversación (conserva el system prompt)\n /system [text] define el system prompt (sin argumento muestra el valor actual)\n /model [id] cambia de modelo durante la sesión (sin argumento = selector con flechas)\n /models [query] explora la lista de modelos\n /save [name] guarda la sesión actual (por defecto: last)\n /load [name] carga una sesión guardada\n /verbose activa/desactiva estadísticas de tokens/velocidad\n /exit, /quit salir\n Entrada multilínea: una línea con \"\"\" → cuerpo → una línea con \"\"\"\n Imágenes: adjunta explícitamente con @./shot.png (o --attach ./shot.png para comandos de una sola ejecución)",
|
|
36
36
|
"Context cleared": "Contexto borrado",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "Ctrl+C de nuevo para salir (o /exit)",
|
|
38
38
|
"Current language: {name}": "Idioma actual: {name}",
|
package/src/locales/ja.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const JA = {
|
|
|
12
12
|
"Account": "アカウント",
|
|
13
13
|
"All models — price and context included": "全モデル — 価格・コンテキスト込み",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Anthropic のコーディングエージェント — サブエージェント対応",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "明示的に選択したローカル画像 {n} 件を添付・アップロード中。",
|
|
16
16
|
"Auto-run:": "自動実行:",
|
|
17
17
|
"Autonomous dev agent": "自律開発エージェント",
|
|
18
18
|
"Multi-provider terminal coding agent": "マルチプロバイダー対応のターミナルコーディングエージェント",
|
|
@@ -32,7 +32,7 @@ export const JA = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "番号を選択、または検索してください (Enter でキャンセル): ",
|
|
33
33
|
"Choose a shell": "シェルを選択",
|
|
34
34
|
"Code": "コード",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "コマンド:\n /help, /? このヘルプ\n /clear 会話コンテキストをリセット (システムプロンプトは保持)\n /system [text] システムプロンプトを設定 (引数なしで現在値を表示)\n /model [id] セッション中にモデルを切り替え (引数なし = 矢印キーで選択)\n /models [query] モデル一覧を閲覧\n /save [name] 現在のセッションを保存 (デフォルト: last)\n /load [name] 保存したセッションを読み込み\n /verbose トークン/速度統計の表示切替\n /exit, /quit 終了\n 複数行入力: \"\"\" の行 → 本文 → \"\"\" の行\n 画像: @./shot.png で明示的に添付 (1回実行では --attach ./shot.png)",
|
|
36
36
|
"Context cleared": "コンテキストをクリアしました",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "もう一度 Ctrl+C で終了 (または /exit)",
|
|
38
38
|
"Current language: {name}": "現在の言語: {name}",
|
package/src/locales/ko.mjs
CHANGED
|
@@ -176,7 +176,7 @@ export const KO = {
|
|
|
176
176
|
[`Specify a model: pleum run <model> ["prompt"] (model selection isn't available on non-interactive stdin)`]:
|
|
177
177
|
`모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)`,
|
|
178
178
|
'Usage: pleum run <model> ["prompt"]\nModel list: pleum models': '사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models',
|
|
179
|
-
"Attaching and uploading {n} local image(s)
|
|
179
|
+
"Attaching and uploading {n} explicit local image(s).": "명시적으로 선택한 로컬 이미지 {n}개를 첨부해 업로드합니다.",
|
|
180
180
|
"Goodbye.": "안녕히 가세요.",
|
|
181
181
|
"Ctrl+C again to quit (or /exit)": "한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)",
|
|
182
182
|
'/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation':
|
|
@@ -192,7 +192,7 @@ export const KO = {
|
|
|
192
192
|
/verbose toggle token/speed stats
|
|
193
193
|
/exit, /quit quit
|
|
194
194
|
Multi-line input: a line with """ → body → a line with """
|
|
195
|
-
Images:
|
|
195
|
+
Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`]: `명령:
|
|
196
196
|
/help, /? 이 도움말
|
|
197
197
|
/clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
|
|
198
198
|
/system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
|
|
@@ -203,7 +203,7 @@ export const KO = {
|
|
|
203
203
|
/verbose 토큰·속도 통계 표시 토글
|
|
204
204
|
/exit, /quit 종료
|
|
205
205
|
여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
|
|
206
|
-
이미지:
|
|
206
|
+
이미지: @./shot.png로 명시 첨부(1회 실행은 --attach ./shot.png)`,
|
|
207
207
|
"Context cleared": "컨텍스트 초기화",
|
|
208
208
|
"System prompt set": "시스템 프롬프트 설정",
|
|
209
209
|
"Current system prompt:": "현재 시스템 프롬프트:",
|
package/src/locales/zh.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const ZH = {
|
|
|
12
12
|
"Account": "账户",
|
|
13
13
|
"All models — price and context included": "所有模型 — 含价格与上下文",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Anthropic 的编程代理 — 支持子代理",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "正在附加并上传明确选择的 {n} 张本地图片。",
|
|
16
16
|
"Auto-run:": "自动运行:",
|
|
17
17
|
"Autonomous dev agent": "自主开发代理",
|
|
18
18
|
"Multi-provider terminal coding agent": "多提供商终端编程代理",
|
|
@@ -32,7 +32,7 @@ export const ZH = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "选择编号,或搜索(Enter 取消): ",
|
|
33
33
|
"Choose a shell": "选择 shell",
|
|
34
34
|
"Code": "代码",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "命令:\n /help, /? 本帮助\n /clear 重置对话上下文(保留系统提示词)\n /system [text] 设置系统提示词(不带参数则显示当前值)\n /model [id] 会话中途切换模型(不带参数 = 方向键选择器)\n /models [query] 浏览模型列表\n /save [name] 保存当前会话(默认:last)\n /load [name] 加载已保存的会话\n /verbose 切换 token/速度统计\n /exit, /quit 退出\n 多行输入:一行 \"\"\" → 正文 → 一行 \"\"\"\n 图片:使用 @./shot.png 显式附加(单次命令使用 --attach ./shot.png)",
|
|
36
36
|
"Context cleared": "上下文已清除",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "再次按 Ctrl+C 退出(或 /exit)",
|
|
38
38
|
"Current language: {name}": "当前语言:{name}",
|
package/src/login.mjs
CHANGED
|
@@ -3,14 +3,24 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { ROOT, V1, readConfig, writeConfig, mergeLoginConfig } from "./config.mjs";
|
|
5
5
|
import { t } from "./i18n.mjs";
|
|
6
|
+
import { sanitizeTerminalText } from "./ansi.mjs";
|
|
7
|
+
|
|
8
|
+
// `start`는 Windows cmd.exe의 built-in이라 shell:true가 필요하다. rundll32는 같은
|
|
9
|
+
// 기본 브라우저 핸드오프를 제공하는 실제 실행 파일이므로 URL을 argv로 안전하게 넘긴다.
|
|
10
|
+
export function browserCommand(url, platform = process.platform) {
|
|
11
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
14
|
+
}
|
|
15
|
+
return { command: "xdg-open", args: [url] };
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
function openBrowser(url) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
spawn(cmd, [url], {
|
|
19
|
+
const { command, args } = browserCommand(url);
|
|
20
|
+
spawn(command, args, {
|
|
11
21
|
stdio: "ignore",
|
|
12
22
|
detached: true,
|
|
13
|
-
shell:
|
|
23
|
+
shell: false,
|
|
14
24
|
}).unref();
|
|
15
25
|
}
|
|
16
26
|
|
|
@@ -56,7 +66,7 @@ export async function login() {
|
|
|
56
66
|
server.listen(0, "127.0.0.1", () => {
|
|
57
67
|
const { port } = server.address();
|
|
58
68
|
const authUrl = `${ROOT}/cli-auth?port=${port}&state=${state}`;
|
|
59
|
-
console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${authUrl}\n`);
|
|
69
|
+
console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${sanitizeTerminalText(authUrl)}\n`);
|
|
60
70
|
openBrowser(authUrl);
|
|
61
71
|
});
|
|
62
72
|
|
|
@@ -81,8 +91,8 @@ export async function loginDevice() {
|
|
|
81
91
|
const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
|
|
82
92
|
|
|
83
93
|
console.log(
|
|
84
|
-
`\n ${t("Code")}: \x1b[1;32m${user_code}\x1b[0m\n` +
|
|
85
|
-
` URL: ${verification_url}\n\n` +
|
|
94
|
+
`\n ${t("Code")}: \x1b[1;32m${sanitizeTerminalText(user_code)}\x1b[0m\n` +
|
|
95
|
+
` URL: ${sanitizeTerminalText(verification_url)}\n\n` +
|
|
86
96
|
` ${t("Log in at the URL above, then enter the code (within {min} min).", { min: expires_in / 60 })}\n`
|
|
87
97
|
);
|
|
88
98
|
|
|
@@ -98,7 +108,7 @@ export async function loginDevice() {
|
|
|
98
108
|
if (!poll.ok) {
|
|
99
109
|
const e = await poll.json().catch(() => ({}));
|
|
100
110
|
if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }
|
|
101
|
-
console.error(t("Error:"), e.detail ?? poll.status);
|
|
111
|
+
console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
|
|
102
112
|
process.exit(1);
|
|
103
113
|
}
|
|
104
114
|
const { key } = await poll.json();
|
package/src/markdown.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
|
|
3
3
|
// 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
|
|
4
4
|
|
|
5
|
-
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
5
|
+
import { style, displayWidth, terminalWidth, sanitizeTerminalText } from "./ansi.mjs";
|
|
6
6
|
|
|
7
7
|
// ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
|
|
8
8
|
export function detectFence(line) {
|
|
@@ -13,7 +13,7 @@ export function detectFence(line) {
|
|
|
13
13
|
// 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
|
|
14
14
|
// 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
|
|
15
15
|
export function renderInline(text, styler = style) {
|
|
16
|
-
return text
|
|
16
|
+
return sanitizeTerminalText(text)
|
|
17
17
|
.replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
|
|
18
18
|
.replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
|
|
19
19
|
.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
|
|
@@ -21,6 +21,7 @@ export function renderInline(text, styler = style) {
|
|
|
21
21
|
|
|
22
22
|
// 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
|
|
23
23
|
export function renderLine(line, state = { inFence: false }, styler = style) {
|
|
24
|
+
line = sanitizeTerminalText(line);
|
|
24
25
|
const fence = detectFence(line);
|
|
25
26
|
if (fence) {
|
|
26
27
|
return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
|
|
@@ -67,6 +68,8 @@ export function createMarkdownStream({
|
|
|
67
68
|
|
|
68
69
|
return {
|
|
69
70
|
push(delta) {
|
|
71
|
+
delta = sanitizeTerminalText(delta);
|
|
72
|
+
if (!delta) return;
|
|
70
73
|
if (!interactive) return void write(delta);
|
|
71
74
|
buf += delta;
|
|
72
75
|
const lines = buf.split("\n");
|
package/src/menu.mjs
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
|
-
import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
|
|
14
|
+
import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
|
|
15
15
|
import { keyFeeder, select } from "./interactive.mjs";
|
|
16
16
|
import { readConfig, writeConfig } from "./config.mjs";
|
|
17
17
|
import { RECIPES, MANUAL } from "./recipes.mjs";
|
|
18
18
|
import { onPath, launch } from "./launch.mjs";
|
|
19
|
-
import { fetchModels, matchModels, listModels } from "./models.mjs";
|
|
19
|
+
import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
|
|
20
20
|
import { run } from "./run.mjs";
|
|
21
21
|
import { login } from "./login.mjs";
|
|
22
22
|
import { top } from "./top.mjs";
|
|
@@ -115,12 +115,12 @@ async function pickModelFor(item) {
|
|
|
115
115
|
try {
|
|
116
116
|
models = await fetchModels();
|
|
117
117
|
} catch (e) {
|
|
118
|
-
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
118
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
119
119
|
return undefined;
|
|
120
120
|
}
|
|
121
121
|
const m = await select(models, {
|
|
122
122
|
message: `${item.title} — ${t("select a model")}`,
|
|
123
|
-
formatItem: (x) =>
|
|
123
|
+
formatItem: (x) => formatModelLabel(x),
|
|
124
124
|
matches: matchModels,
|
|
125
125
|
limit: 12,
|
|
126
126
|
});
|
package/src/models.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
|
-
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
2
|
+
import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
|
|
3
3
|
import { t } from "./i18n.mjs";
|
|
4
4
|
|
|
5
5
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
@@ -15,16 +15,28 @@ export function matchModels(models, query) {
|
|
|
15
15
|
const q = (query || "").toLowerCase();
|
|
16
16
|
if (!q) return models;
|
|
17
17
|
return models.filter(
|
|
18
|
-
(m) => m.id.toLowerCase().includes(q) || (m.display_name
|
|
18
|
+
(m) => String(m.id ?? "").toLowerCase().includes(q) || String(m.display_name ?? "").toLowerCase().includes(q),
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Model registry data is remote input. Keep this boundary before any style/render operation so
|
|
23
|
+
// formatting codes emitted by the CLI itself are the only ANSI sequences written to a terminal.
|
|
24
|
+
export function safeModelText(value, fallback = "") {
|
|
25
|
+
return sanitizeTerminalText(value ?? fallback);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatModelLabel(model, styler = style) {
|
|
29
|
+
const id = safeModelText(model?.id);
|
|
30
|
+
const displayName = safeModelText(model?.display_name);
|
|
31
|
+
return `${id}${displayName ? ` ${styler.dim(displayName)}` : ""}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
// 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
|
|
23
35
|
export function fmtCtx(n) {
|
|
24
36
|
if (!n) return "—";
|
|
25
37
|
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
|
|
26
38
|
if (n >= 1000) return `${Math.round(n / 1000)}K`;
|
|
27
|
-
return
|
|
39
|
+
return safeModelText(n);
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
// pricing(_krw)는 토큰당 단가 문자열. 1M 토큰당으로 환산해 표시(KRW 우선, 없으면 USD).
|
|
@@ -49,9 +61,9 @@ export function formatTable(models, styler = style) {
|
|
|
49
61
|
const rows = models.map((m) => {
|
|
50
62
|
const [pin, pout] = fmtPrice(m);
|
|
51
63
|
return [
|
|
52
|
-
m.id,
|
|
53
|
-
m.provider || "",
|
|
54
|
-
m.modality || "text",
|
|
64
|
+
safeModelText(m.id),
|
|
65
|
+
safeModelText(m.provider || ""),
|
|
66
|
+
safeModelText(m.modality || "text"),
|
|
55
67
|
fmtCtx(m.context_length || m.context_window),
|
|
56
68
|
pin,
|
|
57
69
|
pout,
|
|
@@ -119,14 +131,16 @@ const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
|
|
|
119
131
|
export function renderModelInfo(m, styler = style) {
|
|
120
132
|
const [pin, pout] = fmtPrice(m);
|
|
121
133
|
const lines = [];
|
|
122
|
-
|
|
134
|
+
const id = safeModelText(m.id);
|
|
135
|
+
const displayName = safeModelText(m.display_name);
|
|
136
|
+
lines.push(`${styler.bold(styler.plum(id))}${displayName ? ` ${styler.dim(displayName)}` : ""}`);
|
|
123
137
|
lines.push(divider(undefined, styler));
|
|
124
138
|
const facts = [
|
|
125
|
-
["maker", m.maker || "—"],
|
|
126
|
-
["modality", m.modality || "text"],
|
|
139
|
+
["maker", safeModelText(m.maker || "—")],
|
|
140
|
+
["modality", safeModelText(m.modality || "text")],
|
|
127
141
|
["context", fmtCtx(m.context_length || m.context_window)],
|
|
128
142
|
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
129
|
-
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent
|
|
143
|
+
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: safeModelText(m.effective_markup_percent, 0) }))}`],
|
|
130
144
|
];
|
|
131
145
|
if (m.supports_thinking) {
|
|
132
146
|
facts.push([
|
|
@@ -160,19 +174,19 @@ export function renderModelInfo(m, styler = style) {
|
|
|
160
174
|
const [i1m, o1m] = fmtPrice(p);
|
|
161
175
|
lines.push(
|
|
162
176
|
[
|
|
163
|
-
styler.plum(padDisplay(p.provider, 12)),
|
|
177
|
+
styler.plum(padDisplay(safeModelText(p.provider), 12)),
|
|
164
178
|
padDisplay(i1m, 10),
|
|
165
179
|
padDisplay(o1m, 10),
|
|
166
180
|
padDisplay(fmtCtx(p.context_window), 5),
|
|
167
181
|
padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
|
|
168
182
|
padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
|
|
169
|
-
p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
|
|
183
|
+
p.uptime_percent != null ? `${safeModelText(p.uptime_percent)}%` : "—",
|
|
170
184
|
].join(" "),
|
|
171
185
|
);
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
const sibs = (m.siblings || []).map((s) => s.id);
|
|
189
|
+
const sibs = (m.siblings || []).map((s) => safeModelText(s.id));
|
|
176
190
|
if (sibs.length) {
|
|
177
191
|
lines.push("");
|
|
178
192
|
lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
|
package/src/recipes.mjs
CHANGED
|
@@ -169,15 +169,33 @@ export const RECIPES = {
|
|
|
169
169
|
|
|
170
170
|
// Codex는 Chat Completions가 제거돼 Responses API(wire_api="responses")만 지원.
|
|
171
171
|
// 사용자의 ~/.codex/config.toml을 건드리지 않도록 격리 CODEX_HOME에 이 파일을 쓴다.
|
|
172
|
+
// TOML basic string encoder. 모델명과 base_url은 CLI/env에서 오므로, 따옴표·개행이
|
|
173
|
+
// config의 새 키/테이블로 해석되지 않도록 모든 제어문자를 이스케이프한다.
|
|
174
|
+
export function tomlBasicString(value) {
|
|
175
|
+
const escapes = {
|
|
176
|
+
"\b": "\\b",
|
|
177
|
+
"\t": "\\t",
|
|
178
|
+
"\n": "\\n",
|
|
179
|
+
"\f": "\\f",
|
|
180
|
+
"\r": "\\r",
|
|
181
|
+
'"': '\\"',
|
|
182
|
+
"\\": "\\\\",
|
|
183
|
+
};
|
|
184
|
+
const escaped = String(value).replace(/[\u0000-\u001f\u007f"\\\\]/g, (ch) =>
|
|
185
|
+
escapes[ch] ?? `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`,
|
|
186
|
+
);
|
|
187
|
+
return `"${escaped}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
export function codexToml(model) {
|
|
173
191
|
const m = model || "gpt-5.4-mini";
|
|
174
192
|
return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
|
|
175
|
-
model =
|
|
193
|
+
model = ${tomlBasicString(m)}
|
|
176
194
|
model_provider = "pleum"
|
|
177
195
|
|
|
178
196
|
[model_providers.pleum]
|
|
179
197
|
name = "PleumRouter"
|
|
180
|
-
base_url =
|
|
198
|
+
base_url = ${tomlBasicString(V1)}
|
|
181
199
|
env_key = "PLEUM_API_KEY"
|
|
182
200
|
wire_api = "responses"
|
|
183
201
|
`;
|
package/src/run.mjs
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
|
|
5
|
-
import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
6
|
-
import { style, divider } from "./ansi.mjs";
|
|
5
|
+
import { fetchModels, formatTable, matchModels, formatModelLabel, safeModelText } from "./models.mjs";
|
|
6
|
+
import { style, divider, sanitizeTerminalText } from "./ansi.mjs";
|
|
7
7
|
import { createMarkdownStream } from "./markdown.mjs";
|
|
8
8
|
import { spinner, select } from "./interactive.mjs";
|
|
9
9
|
import { t } from "./i18n.mjs";
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
12
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
13
|
+
const GIF87A_MAGIC = Buffer.from("GIF87a");
|
|
14
|
+
const GIF89A_MAGIC = Buffer.from("GIF89a");
|
|
15
|
+
const RIFF_MAGIC = Buffer.from("RIFF");
|
|
16
|
+
const WEBP_MAGIC = Buffer.from("WEBP");
|
|
17
|
+
const BMP_MAGIC = Buffer.from("BM");
|
|
18
|
+
const startsWith = (bytes, magic, offset = 0) =>
|
|
19
|
+
bytes.length >= offset + magic.length && bytes.subarray(offset, offset + magic.length).equals(magic);
|
|
20
|
+
|
|
21
|
+
const IMAGE_FORMATS = {
|
|
22
|
+
png: { mime: "image/png", matches: (bytes) => startsWith(bytes, PNG_MAGIC) },
|
|
23
|
+
jpg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
|
|
24
|
+
jpeg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
|
|
25
|
+
gif: {
|
|
26
|
+
mime: "image/gif",
|
|
27
|
+
matches: (bytes) => startsWith(bytes, GIF87A_MAGIC) || startsWith(bytes, GIF89A_MAGIC),
|
|
28
|
+
},
|
|
29
|
+
webp: {
|
|
30
|
+
mime: "image/webp",
|
|
31
|
+
matches: (bytes) => startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8),
|
|
32
|
+
},
|
|
33
|
+
bmp: { mime: "image/bmp", matches: (bytes) => startsWith(bytes, BMP_MAGIC) },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const MAX_IMAGE_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
37
|
+
const MAX_IMAGE_ATTACHMENTS = 4;
|
|
38
|
+
const MAX_TOTAL_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
12
39
|
|
|
13
40
|
// `pleum run [model] ["프롬프트"...] [--verbose] [--skip-cache] [--tag a,b]
|
|
14
|
-
// [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
|
|
41
|
+
// [--attach image] [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
|
|
15
42
|
// 인자 파싱(순수 함수 — 테스트 대상).
|
|
16
43
|
const REASONING_EFFORTS = new Set(["minimal", "none", "low", "medium", "high", "xhigh", "max"]);
|
|
17
44
|
const SERVICE_TIERS = new Set(["auto", "default", "flex", "background", "priority"]);
|
|
@@ -24,6 +51,7 @@ export function parseRunArgs(rest = []) {
|
|
|
24
51
|
let reasoningEffort = null;
|
|
25
52
|
let serviceTier = null;
|
|
26
53
|
let reasoningMode = null;
|
|
54
|
+
const attachments = [];
|
|
27
55
|
const pos = [];
|
|
28
56
|
for (let i = 0; i < rest.length; i++) {
|
|
29
57
|
const a = rest[i];
|
|
@@ -53,6 +81,11 @@ export function parseRunArgs(rest = []) {
|
|
|
53
81
|
reasoningMode = v;
|
|
54
82
|
i++;
|
|
55
83
|
}
|
|
84
|
+
} else if (a === "--attach" || a === "-a") {
|
|
85
|
+
const path = rest[i + 1];
|
|
86
|
+
if (typeof path !== "string" || !path) throw new Error("--attach requires an image path");
|
|
87
|
+
attachments.push(path);
|
|
88
|
+
i++;
|
|
56
89
|
} else pos.push(a);
|
|
57
90
|
}
|
|
58
91
|
return {
|
|
@@ -64,19 +97,67 @@ export function parseRunArgs(rest = []) {
|
|
|
64
97
|
reasoningEffort,
|
|
65
98
|
serviceTier,
|
|
66
99
|
reasoningMode,
|
|
100
|
+
attachments,
|
|
67
101
|
};
|
|
68
102
|
}
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
function imageFormat(path) {
|
|
105
|
+
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
|
|
106
|
+
return ext ? IMAGE_FORMATS[ext] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function inlineAttachmentPaths(text) {
|
|
110
|
+
// REPL에서도 안전하게 쓸 수 있도록 토큰 맨 앞 @만 명시 첨부 문법으로 해석한다.
|
|
111
|
+
// 일반적인 ./photo.png 문구와 @mention은 그대로 텍스트로 남는다.
|
|
112
|
+
return text
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter((token) => token.startsWith("@") && imageFormat(token.slice(1)))
|
|
115
|
+
.map((token) => token.slice(1));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function imagePart(path, { lstat, read }) {
|
|
119
|
+
const format = imageFormat(path);
|
|
120
|
+
if (!format) throw new Error(`Unsupported image attachment: ${path}`);
|
|
121
|
+
|
|
122
|
+
let info;
|
|
123
|
+
try {
|
|
124
|
+
info = lstat(path);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(`Image attachment not found: ${path}`);
|
|
127
|
+
}
|
|
128
|
+
if (info.isSymbolicLink()) throw new Error(`Refusing symlink image attachment: ${path}`);
|
|
129
|
+
if (!info.isFile()) throw new Error(`Image attachment is not a regular file: ${path}`);
|
|
130
|
+
if (!Number.isSafeInteger(info.size) || info.size < 1) throw new Error(`Image attachment has an invalid size: ${path}`);
|
|
131
|
+
if (info.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
132
|
+
throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const bytes = Buffer.from(read(path));
|
|
136
|
+
if (bytes.length !== info.size) throw new Error(`Image attachment changed while reading: ${path}`);
|
|
137
|
+
if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
138
|
+
throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
|
|
139
|
+
}
|
|
140
|
+
if (!format.matches(bytes)) throw new Error(`Image attachment does not match its file extension: ${path}`);
|
|
141
|
+
return { type: "image_url", image_url: { url: `data:${format.mime};base64,${bytes.toString("base64")}` }, size: bytes.length };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Local files are uploaded only when explicitly selected with --attach or an @path REPL token.
|
|
145
|
+
// lstat/read injection keeps the filesystem checks focused and regression-testable.
|
|
146
|
+
export function buildContent(text, { attachments = [], lstat = lstatSync, read = readFileSync } = {}) {
|
|
147
|
+
const paths = [...new Set([...attachments, ...inlineAttachmentPaths(text)])];
|
|
148
|
+
if (!paths.length) return text;
|
|
149
|
+
if (paths.length > MAX_IMAGE_ATTACHMENTS) {
|
|
150
|
+
throw new Error(`Too many image attachments (max ${MAX_IMAGE_ATTACHMENTS})`);
|
|
151
|
+
}
|
|
75
152
|
const parts = [{ type: "text", text }];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
153
|
+
let totalSize = 0;
|
|
154
|
+
for (const path of paths) {
|
|
155
|
+
const { size, ...part } = imagePart(path, { lstat, read });
|
|
156
|
+
totalSize += size;
|
|
157
|
+
if (totalSize > MAX_TOTAL_IMAGE_ATTACHMENT_BYTES) {
|
|
158
|
+
throw new Error(`Total image attachments are too large (max ${MAX_TOTAL_IMAGE_ATTACHMENT_BYTES} bytes)`);
|
|
159
|
+
}
|
|
160
|
+
parts.push(part);
|
|
80
161
|
}
|
|
81
162
|
return parts;
|
|
82
163
|
}
|
|
@@ -86,17 +167,17 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
|
|
|
86
167
|
const bits = [];
|
|
87
168
|
if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
|
|
88
169
|
if (verbose && usage) {
|
|
89
|
-
bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
|
|
170
|
+
bits.push(`↑${sanitizeTerminalText(usage.prompt_tokens ?? "?")} ↓${sanitizeTerminalText(usage.completion_tokens ?? "?")} tok`);
|
|
90
171
|
if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
91
172
|
}
|
|
92
|
-
if (verbose) bits.push(`${ms}ms`);
|
|
173
|
+
if (verbose) bits.push(`${sanitizeTerminalText(ms)}ms`);
|
|
93
174
|
return bits.length ? ` (${bits.join(" · ")})` : "";
|
|
94
175
|
}
|
|
95
176
|
|
|
96
177
|
// 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
|
|
97
178
|
function fmtCost(v) {
|
|
98
179
|
const n = Number(v);
|
|
99
|
-
return Number.isFinite(n) ? String(+n.toFixed(2)) :
|
|
180
|
+
return Number.isFinite(n) ? String(+n.toFixed(2)) : sanitizeTerminalText(v);
|
|
100
181
|
}
|
|
101
182
|
|
|
102
183
|
function isAbort(e) {
|
|
@@ -114,10 +195,11 @@ export async function run(rest) {
|
|
|
114
195
|
reasoningEffort,
|
|
115
196
|
serviceTier,
|
|
116
197
|
reasoningMode,
|
|
198
|
+
attachments,
|
|
117
199
|
} = parseRunArgs(args);
|
|
118
200
|
let model = argModel;
|
|
119
201
|
const piped = await readStdin();
|
|
120
|
-
const oneShot = Boolean(inlinePrompt || piped);
|
|
202
|
+
const oneShot = Boolean(inlinePrompt || piped || attachments.length);
|
|
121
203
|
const key = requireKey();
|
|
122
204
|
const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
|
|
123
205
|
|
|
@@ -137,17 +219,16 @@ export async function run(rest) {
|
|
|
137
219
|
|
|
138
220
|
if (oneShot) {
|
|
139
221
|
const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
|
|
140
|
-
const content = buildContent(prompt);
|
|
141
|
-
if (Array.isArray(content)) {
|
|
142
|
-
const n = content.filter((p) => p.type === "image_url").length;
|
|
143
|
-
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업송하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
144
|
-
console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
|
|
145
|
-
}
|
|
146
|
-
const messages = [{ role: "user", content }];
|
|
147
222
|
try {
|
|
223
|
+
const content = buildContent(prompt, { attachments });
|
|
224
|
+
if (Array.isArray(content)) {
|
|
225
|
+
const n = content.filter((p) => p.type === "image_url").length;
|
|
226
|
+
console.error(`ℹ ${t("Attaching and uploading {n} explicit local image(s).", { n })}`);
|
|
227
|
+
}
|
|
228
|
+
const messages = [{ role: "user", content }];
|
|
148
229
|
await streamChat(key, model, messages, { verbose, quiet: true, ...callOpts });
|
|
149
230
|
} catch (e) {
|
|
150
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
231
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
151
232
|
process.exit(1);
|
|
152
233
|
}
|
|
153
234
|
process.exit(0);
|
|
@@ -174,11 +255,14 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
174
255
|
let rl;
|
|
175
256
|
|
|
176
257
|
const send = async (text) => {
|
|
177
|
-
|
|
178
|
-
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
179
|
-
const ac = new AbortController();
|
|
180
|
-
currentAbort = ac;
|
|
258
|
+
let added = false;
|
|
181
259
|
try {
|
|
260
|
+
const content = buildContent(text);
|
|
261
|
+
messages.push({ role: "user", content });
|
|
262
|
+
added = true;
|
|
263
|
+
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
264
|
+
const ac = new AbortController();
|
|
265
|
+
currentAbort = ac;
|
|
182
266
|
const reply = await streamChat(key, model, req, {
|
|
183
267
|
verbose,
|
|
184
268
|
signal: ac.signal,
|
|
@@ -191,8 +275,8 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
191
275
|
if (reply) messages.push({ role: "assistant", content: reply });
|
|
192
276
|
else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
|
|
193
277
|
} catch (e) {
|
|
194
|
-
console.error(`\n${style.red("✗")} ${e.message}`);
|
|
195
|
-
messages.pop(); // 실패한 user 턴 제거
|
|
278
|
+
console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
279
|
+
if (added) messages.pop(); // 실패한 user 턴 제거
|
|
196
280
|
} finally {
|
|
197
281
|
currentAbort = null;
|
|
198
282
|
}
|
|
@@ -310,7 +394,7 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
310
394
|
};
|
|
311
395
|
|
|
312
396
|
makeRl(loadHistory());
|
|
313
|
-
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
397
|
+
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${safeModelText(model)}`);
|
|
314
398
|
console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
|
|
315
399
|
console.log(divider());
|
|
316
400
|
rl.prompt();
|
|
@@ -329,7 +413,7 @@ const SLASH_HELP = `Commands:
|
|
|
329
413
|
/verbose toggle token/speed stats
|
|
330
414
|
/exit, /quit quit
|
|
331
415
|
Multi-line input: a line with """ → body → a line with """
|
|
332
|
-
Images:
|
|
416
|
+
Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`;
|
|
333
417
|
|
|
334
418
|
async function handleSlash(text, ctx) {
|
|
335
419
|
const [cmd] = text.slice(1).split(/\s+/);
|
|
@@ -354,17 +438,17 @@ async function handleSlash(text, ctx) {
|
|
|
354
438
|
case "model":
|
|
355
439
|
if (arg) {
|
|
356
440
|
ctx.model = arg;
|
|
357
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
|
|
441
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(arg))}`);
|
|
358
442
|
} else if (ctx.pickModel) {
|
|
359
443
|
const picked = await ctx.pickModel();
|
|
360
444
|
if (picked) {
|
|
361
445
|
ctx.model = picked;
|
|
362
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
|
|
446
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(picked))}`);
|
|
363
447
|
} else {
|
|
364
|
-
console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
|
|
448
|
+
console.log(`${t("Current model:")} ${style.plum(safeModelText(ctx.model))}`);
|
|
365
449
|
}
|
|
366
450
|
} else {
|
|
367
|
-
console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
|
|
451
|
+
console.log(`${t("Current model:")} ${safeModelText(ctx.model)}\n${t("switch: /model <id> · list: /models")}`);
|
|
368
452
|
}
|
|
369
453
|
break;
|
|
370
454
|
case "models":
|
|
@@ -373,7 +457,7 @@ async function handleSlash(text, ctx) {
|
|
|
373
457
|
const rows = matchModels(models, arg);
|
|
374
458
|
console.log(rows.length ? formatTable(rows) : t("No matching models"));
|
|
375
459
|
} catch (e) {
|
|
376
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
460
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
377
461
|
}
|
|
378
462
|
break;
|
|
379
463
|
case "save":
|
|
@@ -381,13 +465,13 @@ async function handleSlash(text, ctx) {
|
|
|
381
465
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
382
466
|
console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
|
|
383
467
|
} catch (e) {
|
|
384
|
-
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
|
|
468
|
+
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
385
469
|
}
|
|
386
470
|
break;
|
|
387
471
|
case "load":
|
|
388
472
|
try {
|
|
389
473
|
ctx.restore(loadSession(arg || "last"));
|
|
390
|
-
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
|
|
474
|
+
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: safeModelText(ctx.model) })}`);
|
|
391
475
|
} catch {
|
|
392
476
|
console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
|
|
393
477
|
}
|
|
@@ -415,7 +499,7 @@ export async function streamChat(key, model, messages, {
|
|
|
415
499
|
fetchImpl = fetch,
|
|
416
500
|
} = {}) {
|
|
417
501
|
const t0 = Date.now();
|
|
418
|
-
const sp = spinner(t("{model} thinking…", { model }));
|
|
502
|
+
const sp = spinner(t("{model} thinking…", { model: sanitizeTerminalText(model) }));
|
|
419
503
|
let res;
|
|
420
504
|
try {
|
|
421
505
|
// 게이트웨이 패리티(0095): X-Pleum-Skip-Cache 헤더 + bounded tags 본문.
|
|
@@ -443,7 +527,7 @@ export async function streamChat(key, model, messages, {
|
|
|
443
527
|
if (!res.ok) {
|
|
444
528
|
sp.stop();
|
|
445
529
|
const body = await res.text().catch(() => "");
|
|
446
|
-
throw new Error(`${res.status} ${body.slice(0, 300)}`);
|
|
530
|
+
throw new Error(`${res.status} ${sanitizeTerminalText(body.slice(0, 300))}`);
|
|
447
531
|
}
|
|
448
532
|
|
|
449
533
|
const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
|
|
@@ -470,12 +554,14 @@ export async function streamChat(key, model, messages, {
|
|
|
470
554
|
if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
|
|
471
555
|
const delta = j.choices?.[0]?.delta?.content;
|
|
472
556
|
if (delta) {
|
|
557
|
+
const safeDelta = sanitizeTerminalText(delta);
|
|
558
|
+
if (!safeDelta) continue;
|
|
473
559
|
if (!firstAt) {
|
|
474
560
|
firstAt = Date.now();
|
|
475
561
|
sp.stop();
|
|
476
562
|
}
|
|
477
|
-
md.push(
|
|
478
|
-
full +=
|
|
563
|
+
md.push(safeDelta);
|
|
564
|
+
full += safeDelta;
|
|
479
565
|
}
|
|
480
566
|
} catch {
|
|
481
567
|
// 부분 청크 — 무시
|
|
@@ -512,14 +598,14 @@ async function pickModel() {
|
|
|
512
598
|
try {
|
|
513
599
|
models = await fetchModels();
|
|
514
600
|
} catch (e) {
|
|
515
|
-
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
601
|
+
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
516
602
|
return null;
|
|
517
603
|
}
|
|
518
604
|
if (!models.length) return null;
|
|
519
605
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
520
606
|
return select(models, {
|
|
521
607
|
message: t("Select a model"),
|
|
522
|
-
formatItem: (m) =>
|
|
608
|
+
formatItem: (m) => formatModelLabel(m),
|
|
523
609
|
matches: matchModels,
|
|
524
610
|
limit: 12,
|
|
525
611
|
}).then((m) => m?.id ?? null);
|
|
@@ -536,7 +622,7 @@ async function pickModelNumbered(models) {
|
|
|
536
622
|
for (;;) {
|
|
537
623
|
const shown = pool.slice(0, 30);
|
|
538
624
|
shown.forEach((m, i) =>
|
|
539
|
-
console.log(` ${String(i + 1).padStart(2)}. ${m
|
|
625
|
+
console.log(` ${String(i + 1).padStart(2)}. ${formatModelLabel(m, { dim: (s) => `(${s})` })}`),
|
|
540
626
|
);
|
|
541
627
|
if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
|
|
542
628
|
const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
|
package/src/settings.mjs
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
-
import { style } from "./ansi.mjs";
|
|
9
|
+
import { style, sanitizeTerminalText } from "./ansi.mjs";
|
|
10
10
|
import { select, confirm } from "./interactive.mjs";
|
|
11
11
|
import { readConfig, writeConfig, clearCredentials } from "./config.mjs";
|
|
12
12
|
import { getLocale, LANGS, t } from "./i18n.mjs";
|
|
13
13
|
import { RECIPES } from "./recipes.mjs";
|
|
14
|
-
import { fetchModels, matchModels } from "./models.mjs";
|
|
14
|
+
import { fetchModels, matchModels, formatModelLabel } from "./models.mjs";
|
|
15
15
|
import { login } from "./login.mjs";
|
|
16
16
|
import { upgrade } from "./upgrade.mjs";
|
|
17
17
|
import { completion } from "./completion.mjs";
|
|
@@ -82,12 +82,12 @@ async function pickDefaultModelFor(item) {
|
|
|
82
82
|
try {
|
|
83
83
|
models = await fetchModels();
|
|
84
84
|
} catch (e) {
|
|
85
|
-
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
85
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
86
86
|
return undefined;
|
|
87
87
|
}
|
|
88
88
|
const picked = await select([CLEAR_OPTION, ...models], {
|
|
89
89
|
message: `${item.title} — ${t("select a model")}`,
|
|
90
|
-
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) :
|
|
90
|
+
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : formatModelLabel(x)),
|
|
91
91
|
matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
|
|
92
92
|
limit: 12,
|
|
93
93
|
});
|