@wendongfly/myhi 1.0.1
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/bin/myhi.js +143 -0
- package/dist/attach.js +188 -0
- package/dist/chat.html +1484 -0
- package/dist/client-dist/socket.io.esm.min.js +7 -0
- package/dist/client-dist/socket.io.js +4955 -0
- package/dist/client-dist/socket.io.min.js +7 -0
- package/dist/client-dist/socket.io.msgpack.min.js +7 -0
- package/dist/icon.png +0 -0
- package/dist/icon.svg +4 -0
- package/dist/index.html +871 -0
- package/dist/index.js +367 -0
- package/dist/lib/ansi_up.js +431 -0
- package/dist/login.html +125 -0
- package/dist/manifest.json +13 -0
- package/dist/package.json +3 -0
- package/dist/terminal.html +445 -0
- package/package.json +55 -0
package/dist/chat.html
ADDED
|
@@ -0,0 +1,1484 @@
|
|
|
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.0, maximum-scale=1.0, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#0d1117">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
|
+
<link rel="manifest" href="/manifest.json">
|
|
10
|
+
<link rel="apple-touch-icon" href="/icon.png">
|
|
11
|
+
<title>myhi 会话</title>
|
|
12
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
13
|
+
<style>
|
|
14
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
15
|
+
html, body { height: 100%; background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
|
|
16
|
+
body { display: flex; flex-direction: column; }
|
|
17
|
+
|
|
18
|
+
/* ── 顶部栏 ─────────────────────────────── */
|
|
19
|
+
#top-bar { display: flex; align-items: center; padding: 0.5rem 0.75rem; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; gap: 0.5rem; padding-top: max(0.5rem, env(safe-area-inset-top)); }
|
|
20
|
+
#title-group { flex: 1; overflow: hidden; min-width: 0; }
|
|
21
|
+
#session-title { font-size: 0.9rem; font-weight: 600; color: #58a6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
22
|
+
#session-cwd { font-size: 0.68rem; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', 'Consolas', monospace; }
|
|
23
|
+
.top-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: transparent; color: #8b949e; border: none; border-radius: 8px; cursor: pointer; transition: all 0.15s; flex-shrink: 0; }
|
|
24
|
+
.top-btn:hover { background: #21262d; color: #e6edf3; }
|
|
25
|
+
.top-btn:active { transform: scale(0.92); }
|
|
26
|
+
|
|
27
|
+
/* ── 状态栏 ─────────────────────────────── */
|
|
28
|
+
#status-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.3rem 0.75rem; background: #0d1117; border-bottom: 1px solid #21262d; font-size: 0.72rem; overflow-x: auto; flex-shrink: 0; scrollbar-width: none; }
|
|
29
|
+
#status-bar::-webkit-scrollbar { display: none; }
|
|
30
|
+
.sb-item { display: inline-flex; align-items: center; gap: 0.25rem; white-space: nowrap; }
|
|
31
|
+
.sb-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
32
|
+
.sb-dot.idle { background: #3fb950; }
|
|
33
|
+
.sb-dot.thinking { background: #d29922; animation: pulse 1s infinite; }
|
|
34
|
+
.sb-dot.working { background: #58a6ff; animation: pulse 1s infinite; }
|
|
35
|
+
.sb-dot.waiting { background: #f0883e; animation: pulse 0.6s infinite; }
|
|
36
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
37
|
+
#mode-badge { padding: 0.15rem 0.5rem; border-radius: 10px; background: #21262d; border: 1px solid #30363d; cursor: pointer; user-select: none; }
|
|
38
|
+
#mode-badge:hover { background: #30363d; }
|
|
39
|
+
#control-badge { padding: 0.15rem 0.5rem; border-radius: 10px; cursor: pointer; user-select: none; border: 1px solid; }
|
|
40
|
+
#control-badge.controlling { background: #0d3321; color: #3fb950; border-color: #238636; }
|
|
41
|
+
#control-badge.readonly { background: #21262d; color: #8b949e; border-color: #30363d; }
|
|
42
|
+
#viewer-count { color: #8b949e; }
|
|
43
|
+
|
|
44
|
+
/* ── 聊天区 ─────────────────────────────── */
|
|
45
|
+
#chat-area { flex: 1; overflow-y: auto; padding: 0.75rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; scrollbar-color: #21262d transparent; }
|
|
46
|
+
#chat-area::-webkit-scrollbar { width: 6px; }
|
|
47
|
+
#chat-area::-webkit-scrollbar-track { background: transparent; }
|
|
48
|
+
#chat-area::-webkit-scrollbar-thumb { background: #21262d; border-radius: 3px; }
|
|
49
|
+
#chat-area::-webkit-scrollbar-thumb:hover { background: #30363d; }
|
|
50
|
+
@media (min-width: 768px) { #chat-area { max-width: 900px; margin: 0 auto; width: 100%; } }
|
|
51
|
+
.msg { margin-bottom: 0.5rem; animation: fadeIn 0.15s ease; }
|
|
52
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
53
|
+
|
|
54
|
+
/* 用户输入气泡 */
|
|
55
|
+
.msg-input { display: flex; justify-content: flex-end; }
|
|
56
|
+
.msg-input .bubble { background: #1f6feb; color: #fff; padding: 0.5rem 0.75rem; border-radius: 12px 12px 4px 12px; max-width: 85%; font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 0.85rem; word-break: break-word; white-space: pre-wrap; }
|
|
57
|
+
.msg-input .meta { font-size: 0.65rem; color: #8b949e; text-align: right; margin-top: 2px; padding-right: 4px; }
|
|
58
|
+
|
|
59
|
+
/* Claude 文本回复 */
|
|
60
|
+
.msg-assistant { padding: 0.4rem 0; }
|
|
61
|
+
.msg-assistant .content { font-size: 0.88rem; line-height: 1.65; color: #e6edf3; }
|
|
62
|
+
.msg-assistant .content code { background: #21262d; padding: 0.12em 0.35em; border-radius: 4px; font-size: 0.82em; font-family: 'SF Mono', 'Consolas', monospace; }
|
|
63
|
+
.msg-assistant .content pre { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.6rem 0.75rem; overflow-x: auto; margin: 0.5rem 0; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.8rem; line-height: 1.35; }
|
|
64
|
+
.msg-assistant .content pre code { background: none; padding: 0; }
|
|
65
|
+
.msg-assistant .content ul, .msg-assistant .content ol { padding-left: 1.5rem; margin: 0.3rem 0; }
|
|
66
|
+
.msg-assistant .content strong { color: #fff; }
|
|
67
|
+
|
|
68
|
+
/* 工具调用组 */
|
|
69
|
+
.msg-tool-group { border-left: 3px solid #58a6ff; margin-left: 0.3rem; padding-left: 0.6rem; margin-bottom: 0.3rem; }
|
|
70
|
+
.tool-group-header { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.78rem; color: #58a6ff; padding: 0.25rem 0; user-select: none; }
|
|
71
|
+
.tool-group-header:hover { color: #79c0ff; }
|
|
72
|
+
.tool-group-header .arrow { transition: transform 0.15s; font-size: 0.65rem; }
|
|
73
|
+
.tool-group-header .arrow.open { transform: rotate(90deg); }
|
|
74
|
+
.tool-group-body { display: none; }
|
|
75
|
+
.tool-group-body.open { display: block; }
|
|
76
|
+
/* 工具调用卡片 */
|
|
77
|
+
.msg-tool { margin-bottom: 0.2rem; }
|
|
78
|
+
.tool-header { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.78rem; color: #58a6ff; padding: 0.25rem 0; user-select: none; }
|
|
79
|
+
.tool-header:hover { color: #79c0ff; }
|
|
80
|
+
.tool-header .arrow { transition: transform 0.15s; font-size: 0.65rem; }
|
|
81
|
+
.tool-header .arrow.open { transform: rotate(90deg); }
|
|
82
|
+
.tool-body { display: none; background: #161b22; border: 1px solid #21262d; border-radius: 6px; padding: 0.5rem; margin-top: 0.2rem; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.75rem; line-height: 1.3; overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto; }
|
|
83
|
+
.tool-body.open { display: block; }
|
|
84
|
+
|
|
85
|
+
/* 权限请求卡片 */
|
|
86
|
+
.msg-permission { background: #1c1500; border: 1px solid #d29922; border-radius: 8px; padding: 0.65rem 0.75rem; font-size: 0.82rem; }
|
|
87
|
+
.msg-permission .perm-title { color: #d29922; font-weight: 600; margin-bottom: 0.3rem; }
|
|
88
|
+
.msg-permission .perm-detail { color: #e6edf3; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.78rem; margin-bottom: 0.5rem; white-space: pre-wrap; }
|
|
89
|
+
.perm-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
|
90
|
+
.perm-actions button { padding: 0.35rem 0.8rem; border-radius: 6px; font-size: 0.78rem; cursor: pointer; border: none; }
|
|
91
|
+
.btn-allow { background: #238636; color: #fff; }
|
|
92
|
+
.btn-allow:hover { background: #2ea043; }
|
|
93
|
+
.btn-deny { background: #21262d; color: #f85149; border: 1px solid #30363d !important; }
|
|
94
|
+
|
|
95
|
+
/* Diff 视图 */
|
|
96
|
+
.msg-diff { font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.78rem; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.5rem 0.6rem; overflow-x: auto; line-height: 1.35; }
|
|
97
|
+
.diff-add { color: #3fb950; background: rgba(46,160,67,0.1); }
|
|
98
|
+
.diff-del { color: #f85149; background: rgba(248,81,73,0.1); }
|
|
99
|
+
.diff-hunk { color: #8b949e; }
|
|
100
|
+
.diff-file { color: #d2a8ff; font-weight: 600; margin-bottom: 0.2rem; }
|
|
101
|
+
|
|
102
|
+
/* 思考中 */
|
|
103
|
+
.msg-thinking { padding: 0.35rem 0; color: #8b949e; font-size: 0.8rem; }
|
|
104
|
+
.thinking-header { display: flex; align-items: center; gap: 0.4rem; cursor: pointer; user-select: none; }
|
|
105
|
+
.thinking-header:hover { color: #c9d1d9; }
|
|
106
|
+
.thinking-header .arrow { transition: transform 0.15s; font-size: 0.6rem; }
|
|
107
|
+
.thinking-header .arrow.open { transform: rotate(90deg); }
|
|
108
|
+
.thinking-dots { display: inline-flex; gap: 3px; }
|
|
109
|
+
.thinking-dots span { width: 5px; height: 5px; background: #8b949e; border-radius: 50%; animation: blink 1.4s infinite; }
|
|
110
|
+
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
111
|
+
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
112
|
+
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
|
113
|
+
.thinking-body { display: none; margin-top: 0.3rem; padding: 0.4rem 0.6rem; background: #161b22; border: 1px solid #21262d; border-radius: 6px; font-size: 0.75rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; color: #8b949e; scrollbar-width: thin; scrollbar-color: #21262d transparent; }
|
|
114
|
+
.thinking-body::-webkit-scrollbar { width: 4px; }
|
|
115
|
+
.thinking-body::-webkit-scrollbar-thumb { background: #21262d; border-radius: 2px; }
|
|
116
|
+
.thinking-body.open { display: block; }
|
|
117
|
+
|
|
118
|
+
/* 错误消息 */
|
|
119
|
+
.msg-error { background: #1c0a0a; border: 1px solid #f8514933; border-radius: 8px; padding: 0.5rem 0.7rem; color: #f85149; font-size: 0.82rem; }
|
|
120
|
+
|
|
121
|
+
/* 原始输出(降级) */
|
|
122
|
+
.msg-raw .code-block { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.6rem 0.75rem; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.8rem; line-height: 1.35; white-space: pre-wrap; word-break: break-all; overflow-x: auto; }
|
|
123
|
+
|
|
124
|
+
/* 历史和状态 */
|
|
125
|
+
.msg-history .code-block { background: #0d1117; border: 1px solid #21262d; border-radius: 8px; padding: 0.6rem 0.75rem; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.75rem; line-height: 1.3; white-space: pre-wrap; word-break: break-all; color: #8b949e; max-height: 300px; overflow-y: auto; }
|
|
126
|
+
.msg-history .meta { font-size: 0.65rem; color: #484f58; margin-bottom: 4px; }
|
|
127
|
+
.msg-status { text-align: center; font-size: 0.75rem; color: #8b949e; padding: 0.5rem 0; }
|
|
128
|
+
|
|
129
|
+
/* ── 输入栏 ─────────────────────────────── */
|
|
130
|
+
#input-area { background: #161b22; border-top: 1px solid #30363d; flex-shrink: 0; padding: 0.5rem 0.75rem 0.35rem; }
|
|
131
|
+
#input-area.no-shortcuts { padding-bottom: max(0.5rem, env(safe-area-inset-bottom)); }
|
|
132
|
+
#input-box { background: #0d1117; border: 1px solid #30363d; border-radius: 12px; transition: border-color 0.2s; overflow: hidden; }
|
|
133
|
+
#input-box.focused { border-color: #58a6ff; }
|
|
134
|
+
#input-box.disabled { opacity: 0.5; pointer-events: none; }
|
|
135
|
+
#cmd-input { display: block; width: 100%; background: transparent; color: #e6edf3; border: none; padding: 0.6rem 0.75rem 0.3rem; font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 0.85rem; outline: none; resize: none; line-height: 1.4; max-height: 120px; overflow-y: auto; }
|
|
136
|
+
#cmd-input::placeholder { color: #484f58; }
|
|
137
|
+
#input-toolbar { display: flex; align-items: center; padding: 0.25rem 0.4rem 0.4rem; gap: 0.15rem; }
|
|
138
|
+
#input-toolbar .spacer { flex: 1; }
|
|
139
|
+
.tb-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent; color: #8b949e; cursor: pointer; transition: all 0.15s; flex-shrink: 0; }
|
|
140
|
+
.tb-btn:hover { background: #21262d; color: #e6edf3; }
|
|
141
|
+
.tb-btn:active { transform: scale(0.92); }
|
|
142
|
+
.tb-btn.active { color: #58a6ff; }
|
|
143
|
+
.tb-btn.recording { color: #f85149; animation: pulse 1s infinite; }
|
|
144
|
+
.tb-btn svg { width: 18px; height: 18px; }
|
|
145
|
+
#send-btn { color: #3fb950; }
|
|
146
|
+
#send-btn:hover { background: rgba(63,185,80,0.12); color: #3fb950; }
|
|
147
|
+
#send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
148
|
+
#send-btn:disabled:hover { background: transparent; }
|
|
149
|
+
#readonly-overlay { display: none; text-align: center; padding: 0.6rem; font-size: 0.8rem; color: #8b949e; background: #161b22; }
|
|
150
|
+
|
|
151
|
+
/* ── 快捷键栏 ───────────────────────────── */
|
|
152
|
+
#shortcut-bar { background: #161b22; border-top: 1px solid #21262d; padding: 0.35rem 0.5rem; padding-bottom: max(0.35rem, env(safe-area-inset-bottom)); flex-shrink: 0; overflow: hidden; }
|
|
153
|
+
#shortcut-bar > div { display: flex; align-items: center; gap: 0.35rem; overflow-x: auto; scrollbar-width: none; }
|
|
154
|
+
#shortcut-bar > div::-webkit-scrollbar { display: none; }
|
|
155
|
+
.sk { background: #21262d; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 0.3rem 0.55rem; font-size: 0.75rem; font-family: 'SF Mono', 'Consolas', monospace; cursor: pointer; white-space: nowrap; flex-shrink: 0; user-select: none; -webkit-user-select: none; touch-action: manipulation; }
|
|
156
|
+
.sk:active { background: #30363d; transform: scale(0.95); }
|
|
157
|
+
.sk:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
158
|
+
.sk.sk-claude { color: #d2a8ff; border-color: #d2a8ff33; background: #d2a8ff11; }
|
|
159
|
+
|
|
160
|
+
/* ── 设置面板 ───────────────────────────── */
|
|
161
|
+
#settings-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 60; }
|
|
162
|
+
#settings-backdrop.open { display: block; }
|
|
163
|
+
#settings-panel { position: fixed; top: 0; right: -320px; width: 300px; max-width: 85vw; height: 100%; background: #161b22; border-left: 1px solid #30363d; z-index: 61; transition: right 0.25s ease; overflow-y: auto; padding: 1rem; }
|
|
164
|
+
#settings-panel.open { right: 0; }
|
|
165
|
+
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; font-size: 1rem; font-weight: 600; }
|
|
166
|
+
.panel-close { background: none; border: none; color: #8b949e; font-size: 1.2rem; cursor: pointer; }
|
|
167
|
+
.setting-group { margin-bottom: 1.2rem; }
|
|
168
|
+
.setting-group h4 { font-size: 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.5rem; }
|
|
169
|
+
.setting-toggle { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; font-size: 0.85rem; cursor: pointer; }
|
|
170
|
+
.setting-toggle input[type=checkbox] { width: 36px; height: 20px; appearance: none; -webkit-appearance: none; background: #30363d; border-radius: 10px; position: relative; cursor: pointer; transition: background 0.2s; }
|
|
171
|
+
.setting-toggle input[type=checkbox]:checked { background: #238636; }
|
|
172
|
+
.setting-toggle input[type=checkbox]::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: left 0.2s; }
|
|
173
|
+
.setting-toggle input[type=checkbox]:checked::after { left: 18px; }
|
|
174
|
+
|
|
175
|
+
/* ── 底部选择面板 ─────────────────────── */
|
|
176
|
+
.action-sheet { display: none; position: fixed; inset: 0; z-index: 80; }
|
|
177
|
+
.action-sheet.open { display: flex; align-items: flex-end; justify-content: center; }
|
|
178
|
+
.action-sheet-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
|
179
|
+
.action-sheet-box { position: relative; background: #161b22; border-radius: 16px 16px 0 0; width: 100%; max-width: 480px; padding: 0.75rem 1rem; padding-bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 1; }
|
|
180
|
+
.action-sheet-title { font-size: 0.85rem; font-weight: 600; color: #e6edf3; padding: 0.5rem 0; text-align: center; border-bottom: 1px solid #21262d; margin-bottom: 0.5rem; }
|
|
181
|
+
.action-sheet-item { display: flex; align-items: center; justify-content: space-between; padding: 0.65rem 0.5rem; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: #e6edf3; transition: background 0.1s; }
|
|
182
|
+
.action-sheet-item:hover { background: #21262d; }
|
|
183
|
+
.action-sheet-item:active { background: #30363d; }
|
|
184
|
+
.action-sheet-item .desc { font-size: 0.72rem; color: #8b949e; }
|
|
185
|
+
.action-sheet-item .check { color: #3fb950; font-size: 0.9rem; }
|
|
186
|
+
.action-sheet-cancel { display: block; width: 100%; margin-top: 0.5rem; padding: 0.65rem; background: #21262d; border: none; border-radius: 10px; color: #8b949e; font-size: 0.85rem; cursor: pointer; text-align: center; }
|
|
187
|
+
|
|
188
|
+
/* ── QR弹窗 / 状态遮罩 ──────────────── */
|
|
189
|
+
#qr-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
|
190
|
+
#qr-modal.open { display: flex; }
|
|
191
|
+
#qr-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 1.5rem; text-align: center; max-width: 320px; width: 90%; }
|
|
192
|
+
#qr-box h3 { margin-bottom: 1rem; font-size: 0.95rem; }
|
|
193
|
+
#qr-box img { width: 100%; max-width: 240px; border-radius: 8px; background: #fff; padding: 8px; }
|
|
194
|
+
#qr-box .close-btn { margin-top: 1rem; background: #21262d; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 0.5rem 1.5rem; cursor: pointer; font-size: 0.9rem; }
|
|
195
|
+
#status-overlay { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(13,17,23,0.85); color: #8b949e; font-size: 0.9rem; z-index: 50; }
|
|
196
|
+
#status-overlay.hidden { display: none; }
|
|
197
|
+
</style>
|
|
198
|
+
</head>
|
|
199
|
+
<body>
|
|
200
|
+
|
|
201
|
+
<div id="top-bar">
|
|
202
|
+
<div id="title-group">
|
|
203
|
+
<div id="session-title">加载中...</div>
|
|
204
|
+
<div id="session-cwd"></div>
|
|
205
|
+
</div>
|
|
206
|
+
<button class="top-btn" onclick="openSettings()" title="设置">
|
|
207
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
|
208
|
+
</button>
|
|
209
|
+
<button class="top-btn" onclick="goBack()" title="返回">
|
|
210
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div id="status-bar">
|
|
215
|
+
<span class="sb-item" id="work-status"><span class="sb-dot idle"></span> 空闲</span>
|
|
216
|
+
<span id="mode-badge" onclick="switchMode()">默认</span>
|
|
217
|
+
<span id="control-badge" class="readonly" onclick="toggleControl()">只读</span>
|
|
218
|
+
<span id="viewer-count" class="sb-item"></span>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div id="chat-area"></div>
|
|
222
|
+
|
|
223
|
+
<div id="input-area">
|
|
224
|
+
<div id="readonly-overlay">只读模式 — 点击状态栏"获取控制"开始输入</div>
|
|
225
|
+
<div id="input-box">
|
|
226
|
+
<textarea id="cmd-input" rows="1" placeholder="输入消息..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
|
227
|
+
<div id="input-toolbar">
|
|
228
|
+
<button class="tb-btn" id="btn-photo" onclick="openCamera()" title="拍照">
|
|
229
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
230
|
+
</button>
|
|
231
|
+
<div class="spacer"></div>
|
|
232
|
+
<button class="tb-btn" id="voice-btn" onclick="toggleVoice()" title="语音输入">
|
|
233
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
|
234
|
+
</button>
|
|
235
|
+
<button class="tb-btn" id="send-btn" onclick="sendCommand()" title="发送">
|
|
236
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div id="shortcut-bar" style="display:none">
|
|
243
|
+
<div id="sk-shell">
|
|
244
|
+
<button class="sk" data-send="ctrl-c">Ctrl+C</button>
|
|
245
|
+
<button class="sk" data-send="ctrl-d">Ctrl+D</button>
|
|
246
|
+
<button class="sk" data-send="tab">Tab</button>
|
|
247
|
+
<button class="sk" data-send="up">↑</button>
|
|
248
|
+
<button class="sk" data-send="down">↓</button>
|
|
249
|
+
<button class="sk" data-send="esc">Esc</button>
|
|
250
|
+
</div>
|
|
251
|
+
<div id="sk-claude-pty" style="display:none">
|
|
252
|
+
<button class="sk sk-claude" onclick="openModelSheet()">模型</button>
|
|
253
|
+
<button class="sk sk-claude" data-cmd="/cost">费用</button>
|
|
254
|
+
<button class="sk sk-claude" data-cmd="/memory">记忆</button>
|
|
255
|
+
<button class="sk sk-claude" onclick="openResumeSheet()">恢复</button>
|
|
256
|
+
<button class="sk sk-claude" onclick="doCompact()">压缩</button>
|
|
257
|
+
<button class="sk sk-claude" data-cmd="/clear">清除</button>
|
|
258
|
+
<button class="sk sk-claude" onclick="doRename()">命名</button>
|
|
259
|
+
<button class="sk" data-send="ctrl-c">Ctrl+C</button>
|
|
260
|
+
<button class="sk" data-send="esc">Esc</button>
|
|
261
|
+
</div>
|
|
262
|
+
<div id="sk-claude-agent" style="display:none">
|
|
263
|
+
<button class="sk sk-claude" onclick="openModelSheet()">模型</button>
|
|
264
|
+
<button class="sk sk-claude" onclick="showAgentCost()">费用</button>
|
|
265
|
+
<button class="sk sk-claude" onclick="showAgentHistory()">历史</button>
|
|
266
|
+
<button class="sk sk-claude" onclick="openResumeSheet()">恢复</button>
|
|
267
|
+
<button class="sk sk-claude" onclick="doClear()">清除</button>
|
|
268
|
+
<button class="sk sk-claude" onclick="doRename()">命名</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<!-- 设置面板 -->
|
|
273
|
+
<div id="settings-backdrop" onclick="closeSettings()"></div>
|
|
274
|
+
<div id="settings-panel">
|
|
275
|
+
<div class="panel-header"><span>设置</span><button class="panel-close" onclick="closeSettings()">✕</button></div>
|
|
276
|
+
<div class="setting-group">
|
|
277
|
+
<h4>显示</h4>
|
|
278
|
+
<label class="setting-toggle"><span>解析终端输出</span><input type="checkbox" id="set-parse" checked></label>
|
|
279
|
+
<label class="setting-toggle"><span>显示思考过程</span><input type="checkbox" id="set-thinking" checked></label>
|
|
280
|
+
<label class="setting-toggle"><span>工具调用默认折叠</span><input type="checkbox" id="set-collapse" checked></label>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- QR弹窗 -->
|
|
285
|
+
<div id="qr-modal">
|
|
286
|
+
<div id="qr-box">
|
|
287
|
+
<h3>扫码在其他设备打开此会话</h3>
|
|
288
|
+
<img id="qr-img" src="" alt="QR Code"><br>
|
|
289
|
+
<button class="close-btn" onclick="closeQR()">关闭</button>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<!-- 模型选择面板 -->
|
|
294
|
+
<div id="model-sheet" class="action-sheet">
|
|
295
|
+
<div class="action-sheet-backdrop" onclick="closeModelSheet()"></div>
|
|
296
|
+
<div class="action-sheet-box">
|
|
297
|
+
<div class="action-sheet-title">选择模型</div>
|
|
298
|
+
<div id="model-list"></div>
|
|
299
|
+
<button class="action-sheet-cancel" onclick="closeModelSheet()">取消</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- 恢复会话面板 -->
|
|
304
|
+
<div id="resume-sheet" class="action-sheet">
|
|
305
|
+
<div class="action-sheet-backdrop" onclick="closeResumeSheet()"></div>
|
|
306
|
+
<div class="action-sheet-box">
|
|
307
|
+
<div class="action-sheet-title">恢复 Claude 会话</div>
|
|
308
|
+
<div id="resume-list" style="max-height:50vh;overflow-y:auto"></div>
|
|
309
|
+
<button class="action-sheet-cancel" onclick="closeResumeSheet()">取消</button>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div id="status-overlay">连接中...</div>
|
|
314
|
+
|
|
315
|
+
<script type="module">
|
|
316
|
+
import { AnsiUp } from '/lib/ansi_up.js';
|
|
317
|
+
const ansi = new AnsiUp();
|
|
318
|
+
ansi.use_classes = false;
|
|
319
|
+
|
|
320
|
+
// ── 状态 ──────────────────────────────────────
|
|
321
|
+
const SESSION_ID = location.pathname.split('/').pop();
|
|
322
|
+
const chatArea = document.getElementById('chat-area');
|
|
323
|
+
const cmdInput = document.getElementById('cmd-input');
|
|
324
|
+
const sendBtn = document.getElementById('send-btn');
|
|
325
|
+
const overlay = document.getElementById('status-overlay');
|
|
326
|
+
const readonlyOverlay = document.getElementById('readonly-overlay');
|
|
327
|
+
const inputBox = document.getElementById('input-box');
|
|
328
|
+
const shortcutBar = document.getElementById('shortcut-bar');
|
|
329
|
+
|
|
330
|
+
// textarea 自适应高度
|
|
331
|
+
cmdInput.addEventListener('input', () => {
|
|
332
|
+
cmdInput.style.height = 'auto';
|
|
333
|
+
cmdInput.style.height = Math.min(cmdInput.scrollHeight, 120) + 'px';
|
|
334
|
+
});
|
|
335
|
+
cmdInput.addEventListener('focus', () => inputBox.classList.add('focused'));
|
|
336
|
+
cmdInput.addEventListener('blur', () => inputBox.classList.remove('focused'));
|
|
337
|
+
|
|
338
|
+
let currentSession = null;
|
|
339
|
+
let isController = false;
|
|
340
|
+
let myRole = 'viewer';
|
|
341
|
+
let claudeSession = false; // 是否为 Claude Code 会话
|
|
342
|
+
let currentMode = 'default'; // 当前权限模式
|
|
343
|
+
let workState = 'idle';
|
|
344
|
+
|
|
345
|
+
// 设置
|
|
346
|
+
const DEFAULT_SETTINGS = { parseOutput: true, showThinking: true, collapseTools: true };
|
|
347
|
+
let settings = loadSettings();
|
|
348
|
+
|
|
349
|
+
function loadSettings() { try { return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem('myhi_chat_settings')) }; } catch { return { ...DEFAULT_SETTINGS }; } }
|
|
350
|
+
function saveSettings() { localStorage.setItem('myhi_chat_settings', JSON.stringify(settings)); }
|
|
351
|
+
|
|
352
|
+
// 输出累积
|
|
353
|
+
let outputBuffer = '';
|
|
354
|
+
let outputTimer = null;
|
|
355
|
+
const OUTPUT_DELAY = 600; // 输出合并延迟(ms),越大越少DOM操作
|
|
356
|
+
const MAX_MESSAGES = 150; // 聊天区最大消息数
|
|
357
|
+
|
|
358
|
+
// 命令历史
|
|
359
|
+
const cmdHistory = [];
|
|
360
|
+
let historyIdx = -1;
|
|
361
|
+
|
|
362
|
+
// 思考指示器引用(用于原地更新)
|
|
363
|
+
let thinkingEl = null;
|
|
364
|
+
|
|
365
|
+
// ── ANSI 工具 ──────────────────────────────────
|
|
366
|
+
function stripAnsi(text) {
|
|
367
|
+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b[()][AB012]/g, '').replace(/\x1b[>=<]/g, '').replace(/\r/g, '');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function cleanText(text) {
|
|
371
|
+
let c = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
372
|
+
c = c.split('\n').map(l => l.trimEnd()).join('\n');
|
|
373
|
+
return c.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function renderAnsiHtml(text) { return ansi.ansi_to_html(cleanText(text)); }
|
|
377
|
+
|
|
378
|
+
// ── 简易 Markdown ──────────────────────────────
|
|
379
|
+
function escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
380
|
+
|
|
381
|
+
function renderMarkdown(text) {
|
|
382
|
+
const lines = text.split('\n');
|
|
383
|
+
let html = '';
|
|
384
|
+
let inCode = false, codeLang = '', codeLines = [];
|
|
385
|
+
|
|
386
|
+
for (const line of lines) {
|
|
387
|
+
if (!inCode && /^```(\w*)/.test(line)) {
|
|
388
|
+
inCode = true; codeLang = line.match(/^```(\w*)/)[1]; codeLines = [];
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (inCode && line.trim() === '```') {
|
|
392
|
+
html += `<pre><code>${escHtml(codeLines.join('\n'))}</code></pre>`;
|
|
393
|
+
inCode = false; continue;
|
|
394
|
+
}
|
|
395
|
+
if (inCode) { codeLines.push(line); continue; }
|
|
396
|
+
|
|
397
|
+
let l = escHtml(line);
|
|
398
|
+
// 标题
|
|
399
|
+
if (/^### /.test(l)) { html += `<h4>${l.slice(4)}</h4>`; continue; }
|
|
400
|
+
if (/^## /.test(l)) { html += `<h3>${l.slice(3)}</h3>`; continue; }
|
|
401
|
+
if (/^# /.test(l)) { html += `<h2>${l.slice(2)}</h2>`; continue; }
|
|
402
|
+
// 行内格式
|
|
403
|
+
l = l.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
404
|
+
l = l.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
405
|
+
l = l.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
406
|
+
// 列表
|
|
407
|
+
if (/^- /.test(l)) { html += `<li>${l.slice(2)}</li>`; continue; }
|
|
408
|
+
if (/^\d+\. /.test(l)) { html += `<li>${l.replace(/^\d+\.\s*/, '')}</li>`; continue; }
|
|
409
|
+
|
|
410
|
+
html += l + '<br>';
|
|
411
|
+
}
|
|
412
|
+
if (inCode) html += `<pre><code>${escHtml(codeLines.join('\n'))}</code></pre>`;
|
|
413
|
+
// 包裹连续 li 为 ul
|
|
414
|
+
html = html.replace(/(<li>[\s\S]*?<\/li>)(?![\s\S]*?<li>)/g, (m) => `<ul>${m}</ul>`);
|
|
415
|
+
return html;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── 块分类器 ──────────────────────────────────
|
|
419
|
+
function isClaudeSession(session) {
|
|
420
|
+
const cmd = (session.initCmd || '').toLowerCase();
|
|
421
|
+
const title = (session.title || '').toLowerCase();
|
|
422
|
+
return cmd.includes('claude') || title.includes('claude');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function classifyBlock(raw) {
|
|
426
|
+
const clean = stripAnsi(raw).trim();
|
|
427
|
+
if (!clean) return { type: 'empty' };
|
|
428
|
+
|
|
429
|
+
// 思考状态 — 提取思考内容(spinner后的文字)
|
|
430
|
+
if (/^(\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|Thinking)/i.test(clean)) {
|
|
431
|
+
const content = clean.replace(/^[\s⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]+/, '').replace(/^Thinking\S*\s*/i, '').trim();
|
|
432
|
+
return { type: 'thinking', content };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 权限请求
|
|
436
|
+
if (/allow|deny|approve|reject|permission|Do you want to|允许|拒绝/i.test(clean) && clean.length < 500) return { type: 'permission', detail: clean };
|
|
437
|
+
|
|
438
|
+
// Diff
|
|
439
|
+
const lines = clean.split('\n');
|
|
440
|
+
const hasDiffMarkers = lines.some(l => /^[+-](?![-+]{2})/.test(l)) && lines.some(l => /^@@/.test(l) || /^(---|[\+]{3})\s/.test(l));
|
|
441
|
+
if (hasDiffMarkers) return { type: 'diff' };
|
|
442
|
+
|
|
443
|
+
// 工具调用(Claude Code 使用 box-drawing 字符或特定标签)
|
|
444
|
+
if (/^[╭┌│╰┘╮┐└┤├]/.test(lines[0]?.trim()) ||
|
|
445
|
+
/^\s*(Read|Edit|Write|Bash|Grep|Glob|WebFetch|WebSearch|LSP|TodoRead|TodoWrite|Agent)\s*[:(]/i.test(lines[0]?.trim())) {
|
|
446
|
+
return { type: 'tool', name: extractToolName(clean) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 错误
|
|
450
|
+
if (/^(Error|✗|✘|ERROR|error:)/i.test(clean.trim())) return { type: 'error' };
|
|
451
|
+
|
|
452
|
+
// 成功
|
|
453
|
+
if (/^(✓|✔|Done|Success|Created|Updated|已完成|已创建)/i.test(clean.trim())) return { type: 'success' };
|
|
454
|
+
|
|
455
|
+
// 提示符
|
|
456
|
+
if (lines.length <= 2 && /^[>❯$]\s*$/.test(clean)) return { type: 'prompt' };
|
|
457
|
+
|
|
458
|
+
// 默认:文本
|
|
459
|
+
return { type: 'text' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function extractToolName(clean) {
|
|
463
|
+
const m = clean.match(/^\s*(Read|Edit|Write|Bash|Grep|Glob|WebFetch|WebSearch|LSP|TodoRead|TodoWrite|Agent)/i);
|
|
464
|
+
return m ? m[1] : '工具';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const TOOL_ICONS = { Read:'📄', Edit:'✏️', Write:'📝', Bash:'💻', Grep:'🔍', Glob:'📂', WebFetch:'🌐', WebSearch:'🔎', LSP:'🔧', Agent:'🤖' };
|
|
468
|
+
|
|
469
|
+
// ── 消息渲染 ──────────────────────────────────
|
|
470
|
+
function addInputMessage(text) {
|
|
471
|
+
endStream(); endToolGroup();
|
|
472
|
+
const msg = document.createElement('div');
|
|
473
|
+
msg.className = 'msg msg-input';
|
|
474
|
+
msg.innerHTML = `<div><div class="bubble">${escHtml(text)}</div><div class="meta">${formatTime(new Date())}</div></div>`;
|
|
475
|
+
chatArea.appendChild(msg);
|
|
476
|
+
trimMessages();
|
|
477
|
+
scrollToBottom();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function addStatusMessage(text) {
|
|
481
|
+
endStream(); endToolGroup();
|
|
482
|
+
const msg = document.createElement('div');
|
|
483
|
+
msg.className = 'msg msg-status';
|
|
484
|
+
msg.textContent = '— ' + text + ' —';
|
|
485
|
+
chatArea.appendChild(msg);
|
|
486
|
+
trimMessages();
|
|
487
|
+
scrollToBottom();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let _streamEl = null; // 当前流式输出的 assistant 元素
|
|
491
|
+
let _streamText = ''; // 累积的原始文本
|
|
492
|
+
|
|
493
|
+
function addAssistantMessage(raw) {
|
|
494
|
+
removeThinking(); endToolGroup();
|
|
495
|
+
const clean = stripAnsi(raw).trim();
|
|
496
|
+
if (!clean) return;
|
|
497
|
+
|
|
498
|
+
// 流式追加:如果上一个消息还是 assistant 且在活跃状态,合并
|
|
499
|
+
if (_streamEl && chatArea.contains(_streamEl)) {
|
|
500
|
+
_streamText += '\n' + clean;
|
|
501
|
+
const content = _streamEl.querySelector('.content');
|
|
502
|
+
if (content) content.innerHTML = renderMarkdown(_streamText);
|
|
503
|
+
scrollToBottom();
|
|
504
|
+
setWorkState('working');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 新建 assistant 消息
|
|
509
|
+
_streamText = clean;
|
|
510
|
+
const msg = document.createElement('div');
|
|
511
|
+
msg.className = 'msg msg-assistant';
|
|
512
|
+
const content = document.createElement('div');
|
|
513
|
+
content.className = 'content';
|
|
514
|
+
content.innerHTML = renderMarkdown(clean);
|
|
515
|
+
msg.appendChild(content);
|
|
516
|
+
chatArea.appendChild(msg);
|
|
517
|
+
_streamEl = msg;
|
|
518
|
+
trimMessages();
|
|
519
|
+
scrollToBottom();
|
|
520
|
+
setWorkState('working');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 当收到非 assistant 类型的消息时,结束流式追加
|
|
524
|
+
function endStream() { _streamEl = null; _streamText = ''; }
|
|
525
|
+
|
|
526
|
+
let _toolGroupEl = null; // 当前工具组容器
|
|
527
|
+
let _toolGroupCount = 0;
|
|
528
|
+
|
|
529
|
+
function addToolMessage(raw, toolName) {
|
|
530
|
+
removeThinking();
|
|
531
|
+
const icon = TOOL_ICONS[toolName] || '🔧';
|
|
532
|
+
const collapsed = settings.collapseTools;
|
|
533
|
+
|
|
534
|
+
// 创建单个工具项
|
|
535
|
+
const toolItem = document.createElement('div');
|
|
536
|
+
toolItem.className = 'msg-tool';
|
|
537
|
+
const header = document.createElement('div');
|
|
538
|
+
header.className = 'tool-header';
|
|
539
|
+
header.innerHTML = `<span class="arrow ${collapsed ? '' : 'open'}">▶</span> ${icon} ${escHtml(toolName)}`;
|
|
540
|
+
const body = document.createElement('div');
|
|
541
|
+
body.className = `tool-body ${collapsed ? '' : 'open'}`;
|
|
542
|
+
body.innerHTML = renderAnsiHtml(raw);
|
|
543
|
+
header.onclick = () => { body.classList.toggle('open'); header.querySelector('.arrow').classList.toggle('open'); };
|
|
544
|
+
toolItem.appendChild(header);
|
|
545
|
+
toolItem.appendChild(body);
|
|
546
|
+
|
|
547
|
+
// 合并到现有工具组,或创建新组
|
|
548
|
+
const last = chatArea.lastElementChild;
|
|
549
|
+
if (last?.classList.contains('msg-tool-group') && last === _toolGroupEl) {
|
|
550
|
+
// 追加到现有组
|
|
551
|
+
_toolGroupCount++;
|
|
552
|
+
const groupBody = last.querySelector('.tool-group-body');
|
|
553
|
+
groupBody.appendChild(toolItem);
|
|
554
|
+
// 更新组头计数
|
|
555
|
+
const groupHeader = last.querySelector('.tool-group-header');
|
|
556
|
+
groupHeader.innerHTML = `<span class="arrow">▶</span> 🔧 ${_toolGroupCount} 个工具调用`;
|
|
557
|
+
} else {
|
|
558
|
+
// 创建新的工具组
|
|
559
|
+
_toolGroupCount = 1;
|
|
560
|
+
const group = document.createElement('div');
|
|
561
|
+
group.className = 'msg msg-tool-group';
|
|
562
|
+
const groupHeader = document.createElement('div');
|
|
563
|
+
groupHeader.className = 'tool-group-header';
|
|
564
|
+
groupHeader.innerHTML = `<span class="arrow">▶</span> 🔧 1 个工具调用`;
|
|
565
|
+
const groupBody = document.createElement('div');
|
|
566
|
+
groupBody.className = 'tool-group-body';
|
|
567
|
+
groupBody.appendChild(toolItem);
|
|
568
|
+
groupHeader.onclick = () => { groupBody.classList.toggle('open'); groupHeader.querySelector('.arrow').classList.toggle('open'); };
|
|
569
|
+
group.appendChild(groupHeader);
|
|
570
|
+
group.appendChild(groupBody);
|
|
571
|
+
chatArea.appendChild(group);
|
|
572
|
+
_toolGroupEl = group;
|
|
573
|
+
trimMessages();
|
|
574
|
+
}
|
|
575
|
+
scrollToBottom();
|
|
576
|
+
setWorkState('working');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 非工具消息时结束工具组
|
|
580
|
+
function endToolGroup() { _toolGroupEl = null; _toolGroupCount = 0; }
|
|
581
|
+
|
|
582
|
+
function addPermissionMessage(raw, detail) {
|
|
583
|
+
removeThinking();
|
|
584
|
+
const msg = document.createElement('div');
|
|
585
|
+
msg.className = 'msg msg-permission';
|
|
586
|
+
msg.innerHTML = `<div class="perm-title">需要权限确认</div><div class="perm-detail">${escHtml(detail)}</div>
|
|
587
|
+
<div class="perm-actions">
|
|
588
|
+
<button class="btn-allow" onclick="respondPermission('y', this)">允许</button>
|
|
589
|
+
<button class="btn-deny" onclick="respondPermission('n', this)">拒绝</button>
|
|
590
|
+
</div>`;
|
|
591
|
+
chatArea.appendChild(msg);
|
|
592
|
+
trimMessages();
|
|
593
|
+
scrollToBottom();
|
|
594
|
+
setWorkState('waiting');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function addDiffMessage(raw) {
|
|
598
|
+
removeThinking();
|
|
599
|
+
const lines = stripAnsi(raw).split('\n');
|
|
600
|
+
let html = '';
|
|
601
|
+
for (const line of lines) {
|
|
602
|
+
if (/^(---|\+\+\+)\s/.test(line)) html += `<div class="diff-file">${escHtml(line)}</div>`;
|
|
603
|
+
else if (/^@@/.test(line)) html += `<div class="diff-hunk">${escHtml(line)}</div>`;
|
|
604
|
+
else if (/^\+/.test(line)) html += `<div class="diff-add">${escHtml(line)}</div>`;
|
|
605
|
+
else if (/^-/.test(line)) html += `<div class="diff-del">${escHtml(line)}</div>`;
|
|
606
|
+
else html += `<div>${escHtml(line)}</div>`;
|
|
607
|
+
}
|
|
608
|
+
const msg = document.createElement('div');
|
|
609
|
+
msg.className = 'msg msg-diff';
|
|
610
|
+
msg.innerHTML = html;
|
|
611
|
+
chatArea.appendChild(msg);
|
|
612
|
+
trimMessages();
|
|
613
|
+
scrollToBottom();
|
|
614
|
+
setWorkState('working');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function addErrorMessage(raw) {
|
|
618
|
+
removeThinking();
|
|
619
|
+
const msg = document.createElement('div');
|
|
620
|
+
msg.className = 'msg msg-error';
|
|
621
|
+
msg.textContent = stripAnsi(raw).trim();
|
|
622
|
+
chatArea.appendChild(msg);
|
|
623
|
+
trimMessages();
|
|
624
|
+
scrollToBottom();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let _thinkingText = '';
|
|
628
|
+
|
|
629
|
+
function showThinking(content) {
|
|
630
|
+
if (!settings.showThinking && !thinkingEl) return;
|
|
631
|
+
|
|
632
|
+
const cleanContent = content ? stripAnsi(content).trim() : '';
|
|
633
|
+
|
|
634
|
+
if (thinkingEl) {
|
|
635
|
+
// 追加思考内容
|
|
636
|
+
if (cleanContent) {
|
|
637
|
+
_thinkingText += (_thinkingText ? '\n' : '') + cleanContent;
|
|
638
|
+
const body = thinkingEl.querySelector('.thinking-body');
|
|
639
|
+
if (body) {
|
|
640
|
+
body.textContent = _thinkingText;
|
|
641
|
+
body.scrollTop = body.scrollHeight;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
scrollToBottom();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
_thinkingText = cleanContent;
|
|
649
|
+
thinkingEl = document.createElement('div');
|
|
650
|
+
thinkingEl.className = 'msg msg-thinking';
|
|
651
|
+
|
|
652
|
+
const header = document.createElement('div');
|
|
653
|
+
header.className = 'thinking-header';
|
|
654
|
+
header.innerHTML = '<span class="arrow">▶</span> 思考中 <span class="thinking-dots"><span></span><span></span><span></span></span>';
|
|
655
|
+
|
|
656
|
+
const body = document.createElement('div');
|
|
657
|
+
body.className = 'thinking-body';
|
|
658
|
+
if (_thinkingText) body.textContent = _thinkingText;
|
|
659
|
+
|
|
660
|
+
header.onclick = () => {
|
|
661
|
+
body.classList.toggle('open');
|
|
662
|
+
header.querySelector('.arrow').classList.toggle('open');
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
thinkingEl.appendChild(header);
|
|
666
|
+
thinkingEl.appendChild(body);
|
|
667
|
+
chatArea.appendChild(thinkingEl);
|
|
668
|
+
scrollToBottom();
|
|
669
|
+
setWorkState('thinking');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function removeThinking() {
|
|
673
|
+
if (thinkingEl) {
|
|
674
|
+
// 如果有思考内容,保留为折叠状态(去掉动画dots)
|
|
675
|
+
if (_thinkingText) {
|
|
676
|
+
const dots = thinkingEl.querySelector('.thinking-dots');
|
|
677
|
+
if (dots) dots.remove();
|
|
678
|
+
const header = thinkingEl.querySelector('.thinking-header');
|
|
679
|
+
if (header) {
|
|
680
|
+
const arrow = header.querySelector('.arrow');
|
|
681
|
+
// 更新文字为"思考完成"
|
|
682
|
+
header.innerHTML = '';
|
|
683
|
+
header.appendChild(arrow);
|
|
684
|
+
header.append(' 思考过程');
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
thinkingEl.remove();
|
|
688
|
+
}
|
|
689
|
+
thinkingEl = null;
|
|
690
|
+
_thinkingText = '';
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function addRawOutput(raw) {
|
|
695
|
+
endStream(); endToolGroup();
|
|
696
|
+
removeThinking();
|
|
697
|
+
if (!stripAnsi(raw).trim()) return;
|
|
698
|
+
// 合并到上一个 raw 输出块(避免 DOM 碎片化)
|
|
699
|
+
const last = chatArea.lastElementChild;
|
|
700
|
+
if (last?.classList.contains('msg-raw')) {
|
|
701
|
+
const block = last.querySelector('.code-block');
|
|
702
|
+
if (block) {
|
|
703
|
+
block.innerHTML += renderAnsiHtml(raw);
|
|
704
|
+
// 限制单块行数
|
|
705
|
+
const lines = block.innerHTML.split('\n');
|
|
706
|
+
if (lines.length > 300) block.innerHTML = lines.slice(-200).join('\n');
|
|
707
|
+
scrollToBottom();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const msg = document.createElement('div');
|
|
712
|
+
msg.className = 'msg msg-raw';
|
|
713
|
+
const block = document.createElement('div');
|
|
714
|
+
block.className = 'code-block';
|
|
715
|
+
block.innerHTML = renderAnsiHtml(raw);
|
|
716
|
+
msg.appendChild(block);
|
|
717
|
+
chatArea.appendChild(msg);
|
|
718
|
+
trimMessages();
|
|
719
|
+
scrollToBottom();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function addHistoryBlock(raw) {
|
|
723
|
+
if (!stripAnsi(raw).trim()) return;
|
|
724
|
+
let lines = raw.split('\n');
|
|
725
|
+
if (lines.length > 200) lines = lines.slice(-200);
|
|
726
|
+
const msg = document.createElement('div');
|
|
727
|
+
msg.className = 'msg msg-history';
|
|
728
|
+
msg.innerHTML = `<div class="meta">历史输出</div><div class="code-block">${renderAnsiHtml(lines.join('\n'))}</div>`;
|
|
729
|
+
chatArea.appendChild(msg);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 权限响应
|
|
733
|
+
window.respondPermission = function(answer, btn) {
|
|
734
|
+
if (!isController) { addStatusMessage('你没有控制权,无法操作'); return; }
|
|
735
|
+
socket.emit('input', answer + '\r');
|
|
736
|
+
const actions = btn.closest('.perm-actions');
|
|
737
|
+
if (actions) actions.innerHTML = answer === 'y' ? '<span style="color:#3fb950">已允许</span>' : '<span style="color:#f85149">已拒绝</span>';
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// ── 输出处理 ──────────────────────────────────
|
|
741
|
+
function flushOutput() {
|
|
742
|
+
if (!outputBuffer) return;
|
|
743
|
+
const raw = outputBuffer;
|
|
744
|
+
outputBuffer = '';
|
|
745
|
+
clearTimeout(outputTimer);
|
|
746
|
+
processOutput(raw);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function processOutput(raw) {
|
|
750
|
+
if (!stripAnsi(raw).trim()) return;
|
|
751
|
+
|
|
752
|
+
// 非 Claude 会话或解析关闭:原始输出
|
|
753
|
+
if (!claudeSession || !settings.parseOutput) {
|
|
754
|
+
addRawOutput(raw);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Claude 会话:分类并渲染
|
|
759
|
+
const result = classifyBlock(raw);
|
|
760
|
+
switch (result.type) {
|
|
761
|
+
case 'thinking': showThinking(result.content); break;
|
|
762
|
+
case 'tool': endStream(); addToolMessage(raw, result.name); break;
|
|
763
|
+
case 'permission': endStream(); addPermissionMessage(raw, result.detail); break;
|
|
764
|
+
case 'diff': endStream(); addDiffMessage(raw); break;
|
|
765
|
+
case 'error': endStream(); addErrorMessage(raw); break;
|
|
766
|
+
case 'prompt': endStream(); removeThinking(); setWorkState('idle'); break;
|
|
767
|
+
case 'empty': break;
|
|
768
|
+
case 'success':
|
|
769
|
+
case 'text': addAssistantMessage(raw); break;
|
|
770
|
+
default: addRawOutput(raw); break;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── 工作状态 ──────────────────────────────────
|
|
775
|
+
let workStateTimer = null;
|
|
776
|
+
function setWorkState(state) {
|
|
777
|
+
if (workState === state) return;
|
|
778
|
+
workState = state;
|
|
779
|
+
clearTimeout(workStateTimer);
|
|
780
|
+
const el = document.getElementById('work-status');
|
|
781
|
+
const STATES = {
|
|
782
|
+
idle: { text: '空闲', dot: 'idle' },
|
|
783
|
+
thinking: { text: '思考中...', dot: 'thinking' },
|
|
784
|
+
working: { text: '执行中...', dot: 'working' },
|
|
785
|
+
waiting: { text: '等待确认', dot: 'waiting' },
|
|
786
|
+
};
|
|
787
|
+
const s = STATES[state] || STATES.idle;
|
|
788
|
+
el.innerHTML = `<span class="sb-dot ${s.dot}"></span> ${s.text}`;
|
|
789
|
+
// 自动回归空闲
|
|
790
|
+
if (state === 'working') workStateTimer = setTimeout(() => setWorkState('idle'), 5000);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ── 权限模式 ──────────────────────────────────
|
|
794
|
+
const MODES = [
|
|
795
|
+
{ id: 'default', label: '默认' },
|
|
796
|
+
{ id: 'acceptEdits', label: '自动编辑' },
|
|
797
|
+
{ id: 'plan', label: '计划模式' },
|
|
798
|
+
{ id: 'bypass', label: '跳过权限' },
|
|
799
|
+
];
|
|
800
|
+
|
|
801
|
+
function updateModeUI() {
|
|
802
|
+
const m = MODES.find(x => x.id === currentMode) || MODES[0];
|
|
803
|
+
document.getElementById('mode-badge').textContent = m.label;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function updateShortcutBar() {
|
|
807
|
+
if (!currentSession) { shortcutBar.style.display = 'none'; return; }
|
|
808
|
+
const isAgent = currentSession.mode === 'agent';
|
|
809
|
+
const isClaudePty = !isAgent && claudeSession;
|
|
810
|
+
const isPlainShell = !isAgent && !claudeSession;
|
|
811
|
+
shortcutBar.style.display = (isAgent || isClaudePty || isPlainShell) ? '' : 'none';
|
|
812
|
+
document.getElementById('input-area').classList.toggle('no-shortcuts', shortcutBar.style.display === 'none');
|
|
813
|
+
document.getElementById('sk-shell').style.display = isPlainShell ? 'flex' : 'none';
|
|
814
|
+
document.getElementById('sk-claude-pty').style.display = isClaudePty ? 'flex' : 'none';
|
|
815
|
+
document.getElementById('sk-claude-agent').style.display = isAgent ? 'flex' : 'none';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
window.switchMode = function() {
|
|
819
|
+
if (!isController) return;
|
|
820
|
+
if (currentSession?.mode === 'agent') {
|
|
821
|
+
// Agent 模式:仅更新模式设置(下次查询生效)
|
|
822
|
+
const idx = MODES.findIndex(x => x.id === currentMode);
|
|
823
|
+
currentMode = MODES[(idx + 1) % MODES.length].id;
|
|
824
|
+
updateModeUI();
|
|
825
|
+
socket.emit('set-mode', { sessionId: SESSION_ID, mode: currentMode });
|
|
826
|
+
addStatusMessage(`权限模式已切换为:${MODES.find(x => x.id === currentMode)?.label}`);
|
|
827
|
+
} else {
|
|
828
|
+
// PTY 模式:发送 Shift+Tab 到 Claude Code CLI
|
|
829
|
+
socket.emit('input', '\x1b[Z');
|
|
830
|
+
const idx = MODES.findIndex(x => x.id === currentMode);
|
|
831
|
+
currentMode = MODES[(idx + 1) % MODES.length].id;
|
|
832
|
+
updateModeUI();
|
|
833
|
+
socket.emit('set-mode', { sessionId: SESSION_ID, mode: currentMode });
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// ── Socket.IO ──────────────────────────────────
|
|
838
|
+
const socket = io({ transports: ['websocket'] });
|
|
839
|
+
let isScrollbackPhase = false;
|
|
840
|
+
|
|
841
|
+
function showOverlay(text, showBack = false) {
|
|
842
|
+
overlay.innerHTML = '';
|
|
843
|
+
overlay.classList.remove('hidden');
|
|
844
|
+
const msg = document.createElement('div');
|
|
845
|
+
msg.textContent = text;
|
|
846
|
+
overlay.appendChild(msg);
|
|
847
|
+
if (showBack) {
|
|
848
|
+
const btn = document.createElement('button');
|
|
849
|
+
btn.textContent = '返回首页';
|
|
850
|
+
btn.style.cssText = 'margin-top:1rem;padding:0.5rem 1.5rem;border:1px solid #8b949e;border-radius:8px;background:transparent;color:#c9d1d9;cursor:pointer;font-size:0.9rem';
|
|
851
|
+
btn.onclick = () => window.location.href = '/';
|
|
852
|
+
overlay.appendChild(btn);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
socket.on('connect', () => { showOverlay('正在加入会话...'); socket.emit('join', SESSION_ID); });
|
|
857
|
+
socket.on('connect_error', (err) => { showOverlay('连接失败: ' + err.message, true); });
|
|
858
|
+
socket.on('disconnect', () => { showOverlay('已断开连接,正在重连...'); });
|
|
859
|
+
|
|
860
|
+
socket.on('joined', (session) => {
|
|
861
|
+
currentSession = session;
|
|
862
|
+
overlay.classList.add('hidden');
|
|
863
|
+
document.title = session.title + ' — myhi';
|
|
864
|
+
document.getElementById('session-title').textContent = session.title;
|
|
865
|
+
if (session.cwd) document.getElementById('session-cwd').textContent = session.cwd;
|
|
866
|
+
updateViewers(session.viewers);
|
|
867
|
+
if (session.role) myRole = session.role;
|
|
868
|
+
updateControlUI(session.controlHolder === socket.id, session.controlHolderName);
|
|
869
|
+
|
|
870
|
+
claudeSession = isClaudeSession(session) || session.mode === 'agent';
|
|
871
|
+
currentMode = session.permissionMode || 'default';
|
|
872
|
+
updateModeUI();
|
|
873
|
+
updateShortcutBar();
|
|
874
|
+
|
|
875
|
+
// 更新输入框占位符
|
|
876
|
+
cmdInput.placeholder = session.mode === 'agent' ? '输入消息...' : '输入命令...';
|
|
877
|
+
|
|
878
|
+
// 如果没人控制且有权限,自动获取控制权
|
|
879
|
+
if (!session.controlHolder && canTakeControl()) {
|
|
880
|
+
socket.emit('take-control', { sessionId: SESSION_ID });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// PTY 模式的滚动缓冲区回放
|
|
884
|
+
if (session.mode !== 'agent') {
|
|
885
|
+
isScrollbackPhase = true;
|
|
886
|
+
setTimeout(() => { isScrollbackPhase = false; }, 200);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
socket.on('output', (data) => {
|
|
891
|
+
if (isScrollbackPhase) { addHistoryBlock(data); return; }
|
|
892
|
+
outputBuffer += data;
|
|
893
|
+
clearTimeout(outputTimer);
|
|
894
|
+
outputTimer = setTimeout(flushOutput, OUTPUT_DELAY);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
socket.on('session-exit', ({ code }) => { flushOutput(); addStatusMessage(`会话已退出 (code ${code})`); cmdInput.disabled = true; sendBtn.disabled = true; setWorkState('idle'); });
|
|
898
|
+
socket.on('sessions', (sessions) => { const s = sessions.find(x => x.id === SESSION_ID); if (s) updateViewers(s.viewers); });
|
|
899
|
+
socket.on('error', ({ message }) => { showOverlay('错误: ' + message, true); });
|
|
900
|
+
|
|
901
|
+
// 控制权事件
|
|
902
|
+
socket.on('control-changed', ({ sessionId, holder, holderName }) => {
|
|
903
|
+
if (sessionId !== SESSION_ID) return;
|
|
904
|
+
updateControlUI(holder === socket.id, holderName);
|
|
905
|
+
if (holder) addStatusMessage(holder === socket.id ? '你已获取控制权' : `${holderName || '其他用户'} 获取了控制权`);
|
|
906
|
+
else addStatusMessage('控制权已释放');
|
|
907
|
+
});
|
|
908
|
+
socket.on('control-denied', ({ reason }) => addStatusMessage('无法获取控制: ' + reason));
|
|
909
|
+
socket.on('mode-changed', ({ sessionId, mode }) => { if (sessionId === SESSION_ID) { currentMode = mode; updateModeUI(); } });
|
|
910
|
+
|
|
911
|
+
// ── Agent 模式事件 ──────────────────────────────
|
|
912
|
+
socket.on('agent:message', (msg) => {
|
|
913
|
+
if (!msg) return;
|
|
914
|
+
switch (msg.type) {
|
|
915
|
+
case 'system':
|
|
916
|
+
if (msg.subtype === 'init') {
|
|
917
|
+
addStatusMessage('Claude 会话已初始化');
|
|
918
|
+
setWorkState('working');
|
|
919
|
+
} else if (msg.subtype === 'interrupted') {
|
|
920
|
+
addStatusMessage('查询已中断');
|
|
921
|
+
setWorkState('idle');
|
|
922
|
+
}
|
|
923
|
+
break;
|
|
924
|
+
case 'assistant':
|
|
925
|
+
removeThinking();
|
|
926
|
+
if (msg.message?.content) {
|
|
927
|
+
for (const block of msg.message.content) {
|
|
928
|
+
if (block.type === 'thinking' && block.thinking) {
|
|
929
|
+
showThinking(block.thinking);
|
|
930
|
+
} else if (block.type === 'text' && block.text) {
|
|
931
|
+
addAssistantMessage(block.text);
|
|
932
|
+
} else if (block.type === 'tool_use') {
|
|
933
|
+
addToolMessage(JSON.stringify(block.input || {}, null, 2), block.name || '工具');
|
|
934
|
+
} else if (block.type === 'tool_result') {
|
|
935
|
+
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
936
|
+
addRawOutput(content);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
setWorkState('working');
|
|
941
|
+
break;
|
|
942
|
+
case 'result':
|
|
943
|
+
removeThinking();
|
|
944
|
+
setWorkState('idle');
|
|
945
|
+
const cost = msg.total_cost_usd ? ` ($${msg.total_cost_usd.toFixed(4)})` : '';
|
|
946
|
+
addStatusMessage(`完成${cost}`);
|
|
947
|
+
break;
|
|
948
|
+
case 'rate_limit_event':
|
|
949
|
+
break; // 忽略
|
|
950
|
+
default:
|
|
951
|
+
// 未知消息类型,显示为原始 JSON
|
|
952
|
+
if (msg.type !== 'user') {
|
|
953
|
+
addRawOutput(JSON.stringify(msg, null, 2));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
socket.on('agent:history', (history) => {
|
|
959
|
+
for (const msg of history) {
|
|
960
|
+
endStream(); // 每条历史消息都是独立的,不要流式合并
|
|
961
|
+
if (msg.type === 'user' && msg.content) {
|
|
962
|
+
addInputMessage(msg.content);
|
|
963
|
+
} else if (msg.type === 'assistant' && msg.message?.content) {
|
|
964
|
+
// 将同一条 assistant 消息的所有 text block 合并为一条
|
|
965
|
+
const texts = [];
|
|
966
|
+
for (const block of msg.message.content) {
|
|
967
|
+
if (block.type === 'thinking' && block.thinking) { showThinking(block.thinking); removeThinking(); }
|
|
968
|
+
else if (block.type === 'text' && block.text) texts.push(block.text);
|
|
969
|
+
else if (block.type === 'tool_use') addToolMessage(JSON.stringify(block.input || {}, null, 2), block.name || '工具');
|
|
970
|
+
}
|
|
971
|
+
if (texts.length) {
|
|
972
|
+
const combined = texts.join('\n\n');
|
|
973
|
+
const el = document.createElement('div');
|
|
974
|
+
el.className = 'msg msg-assistant';
|
|
975
|
+
const content = document.createElement('div');
|
|
976
|
+
content.className = 'content';
|
|
977
|
+
content.innerHTML = renderMarkdown(combined);
|
|
978
|
+
el.appendChild(content);
|
|
979
|
+
chatArea.appendChild(el);
|
|
980
|
+
}
|
|
981
|
+
} else if (msg.type === 'result') {
|
|
982
|
+
const cost = msg.total_cost_usd ? ` ($${msg.total_cost_usd.toFixed(4)})` : '';
|
|
983
|
+
addStatusMessage(`完成${cost}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
scrollToBottom();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
socket.on('agent:busy', (busy) => {
|
|
990
|
+
if (busy) { showThinking(); setWorkState('thinking'); }
|
|
991
|
+
else { removeThinking(); setWorkState('idle'); }
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
socket.on('agent:error', ({ message }) => {
|
|
995
|
+
removeThinking();
|
|
996
|
+
addErrorMessage(message);
|
|
997
|
+
setWorkState('idle');
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// ── 输入处理 ──────────────────────────────────
|
|
1001
|
+
window.sendCommand = sendCommand;
|
|
1002
|
+
function sendCommand() {
|
|
1003
|
+
const text = cmdInput.value;
|
|
1004
|
+
if (!text) return;
|
|
1005
|
+
|
|
1006
|
+
// 命名模式:发送的是新会话名称
|
|
1007
|
+
if (window._renameMode) {
|
|
1008
|
+
const { prev } = window._renameMode;
|
|
1009
|
+
window._renameMode = null;
|
|
1010
|
+
cmdInput.value = '';
|
|
1011
|
+
cmdInput.style.height = 'auto';
|
|
1012
|
+
cmdInput.placeholder = currentSession?.mode === 'agent' ? '输入消息...' : '输入命令...';
|
|
1013
|
+
socket.emit('rename', { sessionId: SESSION_ID, title: text });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (!isController) { addStatusMessage('请先获取控制权'); return; }
|
|
1018
|
+
endStream();
|
|
1019
|
+
|
|
1020
|
+
// 提取图片路径标记 [图片:xxx]
|
|
1021
|
+
const imgMatch = text.match(/^\[图片:(.+?)\]\s*/);
|
|
1022
|
+
const imgPath = imgMatch ? imgMatch[1] : null;
|
|
1023
|
+
const userText = imgMatch ? text.slice(imgMatch[0].length).trim() : text;
|
|
1024
|
+
const displayText = imgPath ? `📷 ${userText || '分析图片'}` : text;
|
|
1025
|
+
|
|
1026
|
+
addInputMessage(displayText);
|
|
1027
|
+
|
|
1028
|
+
if (currentSession?.mode === 'agent') {
|
|
1029
|
+
// Agent 模式:组合图片+文字
|
|
1030
|
+
const prompt = imgPath
|
|
1031
|
+
? `请直接用 Read 工具读取图片 ${imgPath} 并分析,不要搜索其他文件。${userText || '描述图片内容'}`
|
|
1032
|
+
: text;
|
|
1033
|
+
socket.emit('agent:query', { prompt });
|
|
1034
|
+
showThinking();
|
|
1035
|
+
} else {
|
|
1036
|
+
// PTY 模式:如果有图片,发送路径+用户文字
|
|
1037
|
+
const input = imgPath
|
|
1038
|
+
? (userText ? `${userText} ${imgPath}` : imgPath)
|
|
1039
|
+
: text;
|
|
1040
|
+
socket.emit('input', input + '\r');
|
|
1041
|
+
}
|
|
1042
|
+
cmdHistory.unshift(text);
|
|
1043
|
+
if (cmdHistory.length > 100) cmdHistory.pop();
|
|
1044
|
+
historyIdx = -1;
|
|
1045
|
+
cmdInput.value = '';
|
|
1046
|
+
cmdInput.style.height = 'auto';
|
|
1047
|
+
cmdInput.focus();
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function cancelRenameMode() {
|
|
1051
|
+
if (!window._renameMode) return;
|
|
1052
|
+
const { prev } = window._renameMode;
|
|
1053
|
+
window._renameMode = null;
|
|
1054
|
+
cmdInput.value = prev || '';
|
|
1055
|
+
cmdInput.style.height = 'auto';
|
|
1056
|
+
cmdInput.placeholder = currentSession?.mode === 'agent' ? '输入消息...' : '输入命令...';
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
cmdInput.addEventListener('keydown', (e) => {
|
|
1060
|
+
if (e.key === 'Escape' && window._renameMode) { e.preventDefault(); cancelRenameMode(); return; }
|
|
1061
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCommand(); }
|
|
1062
|
+
else if (e.key === 'ArrowUp' && !cmdInput.value) { if (historyIdx < cmdHistory.length - 1) { historyIdx++; cmdInput.value = cmdHistory[historyIdx]; } e.preventDefault(); }
|
|
1063
|
+
else if (e.key === 'ArrowDown' && !cmdInput.value) { if (historyIdx > 0) { historyIdx--; cmdInput.value = cmdHistory[historyIdx]; } else if (historyIdx === 0) { historyIdx = -1; cmdInput.value = ''; } e.preventDefault(); }
|
|
1064
|
+
});
|
|
1065
|
+
cmdInput.addEventListener('blur', () => { setTimeout(() => cancelRenameMode(), 200); });
|
|
1066
|
+
|
|
1067
|
+
// ── 快捷键 ──────────────────────────────────
|
|
1068
|
+
const SEQ = { 'ctrl-c': '\x03', 'ctrl-d': '\x04', 'tab': '\t', 'up': '\x1b[A', 'down': '\x1b[B', 'right': '\x1b[C', 'left': '\x1b[D', 'esc': '\x1b', 'ctrl-a': '\x01', 'ctrl-e': '\x05', 'ctrl-k': '\x0b', 'ctrl-u': '\x15', 'ctrl-r': '\x12', 'alt-.': '\x1b.' };
|
|
1069
|
+
document.querySelectorAll('.sk[data-send]').forEach(btn => {
|
|
1070
|
+
const send = () => { if (!isController) return; socket.emit('input', SEQ[btn.dataset.send] || btn.dataset.send); };
|
|
1071
|
+
btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
|
|
1072
|
+
btn.addEventListener('click', send);
|
|
1073
|
+
});
|
|
1074
|
+
// Claude 命令快捷按钮
|
|
1075
|
+
document.querySelectorAll('.sk[data-cmd]').forEach(btn => {
|
|
1076
|
+
const send = () => { if (!isController) return; cmdInput.value = btn.dataset.cmd; sendCommand(); };
|
|
1077
|
+
btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
|
|
1078
|
+
btn.addEventListener('click', send);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// ── 控制权 ──────────────────────────────────
|
|
1082
|
+
function canTakeControl() { return myRole === 'admin' || myRole === 'operator'; }
|
|
1083
|
+
window.toggleControl = function() { if (isController) socket.emit('release-control', { sessionId: SESSION_ID }); else if (canTakeControl()) socket.emit('take-control', { sessionId: SESSION_ID }); };
|
|
1084
|
+
|
|
1085
|
+
function updateControlUI(controlling, holderName) {
|
|
1086
|
+
isController = controlling;
|
|
1087
|
+
const badge = document.getElementById('control-badge');
|
|
1088
|
+
if (controlling) {
|
|
1089
|
+
badge.className = 'controlling'; badge.id = 'control-badge';
|
|
1090
|
+
badge.textContent = '控制中';
|
|
1091
|
+
readonlyOverlay.style.display = 'none'; inputBox.style.display = '';
|
|
1092
|
+
inputBox.classList.remove('disabled');
|
|
1093
|
+
cmdInput.disabled = false; sendBtn.disabled = false;
|
|
1094
|
+
document.querySelectorAll('.sk').forEach(b => b.disabled = false);
|
|
1095
|
+
} else {
|
|
1096
|
+
badge.className = 'readonly'; badge.id = 'control-badge';
|
|
1097
|
+
badge.textContent = holderName ? (canTakeControl() ? '获取控制' : `${holderName} 控制中`) : (canTakeControl() ? '获取控制' : '只读');
|
|
1098
|
+
if (!canTakeControl()) { readonlyOverlay.style.display = 'block'; inputBox.style.display = 'none'; }
|
|
1099
|
+
else { readonlyOverlay.style.display = 'none'; inputBox.style.display = ''; inputBox.classList.add('disabled'); cmdInput.disabled = true; sendBtn.disabled = true; }
|
|
1100
|
+
document.querySelectorAll('.sk').forEach(b => b.disabled = true);
|
|
1101
|
+
}
|
|
1102
|
+
updateShortcutBar();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ── 设置面板 ──────────────────────────────────
|
|
1106
|
+
window.openSettings = function() { document.getElementById('settings-backdrop').classList.add('open'); document.getElementById('settings-panel').classList.add('open'); syncSettingsUI(); };
|
|
1107
|
+
window.closeSettings = function() { document.getElementById('settings-backdrop').classList.remove('open'); document.getElementById('settings-panel').classList.remove('open'); };
|
|
1108
|
+
|
|
1109
|
+
function syncSettingsUI() {
|
|
1110
|
+
document.getElementById('set-parse').checked = settings.parseOutput;
|
|
1111
|
+
document.getElementById('set-thinking').checked = settings.showThinking;
|
|
1112
|
+
document.getElementById('set-collapse').checked = settings.collapseTools;
|
|
1113
|
+
}
|
|
1114
|
+
document.getElementById('set-parse').addEventListener('change', (e) => { settings.parseOutput = e.target.checked; saveSettings(); });
|
|
1115
|
+
document.getElementById('set-thinking').addEventListener('change', (e) => { settings.showThinking = e.target.checked; saveSettings(); });
|
|
1116
|
+
document.getElementById('set-collapse').addEventListener('change', (e) => { settings.collapseTools = e.target.checked; saveSettings(); });
|
|
1117
|
+
|
|
1118
|
+
// ── 辅助 ──────────────────────────────────────
|
|
1119
|
+
function updateViewers(count) { document.getElementById('viewer-count').textContent = count > 1 ? `${count} 人在线` : ''; }
|
|
1120
|
+
window.goBack = function() { window.location.href = '/'; };
|
|
1121
|
+
function scrollToBottom() { requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; }); }
|
|
1122
|
+
function trimMessages() {
|
|
1123
|
+
const msgs = chatArea.querySelectorAll('.msg');
|
|
1124
|
+
if (msgs.length > MAX_MESSAGES) {
|
|
1125
|
+
const remove = msgs.length - MAX_MESSAGES;
|
|
1126
|
+
for (let i = 0; i < remove; i++) msgs[i].remove();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function formatTime(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
|
1130
|
+
|
|
1131
|
+
// ── 二维码 ──────────────────────────────────
|
|
1132
|
+
window.showQR = function() { document.getElementById('qr-img').src = `/qr/${SESSION_ID}`; document.getElementById('qr-modal').classList.add('open'); };
|
|
1133
|
+
window.closeQR = function() { document.getElementById('qr-modal').classList.remove('open'); };
|
|
1134
|
+
|
|
1135
|
+
// ── 模型选择 ──────────────────────────────────
|
|
1136
|
+
const MODELS = [
|
|
1137
|
+
{ id: 'claude-sonnet-4-6-20250627', label: 'Sonnet 4.6', desc: '快速、高性价比' },
|
|
1138
|
+
{ id: 'claude-opus-4-6-20250627', label: 'Opus 4.6', desc: '最强推理能力' },
|
|
1139
|
+
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4', desc: '均衡性能' },
|
|
1140
|
+
{ id: 'claude-opus-4-20250514', label: 'Opus 4', desc: '强推理能力' },
|
|
1141
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', desc: '最快响应速度' },
|
|
1142
|
+
];
|
|
1143
|
+
let currentModelId = '';
|
|
1144
|
+
|
|
1145
|
+
window.openModelSheet = function() {
|
|
1146
|
+
const list = document.getElementById('model-list');
|
|
1147
|
+
list.innerHTML = '';
|
|
1148
|
+
MODELS.forEach(m => {
|
|
1149
|
+
const item = document.createElement('div');
|
|
1150
|
+
item.className = 'action-sheet-item';
|
|
1151
|
+
const isCurrent = currentModelId && m.id.includes(currentModelId);
|
|
1152
|
+
item.innerHTML = `<div><div>${m.label}</div><div class="desc">${m.desc}</div></div>${isCurrent ? '<span class="check">✓</span>' : ''}`;
|
|
1153
|
+
item.onclick = () => selectModel(m);
|
|
1154
|
+
list.appendChild(item);
|
|
1155
|
+
});
|
|
1156
|
+
document.getElementById('model-sheet').classList.add('open');
|
|
1157
|
+
};
|
|
1158
|
+
window.closeModelSheet = function() {
|
|
1159
|
+
document.getElementById('model-sheet').classList.remove('open');
|
|
1160
|
+
};
|
|
1161
|
+
function selectModel(m) {
|
|
1162
|
+
closeModelSheet();
|
|
1163
|
+
if (!isController) { addStatusMessage('请先获取控制权'); return; }
|
|
1164
|
+
if (currentSession?.mode === 'agent') {
|
|
1165
|
+
// Agent 模式:通过命令设置
|
|
1166
|
+
cmdInput.value = '/model ' + m.id;
|
|
1167
|
+
sendCommand();
|
|
1168
|
+
} else {
|
|
1169
|
+
// PTY 模式:发送 Escape 清除当前输入,再输入命令
|
|
1170
|
+
socket.emit('input', '\x03');
|
|
1171
|
+
setTimeout(() => {
|
|
1172
|
+
socket.emit('input', '/model ' + m.id + '\r');
|
|
1173
|
+
addInputMessage('/model ' + m.label);
|
|
1174
|
+
}, 100);
|
|
1175
|
+
}
|
|
1176
|
+
currentModelId = m.id;
|
|
1177
|
+
addStatusMessage('模型已切换为 ' + m.label);
|
|
1178
|
+
}
|
|
1179
|
+
document.getElementById('qr-modal').addEventListener('click', (e) => { if (e.target === e.currentTarget) window.closeQR(); });
|
|
1180
|
+
|
|
1181
|
+
// ── 恢复会话 ──────────────────────────────────
|
|
1182
|
+
window.openResumeSheet = async function() {
|
|
1183
|
+
if (!isController) { addStatusMessage('请先获取控制权'); return; }
|
|
1184
|
+
const list = document.getElementById('resume-list');
|
|
1185
|
+
list.innerHTML = '<div style="text-align:center;padding:1rem;color:#8b949e">加载中...</div>';
|
|
1186
|
+
document.getElementById('resume-sheet').classList.add('open');
|
|
1187
|
+
try {
|
|
1188
|
+
const res = await fetch('/api/claude-sessions');
|
|
1189
|
+
const sessions = await res.json();
|
|
1190
|
+
list.innerHTML = '';
|
|
1191
|
+
if (!sessions.length) {
|
|
1192
|
+
list.innerHTML = '<div style="text-align:center;padding:1rem;color:#8b949e">没有找到可恢复的会话</div>';
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
sessions.slice(0, 15).forEach(s => {
|
|
1196
|
+
const item = document.createElement('div');
|
|
1197
|
+
item.className = 'action-sheet-item';
|
|
1198
|
+
const time = s.updatedAt ? new Date(s.updatedAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '未知';
|
|
1199
|
+
const proj = s.projectDir ? decodeURIComponent(s.projectDir).split(/[\\/]/).pop() : '';
|
|
1200
|
+
const title = s.summary || s.sessionId?.slice(0, 12) || '未命名';
|
|
1201
|
+
item.innerHTML = `<div style="min-width:0;flex:1"><div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(title)}</div><div class="desc">${proj ? proj + ' · ' : ''}${time} · ${s.messageCount || 0} 条消息</div></div>`;
|
|
1202
|
+
item.onclick = () => resumeSession(s);
|
|
1203
|
+
list.appendChild(item);
|
|
1204
|
+
});
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
list.innerHTML = `<div style="text-align:center;padding:1rem;color:#f85149">加载失败: ${e.message}</div>`;
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
window.closeResumeSheet = function() {
|
|
1210
|
+
document.getElementById('resume-sheet').classList.remove('open');
|
|
1211
|
+
};
|
|
1212
|
+
function resumeSession(s) {
|
|
1213
|
+
closeResumeSheet();
|
|
1214
|
+
const id = s.sessionId;
|
|
1215
|
+
const label = s.summary ? s.summary.slice(0, 30) : id.slice(0, 8);
|
|
1216
|
+
if (currentSession?.mode === 'agent') {
|
|
1217
|
+
cmdInput.value = '/resume ' + id;
|
|
1218
|
+
sendCommand();
|
|
1219
|
+
} else {
|
|
1220
|
+
socket.emit('input', '\x03');
|
|
1221
|
+
setTimeout(() => {
|
|
1222
|
+
socket.emit('input', '/resume ' + id + '\r');
|
|
1223
|
+
addInputMessage('/resume ' + label);
|
|
1224
|
+
}, 100);
|
|
1225
|
+
}
|
|
1226
|
+
addStatusMessage('正在恢复会话 ' + label + '...');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ── 压缩对话 ──────────────────────────────────
|
|
1230
|
+
window.doCompact = function() {
|
|
1231
|
+
if (!isController) { addStatusMessage('请先获取控制权'); return; }
|
|
1232
|
+
if (currentSession?.mode === 'agent') {
|
|
1233
|
+
cmdInput.value = '/compact';
|
|
1234
|
+
sendCommand();
|
|
1235
|
+
} else {
|
|
1236
|
+
socket.emit('input', '\x03');
|
|
1237
|
+
setTimeout(() => {
|
|
1238
|
+
socket.emit('input', '/compact\r');
|
|
1239
|
+
addInputMessage('/compact');
|
|
1240
|
+
}, 100);
|
|
1241
|
+
}
|
|
1242
|
+
addStatusMessage('正在压缩对话上下文...');
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
// ── 清除会话 ──────────────────────────────────
|
|
1246
|
+
window.doClear = function() {
|
|
1247
|
+
// 清除聊天区所有消息
|
|
1248
|
+
chatArea.innerHTML = '';
|
|
1249
|
+
endStream();
|
|
1250
|
+
addStatusMessage('聊天记录已清除');
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
// ── 会话命名 ──────────────────────────────────
|
|
1254
|
+
window.doRename = function() {
|
|
1255
|
+
const current = document.getElementById('session-title').textContent;
|
|
1256
|
+
// 复用输入框:清空内容,填入当前名称,聚焦
|
|
1257
|
+
const prev = cmdInput.value;
|
|
1258
|
+
cmdInput.value = current;
|
|
1259
|
+
cmdInput.placeholder = '输入新名称后发送...';
|
|
1260
|
+
cmdInput.focus();
|
|
1261
|
+
cmdInput.select();
|
|
1262
|
+
// 临时劫持发送行为
|
|
1263
|
+
window._renameMode = { prev };
|
|
1264
|
+
};
|
|
1265
|
+
socket.on('session-renamed', ({ sessionId, title }) => {
|
|
1266
|
+
if (sessionId !== SESSION_ID) return;
|
|
1267
|
+
document.getElementById('session-title').textContent = title;
|
|
1268
|
+
document.title = title + ' — myhi';
|
|
1269
|
+
addStatusMessage('会话已命名为: ' + title);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// ── Agent 模式快捷功能 ──────────────────────────
|
|
1273
|
+
let _totalAgentCost = 0;
|
|
1274
|
+
// 追踪 result 消息中的费用
|
|
1275
|
+
window.showAgentCost = function() {
|
|
1276
|
+
// 从聊天区收集所有费用状态消息
|
|
1277
|
+
const msgs = chatArea.querySelectorAll('.msg-status');
|
|
1278
|
+
let total = 0;
|
|
1279
|
+
msgs.forEach(el => {
|
|
1280
|
+
const m = el.textContent.match(/\$([0-9.]+)/);
|
|
1281
|
+
if (m) total += parseFloat(m[1]);
|
|
1282
|
+
});
|
|
1283
|
+
addStatusMessage(`当前会话累计费用: $${total.toFixed(4)}`);
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
window.showAgentHistory = function() {
|
|
1287
|
+
// 统计当前聊天区的消息数量
|
|
1288
|
+
const inputs = chatArea.querySelectorAll('.msg-input').length;
|
|
1289
|
+
const assistants = chatArea.querySelectorAll('.msg-assistant').length;
|
|
1290
|
+
const tools = chatArea.querySelectorAll('.msg-tool').length;
|
|
1291
|
+
addStatusMessage(`会话统计: ${inputs} 条提问, ${assistants} 条回复, ${tools} 次工具调用`);
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
// ── 拍照上传 ──────────────────────────────────
|
|
1295
|
+
window.openCamera = async function() {
|
|
1296
|
+
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.capture = 'environment';
|
|
1297
|
+
input.onchange = async () => {
|
|
1298
|
+
const file = input.files?.[0]; if (!file) return;
|
|
1299
|
+
const btn = document.getElementById('btn-photo'); btn.classList.add('active'); btn.disabled = true;
|
|
1300
|
+
try {
|
|
1301
|
+
const img = new Image(); const url = URL.createObjectURL(file);
|
|
1302
|
+
const blob = await new Promise((resolve, reject) => {
|
|
1303
|
+
img.onload = () => { URL.revokeObjectURL(url); let {width:w, height:h} = img;
|
|
1304
|
+
// 压缩策略:长边限制 1024px,JPEG 质量 0.7,适合截图分析
|
|
1305
|
+
const M = 1024;
|
|
1306
|
+
if(w>M||h>M){if(w>=h){h=Math.round(h*M/w);w=M}else{w=Math.round(w*M/h);h=M}}
|
|
1307
|
+
const c=document.createElement('canvas');c.width=w;c.height=h;c.getContext('2d').drawImage(img,0,0,w,h);
|
|
1308
|
+
c.toBlob(b=>b?resolve(b):reject(new Error('toBlob failed')),'image/jpeg',0.7); };
|
|
1309
|
+
img.onerror=reject; img.src=url; });
|
|
1310
|
+
const form = new FormData(); form.append('image', blob, 'photo.jpg');
|
|
1311
|
+
const res = await fetch(`/upload?sessionId=${SESSION_ID}`, { method: 'POST', body: form }); const data = await res.json();
|
|
1312
|
+
if (data.path) {
|
|
1313
|
+
// 如果输入框有内容,带上用户描述一起发送;否则直接分析图片
|
|
1314
|
+
const userDesc = cmdInput.value.trim();
|
|
1315
|
+
cmdInput.value = `[图片:${data.path}] ${userDesc}`;
|
|
1316
|
+
sendCommand();
|
|
1317
|
+
}
|
|
1318
|
+
else addStatusMessage('上传失败: ' + (data.error || '未知'));
|
|
1319
|
+
} catch (e) { addStatusMessage('出错: ' + e.message); }
|
|
1320
|
+
finally { btn.classList.remove('active'); btn.disabled = false; }
|
|
1321
|
+
}; input.click();
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
// ── 语音输入 ──────────────────────────────────
|
|
1325
|
+
let recognition = null;
|
|
1326
|
+
let isRecording = false;
|
|
1327
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1328
|
+
const VOICE_FALLBACK_KEY = 'myhi_voice_fallback';
|
|
1329
|
+
|
|
1330
|
+
// 检测语音支持级别
|
|
1331
|
+
function detectVoiceSupport() {
|
|
1332
|
+
if (!SpeechRecognition) return 'none';
|
|
1333
|
+
const ua = navigator.userAgent;
|
|
1334
|
+
// iOS (iPhone/iPad/iPod) 走 Apple Siri,中国可用
|
|
1335
|
+
if (/iPhone|iPad|iPod/i.test(ua)) return 'native';
|
|
1336
|
+
// macOS Safari 也走 Siri
|
|
1337
|
+
if (/Macintosh.*Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'native';
|
|
1338
|
+
// 其他有 API 的浏览器(Android Chrome、国产浏览器等)依赖 Google,不可靠
|
|
1339
|
+
return 'unreliable';
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const _voiceSupport = detectVoiceSupport();
|
|
1343
|
+
|
|
1344
|
+
function showVoiceFallbackTip() {
|
|
1345
|
+
const ua = navigator.userAgent;
|
|
1346
|
+
if (/Firefox/i.test(ua)) {
|
|
1347
|
+
addStatusMessage('Firefox 不支持语音识别,请使用 Chrome/Safari 或键盘上的 🎤 语音输入');
|
|
1348
|
+
} else if (/Android/i.test(ua)) {
|
|
1349
|
+
addStatusMessage('语音识别不可用,请点击键盘上的 🎤 麦克风图标使用输入法语音');
|
|
1350
|
+
} else {
|
|
1351
|
+
addStatusMessage('语音识别不可用,请使用输入法的语音功能(键盘上的 🎤)');
|
|
1352
|
+
}
|
|
1353
|
+
cmdInput.focus();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
window.toggleVoice = function() {
|
|
1357
|
+
// 完全不支持
|
|
1358
|
+
if (_voiceSupport === 'none') {
|
|
1359
|
+
showVoiceFallbackTip();
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// 非 HTTPS 且非 localhost 时,语音识别无法工作
|
|
1364
|
+
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
|
|
1365
|
+
const httpsUrl = location.href.replace(/^http:/, 'https:').replace(':' + location.port, ':3443');
|
|
1366
|
+
addStatusMessage('语音识别需要 HTTPS,请通过 HTTPS 地址访问');
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// 之前失败过,直接显示回退提示(避免反复失败)
|
|
1371
|
+
if (_voiceSupport === 'unreliable' && localStorage.getItem(VOICE_FALLBACK_KEY)) {
|
|
1372
|
+
showVoiceFallbackTip();
|
|
1373
|
+
addStatusMessage('(长按语音按钮可重试语音识别)');
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (isRecording) {
|
|
1378
|
+
recognition.stop();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
recognition = new SpeechRecognition();
|
|
1383
|
+
recognition.lang = 'zh-CN';
|
|
1384
|
+
recognition.interimResults = true;
|
|
1385
|
+
recognition.continuous = false;
|
|
1386
|
+
recognition.maxAlternatives = 1;
|
|
1387
|
+
|
|
1388
|
+
const voiceBtn = document.getElementById('voice-btn');
|
|
1389
|
+
const savedValue = cmdInput.value;
|
|
1390
|
+
let hasResult = false;
|
|
1391
|
+
let gotAudio = false;
|
|
1392
|
+
let errorOccurred = false;
|
|
1393
|
+
let networkTimeout = null;
|
|
1394
|
+
|
|
1395
|
+
function stopRecording() {
|
|
1396
|
+
isRecording = false;
|
|
1397
|
+
clearTimeout(networkTimeout);
|
|
1398
|
+
voiceBtn.classList.remove('recording');
|
|
1399
|
+
cmdInput.placeholder = currentSession?.mode === 'agent' ? '输入消息...' : '输入命令...';
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
recognition.onstart = () => {
|
|
1403
|
+
isRecording = true;
|
|
1404
|
+
hasResult = false;
|
|
1405
|
+
gotAudio = false;
|
|
1406
|
+
errorOccurred = false;
|
|
1407
|
+
voiceBtn.classList.add('recording');
|
|
1408
|
+
cmdInput.placeholder = '正在听...';
|
|
1409
|
+
// 不可靠环境下设置超时:3秒内没结果视为失败
|
|
1410
|
+
if (_voiceSupport === 'unreliable') {
|
|
1411
|
+
networkTimeout = setTimeout(() => {
|
|
1412
|
+
if (!hasResult && !errorOccurred) {
|
|
1413
|
+
recognition.abort();
|
|
1414
|
+
localStorage.setItem(VOICE_FALLBACK_KEY, '1');
|
|
1415
|
+
stopRecording();
|
|
1416
|
+
addStatusMessage('语音服务连接超时,请使用输入法的语音功能(键盘上的 🎤)');
|
|
1417
|
+
}
|
|
1418
|
+
}, 3000);
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
recognition.onaudiostart = () => { gotAudio = true; };
|
|
1423
|
+
|
|
1424
|
+
recognition.onresult = (e) => {
|
|
1425
|
+
hasResult = true;
|
|
1426
|
+
clearTimeout(networkTimeout);
|
|
1427
|
+
let interim = '', final = '';
|
|
1428
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
1429
|
+
if (e.results[i].isFinal) final += e.results[i][0].transcript;
|
|
1430
|
+
else interim += e.results[i][0].transcript;
|
|
1431
|
+
}
|
|
1432
|
+
cmdInput.value = savedValue + final + interim;
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
recognition.onend = () => {
|
|
1436
|
+
stopRecording();
|
|
1437
|
+
if (!errorOccurred && !hasResult) {
|
|
1438
|
+
if (!gotAudio) {
|
|
1439
|
+
addStatusMessage('未检测到麦克风音频,请检查麦克风是否正常');
|
|
1440
|
+
} else {
|
|
1441
|
+
addStatusMessage('未识别到语音内容,请重试');
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
cmdInput.focus();
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
recognition.onerror = (e) => {
|
|
1448
|
+
errorOccurred = true;
|
|
1449
|
+
console.warn('[语音识别错误]', e.error, e.message);
|
|
1450
|
+
stopRecording();
|
|
1451
|
+
if (e.error === 'network' || e.error === 'service-not-allowed') {
|
|
1452
|
+
// 记住失败状态,下次直接显示回退提示
|
|
1453
|
+
localStorage.setItem(VOICE_FALLBACK_KEY, '1');
|
|
1454
|
+
addStatusMessage('语音服务连接失败,请使用输入法的语音功能(键盘上的 🎤)');
|
|
1455
|
+
} else if (e.error === 'not-allowed') {
|
|
1456
|
+
addStatusMessage('麦克风权限被拒绝,请在浏览器设置中允许');
|
|
1457
|
+
} else if (e.error === 'no-speech') {
|
|
1458
|
+
addStatusMessage('未检测到语音,请对着麦克风说话后重试');
|
|
1459
|
+
} else if (e.error === 'audio-capture') {
|
|
1460
|
+
addStatusMessage('无法捕获音频,请检查麦克风设备');
|
|
1461
|
+
} else if (e.error !== 'aborted') {
|
|
1462
|
+
addStatusMessage('语音识别失败: ' + e.error);
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
try {
|
|
1467
|
+
recognition.start();
|
|
1468
|
+
} catch (e) {
|
|
1469
|
+
stopRecording();
|
|
1470
|
+
addStatusMessage('语音识别启动失败: ' + e.message);
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
// 长按语音按钮重置回退状态,允许重试
|
|
1475
|
+
document.getElementById('voice-btn').addEventListener('contextmenu', (e) => {
|
|
1476
|
+
e.preventDefault();
|
|
1477
|
+
localStorage.removeItem(VOICE_FALLBACK_KEY);
|
|
1478
|
+
addStatusMessage('语音识别已重置,点击 🎤 重试');
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
cmdInput.focus();
|
|
1482
|
+
</script>
|
|
1483
|
+
</body>
|
|
1484
|
+
</html>
|