@oldsuns/pi-switch 0.3.2 → 0.3.4

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.
@@ -0,0 +1,24 @@
1
+ const STORAGE_KEY = "pi-switch.web-theme";
2
+
3
+ export function getTheme() {
4
+ return document.documentElement.dataset.theme;
5
+ }
6
+
7
+ export function themeName() {
8
+ return getTheme() === "light" ? "Catppuccin Latte" : "Catppuccin Mocha";
9
+ }
10
+
11
+ function applyTheme(theme) {
12
+ document.documentElement.dataset.theme = theme;
13
+ document.querySelector('meta[name="color-scheme"]').content = theme;
14
+ document.querySelector('meta[name="theme-color"]').content =
15
+ getComputedStyle(document.documentElement).getPropertyValue("--base").trim();
16
+ }
17
+
18
+ export function setTheme(theme) {
19
+ if (theme !== "light" && theme !== "dark") throw new Error("Unsupported interface theme: " + theme);
20
+ localStorage.setItem(STORAGE_KEY, theme);
21
+ applyTheme(theme);
22
+ }
23
+
24
+ applyTheme(localStorage.getItem(STORAGE_KEY) ?? getTheme());
@@ -0,0 +1,190 @@
1
+ import { h, t, icon, iconButton, emptyState, searchInput, tokens } from "./ui.js";
2
+
3
+ const cancel = () => '<button class="btn quiet" type="button" data-action="close-dialog">' + t("取消", "Cancel") + "</button>";
4
+ const submit = (label) => '<button class="btn primary" type="submit" data-submit>' + label + "</button>";
5
+ const jsonValue = (value) => value && Object.keys(value).length ? JSON.stringify(value, null, 2) : "";
6
+
7
+ export function frame({ title, description = "", body, footer = "", form = "" }) {
8
+ return `${form ? '<form data-form="' + form + '">' : ""}
9
+ <div class="dialog-header"><div><h2 id="dialog-title">${h(title)}</h2>${description ? "<p>" + h(description) + "</p>" : ""}</div>${iconButton("close-dialog", "close", t("关闭", "Close"))}</div>
10
+ <div class="dialog-body"><div class="dialog-error" id="dialog-error" role="alert"></div>${body}</div>
11
+ <div class="dialog-footer">${footer || '<button class="btn" type="button" data-action="close-dialog" autofocus>' + t("完成", "Done") + "</button>"}</div>
12
+ ${form ? "</form>" : ""}`;
13
+ }
14
+
15
+ function field(name, label, value, options = {}) {
16
+ const id = "field-" + name;
17
+ const help = options.help ? `<span id="${id}-help" class="field-help">${h(options.help)}</span>` : "";
18
+ const attributes = `id="${id}" name="${name}" ${options.required ? "required" : ""} ${options.autofocus ? "autofocus" : ""} ${options.help ? 'aria-describedby="' + id + '-help"' : ""}`;
19
+ const input = options.textarea
20
+ ? `<textarea ${attributes} spellcheck="false" placeholder="${h(options.placeholder || "")}">${h(value)}</textarea>`
21
+ : `<input ${attributes} type="${options.type || "text"}" value="${h(value)}" placeholder="${h(options.placeholder || "")}" ${options.type === "number" ? 'min="' + (options.min ?? 0) + '" step="' + (options.step ?? "any") + '" max="9007199254740991" inputmode="decimal"' : 'autocomplete="off" spellcheck="false"'}>`;
22
+ return `<div class="form-field ${options.full ? "full" : ""}"><label for="${id}">${h(label)}${options.required ? ' <span class="required">*</span>' : ""}</label>${options.type === "password" ? '<div class="field-with-action">' + input + iconButton("reveal-key", "eye", t("显示 / 隐藏密钥", "Show / hide key")) + "</div>" : input}${help}<span class="field-error" data-field-error="${name}"></span></div>`;
23
+ }
24
+
25
+ function apiField(value, apiTypes, provider = false) {
26
+ return `<div class="form-field"><label for="field-api">API ${t("类型", "type")}</label><select id="field-api" name="api"><option value="">${provider ? t("由模型指定", "Set per model") : t("继承 Provider 设置", "Inherit from provider")}</option>${apiTypes.map((api) => '<option value="' + h(api) + '" ' + (value === api ? "selected" : "") + ">" + h(api) + "</option>").join("")}</select></div>`;
27
+ }
28
+
29
+ function numericFields(values) {
30
+ return field("contextWindow", t("上下文窗口", "Context window"), values.contextWindow, { type: "number", min: 1, step: 1, placeholder: "128000", help: t("留空使用 Pi 默认值", "Leave empty for Pi's default") })
31
+ + field("maxTokens", t("最大输出 Token", "Max output tokens"), values.maxTokens, { type: "number", min: 1, step: 1, placeholder: "16384", help: t("留空使用 Pi 默认值", "Leave empty for Pi's default") })
32
+ + field("inputCost", t("输入价格 · USD / 1M", "Input cost · USD / 1M"), values.inputCost, { type: "number", placeholder: "0" })
33
+ + field("outputCost", t("输出价格 · USD / 1M", "Output cost · USD / 1M"), values.outputCost, { type: "number", placeholder: "0" })
34
+ + field("cacheReadCost", t("缓存读取 · USD / 1M", "Cache read · USD / 1M"), values.cacheReadCost, { type: "number", placeholder: "0" })
35
+ + field("cacheWriteCost", t("缓存写入 · USD / 1M", "Cache write · USD / 1M"), values.cacheWriteCost, { type: "number", placeholder: "0" });
36
+ }
37
+
38
+ export function providerDialog(snapshot, provider) {
39
+ const value = provider ?? { id: "", baseUrl: "", api: "openai-completions", apiKey: "", authHeader: true, inPi: true, headers: null, compat: null };
40
+ const headers = Object.fromEntries(Object.entries(value.headers ?? {}).filter(([key]) => key.toLowerCase() !== "user-agent"));
41
+ const userAgent = Object.entries(value.headers ?? {}).find(([key]) => key.toLowerCase() === "user-agent")?.[1] ?? "";
42
+ const compat = Object.fromEntries(Object.entries(value.compat ?? {}).filter(([key]) => key !== "sendSessionAffinityHeaders"));
43
+ return frame({
44
+ title: provider ? t("编辑 Provider", "Edit provider") : t("新建 Provider", "New provider"),
45
+ description: t("连接你的模型服务,将配置保存在本地。", "Connect a model service and keep its configuration locally."),
46
+ form: "provider",
47
+ body: `<div class="form-grid">
48
+ ${field("id", "Provider ID", value.id, { required: true, autofocus: true, placeholder: "my-provider", help: t("在本地库中唯一的名称", "A unique name in your local library") })}
49
+ ${apiField(value.api, snapshot.apiTypes, true)}
50
+ ${field("baseUrl", "Base URL", value.baseUrl, { full: true, type: "url", placeholder: "https://api.example.com/v1", help: t("API 基础地址;留空使用内置默认地址。", "API base URL. Leave empty to use the built-in default.") })}
51
+ ${field("apiKey", "API Key", value.apiKey, { type: "password", full: true, placeholder: "$YOUR_API_KEY", help: t("支持环境变量引用;留空使用 Pi 的 auth.json / CLI 鉴权。", "Environment variable references are supported. Leave empty for Pi auth.json / CLI authentication.") })}
52
+ </div>
53
+ <div class="form-options"><label class="checkbox-label"><input name="inPi" type="checkbox" data-action="draft-inpi" ${value.inPi ? "checked" : ""}>${t("同步到 Pi", "Sync to Pi")}</label><label class="checkbox-label"><input name="authHeader" type="checkbox" ${value.authHeader ? "checked" : ""}>${t("发送 Authorization 请求头", "Send Authorization header")}</label></div>
54
+ ${snapshot.defaultProvider === provider?.id ? `<div class="banner" id="unsync-confirmation" hidden><div><p>${t("取消同步会清除当前默认模型,本地配置仍会保留。", "Removing this provider from Pi clears the current default model. The local configuration is kept.")}</p><label class="checkbox-label"><input name="confirmUnsync" type="checkbox">${t("确认清除默认模型", "I confirm clearing the default model")}</label></div></div>` : ""}
55
+ <details class="advanced"><summary>${t("高级设置 · Headers 与兼容性", "Advanced · Headers and compatibility")}</summary><div class="advanced-content"><div class="form-grid">
56
+ ${field("userAgent", "User-Agent", userAgent, { full: true, placeholder: t("可选", "Optional") })}
57
+ ${field("headers", t("其他 Headers · JSON 对象", "Other headers · JSON object"), jsonValue(headers), { full: true, textarea: true, placeholder: '{"X-Custom-Header": "value"}' })}
58
+ <div class="form-field full"><label class="checkbox-label"><input name="sessionAffinity" type="checkbox" ${value.compat?.sendSessionAffinityHeaders ? "checked" : ""}>${t("Session affinity · 会话亲和性", "Session affinity headers")}</label><span class="field-help">sendSessionAffinityHeaders</span></div>
59
+ ${field("compat", t("其他兼容配置 · JSON 对象", "Other compatibility options · JSON"), jsonValue(compat), { full: true, textarea: true, placeholder: "{}" })}
60
+ </div></div></details>`,
61
+ footer: cancel() + submit(t("保存 Provider", "Save provider")),
62
+ });
63
+ }
64
+
65
+ export function modelDialog(model, copy, apiTypes) {
66
+ const value = model ?? { id: "", name: "", api: "", reasoning: false, input: ["text"] };
67
+ return frame({
68
+ title: copy ? t("复制模型", "Duplicate model") : model ? t("编辑模型", "Edit model") : t("新建模型", "New model"),
69
+ description: t("配置模型标识、能力及参数。未修改的扩展字段会保留。", "Configure model identity, capabilities, and limits. Other extension fields are preserved."),
70
+ form: "model",
71
+ body: `<div class="form-grid">${field("id", "Model ID", value.id, { required: true, autofocus: true, placeholder: "model-id" })}${field("name", t("显示名称", "Display name"), value.name, { placeholder: t("可选,默认显示 Model ID", "Optional, defaults to model ID") })}${apiField(value.api, apiTypes)}<div class="form-field"><label>${t("模型能力", "Capabilities")}</label><label class="checkbox-label"><input name="reasoning" type="checkbox" data-action="draft-reasoning" ${value.reasoning ? "checked" : ""}>${t("支持推理", "Reasoning")}</label></div>
72
+ <div class="form-field full"><label>${t("输入类型", "Input types")}</label><div class="form-options"><label class="checkbox-label"><input name="input" type="checkbox" value="text" ${value.input.includes("text") ? "checked" : ""}>${t("文本", "Text")}</label><label class="checkbox-label"><input name="input" type="checkbox" value="image" ${value.input.includes("image") ? "checked" : ""}>${t("图像", "Image")}</label></div><span class="field-error" data-field-error="input"></span></div>
73
+ </div><details class="advanced"><summary>${t("上下文、输出限制与价格", "Context, output limits, and pricing")}</summary><div class="advanced-content"><div class="form-grid">${numericFields(value)}</div></div></details>
74
+ <div id="thinking-fields" ${value.reasoning ? "" : "hidden"}><details class="advanced"><summary>${t("思考等级映射", "Thinking level mapping")}</summary><div class="advanced-content">${field("thinkingLevelMap", "thinkingLevelMap · JSON", jsonValue(value.thinkingLevelMap), { textarea: true, help: t("将 Pi 思考等级映射到服务商参数,留空使用默认设置。", "Map Pi thinking levels to provider values. Leave empty for defaults."), placeholder: '{"low":"low","medium":"medium","high":"high"}' })}</div></details></div>`,
75
+ footer: `${model && !copy ? '<div class="form-extra-actions">' + iconButton("duplicate-model", "copy", t("复制模型", "Duplicate model"), 'data-model="' + h(model.id) + '"') + iconButton("remove-model", "trash", t("删除模型", "Delete model"), 'data-model="' + h(model.id) + '"', "danger") + "</div>" : ""}${cancel()}${submit(t("保存模型", "Save model"))}`,
76
+ });
77
+ }
78
+
79
+ export function defaultsDialog(values) {
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
+ }
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>` });
85
+ }
86
+
87
+ export function loadingDialog(title, description) {
88
+ return frame({ title, body: `<div class="loading-state" role="status"><span class="spinner"></span>${h(description || t("正在读取…", "Loading…"))}</div>` });
89
+ }
90
+
91
+ export function doctorDialog(checks) {
92
+ return frame({ title: t("配置检查", "Configuration checks"), description: checks.every((check) => check.ok) ? t("所有检查已通过。", "All checks passed.") : t("以下项目需要处理。", "Some items need your attention."), body: '<div class="check-list">' + checks.map((check) => `<div class="check-row">${icon(check.ok ? "checkCircle" : "warning", check.ok ? "green" : "red")}<div><h3>${h(check.label)}</h3><p>${h(check.detail)}</p></div></div>`).join("") + "</div>" });
93
+ }
94
+
95
+ export function backupsDialog(backups) {
96
+ return frame({ title: t("配置备份", "Configuration backups"), description: t("恢复前会先备份当前配置。最多保留最近 10 份备份。", "Your current configuration is backed up before restoration. Up to 10 backups are kept."), body: backups.length ? backups.map((backup) => `<div class="backup-row">${icon("file")}<code>${h(backup.name)}</code><button type="button" class="btn small" data-action="restore-backup" data-backup="${h(backup.name)}">${icon("history")}${t("恢复", "Restore")}</button></div>`).join("") : emptyState(t("还没有备份", "No backups yet"), t("保存配置时,会自动为你创建备份。", "A backup will be created when you save configuration."), "history") });
97
+ }
98
+
99
+ export function selectionDialog(context) {
100
+ const isModels = context.kind === "models";
101
+ const visible = context.items.filter((item) => item.id.toLowerCase().includes(context.query.toLowerCase()));
102
+ return frame({
103
+ title: isModels ? t("在线导入模型", "Import models") : t("从 OpenCode 导入", "Import from OpenCode"),
104
+ description: isModels ? context.providerId : t("选中的 provider 将导入本地库并同步到 Pi。OpenCode 原文件不会修改。", "Selected providers will be imported locally and synced to Pi. Your OpenCode file is kept intact."),
105
+ form: "import",
106
+ body: `<div class="import-tools">${searchInput("import-search", context.query, isModels ? t("搜索模型 ID…", "Search model IDs…") : t("搜索 Provider…", "Search providers…"))}<div class="inline-actions"><button type="button" class="text-button" data-action="select-all-import">${t("全选", "Select all")}</button><span class="subtle-divider">/</span><button type="button" class="text-button" data-action="clear-import">${t("清空", "Clear")}</button></div></div>
107
+ ${!isModels ? '<div class="field-help mono">' + h(context.path) + "</div>" : ""}
108
+ <div class="import-list">${visible.length ? visible.map((item) => `<label class="import-row"><input type="checkbox" data-action="import-selection" value="${h(item.id)}" ${context.selected.has(item.id) ? "checked" : ""}><span class="mono">${h(item.id)}</span>${item.existing ? '<span class="badge">' + t("已存在", "Existing") + "</span>" : isModels ? '<span class="badge accent">' + t("新模型", "New") + "</span>" : ""}</label>`).join("") : emptyState(t("没有匹配项", "No matches"), t("试试其他搜索关键词。", "Try another search."), "search")}</div>
109
+ <div class="import-count">${t("已选择", "Selected")} <span id="import-selected-count">${context.selected.size}</span> / ${context.items.length}</div>
110
+ ${isModels ? '<label class="checkbox-label"><input type="checkbox" data-action="import-overwrite" ' + (context.updateExisting ? "checked" : "") + ">" + t("更新已存在模型的元数据", "Update metadata for existing models") + "</label>" : ""}
111
+ `, footer: cancel() + submit(isModels ? t("导入选中模型", "Import selected models") : t("导入并同步到 Pi", "Import and sync to Pi")),
112
+ });
113
+ }
114
+
115
+ export function ambiguityDialog(ambiguities, warning) {
116
+ return frame({
117
+ title: t("选择模型元数据来源", "Choose model metadata"),
118
+ description: t("同一模型在不同来源中有不同配置,请选择要使用的版本。", "Sources describe this model differently. Choose the metadata to use."),
119
+ form: "ambiguities",
120
+ body: (warning ? '<div class="banner">' + h(warning) + "</div>" : "") + ambiguities.map((ambiguity, index) => `<div class="form-field ambiguity-field"><label for="candidate-${index}">${h(ambiguity.providerId)} / ${h(ambiguity.modelId)}</label><select id="candidate-${index}" name="candidate-${index}" required><option value="">${t("请选择来源…", "Select a source…")}</option>${ambiguity.candidates.map((candidate, candidateIndex) => '<option value="' + candidateIndex + '">' + h(candidate.providerId) + " · " + h(candidate.id) + " · " + tokens(candidate.config.contextWindow) + " context</option>").join("")}</select></div>`).join(""),
121
+ footer: cancel() + submit(t("确认并导入", "Confirm import")),
122
+ });
123
+ }
124
+
125
+ export function helpDialog() {
126
+ const rows = [
127
+ ["1 / 2 / 3 / 4", t("主页 / 配置 / 会话 / 设置", "Overview / Profiles / Sessions / Settings")],
128
+ ["/", t("聚焦当前列表搜索", "Focus the current search")],
129
+ ["↑ ↓ / j k", t("在当前列表中移动", "Move through the current list")],
130
+ ["← → / h l", t("切换 provider / 模型焦点,浏览会话树", "Move between providers / models, browse tree")],
131
+ ["n / e / d / c", t("新建 / 编辑 / 删除 / 复制配置", "New / edit / delete / duplicate configuration")],
132
+ ["Space", t("同步 provider / 设为默认 / 折叠分支", "Sync provider / set default / toggle branch")],
133
+ ["i", t("在线导入模型", "Import models")],
134
+ ["r / b", t("重载 / 浏览备份", "Reload / browse backups")],
135
+ ["v", t("检查配置;会话页切换阅读模式", "Validate; toggle reading on Sessions")],
136
+ ["n / u", t("会话页:仅命名 / 仅用户消息", "Sessions: named only / user messages only")],
137
+ ["Ctrl C", t("会话树中复制选中消息", "Copy the selected message in the tree")],
138
+ ["Esc", t("关闭对话框 / 返回列表", "Close dialog / return to list")],
139
+ ];
140
+ return frame({ title: t("熟悉的快捷键", "Familiar keyboard shortcuts"), description: t("保留 TUI 的操作习惯。输入文字时不会触发快捷键。", "Your TUI habits carry over. Shortcuts pause while typing."), body: '<div class="help-grid">' + rows.map(([keys, label]) => '<div class="help-row"><span>' + label + "</span><span><kbd>" + keys + "</kbd></span></div>").join("") + "</div>" });
141
+ }
142
+
143
+ export class FieldError extends Error {
144
+ constructor(field, message) { super(message); this.field = field; }
145
+ }
146
+
147
+ function objectValue(form, name) {
148
+ const value = form.elements[name].value.trim();
149
+ if (!value) return null;
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(value, (_key, item) => {
153
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error("Non-finite number");
154
+ return item;
155
+ });
156
+ } catch {
157
+ throw new FieldError(name, t("请填写有效的 JSON 对象。", "Enter a valid JSON object."));
158
+ }
159
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new FieldError(name, t("这里需要 JSON 对象,不能是数组或其他值。", "Expected a JSON object, not an array or another value."));
160
+ return parsed;
161
+ }
162
+
163
+ export function providerDraft(form, original) {
164
+ const values = new FormData(form);
165
+ const headers = objectValue(form, "headers") ?? {};
166
+ if (Object.keys(headers).some((key) => key.toLowerCase() === "user-agent")) throw new FieldError("headers", t("请在上方独立的 User-Agent 字段填写此请求头。", "Use the dedicated User-Agent field above."));
167
+ const userAgent = values.get("userAgent").trim();
168
+ if (userAgent) headers["User-Agent"] = userAgent;
169
+ const compat = objectValue(form, "compat") ?? {};
170
+ if (Object.hasOwn(compat, "sendSessionAffinityHeaders")) throw new FieldError("compat", t("请使用上方 Session affinity 开关。", "Use the Session affinity checkbox above."));
171
+ if (values.has("sessionAffinity") || Object.hasOwn(original?.compat ?? {}, "sendSessionAffinityHeaders")) compat.sendSessionAffinityHeaders = values.has("sessionAffinity");
172
+ return { id: values.get("id").trim(), inPi: values.has("inPi"), baseUrl: values.get("baseUrl").trim(), api: values.get("api") || null, apiKey: values.get("apiKey"), authHeader: values.has("authHeader"), headers: Object.keys(headers).length ? headers : null, compat: Object.keys(compat).length ? compat : null };
173
+ }
174
+
175
+ export function numericDraft(form) {
176
+ return Object.fromEntries(["contextWindow", "maxTokens", "inputCost", "outputCost", "cacheReadCost", "cacheWriteCost"].map((name) => {
177
+ const input = form.elements[name];
178
+ if (!input.value.trim()) return [name, null];
179
+ const value = Number(input.value);
180
+ if (!Number.isFinite(value) || value < 0 || (["contextWindow", "maxTokens"].includes(name) && (!Number.isSafeInteger(value) || value === 0))) throw new FieldError(name, t("请填写有效的数值。", "Enter a valid number."));
181
+ return [name, value];
182
+ }));
183
+ }
184
+
185
+ export function modelDraft(form) {
186
+ const values = new FormData(form);
187
+ const input = values.getAll("input");
188
+ if (!input.length) throw new FieldError("input", t("至少选择一种输入类型。", "Select at least one input type."));
189
+ return { id: values.get("id").trim(), name: values.get("name").trim() || null, api: values.get("api") || null, reasoning: values.has("reasoning"), input, ...numericDraft(form), thinkingLevelMap: objectValue(form, "thinkingLevelMap") };
190
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="13" fill="#cba6f7"/><path d="M12 16h26M18 16v19m14-19v15c0 3 1 4 4 4" stroke="#1e1e2e" stroke-width="4" stroke-linecap="round" fill="none"/></svg>
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light">
7
+ <meta name="theme-color" content="#eff1f5">
8
+ <title>pi-switch · Your local model workspace</title>
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
10
+ <link rel="stylesheet" href="/styles.css">
11
+ <script type="module" src="/app.js"></script>
12
+ </head>
13
+ <body>
14
+ <a class="skip-link" href="#main" data-action="skip-to-content">跳到主要内容 / Skip to content</a>
15
+ <div id="app">
16
+ <div class="boot-state" role="status"><span class="brand-mark">π</span><p>pi-switch</p><span>正在读取本地配置 · Loading your workspace</span></div>
17
+ </div>
18
+ <div id="announcer" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
19
+ <div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
20
+ <dialog id="dialog" aria-labelledby="dialog-title"></dialog>
21
+ <noscript>pi-switch Web 需要启用 JavaScript。 / JavaScript is required.</noscript>
22
+ </body>
23
+ </html>
@@ -0,0 +1,83 @@
1
+ import { h, t, icon, initials, tint, number, tokens, modelName, emptyState, date } from "./ui.js";
2
+ import { pageHeader } from "./shell.js";
3
+
4
+ const RECENT_SESSION_LIMIT = 3;
5
+ const PROVIDER_OVERVIEW_LIMIT = 4;
6
+
7
+ function stat(label, value, name, note, color = "") {
8
+ return `<article class="stat-card"><div class="stat-heading"><span>${label}</span>${icon(name)}</div><div class="stat-value ${color}">${value}</div><div class="stat-note">${note}</div></article>`;
9
+ }
10
+
11
+ function defaultSelector(snapshot) {
12
+ const providers = snapshot.providers.filter((provider) => provider.inPi && provider.models.length);
13
+ const validDefault = providers.some((provider) => provider.id === snapshot.defaultProvider && provider.models.some((model) => model.id === snapshot.defaultModel));
14
+ return `<label class="default-selector" for="default-model-select"><span>${t("切换默认模型", "Switch default model")}</span>
15
+ <select id="default-model-select" data-action="choose-default" ${providers.length ? "" : "disabled"}>
16
+ <option value="" disabled ${validDefault ? "" : "selected"}>${t("选择已同步的模型…", "Choose a synced model…")}</option>
17
+ ${providers.map((provider) => `<optgroup label="${h(provider.id)}">${provider.models.map((model) => `<option value="${h(JSON.stringify([provider.id, model.id]))}" ${snapshot.defaultProvider === provider.id && snapshot.defaultModel === model.id ? "selected" : ""}>${h(modelName(model))}</option>`).join("")}</optgroup>`).join("")}
18
+ </select>
19
+ </label>`;
20
+ }
21
+
22
+ function defaultCard(snapshot) {
23
+ const provider = snapshot.providers.find((item) => item.id === snapshot.defaultProvider);
24
+ const model = provider?.models.find((item) => item.id === snapshot.defaultModel);
25
+ const available = Boolean(provider?.inPi && model);
26
+ const invalid = !available && Boolean(snapshot.defaultProvider || snapshot.defaultModel);
27
+ return `<section class="panel default-card">
28
+ <div class="panel-header"><h2>${icon("star")}${t("当前默认模型", "Default model")}</h2>
29
+ <span class="badge ${available ? "success" : invalid ? "warning" : ""}">${available ? icon("check") + t("已同步到 Pi", "Synced to Pi") : invalid ? t("需要检查", "Needs attention") : t("未设置", "Not set")}</span>
30
+ </div>
31
+ ${available ? `<div class="default-content"><div class="default-provider">${h(provider.id)}</div><div class="default-name">${h(modelName(model))}</div><div class="default-id">${h(model.id)}</div>
32
+ <div class="model-facts"><span class="model-fact">${icon("cpu")}${tokens(model.contextWindow)} ${t("上下文", "context")}</span><span class="model-fact">${icon("bolt")}${tokens(model.maxTokens)} ${t("输出", "output")}</span>${model.reasoning ? `<span class="model-fact">${icon("brain")}${t("推理", "Reasoning")}</span>` : ""}${model.input.includes("image") ? `<span class="model-fact">${icon("image")}${t("视觉", "Vision")}</span>` : ""}</div>
33
+ </div>` : `<div class="default-empty"><h3>${invalid ? t("默认模型当前不可用", "The default model is unavailable") : t("尚未选择默认模型", "No default model selected")}</h3><p>${invalid ? t("当前默认配置不在 Pi 的同步列表中,请重新选择或检查配置。", "The default is outside the synced configuration. Choose another model or validate your configuration.") : t("从下方选择模型,设置下一次 Pi 对话使用的默认值。", "Choose the model Pi should use for your next conversation.")}</p></div>`}
34
+ <div class="default-bottom">${defaultSelector(snapshot)}<a class="text-button" href="#profiles${provider ? "?provider=" + encodeURIComponent(provider.id) : ""}">${t("管理模型", "Manage models")}${icon("arrow")}</a></div>
35
+ </section>`;
36
+ }
37
+
38
+ function recentSessions(state) {
39
+ const recent = [...state.sessions].sort((left, right) => new Date(right.modifiedAt) - new Date(left.modifiedAt)).slice(0, RECENT_SESSION_LIMIT);
40
+ let content;
41
+ if (state.sessionsError) {
42
+ content = '<div class="panel-body"><div class="banner error" role="alert">' + h(state.sessionsError) + "</div></div>";
43
+ } else if (!state.sessionsLoaded) {
44
+ content = '<div class="loading-state" role="status"><span class="spinner"></span>' + t("正在读取会话…", "Loading sessions…") + "</div>";
45
+ } else if (!recent.length) {
46
+ content = emptyState(t("还没有会话记录", "No sessions yet"), t("使用 Pi 开始对话后,会话会出现在这里。", "Your conversations will appear here after using Pi."), "messages");
47
+ } else {
48
+ content = recent.map((session) => `<a class="recent-session" href="#sessions?session=${encodeURIComponent(session.id)}">
49
+ <span class="recent-session-icon">${icon("messages")}</span><span class="recent-session-content"><span class="item-title">${h(session.title)}</span><span class="recent-session-meta"><span class="recent-workspace" title="${h(session.cwd)}">${icon("folder")}${h(session.cwd.split(/[/\\]/).filter(Boolean).at(-1) || session.cwd)}</span><span>${session.messageCount} ${t("条消息", "messages")}</span><span>${date(session.modifiedAt, true)}</span></span></span>${icon("chevron")}
50
+ </a>`).join("");
51
+ }
52
+ return `<section class="panel recent-card"><div class="panel-header"><h2>${icon("messages")}${t("最近会话", "Recent sessions")}</h2><a class="text-button" href="#sessions">${t("查看全部", "View all")}${icon("arrow")}</a></div>${content}</section>`;
53
+ }
54
+
55
+ export function overview(state) {
56
+ const snapshot = state.snapshot;
57
+ const providers = snapshot.providers;
58
+ const synced = providers.filter((provider) => provider.inPi);
59
+ const modelCount = providers.reduce((sum, provider) => sum + provider.models.length, 0);
60
+ const syncedCount = synced.reduce((sum, provider) => sum + provider.models.length, 0);
61
+ const headerActions = `<button class="btn" data-action="doctor">${icon("shield")}${t("检查配置", "Validate")}</button><button class="btn primary" data-action="new-provider">${icon("plus")}${t("新建 Provider", "New provider")}</button>`;
62
+ return pageHeader(t("工作台", "Overview"), t("查看当前配置,切换默认模型,浏览最近的会话。", "Review your configuration, switch the default model, and browse recent sessions."), headerActions)
63
+ + `<div class="stats-grid">
64
+ ${stat("Providers", number(providers.length), "box", `${number(synced.length)} ${t("个已同步到 Pi", "synced to Pi")}`)}
65
+ ${stat(t("本地模型", "Local models"), number(modelCount), "cpu", t("所有 provider 的模型", "Across all providers"), "mauve")}
66
+ ${stat(t("Pi 可用模型", "Models in Pi"), number(syncedCount), "bolt", t("来自已同步的 provider", "From synced providers"), "green")}
67
+ ${stat(t("本地会话", "Local sessions"), state.sessionsError ? "—" : state.sessionsLoaded ? number(state.sessions.length) : "…", "messages", t("按工作目录整理", "Organized by workspace"))}
68
+ </div>
69
+ <div class="overview-grid">
70
+ ${defaultCard(snapshot)}
71
+ ${recentSessions(state)}
72
+ <section class="panel"><div class="panel-header"><h2>${t("Provider 概览", "Your providers")}<span class="count">${providers.length}</span></h2><a href="#profiles" class="text-button">${t("管理配置", "Manage")}${icon("arrow")}</a></div>
73
+ ${providers.length ? providers.slice(0, PROVIDER_OVERVIEW_LIMIT).map((provider) => `<a class="provider-summary" href="#profiles?provider=${encodeURIComponent(provider.id)}">
74
+ <span class="avatar ${tint(provider.id)}">${initials(provider.id)}</span><div><div class="item-title">${h(provider.id)}</div><div class="item-subtitle">${h(provider.baseUrl || provider.api)}</div></div><span class="provider-count">${provider.models.length} models</span><span class="badge ${provider.inPi ? "success" : ""}">${provider.inPi ? icon("check") + t("已同步", "Synced") : t("仅本地", "Local only")}</span>
75
+ </a>`).join("") : emptyState(t("添加你的第一个 Provider", "Add your first provider"), t("手动添加,或从已有的 OpenCode 配置导入。", "Add a provider or import an existing OpenCode configuration."), "box", `<button class="btn" data-action="opencode">${icon("download")}${t("从 OpenCode 导入", "Import from OpenCode")}</button>`)}
76
+ </section>
77
+ <section class="panel"><div class="panel-header"><h2>${t("常用操作", "Quick actions")}</h2></div><div class="quick-actions">
78
+ <button class="quick-action" data-action="opencode" aria-label="${t("从 OpenCode 导入", "Import from OpenCode")}">${icon("download")}<div><h3>${t("从 OpenCode 导入", "Import from OpenCode")}</h3><p>${t("选择已有配置并同步到 Pi", "Import selected providers and sync to Pi")}</p></div>${icon("chevron")}</button>
79
+ <button class="quick-action" data-action="backups" aria-label="${t("浏览配置备份", "Browse backups")}">${icon("history")}<div><h3>${t("浏览配置备份", "Browse backups")}</h3><p>${t("查看和恢复之前的配置", "View and restore saved configurations")}</p></div>${icon("chevron")}</button>
80
+ <button class="quick-action" data-action="reload" aria-label="${t("重新读取配置", "Reload configuration")}">${icon("refresh")}<div><h3>${t("重新读取配置", "Reload configuration")}</h3><p>${t("获取在 Pi 或 TUI 中的最新修改", "Read the latest changes made in Pi or the TUI")}</p></div>${icon("chevron")}</button>
81
+ </div></section>
82
+ </div>`;
83
+ }
@@ -0,0 +1,47 @@
1
+ import { modelName } from "./ui.js";
2
+
3
+ const names = new Intl.Collator("zh-CN", { sensitivity: "base", numeric: true });
4
+
5
+ export function orderedItems(items, ordering) {
6
+ const ranks = new Map(ordering.order.map((id, index) => [id, index]));
7
+ const byPosition = (left, right) => ranks.get(left.id) - ranks.get(right.id);
8
+ if (ordering.sort === "custom") return [...items].sort(byPosition);
9
+ const direction = ordering.sort.endsWith("-desc") ? -1 : 1;
10
+ return [...items].sort((left, right) => {
11
+ let compared;
12
+ if (ordering.sort.startsWith("name-")) {
13
+ compared = names.compare(modelName(left), modelName(right));
14
+ } else {
15
+ const leftDate = ordering.addedAt[left.id];
16
+ const rightDate = ordering.addedAt[right.id];
17
+ if (leftDate == null || rightDate == null) {
18
+ if (leftDate == null && rightDate == null) return byPosition(left, right);
19
+ return leftDate == null ? 1 : -1;
20
+ }
21
+ compared = Date.parse(leftDate) - Date.parse(rightDate);
22
+ }
23
+ return compared * direction || byPosition(left, right);
24
+ });
25
+ }
26
+
27
+ export function orderSnapshot(snapshot) {
28
+ return {
29
+ ...snapshot,
30
+ providers: orderedItems(snapshot.providers, snapshot.ordering.providers).map((provider) => ({
31
+ ...provider, models: orderedItems(provider.models, snapshot.ordering.models[provider.id]),
32
+ })),
33
+ };
34
+ }
35
+
36
+ export function reorderedVisibleIds(items, visible, sourceId, insertionIndex) {
37
+ const visibleIds = visible.map((item) => item.id);
38
+ const sourceIndex = visibleIds.indexOf(sourceId);
39
+ if (sourceIndex < 0) throw new Error("The item is no longer in the visible list.");
40
+ if (!Number.isInteger(insertionIndex) || insertionIndex < 0 || insertionIndex > visibleIds.length) throw new Error("Invalid list insertion position.");
41
+ const reordered = visibleIds.filter((id) => id !== sourceId);
42
+ reordered.splice(insertionIndex - Number(insertionIndex > sourceIndex), 0, sourceId);
43
+ const visibleSet = new Set(visibleIds);
44
+ let index = 0;
45
+ // Filtered-out entries keep their slots when the visible subset is moved.
46
+ return items.map((item) => visibleSet.has(item.id) ? reordered[index++] : item.id);
47
+ }
@@ -0,0 +1,134 @@
1
+ import { h, t, icon, initials, tint, tokens, price, modelName, capabilities, emptyState, listSearch, iconButton } from "./ui.js";
2
+ import { pageHeader, contextHelp } from "./shell.js";
3
+
4
+ const API_KEY_EDGE_LENGTH = 4;
5
+
6
+ function sortControl(scope, ordering, providerId = "") {
7
+ const id = scope === "providers" ? "provider-sort" : "model-sort";
8
+ const options = [
9
+ ["custom", t("自定义顺序", "Custom order")],
10
+ ["name-asc", t("名称 · A → Z", "Name · A → Z")],
11
+ ["name-desc", t("名称 · Z → A", "Name · Z → A")],
12
+ ["added-asc", t("添加时间升序", "Added ↑")],
13
+ ["added-desc", t("添加时间降序", "Added ↓")],
14
+ ];
15
+ return `<label class="profile-sort" for="${id}"><span ${scope === "providers" ? 'class="sr-only"' : ""}>${t("排序", "Sort")}</span><select id="${id}" data-action="profile-sort" data-scope="${scope}" data-provider="${h(providerId)}">${options.map(([value, label]) => `<option value="${value}" ${ordering.sort === value ? "selected" : ""}>${label}</option>`).join("")}</select></label>`;
16
+ }
17
+
18
+ function orderHandle({ scope, item, providerId = "" }) {
19
+ const attributes = `data-scope="${scope}" data-item="${h(item.id)}" data-provider="${h(providerId)}"`;
20
+ const name = modelName(item);
21
+ return `<button type="button" class="icon-button order-handle" draggable="true" data-order-handle ${attributes} aria-label="${h(t("拖动排序;Alt + ↑ ↓ 调整顺序", "Drag to reorder; Alt + arrows to move") + " · " + name)}" title="${t("拖动排序 · Alt + ↑ ↓", "Drag to reorder · Alt + ↑ ↓")}">${icon("grip")}</button>`;
22
+ }
23
+
24
+ export function selectedProvider(state) {
25
+ return state.snapshot.providers.find((provider) => provider.id === state.providerId);
26
+ }
27
+
28
+ export function visibleProviders(state) {
29
+ return state.snapshot.providers.filter((provider) =>
30
+ provider.id.toLowerCase().includes(state.providerQuery.toLowerCase())
31
+ && (state.providerFilter === "all" || provider.inPi === (state.providerFilter === "synced")));
32
+ }
33
+
34
+ export function visibleModels(state) {
35
+ const query = state.modelQuery.toLowerCase();
36
+ return (selectedProvider(state)?.models ?? []).filter((model) =>
37
+ model.id.toLowerCase().includes(query) || model.name?.toLowerCase().includes(query));
38
+ }
39
+
40
+ function providerSyncButton(provider) {
41
+ const label = t("同步到 Pi", "Sync to Pi") + " · " + provider.id;
42
+ const hint = provider.inPi
43
+ ? t("已同步到 Pi,点击取消同步", "Synced to Pi; click to remove")
44
+ : t("仅保存在本地,点击同步到 Pi", "Local only; click to sync to Pi");
45
+ return `<button type="button" class="icon-button provider-sync-button" data-action="sync-provider" data-provider="${h(provider.id)}" aria-pressed="${provider.inPi}" aria-label="${h(label)}" title="${h(hint)}">${icon(provider.inPi ? "check" : "unlink")}</button>`;
46
+ }
47
+
48
+ function providerList(state) {
49
+ const providers = visibleProviders(state);
50
+ const ordering = state.snapshot.ordering.providers;
51
+ const custom = ordering.sort === "custom";
52
+ const search = listSearch({ scope: "provider", query: state.providerQuery, open: state.providerSearchOpen, label: t("搜索 Provider", "Search providers") });
53
+ return `<section class="panel provider-panel" aria-label="${t("Provider 列表", "Provider list")}">
54
+ <div class="panel-header"><h2>PROVIDERS<span class="count">${state.snapshot.providers.length}</span></h2><div class="inline-actions">${search.button}${iconButton("new-provider", "plus", t("新建 Provider", "New provider"))}</div></div>
55
+ ${search.field}
56
+ <div class="provider-filters profile-filter-row">
57
+ <label class="profile-filter" for="provider-filter"><span class="sr-only">${t("同步状态筛选", "Filter by sync status")}</span><select id="provider-filter" data-action="provider-filter">
58
+ ${[["all", t("全部", "All")], ["synced", t("已同步", "Synced")], ["local", t("未同步", "Not synced")]].map(([value, label]) => `<option value="${value}" ${state.providerFilter === value ? "selected" : ""}>${label}</option>`).join("")}
59
+ </select></label>
60
+ ${sortControl("providers", ordering)}
61
+ </div>
62
+ <div class="provider-list" data-order-list data-order-scope="providers" data-order-provider="" aria-label="${t("选择 Provider", "Select provider")}">
63
+ ${providers.length ? providers.map((provider) => `<div class="provider-row" data-order-item data-order-scope="providers" data-order-id="${h(provider.id)}" data-order-provider="">${providerSyncButton(provider)}<button class="provider-option" data-action="select-provider" data-provider="${h(provider.id)}" aria-current="${provider.id === state.providerId}">
64
+ <span class="item-title">${h(provider.id)}</span>
65
+ ${provider.id === state.snapshot.defaultProvider ? icon("star") : ""}<span class="provider-count">${provider.models.length}</span>
66
+ </button>${custom ? orderHandle({ scope: "providers", item: provider }) : ""}</div>`).join("") : emptyState(t("没有匹配的 Provider", "No matching providers"), t("试试其他关键词或同步状态。", "Try another search or sync filter."), "search")}
67
+ </div>
68
+ </section>`;
69
+ }
70
+
71
+ function apiKeyPreview(value) {
72
+ if (!value) return "auth.json / CLI";
73
+ if (/^[$!]/.test(value)) return value;
74
+ const characters = Array.from(value);
75
+ const mask = "••••••••";
76
+ if (characters.length <= API_KEY_EDGE_LENGTH * 2) return mask;
77
+ return characters.slice(0, API_KEY_EDGE_LENGTH).join("") + mask + characters.slice(-API_KEY_EDGE_LENGTH).join("");
78
+ }
79
+
80
+ function providerInfo(provider) {
81
+ const apiKey = apiKeyPreview(provider.apiKey);
82
+ return `<section class="panel">
83
+ <div class="provider-info"><div class="provider-title-row">
84
+ <span class="avatar ${tint(provider.id)}">${initials(provider.id)}</span>
85
+ <div><h2>${h(provider.id)}</h2><div class="provider-title-label">${h(provider.api || t("按模型设置 API", "API set per model"))}</div></div>
86
+ <div class="inline-actions">
87
+ <button class="btn small" data-action="edit-provider">${icon("edit")}${t("编辑", "Edit")}</button>${iconButton("duplicate-provider", "copy", t("复制 Provider", "Duplicate provider"))}${iconButton("remove-provider", "trash", t("删除 Provider", "Delete provider"), "", "danger")}
88
+ </div>
89
+ </div><div class="provider-meta">
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>
92
+ </div></div>
93
+ </section>`;
94
+ }
95
+
96
+ function modelTable(state, provider) {
97
+ const models = visibleModels(state);
98
+ const ordering = state.snapshot.ordering.models[provider.id];
99
+ const custom = ordering.sort === "custom";
100
+ const search = listSearch({ scope: "model", query: state.modelQuery, open: state.modelSearchOpen, label: t("搜索模型", "Search models") });
101
+ return `<section class="panel models-panel" aria-label="${t("模型列表", "Models")}">
102
+ <div class="panel-header"><h2>${icon("cpu")}${t("模型", "Models")}<span class="count">${provider.models.length}</span></h2><div class="inline-actions">
103
+ <button class="btn small" data-action="import-models">${icon("download")}${t("在线导入", "Import models")}</button>${sortControl("models", ordering, provider.id)}${search.button}${iconButton("new-model", "plus", t("新建模型", "New model"))}
104
+ </div></div>
105
+ ${search.field}
106
+ ${models.length ? `<div class="table-wrap"><table><thead><tr><th>${t("模型名称", "MODEL")}</th><th>${t("能力", "CAPABILITIES")}</th><th>${t("上下文 / 输出", "CONTEXT / OUTPUT")}</th><th class="optional-column">${t("输入 / 输出价格", "INPUT / OUTPUT")}</th><th><span class="sr-only">${t("操作", "Actions")}</span></th></tr></thead><tbody data-order-list data-order-scope="models" data-order-provider="${h(provider.id)}">
107
+ ${models.map((model) => {
108
+ const isDefault = state.snapshot.defaultProvider === provider.id && state.snapshot.defaultModel === model.id;
109
+ return `<tr class="model-row ${state.modelId === model.id ? "selected" : ""}" tabindex="0" data-action="select-model" data-model="${h(model.id)}" data-order-item data-order-scope="models" data-order-id="${h(model.id)}" data-order-provider="${h(provider.id)}" aria-label="${h(modelName(model))}${isDefault ? " · " + t("默认模型", "Default model") : ""}">
110
+ <td><div class="model-title">${h(modelName(model))}${isDefault ? icon("star") : ""}</div><div class="model-id">${h(model.id)}</div></td>
111
+ <td><div class="capabilities">${capabilities(model)}</div></td>
112
+ <td class="number-cell">${tokens(model.contextWindow)}<span class="subtle-divider">/</span><span class="muted">${tokens(model.maxTokens)}</span></td>
113
+ <td class="number-cell optional-column">${price(model.inputCost)}<span class="subtle-divider">/</span>${price(model.outputCost)}</td>
114
+ <td><div class="model-actions">${custom ? orderHandle({ scope: "models", item: model, providerId: provider.id }) : ""}<span class="model-main-actions">
115
+ ${iconButton("default-model", "star", isDefault ? t("当前默认模型", "Current default model") : provider.inPi ? t("设为默认模型", "Set as default") + " · " + modelName(model) : t("先同步 Provider,再设为默认", "Sync this provider before setting a default"), 'data-model="' + h(model.id) + '" ' + (!provider.inPi || isDefault ? "disabled" : ""), isDefault ? "active" : "")}
116
+ ${iconButton("edit-model", "edit", t("编辑模型", "Edit model") + " · " + modelName(model), 'data-model="' + h(model.id) + '"')}
117
+ ${iconButton("remove-model", "trash", t("删除模型", "Delete model") + " · " + modelName(model), 'data-provider="' + h(provider.id) + '" data-model="' + h(model.id) + '"', "danger")}
118
+ </span></div></td>
119
+ </tr>`;
120
+ }).join("")}
121
+ </tbody></table></div>` : emptyState(provider.models.length ? t("没有匹配的模型", "No matching models") : t("给这个 Provider 添加模型", "Add models to this provider"), provider.models.length ? t("试试其他模型名称或 ID。", "Try another model name or ID.") : t("从服务商在线获取模型,或手动填写模型配置。", "Fetch available models from the provider, or add one manually."), "cpu", provider.models.length ? "" : `<button class="btn primary" data-action="import-models">${icon("download")}${t("在线导入模型", "Import models")}</button>`)}
122
+ <div class="models-foot"><span>${t("显示", "Showing")} ${models.length} / ${provider.models.length} ${t("个模型", "models")}</span><span>${t("价格单位:USD / 1M tokens", "Prices in USD / 1M tokens")}<span class="subtle-divider">·</span>${icon("star")} ${t("默认模型", "Default")}</span></div>
123
+ </section>`;
124
+ }
125
+
126
+ export function profiles(state) {
127
+ const provider = selectedProvider(state);
128
+ const header = pageHeader(t("模型配置", "Model configuration"), "", `<button class="btn" data-action="opencode">${icon("download")}${t("从 OpenCode 导入", "Import OpenCode")}</button><button class="btn primary" data-action="new-provider">${icon("plus")}${t("新建 Provider", "New provider")}</button>`);
129
+ if (!state.snapshot.providers.length) {
130
+ return header + `<section class="panel">${emptyState(t("添加你的第一个 Provider", "Add your first provider"), t("支持 OpenAI、Anthropic、Google 兼容 API,也可以连接自己的模型网关。", "Connect OpenAI, Anthropic, Google compatible APIs, or your own model gateway."), "sliders", `<div class="inline-actions"><button class="btn primary" data-action="new-provider">${icon("plus")}${t("新建 Provider", "New provider")}</button><button class="btn" data-action="opencode">${icon("download")}${t("从 OpenCode 导入", "Import OpenCode")}</button></div>`)}</section>`;
131
+ }
132
+ return header + `<div class="profiles-layout">${providerList(state)}<div class="provider-detail">${provider ? providerInfo(provider) + modelTable(state, provider) : `<section class="panel">${emptyState(t("选择一个 Provider", "Select a provider"), t("在左侧选择 provider 来管理模型。", "Select a provider to manage its models."), "sliders")}</section>`}</div></div>`
133
+ + contextHelp([["↑ ↓", t("选择", "Select")], ["Alt ↑ ↓", t("自定义排序", "Move in custom order")], ["n", t("新建", "New")], ["e", t("编辑", "Edit")], ["Space", t("同步 / 设为默认", "Sync / set default")], ["/", t("筛选", "Filter")], ["?", t("更多快捷键", "More shortcuts")]]);
134
+ }