dsh-codex-handoff 0.1.1 → 0.1.2
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/README.md +56 -16
- package/lib/client.js +542 -185
- package/lib/index.js +85 -3
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -10,19 +10,35 @@
|
|
|
10
10
|
需要 DSH(DeepSeek Harness)与**本机已登录、可正常调用的 Codex CLI**(`codex` 可执行文件会自动在
|
|
11
11
|
`%LOCALAPPDATA%\OpenAI\Codex\bin\*\codex.exe`、`CODEX_BIN`、PATH 里找)。
|
|
12
12
|
|
|
13
|
+
### 从 npm(推荐)
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
dsh-codex-handoff@0.1.1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- 界面:**插件** 页 → 添加 → 输入 `dsh-codex-handoff`
|
|
20
|
+
- 工具:`plugin_manager` → `install_bundle`,`target` 填 `dsh-codex-handoff@0.1.1`
|
|
21
|
+
|
|
22
|
+
### 从源码
|
|
23
|
+
|
|
13
24
|
```powershell
|
|
14
25
|
git clone https://github.com/HATO-24/dsh-codex-handoff.git
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
然后在 DSH 里装进当前 profile
|
|
18
|
-
|
|
19
|
-
- 界面:**插件** 页 → 添加 → 指向克隆下来的目录;
|
|
20
|
-
- 工具:`plugin_manager` → `install_bundle`,`target` 填该目录的绝对路径。
|
|
28
|
+
然后在 DSH 里装进当前 profile:界面里 **插件** 页 → 添加 → 指向克隆下来的目录,
|
|
29
|
+
或用 `plugin_manager` → `install_bundle`,`target` 填该目录的绝对路径。
|
|
21
30
|
|
|
22
31
|
装好后:
|
|
23
32
|
|
|
24
|
-
-
|
|
25
|
-
-
|
|
33
|
+
- **左下角**(Settings 旁、催更姬旁边)多一个 **GPT** 按钮:点它在 **DSH 右侧栏** 打开对话面板
|
|
34
|
+
- 右侧栏顶部出现 **GPT 对话** 标签(和「开始」并列)
|
|
35
|
+
- 会话标题栏最右侧还有一个对话气泡按钮,作为快捷入口
|
|
36
|
+
|
|
37
|
+
> 为什么不用左侧栏图标?点它会切走 conversation,`sidebarRight.mounted` 变空,
|
|
38
|
+
> `openResource` 直接抛 `sidebarRight: no session surface is mounted`。
|
|
39
|
+
> 这是 DSH 的机制限制,所以入口必须放在**不切面板**的位置(左下角 / 会话头部)。
|
|
40
|
+
|
|
41
|
+
> 注意:Client 插件改了 `inject` 后**要重启 DSH** 才会重新解析。
|
|
26
42
|
|
|
27
43
|
> 本地改代码后,在插件页把该插件 **禁用 → 启用** 即可重载新代码,不用重启 DSH。
|
|
28
44
|
> 首次调用会消耗 Codex 额度;想省上下文就把 `DSH_CODEX_BRIDGE_ISOLATE=1` 打开。
|
|
@@ -91,12 +107,27 @@ codex exec -c 'mcp_servers={}' ...
|
|
|
91
107
|
|
|
92
108
|
## 三、用
|
|
93
109
|
|
|
94
|
-
### 1.
|
|
110
|
+
### 1. 在右侧栏查看记录
|
|
111
|
+
|
|
112
|
+
对话面板**住在 DSH 右侧栏**里(和催更姬用的是同一个扩展点):
|
|
113
|
+
|
|
114
|
+
- 点**左下角的 GPT 按钮**(或会话标题栏的气泡按钮)→ 右侧栏自动展开「GPT 对话」标签
|
|
115
|
+
- 右侧栏本身可以拖动分隔条调整宽度
|
|
116
|
+
- 主面板不受影响,对话照常进行
|
|
95
117
|
|
|
96
|
-
|
|
118
|
+
实现用的是 `ctx.sidebarRightTabs.register()` + `sidebar.right.pane.tab` slot,来自 DSH 内置的
|
|
119
|
+
`@deepseek-ai/dsh-client-ui-sidebar-right`。**关键点:Client 插件的 `inject` 必须显式声明
|
|
120
|
+
`sidebarRight` / `sidebarRightTabs`**,否则 `ctx` 里根本看不到这两个服务(这是踩过的坑,
|
|
121
|
+
表现为 tab 静默不注册、甚至把整个 apply 打断导致界面元素消失)。
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
**入口位置也踩过坑**:`sidebarRight.openResource()` 内部调 `this.require()`,它要求
|
|
124
|
+
`this.mounted.getSnapshot()` 有值(屏幕上必须挂着一个会话)。所以入口**不能放在会切走
|
|
125
|
+
conversation 的地方**——左侧栏图标会切主面板,conversation 卸载后 `mounted` 为空,
|
|
126
|
+
直接抛 `sidebarRight: no session surface is mounted`。只能用左下角按钮或会话头部按钮。
|
|
127
|
+
|
|
128
|
+
样式全部走 DSH 的 theme token(`--dsw-alias-bg-base` / `--dsw-alias-label-primary` /
|
|
129
|
+
`--dsw-alias-brand-primary` 等),明暗主题与换肤自动跟随。注意 `--dsw-alias-bg` / `--dsw-alias-text`
|
|
130
|
+
这类名字**不存在**,写了会静默回退到硬编码色值——这也是踩过的坑。
|
|
100
131
|
|
|
101
132
|
两个入口都显示每段会话的**模型与推理强度**:
|
|
102
133
|
|
|
@@ -114,7 +145,7 @@ codex exec -c 'mcp_servers={}' ...
|
|
|
114
145
|
|
|
115
146
|
### 1.5 在面板里直接发消息、切换模型
|
|
116
147
|
|
|
117
|
-
|
|
148
|
+
右侧栏底部有发送区:
|
|
118
149
|
|
|
119
150
|
- 输入框:`Enter` 发送、`Shift+Enter` 换行
|
|
120
151
|
- **模型下拉**:来自 `/models`,列出 `codex debug models` 里的全部 10 个模型
|
|
@@ -180,6 +211,7 @@ codex_gpt({ threadId: "01a1ac8e-01a4-79a2-a70d-855254e70751", message: "继续
|
|
|
180
211
|
| GET | `/status` | 工作区、codex 路径、隔离开关 |
|
|
181
212
|
| GET | `/models` | 可用模型目录(slug / 显示名 / 支持的推理档位) |
|
|
182
213
|
| POST | `/send` | 发一轮真实对话,body:`{message, conversationId?, threadId?, model?, reasoning?}` |
|
|
214
|
+
| POST | `/promote` | 把一段会话标记成 Codex App 可见,body:`{threadId}` 或 `{conversationId}` |
|
|
183
215
|
|
|
184
216
|
```powershell
|
|
185
217
|
$body = @{ message = "只回复两个字:收到"; reasoning = "low" } | ConvertTo-Json -Compress
|
|
@@ -191,13 +223,21 @@ Invoke-WebRequest -Uri "http://127.0.0.1:12530/api/codex-bridge/send" -Method PO
|
|
|
191
223
|
> `POST /send` 会**真实消耗 Codex 额度**,且同步等待(一次 `low` 推理实测约 58 秒)。
|
|
192
224
|
> 路由用 Node 原生 handler 而非 `resource`:`resource` 路由只放行 GET/HEAD,POST 会被平台层直接 405。
|
|
193
225
|
|
|
194
|
-
### 3. 在 Codex
|
|
226
|
+
### 3. 在 Codex 软件里做第二轮(按需转换)
|
|
227
|
+
|
|
228
|
+
会话**默认持久化**(不再 `--ephemeral`),会写进 `~/.codex/sessions/` 并注册到 Codex 的
|
|
229
|
+
state DB,命令行随时可以 `codex exec resume <thread_id> -` 继续。
|
|
230
|
+
|
|
231
|
+
但 **Codex 桌面版的会话列表只显示 `source = "vscode"` 的线程**;`codex exec` 写的是 `exec`,
|
|
232
|
+
所以默认在 App 列表里看不到(实测:数据三处齐全——threads、projection state、turns——仍然不显示)。
|
|
195
233
|
|
|
196
|
-
|
|
234
|
+
因此提供了**按需转换**:在会话详情里点 **「转移到 Codex」**,插件会把该 thread 的 `source`
|
|
235
|
+
改成 `vscode`,之后**重启 Codex App** 就能在列表里找到并继续。
|
|
197
236
|
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
237
|
+
- 只改 `source` 一个字段(用 Node 内置 `node:sqlite`)
|
|
238
|
+
- 不想每轮都改,所以做成了手动按钮,不是自动行为
|
|
239
|
+
- `DSH_CODEX_BRIDGE_APP_VISIBLE=0` 可以整个关掉这个能力
|
|
240
|
+
- 试过官方的 `codex exec --thread-source vscode`:**无效**,写进去仍是 `exec`
|
|
201
241
|
|
|
202
242
|
### 4. Codex 侧回读(两边都能继续)
|
|
203
243
|
|
package/lib/client.js
CHANGED
|
@@ -16,6 +16,21 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
|
|
17
17
|
const ROUTE = "/api/codex-bridge";
|
|
18
18
|
const DRAWER_WIDTH = 430;
|
|
19
|
+
const DRAWER_WIDTH_KEY = "dsh-codex-handoff:drawer-width";
|
|
20
|
+
/** 右侧栏 tab 的 id(也是 sidebar.right.pane.tab 的 key)。 */
|
|
21
|
+
const TAB_ID = "dsh-codex-handoff";
|
|
22
|
+
const TAB_RESOURCE = "dsh-resource://codex-bridge/main";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 打开入口:激活右侧栏 tab。apply 里会按实际服务覆盖;
|
|
26
|
+
* 服务不可用时只记一条日志(抽屉已移除,不再有回退界面)。
|
|
27
|
+
*/
|
|
28
|
+
const bridgeOpeners = {
|
|
29
|
+
open: () => console.error("[codex-bridge] 右侧栏服务不可用,无法打开对话面板"),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** apply 时填入 ctx.layout,供主面板挂载后切回对话页。 */
|
|
33
|
+
let bridgeLayout = null;
|
|
19
34
|
|
|
20
35
|
// ── 抽屉开关:模块级小 store,按钮与抽屉共享 ──────────────────────
|
|
21
36
|
const drawerStore = { open: false, listeners: new Set() };
|
|
@@ -41,13 +56,23 @@ window.__ModuleLoader__.load({
|
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
// ── 样式 ─────────────────────────────────────────────────────────
|
|
59
|
+
// 全部使用 DSH 的 alias token(cordis_inspect Theme.listTokens 列出的名字),
|
|
60
|
+
// 这样明暗主题、用户换肤都会自动跟随,外观也和本体一致。
|
|
44
61
|
const colors = {
|
|
45
|
-
bg: "var(--dsw-alias-bg, #16181d)",
|
|
46
|
-
panel: "var(--dsw-alias-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
bg: "var(--dsw-alias-bg-base, #16181d)",
|
|
63
|
+
panel: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))",
|
|
64
|
+
layer2: "var(--dsw-alias-bg-layer-2, rgba(127,127,127,.10))",
|
|
65
|
+
overlay: "var(--dsw-alias-bg-overlay, #1c1f26)",
|
|
66
|
+
sidebar: "var(--dsw-specific-sidebar-fill, #16181d)",
|
|
67
|
+
border: "var(--dsw-alias-border-l1, rgba(127,127,127,.24))",
|
|
68
|
+
borderStrong: "var(--dsw-alias-border-l2, rgba(127,127,127,.36))",
|
|
69
|
+
accent: "var(--dsw-alias-brand-primary, #4f8ef7)",
|
|
70
|
+
text: "var(--dsw-alias-label-primary, #e6edf3)",
|
|
71
|
+
textSecondary: "var(--dsw-alias-label-secondary, #9aa4b2)",
|
|
72
|
+
idle: "var(--dsw-alias-state-idle-primary, #8b949e)",
|
|
73
|
+
error: "var(--dsw-alias-state-error-primary, #d06a5a)",
|
|
74
|
+
success: "var(--dsw-alias-state-success-primary, #3fb950)",
|
|
75
|
+
warn: "var(--dsw-alias-state-warn-primary, #d29922)",
|
|
51
76
|
};
|
|
52
77
|
|
|
53
78
|
const styles = {
|
|
@@ -60,6 +85,15 @@ window.__ModuleLoader__.load({
|
|
|
60
85
|
color: colors.text,
|
|
61
86
|
fontFamily: "inherit",
|
|
62
87
|
},
|
|
88
|
+
content: {
|
|
89
|
+
display: "flex",
|
|
90
|
+
flexDirection: "column",
|
|
91
|
+
height: "100%",
|
|
92
|
+
minHeight: 0,
|
|
93
|
+
background: colors.bg,
|
|
94
|
+
color: colors.text,
|
|
95
|
+
fontFamily: "inherit",
|
|
96
|
+
},
|
|
63
97
|
head: {
|
|
64
98
|
display: "flex",
|
|
65
99
|
alignItems: "center",
|
|
@@ -104,9 +138,43 @@ window.__ModuleLoader__.load({
|
|
|
104
138
|
border: `1px solid ${colors.border}`,
|
|
105
139
|
borderRadius: 8,
|
|
106
140
|
padding: "9px 11px",
|
|
107
|
-
cursor: "pointer",
|
|
108
141
|
background: colors.panel,
|
|
109
142
|
},
|
|
143
|
+
cardHead: {
|
|
144
|
+
position: "relative",
|
|
145
|
+
cursor: "pointer",
|
|
146
|
+
paddingRight: 16,
|
|
147
|
+
},
|
|
148
|
+
cardBody: { marginTop: 10 },
|
|
149
|
+
chevron: {
|
|
150
|
+
position: "absolute",
|
|
151
|
+
right: 0,
|
|
152
|
+
top: 0,
|
|
153
|
+
fontSize: 10,
|
|
154
|
+
opacity: 0.45,
|
|
155
|
+
lineHeight: "18px",
|
|
156
|
+
},
|
|
157
|
+
threadRow: {
|
|
158
|
+
display: "flex",
|
|
159
|
+
alignItems: "center",
|
|
160
|
+
justifyContent: "space-between",
|
|
161
|
+
gap: 8,
|
|
162
|
+
marginBottom: 2,
|
|
163
|
+
},
|
|
164
|
+
threadText: {
|
|
165
|
+
fontSize: 10.5,
|
|
166
|
+
color: colors.textSecondary,
|
|
167
|
+
wordBreak: "break-all",
|
|
168
|
+
},
|
|
169
|
+
resizeHandle: {
|
|
170
|
+
position: "absolute",
|
|
171
|
+
left: -3,
|
|
172
|
+
top: 0,
|
|
173
|
+
bottom: 0,
|
|
174
|
+
width: 7,
|
|
175
|
+
cursor: "col-resize",
|
|
176
|
+
zIndex: 2,
|
|
177
|
+
},
|
|
110
178
|
cardActive: {
|
|
111
179
|
borderColor: colors.accent,
|
|
112
180
|
boxShadow: `0 0 0 1px ${colors.accent} inset`,
|
|
@@ -123,8 +191,8 @@ window.__ModuleLoader__.load({
|
|
|
123
191
|
empty: { fontSize: 12, opacity: 0.6, lineHeight: 1.75, padding: "8px 4px" },
|
|
124
192
|
turn: {
|
|
125
193
|
borderTop: `1px dashed ${colors.border}`,
|
|
126
|
-
paddingTop:
|
|
127
|
-
marginTop:
|
|
194
|
+
paddingTop: 12,
|
|
195
|
+
marginTop: 12,
|
|
128
196
|
},
|
|
129
197
|
label: { fontSize: 10.5, fontWeight: 600, opacity: 0.7, marginBottom: 4 },
|
|
130
198
|
rowBetween: {
|
|
@@ -133,20 +201,75 @@ window.__ModuleLoader__.load({
|
|
|
133
201
|
justifyContent: "space-between",
|
|
134
202
|
gap: 8,
|
|
135
203
|
},
|
|
204
|
+
turnHead: {
|
|
205
|
+
display: "flex",
|
|
206
|
+
alignItems: "center",
|
|
207
|
+
gap: 6,
|
|
208
|
+
flexWrap: "wrap",
|
|
209
|
+
marginBottom: 8,
|
|
210
|
+
},
|
|
211
|
+
turnIndex: { fontSize: 11, fontWeight: 700, opacity: 0.5 },
|
|
212
|
+
turnTime: { fontSize: 11, opacity: 0.5 },
|
|
213
|
+
pill: {
|
|
214
|
+
fontSize: 10,
|
|
215
|
+
padding: "0 6px",
|
|
216
|
+
lineHeight: "16px",
|
|
217
|
+
borderRadius: 999,
|
|
218
|
+
border: `1px solid ${colors.border}`,
|
|
219
|
+
opacity: 0.85,
|
|
220
|
+
whiteSpace: "nowrap",
|
|
221
|
+
},
|
|
222
|
+
pillMuted: { fontSize: 10, opacity: 0.45, whiteSpace: "nowrap" },
|
|
223
|
+
messageAsk: {
|
|
224
|
+
borderRadius: 8,
|
|
225
|
+
background: colors.layer2,
|
|
226
|
+
padding: "8px 10px",
|
|
227
|
+
marginBottom: 8,
|
|
228
|
+
},
|
|
229
|
+
messageReply: {
|
|
230
|
+
borderRadius: 8,
|
|
231
|
+
border: `1px solid ${colors.border}`,
|
|
232
|
+
padding: "8px 10px",
|
|
233
|
+
},
|
|
234
|
+
messageLabel: { fontSize: 10.5, fontWeight: 600, opacity: 0.6, marginBottom: 4 },
|
|
235
|
+
messageText: {
|
|
236
|
+
fontSize: 12,
|
|
237
|
+
lineHeight: 1.8,
|
|
238
|
+
whiteSpace: "pre-wrap",
|
|
239
|
+
wordBreak: "break-word",
|
|
240
|
+
fontFamily: "inherit",
|
|
241
|
+
},
|
|
242
|
+
linkBtn: {
|
|
243
|
+
appearance: "none",
|
|
244
|
+
border: "none",
|
|
245
|
+
background: "transparent",
|
|
246
|
+
color: colors.accent,
|
|
247
|
+
fontSize: 11,
|
|
248
|
+
padding: "4px 0",
|
|
249
|
+
cursor: "pointer",
|
|
250
|
+
fontFamily: "inherit",
|
|
251
|
+
},
|
|
252
|
+
externalBlock: {
|
|
253
|
+
marginTop: 14,
|
|
254
|
+
paddingTop: 12,
|
|
255
|
+
borderTop: `1px dashed ${colors.border}`,
|
|
256
|
+
},
|
|
257
|
+
externalTitle: { fontSize: 11, fontWeight: 600, color: colors.accent },
|
|
258
|
+
externalItem: { marginTop: 8 },
|
|
136
259
|
pre: {
|
|
260
|
+
/* 兜底:不再限制高度,内层滚动会截走滚轮,长文交给外层容器统一滚动 */
|
|
137
261
|
margin: 0,
|
|
138
262
|
padding: "8px 10px",
|
|
139
263
|
borderRadius: 6,
|
|
140
|
-
background: colors.
|
|
264
|
+
background: colors.layer2,
|
|
141
265
|
fontSize: 11.5,
|
|
142
|
-
lineHeight: 1.
|
|
266
|
+
lineHeight: 1.75,
|
|
143
267
|
whiteSpace: "pre-wrap",
|
|
144
268
|
wordBreak: "break-word",
|
|
145
|
-
maxHeight: 320,
|
|
146
|
-
overflowY: "auto",
|
|
147
269
|
fontFamily: "inherit",
|
|
148
270
|
},
|
|
149
|
-
error: { color:
|
|
271
|
+
error: { color: colors.error, fontSize: 12, padding: "8px 4px", lineHeight: 1.7 },
|
|
272
|
+
okNote: { color: colors.success, fontSize: 11, lineHeight: 1.6 },
|
|
150
273
|
/* 底部发送区 */
|
|
151
274
|
composer: {
|
|
152
275
|
flex: "0 0 auto",
|
|
@@ -163,7 +286,7 @@ window.__ModuleLoader__.load({
|
|
|
163
286
|
resize: "none",
|
|
164
287
|
border: `1px solid ${colors.border}`,
|
|
165
288
|
borderRadius: 6,
|
|
166
|
-
background: colors.
|
|
289
|
+
background: colors.layer2,
|
|
167
290
|
color: "inherit",
|
|
168
291
|
fontFamily: "inherit",
|
|
169
292
|
fontSize: 12,
|
|
@@ -198,7 +321,7 @@ window.__ModuleLoader__.load({
|
|
|
198
321
|
gap: 5,
|
|
199
322
|
border: `1px solid ${colors.border}`,
|
|
200
323
|
borderRadius: 6,
|
|
201
|
-
background: colors.
|
|
324
|
+
background: colors.layer2,
|
|
202
325
|
color: colors.text,
|
|
203
326
|
fontFamily: "inherit",
|
|
204
327
|
fontSize: 11,
|
|
@@ -233,7 +356,7 @@ window.__ModuleLoader__.load({
|
|
|
233
356
|
color: colors.text,
|
|
234
357
|
},
|
|
235
358
|
dropdownItemActive: {
|
|
236
|
-
background: colors.
|
|
359
|
+
background: colors.layer2,
|
|
237
360
|
color: colors.accent,
|
|
238
361
|
fontWeight: 600,
|
|
239
362
|
},
|
|
@@ -356,73 +479,100 @@ window.__ModuleLoader__.load({
|
|
|
356
479
|
}
|
|
357
480
|
|
|
358
481
|
// ── 展示件 ───────────────────────────────────────────────────────
|
|
482
|
+
const COLLAPSE_CHARS = 900;
|
|
483
|
+
|
|
484
|
+
/** 一条消息正文:过长时默认收起。内层不滚动,滚轮交给外层容器统一处理。 */
|
|
485
|
+
function MessageBody({ text }) {
|
|
486
|
+
const [expanded, setExpanded] = useState(false);
|
|
487
|
+
const value = String(text || "");
|
|
488
|
+
const isLong = value.length > COLLAPSE_CHARS;
|
|
489
|
+
const shown = expanded || !isLong ? value : `${value.slice(0, COLLAPSE_CHARS)}…`;
|
|
490
|
+
|
|
491
|
+
return h(
|
|
492
|
+
"div",
|
|
493
|
+
null,
|
|
494
|
+
h("div", { style: styles.messageText }, shown),
|
|
495
|
+
isLong
|
|
496
|
+
? h(
|
|
497
|
+
"button",
|
|
498
|
+
{
|
|
499
|
+
type: "button",
|
|
500
|
+
style: styles.linkBtn,
|
|
501
|
+
onClick: () => setExpanded(!expanded),
|
|
502
|
+
},
|
|
503
|
+
expanded ? "收起" : `展开全文(${value.length} 字)`,
|
|
504
|
+
)
|
|
505
|
+
: null,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
359
509
|
function TurnBlock({ turn, index }) {
|
|
360
510
|
const meta = turn.meta || {};
|
|
361
511
|
const usage = meta.usage;
|
|
512
|
+
const model = meta.actualModel || meta.model || "";
|
|
513
|
+
const effort = meta.actualEffort || meta.reasoning || "";
|
|
514
|
+
|
|
362
515
|
return h(
|
|
363
516
|
"div",
|
|
364
517
|
{ style: styles.turn },
|
|
365
518
|
h(
|
|
366
519
|
"div",
|
|
367
|
-
{ style: styles.
|
|
368
|
-
h("
|
|
369
|
-
h(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
520
|
+
{ style: styles.turnHead },
|
|
521
|
+
h("span", { style: styles.turnIndex }, `#${index + 1}`),
|
|
522
|
+
h("span", { style: styles.turnTime }, formatTime(turn.at)),
|
|
523
|
+
model ? h("span", { style: styles.pill }, model) : null,
|
|
524
|
+
effort ? h("span", { style: styles.pill }, `推理 ${effort}`) : null,
|
|
525
|
+
usage
|
|
526
|
+
? h(
|
|
527
|
+
"span",
|
|
528
|
+
{
|
|
529
|
+
style: styles.pillMuted,
|
|
530
|
+
title: `input ${usage.input_tokens} / cached ${usage.cached_input_tokens ?? 0}`,
|
|
531
|
+
},
|
|
532
|
+
`${Math.round((usage.input_tokens || 0) / 1000)}k in`,
|
|
533
|
+
)
|
|
534
|
+
: null,
|
|
535
|
+
),
|
|
536
|
+
h(
|
|
537
|
+
"div",
|
|
538
|
+
{ style: styles.messageAsk },
|
|
539
|
+
h("div", { style: styles.messageLabel }, "我 → GPT"),
|
|
540
|
+
h(MessageBody, { text: turn.prompt || "(空)" }),
|
|
541
|
+
),
|
|
542
|
+
h(
|
|
543
|
+
"div",
|
|
544
|
+
{ style: styles.messageReply },
|
|
545
|
+
h("div", { style: styles.messageLabel }, "GPT → 我"),
|
|
546
|
+
h(MessageBody, { text: turn.reply || "(空)" }),
|
|
384
547
|
),
|
|
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
548
|
);
|
|
390
549
|
}
|
|
391
550
|
|
|
392
551
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
552
|
+
* 只列「在 Codex 里继续、桥不知道」的那些轮次。
|
|
553
|
+
* 桥已经记过的问答不再重复展示——否则同一段内容会在这里出现第二遍。
|
|
395
554
|
*/
|
|
396
555
|
function CodexTimeline({ timeline }) {
|
|
397
556
|
const [expanded, setExpanded] = useState(false);
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
557
|
+
const external = (timeline && timeline.messages ? timeline.messages : []).filter(
|
|
558
|
+
(message) => message.external,
|
|
559
|
+
);
|
|
560
|
+
if (external.length === 0) return null;
|
|
561
|
+
|
|
562
|
+
const COLLAPSED = 2;
|
|
563
|
+
const shown = expanded ? external : external.slice(-COLLAPSED);
|
|
403
564
|
|
|
404
565
|
return h(
|
|
405
566
|
"div",
|
|
406
|
-
{
|
|
407
|
-
style: {
|
|
408
|
-
marginTop: 12,
|
|
409
|
-
borderTop: `1px dashed ${colors.border}`,
|
|
410
|
-
paddingTop: 10,
|
|
411
|
-
},
|
|
412
|
-
},
|
|
567
|
+
{ style: styles.externalBlock },
|
|
413
568
|
h(
|
|
414
569
|
"div",
|
|
415
570
|
{ style: styles.rowBetween },
|
|
416
|
-
h(
|
|
417
|
-
|
|
418
|
-
{ style: styles.label },
|
|
419
|
-
`Codex 会话原文 · ${messages.length} 条`,
|
|
420
|
-
externalCount > 0 ? `(${externalCount} 条 Codex 侧新增)` : "",
|
|
421
|
-
),
|
|
422
|
-
messages.length > COLLAPSED
|
|
571
|
+
h("div", { style: styles.externalTitle }, `在 Codex 里继续的 ${external.length} 条`),
|
|
572
|
+
external.length > COLLAPSED
|
|
423
573
|
? h(
|
|
424
574
|
"button",
|
|
425
|
-
{ type: "button", style: styles.
|
|
575
|
+
{ type: "button", style: styles.linkBtn, onClick: () => setExpanded(!expanded) },
|
|
426
576
|
expanded ? "收起" : "展开全部",
|
|
427
577
|
)
|
|
428
578
|
: null,
|
|
@@ -430,19 +580,13 @@ window.__ModuleLoader__.load({
|
|
|
430
580
|
shown.map((message, index) =>
|
|
431
581
|
h(
|
|
432
582
|
"div",
|
|
433
|
-
{ key: index, style:
|
|
583
|
+
{ key: index, style: styles.externalItem },
|
|
434
584
|
h(
|
|
435
585
|
"div",
|
|
436
|
-
{
|
|
437
|
-
|
|
438
|
-
...styles.label,
|
|
439
|
-
color: message.external ? colors.accent : undefined,
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
message.role === "user" ? "用户" : "GPT",
|
|
443
|
-
message.external ? " · Codex 侧新增" : "",
|
|
586
|
+
{ style: styles.messageLabel },
|
|
587
|
+
message.role === "user" ? "你(Codex 侧)" : "GPT(Codex 侧)",
|
|
444
588
|
),
|
|
445
|
-
h(
|
|
589
|
+
h(MessageBody, { text: message.text || "(空)" }),
|
|
446
590
|
),
|
|
447
591
|
),
|
|
448
592
|
);
|
|
@@ -673,40 +817,80 @@ window.__ModuleLoader__.load({
|
|
|
673
817
|
}
|
|
674
818
|
|
|
675
819
|
function ConversationList({ conversations, selected, onSelect, detail, detailError, detailLoading }) {
|
|
820
|
+
const [promoteNote, setPromoteNote] = useState({});
|
|
821
|
+
|
|
822
|
+
/** 按需把某段会话标记成 Codex App 可见(改完要重启 App 才会出现)。 */
|
|
823
|
+
const promoteToApp = async (item, threadId) => {
|
|
824
|
+
try {
|
|
825
|
+
const response = await fetch(`${ROUTE}/promote`, {
|
|
826
|
+
method: "POST",
|
|
827
|
+
headers: { "content-type": "application/json" },
|
|
828
|
+
body: JSON.stringify({ threadId }),
|
|
829
|
+
});
|
|
830
|
+
let body = null;
|
|
831
|
+
try {
|
|
832
|
+
body = await response.json();
|
|
833
|
+
} catch {
|
|
834
|
+
body = null;
|
|
835
|
+
}
|
|
836
|
+
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
|
837
|
+
setPromoteNote((prev) => ({
|
|
838
|
+
...prev,
|
|
839
|
+
[item.id]: "已标记为 Codex 可见,重启 Codex App 后可在那边继续这条对话",
|
|
840
|
+
}));
|
|
841
|
+
} catch (err) {
|
|
842
|
+
setPromoteNote((prev) => ({
|
|
843
|
+
...prev,
|
|
844
|
+
[item.id]: `标记失败:${String(err?.message ?? err)}`,
|
|
845
|
+
}));
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
676
849
|
return h(
|
|
677
850
|
"div",
|
|
678
851
|
{ style: styles.list },
|
|
679
|
-
conversations.map((item) =>
|
|
680
|
-
|
|
852
|
+
conversations.map((item) => {
|
|
853
|
+
const isOpen = item.id === selected;
|
|
854
|
+
return h(
|
|
681
855
|
"div",
|
|
682
856
|
{
|
|
683
857
|
key: item.id,
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
},
|
|
858
|
+
className: "cbh-card",
|
|
859
|
+
style: { ...styles.card, ...(isOpen ? styles.cardActive : null) },
|
|
694
860
|
},
|
|
695
|
-
|
|
861
|
+
/* 只有头部负责展开/收起。详情里的按钮不冒泡到这里,否则点「展开全文」会把卡片收起来。 */
|
|
696
862
|
h(
|
|
697
863
|
"div",
|
|
698
|
-
{
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
864
|
+
{
|
|
865
|
+
style: styles.cardHead,
|
|
866
|
+
role: "button",
|
|
867
|
+
tabIndex: 0,
|
|
868
|
+
"aria-expanded": isOpen,
|
|
869
|
+
onClick: () => onSelect(isOpen ? "" : item.id),
|
|
870
|
+
onKeyDown: (event) => {
|
|
871
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
872
|
+
event.preventDefault();
|
|
873
|
+
onSelect(isOpen ? "" : item.id);
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
h("div", { style: styles.cardTitle }, item.title),
|
|
878
|
+
h(
|
|
879
|
+
"div",
|
|
880
|
+
{ style: styles.meta },
|
|
881
|
+
h("span", null, formatTime(item.updatedAt)),
|
|
882
|
+
h("span", null, `${item.turnCount} 轮`),
|
|
883
|
+
item.model ? h("span", null, item.model) : null,
|
|
884
|
+
item.reasoning ? h("span", null, `推理 ${item.reasoning}`) : null,
|
|
885
|
+
item.threadId ? h("span", null, `#${item.threadId.slice(0, 6)}`) : null,
|
|
886
|
+
item.legacy ? h("span", null, "旧记录") : null,
|
|
887
|
+
),
|
|
888
|
+
h("span", { style: styles.chevron }, isOpen ? "▾" : "▸"),
|
|
705
889
|
),
|
|
706
|
-
|
|
890
|
+
isOpen
|
|
707
891
|
? h(
|
|
708
892
|
"div",
|
|
709
|
-
|
|
893
|
+
{ style: styles.cardBody },
|
|
710
894
|
detailLoading ? h("div", { style: styles.empty }, "载入中…") : null,
|
|
711
895
|
detailError ? h("div", { style: styles.error }, detailError) : null,
|
|
712
896
|
detail
|
|
@@ -716,10 +900,26 @@ window.__ModuleLoader__.load({
|
|
|
716
900
|
detail.threadId
|
|
717
901
|
? h(
|
|
718
902
|
"div",
|
|
719
|
-
{ style:
|
|
720
|
-
h("span",
|
|
903
|
+
{ style: styles.threadRow },
|
|
904
|
+
h("span", { style: styles.threadText }, `Codex 会话 ${detail.threadId}`),
|
|
905
|
+
h(
|
|
906
|
+
"button",
|
|
907
|
+
{
|
|
908
|
+
type: "button",
|
|
909
|
+
style: styles.linkBtn,
|
|
910
|
+
title: "把这条会话标记成 Codex App 可见(之后重启 App 就能在那边继续)",
|
|
911
|
+
onClick: (event) => {
|
|
912
|
+
event.stopPropagation();
|
|
913
|
+
void promoteToApp(item, detail.threadId);
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
"转移到 Codex",
|
|
917
|
+
),
|
|
721
918
|
)
|
|
722
919
|
: null,
|
|
920
|
+
promoteNote[item.id]
|
|
921
|
+
? h("div", { style: styles.okNote }, promoteNote[item.id])
|
|
922
|
+
: null,
|
|
723
923
|
(detail.turns || []).map((turn, index) =>
|
|
724
924
|
h(TurnBlock, { key: turn.id, turn, index }),
|
|
725
925
|
),
|
|
@@ -728,8 +928,8 @@ window.__ModuleLoader__.load({
|
|
|
728
928
|
: null,
|
|
729
929
|
)
|
|
730
930
|
: null,
|
|
731
|
-
)
|
|
732
|
-
),
|
|
931
|
+
);
|
|
932
|
+
}),
|
|
733
933
|
);
|
|
734
934
|
}
|
|
735
935
|
|
|
@@ -745,7 +945,36 @@ window.__ModuleLoader__.load({
|
|
|
745
945
|
}
|
|
746
946
|
|
|
747
947
|
// ── 侧栏主面板 ───────────────────────────────────────────────────
|
|
948
|
+
/**
|
|
949
|
+
* 左侧栏「GPT 对话」图标对应的主面板。
|
|
950
|
+
*
|
|
951
|
+
* DSH 点这个图标一定会切主面板,所以这里挂载后:
|
|
952
|
+
* 1. 先 openResource —— 它自己会 "reveal the column"(展开右侧栏);
|
|
953
|
+
* 2. 再延迟一点把主面板切回对话。
|
|
954
|
+
* 不能反过来:selectPanel 会重置布局,把刚展开的右栏又关掉。
|
|
955
|
+
*/
|
|
748
956
|
function BridgePanel() {
|
|
957
|
+
useEffect(() => {
|
|
958
|
+
bridgeOpeners.open();
|
|
959
|
+
|
|
960
|
+
const timer = setTimeout(() => {
|
|
961
|
+
if (!bridgeLayout) return;
|
|
962
|
+
try {
|
|
963
|
+
bridgeLayout.selectPanel(null);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
console.error("[codex-bridge] 切回对话面板失败", error);
|
|
966
|
+
}
|
|
967
|
+
}, 150);
|
|
968
|
+
|
|
969
|
+
return () => clearTimeout(timer);
|
|
970
|
+
}, []);
|
|
971
|
+
|
|
972
|
+
return h("div", { style: styles.root });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ── 右侧抽屉 ─────────────────────────────────────────────────────
|
|
976
|
+
/** 侧栏 tab 与抽屉共用的内容体。onClose 为空表示宿主自己有关闭控件(右侧栏 tab)。 */
|
|
977
|
+
function BridgeContent({ onClose }) {
|
|
749
978
|
const { data, error, reload } = useConversationList(15000);
|
|
750
979
|
const [selected, setSelected] = useState("");
|
|
751
980
|
const { detail, error: detailError, loading } = useConversationDetail(selected);
|
|
@@ -754,25 +983,51 @@ window.__ModuleLoader__.load({
|
|
|
754
983
|
|
|
755
984
|
return h(
|
|
756
985
|
"div",
|
|
757
|
-
{ style: styles.
|
|
986
|
+
{ style: styles.content },
|
|
758
987
|
h(
|
|
759
988
|
"div",
|
|
760
989
|
{ style: styles.head },
|
|
761
990
|
h(
|
|
762
991
|
"div",
|
|
763
992
|
null,
|
|
764
|
-
h("
|
|
993
|
+
h("div", { style: styles.headTitle }, "GPT 对话"),
|
|
765
994
|
h(
|
|
766
995
|
"div",
|
|
767
996
|
{ style: styles.headSub },
|
|
768
997
|
`Codex 桥 · ${conversations.length} 段会话${defaultsLabel(data?.defaults)}`,
|
|
769
998
|
),
|
|
770
999
|
),
|
|
771
|
-
h(
|
|
1000
|
+
h(
|
|
1001
|
+
"div",
|
|
1002
|
+
{ style: styles.headActions },
|
|
1003
|
+
h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
|
|
1004
|
+
onClose
|
|
1005
|
+
? h(
|
|
1006
|
+
"button",
|
|
1007
|
+
{
|
|
1008
|
+
type: "button",
|
|
1009
|
+
style: styles.iconBtn,
|
|
1010
|
+
title: "关闭 (Esc)",
|
|
1011
|
+
"aria-label": "关闭",
|
|
1012
|
+
onClick: onClose,
|
|
1013
|
+
},
|
|
1014
|
+
h(
|
|
1015
|
+
"svg",
|
|
1016
|
+
{ viewBox: "0 0 16 16", width: 14, height: 14, fill: "none", "aria-hidden": true },
|
|
1017
|
+
h("path", {
|
|
1018
|
+
d: "M4 4l8 8M12 4l-8 8",
|
|
1019
|
+
stroke: "currentColor",
|
|
1020
|
+
strokeWidth: 1.6,
|
|
1021
|
+
strokeLinecap: "round",
|
|
1022
|
+
}),
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
: null,
|
|
1026
|
+
),
|
|
772
1027
|
),
|
|
773
1028
|
h(
|
|
774
1029
|
"div",
|
|
775
|
-
{ style: styles.body },
|
|
1030
|
+
{ style: styles.body, className: "cbh-scroll" },
|
|
776
1031
|
error ? h("div", { style: styles.error }, error) : null,
|
|
777
1032
|
data && conversations.length === 0 ? h(EmptyState, { hasList: true }) : null,
|
|
778
1033
|
!data && !error ? h(EmptyState, { hasList: false }) : null,
|
|
@@ -797,15 +1052,32 @@ window.__ModuleLoader__.load({
|
|
|
797
1052
|
);
|
|
798
1053
|
}
|
|
799
1054
|
|
|
800
|
-
|
|
1055
|
+
/** 右侧栏 tab 的 body。宿主提供标题与关闭,这里只画内容。 */
|
|
1056
|
+
function BridgeTab() {
|
|
1057
|
+
return h(BridgeContent, {});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
801
1060
|
function BridgeDrawer() {
|
|
802
1061
|
const open = useDrawerOpen();
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const [
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1062
|
+
|
|
1063
|
+
// 宽度可拖拽,记在 localStorage,下次打开沿用。
|
|
1064
|
+
const [width, setWidth] = useState(() => {
|
|
1065
|
+
try {
|
|
1066
|
+
const saved = Number(window.localStorage.getItem(DRAWER_WIDTH_KEY));
|
|
1067
|
+
if (Number.isFinite(saved) && saved >= 320 && saved <= 960) return saved;
|
|
1068
|
+
} catch {
|
|
1069
|
+
/* 隐私模式读不到 localStorage */
|
|
1070
|
+
}
|
|
1071
|
+
return DRAWER_WIDTH;
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
useEffect(() => {
|
|
1075
|
+
try {
|
|
1076
|
+
window.localStorage.setItem(DRAWER_WIDTH_KEY, String(width));
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* 忽略写入失败 */
|
|
1079
|
+
}
|
|
1080
|
+
}, [width]);
|
|
809
1081
|
|
|
810
1082
|
useEffect(() => {
|
|
811
1083
|
if (!open) return undefined;
|
|
@@ -820,70 +1092,29 @@ window.__ModuleLoader__.load({
|
|
|
820
1092
|
|
|
821
1093
|
return h(
|
|
822
1094
|
"div",
|
|
823
|
-
{ style: styles.drawer, role: "dialog", "aria-label": "GPT 对话" },
|
|
824
|
-
h(
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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();
|
|
1095
|
+
{ style: { ...styles.drawer, width }, role: "dialog", "aria-label": "GPT 对话" },
|
|
1096
|
+
h("div", {
|
|
1097
|
+
style: styles.resizeHandle,
|
|
1098
|
+
title: "拖动调整宽度",
|
|
1099
|
+
onMouseDown: (event) => {
|
|
1100
|
+
event.preventDefault();
|
|
1101
|
+
const startX = event.clientX;
|
|
1102
|
+
const startWidth = width;
|
|
1103
|
+
const onMove = (moveEvent) => {
|
|
1104
|
+
const next = Math.min(960, Math.max(320, startWidth + (startX - moveEvent.clientX)));
|
|
1105
|
+
setWidth(next);
|
|
1106
|
+
};
|
|
1107
|
+
const onUp = () => {
|
|
1108
|
+
document.removeEventListener("mousemove", onMove);
|
|
1109
|
+
document.removeEventListener("mouseup", onUp);
|
|
1110
|
+
document.body.style.cursor = "";
|
|
1111
|
+
};
|
|
1112
|
+
document.body.style.cursor = "col-resize";
|
|
1113
|
+
document.addEventListener("mousemove", onMove);
|
|
1114
|
+
document.addEventListener("mouseup", onUp);
|
|
885
1115
|
},
|
|
886
1116
|
}),
|
|
1117
|
+
h(BridgeContent, { onClose: () => setDrawerOpen(false) }),
|
|
887
1118
|
);
|
|
888
1119
|
}
|
|
889
1120
|
|
|
@@ -912,7 +1143,47 @@ window.__ModuleLoader__.load({
|
|
|
912
1143
|
return h(BridgeGlyph, { size: props?.size ?? 18 });
|
|
913
1144
|
}
|
|
914
1145
|
|
|
915
|
-
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* 左下角按钮(Settings 旁,和催更姬同一位置)。
|
|
1149
|
+
* 这里不切主面板,所以 sidebarRight.mounted 仍然指向当前会话,
|
|
1150
|
+
* openResource 才拿得到 session surface(左侧栏图标做不到这点,见 README)。
|
|
1151
|
+
* props.wide 由宿主给出:false 时侧栏是 56px 轨道,只画图标。
|
|
1152
|
+
*/
|
|
1153
|
+
function BridgeFooterAction(props) {
|
|
1154
|
+
const wide = props && props.wide !== false;
|
|
1155
|
+
return h(
|
|
1156
|
+
"button",
|
|
1157
|
+
{
|
|
1158
|
+
type: "button",
|
|
1159
|
+
title: "打开 GPT 对话(右侧栏)",
|
|
1160
|
+
"aria-label": "GPT 对话",
|
|
1161
|
+
onClick: () => bridgeOpeners.open(),
|
|
1162
|
+
style: {
|
|
1163
|
+
appearance: "none",
|
|
1164
|
+
border: "none",
|
|
1165
|
+
background: "transparent",
|
|
1166
|
+
color: "inherit",
|
|
1167
|
+
cursor: "pointer",
|
|
1168
|
+
display: "flex",
|
|
1169
|
+
alignItems: "center",
|
|
1170
|
+
justifyContent: wide ? "flex-start" : "center",
|
|
1171
|
+
gap: 8,
|
|
1172
|
+
padding: "6px 8px",
|
|
1173
|
+
borderRadius: 6,
|
|
1174
|
+
width: "100%",
|
|
1175
|
+
fontFamily: "inherit",
|
|
1176
|
+
fontSize: 12,
|
|
1177
|
+
lineHeight: "18px",
|
|
1178
|
+
opacity: 0.82,
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
h(BridgeGlyph, { size: 16 }),
|
|
1182
|
+
wide ? h("span", null, "GPT") : null,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** 会话头部按钮:打开 GPT 对话(优先激活右侧栏 tab)。 */
|
|
916
1187
|
function BridgeToggle() {
|
|
917
1188
|
const open = useDrawerOpen();
|
|
918
1189
|
return h(
|
|
@@ -933,39 +1204,125 @@ window.__ModuleLoader__.load({
|
|
|
933
1204
|
opacity: open ? 1 : 0.78,
|
|
934
1205
|
padding: 0,
|
|
935
1206
|
},
|
|
936
|
-
title:
|
|
1207
|
+
title: "打开 GPT 对话",
|
|
937
1208
|
"aria-label": "GPT 对话",
|
|
938
|
-
|
|
939
|
-
onClick: () => setDrawerOpen(!open),
|
|
1209
|
+
onClick: () => bridgeOpeners.open(),
|
|
940
1210
|
},
|
|
941
1211
|
h(BridgeGlyph, { size: 16 }),
|
|
942
1212
|
);
|
|
943
1213
|
}
|
|
944
1214
|
|
|
945
1215
|
return {
|
|
946
|
-
|
|
1216
|
+
// 注意:Cordis 的 Client 插件必须在这里声明要用的服务,否则 ctx.<service> 不可见。
|
|
1217
|
+
// sidebarRightTabs / sidebarRight 由 @deepseek-ai/dsh-client-ui-sidebar-right 提供。
|
|
1218
|
+
inject: ["slots", "sidebarRight", "sidebarRightTabs", "layout"],
|
|
947
1219
|
apply(ctx) {
|
|
948
|
-
|
|
949
|
-
|
|
1220
|
+
bridgeLayout = ctx.layout || null;
|
|
1221
|
+
|
|
1222
|
+
// 局部样式:hover 与滚动条是伪类,内联样式写不了。
|
|
1223
|
+
ctx.effect(() => {
|
|
1224
|
+
const style = document.createElement("style");
|
|
1225
|
+
style.dataset.plugin = "dsh-codex-handoff";
|
|
1226
|
+
style.textContent = [
|
|
1227
|
+
".cbh-card{transition:background .15s ease,border-color .15s ease}",
|
|
1228
|
+
".cbh-card:hover{background:rgba(127,127,127,.10)}",
|
|
1229
|
+
".cbh-scroll{scrollbar-width:thin;scrollbar-color:rgba(127,127,127,.35) transparent}",
|
|
1230
|
+
".cbh-scroll::-webkit-scrollbar{width:9px;height:9px}",
|
|
1231
|
+
".cbh-scroll::-webkit-scrollbar-thumb{background:rgba(127,127,127,.32);border-radius:5px}",
|
|
1232
|
+
".cbh-scroll::-webkit-scrollbar-thumb:hover{background:rgba(127,127,127,.5)}",
|
|
1233
|
+
".cbh-scroll::-webkit-scrollbar-track{background:transparent}",
|
|
1234
|
+
].join("\n");
|
|
1235
|
+
document.head.append(style);
|
|
1236
|
+
return () => style.remove();
|
|
1237
|
+
}, "codex-bridge: local styles");
|
|
1238
|
+
|
|
1239
|
+
// 1) 左下角按钮(Settings 旁):不切主面板,
|
|
1240
|
+
// sidebarRight.mounted 仍指向当前会话,openResource 才能拿到 session surface。
|
|
1241
|
+
ctx.slots.inject("sidebar.footer.action", () =>
|
|
950
1242
|
ctx.slots.register(
|
|
951
|
-
{ name: "sidebar.
|
|
952
|
-
|
|
1243
|
+
{ name: "sidebar.footer.action", id: "codex-bridge", order: 42, label: "GPT 对话" },
|
|
1244
|
+
BridgeFooterAction,
|
|
953
1245
|
),
|
|
954
1246
|
);
|
|
955
|
-
ctx.slots.inject("main", () =>
|
|
956
|
-
ctx.slots.register({ name: "main", key: "codex-bridge" }, BridgePanel),
|
|
957
|
-
);
|
|
958
1247
|
|
|
959
|
-
// 2)
|
|
1248
|
+
// 2) 会话头部按钮(快捷入口,同样不切面板)
|
|
960
1249
|
ctx.slots.inject("conversation.session.header.utilities", () =>
|
|
961
1250
|
ctx.slots.register(
|
|
962
1251
|
{ name: "conversation.session.header.utilities", id: "codex-bridge-toggle", order: 2 },
|
|
963
1252
|
BridgeToggle,
|
|
964
1253
|
),
|
|
965
1254
|
);
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1255
|
+
// 左侧栏图标 + main 面板已移除:点图标必然切走 conversation,
|
|
1256
|
+
// 导致 sidebarRight.mounted 为空,openResource 直接抛
|
|
1257
|
+
// "no session surface is mounted",那个入口在 DSH 里走不通。
|
|
1258
|
+
|
|
1259
|
+
// 3) 右侧栏 tab。宿主服务未就绪或抛错时,上面的入口仍然可用。
|
|
1260
|
+
// sidebarRightTabs / sidebarRight 由 @deepseek-ai/dsh-client-ui-sidebar-right 提供,
|
|
1261
|
+
// 激活顺序不保证,所以短轮询等待;访问未知服务可能抛错,全部包在 try 里。
|
|
1262
|
+
const registerSidebarTab = (remaining) => {
|
|
1263
|
+
let rightTabs;
|
|
1264
|
+
let rightPane;
|
|
1265
|
+
try {
|
|
1266
|
+
rightTabs =
|
|
1267
|
+
ctx.sidebarRightTabs ||
|
|
1268
|
+
(typeof ctx.get === "function" ? ctx.get("sidebarRightTabs") : undefined);
|
|
1269
|
+
rightPane =
|
|
1270
|
+
ctx.sidebarRight || (typeof ctx.get === "function" ? ctx.get("sidebarRight") : undefined);
|
|
1271
|
+
} catch {
|
|
1272
|
+
rightTabs = undefined;
|
|
1273
|
+
rightPane = undefined;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (rightTabs && rightPane) {
|
|
1277
|
+
try {
|
|
1278
|
+
ctx.effect(
|
|
1279
|
+
() =>
|
|
1280
|
+
rightTabs.register({
|
|
1281
|
+
id: TAB_ID,
|
|
1282
|
+
kind: "codex-bridge",
|
|
1283
|
+
keepMounted: true,
|
|
1284
|
+
patterns: ["dsh-resource://codex-bridge/**"],
|
|
1285
|
+
title: () => "GPT 对话",
|
|
1286
|
+
}),
|
|
1287
|
+
"codex-bridge: sidebar-right tab",
|
|
1288
|
+
);
|
|
1289
|
+
ctx.slots.inject("sidebar.right.pane.tab", () =>
|
|
1290
|
+
ctx.slots.register({ name: "sidebar.right.pane.tab", key: TAB_ID }, BridgeTab),
|
|
1291
|
+
);
|
|
1292
|
+
bridgeOpeners.open = () => {
|
|
1293
|
+
// 先 openResource:催更姬只调这一个就能把右栏带出来,是最可靠的一步。
|
|
1294
|
+
try {
|
|
1295
|
+
rightPane.openResource(TAB_RESOURCE);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
console.error("[codex-bridge] openResource 失败", error);
|
|
1298
|
+
}
|
|
1299
|
+
// 再尝试显式展开右栏;它若抛错不能影响上面那一步。
|
|
1300
|
+
try {
|
|
1301
|
+
if (typeof ctx.layout?.openRightbar === "function") {
|
|
1302
|
+
ctx.layout.openRightbar(true, false);
|
|
1303
|
+
}
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
console.error("[codex-bridge] openRightbar 失败", error);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
console.info("[codex-bridge] 已注册右侧栏 tab");
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
console.error("[codex-bridge] 注册右侧栏 tab 失败,保留浮层抽屉", error);
|
|
1311
|
+
}
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (remaining > 0) {
|
|
1316
|
+
setTimeout(() => registerSidebarTab(remaining - 1), 250);
|
|
1317
|
+
} else {
|
|
1318
|
+
console.warn("[codex-bridge] 右侧栏服务不可用,使用浮层抽屉作为回退");
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
try {
|
|
1322
|
+
registerSidebarTab(24);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
console.error("[codex-bridge] 注册右侧栏 tab 失败,保留浮层抽屉", error);
|
|
1325
|
+
}
|
|
969
1326
|
},
|
|
970
1327
|
};
|
|
971
1328
|
},
|
package/lib/index.js
CHANGED
|
@@ -221,6 +221,68 @@ function codexHomeDir() {
|
|
|
221
221
|
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// ── 让 exec 会话在 Codex App 里可见 ──────────────────────────────────
|
|
225
|
+
//
|
|
226
|
+
// Codex 桌面版的会话列表只列出 `source = "vscode"` 的线程(实测:App 里能看到的
|
|
227
|
+
// 全是 vscode,CLI 产生的是 exec,即使 rollout、projection、turns 都齐全也不显示)。
|
|
228
|
+
// `codex exec --thread-source vscode` 实测无效(写入的仍是 exec),所以只能事后把
|
|
229
|
+
// `state_N.sqlite` 里这一条的 source 改成 vscode。只动这一个字段,失败不影响主流程。
|
|
230
|
+
// 用 Node 内置的 node:sqlite(Node >= 22.5),不可用时静默跳过。
|
|
231
|
+
|
|
232
|
+
let sqliteModule;
|
|
233
|
+
async function loadSqlite() {
|
|
234
|
+
if (sqliteModule !== undefined) return sqliteModule;
|
|
235
|
+
try {
|
|
236
|
+
sqliteModule = await import("node:sqlite");
|
|
237
|
+
} catch {
|
|
238
|
+
sqliteModule = null;
|
|
239
|
+
}
|
|
240
|
+
return sqliteModule;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 找版本号最大的 state_N.sqlite(Codex 升级会换文件名)。 */
|
|
244
|
+
function findStateDb() {
|
|
245
|
+
const dir = codexHomeDir();
|
|
246
|
+
try {
|
|
247
|
+
const names = fs
|
|
248
|
+
.readdirSync(dir)
|
|
249
|
+
.filter((name) => /^state_\d+\.sqlite$/.test(name))
|
|
250
|
+
.sort((a, b) => {
|
|
251
|
+
const na = Number((a.match(/\d+/) || [0])[0]);
|
|
252
|
+
const nb = Number((b.match(/\d+/) || [0])[0]);
|
|
253
|
+
return nb - na;
|
|
254
|
+
});
|
|
255
|
+
return names.length > 0 ? path.join(dir, names[0]) : "";
|
|
256
|
+
} catch {
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function promoteThreadForApp(threadId) {
|
|
262
|
+
if (!threadId) return false;
|
|
263
|
+
if (process.env.DSH_CODEX_BRIDGE_APP_VISIBLE === "0") return false;
|
|
264
|
+
|
|
265
|
+
const sqlite = await loadSqlite();
|
|
266
|
+
if (!sqlite || typeof sqlite.DatabaseSync !== "function") return false;
|
|
267
|
+
|
|
268
|
+
const dbPath = findStateDb();
|
|
269
|
+
if (!dbPath) return false;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const db = new sqlite.DatabaseSync(dbPath);
|
|
273
|
+
try {
|
|
274
|
+
db.exec("PRAGMA busy_timeout = 3000");
|
|
275
|
+
db.prepare("UPDATE threads SET source = 'vscode' WHERE id = ? AND source LIKE 'exec%'").run(threadId);
|
|
276
|
+
} finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(`[codex-bridge] 无法把会话标记为 Codex App 可见: ${String(error)}`);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
224
286
|
function listRolloutFiles() {
|
|
225
287
|
if (Date.now() - rolloutIndexCache.at < 30000 && rolloutIndexCache.files.length > 0) {
|
|
226
288
|
return rolloutIndexCache.files;
|
|
@@ -867,13 +929,14 @@ function makeHandler(ctx, resolveWorkspace) {
|
|
|
867
929
|
const rest = (url.pathname || "").slice(ROUTE_PREFIX.length).replace(/^\/+/, "");
|
|
868
930
|
|
|
869
931
|
try {
|
|
870
|
-
// POST /api/codex-bridge/send
|
|
871
|
-
//
|
|
932
|
+
// POST /api/codex-bridge/send —— 发一轮真实对话(会消耗 Codex 额度)
|
|
933
|
+
// POST /api/codex-bridge/promote —— 把某段会话标记成 Codex App 可见(按需触发,改完需重启 App)
|
|
872
934
|
if (req.method === "POST") {
|
|
873
|
-
if (rest !== "send") {
|
|
935
|
+
if (rest !== "send" && rest !== "promote") {
|
|
874
936
|
send(404, { error: "not found" });
|
|
875
937
|
return;
|
|
876
938
|
}
|
|
939
|
+
|
|
877
940
|
const chunks = [];
|
|
878
941
|
for await (const chunk of req) chunks.push(chunk);
|
|
879
942
|
let body = {};
|
|
@@ -882,6 +945,25 @@ function makeHandler(ctx, resolveWorkspace) {
|
|
|
882
945
|
} catch {
|
|
883
946
|
body = {};
|
|
884
947
|
}
|
|
948
|
+
|
|
949
|
+
if (rest === "promote") {
|
|
950
|
+
const conversationId = String(body?.conversationId || "");
|
|
951
|
+
const threadId = String(body?.threadId || "");
|
|
952
|
+
let targetThread = threadId;
|
|
953
|
+
if (!targetThread && conversationId) {
|
|
954
|
+
const index = readIndex(root);
|
|
955
|
+
const found = findConversation(index, conversationId);
|
|
956
|
+
targetThread = found?.threadId || "";
|
|
957
|
+
}
|
|
958
|
+
if (!targetThread) {
|
|
959
|
+
send(400, { error: "找不到该对话的 Codex thread" });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const ok = await promoteThreadForApp(targetThread);
|
|
963
|
+
send(200, { ok, threadId: targetThread });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
885
967
|
const result = await sendTurn(undefined, {
|
|
886
968
|
workspace,
|
|
887
969
|
conversationId: String(body?.conversationId || ""),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-codex-handoff",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "GPT/Codex 对话桥:把本机 Codex CLI 的多轮会话接进 DSH,侧栏与右侧抽屉可查看往来,会话落进 Codex 会话库并可 resume 续接;提供 codex_gpt 工具与模型/推理强度切换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
},
|
|
25
25
|
"client": {
|
|
26
26
|
"platform": "web",
|
|
27
|
-
"immediately": true
|
|
27
|
+
"immediately": true,
|
|
28
|
+
"inject": [
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
31
|
+
]
|
|
28
32
|
}
|
|
29
33
|
},
|
|
30
34
|
"license": "MIT",
|