dsh-codex-handoff 0.0.0-stage → 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,972 @@
1
+ /**
2
+ * GPT / Codex 对话桥 —— Client 半边。
3
+ *
4
+ * 两个入口:
5
+ * 1. `sidebar.panellist` + `main` 侧栏面板(整页查看)
6
+ * 2. `conversation.session.header.utilities` 按钮 + `shell.overlay` 右侧抽屉(贴着对话看)
7
+ *
8
+ * 数据全部来自 Host 半边的 /api/codex-bridge/*。
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: "dsh-codex-handoff",
12
+ factory(require) {
13
+ const React = require("react");
14
+ const h = React.createElement;
15
+ const { useState, useEffect, useCallback, useMemo, useRef } = React;
16
+
17
+ const ROUTE = "/api/codex-bridge";
18
+ const DRAWER_WIDTH = 430;
19
+
20
+ // ── 抽屉开关:模块级小 store,按钮与抽屉共享 ──────────────────────
21
+ const drawerStore = { open: false, listeners: new Set() };
22
+ function setDrawerOpen(open) {
23
+ if (drawerStore.open === open) return;
24
+ drawerStore.open = open;
25
+ for (const listener of drawerStore.listeners) {
26
+ try {
27
+ listener(open);
28
+ } catch {
29
+ /* 单个订阅者出错不影响其他 */
30
+ }
31
+ }
32
+ }
33
+ function useDrawerOpen() {
34
+ const [open, setOpen] = useState(drawerStore.open);
35
+ useEffect(() => {
36
+ const listener = (value) => setOpen(value);
37
+ drawerStore.listeners.add(listener);
38
+ return () => drawerStore.listeners.delete(listener);
39
+ }, []);
40
+ return open;
41
+ }
42
+
43
+ // ── 样式 ─────────────────────────────────────────────────────────
44
+ const colors = {
45
+ bg: "var(--dsw-alias-bg, #16181d)",
46
+ panel: "var(--dsw-alias-surface, rgba(127,127,127,.06))",
47
+ border: "var(--dsw-alias-border, rgba(127,127,127,.24))",
48
+ accent: "var(--dsw-alias-accent, #4f8ef7)",
49
+ text: "var(--dsw-alias-text, #e6edf3)",
50
+ code: "var(--dsw-alias-code-bg, rgba(127,127,127,.10))",
51
+ };
52
+
53
+ const styles = {
54
+ root: {
55
+ display: "flex",
56
+ flexDirection: "column",
57
+ height: "100%",
58
+ minHeight: 0,
59
+ background: colors.bg,
60
+ color: colors.text,
61
+ fontFamily: "inherit",
62
+ },
63
+ head: {
64
+ display: "flex",
65
+ alignItems: "center",
66
+ justifyContent: "space-between",
67
+ gap: 8,
68
+ padding: "12px 16px",
69
+ borderBottom: `1px solid ${colors.border}`,
70
+ flex: "0 0 auto",
71
+ },
72
+ headTitle: { fontSize: 14, fontWeight: 600, margin: 0, lineHeight: 1.3 },
73
+ headSub: { fontSize: 11, opacity: 0.6, marginTop: 2 },
74
+ headActions: { display: "flex", alignItems: "center", gap: 6 },
75
+ btn: {
76
+ appearance: "none",
77
+ border: `1px solid ${colors.border}`,
78
+ background: "transparent",
79
+ color: "inherit",
80
+ borderRadius: 6,
81
+ padding: "4px 10px",
82
+ fontSize: 12,
83
+ lineHeight: "18px",
84
+ cursor: "pointer",
85
+ fontFamily: "inherit",
86
+ },
87
+ iconBtn: {
88
+ appearance: "none",
89
+ border: "none",
90
+ background: "transparent",
91
+ color: "inherit",
92
+ cursor: "pointer",
93
+ display: "grid",
94
+ placeItems: "center",
95
+ width: 28,
96
+ height: 28,
97
+ borderRadius: 6,
98
+ opacity: 0.75,
99
+ padding: 0,
100
+ },
101
+ body: { flex: "1 1 auto", overflowY: "auto", padding: 12 },
102
+ list: { display: "flex", flexDirection: "column", gap: 8 },
103
+ card: {
104
+ border: `1px solid ${colors.border}`,
105
+ borderRadius: 8,
106
+ padding: "9px 11px",
107
+ cursor: "pointer",
108
+ background: colors.panel,
109
+ },
110
+ cardActive: {
111
+ borderColor: colors.accent,
112
+ boxShadow: `0 0 0 1px ${colors.accent} inset`,
113
+ },
114
+ cardTitle: { fontSize: 12.5, fontWeight: 600, lineHeight: 1.5, wordBreak: "break-word" },
115
+ meta: {
116
+ fontSize: 10.5,
117
+ opacity: 0.62,
118
+ marginTop: 4,
119
+ display: "flex",
120
+ flexWrap: "wrap",
121
+ gap: 8,
122
+ },
123
+ empty: { fontSize: 12, opacity: 0.6, lineHeight: 1.75, padding: "8px 4px" },
124
+ turn: {
125
+ borderTop: `1px dashed ${colors.border}`,
126
+ paddingTop: 10,
127
+ marginTop: 10,
128
+ },
129
+ label: { fontSize: 10.5, fontWeight: 600, opacity: 0.7, marginBottom: 4 },
130
+ rowBetween: {
131
+ display: "flex",
132
+ alignItems: "center",
133
+ justifyContent: "space-between",
134
+ gap: 8,
135
+ },
136
+ pre: {
137
+ margin: 0,
138
+ padding: "8px 10px",
139
+ borderRadius: 6,
140
+ background: colors.code,
141
+ fontSize: 11.5,
142
+ lineHeight: 1.7,
143
+ whiteSpace: "pre-wrap",
144
+ wordBreak: "break-word",
145
+ maxHeight: 320,
146
+ overflowY: "auto",
147
+ fontFamily: "inherit",
148
+ },
149
+ error: { color: "#d06a5a", fontSize: 12, padding: "8px 4px", lineHeight: 1.7 },
150
+ /* 底部发送区 */
151
+ composer: {
152
+ flex: "0 0 auto",
153
+ borderTop: `1px solid ${colors.border}`,
154
+ padding: "8px 12px 10px",
155
+ display: "flex",
156
+ flexDirection: "column",
157
+ gap: 6,
158
+ background: colors.bg,
159
+ },
160
+ textarea: {
161
+ width: "100%",
162
+ boxSizing: "border-box",
163
+ resize: "none",
164
+ border: `1px solid ${colors.border}`,
165
+ borderRadius: 6,
166
+ background: colors.code,
167
+ color: "inherit",
168
+ fontFamily: "inherit",
169
+ fontSize: 12,
170
+ lineHeight: 1.6,
171
+ padding: "6px 8px",
172
+ outline: "none",
173
+ },
174
+ composerRow: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
175
+ select: {
176
+ appearance: "auto",
177
+ border: `1px solid ${colors.border}`,
178
+ borderRadius: 6,
179
+ background: colors.bg,
180
+ color: colors.text,
181
+ /* 让原生下拉(option 列表)跟随主题,而不是系统默认的白底 */
182
+ colorScheme: "light dark",
183
+ fontFamily: "inherit",
184
+ fontSize: 11,
185
+ padding: "3px 4px",
186
+ maxWidth: 150,
187
+ cursor: "pointer",
188
+ },
189
+ option: {
190
+ background: colors.bg,
191
+ color: colors.text,
192
+ },
193
+ /* 自绘下拉:原生 select 的弹层由系统绘制,深色模式下不受主题控制,也截不到图。 */
194
+ dropdown: { position: "relative" },
195
+ dropdownBtn: {
196
+ display: "flex",
197
+ alignItems: "center",
198
+ gap: 5,
199
+ border: `1px solid ${colors.border}`,
200
+ borderRadius: 6,
201
+ background: colors.code,
202
+ color: colors.text,
203
+ fontFamily: "inherit",
204
+ fontSize: 11,
205
+ lineHeight: "16px",
206
+ padding: "3px 7px",
207
+ cursor: "pointer",
208
+ maxWidth: 160,
209
+ whiteSpace: "nowrap",
210
+ },
211
+ dropdownMenu: {
212
+ position: "absolute",
213
+ bottom: "100%",
214
+ left: 0,
215
+ marginBottom: 4,
216
+ minWidth: "100%",
217
+ maxHeight: 240,
218
+ overflowY: "auto",
219
+ background: colors.bg,
220
+ border: `1px solid ${colors.border}`,
221
+ borderRadius: 6,
222
+ boxShadow: "0 10px 28px rgba(0,0,0,.45)",
223
+ zIndex: 1000,
224
+ padding: 4,
225
+ },
226
+ dropdownItem: {
227
+ padding: "4px 9px",
228
+ borderRadius: 4,
229
+ fontSize: 11.5,
230
+ lineHeight: "17px",
231
+ cursor: "pointer",
232
+ whiteSpace: "nowrap",
233
+ color: colors.text,
234
+ },
235
+ dropdownItemActive: {
236
+ background: colors.code,
237
+ color: colors.accent,
238
+ fontWeight: 600,
239
+ },
240
+ sendBtn: {
241
+ appearance: "none",
242
+ border: "none",
243
+ borderRadius: 6,
244
+ background: colors.accent,
245
+ color: "#fff",
246
+ fontFamily: "inherit",
247
+ fontSize: 12,
248
+ fontWeight: 600,
249
+ padding: "5px 14px",
250
+ cursor: "pointer",
251
+ marginLeft: "auto",
252
+ },
253
+ composerHint: { fontSize: 10.5, opacity: 0.55 },
254
+ /* 抽屉:顶部必须避开窗口控制按钮(− □ ×)——那片区域在页面之上,提高 z-index 也盖不过去。 */
255
+ drawer: {
256
+ position: "fixed",
257
+ top: 48,
258
+ right: 0,
259
+ bottom: 0,
260
+ width: DRAWER_WIDTH,
261
+ maxWidth: "92vw",
262
+ display: "flex",
263
+ flexDirection: "column",
264
+ pointerEvents: "auto",
265
+ background: colors.bg,
266
+ borderLeft: `1px solid ${colors.border}`,
267
+ borderTopLeftRadius: 8,
268
+ boxShadow: "-18px 0 40px rgba(0,0,0,.28)",
269
+ zIndex: 900,
270
+ color: colors.text,
271
+ fontFamily: "inherit",
272
+ },
273
+ };
274
+
275
+ function formatTime(ms) {
276
+ if (!ms) return "";
277
+ const d = new Date(ms);
278
+ const p = (n) => String(n).padStart(2, "0");
279
+ return `${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
280
+ }
281
+
282
+ /** 头部副标题里展示当前默认配置(来自 Host 的 /list defaults)。 */
283
+ function defaultsLabel(defaults) {
284
+ if (!defaults) return "";
285
+ const parts = [defaults.model || "模型 Codex 默认"];
286
+ parts.push(defaults.reasoning ? `推理 ${defaults.reasoning}` : "推理默认");
287
+ if (defaults.isolated) parts.push("已隔离");
288
+ return ` · ${parts.join(" / ")}`;
289
+ }
290
+
291
+ async function getJson(url) {
292
+ const response = await fetch(url, { headers: { accept: "application/json" } });
293
+ if (!response.ok) {
294
+ let detail = "";
295
+ try {
296
+ const body = await response.json();
297
+ detail = body?.error || "";
298
+ } catch {
299
+ /* 非 JSON 错误体 */
300
+ }
301
+ throw new Error(detail || `HTTP ${response.status}`);
302
+ }
303
+ return response.json();
304
+ }
305
+
306
+ // ── 数据 ─────────────────────────────────────────────────────────
307
+ function useConversationList(intervalMs) {
308
+ const [data, setData] = useState(null);
309
+ const [error, setError] = useState("");
310
+ const load = useCallback(async () => {
311
+ try {
312
+ const payload = await getJson(`${ROUTE}/list`);
313
+ setData(payload);
314
+ setError("");
315
+ } catch (err) {
316
+ setError(`读取对话记录失败:${String(err?.message ?? err)}`);
317
+ }
318
+ }, []);
319
+ useEffect(() => {
320
+ void load();
321
+ if (!intervalMs) return undefined;
322
+ const timer = setInterval(() => void load(), intervalMs);
323
+ return () => clearInterval(timer);
324
+ }, [load, intervalMs]);
325
+ return { data, error, reload: load };
326
+ }
327
+
328
+ function useConversationDetail(selected) {
329
+ const [detail, setDetail] = useState(null);
330
+ const [error, setError] = useState("");
331
+ const [loading, setLoading] = useState(false);
332
+ useEffect(() => {
333
+ if (!selected) {
334
+ setDetail(null);
335
+ setError("");
336
+ return undefined;
337
+ }
338
+ let cancelled = false;
339
+ setLoading(true);
340
+ setError("");
341
+ getJson(`${ROUTE}/read?id=${encodeURIComponent(selected)}`)
342
+ .then((payload) => {
343
+ if (!cancelled) setDetail(payload);
344
+ })
345
+ .catch((err) => {
346
+ if (!cancelled) setError(`读取该对话失败:${String(err?.message ?? err)}`);
347
+ })
348
+ .finally(() => {
349
+ if (!cancelled) setLoading(false);
350
+ });
351
+ return () => {
352
+ cancelled = true;
353
+ };
354
+ }, [selected]);
355
+ return { detail, error, loading };
356
+ }
357
+
358
+ // ── 展示件 ───────────────────────────────────────────────────────
359
+ function TurnBlock({ turn, index }) {
360
+ const meta = turn.meta || {};
361
+ const usage = meta.usage;
362
+ return h(
363
+ "div",
364
+ { style: styles.turn },
365
+ h(
366
+ "div",
367
+ { style: styles.rowBetween },
368
+ h("div", { style: styles.label }, `第 ${index + 1} 轮 · ${formatTime(turn.at)}`),
369
+ h(
370
+ "div",
371
+ { style: styles.meta },
372
+ (meta.actualModel || meta.model) ? h("span", null, meta.actualModel || meta.model) : null,
373
+ (meta.actualEffort || meta.reasoning)
374
+ ? h("span", null, `推理 ${meta.actualEffort || meta.reasoning}`)
375
+ : null,
376
+ usage
377
+ ? h(
378
+ "span",
379
+ { title: `input ${usage.input_tokens} / cached ${usage.cached_input_tokens ?? 0}` },
380
+ `${Math.round((usage.input_tokens || 0) / 1000)}k in`,
381
+ )
382
+ : null,
383
+ ),
384
+ ),
385
+ h("div", { style: styles.label }, "提问"),
386
+ h("pre", { style: styles.pre }, turn.prompt || "(空)"),
387
+ h("div", { style: { ...styles.label, marginTop: 8 } }, "GPT 回复"),
388
+ h("pre", { style: styles.pre }, turn.reply || "(空)"),
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Codex 会话库回读:rollout 里可能包含用户在 Codex 软件里继续的轮次,
394
+ * 那些不在桥的记录里,标成「Codex 侧新增」。
395
+ */
396
+ function CodexTimeline({ timeline }) {
397
+ const [expanded, setExpanded] = useState(false);
398
+ const messages = timeline?.messages ?? [];
399
+ if (messages.length === 0) return null;
400
+ const COLLAPSED = 3;
401
+ const shown = expanded ? messages : messages.slice(-COLLAPSED);
402
+ const externalCount = messages.filter((m) => m.external).length;
403
+
404
+ return h(
405
+ "div",
406
+ {
407
+ style: {
408
+ marginTop: 12,
409
+ borderTop: `1px dashed ${colors.border}`,
410
+ paddingTop: 10,
411
+ },
412
+ },
413
+ h(
414
+ "div",
415
+ { style: styles.rowBetween },
416
+ h(
417
+ "div",
418
+ { style: styles.label },
419
+ `Codex 会话原文 · ${messages.length} 条`,
420
+ externalCount > 0 ? `(${externalCount} 条 Codex 侧新增)` : "",
421
+ ),
422
+ messages.length > COLLAPSED
423
+ ? h(
424
+ "button",
425
+ { type: "button", style: styles.btn, onClick: () => setExpanded(!expanded) },
426
+ expanded ? "收起" : "展开全部",
427
+ )
428
+ : null,
429
+ ),
430
+ shown.map((message, index) =>
431
+ h(
432
+ "div",
433
+ { key: index, style: { marginTop: 8 } },
434
+ h(
435
+ "div",
436
+ {
437
+ style: {
438
+ ...styles.label,
439
+ color: message.external ? colors.accent : undefined,
440
+ },
441
+ },
442
+ message.role === "user" ? "用户" : "GPT",
443
+ message.external ? " · Codex 侧新增" : "",
444
+ ),
445
+ h("pre", { style: styles.pre }, message.text || "(空)"),
446
+ ),
447
+ ),
448
+ );
449
+ }
450
+
451
+ const FALLBACK_EFFORTS = ["none", "low", "medium", "high", "xhigh"];
452
+
453
+ /**
454
+ * 自绘下拉。原生 <select> 的弹层由系统绘制,深色模式下不受 DSH 主题控制
455
+ * (Windows 上会变成白底),所以这里自己画一个,token 一改就跟着变。
456
+ */
457
+ function Dropdown({ value, options, onChange, disabled, title, width }) {
458
+ const [open, setOpen] = useState(false);
459
+ const rootRef = useRef(null);
460
+
461
+ useEffect(() => {
462
+ if (!open) return undefined;
463
+ const onPointerDown = (event) => {
464
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
465
+ };
466
+ const onKeyDown = (event) => {
467
+ if (event.key === "Escape") setOpen(false);
468
+ };
469
+ document.addEventListener("mousedown", onPointerDown);
470
+ document.addEventListener("keydown", onKeyDown);
471
+ return () => {
472
+ document.removeEventListener("mousedown", onPointerDown);
473
+ document.removeEventListener("keydown", onKeyDown);
474
+ };
475
+ }, [open]);
476
+
477
+ const current = options.find((item) => item.value === value) || options[0];
478
+
479
+ return h(
480
+ "div",
481
+ { ref: rootRef, style: { ...styles.dropdown, width } },
482
+ h(
483
+ "button",
484
+ {
485
+ type: "button",
486
+ style: { ...styles.dropdownBtn, opacity: disabled ? 0.55 : 1 },
487
+ disabled,
488
+ title,
489
+ "aria-haspopup": "listbox",
490
+ "aria-expanded": open,
491
+ onClick: () => setOpen(!open),
492
+ },
493
+ h(
494
+ "span",
495
+ { style: { overflow: "hidden", textOverflow: "ellipsis" } },
496
+ current ? current.label : "",
497
+ ),
498
+ h("span", { style: { opacity: 0.6, fontSize: 9 } }, "▾"),
499
+ ),
500
+ open
501
+ ? h(
502
+ "div",
503
+ { style: styles.dropdownMenu, role: "listbox" },
504
+ options.map((item) =>
505
+ h(
506
+ "div",
507
+ {
508
+ key: item.value || "__default__",
509
+ role: "option",
510
+ "aria-selected": item.value === value,
511
+ style: {
512
+ ...styles.dropdownItem,
513
+ ...(item.value === value ? styles.dropdownItemActive : null),
514
+ },
515
+ onClick: () => {
516
+ onChange(item.value);
517
+ setOpen(false);
518
+ },
519
+ },
520
+ item.label,
521
+ ),
522
+ ),
523
+ )
524
+ : null,
525
+ );
526
+ }
527
+
528
+ /**
529
+ * 底部发送区:直接在这里给 GPT 发消息,并选择模型与推理强度。
530
+ * conversationId 有值时续接那个 Codex 会话,否则新开一段。
531
+ */
532
+ function Composer({ conversation, onSent }) {
533
+ const conversationId = conversation?.id || "";
534
+ const [message, setMessage] = useState("");
535
+ const [model, setModel] = useState(conversation?.model || "");
536
+ const [effort, setEffort] = useState(conversation?.reasoning || "");
537
+ const [models, setModels] = useState([]);
538
+ const [sending, setSending] = useState(false);
539
+ const [error, setError] = useState("");
540
+
541
+ // 会话级记忆:切换(或首次选中)会话时,下拉回填该会话上次用的模型与推理强度。
542
+ useEffect(() => {
543
+ setModel(conversation?.model || "");
544
+ setEffort(conversation?.reasoning || "");
545
+ // 只跟随会话切换;发送后列表刷新不应覆盖用户当前选择。
546
+ // eslint-disable-next-line react-hooks/exhaustive-deps
547
+ }, [conversationId]);
548
+
549
+ useEffect(() => {
550
+ let cancelled = false;
551
+ getJson(`${ROUTE}/models`)
552
+ .then((data) => {
553
+ if (!cancelled) setModels(Array.isArray(data?.models) ? data.models : []);
554
+ })
555
+ .catch(() => {
556
+ /* 模型目录拿不到就只留「默认」 */
557
+ });
558
+ return () => {
559
+ cancelled = true;
560
+ };
561
+ }, []);
562
+
563
+ const selectedModel = models.find((item) => item.slug === model);
564
+ const efforts =
565
+ selectedModel && selectedModel.reasoningLevels && selectedModel.reasoningLevels.length > 0
566
+ ? selectedModel.reasoningLevels
567
+ : FALLBACK_EFFORTS;
568
+
569
+ async function submit() {
570
+ const text = message.trim();
571
+ if (!text || sending) return;
572
+ setSending(true);
573
+ setError("");
574
+ try {
575
+ const response = await fetch(`${ROUTE}/send`, {
576
+ method: "POST",
577
+ headers: { "content-type": "application/json" },
578
+ body: JSON.stringify({
579
+ message: text,
580
+ conversationId: conversationId || undefined,
581
+ model: model || undefined,
582
+ reasoning: effort || undefined,
583
+ }),
584
+ });
585
+ let body = null;
586
+ try {
587
+ body = await response.json();
588
+ } catch {
589
+ body = null;
590
+ }
591
+ if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
592
+ setMessage("");
593
+ if (onSent) onSent(body);
594
+ } catch (err) {
595
+ setError(`发送失败:${String(err?.message ?? err)}`);
596
+ } finally {
597
+ setSending(false);
598
+ }
599
+ }
600
+
601
+ const modelOptions = useMemo(
602
+ () => [
603
+ { value: "", label: "模型 · 默认" },
604
+ ...models.map((item) => ({ value: item.slug, label: item.displayName || item.slug })),
605
+ ],
606
+ [models],
607
+ );
608
+ const effortOptions = useMemo(
609
+ () => [
610
+ { value: "", label: "推理 · 默认" },
611
+ ...efforts.map((level) => ({ value: level, label: level })),
612
+ ],
613
+ [efforts],
614
+ );
615
+
616
+ return h(
617
+ "div",
618
+ { style: styles.composer },
619
+ h("textarea", {
620
+ style: styles.textarea,
621
+ rows: 2,
622
+ placeholder: conversationId ? "续接选中的 Codex 会话…" : "新开一段对话发给 GPT…",
623
+ value: message,
624
+ disabled: sending,
625
+ onChange: (event) => setMessage(event.target.value),
626
+ onKeyDown: (event) => {
627
+ if (event.key === "Enter" && !event.shiftKey) {
628
+ event.preventDefault();
629
+ void submit();
630
+ }
631
+ },
632
+ }),
633
+ error ? h("div", { style: styles.error }, error) : null,
634
+ h(
635
+ "div",
636
+ { style: styles.composerRow },
637
+ h(Dropdown, {
638
+ value: model,
639
+ options: modelOptions,
640
+ onChange: (next) => {
641
+ setModel(next);
642
+ setEffort("");
643
+ },
644
+ disabled: sending,
645
+ title: "模型",
646
+ width: 132,
647
+ }),
648
+ h(Dropdown, {
649
+ value: effort,
650
+ options: effortOptions,
651
+ onChange: setEffort,
652
+ disabled: sending,
653
+ title: "推理强度",
654
+ width: 98,
655
+ }),
656
+ h(
657
+ "button",
658
+ {
659
+ type: "button",
660
+ style: {
661
+ ...styles.sendBtn,
662
+ opacity: sending || !message.trim() ? 0.55 : 1,
663
+ cursor: sending || !message.trim() ? "default" : "pointer",
664
+ },
665
+ disabled: sending || !message.trim(),
666
+ onClick: () => void submit(),
667
+ },
668
+ sending ? "发送中…" : "发送",
669
+ ),
670
+ ),
671
+ h("div", { style: styles.composerHint }, "Enter 发送 · Shift+Enter 换行 · 会消耗 Codex 额度"),
672
+ );
673
+ }
674
+
675
+ function ConversationList({ conversations, selected, onSelect, detail, detailError, detailLoading }) {
676
+ return h(
677
+ "div",
678
+ { style: styles.list },
679
+ conversations.map((item) =>
680
+ h(
681
+ "div",
682
+ {
683
+ key: item.id,
684
+ style: { ...styles.card, ...(item.id === selected ? styles.cardActive : null) },
685
+ onClick: () => onSelect(item.id === selected ? "" : item.id),
686
+ role: "button",
687
+ tabIndex: 0,
688
+ onKeyDown: (event) => {
689
+ if (event.key === "Enter" || event.key === " ") {
690
+ event.preventDefault();
691
+ onSelect(item.id === selected ? "" : item.id);
692
+ }
693
+ },
694
+ },
695
+ h("div", { style: styles.cardTitle }, item.title),
696
+ h(
697
+ "div",
698
+ { style: styles.meta },
699
+ h("span", null, formatTime(item.updatedAt)),
700
+ h("span", null, `${item.turnCount} 轮`),
701
+ item.model ? h("span", null, item.model) : null,
702
+ item.reasoning ? h("span", null, `推理 ${item.reasoning}`) : null,
703
+ item.threadId ? h("span", null, `#${item.threadId.slice(0, 6)}`) : null,
704
+ item.legacy ? h("span", null, "旧记录") : null,
705
+ ),
706
+ item.id === selected
707
+ ? h(
708
+ "div",
709
+ null,
710
+ detailLoading ? h("div", { style: styles.empty }, "载入中…") : null,
711
+ detailError ? h("div", { style: styles.error }, detailError) : null,
712
+ detail
713
+ ? h(
714
+ "div",
715
+ null,
716
+ detail.threadId
717
+ ? h(
718
+ "div",
719
+ { style: { ...styles.meta, marginTop: 8 } },
720
+ h("span", null, `Codex 会话 ${detail.threadId}`),
721
+ )
722
+ : null,
723
+ (detail.turns || []).map((turn, index) =>
724
+ h(TurnBlock, { key: turn.id, turn, index }),
725
+ ),
726
+ h(CodexTimeline, { timeline: detail.codexTimeline }),
727
+ )
728
+ : null,
729
+ )
730
+ : null,
731
+ ),
732
+ ),
733
+ );
734
+ }
735
+
736
+ function EmptyState({ hasList }) {
737
+ if (!hasList) return h("div", { style: styles.empty }, "载入中…");
738
+ return h(
739
+ "div",
740
+ { style: styles.empty },
741
+ "还没有对话记录。",
742
+ h("br"),
743
+ "让 DSH agent 调用 codex_gpt 工具,或用 gpt_bridge.ps1 发一条消息,这里就会出现。",
744
+ );
745
+ }
746
+
747
+ // ── 侧栏主面板 ───────────────────────────────────────────────────
748
+ function BridgePanel() {
749
+ const { data, error, reload } = useConversationList(15000);
750
+ const [selected, setSelected] = useState("");
751
+ const { detail, error: detailError, loading } = useConversationDetail(selected);
752
+ const conversations = data?.conversations ?? [];
753
+ const activeConversation = conversations.find((item) => item.id === selected) || null;
754
+
755
+ return h(
756
+ "div",
757
+ { style: styles.root },
758
+ h(
759
+ "div",
760
+ { style: styles.head },
761
+ h(
762
+ "div",
763
+ null,
764
+ h("h2", { style: styles.headTitle }, "GPT 对话"),
765
+ h(
766
+ "div",
767
+ { style: styles.headSub },
768
+ `Codex 桥 · ${conversations.length} 段会话${defaultsLabel(data?.defaults)}`,
769
+ ),
770
+ ),
771
+ h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
772
+ ),
773
+ h(
774
+ "div",
775
+ { style: styles.body },
776
+ error ? h("div", { style: styles.error }, error) : null,
777
+ data && conversations.length === 0 ? h(EmptyState, { hasList: true }) : null,
778
+ !data && !error ? h(EmptyState, { hasList: false }) : null,
779
+ conversations.length > 0
780
+ ? h(ConversationList, {
781
+ conversations,
782
+ selected,
783
+ onSelect: setSelected,
784
+ detail,
785
+ detailError,
786
+ detailLoading: loading,
787
+ })
788
+ : null,
789
+ ),
790
+ h(Composer, {
791
+ conversation: activeConversation,
792
+ onSent: (result) => {
793
+ if (result && result.conversationId) setSelected(result.conversationId);
794
+ void reload();
795
+ },
796
+ }),
797
+ );
798
+ }
799
+
800
+ // ── 右侧抽屉 ─────────────────────────────────────────────────────
801
+ function BridgeDrawer() {
802
+ const open = useDrawerOpen();
803
+ // 抽屉常驻挂载(关闭时只是隐藏),避免每次打开都重新拉数据、也方便保留展开状态。
804
+ const { data, error, reload } = useConversationList(open ? 15000 : 0);
805
+ const [selected, setSelected] = useState("");
806
+ const { detail, error: detailError, loading } = useConversationDetail(selected);
807
+ const conversations = data?.conversations ?? [];
808
+ const activeConversation = conversations.find((item) => item.id === selected) || null;
809
+
810
+ useEffect(() => {
811
+ if (!open) return undefined;
812
+ const onKey = (event) => {
813
+ if (event.key === "Escape") setDrawerOpen(false);
814
+ };
815
+ window.addEventListener("keydown", onKey);
816
+ return () => window.removeEventListener("keydown", onKey);
817
+ }, [open]);
818
+
819
+ if (!open) return null;
820
+
821
+ return h(
822
+ "div",
823
+ { style: styles.drawer, role: "dialog", "aria-label": "GPT 对话" },
824
+ h(
825
+ "div",
826
+ { style: styles.head },
827
+ h(
828
+ "div",
829
+ null,
830
+ h("h2", { style: styles.headTitle }, "GPT 对话"),
831
+ h(
832
+ "div",
833
+ { style: styles.headSub },
834
+ `Codex 桥 · ${conversations.length} 段会话${defaultsLabel(data?.defaults)}`,
835
+ ),
836
+ ),
837
+ h(
838
+ "div",
839
+ { style: styles.headActions },
840
+ h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
841
+ h(
842
+ "button",
843
+ {
844
+ type: "button",
845
+ style: styles.iconBtn,
846
+ title: "关闭 (Esc)",
847
+ "aria-label": "关闭",
848
+ onClick: () => setDrawerOpen(false),
849
+ },
850
+ h(
851
+ "svg",
852
+ { viewBox: "0 0 16 16", width: 14, height: 14, fill: "none", "aria-hidden": true },
853
+ h("path", {
854
+ d: "M4 4l8 8M12 4l-8 8",
855
+ stroke: "currentColor",
856
+ strokeWidth: 1.6,
857
+ strokeLinecap: "round",
858
+ }),
859
+ ),
860
+ ),
861
+ ),
862
+ ),
863
+ h(
864
+ "div",
865
+ { style: styles.body },
866
+ error ? h("div", { style: styles.error }, error) : null,
867
+ data && conversations.length === 0 ? h(EmptyState, { hasList: true }) : null,
868
+ !data && !error ? h(EmptyState, { hasList: false }) : null,
869
+ conversations.length > 0
870
+ ? h(ConversationList, {
871
+ conversations,
872
+ selected,
873
+ onSelect: setSelected,
874
+ detail,
875
+ detailError,
876
+ detailLoading: loading,
877
+ })
878
+ : null,
879
+ ),
880
+ h(Composer, {
881
+ conversation: activeConversation,
882
+ onSent: (result) => {
883
+ if (result && result.conversationId) setSelected(result.conversationId);
884
+ void reload();
885
+ },
886
+ }),
887
+ );
888
+ }
889
+
890
+ // ── 图标 ─────────────────────────────────────────────────────────
891
+ function BridgeGlyph({ size = 18 }) {
892
+ return h(
893
+ "svg",
894
+ { viewBox: "0 0 24 24", width: size, height: size, "aria-hidden": true, fill: "none" },
895
+ h("path", {
896
+ d: "M4 7.5A2.5 2.5 0 0 1 6.5 5h6A2.5 2.5 0 0 1 15 7.5v3A2.5 2.5 0 0 1 12.5 13H9l-3 2.4V13H6.5A2.5 2.5 0 0 1 4 10.5v-3Z",
897
+ stroke: "currentColor",
898
+ strokeWidth: 1.6,
899
+ strokeLinejoin: "round",
900
+ }),
901
+ h("path", {
902
+ d: "M13 15.5A2.5 2.5 0 0 1 15.5 13h2A2.5 2.5 0 0 1 20 15.5v2A2.5 2.5 0 0 1 17.5 20H17v2l-2.4-2H15.5A2.5 2.5 0 0 1 13 17.5v-2Z",
903
+ stroke: "currentColor",
904
+ strokeWidth: 1.6,
905
+ strokeLinejoin: "round",
906
+ }),
907
+ );
908
+ }
909
+
910
+ /** 侧栏入口图标(receives { size, active })。 */
911
+ function BridgePanelIcon(props) {
912
+ return h(BridgeGlyph, { size: props?.size ?? 18 });
913
+ }
914
+
915
+ /** 会话头部按钮:切换右侧抽屉。 */
916
+ function BridgeToggle() {
917
+ const open = useDrawerOpen();
918
+ return h(
919
+ "button",
920
+ {
921
+ type: "button",
922
+ style: {
923
+ appearance: "none",
924
+ border: "none",
925
+ background: open ? colors.panel : "transparent",
926
+ color: "inherit",
927
+ cursor: "pointer",
928
+ display: "grid",
929
+ placeItems: "center",
930
+ width: 28,
931
+ height: 28,
932
+ borderRadius: 6,
933
+ opacity: open ? 1 : 0.78,
934
+ padding: 0,
935
+ },
936
+ title: open ? "关闭 GPT 对话" : "打开 GPT 对话",
937
+ "aria-label": "GPT 对话",
938
+ "aria-pressed": open,
939
+ onClick: () => setDrawerOpen(!open),
940
+ },
941
+ h(BridgeGlyph, { size: 16 }),
942
+ );
943
+ }
944
+
945
+ return {
946
+ inject: ["slots"],
947
+ apply(ctx) {
948
+ // 1) 侧栏面板入口 + 主面板
949
+ ctx.slots.inject("sidebar.panellist", () =>
950
+ ctx.slots.register(
951
+ { name: "sidebar.panellist", id: "codex-bridge", order: 30, label: "GPT 对话" },
952
+ BridgePanelIcon,
953
+ ),
954
+ );
955
+ ctx.slots.inject("main", () =>
956
+ ctx.slots.register({ name: "main", key: "codex-bridge" }, BridgePanel),
957
+ );
958
+
959
+ // 2) 会话头部按钮 + 右侧抽屉
960
+ ctx.slots.inject("conversation.session.header.utilities", () =>
961
+ ctx.slots.register(
962
+ { name: "conversation.session.header.utilities", id: "codex-bridge-toggle", order: 2 },
963
+ BridgeToggle,
964
+ ),
965
+ );
966
+ ctx.slots.inject("shell.overlay", () =>
967
+ ctx.slots.register({ name: "shell.overlay", id: "codex-bridge.drawer", order: 40 }, BridgeDrawer),
968
+ );
969
+ },
970
+ };
971
+ },
972
+ });