@spzxg/cli-ui 0.6.1 → 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 +145 -13
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-BKHhqcoa.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 +113 -53
- package/src/chat/Composer.tsx +68 -57
- 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 +16 -6
- package/src/core/config.ts +11 -15
- package/src/core/conversation.ts +4 -4
- 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 +51 -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/icons.tsx +1 -0
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Msg } from "../core/types";
|
|
2
|
+
export interface BranchTask { id: string; project_id: string; title: string; agent: string; status: string; created_at?: number; updated_at?: number; parent_task_id?: string | null; fork_group_id?: string | null; fork_commit?: string | null; fork_source_title?: string | null; fork_context?: string | null; work_branch?: string; running?: number }
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export type BranchStep<T extends BranchTask = BranchTask> = { source: T | null; child: T };
|
|
6
|
+
|
|
7
|
+
/** The selected path, oldest source first. Missing/deleted sources keep a boundary. */
|
|
8
|
+
export function branchAncestry<T extends BranchTask>(task: T, tasks: T[]): BranchStep<T>[] {
|
|
9
|
+
const byId = new Map(tasks.filter((t) => t.project_id === task.project_id).map((t) => [t.id, t]));
|
|
10
|
+
const visited = new Set([task.id]);
|
|
11
|
+
const steps: BranchStep<T>[] = [];
|
|
12
|
+
let child = task;
|
|
13
|
+
while (child.parent_task_id && !visited.has(child.parent_task_id)) {
|
|
14
|
+
visited.add(child.parent_task_id);
|
|
15
|
+
const source = byId.get(child.parent_task_id) ?? null;
|
|
16
|
+
steps.unshift({ source, child });
|
|
17
|
+
if (!source) break;
|
|
18
|
+
child = source;
|
|
19
|
+
}
|
|
20
|
+
return steps;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Outgoing paths only; a source is never presented as its own child branch. */
|
|
24
|
+
export function branchOptions<T extends BranchTask>(sourceId: string, projectId: string, tasks: T[], selected?: T): T[] {
|
|
25
|
+
const local = tasks.filter((t) => t.project_id === projectId);
|
|
26
|
+
const direct = local.filter((t) => t.parent_task_id === sourceId);
|
|
27
|
+
const groups = new Set([...direct, ...(selected ? [selected] : [])].map((t) => t.fork_group_id).filter(Boolean));
|
|
28
|
+
return local.filter((t) => t.id !== sourceId && (t.parent_task_id === sourceId || !!t.fork_group_id && groups.has(t.fork_group_id)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A parent may have been resumed since the fork. Its later messages belong to
|
|
32
|
+
// that parent, not above the historical point where this child branched off.
|
|
33
|
+
export function messagesAtFork(messages: Msg[], child: BranchTask): Msg[] {
|
|
34
|
+
return messages.filter((m) => m.role !== "queued" && (!child.created_at || !m.ts || m.ts <= child.created_at));
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default as OperatorWorkspace } from "./node/Orchestrator";
|
|
2
|
+
export type { TaskRow, ProjectRow, AgentsBundle } from "./node/types";
|
|
3
|
+
|
|
4
|
+
export { default as OperatorFilePage } from "./node/ViewFile";
|
|
5
|
+
export { default as OperatorBrowserPage } from "./node/BrowserPopup";
|
|
6
|
+
|
|
7
|
+
export type { OperatorBoardOptions, OperatorBoardTab } from "./node/boardInteraction";
|
|
8
|
+
|
|
9
|
+
export { createOperatorWorkspace } from "./node/boardWorkspace";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./node/liveStream";
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { jget, jsend } from "./api";
|
|
6
|
+
import { Modal } from "./Modal";
|
|
7
|
+
import { AUTH_BANNER_HINT } from "./protocol/authFailure";
|
|
8
|
+
import { isOptInAgent } from "./protocol/agents/optIn";
|
|
9
|
+
import { ProviderAuthPanel } from "./ProviderAuth";
|
|
10
|
+
import type { AgentInfo, AgentInfoT, AgentsResponseT, AgentLoginT, ClaudeVerifyT } from "./types";
|
|
11
|
+
|
|
12
|
+
const NUDGE_DISMISSED = "orch_agent_nudge_dismissed";
|
|
13
|
+
|
|
14
|
+
// The instance-wide "your agent login died" strip, under the titlebar.
|
|
15
|
+
//
|
|
16
|
+
// An expired OAuth session is per-INSTANCE (one login per agent, shared by every
|
|
17
|
+
// task), but it only shows up as a failed turn inside whichever task ran first —
|
|
18
|
+
// so without this you'd have to open that task to learn that nothing can run.
|
|
19
|
+
// The flag is server-persisted (lib/agents/connections.ts) and broadcast on
|
|
20
|
+
// GET /api/events, so this appears in every tab within a moment of the failure
|
|
21
|
+
// and disappears the instant a turn succeeds again. Deliberately not
|
|
22
|
+
// dismissible: no session can run until it's fixed.
|
|
23
|
+
export function AgentAuthBanner({ broken, onReconnect }: { broken: AgentInfo[]; onReconnect: () => void }) {
|
|
24
|
+
if (broken.length === 0) return null;
|
|
25
|
+
const names = broken.map((a) => a.label).join(" 与 ");
|
|
26
|
+
// One agent: show what the provider actually said, so "expired session" isn't
|
|
27
|
+
// confused with "revoked key" or a network blip. Several: keep it to the names.
|
|
28
|
+
const detail = broken.length === 1 ? broken[0].authBroken?.reason : null;
|
|
29
|
+
return (
|
|
30
|
+
<div className="auth-banner" role="alert">
|
|
31
|
+
<span className="ab-ic">{Icon.bolt()}</span>
|
|
32
|
+
<span className="ab-msg">
|
|
33
|
+
<b>{names} 已无法工作 —— 其登录凭据需要处理。</b> {AUTH_BANNER_HINT}
|
|
34
|
+
{detail && <span className="ab-why" title={detail}>{detail}</span>}
|
|
35
|
+
</span>
|
|
36
|
+
<span className="ab-spacer" />
|
|
37
|
+
<button className="btn btn-sm btn-accent" onClick={onReconnect}>
|
|
38
|
+
重新连接 {broken.length === 1 ? broken[0].label : "智能体"}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Post-setup nudge: once the required Claude connection is done, gently suggest
|
|
45
|
+
// connecting the other available agents (Codex) so tasks can run on them too.
|
|
46
|
+
// Optional and dismissible (once, via localStorage) — the wizard never requires
|
|
47
|
+
// a second agent. Renders nothing until it confirms there's an unconnected agent
|
|
48
|
+
// to offer, so it never flashes for a single-agent instance.
|
|
49
|
+
export function AgentNudge({ ready, onConnect }: { ready: boolean; onConnect: () => void }) {
|
|
50
|
+
const [pending, setPending] = useState<AgentInfoT[] | null>(null);
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!ready) return;
|
|
55
|
+
try { if (localStorage.getItem(NUDGE_DISMISSED) === "1") return; } catch {}
|
|
56
|
+
jget<AgentsResponseT>("/api/agents")
|
|
57
|
+
.then((r) => {
|
|
58
|
+
// Opt-in agents (Grok, Pi) never reopen the existing nudge.
|
|
59
|
+
const unconnected = r.agents.filter((a) => !isOptInAgent(a.id) && !a.connected);
|
|
60
|
+
if (unconnected.length) { setPending(unconnected); setOpen(true); }
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {});
|
|
63
|
+
}, [ready]);
|
|
64
|
+
|
|
65
|
+
if (!open || !pending || pending.length === 0) return null;
|
|
66
|
+
const names = pending.map((a) => a.label).join(" 与 ");
|
|
67
|
+
const close = () => { setOpen(false); try { localStorage.setItem(NUDGE_DISMISSED, "1"); } catch {} };
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Modal
|
|
71
|
+
title="再连接一个智能体"
|
|
72
|
+
sub="让任务可以在多个编程智能体上运行 —— 完全可选。"
|
|
73
|
+
onClose={close}
|
|
74
|
+
width={480}
|
|
75
|
+
footer={<>
|
|
76
|
+
<span className="spacer" />
|
|
77
|
+
<button className="btn btn-ghost" onClick={close}>暂不</button>
|
|
78
|
+
<button className="btn btn-accent" onClick={() => { close(); onConnect(); }}>
|
|
79
|
+
{Icon.bolt()} 连接 {pending.length === 1 ? pending[0].label : "智能体"}
|
|
80
|
+
</button>
|
|
81
|
+
</>}
|
|
82
|
+
>
|
|
83
|
+
<p style={{ margin: 0, color: "var(--ink-2)", lineHeight: 1.55 }}>
|
|
84
|
+
用订阅账号登录 {names}(无需 API 密钥),任务中就也能选用它{pending.length > 1 ? "们" : ""}。
|
|
85
|
+
之后随时可以在 <strong>设置 → 智能体</strong> 中完成。
|
|
86
|
+
</p>
|
|
87
|
+
</Modal>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generic "connect an agent" card, driven entirely by the agent-scoped auth
|
|
92
|
+
// routes (/api/agents/[id]/{login,login/code,verify,api-key}) and the driver's
|
|
93
|
+
// capabilities from GET /api/agents. One component serves every agent — Claude's
|
|
94
|
+
// paste-a-code OAuth and Codex's device-code flow both fit — so agent #3 is a
|
|
95
|
+
// registry entry with no new UI. Used by the Settings "Agents" section and the
|
|
96
|
+
// post-setup "connect another agent" nudge. (The first-run wizard keeps its own
|
|
97
|
+
// Claude-specific step so it can drive the onboarding funnel.)
|
|
98
|
+
//
|
|
99
|
+
// Agents that front many model providers (capabilities.supportsProviderAuth —
|
|
100
|
+
// Pi) also get the per-provider auth panel under the card; every other agent
|
|
101
|
+
// renders exactly the card.
|
|
102
|
+
export function AgentConnect(props: { agent: AgentInfoT; onConnected?: () => void; compact?: boolean }) {
|
|
103
|
+
// No panel when the CLI isn't on this machine: there is nothing to manage,
|
|
104
|
+
// and listing providers would only spawn a helper that fails.
|
|
105
|
+
if (!props.agent.capabilities.supportsProviderAuth || props.agent.installed === false) return <AgentConnectCard {...props} />;
|
|
106
|
+
return (
|
|
107
|
+
<div>
|
|
108
|
+
<AgentConnectCard {...props} />
|
|
109
|
+
<ProviderAuthPanel agent={props.agent} onConnected={props.onConnected} />
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function AgentConnectCard({
|
|
115
|
+
agent,
|
|
116
|
+
onConnected,
|
|
117
|
+
compact,
|
|
118
|
+
}: {
|
|
119
|
+
agent: AgentInfoT;
|
|
120
|
+
onConnected?: () => void;
|
|
121
|
+
compact?: boolean;
|
|
122
|
+
}) {
|
|
123
|
+
const configuredBedrock = agent.provider === "bedrock";
|
|
124
|
+
const configuredCompatible = agent.provider === "compatible";
|
|
125
|
+
const canApiKey = !!agent.capabilities.apiKeyHint && !configuredBedrock;
|
|
126
|
+
const canBedrock = agent.capabilities.supportsBedrock === true;
|
|
127
|
+
const [mode, setMode] = useState<"subscription" | "api_key" | "bedrock">(
|
|
128
|
+
configuredBedrock || agent.account?.method === "bedrock" ? "bedrock" : agent.account?.method === "api_key" ? "api_key" : "subscription"
|
|
129
|
+
);
|
|
130
|
+
const [reconnect, setReconnect] = useState(false);
|
|
131
|
+
|
|
132
|
+
// Connected on record, but its credentials died in flight (lib/authFailure.ts).
|
|
133
|
+
// Never show the green "is connected" state here: the banner sends people to
|
|
134
|
+
// this card to FIX it, so lead with what broke and put the login one click away.
|
|
135
|
+
if (agent.connected && agent.authBroken && !reconnect) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="wiz-connected broken">
|
|
138
|
+
<span className="wiz-warn">{Icon.bolt()}</span>
|
|
139
|
+
<div>
|
|
140
|
+
<div className="wiz-ok-t">
|
|
141
|
+
{agent.label} 的登录凭据已失效
|
|
142
|
+
{agent.account?.email ? <>(账号 <strong>{agent.account.email}</strong>)</> : ""}
|
|
143
|
+
</div>
|
|
144
|
+
<div className="hlp" style={{ margin: "3px 0 0" }}>{agent.authBroken.reason}</div>
|
|
145
|
+
<button className="btn btn-accent btn-sm" style={{ marginTop: 9 }} onClick={() => setReconnect(true)}>
|
|
146
|
+
{Icon.bolt()} 刷新连接
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Already connected from a prior run — show the state + a reconnect affordance.
|
|
154
|
+
if (agent.connected && !reconnect) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="wiz-connected">
|
|
157
|
+
<span className="wiz-ok">{Icon.check()}</span>
|
|
158
|
+
<div>
|
|
159
|
+
<div className="wiz-ok-t">
|
|
160
|
+
{agent.label} 已连接
|
|
161
|
+
{agent.account?.email ? <>,账号 <strong>{agent.account.email}</strong></> : ""}
|
|
162
|
+
{agent.account?.plan ? `(${agent.account.plan})` : ""}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="hlp" style={{ margin: "3px 0 0" }}>
|
|
165
|
+
<button className="linkbtn" onClick={() => setReconnect(true)}>{configuredCompatible ? "重新验证连接" : "换一个账号重新连接"}</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div>
|
|
174
|
+
{!configuredCompatible && !configuredBedrock && (canApiKey || canBedrock) && (
|
|
175
|
+
<div className="seg" style={{ maxWidth: 460, marginBottom: 16 }}>
|
|
176
|
+
<button className={mode === "subscription" ? "on" : ""} onClick={() => setMode("subscription")}>
|
|
177
|
+
{Icon.bolt()} 登录
|
|
178
|
+
</button>
|
|
179
|
+
<button className={mode === "api_key" ? "on" : ""} onClick={() => setMode("api_key")}>
|
|
180
|
+
{Icon.lock()} 我有 API 密钥
|
|
181
|
+
</button>
|
|
182
|
+
{canBedrock && (
|
|
183
|
+
<button className={mode === "bedrock" ? "on" : ""} onClick={() => setMode("bedrock")}>
|
|
184
|
+
AWS Bedrock
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
{configuredCompatible ? (
|
|
190
|
+
<BedrockConnect agent={agent} onConnected={onConnected} compatible />
|
|
191
|
+
) : mode === "subscription" ? (
|
|
192
|
+
<SubscriptionConnect agent={agent} compact={compact} onConnected={onConnected} />
|
|
193
|
+
) : mode === "api_key" ? (
|
|
194
|
+
<ApiKeyConnect agent={agent} onConnected={onConnected} />
|
|
195
|
+
) : (
|
|
196
|
+
<BedrockConnect agent={agent} onConnected={onConnected} />
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------- Amazon Bedrock ----------
|
|
203
|
+
|
|
204
|
+
function BedrockConnect({ agent, onConnected, compatible = false }: { agent: AgentInfoT; onConnected?: () => void; compatible?: boolean }) {
|
|
205
|
+
const provider = compatible ? "compatible" : "bedrock";
|
|
206
|
+
const label = compatible ? "兼容端点" : "Amazon Bedrock";
|
|
207
|
+
const [busy, setBusy] = useState(false);
|
|
208
|
+
const [err, setErr] = useState<string | null>(null);
|
|
209
|
+
|
|
210
|
+
const verify = async () => {
|
|
211
|
+
setBusy(true);
|
|
212
|
+
setErr(null);
|
|
213
|
+
try {
|
|
214
|
+
const r = await jsend<ClaudeVerifyT>(`/api/agents/${agent.id}/verify`, "POST");
|
|
215
|
+
if (!r.connected || r.provider !== provider) {
|
|
216
|
+
setErr(r.connected
|
|
217
|
+
? `Claude 有响应,但它并未配置为使用 ${label}。`
|
|
218
|
+
: r.error ?? `${label} 验证失败。`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
onConnected?.();
|
|
222
|
+
} catch (e) {
|
|
223
|
+
setErr((e instanceof Error ? e.message : String(e)).replace(/^\d+\s*/, ""));
|
|
224
|
+
} finally {
|
|
225
|
+
setBusy(false);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="field" style={{ maxWidth: 620 }}>
|
|
231
|
+
<div className="hlp" style={{ marginTop: 0 }}>
|
|
232
|
+
{compatible ? <>已检测到 Claude Code 的第三方兼容端点配置。点击验证,通过现有 CLI 运行一次测试,成功后即可在 Operator 中使用。</> : <>为 Claude Code 配置 <code>CLAUDE_CODE_USE_BEDROCK=1</code>、AWS 区域和 AWS 凭据,然后重启 Operator。在 Docker 中,请在 Compose 的 environment 里设置这些值。你也可以在终端运行 <code>claude</code> 并使用 <code>/setup-bedrock</code>;其设置会保存在 <code>~/.claude</code>。</>}
|
|
233
|
+
</div>
|
|
234
|
+
<button className="btn btn-accent" disabled={busy} onClick={verify} style={{ alignSelf: "flex-start" }}>
|
|
235
|
+
{Icon.bolt()} {busy ? `正在测试 ${label}…` : `验证 ${label}`}
|
|
236
|
+
</button>
|
|
237
|
+
{busy && <div className="wiz-verify"><span className="wiz-spin" /> <span>正在通过 {label} 运行一次快速测试…</span></div>}
|
|
238
|
+
{err && <div className="hlp" style={{ color: "var(--red)", marginTop: 8 }}>⚠ {err}</div>}
|
|
239
|
+
{!compatible && agent.providerRefresh && <BedrockRefresh agent={agent} onConnected={onConnected} />}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The in-app AWS SSO refresh: drives the agent's login surface, which on
|
|
245
|
+
// Bedrock runs the instance's refresh command (Claude Code's `awsAuthRefresh`
|
|
246
|
+
// setting, or a device-code `aws sso login` for the SSO profile) and surfaces
|
|
247
|
+
// the device URL + one-time code here. Clicking through lands on the IdP
|
|
248
|
+
// (silent if that session is still live) then the AWS approval page; nothing is
|
|
249
|
+
// pasted back — the command exits on approval and the poll flips to success.
|
|
250
|
+
function BedrockRefresh({ agent, onConnected }: { agent: AgentInfoT; onConnected?: () => void }) {
|
|
251
|
+
const [login, setLogin] = useState<AgentLoginT | null>(null);
|
|
252
|
+
const [busy, setBusy] = useState(false);
|
|
253
|
+
const [showLog, setShowLog] = useState(false);
|
|
254
|
+
const fired = useRef(false);
|
|
255
|
+
const base = `/api/agents/${agent.id}/login`;
|
|
256
|
+
|
|
257
|
+
// Rejoin a refresh already underway (card re-mounted / page reload).
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
jget<AgentLoginT | null>(base)
|
|
260
|
+
.then((l) => { if (l && l.status !== "idle") setLogin(l); })
|
|
261
|
+
.catch(() => {});
|
|
262
|
+
}, [base]);
|
|
263
|
+
|
|
264
|
+
const succeed = useCallback(() => {
|
|
265
|
+
if (fired.current) return;
|
|
266
|
+
fired.current = true;
|
|
267
|
+
// Prove the fresh credentials end-to-end (also updates the connection record).
|
|
268
|
+
jsend<ClaudeVerifyT>(`/api/agents/${agent.id}/verify`, "POST").catch(() => {}).finally(() => onConnected?.());
|
|
269
|
+
}, [agent.id, onConnected]);
|
|
270
|
+
|
|
271
|
+
const start = async () => {
|
|
272
|
+
setBusy(true);
|
|
273
|
+
fired.current = false;
|
|
274
|
+
try {
|
|
275
|
+
const l = await jsend<AgentLoginT>(base, "POST");
|
|
276
|
+
setLogin(l);
|
|
277
|
+
if (l.status === "success") succeed();
|
|
278
|
+
} catch (e) {
|
|
279
|
+
setLogin({ status: "error", url: null, email: null, plan: null, log: "", error: e instanceof Error ? e.message : String(e) });
|
|
280
|
+
} finally {
|
|
281
|
+
setBusy(false);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Poll while the CLI waits for the browser-side approval.
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!login || (login.status !== "starting" && login.status !== "awaiting")) return;
|
|
288
|
+
const t = setInterval(() => {
|
|
289
|
+
jget<AgentLoginT>(base).then((l) => {
|
|
290
|
+
setLogin(l);
|
|
291
|
+
if (l.status === "success") succeed();
|
|
292
|
+
}).catch(() => {});
|
|
293
|
+
}, 1800);
|
|
294
|
+
return () => clearInterval(t);
|
|
295
|
+
}, [login, base, succeed]);
|
|
296
|
+
|
|
297
|
+
if (login?.status === "success") {
|
|
298
|
+
return (
|
|
299
|
+
<div className="wiz-connected" style={{ marginTop: 12 }}>
|
|
300
|
+
<span className="wiz-ok">{Icon.check()}</span>
|
|
301
|
+
<div className="wiz-ok-t">AWS 登录已刷新</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (login?.status === "error") {
|
|
307
|
+
return (
|
|
308
|
+
<div style={{ marginTop: 12 }}>
|
|
309
|
+
<div className="hlp" style={{ marginTop: 0, color: "var(--red)" }}>⚠ {login.error ?? "AWS 刷新失败"}</div>
|
|
310
|
+
<button className="btn btn-line btn-sm" onClick={start} disabled={busy} style={{ marginTop: 8 }}>{Icon.restore()} 重试</button>
|
|
311
|
+
<LogToggle log={login.log} show={showLog} setShow={setShowLog} />
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (login && (login.status === "awaiting" || login.status === "submitting")) {
|
|
317
|
+
return (
|
|
318
|
+
<div className="wiz-codecard" style={{ marginTop: 12 }}>
|
|
319
|
+
<div className="hlp" style={{ marginTop: 0 }}>1. 打开此链接,用你的 AWS / 公司账号确认登录:</div>
|
|
320
|
+
<div style={{ display: "flex", gap: 10, margin: "10px 0 6px", flexWrap: "wrap" }}>
|
|
321
|
+
<a className="btn btn-accent" href={login.url ?? undefined} target="_blank" rel="noreferrer">{Icon.bolt()} 打开 AWS 登录页</a>
|
|
322
|
+
<button className="btn btn-line" onClick={() => login.url && navigator.clipboard?.writeText(login.url)}>复制链接</button>
|
|
323
|
+
</div>
|
|
324
|
+
<div className="hlp" style={{ marginTop: 6 }}>
|
|
325
|
+
2. 如果页面要求输入验证码,请输入{login.code ? ":" : "页面上显示的验证码。"}
|
|
326
|
+
</div>
|
|
327
|
+
{login.code && <div className="ctx-mono" style={{ fontSize: 20, letterSpacing: 2, margin: "8px 0 2px", fontWeight: 600 }}>{login.code}</div>}
|
|
328
|
+
<div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中确认…</span></div>
|
|
329
|
+
<LogToggle log={login.log} show={showLog} setShow={setShowLog} />
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (login?.status === "starting") {
|
|
335
|
+
return <div className="wiz-verify" style={{ marginTop: 12 }}><span className="wiz-spin" /> <span>正在启动 AWS 刷新…获取登录链接。</span></div>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// idle — the refresh CTA under the verify button
|
|
339
|
+
return (
|
|
340
|
+
<div className="hlp" style={{ marginTop: 10 }}>
|
|
341
|
+
AWS 会话已过期?{" "}
|
|
342
|
+
<button className="linkbtn" onClick={start} disabled={busy}>
|
|
343
|
+
{busy ? "启动中…" : "刷新 AWS 登录"}
|
|
344
|
+
</button>{" "}
|
|
345
|
+
—— 在浏览器里确认即可,无需回填任何内容。
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------- subscription (device / paste-code) login ----------
|
|
351
|
+
|
|
352
|
+
function SubscriptionConnect({ agent, onConnected, compact }: { agent: AgentInfoT; onConnected?: () => void; compact?: boolean }) {
|
|
353
|
+
const [existingAuth, setExistingAuth] = useState<{ authenticated: boolean; email: string | null; plan: string | null } | null>(null);
|
|
354
|
+
const [checkingAuth, setCheckingAuth] = useState(true);
|
|
355
|
+
const [chooseLogin, setChooseLogin] = useState(false);
|
|
356
|
+
const [login, setLogin] = useState<AgentLoginT | null>(null);
|
|
357
|
+
const [busy, setBusy] = useState(false);
|
|
358
|
+
const [code, setCode] = useState("");
|
|
359
|
+
const [showLog, setShowLog] = useState(false);
|
|
360
|
+
const [verifying, setVerifying] = useState(false);
|
|
361
|
+
const [verifyErr, setVerifyErr] = useState<string | null>(null);
|
|
362
|
+
const fired = useRef(false);
|
|
363
|
+
const pasteStyle = agent.capabilities.loginStyle === "paste_code";
|
|
364
|
+
const base = `/api/agents/${agent.id}/login`;
|
|
365
|
+
|
|
366
|
+
// Prefer the CLI's existing credentials over an unfinished app login.
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
let active = true;
|
|
369
|
+
setCheckingAuth(true);
|
|
370
|
+
setExistingAuth(null);
|
|
371
|
+
setChooseLogin(false);
|
|
372
|
+
jget<{ authenticated: boolean; email: string | null; plan: string | null }>(`/api/agents/${agent.id}/status`)
|
|
373
|
+
.then((status) => { if (active) setExistingAuth(status); })
|
|
374
|
+
.catch(() => {})
|
|
375
|
+
.finally(() => { if (active) setCheckingAuth(false); });
|
|
376
|
+
return () => { active = false; };
|
|
377
|
+
}, [agent.id]);
|
|
378
|
+
|
|
379
|
+
// Rejoin a login already underway (card re-mounted / page reload).
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
jget<AgentLoginT | null>(base)
|
|
382
|
+
.then((l) => { if (l && l.status !== "idle") setLogin(l); })
|
|
383
|
+
.catch(() => {});
|
|
384
|
+
}, [base]);
|
|
385
|
+
|
|
386
|
+
const succeed = useCallback(() => {
|
|
387
|
+
if (fired.current) return;
|
|
388
|
+
fired.current = true;
|
|
389
|
+
// Prove it end-to-end before declaring victory (updates the connection record).
|
|
390
|
+
jsend<ClaudeVerifyT>(`/api/agents/${agent.id}/verify`, "POST").catch(() => {}).finally(() => onConnected?.());
|
|
391
|
+
}, [agent.id, onConnected]);
|
|
392
|
+
|
|
393
|
+
// Escape hatch for a CLI that's already authenticated on this machine (login
|
|
394
|
+
// done outside the app, restored home dir): skip the OAuth dance and just run
|
|
395
|
+
// the verify test turn, which writes the connection record on success.
|
|
396
|
+
const verifyExisting = async () => {
|
|
397
|
+
setVerifying(true);
|
|
398
|
+
setVerifyErr(null);
|
|
399
|
+
try {
|
|
400
|
+
const r = await jsend<ClaudeVerifyT>(`/api/agents/${agent.id}/verify`, "POST");
|
|
401
|
+
if (r.connected) {
|
|
402
|
+
fired.current = true; // verify already ran — succeed() would run it again
|
|
403
|
+
onConnected?.();
|
|
404
|
+
} else {
|
|
405
|
+
setVerifyErr(r.error ?? `本机未找到可用的 ${agent.label} 登录`);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
setVerifyErr((e instanceof Error ? e.message : String(e)).replace(/^\d+\s*/, ""));
|
|
409
|
+
} finally {
|
|
410
|
+
setVerifying(false);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const start = async () => {
|
|
415
|
+
setBusy(true);
|
|
416
|
+
setCode("");
|
|
417
|
+
fired.current = false;
|
|
418
|
+
setVerifyErr(null);
|
|
419
|
+
try {
|
|
420
|
+
const l = await jsend<AgentLoginT>(base, "POST");
|
|
421
|
+
setLogin(l);
|
|
422
|
+
if (l.status === "success") succeed();
|
|
423
|
+
} catch (e) {
|
|
424
|
+
setLogin({ status: "error", url: null, email: null, plan: null, log: "", error: e instanceof Error ? e.message : String(e) });
|
|
425
|
+
} finally {
|
|
426
|
+
setBusy(false);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Poll while the CLI waits for the user to authorize / exchange the code.
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (checkingAuth || (existingAuth?.authenticated && !chooseLogin)) return;
|
|
433
|
+
if (!login || (login.status !== "starting" && login.status !== "awaiting" && login.status !== "submitting")) return;
|
|
434
|
+
const t = setInterval(() => {
|
|
435
|
+
jget<AgentLoginT>(base).then((l) => {
|
|
436
|
+
setLogin(l);
|
|
437
|
+
if (l.status === "success") succeed();
|
|
438
|
+
}).catch(() => {});
|
|
439
|
+
}, 1800);
|
|
440
|
+
return () => clearInterval(t);
|
|
441
|
+
}, [login, base, succeed, checkingAuth, existingAuth, chooseLogin]);
|
|
442
|
+
|
|
443
|
+
const submitCode = async () => {
|
|
444
|
+
if (!code.trim()) return;
|
|
445
|
+
setBusy(true);
|
|
446
|
+
try {
|
|
447
|
+
const l = await jsend<AgentLoginT>(`${base}/code`, "POST", { code: code.trim() });
|
|
448
|
+
setLogin(l);
|
|
449
|
+
if (l.status === "success") succeed();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
setLogin((p) => ({ ...(p as AgentLoginT), status: "error", error: e instanceof Error ? e.message : String(e) }));
|
|
452
|
+
} finally {
|
|
453
|
+
setBusy(false);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (checkingAuth) {
|
|
458
|
+
return <div className="wiz-verify"><span className="wiz-spin" /> <span>正在检查本机已有登录…</span></div>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (existingAuth?.authenticated && !chooseLogin) {
|
|
462
|
+
return (
|
|
463
|
+
<div className="field" style={{ maxWidth: 560 }}>
|
|
464
|
+
<div className="hlp" style={{ marginTop: 0 }}>
|
|
465
|
+
已检测到本机 {agent.label} 登录
|
|
466
|
+
{existingAuth.email ? `:${existingAuth.email}` : ""}
|
|
467
|
+
{existingAuth.plan ? `(${existingAuth.plan})` : ""}。验证通过后即可使用。
|
|
468
|
+
</div>
|
|
469
|
+
<button className="btn btn-accent" disabled={verifying} onClick={verifyExisting} style={{ alignSelf: "flex-start" }}>
|
|
470
|
+
{Icon.bolt()} {verifying ? "正在验证已有登录…" : "验证并使用本机登录"}
|
|
471
|
+
</button>
|
|
472
|
+
{verifyErr && <div className="hlp" style={{ color: "var(--red)" }}>⚠ {verifyErr}</div>}
|
|
473
|
+
<button className="linkbtn" disabled={verifying} onClick={() => setChooseLogin(true)} style={{ alignSelf: "flex-start" }}>换账号登录</button>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (login?.status === "success") {
|
|
479
|
+
return (
|
|
480
|
+
<div className="wiz-connected">
|
|
481
|
+
<span className="wiz-ok">{Icon.check()}</span>
|
|
482
|
+
<div className="wiz-ok-t">已登录{login.email ? <>,账号 <strong>{login.email}</strong></> : ""}{login.plan ? `(${login.plan})` : ""}</div>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (login?.status === "error") {
|
|
488
|
+
return (
|
|
489
|
+
<div className="field" style={{ maxWidth: 560 }}>
|
|
490
|
+
<div className="hlp" style={{ marginTop: 0, color: "var(--red)" }}>⚠ {login.error ?? "登录失败"}</div>
|
|
491
|
+
<button className="btn btn-line" onClick={start} disabled={busy} style={{ alignSelf: "flex-start" }}>{Icon.restore()} 重试</button>
|
|
492
|
+
<LogToggle log={login.log} show={showLog} setShow={setShowLog} />
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (login && (login.status === "awaiting" || login.status === "submitting")) {
|
|
498
|
+
return (
|
|
499
|
+
<div className="wiz-codecard">
|
|
500
|
+
<div className="hlp" style={{ marginTop: 0 }}>1. 打开此链接,登录你的 {agent.label} 账号:</div>
|
|
501
|
+
<div style={{ display: "flex", gap: 10, margin: "10px 0 16px", flexWrap: "wrap" }}>
|
|
502
|
+
<a className="btn btn-accent" href={login.url ?? undefined} target="_blank" rel="noreferrer">{Icon.bolt()} 打开登录页</a>
|
|
503
|
+
<button className="btn btn-line" onClick={() => login.url && navigator.clipboard?.writeText(login.url)}>复制链接</button>
|
|
504
|
+
</div>
|
|
505
|
+
{pasteStyle ? (
|
|
506
|
+
<>
|
|
507
|
+
<div className="hlp" style={{ marginTop: 0 }}>2. 把页面上显示的验证码粘贴到这里:</div>
|
|
508
|
+
<div style={{ display: "flex", gap: 8, marginTop: 8, maxWidth: 460 }}>
|
|
509
|
+
<input
|
|
510
|
+
type="text" className="ctx-mono" autoFocus value={code} placeholder="粘贴授权码"
|
|
511
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
512
|
+
onChange={(e) => setCode(e.target.value)}
|
|
513
|
+
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }}
|
|
514
|
+
/>
|
|
515
|
+
<button className="btn btn-accent" disabled={busy || !code.trim()} onClick={submitCode}>
|
|
516
|
+
{login.status === "submitting" || busy ? "验证中…" : "提交"}
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
</>
|
|
520
|
+
) : (
|
|
521
|
+
<>
|
|
522
|
+
<div className="hlp" style={{ marginTop: 0 }}>
|
|
523
|
+
2. 出现提示时,输入这个一次性验证码{login.code ? "" : "(显示在登录页上)"}:
|
|
524
|
+
</div>
|
|
525
|
+
{login.code && <div className="ctx-mono" style={{ fontSize: 20, letterSpacing: 2, margin: "8px 0 2px", fontWeight: 600 }}>{login.code}</div>}
|
|
526
|
+
<div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中完成授权…</span></div>
|
|
527
|
+
</>
|
|
528
|
+
)}
|
|
529
|
+
<LogToggle log={login.log} show={showLog} setShow={setShowLog} />
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (login?.status === "starting") {
|
|
535
|
+
return <div className="wiz-verify"><span className="wiz-spin" /> <span>正在启动登录…准备授权链接。</span></div>;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// idle — initial CTA
|
|
539
|
+
return (
|
|
540
|
+
<div className="field" style={{ maxWidth: 560 }}>
|
|
541
|
+
{!compact && (
|
|
542
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
|
|
543
|
+
{agent.capabilities.loginHint ?? "你会拿到一个登录链接,在任意浏览器中打开即可。登录状态保存在此工作区中,重启后依然有效 —— 只需操作一次。"}
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
<button className="btn btn-accent" disabled={busy || verifying} onClick={start} style={{ alignSelf: "flex-start" }}>
|
|
547
|
+
{Icon.bolt()} {busy ? "启动中…" : `连接 ${agent.label} 账号`}
|
|
548
|
+
</button>
|
|
549
|
+
{verifying ? (
|
|
550
|
+
<div className="wiz-verify" style={{ marginTop: 10 }}>
|
|
551
|
+
<span className="wiz-spin" /> <span>正在检查已有登录 —— 运行一次快速测试…</span>
|
|
552
|
+
</div>
|
|
553
|
+
) : (
|
|
554
|
+
<div className="hlp" style={{ marginTop: 10 }}>
|
|
555
|
+
本机已经登录过 {agent.label}?{" "}
|
|
556
|
+
<button className="linkbtn" onClick={verifyExisting}>验证连接</button>
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
{verifyErr && !verifying && (
|
|
560
|
+
<div className="hlp" style={{ color: "var(--red)", marginTop: 6 }}>⚠ {verifyErr}</div>
|
|
561
|
+
)}
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function LogToggle({ log, show, setShow }: { log: string; show: boolean; setShow: (b: boolean) => void }) {
|
|
567
|
+
if (!log) return null;
|
|
568
|
+
return (
|
|
569
|
+
<div style={{ marginTop: 14 }}>
|
|
570
|
+
<button className="linkbtn" onClick={() => setShow(!show)}>{show ? "隐藏" : "显示"}终端输出</button>
|
|
571
|
+
{show && <pre className="wiz-termlog">{log}</pre>}
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------- API-key path ----------
|
|
577
|
+
|
|
578
|
+
function ApiKeyConnect({ agent, onConnected }: { agent: AgentInfoT; onConnected?: () => void }) {
|
|
579
|
+
const [key, setKey] = useState("");
|
|
580
|
+
const [busy, setBusy] = useState(false);
|
|
581
|
+
const [saved, setSaved] = useState(false);
|
|
582
|
+
const [err, setErr] = useState<string | null>(null);
|
|
583
|
+
const hint = agent.capabilities.apiKeyHint ?? "sk-…";
|
|
584
|
+
|
|
585
|
+
const save = async () => {
|
|
586
|
+
setBusy(true);
|
|
587
|
+
setErr(null);
|
|
588
|
+
try {
|
|
589
|
+
await jsend(`/api/agents/${agent.id}/api-key`, "POST", { key: key.trim() });
|
|
590
|
+
setSaved(true);
|
|
591
|
+
onConnected?.();
|
|
592
|
+
} catch (e) {
|
|
593
|
+
setErr((e instanceof Error ? e.message : String(e)).replace(/^\d+\s*/, ""));
|
|
594
|
+
} finally {
|
|
595
|
+
setBusy(false);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
if (saved && !key) {
|
|
600
|
+
return (
|
|
601
|
+
<div className="wiz-connected">
|
|
602
|
+
<span className="wiz-ok">{Icon.check()}</span>
|
|
603
|
+
<div>
|
|
604
|
+
<div className="wiz-ok-t">API 密钥已保存</div>
|
|
605
|
+
<div className="hlp" style={{ margin: "3px 0 0" }}>按 token 计费。<button className="linkbtn" onClick={() => setSaved(false)}>更换密钥</button></div>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<div className="field" style={{ maxWidth: 560 }}>
|
|
613
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
|
|
614
|
+
粘贴一个 API 密钥(<code>{hint}</code>)。仅保存在此工作区,并按 token 为 {agent.label} 的用量计费。大多数人更适合使用订阅账号登录。
|
|
615
|
+
</div>
|
|
616
|
+
<div style={{ display: "flex", gap: 8, maxWidth: 520 }}>
|
|
617
|
+
<input type="password" className="ctx-mono" value={key} placeholder={hint} style={{ flex: 1, minWidth: 0 }}
|
|
618
|
+
onChange={(e) => setKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && key.trim()) save(); }} />
|
|
619
|
+
<button className="btn btn-accent" disabled={busy || !key.trim()} onClick={save}>{busy ? "保存中…" : "保存密钥"}</button>
|
|
620
|
+
</div>
|
|
621
|
+
{err && <div className="hlp" style={{ color: "var(--red)", marginTop: 8 }}>⚠ {err}</div>}
|
|
622
|
+
</div>
|
|
623
|
+
);
|
|
624
|
+
}
|