pleumrouter 0.5.45 → 0.5.47
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 +1 -1
- package/bin/pleum.mjs +1 -1
- package/package.json +1 -1
- package/src/balance.mjs +38 -137
- package/src/config.mjs +5 -18
- package/src/launch.mjs +2 -6
- package/src/locales/es.mjs +9 -2
- package/src/locales/ja.mjs +9 -2
- package/src/locales/ko.mjs +9 -2
- package/src/locales/zh.mjs +9 -2
- package/src/menu.mjs +3 -5
- package/src/run.mjs +19 -2
- package/src/settings.mjs +3 -6
- package/src/wire.mjs +375 -164
- package/src/workspace.mjs +3 -3
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ SSH/CI에서는 `pleum login --device`를 쓰세요.
|
|
|
39
39
|
| `pleum logout` | 서버 세션 revoke + 로컬 토큰 삭제(언어·기본 모델 등 나머지 설정은 유지) |
|
|
40
40
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
41
41
|
| `pleum wire [claude\|codex]` | Desktop용으로 `~/.claude/settings.json`·`~/.codex/config.toml`에 같은 `plm_` 키를 병합. 생략 시 둘 다 |
|
|
42
|
-
| `pleum unwire [
|
|
42
|
+
| `pleum unwire [tool...] [--revoke-key]` | wire가 바꾼 설정을 원래 값으로 복원(도구 생략 시 배선된 도구 전부). `--revoke-key`는 서버 키도 폐기 — 다른 배선 도구가 남아 있으면 거부 |
|
|
43
43
|
| `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
|
|
44
44
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
45
45
|
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
package/bin/pleum.mjs
CHANGED
|
@@ -38,7 +38,7 @@ function buildHelp() {
|
|
|
38
38
|
["", `(${Object.keys(RECIPES).join(" · ")})`],
|
|
39
39
|
["", t("omit the tool to see the supported list and install status")],
|
|
40
40
|
["pleum wire [claude|codex]", t("persist config in ~/.claude or ~/.codex for Desktop apps")],
|
|
41
|
-
["pleum unwire [
|
|
41
|
+
["pleum unwire [tool]", t("Restore settings changed by wire (omit the tool = all wired tools)")],
|
|
42
42
|
['pleum run [model] ["prompt"] [--attach image]', t("Terminal chat (REPL) / one-shot / pipe")],
|
|
43
43
|
["", t("omit the model for an arrow-key picker; /help lists commands")],
|
|
44
44
|
["pleum models [query] [--json]", t("Model list (price, context included)")],
|
package/package.json
CHANGED
package/src/balance.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { authFetch, getActiveContext
|
|
1
|
+
import { authFetch, getActiveContext } from "./config.mjs";
|
|
2
2
|
import { style } from "./ansi.mjs";
|
|
3
3
|
import { select } from "./interactive.mjs";
|
|
4
4
|
import { t } from "./i18n.mjs";
|
|
5
|
+
import { fetchWorkspaceContext, selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
|
|
5
6
|
|
|
6
7
|
export function formatKrwCredit(amount) {
|
|
7
8
|
return Number(amount || 0).toLocaleString("ko-KR", {
|
|
@@ -10,146 +11,59 @@ export function formatKrwCredit(amount) {
|
|
|
10
11
|
});
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// 개인 지갑 잔액만 조회한다. 공유 공간 목록·선택은 selectAndBindWorkspace가 맡는다.
|
|
15
|
+
export async function fetchCreditSummary({ auth = authFetch } = {}) {
|
|
16
|
+
const res = await auth("/credits");
|
|
15
17
|
if (!res.ok) {
|
|
16
18
|
const body = await res.text().catch(() => "");
|
|
17
19
|
throw new Error(t("Failed to fetch balance ({status})", { status: res.status }) + (body ? `: ${body}` : ""));
|
|
18
20
|
}
|
|
19
21
|
const personal = await res.json();
|
|
20
|
-
|
|
21
|
-
let orgs = [];
|
|
22
|
-
try {
|
|
23
|
-
const orgRes = await authFetch("/orgs");
|
|
24
|
-
if (orgRes.ok) {
|
|
25
|
-
orgs = await orgRes.json();
|
|
26
|
-
}
|
|
27
|
-
} catch {}
|
|
28
|
-
|
|
29
|
-
let teamWallets = [];
|
|
30
|
-
if (Array.isArray(orgs) && orgs.length > 0) {
|
|
31
|
-
const results = await Promise.all(
|
|
32
|
-
orgs.map(async (org) => {
|
|
33
|
-
try {
|
|
34
|
-
const wRes = await authFetch(`/orgs/${org.id}/wallet`);
|
|
35
|
-
if (wRes.ok) {
|
|
36
|
-
const wallet = await wRes.json();
|
|
37
|
-
return {
|
|
38
|
-
id: String(org.id),
|
|
39
|
-
name: org.name,
|
|
40
|
-
role: org.role,
|
|
41
|
-
balance_krw: wallet.available_krw ?? wallet.balance_krw ?? 0,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
} catch {}
|
|
45
|
-
return null;
|
|
46
|
-
})
|
|
47
|
-
);
|
|
48
|
-
teamWallets = results.filter(Boolean);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
22
|
return {
|
|
52
23
|
personal: {
|
|
53
24
|
balance_krw: personal.balance_krw ?? 0,
|
|
54
25
|
balance_usd: personal.balance_usd,
|
|
55
26
|
},
|
|
56
|
-
teams: teamWallets,
|
|
57
27
|
};
|
|
58
28
|
}
|
|
59
29
|
|
|
60
|
-
export function buildCreditOptions({ personal,
|
|
61
|
-
const options = [];
|
|
30
|
+
export function buildCreditOptions({ personal, activeContext = { type: "personal" } }) {
|
|
62
31
|
const isPersonalActive = activeContext.type === "personal";
|
|
63
32
|
const personalBalanceStr = `${formatKrwCredit(personal.balance_krw)} ${t("credits")}${
|
|
64
33
|
personal.balance_usd != null ? ` / $${personal.balance_usd.toFixed(4)}` : ""
|
|
65
34
|
}`;
|
|
66
35
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const isTeamActive = activeContext.type === "team" && String(activeContext.org_id) === String(team.id);
|
|
79
|
-
const teamBalanceStr = `${formatKrwCredit(team.balance_krw)} ${t("credits")}`;
|
|
80
|
-
const roleStr = team.role ? ` (${team.role})` : "";
|
|
81
|
-
const teamTitle = `${t("Team")}: ${team.name}${roleStr}`;
|
|
82
|
-
options.push({
|
|
83
|
-
id: String(team.id),
|
|
84
|
-
type: "team",
|
|
85
|
-
name: team.name,
|
|
86
|
-
title: teamTitle,
|
|
87
|
-
balance: teamBalanceStr,
|
|
88
|
-
isActive: isTeamActive,
|
|
89
|
-
label: `${isTeamActive ? "✓ " : " "}${teamTitle}: ${teamBalanceStr}`,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return options;
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
id: "personal",
|
|
39
|
+
type: "personal",
|
|
40
|
+
name: t("Personal"),
|
|
41
|
+
title: t("Personal Credit"),
|
|
42
|
+
balance: personalBalanceStr,
|
|
43
|
+
isActive: isPersonalActive,
|
|
44
|
+
label: `${isPersonalActive ? "✓ " : " "}${t("Personal Credit")}: ${personalBalanceStr}`,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
94
47
|
}
|
|
95
48
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
} catch (e) {
|
|
101
|
-
console.error(`✗ ${e.message}`);
|
|
102
|
-
return getActiveContext();
|
|
103
|
-
}
|
|
104
|
-
const active = getActiveContext();
|
|
105
|
-
const options = buildCreditOptions({
|
|
106
|
-
personal: data.personal,
|
|
107
|
-
teams: data.teams,
|
|
108
|
-
activeContext: active,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (options.length <= 1) {
|
|
112
|
-
console.log(t("You are not a member of any team. Using personal credit."));
|
|
113
|
-
return active;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const picked = await selectFn(options, {
|
|
117
|
-
message: t("Select credit workspace"),
|
|
118
|
-
formatItem: (x) => x.label,
|
|
119
|
-
limit: 10,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (!picked) return active;
|
|
123
|
-
|
|
124
|
-
const targetOrgId = picked.type === "team" ? picked.id : null;
|
|
49
|
+
// 과금 공간 전환. 후보 선택·키 재바인딩·저장은 selectAndBindWorkspace가 한다.
|
|
50
|
+
export async function switchCreditWorkspace({ selectFn = select } = {}) {
|
|
51
|
+
return selectAndBindWorkspace({ selectFn });
|
|
52
|
+
}
|
|
125
53
|
|
|
54
|
+
// 전환 안내는 공유 공간 후보가 있을 때만 띄운다. 조회가 실패하면 조용히 생략한다.
|
|
55
|
+
async function hasWorkspaceChoices() {
|
|
126
56
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
if (!res.ok) {
|
|
133
|
-
const err = await res.text().catch(() => "");
|
|
134
|
-
console.error(`✗ ${t("Failed to update credit context")}: ${err}`);
|
|
135
|
-
return active;
|
|
136
|
-
}
|
|
137
|
-
} catch (e) {
|
|
138
|
-
console.error(`✗ ${t("Failed to update credit context")}: ${e.message}`);
|
|
139
|
-
return active;
|
|
57
|
+
const data = await fetchWorkspaceContext();
|
|
58
|
+
return (data?.workspace_candidates ?? []).length > 0;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
140
61
|
}
|
|
141
|
-
|
|
142
|
-
const newContext = setActiveContext({
|
|
143
|
-
type: picked.type,
|
|
144
|
-
org_id: targetOrgId,
|
|
145
|
-
org_name: picked.type === "team" ? picked.name : null,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
console.log(`✓ ${t("Active credit set to: {name}", { name: picked.title })}`);
|
|
149
|
-
return newContext;
|
|
150
62
|
}
|
|
151
63
|
|
|
152
64
|
export async function balance({ interactive = false, switchOnly = false, exitOnError = true } = {}) {
|
|
65
|
+
if (switchOnly) return await switchCreditWorkspace();
|
|
66
|
+
|
|
153
67
|
let summary;
|
|
154
68
|
try {
|
|
155
69
|
summary = await fetchCreditSummary();
|
|
@@ -159,28 +73,15 @@ export async function balance({ interactive = false, switchOnly = false, exitOnE
|
|
|
159
73
|
return;
|
|
160
74
|
}
|
|
161
75
|
|
|
162
|
-
if (switchOnly) {
|
|
163
|
-
return await switchCreditWorkspace({ summary });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
76
|
const active = getActiveContext();
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const mark = opt.isActive ? style.bold(style.plum("▸ [Active] ")) : " [ ] ";
|
|
177
|
-
console.log(`${mark}${opt.title}: ${opt.balance}`);
|
|
178
|
-
}
|
|
179
|
-
if (options.length > 1 && !interactive) {
|
|
180
|
-
console.log(`\n${style.dim(t("Run `pleum balance --switch` to switch credit workspace."))}`);
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
77
|
+
const [personal] = buildCreditOptions({ personal: summary.personal, activeContext: active });
|
|
78
|
+
const mark = personal.isActive ? style.bold(style.plum("▸ [Active] ")) : " [ ] ";
|
|
79
|
+
console.log(t("Credit Balance:"));
|
|
80
|
+
console.log(`${mark}${personal.title}: ${personal.balance}`);
|
|
81
|
+
console.log(` ${t("Credit workspace")}: ${workspaceLabel(active)}`);
|
|
82
|
+
|
|
83
|
+
if (interactive) return await switchCreditWorkspace();
|
|
84
|
+
if (await hasWorkspaceChoices()) {
|
|
85
|
+
console.log(`\n${style.dim(t("Run `pleum balance --switch` to switch credit workspace."))}`);
|
|
183
86
|
}
|
|
184
|
-
|
|
185
|
-
return await switchCreditWorkspace({ summary });
|
|
186
87
|
}
|
package/src/config.mjs
CHANGED
|
@@ -215,9 +215,7 @@ export function getActiveContext(cfg = readConfig()) {
|
|
|
215
215
|
workspace_name: ctx.workspace_name || ctx.workspace_id,
|
|
216
216
|
};
|
|
217
217
|
}
|
|
218
|
-
|
|
219
|
-
return { type: "team", org_id: ctx.org_id, org_name: ctx.org_name || ctx.org_id };
|
|
220
|
-
}
|
|
218
|
+
// 팀 컨텍스트는 제거됐다. 예전에 저장된 team 값도 개인으로 본다.
|
|
221
219
|
return { type: "personal", org_id: null, org_name: null };
|
|
222
220
|
}
|
|
223
221
|
|
|
@@ -230,29 +228,18 @@ export function setActiveContext(context, cfg = readConfig()) {
|
|
|
230
228
|
workspace_id: context.workspace_id,
|
|
231
229
|
workspace_name: context.workspace_name || null,
|
|
232
230
|
};
|
|
233
|
-
} else if (!context || context.type === "personal" || !context.org_id) {
|
|
234
|
-
updated.active_context = { type: "personal" };
|
|
235
231
|
} else {
|
|
236
|
-
updated.active_context = {
|
|
237
|
-
type: "team",
|
|
238
|
-
org_id: context.org_id,
|
|
239
|
-
org_name: context.org_name || null,
|
|
240
|
-
};
|
|
232
|
+
updated.active_context = { type: "personal" };
|
|
241
233
|
}
|
|
242
234
|
writeConfig(updated);
|
|
243
235
|
return updated.active_context;
|
|
244
236
|
}
|
|
245
237
|
|
|
246
|
-
/** Bearer 요청 — 401이면 1회 refresh 후 재시도.
|
|
238
|
+
/** Bearer 요청 — 401이면 1회 refresh 후 재시도. 과금 귀속은 키의 workspace_id가 정하므로 헤더를 붙이지 않는다. */
|
|
247
239
|
export async function authFetch(path, init = {}) {
|
|
248
240
|
const token = await requireAuth();
|
|
249
241
|
const url = path.startsWith("http") ? path : `${V1}${path.startsWith("/") ? path : `/${path}`}`;
|
|
250
|
-
const
|
|
251
|
-
const customHeaders = {};
|
|
252
|
-
if (ctx.type === "team" && ctx.org_id) {
|
|
253
|
-
customHeaders["X-Pleum-Org"] = ctx.org_id;
|
|
254
|
-
}
|
|
255
|
-
const headers = { ...customHeaders, ...(init.headers || {}), Authorization: `Bearer ${token}` };
|
|
242
|
+
const headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` };
|
|
256
243
|
let res = await fetch(url, { ...init, headers });
|
|
257
244
|
if (res.status !== 401) return res;
|
|
258
245
|
|
|
@@ -261,7 +248,7 @@ export async function authFetch(path, init = {}) {
|
|
|
261
248
|
if (!refreshed) return res;
|
|
262
249
|
return fetch(url, {
|
|
263
250
|
...init,
|
|
264
|
-
headers: { ...
|
|
251
|
+
headers: { ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
|
|
265
252
|
});
|
|
266
253
|
}
|
|
267
254
|
|
package/src/launch.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { join, delimiter, dirname, resolve } from "node:path";
|
|
|
5
5
|
import { readConfig, isLoggedIn, getActiveContext, ROOT } from "./config.mjs";
|
|
6
6
|
import { login } from "./login.mjs";
|
|
7
7
|
import { ensureWireKey } from "./wire.mjs";
|
|
8
|
+
import { workspaceLabel } from "./workspace.mjs";
|
|
8
9
|
import {
|
|
9
10
|
RECIPES,
|
|
10
11
|
CORE_MODELS,
|
|
@@ -316,12 +317,7 @@ export async function launch(tool, model, passthrough, {
|
|
|
316
317
|
process.removeListener("unhandledRejection", onException);
|
|
317
318
|
};
|
|
318
319
|
|
|
319
|
-
const
|
|
320
|
-
const creditMsg = activeCtx.type === "workspace"
|
|
321
|
-
? `${t("Workspace")}: ${sanitizeTerminalText(activeCtx.workspace_name)}`
|
|
322
|
-
: activeCtx.type === "team"
|
|
323
|
-
? `${t("Team")}: ${activeCtx.org_name}`
|
|
324
|
-
: t("Personal");
|
|
320
|
+
const creditMsg = workspaceLabel(getActiveContext());
|
|
325
321
|
console.log(`ℹ ${t("Credit:")} ${creditMsg}`);
|
|
326
322
|
|
|
327
323
|
const args = [...r.args(model), ...passthrough];
|
package/src/locales/es.mjs
CHANGED
|
@@ -161,14 +161,17 @@ export const ES = {
|
|
|
161
161
|
"Unknown command: /{cmd} (/help)": "Comando desconocido: /{cmd} (/help)",
|
|
162
162
|
"Unknown command: {cmd}": "Comando desconocido: {cmd}",
|
|
163
163
|
"Usage: pleum wire [claude|codex]\n": "Uso: pleum wire [claude|codex]\n",
|
|
164
|
-
"Usage: pleum unwire [
|
|
164
|
+
"Usage: pleum unwire [tool...] [--revoke-key]\n": "Uso: pleum unwire [tool...] [--revoke-key]\n",
|
|
165
165
|
"Example: pleum wire": "Ejemplo: pleum wire",
|
|
166
166
|
"Omit the tool name to wire both claude and codex.": "Si omites el nombre, cablea claude y codex.",
|
|
167
|
+
"Omit the tool name to unwire every wired tool.": "Si omites el nombre, desconecta todas las herramientas conectadas.",
|
|
167
168
|
"persist config in ~/.claude or ~/.codex for Desktop apps": "guardar la config en ~/.claude o ~/.codex para apps Desktop",
|
|
168
|
-
"
|
|
169
|
+
"Restore settings changed by wire (omit the tool = all wired tools)": "Restaura la config que cambió wire (sin herramienta = todas las conectadas)",
|
|
169
170
|
"Unsupported tool: {tool}. Use claude or codex.": "Herramienta no compatible: {tool}. Usa claude o codex.",
|
|
170
171
|
"Wrote {path}": "Escrito en {path}",
|
|
171
172
|
"Wired {tool}.": "Cableado {tool}.",
|
|
173
|
+
"The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "La clave de conexión la comparten todas las herramientas conectadas y launch, así que cambiar el espacio de facturación se aplica a todas.",
|
|
174
|
+
"{path}: not valid JSON — fix or remove it, nothing was written": "{path}: no es JSON válido — corríjalo o elimínelo; no se escribió nada",
|
|
172
175
|
"Desktop key: {prefix}…": "Clave de escritorio: {prefix}…",
|
|
173
176
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
|
|
174
177
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (sin /v1), Bearer, pega la clave plm_.",
|
|
@@ -177,6 +180,9 @@ export const ES = {
|
|
|
177
180
|
"Cursor IDE: Settings → Models → OpenAI Compatible. Base URL {v1}, same plm_ key. Cursor CLI is out of scope.":
|
|
178
181
|
"Cursor IDE: Settings → Models → OpenAI Compatible. Base URL {v1}, la misma clave plm_. Cursor CLI queda fuera de alcance.",
|
|
179
182
|
"Removed PleumRouter settings from {path}.": "Se quitó la config de PleumRouter de {path}.",
|
|
183
|
+
"{tool} is not wired by PleumRouter; left unchanged.": "{tool} no está conectado por PleumRouter; se dejó sin cambios.",
|
|
184
|
+
"No tools are wired by PleumRouter.": "PleumRouter no tiene herramientas conectadas.",
|
|
185
|
+
"Other tools still use this key: {tools}. Run `pleum unwire --revoke-key` to unwire all of them first.": "Otras herramientas aún usan esta clave: {tools}. Antes ejecuta `pleum unwire --revoke-key` para desconectarlas todas.",
|
|
180
186
|
"No stored desktop API key.": "No hay clave API de escritorio guardada.",
|
|
181
187
|
"Revoked desktop API key {id}.": "Se revocó la clave API de escritorio {id}.",
|
|
182
188
|
"Failed to revoke the desktop API key (HTTP {status})": "No se pudo revocar la clave API de escritorio (HTTP {status})",
|
|
@@ -244,6 +250,7 @@ export const ES = {
|
|
|
244
250
|
"↑/↓ move • enter set model • esc back": "↑/↓ mover • enter definir modelo • esc volver",
|
|
245
251
|
"⏹ aborted": "⏹ abortado",
|
|
246
252
|
"⏹ generation aborted — the partial reply stays in context": "⏹ generación abortada — la respuesta parcial permanece en el contexto",
|
|
253
|
+
"partial reply kept in context": "la respuesta parcial se mantuvo en el contexto",
|
|
247
254
|
"✓ installed": "✓ instalado",
|
|
248
255
|
"✗ not installed —": "✗ no instalado —",
|
|
249
256
|
"Log out": "Cerrar sesión",
|
package/src/locales/ja.mjs
CHANGED
|
@@ -99,6 +99,7 @@ export const JA = {
|
|
|
99
99
|
"Session expired. Run:\n pleum login": "セッションの有効期限が切れました。実行してください:\n pleum login",
|
|
100
100
|
"OAuth session": "OAuth セッション",
|
|
101
101
|
"Omit the tool name to wire both claude and codex.": "ツール名を省略すると claude と codex の両方を配線します。",
|
|
102
|
+
"Omit the tool name to unwire every wired tool.": "ツール名を省略すると、配線済みのツールをすべて解除します。",
|
|
102
103
|
"Log in via browser (OAuth session, no API key)": "ブラウザでログイン(OAuth セッション、API キーなし)",
|
|
103
104
|
"Revoke session and clear local tokens": "セッションを取り消し、ローカル トークンを削除",
|
|
104
105
|
"Logged out": "ログアウトしました",
|
|
@@ -136,7 +137,7 @@ export const JA = {
|
|
|
136
137
|
"Print a zsh/bash completion script": "zsh/bash補完スクリプトを出力",
|
|
137
138
|
"Print the version": "バージョンを表示",
|
|
138
139
|
"Provider share": "プロバイダーシェア",
|
|
139
|
-
"
|
|
140
|
+
"Restore settings changed by wire (omit the tool = all wired tools)": "wire が変更した設定を元に戻す(ツール省略で配線済みすべて)",
|
|
140
141
|
"Revoked desktop API key {id}.": "デスクトップ API キー {id} を失効しました。",
|
|
141
142
|
"Run a coding agent against PleumRouter": "PleumRouter に対してコーディングエージェントを実行",
|
|
142
143
|
"Run pleum login again to get a new key (recent keys include it by default).": "新しいキーを取得するには pleum login を再実行してください (最近のキーはデフォルトで含まれます)。",
|
|
@@ -169,6 +170,8 @@ export const JA = {
|
|
|
169
170
|
"Example: pleum wire": "例: pleum wire",
|
|
170
171
|
"Wrote {path}": "{path} に書きました",
|
|
171
172
|
"Wired {tool}.": "{tool} を配線しました。",
|
|
173
|
+
"The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "配線キーは配線済みのすべてのツールと launch で共有されるため、課金ワークスペースを変更するとすべてに適用されます。",
|
|
174
|
+
"{path}: not valid JSON — fix or remove it, nothing was written": "{path}: 有効な JSON ではありません — 修正するか削除してください。何も書き込んでいません",
|
|
172
175
|
"Desktop key: {prefix}…": "デスクトップキー: {prefix}…",
|
|
173
176
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
|
|
174
177
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root}(/v1 なし)、Bearer、plm_ キーを貼り付けます。",
|
|
@@ -177,6 +180,9 @@ export const JA = {
|
|
|
177
180
|
"Cursor IDE: Settings → Models → OpenAI Compatible. Base URL {v1}, same plm_ key. Cursor CLI is out of scope.":
|
|
178
181
|
"Cursor IDE: Settings → Models → OpenAI Compatible。Base URL {v1}、同じ plm_ キー。Cursor CLI は対象外です。",
|
|
179
182
|
"Removed PleumRouter settings from {path}.": "{path} から PleumRouter 設定を削除しました。",
|
|
183
|
+
"{tool} is not wired by PleumRouter; left unchanged.": "{tool} は PleumRouter で配線されていないため、そのままにしました。",
|
|
184
|
+
"No tools are wired by PleumRouter.": "PleumRouter で配線されたツールはありません。",
|
|
185
|
+
"Other tools still use this key: {tools}. Run `pleum unwire --revoke-key` to unwire all of them first.": "他のツールがまだこのキーを使っています: {tools}。先に `pleum unwire --revoke-key` ですべて解除してください。",
|
|
180
186
|
"Failed to create a desktop API key (HTTP {status})": "デスクトップ API キーの発行に失敗しました (HTTP {status})",
|
|
181
187
|
"kept the desktop key on this machine (logout does not delete it)": "このマシンのデスクトップキーは残します(logout では消しません)",
|
|
182
188
|
"Unsupported language: {code}\n Available: {list}": "サポートされていない言語です: {code}\n 利用可能: {list}",
|
|
@@ -188,7 +194,7 @@ export const JA = {
|
|
|
188
194
|
"Usage: pleum launch <tool> [--model id] [-- tool args]\n": "使い方: pleum launch <tool> [--model id] [-- tool args]\n",
|
|
189
195
|
"Usage: pleum models <model-id> --info": "使い方: pleum models <model-id> --info",
|
|
190
196
|
"Usage: pleum run <model> [\"prompt\"]\nModel list: pleum models": "使い方: pleum run <model> [\"prompt\"]\nモデル一覧: pleum models",
|
|
191
|
-
"Usage: pleum unwire [
|
|
197
|
+
"Usage: pleum unwire [tool...] [--revoke-key]\n": "使い方: pleum unwire [tool...] [--revoke-key]\n",
|
|
192
198
|
"Usage: pleum wire [claude|codex]\n": "使い方: pleum wire [claude|codex]\n",
|
|
193
199
|
"Use default (clear)": "デフォルトを使用(解除)",
|
|
194
200
|
"Your spend report (model|provider|api_key|day|tag|user)": "支出レポート (model|provider|api_key|day|tag|user)",
|
|
@@ -244,6 +250,7 @@ export const JA = {
|
|
|
244
250
|
"↑/↓ move • enter set model • esc back": "↑/↓ 移動 • enter モデル設定 • esc 戻る",
|
|
245
251
|
"⏹ aborted": "⏹ 中断しました",
|
|
246
252
|
"⏹ generation aborted — the partial reply stays in context": "⏹ 生成を中断しました — 途中までの応答はコンテキストに残ります",
|
|
253
|
+
"partial reply kept in context": "途中まで受信した応答をコンテキストに残しました",
|
|
247
254
|
"✓ installed": "✓ インストール済み",
|
|
248
255
|
"✗ not installed —": "✗ 未インストール —",
|
|
249
256
|
"Log out": "ログアウト",
|
package/src/locales/ko.mjs
CHANGED
|
@@ -233,6 +233,7 @@ export const KO = {
|
|
|
233
233
|
"{model} thinking…": "{model} 생각 중…",
|
|
234
234
|
"⏹ aborted": "⏹ 중단됨",
|
|
235
235
|
"⏹ generation aborted — the partial reply stays in context": "⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지",
|
|
236
|
+
"partial reply kept in context": "중간까지 받은 응답을 컨텍스트에 남겼습니다",
|
|
236
237
|
"Select a model": "모델 선택",
|
|
237
238
|
"… {n} more — narrow with a search term": "… {n}개 더 — 검색어로 좁히기",
|
|
238
239
|
"Choose a number, or search (Enter to cancel): ": "번호 선택 또는 검색어 (Enter=취소): ",
|
|
@@ -267,14 +268,17 @@ export const KO = {
|
|
|
267
268
|
"Logged out": "로그아웃됨",
|
|
268
269
|
"Unknown command: {cmd}": "알 수 없는 명령: {cmd}",
|
|
269
270
|
"Usage: pleum wire [claude|codex]\n": "사용법: pleum wire [claude|codex]\n",
|
|
270
|
-
"Usage: pleum unwire [
|
|
271
|
+
"Usage: pleum unwire [tool...] [--revoke-key]\n": "사용법: pleum unwire [tool...] [--revoke-key]\n",
|
|
271
272
|
"Example: pleum wire": "예: pleum wire",
|
|
272
273
|
"Omit the tool name to wire both claude and codex.": "도구 이름을 생략하면 claude와 codex를 모두 배선합니다.",
|
|
274
|
+
"Omit the tool name to unwire every wired tool.": "도구 이름을 생략하면 배선된 도구를 모두 해제합니다.",
|
|
273
275
|
"persist config in ~/.claude or ~/.codex for Desktop apps": "Desktop 앱용으로 ~/.claude 또는 ~/.codex에 설정을 저장",
|
|
274
|
-
"
|
|
276
|
+
"Restore settings changed by wire (omit the tool = all wired tools)": "wire가 바꾼 설정을 복원 (도구 생략 = 배선된 도구 전부)",
|
|
275
277
|
"Unsupported tool: {tool}. Use claude or codex.": "지원하지 않는 도구: {tool}. claude 또는 codex를 쓰세요.",
|
|
276
278
|
"Wrote {path}": "{path}에 썼습니다",
|
|
277
279
|
"Wired {tool}.": "{tool}을 배선했습니다.",
|
|
280
|
+
"The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "배선 키는 배선된 모든 도구와 launch가 공유하므로, 과금 공간을 바꾸면 전부에 적용됩니다.",
|
|
281
|
+
"{path}: not valid JSON — fix or remove it, nothing was written": "{path}: JSON 형식이 아닙니다 — 고치거나 삭제하세요. 아무것도 쓰지 않았습니다",
|
|
278
282
|
"Desktop key: {prefix}…": "데스크톱 키: {prefix}…",
|
|
279
283
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
|
|
280
284
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (/v1 없음), Bearer, plm_ 키를 붙여넣으세요.",
|
|
@@ -283,6 +287,9 @@ export const KO = {
|
|
|
283
287
|
"Cursor IDE: Settings → Models → OpenAI Compatible. Base URL {v1}, same plm_ key. Cursor CLI is out of scope.":
|
|
284
288
|
"Cursor IDE: 설정 → Models → OpenAI Compatible. Base URL {v1}, 같은 plm_ 키. Cursor CLI는 범위 밖입니다.",
|
|
285
289
|
"Removed PleumRouter settings from {path}.": "{path}에서 PleumRouter 설정을 제거했습니다.",
|
|
290
|
+
"{tool} is not wired by PleumRouter; left unchanged.": "{tool}은(는) PleumRouter가 배선하지 않아 그대로 둡니다.",
|
|
291
|
+
"No tools are wired by PleumRouter.": "PleumRouter가 배선한 도구가 없습니다.",
|
|
292
|
+
"Other tools still use this key: {tools}. Run `pleum unwire --revoke-key` to unwire all of them first.": "다른 도구가 이 키를 아직 쓰고 있습니다: {tools}. 먼저 `pleum unwire --revoke-key`로 전부 해제하세요.",
|
|
286
293
|
"No stored desktop API key.": "저장된 데스크톱 API 키가 없습니다.",
|
|
287
294
|
"Revoked desktop API key {id}.": "데스크톱 API 키 {id}를 폐기했습니다.",
|
|
288
295
|
"Failed to revoke the desktop API key (HTTP {status})": "데스크톱 API 키 폐기에 실패했습니다 (HTTP {status})",
|
package/src/locales/zh.mjs
CHANGED
|
@@ -161,14 +161,17 @@ export const ZH = {
|
|
|
161
161
|
"Unknown command: /{cmd} (/help)": "未知命令:/{cmd} (/help)",
|
|
162
162
|
"Unknown command: {cmd}": "未知命令:{cmd}",
|
|
163
163
|
"Usage: pleum wire [claude|codex]\n": "用法:pleum wire [claude|codex]\n",
|
|
164
|
-
"Usage: pleum unwire [
|
|
164
|
+
"Usage: pleum unwire [tool...] [--revoke-key]\n": "用法:pleum unwire [tool...] [--revoke-key]\n",
|
|
165
165
|
"Example: pleum wire": "示例:pleum wire",
|
|
166
166
|
"Omit the tool name to wire both claude and codex.": "省略工具名会同时接线 claude 和 codex。",
|
|
167
|
+
"Omit the tool name to unwire every wired tool.": "省略工具名会解除所有已接线的工具。",
|
|
167
168
|
"persist config in ~/.claude or ~/.codex for Desktop apps": "为 Desktop 应用把配置写入 ~/.claude 或 ~/.codex",
|
|
168
|
-
"
|
|
169
|
+
"Restore settings changed by wire (omit the tool = all wired tools)": "还原 wire 修改的设置(省略工具名 = 所有已接线的工具)",
|
|
169
170
|
"Unsupported tool: {tool}. Use claude or codex.": "不支持的工具:{tool}。请使用 claude 或 codex。",
|
|
170
171
|
"Wrote {path}": "已写入 {path}",
|
|
171
172
|
"Wired {tool}.": "已接线 {tool}。",
|
|
173
|
+
"The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "接入密钥由所有已接入的工具和 launch 共享,因此更改计费工作区会对全部生效。",
|
|
174
|
+
"{path}: not valid JSON — fix or remove it, nothing was written": "{path}: 不是有效的 JSON — 请修复或删除,未写入任何内容",
|
|
172
175
|
"Desktop key: {prefix}…": "桌面密钥:{prefix}…",
|
|
173
176
|
"Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
|
|
174
177
|
"Claude Code Desktop:Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference。Gateway base URL {root}(不要 /v1),Bearer,粘贴 plm_ 密钥。",
|
|
@@ -177,6 +180,9 @@ export const ZH = {
|
|
|
177
180
|
"Cursor IDE: Settings → Models → OpenAI Compatible. Base URL {v1}, same plm_ key. Cursor CLI is out of scope.":
|
|
178
181
|
"Cursor IDE:设置 → Models → OpenAI Compatible。Base URL {v1},同一把 plm_ 密钥。Cursor CLI 不在范围内。",
|
|
179
182
|
"Removed PleumRouter settings from {path}.": "已从 {path} 移除 PleumRouter 设置。",
|
|
183
|
+
"{tool} is not wired by PleumRouter; left unchanged.": "{tool} 不是由 PleumRouter 接线的,保持不变。",
|
|
184
|
+
"No tools are wired by PleumRouter.": "没有由 PleumRouter 接线的工具。",
|
|
185
|
+
"Other tools still use this key: {tools}. Run `pleum unwire --revoke-key` to unwire all of them first.": "其他工具仍在使用此密钥:{tools}。请先运行 `pleum unwire --revoke-key` 全部解除。",
|
|
180
186
|
"No stored desktop API key.": "没有保存的桌面 API 密钥。",
|
|
181
187
|
"Revoked desktop API key {id}.": "已撤销桌面 API 密钥 {id}。",
|
|
182
188
|
"Failed to revoke the desktop API key (HTTP {status})": "撤销桌面 API 密钥失败(HTTP {status})",
|
|
@@ -244,6 +250,7 @@ export const ZH = {
|
|
|
244
250
|
"↑/↓ move • enter set model • esc back": "↑/↓ 移动 • enter 设置模型 • esc 返回",
|
|
245
251
|
"⏹ aborted": "⏹ 已中止",
|
|
246
252
|
"⏹ generation aborted — the partial reply stays in context": "⏹ 生成已中止 — 部分回复保留在上下文中",
|
|
253
|
+
"partial reply kept in context": "已接收的部分回复已保留在上下文中",
|
|
247
254
|
"✓ installed": "✓ 已安装",
|
|
248
255
|
"✗ not installed —": "✗ 未安装 —",
|
|
249
256
|
"Log out": "登出",
|
package/src/menu.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { runSettings } from "./settings.mjs";
|
|
|
16
16
|
import { t } from "./i18n.mjs";
|
|
17
17
|
import { parseInstallCommand, runInstallCommand, printManualInstall } from "./install.mjs";
|
|
18
18
|
import { getWireStatus, wire, unwire, WIRE_WARNINGS } from "./wire.mjs";
|
|
19
|
-
import { selectAndBindWorkspace } from "./workspace.mjs";
|
|
19
|
+
import { selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
|
|
20
20
|
|
|
21
21
|
// 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
|
|
22
22
|
// 번역이 확정돼, 같은 프로세스 안에서 언어를 바꿔도(Settings) 이 설명들만 예전 언어로 남는다.
|
|
@@ -74,9 +74,7 @@ export function buildItems({ savedModels = {}, activeContext = { type: "personal
|
|
|
74
74
|
title: t("Chat with a model"),
|
|
75
75
|
desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
|
|
76
76
|
});
|
|
77
|
-
const contextDesc = activeContext
|
|
78
|
-
? `${t("Team")}: ${activeContext.org_name}`
|
|
79
|
-
: t("Personal");
|
|
77
|
+
const contextDesc = workspaceLabel(activeContext);
|
|
80
78
|
items.push(
|
|
81
79
|
{ id: "models", title: t("Model list"), desc: t("All models — price and context included") },
|
|
82
80
|
{ id: "balance", title: t("Balance"), desc: `${t("Check your credit balance")} · ${contextDesc}` },
|
|
@@ -624,7 +622,7 @@ export async function runWireMenu({ loop = true } = {}) {
|
|
|
624
622
|
if (selectedModels.length > 1) {
|
|
625
623
|
const defaultChoice = await select(selectedModels.map((id) => ({ id })), {
|
|
626
624
|
message: `${r.label} — ${t("Select default startup model")}`,
|
|
627
|
-
formatItem: (x) => x.id,
|
|
625
|
+
formatItem: (x) => sanitizeTerminalText(x.id),
|
|
628
626
|
});
|
|
629
627
|
if (defaultChoice) selectedModel = defaultChoice.id;
|
|
630
628
|
} else if (selectedModels.length === 1) {
|
package/src/run.mjs
CHANGED
|
@@ -254,6 +254,7 @@ async function repl(initialModel, initialVerbose, {
|
|
|
254
254
|
let lastSigint = 0;
|
|
255
255
|
let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
|
|
256
256
|
let rl;
|
|
257
|
+
let queue = Promise.resolve(); // 입력 직렬화 큐 — /model 피커가 rl을 다시 만들어도 유지된다
|
|
257
258
|
|
|
258
259
|
const send = async (text) => {
|
|
259
260
|
let added = false;
|
|
@@ -277,7 +278,11 @@ async function repl(initialModel, initialVerbose, {
|
|
|
277
278
|
else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
|
|
278
279
|
} catch (e) {
|
|
279
280
|
console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
280
|
-
if (
|
|
281
|
+
if (e.partialText) {
|
|
282
|
+
// 중간에 끊긴 답도 맥락에 남겨 다음 질문이 이어받게 한다.
|
|
283
|
+
messages.push({ role: "assistant", content: e.partialText });
|
|
284
|
+
console.error(style.dim(t("partial reply kept in context")));
|
|
285
|
+
} else if (added) messages.pop(); // 실패한 user 턴 제거
|
|
281
286
|
} finally {
|
|
282
287
|
currentAbort = null;
|
|
283
288
|
}
|
|
@@ -388,7 +393,10 @@ async function repl(initialModel, initialVerbose, {
|
|
|
388
393
|
history,
|
|
389
394
|
historySize: 1000,
|
|
390
395
|
});
|
|
391
|
-
|
|
396
|
+
// 생성 중 입력은 순서대로 대기한다. 여러 줄을 한 질문으로 보내려면 """ 로 감싼다.
|
|
397
|
+
rl.on("line", (l) => {
|
|
398
|
+
queue = queue.then(() => onLine(l)).catch((e) => console.error(`✗ ${sanitizeTerminalText(e.message)}`));
|
|
399
|
+
});
|
|
392
400
|
rl.on("close", onClose);
|
|
393
401
|
rl.on("SIGINT", onSigint);
|
|
394
402
|
return rl;
|
|
@@ -542,6 +550,7 @@ export async function streamChat(model, messages, {
|
|
|
542
550
|
let cost = null;
|
|
543
551
|
let firstAt = 0;
|
|
544
552
|
let aborted = false;
|
|
553
|
+
let streamError = null; // 서버가 스트림 중간에 보낸 에러 프레임
|
|
545
554
|
const decoder = new TextDecoder();
|
|
546
555
|
try {
|
|
547
556
|
for await (const chunk of res.body) {
|
|
@@ -555,6 +564,8 @@ export async function streamChat(model, messages, {
|
|
|
555
564
|
if (data === "[DONE]") continue;
|
|
556
565
|
try {
|
|
557
566
|
const j = JSON.parse(data);
|
|
567
|
+
// 여기서 throw하면 아래 catch가 삼키므로 기록만 해 두고 루프 뒤에 던진다.
|
|
568
|
+
if (j.error) streamError = { message: j.error.message || "stream error", partial: j.error.partial === true };
|
|
558
569
|
if (j.usage) usage = j.usage;
|
|
559
570
|
if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
|
|
560
571
|
const delta = j.choices?.[0]?.delta?.content;
|
|
@@ -583,6 +594,12 @@ export async function streamChat(model, messages, {
|
|
|
583
594
|
sp.stop();
|
|
584
595
|
md.finish();
|
|
585
596
|
if (aborted) console.error(style.dim(t("⏹ generation aborted — the partial reply stays in context")));
|
|
597
|
+
if (streamError) {
|
|
598
|
+
// 받은 부분 응답을 함께 실어, REPL이 끊긴 답도 맥락에 남길 수 있게 한다.
|
|
599
|
+
const err = new Error(sanitizeTerminalText(streamError.message));
|
|
600
|
+
err.partialText = full;
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
586
603
|
|
|
587
604
|
const ms = Date.now() - t0;
|
|
588
605
|
const genSec = (Date.now() - (firstAt || t0)) / 1000;
|
package/src/settings.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
-
import { style
|
|
9
|
+
import { style } from "./ansi.mjs";
|
|
10
10
|
import { select, confirm } from "./interactive.mjs";
|
|
11
11
|
import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
|
|
12
12
|
import { getLocale, LANGS, t } from "./i18n.mjs";
|
|
@@ -16,6 +16,7 @@ import { login, logout } from "./login.mjs";
|
|
|
16
16
|
import { completion } from "./completion.mjs";
|
|
17
17
|
import { lang } from "./lang.mjs";
|
|
18
18
|
import { switchCreditWorkspace } from "./balance.mjs";
|
|
19
|
+
import { workspaceLabel } from "./workspace.mjs";
|
|
19
20
|
import { runList, saveModel } from "./menu.mjs";
|
|
20
21
|
import { checkLatestVersion, isNewerVersion, runUpdateCommand } from "./update.mjs";
|
|
21
22
|
|
|
@@ -28,11 +29,7 @@ export function buildSettingsItems({
|
|
|
28
29
|
activeContext = { type: "personal" },
|
|
29
30
|
latestVersion = null,
|
|
30
31
|
}) {
|
|
31
|
-
const contextDesc = activeContext?.type === "workspace"
|
|
32
|
-
? `${t("Workspace")}: ${sanitizeTerminalText(activeContext.workspace_name || activeContext.workspace_id)}`
|
|
33
|
-
: activeContext?.type === "team"
|
|
34
|
-
? `${t("Team")}: ${activeContext.org_name || activeContext.org_id}`
|
|
35
|
-
: t("Personal Credit");
|
|
32
|
+
const contextDesc = activeContext?.type === "workspace" ? workspaceLabel(activeContext) : t("Personal Credit");
|
|
36
33
|
const updateDesc = latestVersion && isNewerVersion(version, latestVersion)
|
|
37
34
|
? `${t("Current version: {version}", { version })} → v${latestVersion} (${t("Update available")})`
|
|
38
35
|
: t("Current version: {version}", { version });
|