@aiti-ai/aiti-cursor-agent 0.1.9

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,270 @@
1
+ /**
2
+ * controlled-login — 外部 Agent 受控登录标准 · 前端统一弹窗(原生 h() 渲染,零构建步骤)。
3
+ *
4
+ * ⚠ 副本同步(字节级拷贝,改动须同步到全部副本;测试 `_shared/controlled-login.test.mjs` 钉住一致性)。
5
+ * 权威源:pi-web-ext/_shared/controlled-login-dialog.mjs
6
+ * 部署副本(client 侧走部署树内相对导入 —— 零构建 client 无法像 vite 客户端那样在构建期
7
+ * 内联 ../../_shared(资产路由拒绝越目录穿越),与 draft-keeper 的 ./core.mjs 同一惯例):
8
+ * pi-web-ext/aiti-{zcode,cursor,qoder,claude-code,codex}-agent/client/controlled-login-dialog.mjs
9
+ *
10
+ * 契约(docs/ext-controlled-login-standard.md §3.5):
11
+ * 🔗「打开授权页」按钮(target=_blank)+ URL 文本可复制 + 模式说明行
12
+ * + 状态区(waiting 转圈 / success ✅ 用户名 / failed / timeout / cancelled,均带重试)
13
+ * + B 模式(paste)专属 code 输入框+提交。
14
+ * 状态刷新:弹窗打开期间每 2s 轮询 GET /login/status,关闭即停
15
+ * (方案 §3.6 已论证并拍板的轮询例外:登录是分钟级一次性交互)。
16
+ *
17
+ * 浮动契约:面板根节点 data-ext-float="1" + data-ext-float-w/-h + data-ext-float-full="1"
18
+ * (aiti-float 接管拖拽/缩放/持久化/小屏全屏;未装时自身 style 居中兜底)。
19
+ *
20
+ * 接入(各扩展 client/controlled-login-client.mjs,两行):
21
+ * import { registerControlledLoginClient } from "./controlled-login-dialog.mjs";
22
+ * export default function register(ctx) { registerControlledLoginClient(ctx, { … }); }
23
+ */
24
+
25
+ // ---------- 模块内 open 状态(toolbar 入口与 dialogs 条目同模块共享,不落 window 全局) ----------
26
+ let moduleSetOpen = null;
27
+ // 懒加载(clientImmediately:false)下 toolbar 静态条目点击时弹窗组件可能尚未挂载:
28
+ // 记 pending,挂载 effect 消费——避免首点失效。
29
+ let pendingOpen = false;
30
+
31
+ /** 打开本扩展的受控登录弹窗(toolbar 🔑 按钮 / 静态条目 meta.onClick 用)。 */
32
+ export function openControlledLogin() {
33
+ if (moduleSetOpen) moduleSetOpen(true);
34
+ else pendingOpen = true;
35
+ }
36
+
37
+ // ---------- 自持样式层(幂等注入;clg- 前缀 + var(--x, 兜底),零宿主耦合) ----------
38
+ const STYLE_ID = "aiti-controlled-login-styles";
39
+ const STYLES = `
40
+ .clg-mask { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,.32); display: flex; align-items: center; justify-content: center; }
41
+ .clg-panel { width: 520px; max-width: calc(100vw - 16px); display: flex; flex-direction: column; overflow: hidden;
42
+ background: var(--bg,#fff); color: var(--text,#1a1a1a); border: 1px solid var(--border,#e0e0e0); border-radius: 12px;
43
+ box-shadow: 0 16px 64px rgba(0,0,0,.25); font-size: 13px; line-height: 1.55; }
44
+ .clg-head { display: flex; align-items: center; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border,#e0e0e0); flex-shrink: 0; background: var(--bg-subtle,#f7f7f7); }
45
+ .clg-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
46
+ .clg-x { margin-left: auto; border: none; background: none; cursor: pointer; color: var(--text-muted,#888); font-size: 16px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
47
+ .clg-x:hover { background: var(--bg-hover,#eee); color: var(--text,#1a1a1a); }
48
+ .clg-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 10px; }
49
+ .clg-intro { color: var(--text-muted,#777); font-size: 12.5px; }
50
+ .clg-urlrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
51
+ .clg-open { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border: 1px solid var(--accent,#2563eb); border-radius: 8px;
52
+ cursor: pointer; background: var(--accent,#2563eb); color: #fff; font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
53
+ .clg-open:hover { background: var(--accent-hover,#1d4ed8); }
54
+ .clg-url { flex: 1; min-width: 200px; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#777);
55
+ word-break: break-all; cursor: pointer; padding: 5px 8px; border: 1px dashed var(--border,#d8d8d8); border-radius: 6px; }
56
+ .clg-url:hover { border-color: var(--accent,#2563eb); color: var(--text,#1a1a1a); }
57
+ .clg-copied { color: #16a34a; font-size: 11.5px; }
58
+ .clg-status { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 8px; font-size: 12.5px; flex-wrap: wrap; }
59
+ .clg-status.waiting { background: rgba(37,99,235,.08); color: var(--accent,#2563eb); }
60
+ .clg-status.success { background: color-mix(in srgb, var(--ok, #22c55e) 12%, transparent); color: #16a34a; }
61
+ .clg-status.failed, .clg-status.timeout, .clg-status.cancelled { background: color-mix(in srgb, var(--danger, #ef4444) 8%, transparent); color: #dc2626; }
62
+ .clg-spin { width: 14px; height: 14px; flex-shrink: 0; border: 2px solid color-mix(in srgb, currentColor 25%, transparent); border-top-color: currentColor;
63
+ border-radius: 50%; animation: clg-rotate .9s linear infinite; }
64
+ @keyframes clg-rotate { to { transform: rotate(360deg); } }
65
+ .clg-errline { font-size: 12px; color: #dc2626; word-break: break-all; }
66
+ .clg-tail { font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#888); background: var(--bg-subtle,#f5f5f5);
67
+ border: 1px solid var(--border,#e0e0e0); border-radius: 8px; padding: 8px 10px; max-height: 140px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
68
+ .clg-coderow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
69
+ .clg-input { flex: 1; min-width: 180px; padding: 7px 10px; border: 1px solid var(--border,#d8d8d8); border-radius: 8px;
70
+ background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; outline: none; }
71
+ .clg-input:focus { border-color: var(--accent,#2563eb); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent,#2563eb) 18%, transparent); }
72
+ .clg-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border: 1px solid var(--border,#d8d8d8); border-radius: 7px;
73
+ cursor: pointer; background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; font-weight: 500; white-space: nowrap; }
74
+ .clg-btn:hover { background: var(--bg-hover,#f0f0f0); }
75
+ .clg-btn:disabled { opacity: .5; cursor: not-allowed; }
76
+ .clg-btn.primary { background: var(--accent,#2563eb); border-color: var(--accent,#2563eb); color: #fff; }
77
+ .clg-btn.primary:hover { background: var(--accent-hover,#1d4ed8); }
78
+ .clg-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
79
+ .clg-hint { color: var(--text-dim,#999); font-size: 11.5px; }
80
+ `;
81
+
82
+ let stylesInjected = false;
83
+ function ensureStyles() {
84
+ if (stylesInjected) return;
85
+ if (typeof document === "undefined") return;
86
+ if (!document.getElementById(STYLE_ID)) {
87
+ const el = document.createElement("style");
88
+ el.id = STYLE_ID;
89
+ el.textContent = STYLES;
90
+ document.head.appendChild(el);
91
+ }
92
+ stylesInjected = true;
93
+ }
94
+
95
+ /**
96
+ * 统一弹窗注册(每扩展 client 的薄壳调用一次)。
97
+ *
98
+ * @param {*} ctx 宿主 client 上下文(h/useState/useEffect/apiUrl/registerItems)
99
+ * @param {object} config
100
+ * @param {string} config.extId 扩展 id(API 前缀 / toolbar item id 用)
101
+ * @param {string} config.toolbarLabel 工具条按钮 title/label(如 "登录 ZCode")
102
+ * @param {string} config.title 弹窗标题
103
+ * @param {string} [config.urlHint] A url-only 模式说明行
104
+ * @param {string} [config.pasteHint] B paste-relay 模式说明行
105
+ * @param {string} [config.successHint] 成功后的补充说明(如凭据换 key 提示)
106
+ */
107
+ export function registerControlledLoginClient(ctx, config) {
108
+ ensureStyles();
109
+ const h = (...a) => ctx.h(...a);
110
+ const useState = (v) => ctx.useState(v);
111
+ const useEffect = (fn, deps) => ctx.useEffect(fn, deps);
112
+ const api = (p, init) => fetch(ctx.apiUrl("/api/extensions/" + config.extId + p), { cache: "no-store", ...init });
113
+ const jsonInit = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
114
+
115
+ // ---- 工具条入口:声明式图标按钮(meta.icon + meta.onClick、无 render —— 宿主统一渲染 36×36 线图按钮,禁 emoji) ----
116
+ ctx.registerItems("toolbar", [{
117
+ id: `${config.extId}-controlled-login`,
118
+ meta: {
119
+ label: config.toolbarLabel,
120
+ title: config.toolbarLabel,
121
+ icon: "icons/key-round.svg",
122
+ onClick: () => openControlledLogin(),
123
+ },
124
+ }]);
125
+ // ---- 弹窗条目(dialogs slot;宿主包 display:contents 壳,float 持久化键认它) ----
126
+ ctx.registerItems("dialogs", [{
127
+ id: `${config.extId}-controlled-login-dialog`,
128
+ render: () => h(ControlledLoginDialog),
129
+ }]);
130
+
131
+ function ControlledLoginDialog() {
132
+ const [open, setOpen] = useState(false);
133
+ const [status, setStatus] = useState(null); // /login/status 响应({state,...})
134
+ const [busy, setBusy] = useState(false);
135
+ const [reqError, setReqError] = useState(null); // 请求层错误(网络/400)
136
+ const [code, setCode] = useState("");
137
+ const [copied, setCopied] = useState(false);
138
+
139
+ useEffect(() => {
140
+ moduleSetOpen = setOpen;
141
+ if (pendingOpen) { pendingOpen = false; setOpen(true); }
142
+ return () => { if (moduleSetOpen === setOpen) moduleSetOpen = null; };
143
+ }, []);
144
+
145
+ // 打开期间 2s 轮询 /login/status(§3.6 拍板例外);关闭即停
146
+ useEffect(() => {
147
+ if (!open) return;
148
+ let stopped = false;
149
+ let timer = null;
150
+ const tick = async () => {
151
+ try {
152
+ const r = await api("/login/status");
153
+ const d = await r.json();
154
+ if (!stopped) { setStatus(d); setReqError(null); }
155
+ } catch { /* 网络抖动忽略,下轮再试 */ }
156
+ if (!stopped) timer = setTimeout(tick, 2000);
157
+ };
158
+ tick();
159
+ return () => { stopped = true; if (timer) clearTimeout(timer); };
160
+ }, [open]);
161
+
162
+ if (!open) return null;
163
+
164
+ const st = status?.state ?? "idle";
165
+ const mode = status?.mode ?? "url";
166
+ const pasteMode = mode === "paste";
167
+
168
+ async function call(p, init) {
169
+ setBusy(true); setReqError(null);
170
+ try {
171
+ const r = await api(p, init);
172
+ const d = await r.json();
173
+ if (d && d.error) setReqError(d.error);
174
+ if (d) setStatus(d);
175
+ } catch (e) {
176
+ setReqError(e?.message || String(e));
177
+ }
178
+ setBusy(false);
179
+ }
180
+ const startLogin = () => call("/login/start", jsonInit({}));
181
+ const cancelLogin = () => call("/login/cancel", jsonInit({}));
182
+ const submitCode = () => { if (code.trim()) call("/login/submit", jsonInit({ code })); };
183
+
184
+ async function copyUrl() {
185
+ const url = status?.url || "";
186
+ if (!url) return;
187
+ try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 剪贴板不可用:URL 文本本身可选中复制 */ }
188
+ }
189
+
190
+ // ---- 状态区 ----
191
+ let statusLine = null;
192
+ if (st === "idle") {
193
+ statusLine = h("div", { className: "clg-status waiting" }, "尚未发起登录");
194
+ } else if (st === "waiting") {
195
+ statusLine = h("div", { className: "clg-status waiting" },
196
+ h("span", { className: "clg-spin" }),
197
+ status?.url ? "等待你在浏览器完成授权…(本页自动感知,约 2s 内)" : "正在启动官方登录进程,等待授权链接…",
198
+ h("button", { className: "clg-btn", disabled: busy, style: { marginLeft: "auto" }, onClick: cancelLogin }, "取消"),
199
+ );
200
+ } else if (st === "success") {
201
+ statusLine = h("div", { className: "clg-status success" },
202
+ "登录成功", status?.user ? h("span", { style: { fontWeight: 700 } }, status.user) : null,
203
+ config.successHint ? h("span", { className: "clg-hint", style: { color: "inherit" } }, config.successHint) : null,
204
+ );
205
+ } else if (st === "failed" || st === "timeout" || st === "cancelled") {
206
+ const label = st === "failed" ? "登录失败" : st === "timeout" ? "登录超时" : "已取消";
207
+ statusLine = h("div", { className: "clg-status " + st },
208
+ h("span", null, (st === "timeout" ? "⏱ " : st === "cancelled" ? "⚪ " : "❌ ") + label),
209
+ status?.error ? h("span", { className: "clg-errline" }, status.error) : null,
210
+ );
211
+ }
212
+
213
+ return h("div", { className: "clg-mask", onClick: () => setOpen(false) },
214
+ h("div", {
215
+ className: "clg-panel",
216
+ "data-ext-float": "1", "data-ext-float-w": 520, "data-ext-float-h": 380, "data-ext-float-full": "1",
217
+ onClick: (e) => e.stopPropagation(),
218
+ },
219
+ // ── 标题栏(首个子元素 = float 拖拽把手)──
220
+ h("div", { className: "clg-head" },
221
+ h("span", { className: "clg-title" }, "🔑 " + config.title),
222
+ h("button", { className: "clg-x", title: "关闭", onClick: () => setOpen(false) }, "✕"),
223
+ ),
224
+ h("div", { className: "clg-body" },
225
+ config.urlHint && !pasteMode ? h("div", { className: "clg-intro" }, config.urlHint) : null,
226
+ pasteMode && config.pasteHint ? h("div", { className: "clg-intro" }, config.pasteHint) : null,
227
+
228
+ // ── URL 区(waiting 且已抓到链接)──
229
+ st === "waiting" && status?.url
230
+ ? h("div", { className: "clg-urlrow" },
231
+ h("a", { className: "clg-open", href: status.url, target: "_blank", rel: "noopener noreferrer" }, "打开授权页"),
232
+ h("span", { className: "clg-url", title: "点击复制链接", onClick: copyUrl }, status.url),
233
+ copied ? h("span", { className: "clg-copied" }, "已复制") : null,
234
+ )
235
+ : null,
236
+
237
+ // ── B 模式 code 输入(paste 且 waiting)──
238
+ pasteMode && st === "waiting"
239
+ ? h("div", { className: "clg-coderow" },
240
+ h("input", {
241
+ className: "clg-input",
242
+ placeholder: "粘贴授权完成页显示的 code(或完整回调链接)",
243
+ value: code,
244
+ onChange: (e) => setCode(e.target.value),
245
+ onKeyDown: (e) => { if (e.key === "Enter") submitCode(); },
246
+ }),
247
+ h("button", { className: "clg-btn primary", disabled: busy || !code.trim(), onClick: submitCode }, busy ? "…" : "提交 code"),
248
+ )
249
+ : null,
250
+
251
+ statusLine,
252
+ reqError ? h("div", { className: "clg-errline" }, reqError) : null,
253
+
254
+ // CLI 升级改输出格式导致 URL 抓取失败时:原文透出(人工可读可复制,方案 §7)
255
+ (st === "failed" || st === "timeout") && status?.outputTail
256
+ ? h("div", { className: "clg-tail" }, status.outputTail)
257
+ : null,
258
+
259
+ // ── 底部动作:idle/终态带(重)试 ──
260
+ h("div", { className: "clg-foot" },
261
+ st === "idle" || st === "failed" || st === "timeout" || st === "cancelled" || st === "success"
262
+ ? h("button", { className: "clg-btn primary", disabled: busy, onClick: startLogin }, busy ? "…" : st === "idle" ? "发起登录" : "重试")
263
+ : null,
264
+ h("span", { className: "clg-hint" }, "授权在浏览器完成,凭据由官方 CLI 落盘;本页关闭不影响进行中的登录。"),
265
+ ),
266
+ ),
267
+ ),
268
+ );
269
+ }
270
+ }