@oldsuns/pi-switch 0.3.4 → 0.3.5

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 CHANGED
@@ -147,7 +147,8 @@ Session 根目录优先级:非空 `PI_CODING_AGENT_SESSION_DIR` → `<Pi agent
147
147
  - 已同步 provider 的编辑与 model 变更会同步两份文件;不同步项只更新本地库。
148
148
  - 在线导入 model **不会**隐式同步到 Pi。
149
149
  - 启动或手动重载时,以 `models.json` 中同 ID provider 为准回灌本地库;外部从 Pi 删除的 provider 仍作为不同步项保留。
150
- - 从 Pi 移除当前默认 provider 会先确认并清除默认模型;`d` 永久删除本地副本,必要时同时从 Pi 删除。
150
+ - 从 Pi 移除当前默认 provider 会先确认并清除默认模型;`d` 永久删除本地副本,必要时同时从 Pi 删除;若该 provider 在 `auth.json` 有 `api_key` 凭据,删除时会询问是否一并删除(OAuth 等凭据不会被删除)。
151
+ - API 密钥保存位置可在 Settings 里切换:默认写入 Pi 的 `auth.json`(provider 的 ID 映射到 `{"type":"api_key","key":...}`,`models.json` 与 `providers.json` 不保存密钥);可切换回旧的 `models.json` 行为。provider 重命名时凭据随之迁移(重命名并输入新密钥时源 OAuth 凭据保留原位),目标 ID 已有凭据时先询问。切到 `models.json` 后保存密钥会移除同名 ID 上的 auth.json 条目(否则它会继续优先于 models.json);该条目含 `env` 等配置时先询问。回到 `auth.json` 后保存会清掉 provider 文档里遗留的同名明文密钥。
151
152
 
152
153
  ## 模型导入与价格
153
154
 
@@ -193,6 +194,7 @@ Pi `settings.json` 中由 pi-switch 管理的字段:
193
194
  |------|------|
194
195
  | `language` | `en` \| `zh-CN` |
195
196
  | `fetchModelMetadata` | 是否拉 models.dev(默认 `true`) |
197
+ | `keyStorage` | API 密钥保存位置:`auth.json`(默认)或 `models.json` |
196
198
  | `checkForUpdates` | 是否启动时检查 npm 新版本(默认 `true`) |
197
199
  | `modelDefaults` | 关闭实时元数据时的导入缺省(context / maxTokens / cost) |
198
200
 
@@ -203,6 +205,7 @@ Pi `settings.json` 中由 pi-switch 管理的字段:
203
205
  - 写前备份 `providers.json`、Pi `models.json` / `settings.json` 和 pi-switch `settings.json` 到 `~/.pi-switch/backups/`(version 3);最多保留最近 10 份。现有 version 2 备份仍可恢复并自动拆分设置,version 1 备份不支持恢复。
204
206
  - 写入使用 `write.lock` 互斥;异常残留锁时 `doctor` 会提示。
205
207
  - `providers.json` 损坏时归档为 `corrupt-providers-*.json`,再从当前 Pi 配置重建,启动时显示归档路径。
208
+ - `auth.json` 不参与备份;写入前以 `<auth.json>.lock` 目录与 Pi 的 proper-lockfile 互斥,并额外持有 `<auth.json>.lock.guard` 这个 OS 级锁(进程退出时内核自动释放),因此崩溃遗留的锁目录会在 30 秒后被安全接管,不会永久阻塞写入。新文件权限 `0600`,只修改目标 provider 的条目(OAuth 等其他凭据原样保留)。要替换目标 ID 上不属于该 provider 的凭据(重命名落到已有凭据的 ID、或给已有 OAuth 登录的 ID 写 API 密钥)时会先询问,未确认不写入。
206
209
  - 原子写入,只 patch 目标字段,保留未知 JSON;格式错误时停止写入并显示错误。
207
210
  - 支持 Pi 的 `$ENV` / `${ENV}` 插值与 `$$` / `$!` 转义;`!command` 原样保存,在线拉取**不会**执行它。
208
211
  - Session 删除只作用于选中的 JSONL,并校验路径必须位于 session 根目录内;优先调用系统 `trash`,失败后再永久删除。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oldsuns/pi-switch",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "A local terminal and Web UI for Pi provider and model configuration",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file
Binary file
package/web/public/app.js CHANGED
@@ -174,12 +174,15 @@ function openDialog(content, context = null, preserveFocus = false) {
174
174
 
175
175
  function closeDialog() {
176
176
  if (state.busy) return;
177
+ const onCancel = dialogContext?.onCancel;
177
178
  dialogVersion++;
178
179
  dialog.close();
180
+ onCancel?.();
179
181
  }
180
182
 
181
183
  dialog.addEventListener("cancel", (event) => {
182
- if (state.busy) event.preventDefault();
184
+ event.preventDefault();
185
+ closeDialog();
183
186
  });
184
187
  dialog.addEventListener("close", () => {
185
188
  dialogVersion++;
@@ -237,8 +240,8 @@ async function save(action, payload, message, options = {}) {
237
240
  return result;
238
241
  }
239
242
 
240
- function confirm(options, task) {
241
- openDialog(dialogs.confirmationDialog(options), { kind: "confirm", task });
243
+ function confirm(options, task, onCancel = null) {
244
+ openDialog(dialogs.confirmationDialog(options), { kind: "confirm", task, onCancel });
242
245
  }
243
246
 
244
247
  async function refresh() {
@@ -412,10 +415,29 @@ async function submitImport() {
412
415
  if (prepared.ambiguities.length) {
413
416
  openDialog(dialogs.ambiguityDialog(prepared.ambiguities), { kind: "ambiguities", action: "opencode.import", payload: { planId: prepared.planId }, count: prepared.ambiguities.length });
414
417
  } else {
415
- showImportResult(await api("opencode.import", { planId: prepared.planId, candidateIndices: [] }));
418
+ await applyOpenCodeImport({ planId: prepared.planId });
416
419
  }
417
420
  }
418
421
 
422
+ async function applyOpenCodeImport(payload) {
423
+ const result = await api("opencode.import", { ...payload, candidateIndices: [] });
424
+ if (await confirmCredentialOverwrite(result, () => applyOpenCodeImport(payload))) return;
425
+ showImportResult(result);
426
+ }
427
+
428
+ /// Pi resolves auth.json before the provider documents, so replacing an entry
429
+ /// there discards a credential only Pi can restore by signing in again.
430
+ async function confirmCredentialOverwrite(result, retry) {
431
+ if (!result.requiresCredentialOverwrite) return false;
432
+ confirm({
433
+ title: t("覆盖已有的 Pi 凭据?", "Replace the existing Pi credential?"),
434
+ description: t("auth.json 里该 ID 已有 Pi 凭据(例如 OAuth 登录)。继续会丢弃它,Pi 需要重新登录才能恢复。", "auth.json already stores a Pi credential for this ID (an OAuth sign-in, for example). Continuing discards it, and Pi has to sign in again to restore it."),
435
+ label: t("覆盖", "Replace"),
436
+ danger: true,
437
+ }, retry);
438
+ return true;
439
+ }
440
+
419
441
  async function copyText(value) {
420
442
  await navigator.clipboard.writeText(value);
421
443
  toast(t("已复制到剪贴板", "Copied to clipboard"));
@@ -630,8 +652,8 @@ async function handleAction(action, target) {
630
652
  }); break;
631
653
  case "remove-provider":
632
654
  if (!provider) break;
633
- confirm({ title: t("删除 Provider?", "Delete provider?"), description: t("这会从本地库删除此 Provider,并取消其 Pi 同步。关联的默认模型也会清除。", "This deletes the provider from your local library and Pi, and clears its default model if selected."), detail: provider.id, label: t("删除 Provider", "Delete provider"), danger: true },
634
- () => save("provider.remove", { providerId: provider.id }, t("Provider 已删除", "Provider deleted"))); break;
655
+ confirm({ title: t("删除 Provider?", "Delete provider?"), description: t("这会从本地库删除此 Provider,并取消其 Pi 同步。关联的默认模型也会清除。", "This deletes the provider from your local library and Pi, and clears its default model if selected."), detail: provider.id, label: t("删除 Provider", "Delete provider"), danger: true, checkbox: provider.hasAuth ? { label: t("同时删除 auth.json 中的凭据", "Also delete the credential in auth.json"), checked: true } : null },
656
+ () => save("provider.remove", { providerId: provider.id, removeAuth: Boolean(provider.hasAuth && dialog.querySelector("[data-confirm-option]")?.checked) }, t("Provider 已删除", "Provider deleted"))); break;
635
657
  case "sync-provider": {
636
658
  const inPi = !provider.inPi;
637
659
  if (!inPi && state.snapshot.defaultProvider === provider.id) {
@@ -682,6 +704,7 @@ async function handleAction(action, target) {
682
704
  () => save("backups.restore", { name }, t("配置已恢复", "Configuration restored"))); break;
683
705
  }
684
706
  case "language": await execute(() => save("settings.language", { value: target.value }, t("语言设置已保存", "Language preference saved"))); break;
707
+ case "key-storage": await execute(() => save("settings.key-storage", { value: target.value }, t("密钥保存位置已更新", "Key storage preference saved"))); break;
685
708
  case "metadata": await execute(() => save("settings.metadata", { value: target.checked }, t("元数据设置已保存", "Metadata preference saved"))); break;
686
709
  case "auto-updates": await execute(() => save("settings.updates", { value: target.checked }, t("更新设置已保存", "Update preference saved"))); break;
687
710
  case "model-defaults": openDialog(dialogs.defaultsDialog(state.snapshot.modelDefaults), { kind: "defaults" }); break;
@@ -772,9 +795,24 @@ document.addEventListener("submit", (event) => {
772
795
  switch (form.dataset.form) {
773
796
  case "provider": {
774
797
  const draft = dialogs.providerDraft(form, context.provider);
775
- await save("provider.save", { previousId: context.provider?.id ?? null, draft }, t("Provider 已保存", "Provider saved"));
776
- state.providerQuery = ""; state.providerFilter = "all";
777
- navigate("profiles", "provider", draft.id); break;
798
+ const previousId = context.provider?.id ?? null;
799
+ const finish = () => { state.providerQuery = ""; state.providerFilter = "all"; navigate("profiles", "provider", draft.id); };
800
+ const result = await save("provider.save", { previousId, draft }, null, { close: false });
801
+ if (result.requiresCredentialOverwrite) {
802
+ confirm({
803
+ title: t("覆盖已有的 Pi 凭据?", "Replace the existing Pi credential?"),
804
+ description: t("auth.json 里该 ID 已有 Pi 凭据(例如 OAuth 登录)。继续会丢弃它,Pi 需要重新登录才能恢复。", "auth.json already stores a Pi credential for this ID (an OAuth sign-in, for example). Continuing discards it, and Pi has to sign in again to restore it."),
805
+ detail: draft.id,
806
+ label: t("覆盖", "Replace"),
807
+ danger: true,
808
+ },
809
+ () => save("provider.save", { previousId, draft, overwriteCredential: true }, t("Provider 已保存", "Provider saved")).then(finish),
810
+ () => openDialog(dialogs.providerDialog(state.snapshot, draft, Boolean(context.provider)), context));
811
+ return;
812
+ }
813
+ dialog.close();
814
+ toast(t("Provider 已保存", "Provider saved"));
815
+ finish(); break;
778
816
  }
779
817
  case "model": {
780
818
  const draft = dialogs.modelDraft(form);
@@ -788,7 +826,10 @@ document.addEventListener("submit", (event) => {
788
826
  case "import": await submitImport(); break;
789
827
  case "ambiguities": {
790
828
  const candidateIndices = Array.from({ length: context.count }, (_, index) => Number(form.elements["candidate-" + index].value));
791
- showImportResult(await api(context.action, { ...context.payload, candidateIndices })); break;
829
+ const retry = async () => showImportResult(await api(context.action, { ...context.payload, candidateIndices, overwriteCredential: true }));
830
+ const result = await api(context.action, { ...context.payload, candidateIndices });
831
+ if (await confirmCredentialOverwrite(result, retry)) break;
832
+ showImportResult(result); break;
792
833
  }
793
834
  }
794
835
  });
@@ -35,13 +35,13 @@ function numericFields(values) {
35
35
  + field("cacheWriteCost", t("缓存写入 · USD / 1M", "Cache write · USD / 1M"), values.cacheWriteCost, { type: "number", placeholder: "0" });
36
36
  }
37
37
 
38
- export function providerDialog(snapshot, provider) {
38
+ export function providerDialog(snapshot, provider, editing = Boolean(provider)) {
39
39
  const value = provider ?? { id: "", baseUrl: "", api: "openai-completions", apiKey: "", authHeader: true, inPi: true, headers: null, compat: null };
40
40
  const headers = Object.fromEntries(Object.entries(value.headers ?? {}).filter(([key]) => key.toLowerCase() !== "user-agent"));
41
41
  const userAgent = Object.entries(value.headers ?? {}).find(([key]) => key.toLowerCase() === "user-agent")?.[1] ?? "";
42
42
  const compat = Object.fromEntries(Object.entries(value.compat ?? {}).filter(([key]) => key !== "sendSessionAffinityHeaders"));
43
43
  return frame({
44
- title: provider ? t("编辑 Provider", "Edit provider") : t("新建 Provider", "New provider"),
44
+ title: editing ? t("编辑 Provider", "Edit provider") : t("新建 Provider", "New provider"),
45
45
  description: t("连接你的模型服务,将配置保存在本地。", "Connect a model service and keep its configuration locally."),
46
46
  form: "provider",
47
47
  body: `<div class="form-grid">
