@aiti-ai/aiti-devtools-pi 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.
@@ -0,0 +1,1485 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>aiti cockpit</title>
7
+ <style>
8
+ :root {
9
+ /* 常规(浅色)主题——跟随系统,不强制深夜 */
10
+ --bg: #f8fafc; --panel: #ffffff; --panel2: #f1f5f9; --border: #e2e8f0;
11
+ --text: #0f172a; --muted: #64748b; --dim: #94a3b8;
12
+ --accent: #4f46e5; --accent-2: #6366f1; --green: #16a34a; --amber: #d97706; --red: #dc2626;
13
+ --hover: #e2e8f0; --btn-hover: #e8edf3; --hover-border: #cbd5e1;
14
+ --toast-bg: #1e293b; --toast-text: #e2e8f0;
15
+ --chip-bg: #eef2ff; --chip-border: #c7d2fe; --chip-text: #4338ca;
16
+ --chip-err-bg: #fee2e2; --chip-err-border: #fecaca; --chip-err-text: #b91c1c;
17
+ --radius: 10px;
18
+ color-scheme: light;
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #0f172a; --panel: #1e293b; --panel2: #111827; --border: #334155;
23
+ --text: #e2e8f0; --muted: #94a3b8; --dim: #64748b;
24
+ --accent: #4f46e5; --accent-2: #6366f1; --green: #34d399; --amber: #f59e0b; --red: #ef4444;
25
+ --hover: #1e293b; --btn-hover: #334155; --hover-border: #475569;
26
+ --toast-bg: #111827; --toast-text: #e2e8f0;
27
+ --chip-bg: #1e293b; --chip-border: #334155; --chip-text: #a5b4fc;
28
+ --chip-err-bg: #2d1a1a; --chip-err-border: #7f1d1d; --chip-err-text: #fca5a5;
29
+ color-scheme: dark;
30
+ }
31
+ }
32
+ * { box-sizing: border-box; }
33
+ html, body { height: 100%; }
34
+ body {
35
+ margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
36
+ background: var(--bg); color: var(--text); font-size: 13px;
37
+ display: flex; flex-direction: column; height: 100vh; overflow: hidden;
38
+ }
39
+ /* ============ 顶栏 ============ */
40
+ .topbar {
41
+ display: flex; align-items: center; gap: 8px; padding: 7px 12px;
42
+ background: var(--panel); border-bottom: 1px solid var(--border);
43
+ z-index: 30;
44
+ }
45
+ .brand { display: flex; align-items: baseline; gap: 8px; font-weight: 700; font-size: 14px; flex-shrink: 0; }
46
+ .brand .sub { font-size: 11px; color: var(--muted); font-weight: 400; }
47
+ /* 顶栏布局:🤖 ❓ ⚙ 左对齐成组,状态(最后一个)贴右;状态长度自适应——内容长则变长、空间小则收缩省略 */
48
+ .top-actions { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1 1 auto; }
49
+ .top-actions > * { flex-shrink: 0; } /* 左组按钮固定不压缩 */
50
+ #frameSelWrap { margin-left: auto; flex-shrink: 1; min-width: 0; display: flex; align-items: center; } /* 贴右 + 可收缩 */
51
+ /* 顶栏按钮统一高度(左组 .btn 与右侧 .status 胶囊等高——.status 字号小,用 min-height 拉齐) */
52
+ .topbar .btn, .topbar .status { min-height: 26px; display: inline-flex; align-items: center; justify-content: center; }
53
+ select, input, textarea, button { font: inherit; }
54
+ select {
55
+ background: var(--panel2); color: var(--text); border: 1px solid var(--border);
56
+ border-radius: 8px; padding: 4px 8px; max-width: 30vw; outline: none;
57
+ }
58
+ select:focus { border-color: var(--accent-2); }
59
+ .btn {
60
+ background: var(--panel2); color: var(--text); border: 1px solid var(--border);
61
+ border-radius: 8px; padding: 4px 10px; cursor: pointer; white-space: nowrap;
62
+ transition: background .15s, border-color .15s;
63
+ }
64
+ .btn:hover { background: var(--btn-hover); border-color: var(--hover-border); }
65
+ .btn.primary { background: var(--accent); border-color: var(--accent-2); color: var(--on-accent, #fff); }
66
+ .btn.primary:hover { background: var(--accent-2); color: var(--on-accent, #fff); }
67
+ .btn.ghost { background: transparent; }
68
+ .status { font-size: 11px; padding: 2px 9px; border-radius: 999px; background: var(--panel2); color: var(--dim); border: 1px solid var(--border); white-space: nowrap; cursor: pointer; flex: 0 1 auto; min-width: 0; max-width: 56vw; overflow: hidden; text-overflow: ellipsis; } /* 长度自适应:内容长→变长;窗口窄→收缩省略 */
69
+ .status:hover { background: var(--btn-hover); border-color: var(--hover-border); }
70
+ .status.ok { color: var(--green); border-color: #10b98155; }
71
+ .status.bad { color: var(--red); border-color: #ef444455; }
72
+
73
+ /* 帧选择下拉(Chrome F12 式:主页/iframe 树形) */
74
+ .dd.frame-dd { width: min(400px, 88vw); }
75
+ .frow { padding: 5px 8px; border-radius: 8px; cursor: pointer; }
76
+ .frow:hover { background: var(--hover); }
77
+ .frow.sel { background: var(--chip-bg); outline: 1px solid var(--chip-border); }
78
+ .frow.fgroup { cursor: default; margin-top: 6px; padding: 3px 8px 2px; background: rgba(127,127,127,.08); border-radius: 6px; pointer-events: none; }
79
+ .frow.fgroup .l1 { font-weight: 600; font-size: 11px; color: var(--dim); letter-spacing: .2px; }
80
+ .frow.fgroup .grp { color: inherit; }
81
+ .frow.fgroup .tt { color: var(--dim); opacity: .7; font-size: 10px; }
82
+ .frow .l1 { display: flex; align-items: baseline; gap: 5px; font-size: 12px; min-width: 0; }
83
+ .frow .tgl { flex-shrink: 0; width: 13px; text-align: center; color: var(--dim); font-size: 10px; user-select: none; }
84
+ .frow .tgl.collapsed::before { content: '▸'; } .frow .tgl.expanded::before { content: '▾'; }
85
+ .frow .nm { font-weight: 600; color: var(--accent); flex-shrink: 0; }
86
+ .frow .nm.sub { color: #818cf8; }
87
+ .frow .tt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .92; }
88
+ .frow .l2 { font-size: 10.5px; color: var(--muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 5px; }
89
+ .frow .dom { font-family: ui-monospace, SFMono-Regular, monospace; }
90
+ .frow .ext { color: var(--amber); font-weight: 600; }
91
+
92
+ /* 下拉(页面/agent 列表 popover——顶部工具条,消息区更大) */
93
+ .dd-wrap { position: relative; }
94
+ .dd {
95
+ position: absolute; right: 0; top: calc(100% + 6px);
96
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
97
+ width: min(340px, 82vw); max-height: 60vh; display: none; flex-direction: column;
98
+ box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 40;
99
+ }
100
+ .dd.open { display: flex; }
101
+ .dd-head { padding: 7px 12px; font-size: 12px; font-weight: 600; color: var(--muted); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; }
102
+ .dd-body { overflow-y: auto; padding: 5px 8px; flex: 1; min-height: 0; }
103
+ .dd-foot {
104
+ border-top: 1px solid var(--border); background: var(--panel2);
105
+ padding: 8px 12px; font-size: 11px; color: var(--muted); line-height: 1.55; flex-shrink: 0;
106
+ }
107
+ .dd-foot a { color: var(--accent-2); text-decoration: none; }
108
+ .dd-foot a:hover { text-decoration: underline; }
109
+ .dd-foot-sub { margin: 2px 0; opacity: .85; }
110
+ .dd-foot .copy-link {
111
+ color: var(--accent-2); cursor: pointer; border-bottom: 1px dashed var(--accent-2);
112
+ white-space: nowrap;
113
+ }
114
+ .dd-foot .copy-link:hover { color: var(--accent); border-bottom-style: solid; }
115
+ .row {
116
+ display: flex; align-items: baseline; gap: 6px; padding: 5px 8px; border-radius: 8px;
117
+ font-size: 12px; cursor: pointer;
118
+ }
119
+ .row:hover { background: var(--hover); }
120
+ .row .pid { color: #818cf8; font-weight: 600; flex-shrink: 0; font-size: 11px; }
121
+ .row .txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .85; }
122
+ .row .badge { margin-left: auto; flex-shrink: 0; font-size: 10px; }
123
+ /* 发送目标选中态(🤖 下拉合并后:当前目标行高亮) */
124
+ .row.sel { background: var(--accent); }
125
+ .row.sel:hover { background: var(--accent-2); }
126
+ .row.sel .pid, .row.sel .txt, .row.sel .badge { color: #fff; opacity: 1; }
127
+ .badge.dim { color: var(--dim); } .badge.warn { color: var(--amber); } .badge.ok { color: var(--green); }
128
+ .guide { font-size: 10px; color: var(--amber); padding: 2px 8px 8px; cursor: pointer; opacity: .85; }
129
+ .empty { color: var(--dim); font-size: 11px; padding: 14px 6px; text-align: center; line-height: 1.6; }
130
+
131
+ /* ============ 聊天主区(最大化消息列表) ============ */
132
+ .main { flex: 1; display: flex; min-height: 0; }
133
+ .chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
134
+ /* 候选区 */
135
+ /* 引导区(Welcome 卡 / 状态提示条——消息列表上方) */
136
+ .guide-area { padding: 6px 12px; border-bottom: 1px solid var(--border); background: var(--panel2); }
137
+ .guide-card {
138
+ background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
139
+ padding: 14px 16px; margin: 4px 0;
140
+ }
141
+ .guide-card h2 { margin: 0 0 8px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
142
+ .guide-card .step { display: flex; gap: 10px; margin: 8px 0; font-size: 12.5px; line-height: 1.6; }
143
+ .guide-card .step .no { flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
144
+ .guide-card .sub { color: var(--muted); font-size: 11.5px; }
145
+ .guide-card .codebox { max-height: 120px; }
146
+ .guide-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
147
+ .guide-tip {
148
+ display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 7px 12px;
149
+ border-radius: 8px; margin: 3px 0; flex-wrap: wrap;
150
+ }
151
+ .guide-tip.amber { background: rgba(245,158,11,.1); border: 1px solid rgba(245,158,11,.3); color: var(--amber); }
152
+ .guide-tip.dim { background: rgba(148,163,184,.08); border: 1px solid var(--border); color: var(--muted); }
153
+ .guide-tip .btn { padding: 1px 10px; font-size: 11px; }
154
+ .guide-tip .btn.right { margin-left: auto; } /* 指南/链接类按钮贴右,操作按钮保持左对齐 */
155
+ .msg-list { flex: 1; overflow-y: auto; padding: 12px 14px; scroll-behavior: smooth; display: flex; flex-direction: column; }
156
+ .msg { margin-bottom: 10px; display: flex; flex-direction: column; max-width: 94%; animation: fadein .2s ease; }
157
+ .msg.from-me { align-self: flex-end; align-items: flex-end; }
158
+ .msg.from-me .meta { justify-content: flex-end; }
159
+ @keyframes fadein { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; } }
160
+ .msg .meta { font-size: 10px; color: var(--dim); margin-bottom: 3px; display: flex; gap: 6px; align-items: center; }
161
+ .msg .meta .tag { padding: 1px 6px; border-radius: 4px; background: var(--panel); color: var(--muted); }
162
+ .msg .meta .st { color: var(--amber); } .msg .meta .st.cons { color: var(--green); }
163
+ .msg.sel { outline: 1px solid var(--accent); outline-offset: -1px; border-radius: 8px; background: var(--panel2); }
164
+ .msg .meta .who { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); cursor: pointer; }
165
+ .msg .meta .who:hover { text-decoration: underline; }
166
+ .msg .meta .who-icon { font-weight: 700; }
167
+ .msg.from-agent .bubble { border-left: 2px solid var(--green); }
168
+ .msg.from-agent .meta .who { color: var(--green); }
169
+ .msg.from-me .meta .who { color: #818cf8; }
170
+ .msg .bubble {
171
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
172
+ padding: 8px 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-all; font-size: 12.5px;
173
+ }
174
+ .msg.from-me .bubble { background: var(--accent); border-color: var(--accent-2); color: var(--on-accent, #fff); }
175
+ .msg.sys .bubble { background: transparent; border-style: dashed; color: var(--muted); font-size: 12px; }
176
+
177
+ /* 交互卡(可折叠) */
178
+ .interactions {
179
+ border-top: 1px solid var(--border); max-height: 30%; overflow-y: auto; padding: 6px 12px;
180
+ background: var(--panel2); display: flex; flex-direction: column;
181
+ }
182
+ .interactions.collapsed { max-height: 30px; overflow: hidden; }
183
+ .ix-head { font-size: 10px; color: var(--dim); display: flex; align-items: center; gap: 8px; padding-bottom: 4px; cursor: pointer; user-select: none; }
184
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 9px 12px; margin-bottom: 7px; }
185
+ .card .k { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
186
+ .card .p { line-height: 1.5; white-space: pre-wrap; word-break: break-all; margin-bottom: 8px; }
187
+ .card .row { display: flex; gap: 6px; flex-wrap: wrap; }
188
+
189
+ /* 底部输入(多行:textarea 自动增高,上限内部滚动;按钮底对齐) */
190
+ .composer { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--panel); }
191
+ /* 待选项(输入框上方) */
192
+ .picked-bar {
193
+ display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
194
+ padding: 6px 12px; border-top: 1px solid var(--border);
195
+ background: var(--panel); font-size: 11px;
196
+ }
197
+ .picked-bar .lb { color: var(--dim); font-size: 10px; flex-shrink: 0; }
198
+ .picked-chip {
199
+ background: var(--chip-bg); border: 1px solid var(--chip-border); border-radius: 6px;
200
+ padding: 2px 9px; color: var(--chip-text); cursor: pointer; white-space: nowrap;
201
+ }
202
+ .picked-chip.err { background: var(--chip-err-bg); border-color: var(--chip-err-border); color: var(--chip-err-text); }
203
+ .picked-chip:hover { filter: brightness(.96); }
204
+ .picked-bar .btn { padding: 1px 8px; font-size: 11px; }
205
+ /* 元素详情行:label + value + hover 复制/打开按钮 */
206
+ .el-detail-row { display: flex; gap: 8px; align-items: center; padding: 3px 0; border-bottom: 1px solid var(--border); }
207
+ .el-detail-label { color: var(--dim); min-width: 68px; font-weight: 600; font-size: 11px; flex-shrink: 0; }
208
+ .el-detail-val { flex: 1; word-break: break-all; font-size: 11px; }
209
+ .el-detail-copy { opacity: 0; transition: opacity .15s; padding: 0 6px; font-size: 10px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px; background: var(--panel2); color: var(--text); flex-shrink: 0; }
210
+ .el-detail-row:hover .el-detail-copy { opacity: 1; }
211
+ .el-detail-ops { display: inline-flex; gap: 4px; flex-shrink: 0; }
212
+ .el-detail-ops .btn.mini { padding: 0 6px; font-size: 10px; }
213
+ .el-detail-chk { color: var(--dim); font-size: 10px; }
214
+ .el-detail-missing { color: var(--dim); font-size: 10px; flex-shrink: 0; }
215
+ .composer textarea {
216
+ flex: 1; background: var(--panel2); color: var(--text); border: 1px solid var(--border);
217
+ border-radius: 8px; padding: 8px 12px; outline: none;
218
+ resize: none; line-height: 1.5;
219
+ min-height: 34px; max-height: 140px; overflow-y: auto;
220
+ scrollbar-width: none; /* 输入框不显示滚动条(滚轮/方向键可滚,参照 pi-web 无滚动条观感) */
221
+ }
222
+ .composer textarea::-webkit-scrollbar { display: none; width: 0; height: 0; }
223
+ .composer textarea:focus { border-color: var(--accent-2); }
224
+ .composer .btn.primary { align-self: flex-end; flex-shrink: 0; }
225
+
226
+ /* 全局滚动条:4px 极细条(参照 pi-web app/globals.css——消息列表/下拉/弹窗统一,小屏不突兀) */
227
+ * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } /* Firefox */
228
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
229
+ ::-webkit-scrollbar-track { background: transparent; }
230
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
231
+ ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
232
+
233
+ /* 设置弹窗 */
234
+ .modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; align-items: center; justify-content: center; z-index: 50; padding: 16px; }
235
+ .modal-mask.show { display: flex; }
236
+ .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; width: min(640px, 96vw); max-height: 88vh; overflow-y: auto; padding: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
237
+ .modal h3 { margin: 0 0 14px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
238
+ .modal .close { margin-left: auto; }
239
+ .set-block { margin-bottom: 16px; }
240
+ .set-block .label { font-size: 11px; color: var(--muted); margin-bottom: 6px; }
241
+ .codebox { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; white-space: pre-wrap; word-break: break-all; margin: 6px 0; user-select: all; max-height: 200px; overflow: auto; }
242
+ .hint { font-size: 11px; color: var(--dim); line-height: 1.6; }
243
+ .toast { position: fixed; bottom: 70px; left: 50%; transform: translateX(-50%); background: var(--toast-bg); color: var(--toast-text); border: 1px solid var(--border); border-radius: 999px; padding: 8px 18px; font-size: 12px; opacity: 0; transition: opacity .25s; pointer-events: none; z-index: 99; }
244
+ .toast.show { opacity: 1; }
245
+
246
+ /* 小屏 */
247
+ @media (max-width: 720px) {
248
+ .topbar { padding: 5px 8px; gap: 5px; flex-wrap: wrap; }
249
+ .brand .sub { display: none; }
250
+ select { max-width: 36vw; }
251
+ .status { font-size: 10px; padding: 2px 7px; }
252
+ .msg { max-width: 98%; }
253
+ .composer { padding: 7px 8px; }
254
+ .composer textarea { min-width: 0; flex: 1; order: 1; min-height: 56px; } /* 小屏 placeholder 占两行——min-height 提到两行高度(CSS min-height 优先于 JS 设置的 height) */
255
+ .composer .btn.primary { order: 2; flex: 0 0 auto; }
256
+ .interactions { max-height: 24vh; }
257
+ /* 小屏下拉固定到视口内(不再相对按钮右对齐——按钮在屏中时下拉左铺会出屏被遮挡) */
258
+ .dd { position: fixed; top: 56px; left: 8px; right: 8px; width: auto; max-height: 62vh; }
259
+ }
260
+ /* 手机小屏专项(≤480px):紧凑布局 + 长内容截断,不溢出 */
261
+ @media (max-width: 480px) {
262
+ body { font-size: 12px; }
263
+ .topbar { padding: 4px 6px; gap: 4px; }
264
+ .topbar .btn { padding: 3px 8px; font-size: 12px; min-height: 24px; }
265
+ .topbar select { font-size: 11px; max-width: 32vw; padding: 3px 4px; }
266
+ .topbar .status { font-size: 10px; padding: 2px 6px; white-space: normal; line-height: 1.3; } /* 状态完整显示(不截断),按钮内可换行 */
267
+ .msg-list { padding: 8px 8px; }
268
+ .msg { max-width: 100%; margin-bottom: 8px; }
269
+ .msg .meta { flex-wrap: wrap; gap: 4px 6px; font-size: 9px; }
270
+ .msg .meta .who { max-width: 100%; }
271
+ .msg .meta .who span:last-child { max-width: 46vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* agent 名长截断 */
272
+ .msg .bubble { font-size: 12px; }
273
+ .composer { padding: 6px 8px; gap: 6px; }
274
+ .composer input, .composer textarea { font-size: 14px; padding: 6px 8px; }
275
+ .composer .btn.primary { padding: 6px 12px; }
276
+ .picked-bar { padding: 4px 8px; font-size: 10px; }
277
+ .guide-card h2 { font-size: 13px; }
278
+ .guide-card .step { font-size: 12px; }
279
+ .guide-card .codebox { white-space: pre; overflow-x: auto; }
280
+ .guide-tip { font-size: 11px; padding: 6px 8px; }
281
+ .guide-tip .btn { margin-top: 4px; }
282
+ .modal { padding: 12px; width: 98vw; }
283
+ .modal h3 { font-size: 13px; }
284
+ .interactions { max-height: 20vh; }
285
+ .dd { top: 48px; }
286
+ .row { font-size: 11px; padding: 4px 6px; }
287
+ }
288
+ </style>
289
+ </head>
290
+ <body>
291
+
292
+ <!-- 顶栏:session 切换 + agent 下拉 + 帮助/设置 + 状态(品牌标题已删) -->
293
+ <header class="topbar">
294
+ <div class="top-actions">
295
+ <div class="dd-wrap">
296
+ <button class="btn ghost" id="agentsBtn">🤖 <span id="agentCount"></span></button>
297
+ <div class="dd" id="agentsDd">
298
+ <div class="dd-head">🤖 agent 会话 <span style="margin-left:auto" class="cnt">点选设为发送目标</span></div>
299
+ <div class="dd-body" id="agentsList"><div class="empty">agent 连接注册后显示</div></div>
300
+ <div class="dd-foot">
301
+ <div>📥 <b>消息怎么到 agent</b>:agent 需<b>注册(aiti_hello)</b>并<b>持续监听 aiti_inbox</b>(拉取 + ack)才能收到/处理消息。<span class="copy-link" onclick="copyInboxGuide()" title="复制给 agent 的监听 prompt">📋 点我复制这个 prompt</span></div>
302
+ <div class="dd-foot-sub">pi 安装 aiti-devtools-pi 扩展,可以自动注册并监听。</div>
303
+ <a href="https://aiti.pw/guide/mcp" target="_blank" rel="noopener">📖 完整说明见 aiti.pw/guide/mcp ↗</a> ·
304
+ <a href="https://aiti.pw" target="_blank" rel="noopener">⭐ 推荐使用 aiti-agent:aiti.pw ↗</a>效果更佳。
305
+ </div>
306
+ </div>
307
+ </div>
308
+ <button class="btn ghost" id="helpBtn" title="帮助与引导">❓</button>
309
+ <button class="btn ghost" id="settingsBtn" title="设置:注入代码 / MCP 配置 / 连接端点">⚙</button>
310
+ <div class="dd-wrap" id="frameSelWrap">
311
+ <button class="status" id="status" title="调试帧:主页/iframe(下拉树形选择;选择后发消息携带该帧 pageId)">未连接</button>
312
+ <div class="dd frame-dd" id="frameDd">
313
+ <div class="dd-body" id="frameList"><div class="empty">无已连接页面</div></div>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </header>
318
+
319
+ <div class="main">
320
+ <!-- 聊天主区(消息列表最大化):候选区 + 消息 + 交互卡 + 输入 -->
321
+ <section class="chat">
322
+ <!-- 引导区:无 agent 显示 Welcome 卡;状态异常显示提示条(条件渲染,正常时隐藏) -->
323
+ <div class="guide-area" id="guide"></div>
324
+ <div class="msg-list" id="msgList"><div class="empty">页面报错 / 人发消息会实时推送;选择 agent 查看消息历史<br>(消息持久化于服务端 JSONL,参考 pi session)</div></div>
325
+ <div class="interactions" id="interactions" style="display:none">
326
+ <div class="ix-head" id="ixHead">💬 交互卡(agent 提问作答)<span style="margin-left:auto" id="ixCount"></span><span>▾/▴</span></div>
327
+ <div id="ixBody"></div>
328
+ </div>
329
+ <!-- 待选项(inspect 元素/错误/作答 → [元素N] 占位符)——放输入框上方;agent 会话选择在顶栏已有 -->
330
+ <div class="picked-bar" id="pickedBar" style="display:none"></div>
331
+ <footer class="composer">
332
+ <textarea id="pushText" rows="1" placeholder="给选中 agent 发消息…([元素N] 占位符发送时替换为完整描述;Enter 发送,Shift+Enter 换行)"></textarea>
333
+ <button class="btn primary" onclick="pushMessage()">发送</button>
334
+ </footer>
335
+ </section>
336
+ </div>
337
+
338
+ <!-- 帮助弹窗(引导内容) -->
339
+ <div class="modal-mask" id="helpModal">
340
+ <div class="modal">
341
+ <h3>❓ 帮助与引导 <button class="btn ghost close" onclick="closeHelp()">✕</button></h3>
342
+ <div class="set-block">
343
+ <div class="label">🛰️ 当前状态与使用</div>
344
+ <div class="hint">
345
+ <b>连接</b>——已连接(顶部状态)。<br>
346
+ <b>🤖 agent 会话</b>——右上角查看/切换/设置发送目标(点行即选;🌐 全部 = 群发)。<br>
347
+ <b>💬 消息</b>——发消息 → agent 处理 → 回答显示;状态:pending=待拉取 · delivered=已送达 · consumed=已处理。<br>
348
+ <b>📎 待选项</b>——页面点选(🧰 工具条)结果进输入框上方,发送时替换为完整描述。<br>
349
+ <b>接入 agent</b>——pi 扩展自动注册;其他 agent 配 mcp.json + 调 aiti_inbox(下方复制)。
350
+ </div>
351
+ </div>
352
+ <div class="set-block">
353
+ <div class="label">🔌 Agent MCP 配置(mcp.json → mcpServers;agent 调 aiti_inbox 收消息)</div>
354
+ <div class="codebox" id="helpMcp"></div>
355
+ <button class="btn" onclick="copyText('helpMcp')">📋 复制</button>
356
+ </div>
357
+ <div class="guide-actions">
358
+ <button class="btn" onclick="window.open('https://aiti.pw/guide/mcp','_blank')">📖 打开完整指南(aiti.pw/guide/mcp)</button>
359
+ </div>
360
+ </div>
361
+ </div>
362
+
363
+ <!-- 设置弹窗 -->
364
+ <div class="modal-mask" id="settingsModal">
365
+ <div class="modal">
366
+ <h3>⚙ 设置 <button class="btn ghost close" onclick="closeSettings()">✕</button></h3>
367
+ <div class="set-block">
368
+ <div class="label">⭐ aiti-agent(第一推荐)——带 Web 界面的 AI 助手,安装后设地址,AI 窗直接由它接管</div>
369
+ <div class="hint">访问 <a href="https://aiti.pw" target="_blank" rel="noopener">aiti.pw</a> 下载安装;装好后设置地址(应用后自动协商重开 AI 窗,10s 协议探测,成功才生效,失败回退本页)。</div>
370
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
371
+ <button class="btn primary" onclick="setupAiAgent()">⚙ 设置 aiti-agent 地址</button>
372
+ <button class="btn" onclick="window.open('https://aiti.pw','_blank')">🔗 打开 aiti.pw</button>
373
+ </div>
374
+ </div>
375
+ <div class="set-block">
376
+ <div class="label">🧩 页面注入(动态:本地连 127.0.0.1:8777,非本地连 https://8777.pi.example.com:8443)</div>
377
+ <div class="codebox" id="injectCode"></div>
378
+ <button class="btn" onclick="copyText('injectCode')">📋 复制</button>
379
+ </div>
380
+ <div class="set-block">
381
+ <div class="label">🔌 Agent MCP 配置(mcp.json → mcpServers;agent 调 aiti_inbox 拉消息)</div>
382
+ <div class="codebox" id="mcpCfg"></div>
383
+ <button class="btn" onclick="copyText('mcpCfg')">📋 复制</button>
384
+ </div>
385
+ <div class="set-block">
386
+ <div class="label">🔗 连接端点</div>
387
+ <div class="codebox" id="bridgeEndpoints"></div>
388
+ </div>
389
+ <div class="hint">💡 页面报错默认实时推送已订阅的 agent(pi 扩展自动订阅 page_error);消息历史持久化于服务端(JSONL,参考 pi session 同款)。</div>
390
+ </div>
391
+ </div>
392
+
393
+ <div class="modal-mask" id="detailModal">
394
+ <div class="modal">
395
+ <h3>🔍 工具调用详情 <button class="btn ghost close" onclick="closeDetail()">✕</button></h3>
396
+ <pre id="detailBody" style="white-space:pre-wrap;word-break:break-all;font-size:11px;margin:0;max-height:60vh;overflow:auto"></pre>
397
+ </div>
398
+ </div>
399
+
400
+ <!-- 元素候选详情(detailRows 渲染 + hover 复制 + 文件 后台/前端 打开) -->
401
+ <div class="modal-mask" id="elDetailModal">
402
+ <div class="modal" style="max-width:min(640px,94vw)">
403
+ <h3>📋 元素详情 <button class="btn ghost close" onclick="closeElDetail()">✕</button></h3>
404
+ <div id="elDetailBody" style="max-height:60vh;overflow:auto;font-size:12px"></div>
405
+ <div class="hint" style="margin-top:8px;font-size:11px">📋 hover 行复制;📂 后台打开经桥(AITI_OPEN_CMD/系统默认);🖥 前端在 pi-web 宿主打开文件标签页(独立桥无宿主时不可用)</div>
406
+ </div>
407
+ </div>
408
+
409
+ <div class="modal-mask" id="copyFallbackModal">
410
+ <div class="modal">
411
+ <h3>📋 复制内容 <button class="btn ghost close" onclick="closeCopyFallback()">✕</button></h3>
412
+ <div class="hint" style="margin-bottom:8px">剪贴板不可用——已自动全选下方内容,直接 Ctrl/Cmd+C 复制:</div>
413
+ <textarea id="copyFallbackText" readonly spellcheck="false" style="width:100%;height:130px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:inherit;font-size:13px;outline:none;box-sizing:border-box;white-space:pre-wrap;word-break:break-all"></textarea>
414
+ <div style="display:flex;gap:8px;margin-top:10px;justify-content:flex-end">
415
+ <button class="btn primary" onclick="copyFallbackAgain()">📋 再试复制</button>
416
+ <button class="btn" onclick="closeCopyFallback()">关闭</button>
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ <div class="toast" id="toast"></div>
422
+ <script src="./bridge-client.js"></script>
423
+ <script src="./host-protocol.js"></script>
424
+ <script src="./cdp-core.js"></script>
425
+ <script src="./aiwin.js"></script>
426
+ <script>
427
+ 'use strict';
428
+ const $ = (id) => document.getElementById(id);
429
+ const base = location.origin + (location.pathname.startsWith('/aiti/') ? '/aiti' : '');
430
+ const REMOTE_BRIDGE = 'https://8777.pi.example.com:8443/aiti';
431
+
432
+ function toast(text) { const t = $('toast'); t.textContent = text; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1600); }
433
+ function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
434
+ // HTML 属性转义(单/双引号用于 data-* 属性值)
435
+ function escAttr(s) { return esc(String(s == null ? '' : s)).replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
436
+ function copyText(id) {
437
+ const txt = $(id).textContent || '';
438
+ navigator.clipboard.writeText(txt).then(() => toast('已复制'), () => showCopyFallback(txt));
439
+ }
440
+
441
+ // ---- 复制兑底:剪贴板不可用(iframe 权限限制)→ 弹窗展示全文,用户自行选择复制 ----
442
+ let __copyFallbackText = '';
443
+ function showCopyFallback(text) {
444
+ __copyFallbackText = text;
445
+ const hp = aiwin.getHost();
446
+ if (hp && hp.getHostWin && hp.getHostWin()) {
447
+ // 有宿主(iframe/popup 内嵌):委托宿主 top-level 弹——宿主剪贴板可用,再试即成功(本 iframe 内 clipboard 无权限)
448
+ hp.postToHost('dbg_ai_copy_fallback', { text: text });
449
+ return;
450
+ }
451
+ // 无宿主(独立窗口部署):本地弹
452
+ $('copyFallbackText').value = text;
453
+ $('copyFallbackModal').classList.add('show');
454
+ const ta = $('copyFallbackText');
455
+ ta.focus();
456
+ ta.select(); // 自动全选,Ctrl/Cmd+C 即复制
457
+ }
458
+ function closeCopyFallback() { $('copyFallbackModal').classList.remove('show'); }
459
+ $('copyFallbackModal').onclick = (e) => { if (e.target === $('copyFallbackModal')) closeCopyFallback(); };
460
+ function copyFallbackAgain() {
461
+ navigator.clipboard.writeText(__copyFallbackText)
462
+ .then(() => { toast('已复制'); closeCopyFallback(); })
463
+ .catch(() => toast('剪贴板仍不可用——请用 Ctrl/Cmd+C 手动复制'));
464
+ }
465
+
466
+ // ---- 设置弹窗 ----
467
+ $('settingsBtn').onclick = () => $('settingsModal').classList.add('show');
468
+ $('settingsModal').onclick = (e) => { if (e.target === $('settingsModal')) closeSettings(); };
469
+ function closeSettings() { $('settingsModal').classList.remove('show'); }
470
+
471
+ // ---- aiti-agent 地址设置(第一推荐):设置弹窗统一在宿主层打开(与 copy fallback 同机制——cockpit 只发消息,宿主弹) ----
472
+ function setupAiAgent() {
473
+ const hp = aiwin.getHost();
474
+ if (hp && hp.postToHost) {
475
+ hp.postToHost('dbg_ai_setup_aiagent', {});
476
+ } else {
477
+ toast('未连接宿主(独立窗口部署)——请在页面内打开 AI 窗,用其 ⚙ 设置');
478
+ }
479
+ }
480
+ const injectCode = `<script>\n(function () {\n if (window.__dbgLauncherInstalled) return;\n var host = location.hostname;\n var bridge = (host === '127.0.0.1' || host === 'localhost')\n ? 'http://127.0.0.1:8777/aiti'\n : '${REMOTE_BRIDGE}';\n window.__AITI_BRIDGE_URL = bridge;\n var s = document.createElement('script');\n s.src = bridge + '/aiti-launcher.js';\n document.head.appendChild(s);\n})();\n<\\/script>`;
481
+ $('injectCode').textContent = injectCode;
482
+ $('mcpCfg').textContent = JSON.stringify({ mcpServers: { "aiti-devtools": { type: "http", url: `${base}/mcp/mcp` } } }, null, 2);
483
+ $('bridgeEndpoints').textContent = 'REST: ' + base + '/bridge/*\nMCP: ' + base + '/mcp/mcp\nSSE(page): ' + base + '/bridge/sse?role=page\nagent SSE: ' + base + '/bridge/agent/sse?agentKey=<agentId>/<sessionId>';
484
+ // 帮助弹窗内容
485
+ $('helpMcp').textContent = JSON.stringify({ mcpServers: { "aiti-devtools": { type: "http", url: `${base}/mcp/mcp` } } }, null, 2);
486
+
487
+ // ---- 下拉(页面/agent popover) ----
488
+ function bindDd(btnId, ddId) {
489
+ const btn = $(btnId), dd = $(ddId);
490
+ btn.onclick = (e) => { e.stopPropagation(); dd.classList.toggle('open'); };
491
+ dd.addEventListener('click', (e) => e.stopPropagation());
492
+ }
493
+ bindDd('agentsBtn', 'agentsDd');
494
+ bindDd('status', 'frameDd');
495
+ document.addEventListener('click', () => { document.querySelectorAll('.dd.open').forEach((d) => d.classList.remove('open')); });
496
+
497
+ // ================= 帧树下拉(主页/iframe —— Chrome F12 式;选择后发消息携带该帧 pageId) =================
498
+ // boot 帧 → 行(launcher 本地帧树:id/parentId 相对实例;pageId = 实例base + '/' + id)
499
+ function frameRowFromBoot(basePageId, f) {
500
+ const base = String(basePageId).replace(/\/host$/, '');
501
+ const fid = String((f && f.id) || 'host');
502
+ const isHost = fid === 'host';
503
+ return {
504
+ instanceId: base, frameId: fid, pageId: base + '/' + fid, viaAI: true,
505
+ url: null, title: null,
506
+ frameUrl: (f && f.url) || null, frameTitle: (f && f.title) || null,
507
+ frameName: (f && f.name) || '', frameSrc: (f && f.src) || '',
508
+ frameParent: isHost ? null : ((f && f.parentId) || null),
509
+ root: isHost || !(f && f.parentId),
510
+ };
511
+ }
512
+ // 帧域名(第二行):chrome 扩展 → 「扩展」;srcdoc/data → 字面量;其余 hostname
513
+ function frameDomain(f) {
514
+ const u = (f && (f.frameUrl || f.frameSrc || f.url)) || '';
515
+ if (!u) return '未知';
516
+ const s = String(u);
517
+ if (/^(chrome-extension|moz-extension):\/\//.test(s)) return '扩展';
518
+ if (s.indexOf('about:') === 0) return 'srcdoc';
519
+ if (s.indexOf('data:') === 0) return 'data:';
520
+ try {
521
+ const url = new URL(s, location.href);
522
+ // host 带端口(如 127.0.0.1:8899;默认端口 80/443 自动省略)
523
+ return url.host || (url.origin && url.origin !== 'null' ? url.origin : s.slice(0, 48));
524
+ } catch (e) { return s.slice(0, 48); }
525
+ }
526
+ function frameIsExt(f) { return /^(chrome-extension|moz-extension):\/\//.test(String((f && (f.frameUrl || f.frameSrc || f.url)) || '')); }
527
+ // 帧行 HTML:两行——标题 + 域名/扩展;缩进随 depth;父帧带 ▸/▾ 折叠;组头行(不同页面实例的帧树分隔)
528
+ function frameRowHtml(r, sel) {
529
+ if (r.group) {
530
+ // 组头:页面实例分隔(多页面时树列表不混排)——显示标题 + host + instanceId 短名
531
+ const gTitle = (r.title || r.url || '页面') .slice(0, 30);
532
+ const gHost = (function () { try { const u = new URL(r.url || '', location.href); return u.host; } catch (e) { return ''; } })();
533
+ const gTag = r.instanceId ? ' · ' + String(r.instanceId).slice(0, 10) : '';
534
+ const gDev = r.deviceLabel ? ' · 🖥 ' + esc(String(r.deviceLabel).slice(0, 44)) : '';
535
+ return `<div class="frow fgroup" data-grp="${escAttr(r.instanceId || '')}">` +
536
+ `<div class="l1"><span class="grp">📁 ${esc(gTitle)}</span><span class="tt">${esc((gHost + gTag) || '')}</span></div>` +
537
+ (gDev ? `<div class="l2"><span class="dev">${gDev}</span></div>` : '') + `</div>`;
538
+ }
539
+ const isHost = r.frameId === 'host';
540
+ const nm = isHost ? '主页' : 'iframe';
541
+ const tt = (r.frameTitle || r.title || r.frameName) || '(无标题)';
542
+ const dom = frameDomain(r);
543
+ const isExt = frameIsExt(r);
544
+ const pad = (r.depth || 0) * 16;
545
+ let tgl = '<span class="tgl"></span>';
546
+ const kids = frameTreeRows.filter((x) => x.frameParent === r.frameId && x.instanceId === r.instanceId);
547
+ if (kids.length) tgl = `<span class="tgl ${collapsedFrames.has(r.pageId) ? 'collapsed' : 'expanded'}" data-tgl="${escAttr(r.pageId)}"></span>`;
548
+ return `<div class="frow${sel ? ' sel' : ''}" data-pageid="${escAttr(r.pageId)}" title="选择「${esc(nm)}: ${esc(tt)}」→ 发消息携带 pageId ${esc(r.pageId)}">` +
549
+ `<div class="l1" style="padding-left:${pad}px">${tgl}<span class="nm${isHost ? '' : ' sub'}">${isHost ? '🌐 ' : '📦 '}${esc(nm)}</span><span class="tt">${esc(String(tt).slice(0, 40))}</span></div>` +
550
+ `<div class="l2" style="padding-left:${pad + 20}px"><span class="dom${isExt ? ' ext' : ''}">${isExt ? '🧩 扩展' : esc(dom)}</span>${r.frameName ? '<span>· ' + esc(String(r.frameName).slice(0, 16)) + '</span>' : ''}${r.deviceLabel ? '<span class="dev">· 🖥 ' + esc(String(r.deviceLabel).slice(0, 34)) + '</span>' : ''}</div></div>`;
551
+ }
552
+ // sessions 行 → 树形展开(按 instanceId 分组;root(host/顶层 fN)→ 子级)
553
+ // 去重:同一 pageId 出现多行(直接页 register + viaAI 展开双路径)→ 保留信息更全(非 viaAI、有 frameTitle)的一行
554
+ function buildFrameTreeRows(sessions) {
555
+ const groups = new Map();
556
+ const merged = new Map();
557
+ for (const s of sessions || []) {
558
+ if (!s || !s.pageId || !s.frameId) continue;
559
+ const prev = merged.get(s.pageId);
560
+ if (!prev) { merged.set(s.pageId, s); continue; }
561
+ const prevScore = (prev.viaAI ? 0 : 2) + ((prev.frameTitle || prev.frameUrl) ? 1 : 0);
562
+ const sScore = (s.viaAI ? 0 : 2) + ((s.frameTitle || s.frameUrl) ? 1 : 0);
563
+ if (sScore > prevScore) merged.set(s.pageId, s);
564
+ }
565
+ for (const s of merged.values()) {
566
+ let g = groups.get(s.instanceId);
567
+ if (!g) { g = { instanceId: s.instanceId, url: s.url, title: s.title, rows: [] }; groups.set(s.instanceId, g); }
568
+ g.rows.push(s);
569
+ }
570
+ const out = [];
571
+ for (const g of groups.values()) {
572
+ // 组头:该页面实例帧树的独立子树(多页面时树列表清晰分隔)
573
+ out.push({ group: true, instanceId: g.instanceId, url: g.url, title: g.title, depth: -1, pageId: '__grp_' + g.instanceId, deviceLabel: (g.rows.find((r) => r.deviceLabel) || {}).deviceLabel || null });
574
+ // DOM 帧树:主页为根,无父的 iframe(顶层 fN)挂在主页下;嵌套帧按 parentId
575
+ const hostRow = g.rows.find((r) => r.frameId === 'host') || null;
576
+ const byParent = new Map();
577
+ for (const f of g.rows) {
578
+ if (f === hostRow) continue;
579
+ const pid = f.frameParent || 'host'; // 无父(顶层 iframe)→ 挂在主页下
580
+ const arr = byParent.get(pid) || []; arr.push(f); byParent.set(pid, arr);
581
+ }
582
+ const roots = hostRow ? [hostRow] : g.rows.filter((r) => r.root || !r.frameParent);
583
+ roots.sort((a, b) => (a.frameId === 'host' ? -1 : b.frameId === 'host' ? 1 : String(a.frameId).localeCompare(String(b.frameId))));
584
+ (function walk(rows, depth) {
585
+ for (const f of rows) {
586
+ out.push(Object.assign({}, f, { depth }));
587
+ const kids = (byParent.get(f.frameId) || []).sort((a, b) => String(a.frameId).localeCompare(String(b.frameId)));
588
+ if (kids.length) walk(kids, depth + 1);
589
+ }
590
+ })(roots, 0);
591
+ }
592
+ return out;
593
+ }
594
+ // 合并 sessions(桥展开,直接页/独立桥) + boot 本地帧(宿主直报,桥未展开时即时可用);按 pageId 去重
595
+ function rebuildFrameTree(active) {
596
+ // 只显示本 AI 窗宿主页的帧树(不混入其他 AI 窗/标签页的页面——每窗一页,帧是页内的);独立窗(无宿主)才展示全部页面
597
+ const own = (aiwin.getHostTarget && aiwin.getHostTarget()) || null;
598
+ const ownBase = (own && own.pageId) ? String(own.pageId).replace(/\/host$/, '') : '';
599
+ const rows = ownBase
600
+ ? buildFrameTreeRows((active || []).filter((s) => s.pageId && s.pageId.split('/')[0] === ownBase))
601
+ : buildFrameTreeRows(active);
602
+ const seen = new Set(rows.map((r) => r.pageId));
603
+ for (const b of bootFrameRows) if (!seen.has(b.pageId)) { rows.push(b); seen.add(b.pageId); }
604
+ frameTreeRows = rows.sort((a, b) => (a.instanceId === b.instanceId ? (a.depth - b.depth) || 0 : 0));
605
+ // 选中失效(页面断开/帧移除)→ 回退默认 主页(首个页面 host)
606
+ if (selectedFrame && !frameTreeRows.some((r) => r.pageId === selectedFrame.pageId)) selectedFrame = null;
607
+ renderFrameDropdown();
608
+ }
609
+ function updateStatusLabel() {
610
+ const st = $('status');
611
+ if (!st) return;
612
+ if (!frameTreeRows.length) {
613
+ // 无活跃帧:宿主页(AI 窗由它打开)→ 显示宿主页身份;否则「无页面」;选中帧失效 → 发消息回退宿主/兜底页
614
+ if (selectedFrame) { selectedFrame = null; aiwin.setTargetPage(null); }
615
+ if (hostPage) {
616
+ const hpLabel = (hostPage.title || hostPage.url) ? ':' + String(hostPage.title || hostPage.url).slice(0, 24) : '';
617
+ st.textContent = '🔗 已连接 · 🖥 宿主页' + hpLabel;
618
+ st.title = '宿主页(页面未连桥,经 AI 窗可操作)——下拉选择 主页/iframe';
619
+ } else {
620
+ st.textContent = '🔗 已连接 · 🖥 无页面';
621
+ st.title = '下拉选择调试帧(主页/iframe)';
622
+ }
623
+ return;
624
+ }
625
+ // 默认选中:主页(优先本 AI 窗所在页面的 host;多页面取第一个 host)
626
+ if (!selectedFrame) {
627
+ const own = aiwin.getHostTarget ? aiwin.getHostTarget() : null;
628
+ const ownBase = (own && own.pageId) ? String(own.pageId).replace(/\/host$/, '') : '';
629
+ selectedFrame = (ownBase
630
+ ? frameTreeRows.find((r) => r.frameId === 'host' && r.pageId === ownBase + '/host')
631
+ : null)
632
+ || frameTreeRows.find((r) => r.frameId === 'host')
633
+ || frameTreeRows[0] || null;
634
+ if (selectedFrame) aiwin.setTargetPage(selectedFrame.pageId);
635
+ }
636
+ const f = selectedFrame;
637
+ const nm = f && f.frameId === 'host' ? '主页' : 'iframe';
638
+ const tt = (f && (f.frameTitle || f.title || f.frameName)) || '…';
639
+ st.textContent = '🔗 已连接 · 🖥 ' + nm + ': ' + String(tt).slice(0, 22);
640
+ st.title = '调试帧:' + (f ? f.pageId : '') + '(下拉树形选择 主页/iframe;选择后发消息携带该帧 pageId)';
641
+ }
642
+ // 帧树上报到桥(/bridge/ai/frames;成功即清除挂起)
643
+ function postFramesToBridge() {
644
+ if (!pendingFrames) return;
645
+ const pf = pendingFrames;
646
+ fetch(base + '/bridge/ai/frames', {
647
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
648
+ body: JSON.stringify(pf),
649
+ }).then((r) => r.json().catch(() => null)).then((b) => { if (b && b.ok && pendingFrames === pf) pendingFrames = null; }).catch(() => {});
650
+ }
651
+ // 选中帧 → 下次 push 携带该帧 pageId(aiwin.push targetPageId 优先)
652
+ function selectFrame(pageId) {
653
+ selectedFrame = frameTreeRows.find((r) => r.pageId === pageId) || null;
654
+ if (selectedFrame) aiwin.setTargetPage(pageId);
655
+ $('frameDd').classList.remove('open');
656
+ updateStatusLabel();
657
+ renderFrameDropdown();
658
+ }
659
+ // 渲染下拉:纯 DOM 帧树(主页根 → iframe 缩进;无分组头/无选中帧头)
660
+ function renderFrameDropdown() {
661
+ const list = $('frameList');
662
+ if (!frameTreeRows.length) { list.innerHTML = '<div class="empty">无已连接页面(注入页面后自动出现)</div>'; return; }
663
+ let html = '';
664
+ let skipBelow = -1; // 折叠的父帧子树:depth > skipBelow 的行跳过
665
+ for (const r of frameTreeRows) {
666
+ if (skipBelow >= 0) {
667
+ if (r.depth > skipBelow) continue;
668
+ skipBelow = -1;
669
+ }
670
+ const kids = frameTreeRows.filter((x) => x.frameParent === r.frameId && x.instanceId === r.instanceId);
671
+ const collapsed = kids.length > 0 && collapsedFrames.has(r.pageId);
672
+ if (collapsed) skipBelow = r.depth;
673
+ const sel = selectedFrame && selectedFrame.pageId === r.pageId;
674
+ html += frameRowHtml(r, sel);
675
+ }
676
+ list.innerHTML = html;
677
+ // 点击:选帧;点 ▸/▾:折叠/展开
678
+ [...list.querySelectorAll('.frow')].forEach((row) => {
679
+ row.onclick = (e) => {
680
+ const tgl = e.target.closest('.tgl');
681
+ if (tgl && tgl.dataset.tgl) {
682
+ const pid = tgl.dataset.tgl;
683
+ if (collapsedFrames.has(pid)) collapsedFrames.delete(pid); else collapsedFrames.add(pid);
684
+ renderFrameDropdown();
685
+ return;
686
+ }
687
+ e.stopPropagation();
688
+ selectFrame(row.dataset.pageid);
689
+ };
690
+ });
691
+ }
692
+
693
+ // ================= 适配端:仅渲染(逻辑全在 aiwin.js + host-protocol.js 公共库) =================
694
+ // 已渲染消息 id(增量去重)
695
+ let renderedMsgIds = new Set();
696
+
697
+ // 窗口 title:新消息提醒(失焦/切标签可见);聚焦恢复当前 agent session title
698
+ let BASE_TITLE = document.title;
699
+ function bumpTitle() {
700
+ if (!document.hasFocus()) {
701
+ document.title = '📥 aiti(新消息)';
702
+ clearTimeout(window.__titleT);
703
+ window.__titleT = setTimeout(() => { document.title = BASE_TITLE; }, 6000);
704
+ }
705
+ }
706
+ window.addEventListener('focus', () => { document.title = BASE_TITLE; });
707
+
708
+ // ---- 发送目标显示辅助:CWD . name (id)(用户可识别;兼容旧格式 name='pi · <cwd>')----
709
+ let lastAgents = [];
710
+ let curTarget = '__all__'; // 当前发送目标(aiwin.setTarget 同步维护;🤖 下拉行高亮依据)
711
+ let pageSessions = []; // 已连接页面(onSessions 更新;用于状态计数 + 无页面引导)
712
+ let hostPage = null; // 宿主页合成记录(fromHost:桥无 session 但宿主 launcher 在,AI 窗由它打开)
713
+ let sentDetail = {}; // 发送消息 ref → 实际传输的完整 body(点击消息行 → 详情弹窗展示)
714
+ // ---- 帧树下拉(主页/iframe,Chrome F12 式)----
715
+ let frameTreeRows = []; // 树形展开后的行(每行一个 frame,含 depth/pageId/标题/域名)
716
+ let selectedFrame = null; // 当前选中帧 { pageId, frameId, title, url }(缺省=主页)
717
+ let bootFrameRows = []; // 宿主 boot 帧树(launcher 直报;桥未展开时本地渲染用)
718
+ let collapsedFrames = new Set(); // 已折叠的父帧(默认全展开)
719
+ let pendingFrames = null; // 宿主帧树上报到桥未确认 { pageId, frames } → onSessions 轮询重试
720
+ // 短路径:取最后 2 段(如 /home/pi/workspace/pi-work → workspace/pi-work)
721
+ function shortCwd(cwd) {
722
+ const s = String(cwd || '').replace(/[\\/]+$/, '');
723
+ const parts = s.split(/[\\/]/).filter(Boolean);
724
+ return parts.slice(-2).join('/') || s || '';
725
+ }
726
+ function agentCwdOf(a) {
727
+ if (a && a.cwd) return String(a.cwd);
728
+ // 旧格式(无 cwd 字段):name = 'pi · /path/to/cwd'
729
+ const n = (a && a.name) ? String(a.name) : '';
730
+ const m = n.match(/^([^·]+)·\s*(\S.*)$/);
731
+ if (m && (/^[\\/]/.test(m[2]) || m[2].includes('/') || m[2].includes('\\'))) return m[2];
732
+ return '';
733
+ }
734
+ function agentNameOf(a) {
735
+ const n = (a && a.name) ? String(a.name) : '';
736
+ if (!n) return (a && a.agentId) || '?';
737
+ const cwd = (a && a.cwd) ? String(a.cwd) : '';
738
+ if (cwd && n.endsWith(cwd)) { // 'pi · /path'(cwd 单独传)→ 剥掉 cwd 前缀
739
+ const pre = n.slice(0, n.length - cwd.length).replace(/[·|]\s*$/g, '').trim();
740
+ if (pre) return pre;
741
+ }
742
+ // 旧格式:'pi · /path'(无 cwd 字段)→ 名字取 'pi'
743
+ const m = n.match(/^([^·]+)·\s*(\S.*)$/);
744
+ if (m && (/^[\\/]/.test(m[2]) || m[2].includes('/') || m[2].includes('\\'))) return m[1].trim();
745
+ return n;
746
+ }
747
+ // 统一显示:CWD . name (id)
748
+ function agentLabel(a) {
749
+ const cwd = agentCwdOf(a);
750
+ const name = agentNameOf(a);
751
+ const id = (a && a.sessionId) ? shortSession(a.sessionId) : '';
752
+ const base = (cwd ? shortCwd(cwd) + ' . ' : '') + name;
753
+ return id ? base + ' (' + id + ')' : base;
754
+ }
755
+ function timeAgo(t) {
756
+ if (!t) return '';
757
+ const s = Math.max(1, Math.round((Date.now() - t) / 1000));
758
+ if (s < 60) return s + '秒前';
759
+ const m = Math.floor(s / 60);
760
+ if (m < 60) return m + '分钟前';
761
+ const h = Math.floor(m / 60);
762
+ return h < 24 ? h + '小时前' : Math.floor(h / 24) + '天前';
763
+ }
764
+ function shortSession(id) { return id ? String(id).slice(0, 8) : ''; }
765
+
766
+ // ---- AI 窗元信息:title + favicon 随 agent session(宿主 launcher 标题栏跟随;独立窗口标签直接体现) ----
767
+ // agent 图标映射(agentId → 品牌色 + 字标;未知 → 默认 cockpit 蓝 a)
768
+ const AGENT_ICONS = {
769
+ pi: { color: '#10b981', glyph: 'pi' },
770
+ cc: { color: '#d97757', glyph: 'CC' },
771
+ claude: { color: '#d97757', glyph: 'CC' },
772
+ qoder: { color: '#4f46e5', glyph: 'Q' },
773
+ cursor: { color: '#8b5cf6', glyph: 'C' },
774
+ github: { color: '#24292f', glyph: 'GH' },
775
+ };
776
+ function makeFavicon(icon) {
777
+ const c = document.createElement('canvas'); c.width = 16; c.height = 16;
778
+ const g = c.getContext('2d');
779
+ g.fillStyle = icon.color; g.fillRect(0, 0, 16, 16);
780
+ g.fillStyle = '#fff'; g.font = 'bold 8px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
781
+ g.fillText(icon.glyph, 8, 9);
782
+ return c.toDataURL('image/png');
783
+ }
784
+ function applyWindowMeta(key) {
785
+ const isAll = key === '__all__' || !key;
786
+ const a = lastAgents.find((x) => x.agentKey === key);
787
+ const agentId = a ? a.agentId : (isAll ? '' : String(key).split('/')[0]);
788
+ const label = isAll ? '' : (a ? agentLabel(a) : key);
789
+ const icon = AGENT_ICONS[agentId] || { color: '#4f46e5', glyph: (agentId || 'a')[0].toUpperCase() };
790
+ const title = isAll ? 'aiti cockpit' : label + ' - aiti cockpit';
791
+ document.title = title;
792
+ BASE_TITLE = title; // bumpTitle/focus 恢复当前 agent session title
793
+ let link = document.querySelector('link[rel="icon"]');
794
+ if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
795
+ link.href = makeFavicon(icon);
796
+ // 上报宿主(iframe 内嵌 / window.open 的 opener)——title + agent 图标,宿主标题栏跟随
797
+ const payload = { title, icon: link.href };
798
+ try { if (window.parent && window.parent !== window) window.parent.postMessage({ __dbg: 1, type: 'dbg_ai_title', payload }, '*'); } catch (e) {}
799
+ try { if (window.opener) window.opener.postMessage({ __dbg: 1, type: 'dbg_ai_title', payload }, '*'); } catch (e) {}
800
+ }
801
+
802
+ const aiwin = window.AitiAiWin.create({
803
+ base,
804
+ render: {
805
+ onStatus(text, color) { const el = $('status');
806
+ el.textContent = text;
807
+ el.className = 'status ' + (color === '#4ade80' ? 'ok' : color === '#f87171' ? 'bad' : '');
808
+ },
809
+ // 页面连接 → 帧树下拉(Chrome F12 式:主页/iframe 树形)+ 顶栏状态(默认选中 主页)
810
+ onSessions(list) {
811
+ const active = (list || []).filter((x) => x.active);
812
+ const hostRow = (list || []).find((x) => x.fromHost);
813
+ const st = $('status');
814
+ pageSessions = active;
815
+ hostPage = hostRow || null;
816
+ rebuildFrameTree(active);
817
+ if (pendingFrames) postFramesToBridge(); // 帧上报未确认 → 轮询重试(ai/page 注册竞态自愈)
818
+ if (st && !st.classList.contains('bad')) updateStatusLabel();
819
+ computeGuide(); // 页面连接变化 → 引导重算(无页面时提示注入/重连)
820
+ },
821
+ // 宿主帧树(launcher boot 携带:host+fN+嵌套)→ 上报桥(viaAI 页 sessions 展开)+ 本地即时渲染
822
+ onFrames(frames) {
823
+ const list = frames || [];
824
+ const t = aiwin.getHostTarget ? aiwin.getHostTarget() : null;
825
+ const basePageId = (t && t.pageId) || '';
826
+ // 上报桥:/bridge/ai/frames 让 sessions 每帧一行(与 register 页同构;桥展开后可定向 exec/携带 pageId)
827
+ // 未确认成功(ai/page 注册竞态/桥未就绪)→ 挂起,onSessions 轮询时重试
828
+ if (basePageId && list.length) {
829
+ pendingFrames = { pageId: basePageId, frames: list };
830
+ postFramesToBridge();
831
+ }
832
+ if (basePageId) { bootFrameRows = list.map((f) => frameRowFromBoot(basePageId, f)); }
833
+ else bootFrameRows = [];
834
+ rebuildFrameTree(pageSessions);
835
+ const st = $('status');
836
+ if (st && !st.classList.contains('bad')) updateStatusLabel();
837
+ },
838
+ // agent 会话 → 🤖 下拉(发送目标单入口:群发 + 在线/最近 + 📜 历史;显示 CWD . name (id))
839
+ // 分组:在线/最近(main)在前,📜 历史(掉线很久 > 10min,按掉线时间倒序)在后;
840
+ // 桥端对掉线 > 1h 的 agent 定期清理(消息历史 JSONL 保留)——历史段随之收敛
841
+ onAgents(list) {
842
+ lastAgents = list;
843
+ // 刷新恢复:URL hash 带的目标且已在列表 → 切过去(仅一次;agent 不在了回到全部,清 hash)
844
+ if (pendingHashTarget !== null) {
845
+ var want = pendingHashTarget;
846
+ pendingHashTarget = null;
847
+ if (want && want !== '__all__' && list.some((a) => a.agentKey === want)) {
848
+ if (curTarget !== want) setAgentTarget(want);
849
+ } else {
850
+ syncTargetHash('__all__');
851
+ }
852
+ }
853
+ $('agentCount').textContent = list.length ? '(' + list.length + ')' : '';
854
+ const WARM_TTL = 10 * 60 * 1000;
855
+ const now = Date.now();
856
+ const tierOf = (a) => {
857
+ if (a.online) return 'main';
858
+ return (now - (a.offlineAt || a.lastSeen || 0)) < WARM_TTL ? 'main' : 'hist';
859
+ };
860
+ const mainList = list.filter((a) => tierOf(a) === 'main').sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
861
+ const histList = list.filter((a) => tierOf(a) === 'hist').sort((x, y) => ((y.offlineAt || y.lastSeen) || 0) - ((x.offlineAt || x.lastSeen) || 0));
862
+ // 🤖 popover:群发行固定第一行 + 「在线/最近」/「📜 历史(掉线)」两段(合并原 select——发送目标单入口)
863
+ const el = $('agentsList');
864
+ if (!list.length) { el.innerHTML = '<div class="empty">agent 连接注册后显示</div>'; computeGuide(); return; }
865
+ el.innerHTML = '';
866
+ const allRow = document.createElement('div');
867
+ allRow.className = 'row' + (curTarget === '__all__' ? ' sel' : '');
868
+ allRow.dataset.key = '__all__';
869
+ allRow.title = '发送给所有已注册 agent';
870
+ allRow.innerHTML = '<span class="pid">🌐</span><span class="txt">全部 agent(群发)</span><span class="badge dim">' + list.length + ' 个</span>';
871
+ allRow.onclick = () => { setAgentTarget('__all__'); };
872
+ el.appendChild(allRow);
873
+ const addRow = (a, sec) => {
874
+ const row = document.createElement('div');
875
+ row.className = 'row' + (curTarget === a.agentKey ? ' sel' : '');
876
+ row.dataset.key = a.agentKey;
877
+ row.title = '点击设为发送目标(' + agentLabel(a) + ')' + (a.registered ? '' : ';⚠未注册——pi 会话启动后自动注册(aiti-devtools-pi 扩展);若长时间未注册,请检查扩展是否加载');
878
+ const cls = a.online ? 'ok' : 'dim';
879
+ const badge = a.online ? '● 在线' : (sec === 'hist' ? '○ 掉线 ' + timeAgo(a.offlineAt || a.lastSeen) : '○ 最近活动');
880
+ row.innerHTML = `<span class="pid">${esc(a.agentId)}/${esc(a.sessionId)}</span>` +
881
+ `<span class="txt">${a.registered ? '' : '⚠未注册'}${a.inboxPending ? ' 📥' + a.inboxPending : ''} · ${esc(agentLabel(a))}</span>` +
882
+ `<span class="badge ${cls}">${esc(badge)}</span>`;
883
+ row.onclick = () => { setAgentTarget(a.agentKey); };
884
+ el.appendChild(row);
885
+ };
886
+ const addSec = (title, n) => {
887
+ const s = document.createElement('div');
888
+ s.className = 'sec';
889
+ s.innerHTML = title + ' <span class="cnt">' + n + '</span>';
890
+ el.appendChild(s);
891
+ };
892
+ if (mainList.length) { addSec('🤖 在线/最近', mainList.length); mainList.forEach((a) => addRow(a, 'main')); }
893
+ if (histList.length) { addSec('📜 历史(掉线)', histList.length); histList.forEach((a) => addRow(a, 'hist')); }
894
+ if (!mainList.length && !histList.length) el.innerHTML = '<div class="empty">agent 连接注册后显示</div>';
895
+ computeGuide(); // 状态驱动引导
896
+ },
897
+ // 消息列表:增量渲染(时间线插入 + 滚动保持;isFull=重建)
898
+ onMessages(rows, mode, key, isFull) {
899
+ if (!isFull && rows && rows.length) bumpTitle(); // 增量新消息 → title 提醒(失焦时)
900
+ const list = $('msgList');
901
+ if (isFull) {
902
+ renderedMsgIds = new Set();
903
+ const hostRows = [...list.querySelectorAll('.msg.host')].map((r) => r.outerHTML).join('');
904
+ list.innerHTML = hostRows || '';
905
+ }
906
+ if (!rows || !rows.length) {
907
+ if (isFull && !list.querySelector('.msg')) list.innerHTML = '<div class="empty">暂无消息</div>';
908
+ computeGuide(); // 消息为空也重算引导(无 agent → Welcome)
909
+ return;
910
+ }
911
+ const empty = list.querySelector('.empty');
912
+ if (empty) empty.remove();
913
+ const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 120;
914
+ let added = 0;
915
+ const showKey = mode === 'all';
916
+ for (const m of rows) {
917
+ if (renderedMsgIds.has(m.id)) continue;
918
+ renderedMsgIds.add(m.id);
919
+ insertMsgRow(list, m, showKey);
920
+ added++;
921
+ }
922
+ if (added && nearBottom) list.scrollTop = list.scrollHeight;
923
+ // 重建/会话切换(isFull)后滚到底部(首次加载看最新;增量新消息则按 nearBottom 跟随,滚上去看历史不打扰)
924
+ if (isFull && list.querySelector('.msg')) list.scrollTop = list.scrollHeight;
925
+ // 全部 agent 模式 = 各会话消息合并(见 🤖 下拉)
926
+ computeGuide(); // 状态驱动引导
927
+ },
928
+ onTarget(key) {
929
+ // 发送目标:🤖 下拉行高亮(合并自 select——单一入口)
930
+ curTarget = key;
931
+ const ddList = $('agentsList');
932
+ if (ddList) [...ddList.querySelectorAll('.row')].forEach((r) => r.classList.toggle('sel', r.dataset.key === String(key)));
933
+ applyWindowMeta(key); // AI 窗 title/favicon 随 agent session(+ 上报宿主标题栏)
934
+ },
935
+ onInteraction(m) { renderInteraction(m); },
936
+ // 结果回传(他端作答 / 服务器超时)→ 交互卡完成 → 卡片区移除 + 消息流记录(幂等:本地已处理则卡片已删)
937
+ onResult(m) {
938
+ if (!m || !m.requestId) return;
939
+ finishInteractionCard(m.requestId, m.result || {});
940
+ },
941
+ onHostMsg(kind, text, meta) { addMsgRow(kind, text, meta); },
942
+ // 宿主页面事件(page_log/page_network/open_file…)→ 桥:AI 窗是桥唯一浏览器端点(debugTarget.pageId 由 boot 提供)
943
+ onBridgeEvent(type, payload, target) {
944
+ try { if (window.BridgeClient && window.BridgeClient.isConnected && window.BridgeClient.isConnected()) window.BridgeClient.postEvent(type, payload, (target && target.pageId) || null); } catch (e) {}
945
+ },
946
+ // 复制转发协议(dbg_copy):iframe/子窗复制失败 → 本宿主执行复制(core copyText)
947
+ onCopy(p) {
948
+ try { if (p && p.text) cockpitCopyText(p.text); } catch (e) {}
949
+ },
950
+ // 前端打开转发(dbg_ai_open_file):launcher 预览窗「🖥 前端」→ 宿主打开文件(有 pi-web 宿主则 aiti_open_file)
951
+ onAiOpenFile(p) {
952
+ try { if (p && p.file) cockpitOpenFrontend(p.file); } catch (e) {}
953
+ },
954
+ // 新消息到达(增量推送)→ 窗口 title 提醒
955
+ // 消息状态实时更新(pending → delivered → consumed,无需重新拉取)
956
+ onMsgStatus(msgId, status, agentKey) {
957
+ if (!msgId) return;
958
+ const row = [...document.querySelectorAll('.msg')].find((r) => r.dataset.mid === msgId);
959
+ if (!row) return;
960
+ const st = row.querySelector('.st');
961
+ if (st) { st.textContent = status || ''; st.className = 'st' + (status === 'consumed' ? ' cons' : ''); }
962
+ },
963
+ // 候选占位符 → 输入框([元素N] 发送时替换;inspect 结果已作为宿主消息进消息流)
964
+ onCandidate(el) { appendToInput(el.tag || ''); },
965
+ // 待选项 → 输入框上方的候选区
966
+ onPicked(list) { renderPickedBar(list); },
967
+ },
968
+ });
969
+
970
+ // ---- 待选项(输入框上方) ----
971
+ function renderPickedBar(list) {
972
+ const bar = $('pickedBar');
973
+ if (!list || !list.length) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
974
+ const hp0 = aiwin.getHost();
975
+ bar.style.display = 'flex';
976
+ bar.innerHTML = '<span class="lb">📎 待选项(发送时替换为完整描述)</span>' + list.map((el, i) => {
977
+ const brief = hp0 && hp0.brief ? hp0.brief(el) : (el.kind === 'error' ? '⚠ ' + String(el.message || '').slice(0, 18) : (el.answer !== undefined ? String(el.answer).slice(0, 18) : '<' + (el.tag || '?') + '>'));
978
+ return `<span class="picked-chip ${el.kind === 'error' ? 'err' : ''}" data-i="${i}" title="点击:输入框补占位符 + 查看详情">${esc(el.tag || '')} ${esc(brief)}</span>`;
979
+ }).join('') + '<button class="btn ghost" style="margin-left:auto;color:var(--red)">清空</button>';
980
+ bar.querySelectorAll('.picked-chip').forEach((chip) => {
981
+ chip.onclick = () => {
982
+ const el = list[+chip.dataset.i];
983
+ if (!el) return;
984
+ const inp = $('pushText');
985
+ if (!inp.value.includes(el.tag)) appendToInput(el.tag);
986
+ showElDetail(el); // 详情弹窗:detailRows 渲染 + hover 复制 + 文件打开(替换原 describe 文本消息)
987
+ };
988
+ });
989
+ const clear = bar.querySelector('button');
990
+ if (clear) clear.onclick = () => { list.forEach((el) => { const hp = aiwin.getHost(); if (hp) hp.removeCandidate(el); }); };
991
+ }
992
+
993
+ // ---- 元素候选详情弹窗(detailRows 渲染 + hover 复制 + 文件 后台/前端 打开;逻辑与 aiti-client 详情一致,经 core host-protocol) ----
994
+ let elDetailFileChecks = {}; // { file: {exists,isFile} | 'checking' }
995
+ function cockpitCopyText(text) {
996
+ const hp = aiwin.getHost();
997
+ return (hp && hp.copyText ? hp.copyText(text) : Promise.resolve(false)).then((ok) => {
998
+ if (!ok) { try { const h = aiwin.getHost(); if (h && h.postToHost) h.postToHost('dbg_copy', { text }); } catch (e) {} toast('📋 已转发宿主复制'); return false; }
999
+ toast('📋 已复制'); return true;
1000
+ });
1001
+ }
1002
+ function cockpitOpenBackend(file, line) {
1003
+ try {
1004
+ if (window.BridgeClient && window.BridgeClient.isConnected && window.BridgeClient.isConnected()) window.BridgeClient.postEvent('open_file', { file, line }, null);
1005
+ else toast('⚠ 桥未连接——后台打开失败');
1006
+ toast('📂 已请求后台打开(编辑器)');
1007
+ } catch (e) {}
1008
+ }
1009
+ function cockpitOpenFrontend(file) {
1010
+ // 前端打开:有 pi-web 宿主(opener/parent)→ aiti_open_file(宿主 AppShell 开文件标签页);独立桥无宿主 → 提示
1011
+ try {
1012
+ const host = (window.opener && !window.opener.closed) ? window.opener : (window.parent !== window ? window.parent : null);
1013
+ if (host) { host.postMessage({ __dbg: 1, type: 'aiti_open_file', payload: { filePath: file } }, '*'); toast('🖥 已请求前端打开'); }
1014
+ else toast('⚠ 独立桥无前端打开(pi-web 文件标签页)——请用 📂 后端打开');
1015
+ } catch (e) { toast('⚠ 前端打开不可用——请用 📂 后端打开'); }
1016
+ }
1017
+ function closeElDetail() { $('elDetailModal').classList.remove('show'); }
1018
+ function showElDetail(el) {
1019
+ const hp = aiwin.getHost();
1020
+ const rows = hp && hp.detailRows ? hp.detailRows(el) : [];
1021
+ const body = $('elDetailBody');
1022
+ body.innerHTML = '';
1023
+ const renderOps = (opsEl, file, line) => {
1024
+ opsEl.innerHTML = '<button class="btn mini" data-oa="backend" data-file="' + escAttr(file) + '" data-line="' + escAttr(line || '') + '">📂 后台</button>'
1025
+ + '<button class="btn mini" data-oa="frontend" data-file="' + escAttr(file) + '" data-line="' + escAttr(line || '') + '">🖥 前端</button>';
1026
+ opsEl.querySelectorAll('button').forEach((b) => { b.onclick = (e) => { e.stopPropagation(); if (b.getAttribute('data-oa') === 'backend') cockpitOpenBackend(b.getAttribute('data-file'), parseInt(b.getAttribute('data-line'), 10) || null); else cockpitOpenFrontend(b.getAttribute('data-file')); }; });
1027
+ };
1028
+ rows.forEach(([k, v]) => {
1029
+ const isFile = k === '组件源码' || k === '调用处';
1030
+ const f = el.framework || {}; const ua = f.usedAt || el.usedAt;
1031
+ const file = k === '组件源码' ? (f.file || el.file) : ((ua && ua.file) || null);
1032
+ const line = k === '组件源码' ? (f.line || el.line) : ((ua && ua.line) || null);
1033
+ const row = document.createElement('div');
1034
+ row.className = 'el-detail-row';
1035
+ row.innerHTML = '<span class="el-detail-label">' + esc(k) + '</span>'
1036
+ + '<code class="el-detail-val">' + esc(v) + '</code>'
1037
+ + '<button class="el-detail-copy" data-copy="' + escAttr(v) + '" title="复制此内容">📋</button>';
1038
+ row.querySelector('.el-detail-copy').onclick = (e) => { e.stopPropagation(); cockpitCopyText(e.currentTarget.getAttribute('data-copy') || ''); };
1039
+ if (isFile && file) {
1040
+ const ops = document.createElement('span');
1041
+ ops.className = 'el-detail-ops';
1042
+ row.appendChild(ops);
1043
+ const st = elDetailFileChecks[file];
1044
+ if (st === 'checking') ops.innerHTML = '<span class="el-detail-chk">…</span>';
1045
+ else if (st && st.exists && st.isFile) renderOps(ops, file, line);
1046
+ else if (st && !st.exists) ops.innerHTML = '<span class="el-detail-missing">(文件不存在)</span>';
1047
+ else {
1048
+ elDetailFileChecks[file] = 'checking';
1049
+ (hp && hp.checkFileStat ? hp.checkFileStat(file, '') : Promise.resolve(null)).then((d) => {
1050
+ elDetailFileChecks[file] = d || { exists: false };
1051
+ const ops2 = row.querySelector('.el-detail-ops');
1052
+ if (ops2) {
1053
+ if (d && d.exists && d.isFile) renderOps(ops2, file, line);
1054
+ else if (d && !d.exists) ops2.innerHTML = '<span class="el-detail-missing">(文件不存在)</span>';
1055
+ else renderOps(ops2, file, line); // 端点不可用(独立桥)→ 乐观显示按钮,后端打开编辑器自校验
1056
+ }
1057
+ });
1058
+ }
1059
+ }
1060
+ body.appendChild(row);
1061
+ });
1062
+ if (!rows.length) body.innerHTML = '<div class="empty">无详情(候选可能已清除)</div>';
1063
+ $('elDetailModal').classList.add('show');
1064
+ }
1065
+
1066
+ // ================= 引导系统(状态驱动:agents/messages → 提示/引导卡) =================
1067
+ let lastGuideState = '';
1068
+ function computeGuide() {
1069
+ const agents = aiwin.getAgents();
1070
+ const el = $('guide');
1071
+ if (!agents.length) {
1072
+ // 无 agent → Welcome 引导卡
1073
+ if (lastGuideState !== 'welcome') { lastGuideState = 'welcome'; el.innerHTML = welcomeCard(); }
1074
+ return;
1075
+ }
1076
+ const unreg = agents.filter((a) => !a.registered);
1077
+ const pendingCount = agents.reduce((s, a) => s + (a.inboxPending || 0), 0);
1078
+ const anyOnline = agents.some((a) => a.online);
1079
+ // 页面连接:无已连接页面 → 提示(有宿主页:正常状态——AI 窗已连宿主并拿到页面身份,不弹警告;无宿主页:需注入)
1080
+ const noPage = !(pageSessions || []).length;
1081
+ const hostOnly = noPage && hostPage;
1082
+ // 条件提示条(优先级:真无页面 > 未注册 > 待处理 > 全部正常隐藏)
1083
+ let tip = '';
1084
+ if (noPage && !hostOnly) {
1085
+ tip = `<div class="guide-tip amber">🖥 <b>当前无已连接页面</b>——在目标页面执行注入代码(或刷新已注入页面),launcher 自动连接(pageId 保持复用)。` +
1086
+ `<button class="btn right" onclick="window.open('https://aiti.pw/devtools/quick-start.html','_blank')">📖 注入指南(注入代码 + aiti-devtools-vite)</button></div>`;
1087
+ } else if (unreg.length) {
1088
+ // pi 扩展自动注册(session_start 调 aiti_hello)——无需注册引导;其他 agent 需「注册 + 持续监听 aiti_inbox」
1089
+ // pi 未注册不弹提示条(信息在 🤖 下拉对应行:⚠未注册 + title 说明);仅其他 agent 未接入时弹引导
1090
+ const others = unreg.filter((a) => a.agentId !== 'pi');
1091
+ if (others.length) {
1092
+ const a = others[0];
1093
+ tip = `<div class="guide-tip amber">⚠ 有 agent 未接入(${esc(a.agentId)}/${esc(a.sessionId)})——接入需<b>注册(aiti_hello)</b>并<b>持续监听 aiti_inbox</b>拉取消息 ` +
1094
+ `<button class="btn" onclick="copyRegSuggestion('${esc(a.agentId)}','${esc(a.sessionId)}')">📋 复制接入说明</button></div>`;
1095
+ }
1096
+ } else if (pendingCount > 0) {
1097
+ if (!anyOnline) {
1098
+ // agent 不在线:发消息/提醒它都收不到(pending 保留)——引导接入(注册 + 监听 inbox),不提供无效提醒
1099
+ tip = `<div class="guide-tip amber" style="display:block">📥 ${pendingCount} 条消息待处理——<b>agent 当前不在线(🤖 ○),现在发消息它收不到</b>,消息会保留直到 agent 上线监听拉取。<br>` +
1100
+ `· pi:启动 pi 会话(aiti-devtools-pi 扩展自动注册连接)<br>` +
1101
+ `· 其他:注册(aiti_hello)+ 持续监听 aiti_inbox<br>` +
1102
+ `· ⭐ 建议尝试 <b>aiti-agent</b>(<a href="https://aiti.pw" target="_blank" rel="noopener">aiti.pw ↗</a>)——带 Web 界面的 AI 助手,设置地址即接管` +
1103
+ `<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">` +
1104
+ `<button class="btn" onclick="copyInboxGuide()">📋 复制注册与监听提示词</button>` +
1105
+ `<button class="btn right" onclick="window.open('https://aiti.pw/guide/mcp','_blank')">📖 接入指南</button></div></div>`;
1106
+ } else {
1107
+ // agent 在线但未处理:可发提醒(它能收到)+ 引导监听 inbox
1108
+ tip = `<div class="guide-tip amber" style="display:block">📥 ${pendingCount} 条消息待 agent 处理——agent 需<b>持续监听 aiti_inbox</b>(拉取 + ack)才能收到` +
1109
+ `<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">` +
1110
+ `<button class="btn" onclick="copyInboxGuide()">📋 复制注册与监听提示词</button>` +
1111
+ `<button class="btn" onclick="pushReminder()">📣 发送提醒</button>` +
1112
+ `<button class="btn right" onclick="window.open('https://aiti.pw/guide/mcp','_blank')">📖 接入指南</button></div></div>`;
1113
+ }
1114
+ }
1115
+ el.innerHTML = tip;
1116
+ lastGuideState = tip ? 'tip' : 'ok';
1117
+ }
1118
+ function welcomeCard() {
1119
+ return `<div class="guide-card">
1120
+ <h2>⭐ 接入 AI 助手</h2>
1121
+ <div class="sub">还没有 agent 接入——推荐用 aiti-agent(第一推荐),或接入其他 agent 协作(详情 <a href="https://aiti.pw/guide/mcp" target="_blank" rel="noopener">aiti.pw/guide/mcp ↗</a>)</div>
1122
+ <div class="step"><span class="no">1</span><div><b>aiti-agent(第一推荐)</b>——访问 <a href="https://aiti.pw" target="_blank" rel="noopener">aiti.pw</a> 下载安装;装好后把它的地址设置进来,AI 窗将直接由它接管:
1123
+ <div class="guide-actions">
1124
+ <button class="btn" onclick="window.open('https://aiti.pw','_blank')">🔗 打开 aiti.pw</button>
1125
+ <button class="btn primary" onclick="setupAiAgent()">⚙ 设置 aiti-agent 地址</button>
1126
+ </div>
1127
+ <span class="sub">设置后自动与宿主协商重开 AI 窗,10 秒内协议探测,连接失败自动回退本页。</span></div></div>
1128
+ <div class="step"><span class="no">2</span><div><b>pi 扩展(备选)</b>——安装 aiti-devtools-pi 扩展,启动 pi 会话即自动注册连接(本窗口右上角 🤖 出现 agent)。</div></div>
1129
+ <div class="step"><span class="no">3</span><div><b>其他 agent</b>(Claude Code / Cursor / Qoder)——mcp.json 指向服务地址,agent 调 <b>aiti_inbox</b> 收消息:
1130
+ <div class="guide-actions">
1131
+ <button class="btn" onclick="copyText('mcpCfg')">📋 复制 mcp.json</button>
1132
+ <button class="btn" onclick="window.open('https://aiti.pw/guide/mcp','_blank')">📖 完整指南</button>
1133
+ </div>
1134
+ </div></div>
1135
+ <div class="step"><span class="no">4</span><div><b>开始协作</b>——下方输入框发消息 → agent 处理 → 回答显示在消息列表。<br>
1136
+ <span class="sub">(页面注入/其他设置见 ⚙ 设置——你已在用本窗口,页面通常已接入)</span></div></div>
1137
+ </div>`;
1138
+ }
1139
+ function copyRegSuggestion(aid, sid) {
1140
+ const txt = `1) 调 aiti_hello 传 agentId="${aid}" sessionId="${sid}" name="..." 注册会话;2) 持续调 aiti_inbox 拉取消息(消息将定向到该会话)`;
1141
+ navigator.clipboard.writeText(txt)
1142
+ .then(() => toast('已复制接入说明'), () => showCopyFallback(txt));
1143
+ }
1144
+ function copyInboxGuide() {
1145
+ const txt = '请持续监听 aiti-devtools 消息:1) 调 aiti_hello 注册(agentId/sessionId/name);2) 每轮/定时调 aiti_inbox 拉取消息(处理完 ack 确认)——消息将定向到本会话。详见 https://aiti.pw/guide/mcp';
1146
+ navigator.clipboard.writeText(txt)
1147
+ .then(() => toast('已复制注册与监听提示词'), () => showCopyFallback(txt));
1148
+ }
1149
+ function pushReminder() {
1150
+ const text = '【aiti 提醒】你有未处理的消息,请调 aiti_inbox 查看并处理。';
1151
+ aiwin.push(text, (b) => toast(b && b.ok ? '提醒已发送' : ('发送失败' + (b ? ':' + b.error : ''))));
1152
+ }
1153
+
1154
+ // ---- 工具调用详情弹窗(agent_tool 记录带请求/返回;默认折叠,点 🔍 弹出) ----
1155
+ function showDetail(detailRaw) {
1156
+ let out = detailRaw;
1157
+ if (typeof detailRaw === 'string') {
1158
+ try { out = JSON.stringify(JSON.parse(detailRaw), null, 2); } catch (e) { /* 原样 */ }
1159
+ } else {
1160
+ try { out = JSON.stringify(detailRaw, null, 2); } catch (e) { out = String(detailRaw); }
1161
+ }
1162
+ $('detailBody').textContent = out;
1163
+ $('detailModal').classList.add('show');
1164
+ }
1165
+ function closeDetail() { $('detailModal').classList.remove('show'); }
1166
+ $('detailModal').onclick = (e) => { if (e.target === $('detailModal')) closeDetail(); };
1167
+ // 工具详情按钮事件委托(data-detail 传值,避免行内 onclick 转义问题)
1168
+ document.addEventListener('click', (e) => {
1169
+ const b = e.target.closest('.tool-detail');
1170
+ if (b && b.dataset.detail) showDetail(b.dataset.detail);
1171
+ });
1172
+
1173
+ // ---- 时间线插入(按 at 排序,新消息插到正确位置) ----
1174
+ function agentMetaOf(m) {
1175
+ // agent 可读标识:图标(品牌字标)+ name(agent/session 短名);未知回退 agentKey
1176
+ const a = lastAgents.find((x) => x.agentKey === m.agentKey);
1177
+ if (!a) return { icon: '🤖', name: m.agentKey || 'agent' };
1178
+ const ic = AGENT_ICONS[a.agentId];
1179
+ return { icon: ic ? ic.glyph : '🤖', color: ic ? ic.color : '', name: agentLabel(a) };
1180
+ }
1181
+ function insertMsgRow(list, m, showKey) {
1182
+ const fromMe = m.from === 'window';
1183
+ const fromAgent = m.from === 'agent';
1184
+ const sys = m.kind === 'page_error' || m.kind === 'page_log' || m.kind === 'page_network';
1185
+ const am = agentMetaOf(m);
1186
+ const row = document.createElement('div');
1187
+ row.className = 'msg ' + (fromMe ? 'from-me' : fromAgent ? 'from-agent' : sys ? 'sys' : '');
1188
+ row.dataset.at = m.at || '';
1189
+ row.dataset.mid = m.id || '';
1190
+ // 格式:我发 → "→ 图标 名";agent 回 → "图标 名 →";sys 保留 tag(user_message 等普通 tag 不显示)
1191
+ // 点“人”(agent 标识)→ 顶栏发送目标切到该 agent(相同则不重复切换);不选中消息
1192
+ const whoClk = `onclick="event.stopPropagation();selectAgentSession('${esc(m.agentKey || '')}')"`;
1193
+ const who = `<span class="who-icon" style="color:${am.color || 'inherit'}">${am.icon}</span> <span>${esc(am.name)}</span>`;
1194
+ const meta = fromMe ? `<span class="who" ${whoClk} title="点击:发送目标切换到该 agent">→ ${who}</span>`
1195
+ : fromAgent ? `<span class="who" ${whoClk} title="点击:发送目标切换到该 agent">${who} →</span>`
1196
+ : `<span class="tag">${esc(m.kind || '?')}</span>`;
1197
+ // 点击消息查看完整详情(发送/接收一致):带 detail 用原始 detail;否则组装结构化字段(text/payload/source/pageId/url/title/status…)
1198
+ // ——发送的消息也能点开看“cockpit 究竟发了什么”(与接收消息同一 showDetail 弹窗)
1199
+ if (m.detail || m.payload || m.from === 'window') {
1200
+ row.style.cursor = 'pointer';
1201
+ const p = m.payload || {};
1202
+ // 我发的消息:优先显示「实际传输的原始 body」(含 ref;无桥追加的 [发自:] 标注,可核对 source/sourceLabel/pageId/url/title 到底带了什么)
1203
+ const sentBody = (fromMe && m.ref && sentDetail[m.ref]) || null;
1204
+ row.title = sentBody ? '点击:查看发送消息完整详情(实际传输 payload)' : '点击:查看消息详情';
1205
+ const detail = (sentBody && JSON.stringify(sentBody, null, 2)) || m.detail || JSON.stringify({
1206
+ id: m.id, kind: m.kind, from: m.from, at: m.at, status: m.status,
1207
+ agentKey: m.agentKey, text: m.text,
1208
+ payload: p,
1209
+ source: m.source ?? p.source ?? null,
1210
+ sourceLabel: m.sourceLabel ?? p.sourceLabel ?? null,
1211
+ pageId: m.pageId ?? p.pageId ?? null,
1212
+ url: m.url ?? p.url ?? null,
1213
+ title: m.title ?? p.title ?? null,
1214
+ }, null, 2);
1215
+ row.onclick = () => showDetail(detail);
1216
+ }
1217
+ row.innerHTML = `<div class="meta">${meta}` +
1218
+ `<span>${(m.at || '').slice(11, 19)}</span>` +
1219
+ `<span class="st ${m.status === 'consumed' ? 'cons' : ''}">${esc(m.status || '')}</span></div>` +
1220
+ `<div class="bubble">${esc((m.text || '').slice(0, 500))}</div>`;
1221
+ const at = m.at || '';
1222
+ const nodes = [...list.querySelectorAll('.msg:not(.host)')];
1223
+ let inserted = false;
1224
+ for (const n of nodes) {
1225
+ if (String(n.dataset.at || '') > at) { list.insertBefore(row, n); inserted = true; break; }
1226
+ }
1227
+ if (!inserted) list.appendChild(row);
1228
+ }
1229
+ // 切换发送目标(🤖 下拉行 / 消息行 who 点击 → aiwin.setTarget → onTarget 高亮):相同 key 不重复切换
1230
+ // 目标同步到 URL hash(#agent=pi/sess-x):刷新自动回到该目标(agent 未拉到时等 onAgents 恢复)
1231
+ function syncTargetHash(key) {
1232
+ try {
1233
+ var h = (key && key !== '__all__') ? '#agent=' + encodeURIComponent(key) : '';
1234
+ var cur = location.hash || '';
1235
+ if (cur === h) return;
1236
+ history.replaceState(null, '', location.pathname + location.search + h);
1237
+ } catch (e) {}
1238
+ }
1239
+ var pendingHashTarget = null; // 刷新恢复:agents 首次拉到后再切(避免 key 未在列表被拒)
1240
+ try {
1241
+ var mh = /(?:^|#|&)agent=([^&]+)/.exec(location.hash || '');
1242
+ if (mh) pendingHashTarget = decodeURIComponent(mh[1]);
1243
+ } catch (e) {}
1244
+ function selectAgentSession(key) {
1245
+ if (!lastAgents.some((a) => a.agentKey === key)) return;
1246
+ if (curTarget === key) return;
1247
+ setAgentTarget(key);
1248
+ }
1249
+ function setAgentTarget(key) {
1250
+ curTarget = key;
1251
+ aiwin.setTarget(key);
1252
+ $('agentsDd').classList.remove('open');
1253
+ syncTargetHash(key);
1254
+ }
1255
+
1256
+ // ---- 宿主消息(保留不被增量覆盖) ----
1257
+ function addMsgRow(kind, text, meta) {
1258
+ const list = $('msgList');
1259
+ if (list.querySelector('.empty')) list.innerHTML = '';
1260
+ const row = document.createElement('div');
1261
+ row.className = 'msg sys host';
1262
+ row.innerHTML = `<div class="meta"><span class="tag">${esc(kind)}</span>${meta ? '<span>' + esc(meta) + '</span>' : ''}</div>` +
1263
+ `<div class="bubble">${esc(String(text).slice(0, 500))}</div>`;
1264
+ list.appendChild(row);
1265
+ list.scrollTop = list.scrollHeight;
1266
+ }
1267
+ function appendToInput(text) {
1268
+ const inp = $('pushText');
1269
+ const cur = inp.value.trim();
1270
+ inp.value = (cur ? cur + ' ' : '') + text;
1271
+ inp.focus();
1272
+ // 直接改 value 不触发 input 事件——手动联动增高 + 草稿保存
1273
+ resizeComposer();
1274
+ scheduleDraftSave();
1275
+ }
1276
+
1277
+ // ---- 交互卡(可折叠):作答/超时完成 → 从卡片区移除,作为消息记录进消息流(不滞留卡片区) ----
1278
+ let cards = new Map();
1279
+ let ixCollapsed = false;
1280
+ $('ixHead').onclick = () => {
1281
+ ixCollapsed = !ixCollapsed;
1282
+ $('interactions').classList.toggle('collapsed', ixCollapsed);
1283
+ };
1284
+ function updateIx() {
1285
+ $('ixCount').textContent = cards.size ? '(' + cards.size + ')' : '';
1286
+ $('interactions').style.display = cards.size ? 'flex' : 'none'; // 无卡隐藏
1287
+ }
1288
+ // 作答/超时 → 可读回答描述(消息流记录用)
1289
+ function describeAnswer(kind, result) {
1290
+ const r = result || {};
1291
+ if (r.status === 'rejected') return '✋ 拒绝' + (r.reason ? ':' + r.reason : '');
1292
+ if (r.status === 'timeout') return '⏱ 超时(用户无响应)';
1293
+ if (r.status === 'error') return '⚠ ' + (r.error || '出错');
1294
+ if (kind === 'confirm') return r.approved === false ? '❌ 否' : '✅ 是';
1295
+ if (kind === 'choose') {
1296
+ const v = r.value || {};
1297
+ return (v.custom || v.index === -1) ? '✍️ 都不适合' : String(v.label != null ? v.label : v.index);
1298
+ }
1299
+ if (kind === 'input') { const v = r.value || {}; return String(v.text || ''); }
1300
+ if (kind === 'presence') return r.present === false ? '⏳ 稍后' : '🙋 我在';
1301
+ if (kind === 'pick_element') {
1302
+ const el = r.result || {};
1303
+ if (el.tag || el.text) return '🎯 <' + (el.tag || '?') + '>' + (el.text ? ' ' + String(el.text).trim().slice(0, 40) : '');
1304
+ return '🎯 已选元素';
1305
+ }
1306
+ return '✅ 已作答';
1307
+ }
1308
+ // 交互完成(本地作答 / 他端作答结果回传 / 服务器超时)→ 卡片区移除 + 消息流记录(幂等:已移除则跳过)
1309
+ function finishInteractionCard(rid, result) {
1310
+ const card = cards.get(rid);
1311
+ if (!card) return;
1312
+ if (card.parentNode) card.parentNode.removeChild(card);
1313
+ cards.delete(rid);
1314
+ updateIx();
1315
+ const ix = card.__ix || {};
1316
+ addMsgRow('💬 ' + (ix.kindLabel || '交互'), ((ix.prompt || '').trim() ? String(ix.prompt).trim() + '\n' : '') + '→ ' + describeAnswer(ix.kind, result), ix.agentId ? 'from ' + ix.agentId : '');
1317
+ }
1318
+ function renderInteraction(m) {
1319
+ const it = m.interaction || {};
1320
+ const rid = m.requestId;
1321
+ const isNotify = it.kind === 'notify';
1322
+ // 通知:无作答——直接进消息流(不占交互卡区域)
1323
+ if (isNotify) {
1324
+ addMsgRow('🔔 通知', (it.body || it.title || it.prompt || ''), (it.agentId || m.agentId) ? 'from ' + (it.agentId || m.agentId) : '');
1325
+ return;
1326
+ }
1327
+ const card = document.createElement('div');
1328
+ card.className = 'card';
1329
+ const kindLabel = { confirm: '❓ 确认', choose: '🔀 选择', input: '⌨️ 输入', pick_element: '🖱️ 点选', presence: '🙋 在场' }[it.kind] || it.kind || '?';
1330
+ const agentLabel = it.agentId || m.agentId || '';
1331
+ card.__ix = { kind: it.kind, kindLabel, prompt: it.prompt || '', agentId: agentLabel }; // 完成 → 消息流记录存根
1332
+ card.innerHTML = `<div class="k">${esc(kindLabel)}${agentLabel ? ' · from ' + esc(agentLabel) : ''}</div><div class="p">${esc(it.prompt || '')}</div>`;
1333
+ if (!isNotify) {
1334
+ const row = document.createElement('div');
1335
+ row.className = 'row';
1336
+ if (it.kind === 'confirm') {
1337
+ row.innerHTML = `<button class="btn primary" data-r="t">✅ 是</button><button class="btn" data-r="f">❌ 否</button><button class="btn ghost" data-c="1">都不适合</button>`;
1338
+ row.querySelector('[data-r="t"]').onclick = () => answer(rid, { status: 'answered', approved: true });
1339
+ row.querySelector('[data-r="f"]').onclick = () => answer(rid, { status: 'answered', approved: false });
1340
+ row.querySelector('[data-c="1"]').onclick = () => answer(rid, { status: 'answered', approved: null, custom: true });
1341
+ } else if (it.kind === 'choose') {
1342
+ (it.choices || []).forEach((c, i) => {
1343
+ const b = document.createElement('button');
1344
+ b.className = 'btn';
1345
+ b.textContent = c;
1346
+ b.onclick = () => answer(rid, { status: 'answered', value: { index: i, label: c } });
1347
+ row.appendChild(b);
1348
+ });
1349
+ if (it.rejectable !== false) { const r = document.createElement('button'); r.className = 'btn ghost'; r.textContent = '这些都不适合'; r.onclick = () => answer(rid, { status: 'answered', value: { index: -1, custom: true } }); row.appendChild(r); }
1350
+ } else if (it.kind === 'input') {
1351
+ const inp = document.createElement('input');
1352
+ inp.placeholder = it.defaultValue || '输入…';
1353
+ const b = document.createElement('button');
1354
+ b.className = 'btn primary';
1355
+ b.textContent = '发送';
1356
+ b.onclick = () => answer(rid, { status: 'answered', value: { text: inp.value } });
1357
+ inp.onkeydown = (e) => { if (e.key === 'Enter') b.onclick(); };
1358
+ row.appendChild(inp); row.appendChild(b);
1359
+ if (it.rejectable !== false) { const r = document.createElement('button'); r.className = 'btn ghost'; r.textContent = '暂时无法处理'; r.onclick = () => answer(rid, { status: 'rejected', reason: 'user rejected' }); row.appendChild(r); }
1360
+ } else if (it.kind === 'presence') {
1361
+ const b1 = document.createElement('button'); b1.className = 'btn primary'; b1.textContent = '🙋 我在'; b1.onclick = () => answer(rid, { status: 'answered', present: true });
1362
+ const b2 = document.createElement('button'); b2.textContent = '稍后'; b2.onclick = () => answer(rid, { status: 'answered', present: false });
1363
+ row.appendChild(b1); row.appendChild(b2);
1364
+ } else if (it.kind === 'pick_element') {
1365
+ row.innerHTML = `<span style="font-size:11px;opacity:.8">请在页面点击元素…</span>`;
1366
+ }
1367
+ if (it.rejectable !== false && !['choose', 'input', 'presence'].includes(it.kind)) {
1368
+ const rej = document.createElement('button'); rej.className = 'btn ghost'; rej.textContent = '✋ 拒绝'; rej.onclick = () => answer(rid, { status: 'rejected', reason: 'user rejected' });
1369
+ row.appendChild(rej);
1370
+ }
1371
+ const pause = document.createElement('button'); pause.className = 'btn ghost'; pause.textContent = '⏸';
1372
+ pause.title = '暂停/继续倒计时';
1373
+ pause.onclick = () => { window.BridgeClient.postInteraction(rid, 'pause'); pause.textContent = '▶'; pause.onclick = () => { window.BridgeClient.postInteraction(rid, 'resume'); pause.textContent = '⏸'; }; };
1374
+ row.appendChild(pause);
1375
+ card.appendChild(row);
1376
+ }
1377
+ $('ixBody').prepend(card);
1378
+ cards.set(rid, card);
1379
+ updateIx(); // 有交互卡才显示(无卡隐藏)
1380
+ }
1381
+ function answer(rid, result) {
1382
+ window.BridgeClient.postResult(rid, true, result, null);
1383
+ finishInteractionCard(rid, result); // 作答完成 → 卡片区移除 + 消息流记录
1384
+ }
1385
+
1386
+ // ---- 输入框:多行(textarea 自动增高)+ 草稿防丢(localStorage 单条) ----
1387
+ const DRAFT_KEY = 'aiti-cockpit-draft';
1388
+ const COMPOSER_MAX_H = 140, COMPOSER_MIN_H = 34, DRAFT_DEBOUNCE_MS = 300;
1389
+ let composing = false; // IME 组合中
1390
+ let draftTimer = null;
1391
+
1392
+ function resizeComposer() {
1393
+ const ta = $('pushText');
1394
+ if (!ta) return;
1395
+ ta.style.height = 'auto';
1396
+ ta.style.height = Math.min(Math.max(ta.scrollHeight, COMPOSER_MIN_H), COMPOSER_MAX_H) + 'px';
1397
+ }
1398
+ // 草稿防丢:候选占位符([元素N]/[回答N]/[错误N])是候选区状态、不持久化——保存/恢复时摘掉,
1399
+ // 否则历史累积的 [错误1] 被存进草稿,每次打开 cockpit 恢复草稿"复活"(实测输入框重复 [错误1])
1400
+ function stripCandidateTags(v) {
1401
+ return String(v || '').replace(/\[(元素|回答|错误|选择)\d+\]/g, '').replace(/^\s+|\s+$/g, '');
1402
+ }
1403
+ function saveDraftNow() { // 同步写盘(可安全用于 beforeunload/pagehide);纯空白不落盘
1404
+ clearTimeout(draftTimer);
1405
+ const v = stripCandidateTags($('pushText').value);
1406
+ try { if (v.trim()) localStorage.setItem(DRAFT_KEY, v); else localStorage.removeItem(DRAFT_KEY); }
1407
+ catch (e) { /* 隐私模式等,忽略 */ }
1408
+ }
1409
+ function scheduleDraftSave() { clearTimeout(draftTimer); draftTimer = setTimeout(saveDraftNow, DRAFT_DEBOUNCE_MS); }
1410
+ function clearDraft() { clearTimeout(draftTimer); try { localStorage.removeItem(DRAFT_KEY); } catch (e) {} }
1411
+ function restoreDraft() {
1412
+ let v = '';
1413
+ try { v = localStorage.getItem(DRAFT_KEY) || ''; } catch (e) {}
1414
+ v = stripCandidateTags(v); // 旧草稿可能含历史占位符(修复前)——恢复时一并摘掉
1415
+ if (!v.trim()) return;
1416
+ $('pushText').value = v;
1417
+ resizeComposer();
1418
+ toast('已恢复上次未发送的草稿');
1419
+ }
1420
+
1421
+ // 输入事件:自动增高 + debounce 落盘
1422
+ $('pushText').addEventListener('input', () => { resizeComposer(); scheduleDraftSave(); });
1423
+ // IME 组合期 Enter 不发送(中文输入法确认候选字)
1424
+ $('pushText').addEventListener('compositionstart', () => { composing = true; });
1425
+ $('pushText').addEventListener('compositionend', () => { composing = false; });
1426
+ $('pushText').addEventListener('keydown', (e) => {
1427
+ if (e.key !== 'Enter') return;
1428
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; // Shift+Enter 换行,其余组合键不拦截
1429
+ // 组合期 Enter 不发送:composing 标志(compositionstart/end)兜底,原生 KeyboardEvent 直接读 isComposing / keyCode 229
1430
+ if (composing || e.isComposing || e.keyCode === 229) return;
1431
+ e.preventDefault();
1432
+ pushMessage();
1433
+ });
1434
+
1435
+ // 离开页面前同步 flush(补 debounce 未到点的最后一次击键)
1436
+ window.addEventListener('beforeunload', saveDraftNow);
1437
+ window.addEventListener('pagehide', saveDraftNow);
1438
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveDraftNow(); });
1439
+
1440
+ // ---- 绑定:发送(切换目标在 🤖 下拉内) ----
1441
+ // 来源标注 [发自: pageId · url]:前端只带页面信息(后端 push 时按目标 agent source 追加来源说明:扩展/MCP——不写死)
1442
+ function withSourceTag(text) {
1443
+ // 来源信息已全结构化(aiwin.push payload:宿主页/选中页 pageId+url;桥补充 source/sourceLabel)——不再拼文本
1444
+ return text;
1445
+ }
1446
+
1447
+ function pushMessage() {
1448
+ const raw = $('pushText').value.trim();
1449
+ if (!raw) return;
1450
+ const text = withSourceTag(raw);
1451
+ const agents = aiwin.getAgents();
1452
+ if (!agents.length) {
1453
+ // 无 agent → 引导接入(不静默失败)
1454
+ toast('没有已注册 agent——见上方引导:接入页面 / 接入 agent');
1455
+ computeGuide();
1456
+ return;
1457
+ }
1458
+ aiwin.push(text, (body, b) => {
1459
+ if (b && b.ok) {
1460
+ toast('已送达:' + (b.delivered || []).map((d) => d.agentKey + '@' + d.status).join(', '));
1461
+ $('pushText').value = '';
1462
+ resizeComposer(); // 清空后缩回单行
1463
+ clearDraft(); // 发送成功 → 删除防丢数据
1464
+ // 记录实际传输的完整消息(ref 对应回桥回显的消息行 → 点击弹详情,与接收消息的详情弹窗一致)
1465
+ if (body && body.ref) sentDetail[body.ref] = body;
1466
+ } else {
1467
+ toast('发送失败' + (b ? ':' + b.error : '') + '——见上方引导');
1468
+ computeGuide();
1469
+ }
1470
+ // 发送完直接滚动到消息列表底部(看到刚发的消息/最新动态)
1471
+ const list = $('msgList');
1472
+ if (list) list.scrollTop = list.scrollHeight;
1473
+ });
1474
+ }
1475
+
1476
+ // ---- 帮助弹窗(引导内容) ----
1477
+ $('helpBtn').onclick = () => $('helpModal').classList.add('show');
1478
+ function closeHelp() { $('helpModal').classList.remove('show'); }
1479
+
1480
+ aiwin.start();
1481
+ applyWindowMeta('__all__'); // 初始 title/favicon + 上报宿主
1482
+ restoreDraft(); // 恢复上次未发送草稿(localStorage 单条防丢)
1483
+ </script>
1484
+ </body>
1485
+ </html>