@spzxg/cli-ui 0.6.2 → 0.6.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/CHANGELOG.md +26 -0
- package/README.md +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +73 -30
- package/src/chat/Composer.tsx +41 -41
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +8 -6
- package/src/core/conversation.ts +3 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +20 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Per-provider auth panel for agents that front many model providers (Pi):
|
|
4
|
+
// which providers can run right now, OAuth / API-key logins driven entirely in
|
|
5
|
+
// the browser (auth links, device codes, pasted codes, choices), logout, and
|
|
6
|
+
// user-defined endpoints. Talks to the generic /api/agents/[id]/providers/*
|
|
7
|
+
// routes; rendered by AgentConnect when capabilities.supportsProviderAuth.
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
10
|
+
import { Icon } from "./icons";
|
|
11
|
+
import { jget, jsend } from "./api";
|
|
12
|
+
import type { AgentInfoT, ClaudeVerifyT } from "./types";
|
|
13
|
+
import type { AgentLoginEvent, AgentLoginPrompt, AgentProviderInfo, AgentProviderLogin } from "./protocol/agents/types";
|
|
14
|
+
|
|
15
|
+
// The global .field rules style text/number inputs only; selects and password
|
|
16
|
+
// inputs here get the same look inline instead of widening those shared rules.
|
|
17
|
+
const control: CSSProperties = {
|
|
18
|
+
border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)",
|
|
19
|
+
padding: "9px 12px", fontSize: 13, color: "var(--ink)", outline: "none", minWidth: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// fetch() rejects with a bare TypeError ("Failed to fetch") when the server is
|
|
23
|
+
// unreachable — say that in words a person can act on.
|
|
24
|
+
const errText = (e: unknown) => {
|
|
25
|
+
if (e instanceof TypeError && /fetch|network|load failed/i.test(e.message)) return "连不上 Operator 服务(服务已停止或网络中断),请刷新页面后重试";
|
|
26
|
+
return (e instanceof Error ? e.message : String(e)).replace(/^\d+\s*/, "");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const API_TYPES: { value: string; label: string }[] = [
|
|
30
|
+
{ value: "openai-completions", label: "OpenAI 兼容(Chat Completions)" },
|
|
31
|
+
{ value: "openai-responses", label: "OpenAI Responses" },
|
|
32
|
+
{ value: "anthropic-messages", label: "Anthropic 兼容(Messages)" },
|
|
33
|
+
{ value: "google-generative-ai", label: "Google Gemini 兼容" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function sourceLabel(p: AgentProviderInfo): string {
|
|
37
|
+
if (p.source === "stored credential") return p.authType === "oauth" ? (p.oauth?.subscription ? "订阅账号登录" : "OAuth 登录") : "API Key(已保存)";
|
|
38
|
+
if (p.source === "configured API key") return "配置文件中的 Key";
|
|
39
|
+
return p.source ? `本机凭据:${p.source}` : "已配置";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ProviderAuthPanel({ agent, onConnected }: { agent: AgentInfoT; onConnected?: () => void }) {
|
|
43
|
+
const base = `/api/agents/${agent.id}/providers`;
|
|
44
|
+
const [providers, setProviders] = useState<AgentProviderInfo[] | null>(null);
|
|
45
|
+
const [listError, setListError] = useState<string | null>(null);
|
|
46
|
+
const [loading, setLoading] = useState(false);
|
|
47
|
+
const [pick, setPick] = useState("");
|
|
48
|
+
const [login, setLogin] = useState<AgentProviderLogin | null>(null);
|
|
49
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
50
|
+
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
|
51
|
+
const [editing, setEditing] = useState<AgentProviderInfo | "new" | null>(null);
|
|
52
|
+
// Which provider's "API Key / address" form is open.
|
|
53
|
+
const [keyFor, setKeyFor] = useState<string | null>(null);
|
|
54
|
+
// Bumped by every user action, so a slow verify (Pi retries network errors)
|
|
55
|
+
// can't overwrite the note of something done after it started.
|
|
56
|
+
const opSeq = useRef(0);
|
|
57
|
+
const say = (seq: number, n: { ok: boolean; text: string } | null) => { if (seq === opSeq.current) setNote(n); };
|
|
58
|
+
|
|
59
|
+
const load = useCallback(async () => {
|
|
60
|
+
setLoading(true);
|
|
61
|
+
try {
|
|
62
|
+
const r = await jget<{ providers: AgentProviderInfo[]; error: string | null }>(base);
|
|
63
|
+
setProviders(r.providers);
|
|
64
|
+
setListError(r.error);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
setListError(errText(e));
|
|
67
|
+
setProviders((p) => p ?? []);
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}, [base]);
|
|
72
|
+
useEffect(() => { void load(); }, [load]);
|
|
73
|
+
|
|
74
|
+
// Rejoin a login already underway (card remounted / page reloaded).
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
jget<AgentProviderLogin | null>(`${base}/login`).then((l) => { if (l && l.status === "running") setLogin(l); }).catch(() => {});
|
|
77
|
+
}, [base]);
|
|
78
|
+
|
|
79
|
+
// After any change that may have made the agent usable: prove it with a real
|
|
80
|
+
// turn so it shows as connected (and tasks can pick it).
|
|
81
|
+
const afterChange = useCallback(async () => {
|
|
82
|
+
const seq = opSeq.current;
|
|
83
|
+
await load();
|
|
84
|
+
if (agent.connected) return;
|
|
85
|
+
try {
|
|
86
|
+
const r = await jsend<ClaudeVerifyT>(`/api/agents/${agent.id}/verify`, "POST");
|
|
87
|
+
if (r.connected) { say(seq, { ok: true, text: `${agent.label} 已验证可用。` }); onConnected?.(); }
|
|
88
|
+
else say(seq, { ok: false, text: `授权已保存,但验证没通过:${r.error ?? "未知原因"}` });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
say(seq, { ok: false, text: `授权已保存,但验证没通过:${errText(e)}` });
|
|
91
|
+
}
|
|
92
|
+
}, [agent.connected, agent.id, agent.label, load, onConnected]);
|
|
93
|
+
|
|
94
|
+
// Poll while the flow runs; finish once it settles.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (login?.status !== "running") return;
|
|
97
|
+
const t = setInterval(() => {
|
|
98
|
+
jget<AgentProviderLogin | null>(`${base}/login`).then((l) => {
|
|
99
|
+
if (!l) { setLogin(null); return; }
|
|
100
|
+
setLogin(l);
|
|
101
|
+
}).catch(() => {});
|
|
102
|
+
}, 1500);
|
|
103
|
+
return () => clearInterval(t);
|
|
104
|
+
}, [login?.status, base]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (login?.status === "success") { setNote({ ok: true, text: `已登录 ${nameOf(login.provider)}。` }); void afterChange(); }
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [login?.status]);
|
|
110
|
+
|
|
111
|
+
const nameOf = (id: string) => providers?.find((p) => p.id === id)?.name ?? id;
|
|
112
|
+
const configured = useMemo(() => (providers ?? []).filter((p) => p.configured || p.credential || p.endpoint), [providers]);
|
|
113
|
+
const picked = providers?.find((p) => p.id === pick) ?? null;
|
|
114
|
+
const groups = useMemo(() => {
|
|
115
|
+
const all = (providers ?? []).filter((p) => (p.oauth || p.apiKey?.canLogin) && !p.endpoint);
|
|
116
|
+
return {
|
|
117
|
+
oauth: all.filter((p) => p.oauth).sort((a, b) => a.name.localeCompare(b.name)),
|
|
118
|
+
keyOnly: all.filter((p) => !p.oauth).sort((a, b) => a.name.localeCompare(b.name)),
|
|
119
|
+
};
|
|
120
|
+
}, [providers]);
|
|
121
|
+
|
|
122
|
+
const start = async (type: "oauth" | "api_key") => {
|
|
123
|
+
if (!pick) return;
|
|
124
|
+
opSeq.current++;
|
|
125
|
+
setNote(null);
|
|
126
|
+
setBusy("login");
|
|
127
|
+
try {
|
|
128
|
+
setLogin(await jsend<AgentProviderLogin>(`${base}/login`, "POST", { provider: pick, type }));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
setLogin({ provider: pick, type, status: "error", events: [], prompt: null, error: errText(e) });
|
|
131
|
+
} finally {
|
|
132
|
+
setBusy(null);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const answer = async (prompt: AgentLoginPrompt, value: string) => {
|
|
137
|
+
setBusy("answer");
|
|
138
|
+
try {
|
|
139
|
+
const l = await jsend<AgentProviderLogin | null>(`${base}/login`, "PUT", { promptId: prompt.id, value });
|
|
140
|
+
setLogin(l);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
setLogin((l) => (l ? { ...l, status: "error", prompt: null, error: errText(e) } : l));
|
|
143
|
+
} finally {
|
|
144
|
+
setBusy(null);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// The API-key form: key (optional when one is stored) + optional address.
|
|
149
|
+
// A provider that asks follow-ups (e.g. an account id) continues in the flow card.
|
|
150
|
+
const saveKey = async (p: AgentProviderInfo, input: { key: string; baseUrl: string; models: string[] }) => {
|
|
151
|
+
opSeq.current++;
|
|
152
|
+
setNote(null);
|
|
153
|
+
const l = await jsend<AgentProviderLogin | null>(`${base}/key`, "POST", { provider: p.id, ...input });
|
|
154
|
+
setKeyFor(null);
|
|
155
|
+
if (l) setLogin(l);
|
|
156
|
+
else { setNote({ ok: true, text: `已更新 ${p.name} 的地址设置。` }); await afterChange(); }
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const cancel = async () => {
|
|
160
|
+
await jsend(`${base}/login`, "DELETE").catch(() => {});
|
|
161
|
+
setLogin(null);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const logout = async (p: AgentProviderInfo) => {
|
|
165
|
+
if (!window.confirm(`移除 ${p.name} 在 Pi 中保存的${p.credential === "oauth" ? "登录" : " API Key"}?终端里的 pi 也会一并失去这个授权。`)) return;
|
|
166
|
+
setBusy(`logout:${p.id}`);
|
|
167
|
+
opSeq.current++;
|
|
168
|
+
setNote(null);
|
|
169
|
+
try {
|
|
170
|
+
await jsend(`${base}?provider=${encodeURIComponent(p.id)}`, "DELETE");
|
|
171
|
+
setNote({ ok: true, text: `已移除 ${p.name} 的授权。` });
|
|
172
|
+
setLogin((l) => (l?.provider === p.id ? null : l));
|
|
173
|
+
await load();
|
|
174
|
+
onConnected?.();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
setNote({ ok: false, text: errText(e) });
|
|
177
|
+
} finally {
|
|
178
|
+
setBusy(null);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const removeEndpoint = async (p: AgentProviderInfo) => {
|
|
183
|
+
if (!window.confirm(`删除自建端点 ${p.name}(${p.id})及其保存的 Key?`)) return;
|
|
184
|
+
setBusy(`endpoint:${p.id}`);
|
|
185
|
+
opSeq.current++;
|
|
186
|
+
setNote(null);
|
|
187
|
+
try {
|
|
188
|
+
const r = await jsend<{ backup: string | null }>(`${base}/endpoint?id=${encodeURIComponent(p.id)}`, "DELETE");
|
|
189
|
+
setNote({ ok: true, text: `已删除端点 ${p.id}。${r.backup ? `原 models.json 已备份到 ${r.backup}` : ""}` });
|
|
190
|
+
await load();
|
|
191
|
+
onConnected?.();
|
|
192
|
+
} catch (e) {
|
|
193
|
+
setNote({ ok: false, text: errText(e) });
|
|
194
|
+
} finally {
|
|
195
|
+
setBusy(null);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="field" style={{ maxWidth: 640, marginTop: 18, marginBottom: 0 }}>
|
|
201
|
+
<div className="lab" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
202
|
+
{Icon.lock()} 模型提供商授权
|
|
203
|
+
<button className="linkbtn" style={{ marginLeft: "auto", textTransform: "none", letterSpacing: 0 }} disabled={loading} onClick={() => void load()}>
|
|
204
|
+
{loading ? "刷新中…" : "刷新"}
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
|
|
208
|
+
授权保存在 {agent.label} 自己的配置里(<code>~/.pi/agent</code>),终端里的 <code>pi</code> 与 Operator 共用。Operator 不保存任何密钥。
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{listError && <div className="hlp" style={{ color: "var(--red)", marginTop: 0, overflowWrap: "anywhere" }}>⚠ {listError}</div>}
|
|
212
|
+
{providers === null ? (
|
|
213
|
+
<div className="wiz-verify"><span className="wiz-spin" /> <span>正在读取 {agent.label} 的授权…</span></div>
|
|
214
|
+
) : configured.length === 0 ? (
|
|
215
|
+
<div className="hlp" style={{ marginTop: 0 }}>还没有可用的模型提供商。在下面登录一个,或添加自建端点。</div>
|
|
216
|
+
) : (
|
|
217
|
+
<div style={{ display: "flex", flexDirection: "column", border: "1px solid var(--line-strong)", borderRadius: "var(--r)", overflow: "hidden" }}>
|
|
218
|
+
{configured.map((p, i) => (
|
|
219
|
+
<div key={p.id} style={{ borderTop: i ? "1px solid var(--line)" : undefined, background: "var(--raise)" }}>
|
|
220
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "10px 12px" }}>
|
|
221
|
+
<span className={p.configured ? "wiz-ok" : "wiz-warn"} style={{ flex: "0 0 22px", height: 22 }} title={p.configured ? "可用" : "缺少可用凭据"}>
|
|
222
|
+
{p.configured ? Icon.check() : Icon.bolt()}
|
|
223
|
+
</span>
|
|
224
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
225
|
+
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
|
226
|
+
{p.name} <span className="ctx-mono" style={{ color: "var(--ink-4)", fontSize: 11.5 }}>{p.id}</span>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="hlp" style={{ marginTop: 2 }}>
|
|
229
|
+
{p.endpoint ? `自建端点 · ${p.endpoint.baseUrl} · ` : ""}
|
|
230
|
+
{p.override?.baseUrl ? `地址 ${p.override.baseUrl} · ` : ""}
|
|
231
|
+
{p.configured ? `${sourceLabel(p)} · ${p.models} 个可用模型` : "缺少 Key,暂不可用"}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
235
|
+
{!p.endpoint && p.apiKey?.canLogin && (
|
|
236
|
+
<button className="btn btn-line btn-sm" disabled={!!busy || login?.status === "running"} onClick={() => setKeyFor(keyFor === p.id ? null : p.id)}>
|
|
237
|
+
{Icon.edit()} Key / 地址
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
{p.endpoint && (
|
|
241
|
+
<button className="btn btn-line btn-sm" disabled={!!busy} onClick={() => setEditing(p)}>{Icon.edit()} 编辑</button>
|
|
242
|
+
)}
|
|
243
|
+
{p.credential && !p.endpoint && (
|
|
244
|
+
<button className="btn-danger" disabled={!!busy} onClick={() => void logout(p)}>{busy === `logout:${p.id}` ? "移除中…" : "退出登录"}</button>
|
|
245
|
+
)}
|
|
246
|
+
{p.endpoint && (
|
|
247
|
+
<button className="btn-danger" disabled={!!busy} onClick={() => void removeEndpoint(p)}>{busy === `endpoint:${p.id}` ? "删除中…" : "删除端点"}</button>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
{keyFor === p.id && (
|
|
252
|
+
<div style={{ padding: "0 12px 12px" }}>
|
|
253
|
+
<KeyForm provider={p} onSave={(input) => saveKey(p, input)} onCancel={() => setKeyFor(null)} />
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{note && <div className="hlp" style={{ color: note.ok ? "var(--green, #5C8C5A)" : "var(--red)", overflowWrap: "anywhere" }}>{note.ok ? "✓" : "⚠"} {note.text}</div>}
|
|
262
|
+
|
|
263
|
+
{/* ---- add a provider login ---- */}
|
|
264
|
+
<div style={{ marginTop: 16 }}>
|
|
265
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 6, fontWeight: 600, color: "var(--ink-2)" }}>登录模型提供商</div>
|
|
266
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
267
|
+
<select className="ctx-mono" style={{ ...control, minWidth: 240, maxWidth: "100%" }} value={pick} aria-label="模型提供商"
|
|
268
|
+
onChange={(e) => { setPick(e.target.value); setKeyFor(null); }} disabled={login?.status === "running"}>
|
|
269
|
+
<option value="">选择提供商…</option>
|
|
270
|
+
<optgroup label="支持订阅账号登录">
|
|
271
|
+
{groups.oauth.map((p) => <option key={p.id} value={p.id}>{p.name}{p.configured ? "(已授权)" : ""}</option>)}
|
|
272
|
+
</optgroup>
|
|
273
|
+
<optgroup label="API Key">
|
|
274
|
+
{groups.keyOnly.map((p) => <option key={p.id} value={p.id}>{p.name}{p.configured ? "(已授权)" : ""}</option>)}
|
|
275
|
+
</optgroup>
|
|
276
|
+
</select>
|
|
277
|
+
{picked?.oauth && (
|
|
278
|
+
<button className="btn btn-accent btn-sm" disabled={!!busy || login?.status === "running"} onClick={() => void start("oauth")}>
|
|
279
|
+
{Icon.bolt()} {picked.oauth.label || (picked.oauth.subscription ? "订阅账号登录" : "OAuth 登录")}
|
|
280
|
+
</button>
|
|
281
|
+
)}
|
|
282
|
+
{picked?.apiKey?.canLogin && (
|
|
283
|
+
<button className={`btn btn-sm ${keyFor === picked.id ? "btn-accent" : "btn-line"}`} disabled={!!busy || login?.status === "running"}
|
|
284
|
+
onClick={() => setKeyFor(keyFor === picked.id ? null : picked.id)}>
|
|
285
|
+
{Icon.lock()} 填写 API Key
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
{picked?.oauth && keyFor !== picked.id && <div className="hlp">{picked.oauth.name}</div>}
|
|
290
|
+
{picked && keyFor === picked.id && !configured.some((c) => c.id === picked.id) && (
|
|
291
|
+
<div style={{ marginTop: 10 }}>
|
|
292
|
+
<KeyForm provider={picked} onSave={(input) => saveKey(picked, input)} onCancel={() => setKeyFor(null)} />
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
{login && <LoginFlow login={login} name={nameOf(login.provider)} busy={busy === "answer"} onAnswer={answer} onCancel={cancel} onDismiss={() => setLogin(null)} />}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* ---- user-defined endpoints ---- */}
|
|
299
|
+
<div style={{ marginTop: 16 }}>
|
|
300
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
301
|
+
<div className="hlp" style={{ margin: 0, fontWeight: 600, color: "var(--ink-2)" }}>自建端点</div>
|
|
302
|
+
{editing === null && (
|
|
303
|
+
<button className="linkbtn" style={{ fontSize: 12 }} onClick={() => setEditing("new")}>+ 添加端点</button>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
<div className="hlp" style={{ marginTop: 2 }}>公司网关、vLLM、Ollama 等兼容 OpenAI / Anthropic / Gemini 接口的服务。写入 <code>~/.pi/agent/models.json</code>,Key 单独存进 Pi 的凭据文件。</div>
|
|
307
|
+
{editing !== null && (
|
|
308
|
+
<EndpointForm
|
|
309
|
+
base={base}
|
|
310
|
+
existing={editing === "new" ? null : editing}
|
|
311
|
+
onCancel={() => setEditing(null)}
|
|
312
|
+
onSaved={async (text) => { opSeq.current++; setEditing(null); setNote({ ok: true, text }); await afterChange(); }}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------- one login flow: what the provider shows + what it asks ----------
|
|
321
|
+
|
|
322
|
+
// Links come from the provider's login code; only ever make http(s) clickable.
|
|
323
|
+
const webUrl = (u: unknown): string | undefined => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : undefined);
|
|
324
|
+
|
|
325
|
+
function EventLine({ event }: { event: AgentLoginEvent }) {
|
|
326
|
+
if (event.type === "auth_url") {
|
|
327
|
+
const url = webUrl(event.url);
|
|
328
|
+
if (!url) return <div className="hlp" style={{ marginTop: 0, overflowWrap: "anywhere" }}>{event.instructions} {String(event.url)}</div>;
|
|
329
|
+
return (
|
|
330
|
+
<div style={{ marginBottom: 10 }}>
|
|
331
|
+
<div className="hlp" style={{ marginTop: 0 }}>{event.instructions || "在浏览器中打开授权页面并完成登录:"}</div>
|
|
332
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 6 }}>
|
|
333
|
+
<a className="btn btn-accent btn-sm" href={url} target="_blank" rel="noreferrer">{Icon.external()} 打开授权页面</a>
|
|
334
|
+
<button className="btn btn-line btn-sm" onClick={() => navigator.clipboard?.writeText(url)}>{Icon.copy()} 复制链接</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (event.type === "device_code") {
|
|
340
|
+
return (
|
|
341
|
+
<div style={{ marginBottom: 10 }}>
|
|
342
|
+
<div className="hlp" style={{ marginTop: 0 }}>打开下面的页面,输入这个一次性验证码:</div>
|
|
343
|
+
<div className="ctx-mono" style={{ fontSize: 20, letterSpacing: 2, margin: "6px 0", fontWeight: 600 }}>{event.userCode}</div>
|
|
344
|
+
{webUrl(event.verificationUri)
|
|
345
|
+
? <a className="btn btn-accent btn-sm" href={webUrl(event.verificationUri)} target="_blank" rel="noreferrer">{Icon.external()} {event.verificationUri}</a>
|
|
346
|
+
: <span className="ctx-mono">{String(event.verificationUri)}</span>}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (event.type === "info") {
|
|
351
|
+
return (
|
|
352
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 6 }}>
|
|
353
|
+
{event.message}
|
|
354
|
+
{event.links?.map((l) => webUrl(l.url) && <span key={l.url}> <a className="linkbtn" href={webUrl(l.url)} target="_blank" rel="noreferrer">{l.label || l.url}</a></span>)}
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return <div className="hlp" style={{ marginTop: 0, marginBottom: 6 }}>{event.message}</div>;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function PromptInput({ prompt, busy, onAnswer }: { prompt: AgentLoginPrompt; busy: boolean; onAnswer: (p: AgentLoginPrompt, v: string) => void }) {
|
|
362
|
+
const [value, setValue] = useState("");
|
|
363
|
+
useEffect(() => { setValue(""); }, [prompt.id]);
|
|
364
|
+
if (prompt.type === "select") {
|
|
365
|
+
return (
|
|
366
|
+
<div style={{ marginTop: 8 }}>
|
|
367
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 6 }}>{prompt.message}</div>
|
|
368
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
369
|
+
{prompt.options.map((o) => (
|
|
370
|
+
<button key={o.id} className="btn btn-line btn-sm" style={{ justifyContent: "flex-start", height: "auto", padding: "8px 12px", textAlign: "left" }}
|
|
371
|
+
disabled={busy} onClick={() => onAnswer(prompt, o.id)}>
|
|
372
|
+
<span><strong>{o.label}</strong>{o.description ? <span style={{ color: "var(--ink-3)" }}> — {o.description}</span> : null}</span>
|
|
373
|
+
</button>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const submit = () => onAnswer(prompt, value);
|
|
380
|
+
return (
|
|
381
|
+
<div style={{ marginTop: 8 }}>
|
|
382
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 6 }}>{prompt.message}</div>
|
|
383
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
384
|
+
<input
|
|
385
|
+
type={prompt.type === "secret" ? "password" : "text"} className="ctx-mono" autoFocus autoComplete="off"
|
|
386
|
+
value={value} placeholder={prompt.placeholder ?? ""} style={{ ...control, flex: 1 }}
|
|
387
|
+
onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
|
388
|
+
/>
|
|
389
|
+
<button className="btn btn-accent" disabled={busy} onClick={submit}>{busy ? "提交中…" : "提交"}</button>
|
|
390
|
+
</div>
|
|
391
|
+
{prompt.type === "manual_code" && <div className="hlp">浏览器和 Operator 不在同一台机器时,把授权后显示的代码或跳转后的完整地址粘贴到这里。</div>}
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function LoginFlow({ login, name, busy, onAnswer, onCancel, onDismiss }: {
|
|
397
|
+
login: AgentProviderLogin; name: string; busy: boolean;
|
|
398
|
+
onAnswer: (p: AgentLoginPrompt, v: string) => void; onCancel: () => void; onDismiss: () => void;
|
|
399
|
+
}) {
|
|
400
|
+
if (login.status === "success") {
|
|
401
|
+
return <div className="wiz-connected" style={{ marginTop: 10 }}><span className="wiz-ok">{Icon.check()}</span><div className="wiz-ok-t">已登录 {name}</div></div>;
|
|
402
|
+
}
|
|
403
|
+
if (login.status === "error" || login.status === "cancelled") {
|
|
404
|
+
return (
|
|
405
|
+
<div className="hlp" style={{ color: "var(--red)", overflowWrap: "anywhere" }}>
|
|
406
|
+
⚠ {login.status === "cancelled" ? "已取消登录" : `登录 ${name} 失败:${login.error ?? "未知原因"}`}{" "}
|
|
407
|
+
<button className="linkbtn" onClick={onDismiss}>关闭</button>
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const events = login.events.filter((e, i, all) => e.type !== "progress" || i === all.length - 1);
|
|
412
|
+
return (
|
|
413
|
+
<div className="wiz-codecard" style={{ marginTop: 10 }}>
|
|
414
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 8, fontWeight: 600, color: "var(--ink-2)" }}>
|
|
415
|
+
正在登录 {name}({login.type === "oauth" ? "账号授权" : "API Key"})
|
|
416
|
+
</div>
|
|
417
|
+
{events.map((e, i) => <EventLine key={i} event={e} />)}
|
|
418
|
+
{login.prompt ? (
|
|
419
|
+
<PromptInput prompt={login.prompt} busy={busy} onAnswer={onAnswer} />
|
|
420
|
+
) : (
|
|
421
|
+
<div className="wiz-verify" style={{ marginTop: 6 }}><span className="wiz-spin" /> <span>等待授权完成…</span></div>
|
|
422
|
+
)}
|
|
423
|
+
<button className="linkbtn" style={{ marginTop: 10 }} onClick={onCancel}>取消</button>
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------- API key (+ optional address) for one provider ----------
|
|
429
|
+
|
|
430
|
+
function KeyForm({ provider, onSave, onCancel }: {
|
|
431
|
+
provider: AgentProviderInfo;
|
|
432
|
+
onSave: (input: { key: string; baseUrl: string; models: string[] }) => Promise<void>;
|
|
433
|
+
onCancel: () => void;
|
|
434
|
+
}) {
|
|
435
|
+
const hasKey = provider.credential === "api_key";
|
|
436
|
+
const [key, setKey] = useState("");
|
|
437
|
+
const [baseUrl, setBaseUrl] = useState(provider.override?.baseUrl ?? "");
|
|
438
|
+
const [models, setModels] = useState((provider.override?.models ?? []).join(", "));
|
|
439
|
+
const [busy, setBusy] = useState(false);
|
|
440
|
+
const [err, setErr] = useState<string | null>(null);
|
|
441
|
+
const needKey = !hasKey && !provider.credential;
|
|
442
|
+
|
|
443
|
+
const save = async () => {
|
|
444
|
+
const url = baseUrl.trim();
|
|
445
|
+
if (needKey && !key.trim()) { setErr("请填写 API Key"); return; }
|
|
446
|
+
if (url && !/^https?:\/\/\S+$/i.test(url)) { setErr("Base URL 必须以 http:// 或 https:// 开头"); return; }
|
|
447
|
+
setBusy(true);
|
|
448
|
+
setErr(null);
|
|
449
|
+
try {
|
|
450
|
+
await onSave({ key: key.trim(), baseUrl: url, models: models.split(/[\s,,]+/).map((m) => m.trim()).filter(Boolean) });
|
|
451
|
+
} catch (e) {
|
|
452
|
+
setErr(errText(e));
|
|
453
|
+
} finally {
|
|
454
|
+
setBusy(false);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const label = (text: string) => <div className="hlp" style={{ margin: "0 0 4px" }}>{text}</div>;
|
|
459
|
+
return (
|
|
460
|
+
<div className="wiz-codecard">
|
|
461
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 8, fontWeight: 600, color: "var(--ink-2)" }}>
|
|
462
|
+
{provider.name} · {provider.apiKey?.name ?? "API Key"}
|
|
463
|
+
</div>
|
|
464
|
+
<label style={{ display: "block" }}>
|
|
465
|
+
{label(hasKey ? "API Key(已保存,留空则不修改)" : "API Key")}
|
|
466
|
+
<input type="password" className="ctx-mono" autoComplete="off" autoFocus value={key} placeholder={hasKey ? "••••••••" : "粘贴 API Key"}
|
|
467
|
+
style={{ ...control, width: "100%" }} onChange={(e) => setKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} />
|
|
468
|
+
</label>
|
|
469
|
+
<label style={{ display: "block", marginTop: 10 }}>
|
|
470
|
+
{label("Base URL(可选)")}
|
|
471
|
+
<input type="text" className="ctx-mono" value={baseUrl} placeholder="留空使用官方地址;走公司网关或代理时填写,如 https://gateway.example.com"
|
|
472
|
+
onChange={(e) => setBaseUrl(e.target.value)} />
|
|
473
|
+
</label>
|
|
474
|
+
<label style={{ display: "block", marginTop: 10 }}>
|
|
475
|
+
{label("额外的模型 ID(可选,多个用逗号分隔)")}
|
|
476
|
+
<input type="text" className="ctx-mono" value={models} placeholder="网关使用自定义模型名时填写,如 book-model"
|
|
477
|
+
onChange={(e) => setModels(e.target.value)} />
|
|
478
|
+
</label>
|
|
479
|
+
<div className="hlp">Key 存进 Pi 的凭据文件;地址和额外模型写入 <code>~/.pi/agent/models.json</code>({provider.name} 自带的模型保留)。有的提供商还会追问 account ID 等信息,保存后在这里接着填。</div>
|
|
480
|
+
{err && <div className="hlp" style={{ color: "var(--red)", overflowWrap: "anywhere" }}>⚠ {err}</div>}
|
|
481
|
+
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
|
482
|
+
<button className="btn btn-accent" disabled={busy} onClick={() => void save()}>{busy ? "保存中…" : "保存"}</button>
|
|
483
|
+
<button className="btn btn-line" disabled={busy} onClick={onCancel}>取消</button>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------- add / edit a user-defined endpoint ----------
|
|
490
|
+
|
|
491
|
+
function EndpointForm({ base, existing, onCancel, onSaved }: {
|
|
492
|
+
base: string; existing: AgentProviderInfo | null; onCancel: () => void; onSaved: (text: string) => void;
|
|
493
|
+
}) {
|
|
494
|
+
const ep = existing?.endpoint;
|
|
495
|
+
const [id, setId] = useState(existing?.id ?? "");
|
|
496
|
+
const [name, setName] = useState(existing && existing.name !== existing.id ? existing.name : "");
|
|
497
|
+
const [baseUrl, setBaseUrl] = useState(ep?.baseUrl ?? "");
|
|
498
|
+
const [api, setApi] = useState(ep?.api ?? "openai-completions");
|
|
499
|
+
const [models, setModels] = useState((ep?.models ?? []).join(", "));
|
|
500
|
+
const [ctx, setCtx] = useState(String(ep?.contextWindow ?? 200000));
|
|
501
|
+
const [reasoning, setReasoning] = useState(ep?.reasoning ?? false);
|
|
502
|
+
const [key, setKey] = useState("");
|
|
503
|
+
const [busy, setBusy] = useState(false);
|
|
504
|
+
const [err, setErr] = useState<string | null>(null);
|
|
505
|
+
|
|
506
|
+
const save = async () => {
|
|
507
|
+
setBusy(true);
|
|
508
|
+
setErr(null);
|
|
509
|
+
try {
|
|
510
|
+
const r = await jsend<{ backup: string | null }>(`${base}/endpoint`, "POST", {
|
|
511
|
+
id: id.trim(), name: name.trim(), baseUrl: baseUrl.trim(), api,
|
|
512
|
+
models: models.split(/[\s,,]+/).map((m) => m.trim()).filter(Boolean),
|
|
513
|
+
contextWindow: Number(ctx) || undefined, reasoning, apiKey: key.trim() || undefined,
|
|
514
|
+
});
|
|
515
|
+
onSaved(`端点 ${id.trim()} 已保存${key.trim() ? ",Key 已存入 Pi 的凭据文件" : ""}。${r.backup ? `原 models.json 已备份到 ${r.backup}` : ""}`);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
setErr(errText(e));
|
|
518
|
+
} finally {
|
|
519
|
+
setBusy(false);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const row = { display: "flex", gap: 8, flexWrap: "wrap" as const, marginTop: 8 };
|
|
524
|
+
const cell = { flex: "1 1 200px", minWidth: 0 };
|
|
525
|
+
return (
|
|
526
|
+
<div className="wiz-codecard" style={{ marginTop: 10 }}>
|
|
527
|
+
<div style={row}>
|
|
528
|
+
<label style={cell}><div className="hlp" style={{ margin: "0 0 4px" }}>端点 ID(模型名的前缀,如 gw/xxx)</div>
|
|
529
|
+
<input type="text" className="ctx-mono" value={id} disabled={!!existing} placeholder="gw" onChange={(e) => setId(e.target.value)} /></label>
|
|
530
|
+
<label style={cell}><div className="hlp" style={{ margin: "0 0 4px" }}>显示名称(可选)</div>
|
|
531
|
+
<input type="text" value={name} placeholder="公司网关" onChange={(e) => setName(e.target.value)} /></label>
|
|
532
|
+
</div>
|
|
533
|
+
<div style={row}>
|
|
534
|
+
<label style={{ flex: "2 1 300px", minWidth: 0 }}><div className="hlp" style={{ margin: "0 0 4px" }}>Base URL</div>
|
|
535
|
+
<input type="text" className="ctx-mono" value={baseUrl} placeholder="https://gateway.example.com/v1" onChange={(e) => setBaseUrl(e.target.value)} /></label>
|
|
536
|
+
<label style={cell}><div className="hlp" style={{ margin: "0 0 4px" }}>接口类型</div>
|
|
537
|
+
<select className="ctx-mono" value={api} style={{ ...control, width: "100%" }} onChange={(e) => setApi(e.target.value)}>
|
|
538
|
+
{API_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
539
|
+
</select></label>
|
|
540
|
+
</div>
|
|
541
|
+
<div style={row}>
|
|
542
|
+
<label style={{ flex: "2 1 300px", minWidth: 0 }}><div className="hlp" style={{ margin: "0 0 4px" }}>模型 ID(多个用逗号分隔)</div>
|
|
543
|
+
<input type="text" className="ctx-mono" value={models} placeholder="glm-5.3, deepseek-v4" onChange={(e) => setModels(e.target.value)} /></label>
|
|
544
|
+
<label style={cell}><div className="hlp" style={{ margin: "0 0 4px" }}>上下文窗口(tokens)</div>
|
|
545
|
+
<input type="number" className="ctx-mono" value={ctx} onChange={(e) => setCtx(e.target.value)} /></label>
|
|
546
|
+
</div>
|
|
547
|
+
<label className="hlp" style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 8 }}>
|
|
548
|
+
<input type="checkbox" checked={reasoning} onChange={(e) => setReasoning(e.target.checked)} /> 推理模型(支持思考强度)
|
|
549
|
+
</label>
|
|
550
|
+
<div style={{ marginTop: 8 }}>
|
|
551
|
+
<div className="hlp" style={{ margin: "0 0 4px" }}>API Key{existing ? "(留空则保持现有 Key)" : "(本地无鉴权的服务可填任意值)"}</div>
|
|
552
|
+
<input type="password" className="ctx-mono" autoComplete="off" value={key} placeholder="sk-…" style={{ ...control, width: "100%" }} onChange={(e) => setKey(e.target.value)} />
|
|
553
|
+
</div>
|
|
554
|
+
{err && <div className="hlp" style={{ color: "var(--red)", overflowWrap: "anywhere" }}>⚠ {err}</div>}
|
|
555
|
+
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
|
556
|
+
<button className="btn btn-accent" disabled={busy || !id.trim() || !baseUrl.trim() || !models.trim()} onClick={() => void save()}>{busy ? "保存中…" : "保存端点"}</button>
|
|
557
|
+
<button className="btn btn-line" disabled={busy} onClick={onCancel}>取消</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|