@@ -80,8 +80,9 @@ export function defaultsDialog(values) {
80
80
  return frame({ title: t("默认模型参数", "Default model parameters"), description: t("关闭 models.dev 元数据后,这些参数用于模型导入。", "Used for model imports when models.dev metadata is disabled."), form: "defaults", body: '<div class="form-grid">' + numericFields(values) + "</div>", footer: cancel() + submit(t("保存参数", "Save parameters")) });
81
81
  }
82
82
 
83
- export function confirmationDialog({ title, description, detail, label, danger = false }) {
84
- return frame({ title, body: `<p class="confirm-description">${h(description)}</p>${detail ? '<div class="confirm-detail">' + h(detail) + "</div>" : ""}`, footer: `<button class="btn quiet" type="button" data-action="close-dialog" autofocus>${t("取消", "Cancel")}</button><button class="btn ${danger ? "danger" : "primary"}" type="button" data-action="confirm" data-submit>${h(label || t("确认", "Confirm"))}</button>` });
83
+ export function confirmationDialog({ title, description, detail, label, danger = false, checkbox = null }) {
84
+ const option = checkbox ? `<p class="confirm-description"><label class="checkbox-label"><input type="checkbox" data-confirm-option ${checkbox.checked ? "checked" : ""}>${h(checkbox.label)}</label></p>` : "";
85
+ return frame({ title, body: `<p class="confirm-description">${h(description)}</p>${detail ? '<div class="confirm-detail">' + h(detail) + "</div>" : ""}${option}`, footer: `<button class="btn quiet" type="button" data-action="close-dialog" autofocus>${t("取消", "Cancel")}</button><button class="btn ${danger ? "danger" : "primary"}" type="button" data-action="confirm" data-submit>${h(label || t("确认", "Confirm"))}</button>` });
85
86
  }
86
87
 
87
88
  export function loadingDialog(title, description) {
@@ -88,7 +88,7 @@ function providerInfo(provider) {
88
88
  </div>
89
89
  </div><div class="provider-meta">
90
90
  <div><div class="meta-label">BASE URL</div><div class="meta-value">${icon("globe")}${h(provider.baseUrl || "—")}</div></div>
91
- <div><div class="meta-label">API KEY</div><div class="meta-value">${icon("key")}${h(apiKey)}</div></div>
91
+ <div><div class="meta-label">API KEY</div><div class="meta-value">${icon("key")}${h(apiKey)}${provider.apiKeySource ? `<span class="meta-hint"> · ${h(provider.apiKeySource)}</span>` : ""}</div></div>
92
92
  </div></div>
93
93
  </section>`;
94
94
  }
@@ -22,6 +22,7 @@ export function settings(state) {
22
22
  ${setting(t("界面语言", "Language"), t("Web 和 TUI 共享这项设置。", "Shared between the Web interface and TUI."), `<select data-action="language" aria-label="${t("界面语言", "Interface language")}"><option value="zh-CN" ${snapshot.language === "zh-CN" ? "selected" : ""}>简体中文</option><option value="en" ${snapshot.language === "en" ? "selected" : ""}>English</option></select>`)}
23
23
  ${setting(t("获取模型元数据", "Fetch model metadata"), t("导入时从 models.dev 获取上下文、价格与模型能力信息。", "Get context limits, pricing, and capabilities from models.dev during import."), toggle("metadata", snapshot.fetchModelMetadata, t("获取模型元数据", "Fetch model metadata")))}
24
24
  ${!snapshot.fetchModelMetadata ? setting(t("默认模型参数", "Default model parameters"), t("未使用在线元数据时,导入模型采用这些缺省值。", "Defaults used when importing without online metadata."), `<button class="btn" data-action="model-defaults">${icon("edit")}${t("编辑", "Edit")}</button>`) : ""}
25
+ ${setting(t("API 密钥保存位置", "API key storage"), t("编辑 Provider 时,密钥写入 Pi 的 auth.json(推荐)还是 models.json。", "When saving a provider, its API key goes to Pi's auth.json (recommended) or models.json."), `<select data-action="key-storage" aria-label="${t("API 密钥保存位置", "API key storage")}"><option value="auth.json" ${snapshot.keyStorage !== "models.json" ? "selected" : ""}>auth.json</option><option value="models.json" ${snapshot.keyStorage === "models.json" ? "selected" : ""}>models.json</option></select>`)}
25
26
  ${setting(t("TUI 启动时检查更新", "Check for updates on TUI startup"), t("控制终端界面的自动检查。Web 可在下方手动检查。", "Controls automatic checks in the terminal interface. Check manually below on the Web."), toggle("auto-updates", snapshot.checkUpdates, t("TUI 启动时检查更新", "Check for updates on TUI startup")))}
26
27
  </section>
27
28
  <section class="panel settings-group"><div class="panel-header"><h2>${icon("shield")}${t("配置维护", "Configuration tools")}</h2></div>
@@ -395,6 +395,7 @@ kbd { display: inline-flex; align-items: center; justify-content: center; min-wi
395
395
  .meta-label { color: var(--muted); font-size: 10px; margin-bottom: 5px; }
396
396
  .meta-value { color: var(--subtext); font: 11px var(--mono); display: flex; gap: 7px; align-items: center; overflow-wrap: anywhere; }
397
397
  .meta-value .icon { color: var(--overlay); width: 13px; height: 13px; }
398
+ .meta-value .meta-hint { color: var(--muted); }
398
399
  .switch { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 44px; cursor: pointer; flex-shrink: 0; }
399
400
  .switch input { opacity: 0; position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
400
401
  .switch-track { display: block; width: 31px; height: 18px; border-radius: 10px; background: var(--switch-track); transition: background 160ms; pointer-events: none; }