feihong-code 0.6.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -446
- package/dist/agent/orchestrator.js +33 -6
- package/dist/agent/orchestrator.js.map +1 -1
- package/dist/agent/parallel-orchestrator.js +6 -3
- package/dist/agent/parallel-orchestrator.js.map +1 -1
- package/dist/agent/prompts.js +46 -16
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/self-heal.js +2 -2
- package/dist/agent/self-heal.js.map +1 -1
- package/dist/cli/repl.js +10 -9
- package/dist/cli/repl.js.map +1 -1
- package/dist/cli/run.js +26 -23
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/shared/concurrency.js +80 -0
- package/dist/shared/concurrency.js.map +1 -0
- package/dist/web/public/css/style.css +132 -7
- package/dist/web/public/index.html +193 -9
- package/dist/web/public/js/api.js +76 -3
- package/dist/web/public/js/app.js +858 -120
- package/dist/web/public/js/ui.js +367 -140
- package/dist/web/public/js/utils.js +44 -0
- package/dist/web/server.js +897 -53
- package/dist/web/server.js.map +1 -1
- package/dist/web/task-queue.js +14 -6
- package/dist/web/task-queue.js.map +1 -1
- package/docs/BENCHMARK_REPORT_en.md +329 -0
- package/docs/BENCHMARK_REPORT_zh.md +329 -0
- package/docs/TECHNICAL_SPEC_en.md +292 -0
- package/docs/TECHNICAL_SPEC_zh.md +292 -0
- package/docs/UPDATE_NOTES_en.md +159 -0
- package/docs/UPDATE_NOTES_zh.md +159 -0
- package/electron/main.js +404 -0
- package/package.json +62 -3
- package/tool-schema.json +2 -2
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
'chat.screenshot': '📷 截图',
|
|
38
38
|
'chat.upload_file': '📄 上传文件',
|
|
39
39
|
'chat.upload_image': '🖼 上传图片',
|
|
40
|
-
'chat.direct_mode': '🖥
|
|
40
|
+
'chat.direct_mode': '🖥 电脑操作',
|
|
41
41
|
'chat.voice': '🎤 语音输入',
|
|
42
42
|
'chat.agent_type': '🤖 选择智能体类型',
|
|
43
43
|
'chat.agent_default': '🤖 通用助手',
|
|
@@ -49,6 +49,25 @@
|
|
|
49
49
|
'call.voice': '📞 语音',
|
|
50
50
|
'call.video': '🎥 视频',
|
|
51
51
|
'call.hangup': '⛔ 挂断',
|
|
52
|
+
'call.voice_call': '语音通话',
|
|
53
|
+
'call.video_call': '视频通话',
|
|
54
|
+
'call.voice_hint': '语音通话中,说话内容实时转文字',
|
|
55
|
+
'call.video_hint': '视频通话中,可截取画面发送',
|
|
56
|
+
'call.ended': '通话已结束,识别的文字已保留在输入框',
|
|
57
|
+
'computer.tab': '🖥 电脑操作',
|
|
58
|
+
'computer.capture': '📷 截取屏幕',
|
|
59
|
+
'computer.mouse_pos': '坐标',
|
|
60
|
+
'computer.click_left': '左键点击',
|
|
61
|
+
'computer.click_right': '右键点击',
|
|
62
|
+
'computer.double_click': '双击',
|
|
63
|
+
'computer.type_hint': '输入文字后回车发送',
|
|
64
|
+
'computer.screenshot_added': '摄像头画面已添加到输入框,可直接发送',
|
|
65
|
+
'computer.screenshot_not_ready': '视频还没准备好,请稍等',
|
|
66
|
+
'msg.copy': '📋 复制',
|
|
67
|
+
'msg.create_doc': '📄 建文档',
|
|
68
|
+
'msg.copied': '已复制到剪贴板',
|
|
69
|
+
'msg.doc_downloaded': '文档已下载',
|
|
70
|
+
'msg.no_content': '没有可复制的内容',
|
|
52
71
|
'crop.tip': '按住鼠标拖拽选择截图区域 · 松开确认 · ESC 取消',
|
|
53
72
|
'page.automations.sub': '把常用目标存成指令,一键发起任务',
|
|
54
73
|
'btn.new_automation': '+ 新建指令',
|
|
@@ -218,7 +237,7 @@
|
|
|
218
237
|
'chat.screenshot': '📷 Screenshot',
|
|
219
238
|
'chat.upload_file': '📄 Upload File',
|
|
220
239
|
'chat.upload_image': '🖼 Upload Image',
|
|
221
|
-
'chat.direct_mode': '🖥
|
|
240
|
+
'chat.direct_mode': '🖥 Computer Control',
|
|
222
241
|
'chat.voice': '🎤 Voice Input',
|
|
223
242
|
'chat.agent_type': '🤖 Agent Type',
|
|
224
243
|
'chat.agent_default': '🤖 Assistant',
|
|
@@ -230,6 +249,25 @@
|
|
|
230
249
|
'call.voice': '📞 Voice',
|
|
231
250
|
'call.video': '🎥 Video',
|
|
232
251
|
'call.hangup': '⛔ Hang Up',
|
|
252
|
+
'call.voice_call': 'Voice Call',
|
|
253
|
+
'call.video_call': 'Video Call',
|
|
254
|
+
'call.voice_hint': 'Voice call in progress, speech transcribed in real-time',
|
|
255
|
+
'call.video_hint': 'Video call in progress, capture frames to send',
|
|
256
|
+
'call.ended': 'Call ended, transcribed text kept in input box',
|
|
257
|
+
'computer.tab': '🖥 Computer Control',
|
|
258
|
+
'computer.capture': '📷 Capture Screen',
|
|
259
|
+
'computer.mouse_pos': 'Pos',
|
|
260
|
+
'computer.click_left': 'Left Click',
|
|
261
|
+
'computer.click_right': 'Right Click',
|
|
262
|
+
'computer.double_click': 'Double Click',
|
|
263
|
+
'computer.type_hint': 'Type text and press Enter to send',
|
|
264
|
+
'computer.screenshot_added': 'Camera frame added to input box, ready to send',
|
|
265
|
+
'computer.screenshot_not_ready': 'Video not ready yet, please wait',
|
|
266
|
+
'msg.copy': '📋 Copy',
|
|
267
|
+
'msg.create_doc': '📄 New Doc',
|
|
268
|
+
'msg.copied': 'Copied to clipboard',
|
|
269
|
+
'msg.doc_downloaded': 'Document downloaded',
|
|
270
|
+
'msg.no_content': 'No content to copy',
|
|
233
271
|
'crop.tip': 'Drag to select a screenshot area · Release to confirm · ESC to cancel',
|
|
234
272
|
'page.automations.sub': 'Save common goals as shortcuts, launch tasks with one click',
|
|
235
273
|
'btn.new_automation': '+ New Command',
|
|
@@ -440,6 +478,16 @@
|
|
|
440
478
|
};
|
|
441
479
|
|
|
442
480
|
(function initSession() {
|
|
481
|
+
// 提前检测登录状态,给 html 加 class,配合 CSS 避免登录页闪烁(感觉像打开两次)
|
|
482
|
+
try {
|
|
483
|
+
const urlToken = new URLSearchParams(location.search).get('token');
|
|
484
|
+
if (urlToken) localStorage.setItem('fhcode.token', urlToken);
|
|
485
|
+
const t = localStorage.getItem('fhcode.token');
|
|
486
|
+
const p = localStorage.getItem('fhcode.phone');
|
|
487
|
+
if (t && p) {
|
|
488
|
+
document.documentElement.classList.add('logged-in');
|
|
489
|
+
}
|
|
490
|
+
} catch(e) {}
|
|
443
491
|
const urlToken = new URLSearchParams(location.search).get('token');
|
|
444
492
|
if (urlToken) localStorage.setItem('fhcode.token', urlToken);
|
|
445
493
|
const savedModel = localStorage.getItem('fhcode.model');
|
|
@@ -539,6 +587,40 @@
|
|
|
539
587
|
}
|
|
540
588
|
}
|
|
541
589
|
|
|
590
|
+
// 消息操作按钮事件委托(复制、创建文档)
|
|
591
|
+
document.getElementById('messages')?.addEventListener('click', (e) => {
|
|
592
|
+
const btn = e.target.closest('.msg-action-btn');
|
|
593
|
+
if (!btn) return;
|
|
594
|
+
e.stopPropagation();
|
|
595
|
+
const action = btn.getAttribute('data-action');
|
|
596
|
+
const msgEl = btn.closest('.msg');
|
|
597
|
+
if (!msgEl) return;
|
|
598
|
+
// 获取消息文本内容(排除操作按钮)
|
|
599
|
+
const actionsEl = msgEl.querySelector('.msg-actions');
|
|
600
|
+
let content = '';
|
|
601
|
+
if (actionsEl) {
|
|
602
|
+
// 临时移除操作按钮,获取文本,再放回去
|
|
603
|
+
const parent = actionsEl.parentNode;
|
|
604
|
+
const nextSibling = actionsEl.nextSibling;
|
|
605
|
+
parent.removeChild(actionsEl);
|
|
606
|
+
content = msgEl.textContent || '';
|
|
607
|
+
if (nextSibling) parent.insertBefore(actionsEl, nextSibling);
|
|
608
|
+
else parent.appendChild(actionsEl);
|
|
609
|
+
} else {
|
|
610
|
+
content = msgEl.textContent || '';
|
|
611
|
+
}
|
|
612
|
+
content = content.trim();
|
|
613
|
+
if (!content) {
|
|
614
|
+
toast('没有可复制的内容');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (action === 'copy') {
|
|
618
|
+
handleBubbleAction('copy', content);
|
|
619
|
+
} else if (action === 'create-doc') {
|
|
620
|
+
handleBubbleAction('doc', content);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
542
624
|
/* ========== 登录 ========== */
|
|
543
625
|
document.getElementById('loginBtn').addEventListener('click', doLogin);
|
|
544
626
|
document.getElementById('loginPhone').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
|
@@ -561,7 +643,7 @@
|
|
|
561
643
|
}
|
|
562
644
|
showWelcomeGuide([]);
|
|
563
645
|
}
|
|
564
|
-
await Promise.allSettled([loadTasks(), loadAutomations(), loadTemplates(), loadMarket(), loadOffice(), loadWorkspace(), loadModels(), loadMemoryStats()]);
|
|
646
|
+
await Promise.allSettled([loadTasks(), loadAutomations(), loadTemplates(), loadMarket(), loadOffice(), loadWorkspace(), loadModels(), loadMemoryStats(), loadNodes(), loadSources()]);
|
|
565
647
|
startRefresh();
|
|
566
648
|
}
|
|
567
649
|
|
|
@@ -786,7 +868,7 @@
|
|
|
786
868
|
// 允许只发送文件(无文本)
|
|
787
869
|
if (!goal && !state.stagedFiles.length) return;
|
|
788
870
|
if (!state.token) { toast('请先登录'); return; }
|
|
789
|
-
if (state.directMode) goal = goal ? ('[
|
|
871
|
+
if (state.directMode) goal = goal ? ('[电脑操作] ' + goal) : '[电脑操作](仅附件)';
|
|
790
872
|
const btn = document.getElementById('sendBtn');
|
|
791
873
|
btn.disabled = true;
|
|
792
874
|
try {
|
|
@@ -883,6 +965,25 @@
|
|
|
883
965
|
}
|
|
884
966
|
document.getElementById('sendBtn').addEventListener('click', sendTask);
|
|
885
967
|
document.getElementById('goalInput').addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) sendTask(); });
|
|
968
|
+
// 粘贴图片:用户用 Win+Shift+S 截图后可直接粘贴到输入框
|
|
969
|
+
document.getElementById('goalInput').addEventListener('paste', (e) => {
|
|
970
|
+
const items = e.clipboardData?.items;
|
|
971
|
+
if (!items) return;
|
|
972
|
+
for (const item of items) {
|
|
973
|
+
if (item.type.startsWith('image/')) {
|
|
974
|
+
e.preventDefault();
|
|
975
|
+
const file = item.getAsFile();
|
|
976
|
+
if (!file) continue;
|
|
977
|
+
const reader = new FileReader();
|
|
978
|
+
reader.onload = () => {
|
|
979
|
+
stageFile({ name: 'paste-' + Date.now() + '.png', mime: file.type || 'image/png', dataUrl: String(reader.result) });
|
|
980
|
+
toast('图片已粘贴到输入框,可直接输入文字发送');
|
|
981
|
+
};
|
|
982
|
+
reader.readAsDataURL(file);
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
});
|
|
886
987
|
document.getElementById('tchRefresh').addEventListener('click', () => { refreshCurrentThread(); toast('已刷新当前任务'); });
|
|
887
988
|
document.getElementById('tchStop').addEventListener('click', async () => {
|
|
888
989
|
if (!state.currentTaskId) return;
|
|
@@ -894,6 +995,7 @@
|
|
|
894
995
|
});
|
|
895
996
|
document.getElementById('tchNewTask').addEventListener('click', () => { try { openNewTaskModal(); } catch { toast('新建任务面板不可用'); } });
|
|
896
997
|
document.getElementById('screenshotBtn').addEventListener('click', takeScreenshot);
|
|
998
|
+
document.getElementById('voiceBtn')?.addEventListener('click', startVoice);
|
|
897
999
|
|
|
898
1000
|
/* ========== 输入框右键菜单(复制/剪切/粘贴/全选) ========== */
|
|
899
1001
|
const goalInput = document.getElementById('goalInput');
|
|
@@ -1047,8 +1149,8 @@
|
|
|
1047
1149
|
} catch (e) { toast('创建失败:' + e.message); }
|
|
1048
1150
|
finally { btn.disabled = false; }
|
|
1049
1151
|
}
|
|
1050
|
-
document.getElementById('newTaskBtn')?.addEventListener('click',
|
|
1051
|
-
document.getElementById('tchNewTask')?.addEventListener('click',
|
|
1152
|
+
document.getElementById('newTaskBtn')?.addEventListener('click', () => { switchView('chat'); startNewChat(); });
|
|
1153
|
+
document.getElementById('tchNewTask')?.addEventListener('click', startNewChat);
|
|
1052
1154
|
document.getElementById('ntCreateBtn')?.addEventListener('click', createNewTask);
|
|
1053
1155
|
document.getElementById('ntGoal')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) createNewTask(); });
|
|
1054
1156
|
|
|
@@ -1076,17 +1178,63 @@
|
|
|
1076
1178
|
});
|
|
1077
1179
|
|
|
1078
1180
|
|
|
1181
|
+
// 截图锁,防止重复触发
|
|
1182
|
+
let screenshotRunning = false;
|
|
1183
|
+
|
|
1079
1184
|
async function takeScreenshot() {
|
|
1185
|
+
if (screenshotRunning) {
|
|
1186
|
+
toast('正在截图中,请稍候...');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
screenshotRunning = true;
|
|
1080
1190
|
try {
|
|
1081
1191
|
const overlay = document.getElementById('cropOverlay');
|
|
1082
1192
|
const selection = document.getElementById('cropSelection');
|
|
1083
1193
|
const video = document.getElementById('cropVideo');
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1194
|
+
|
|
1195
|
+
let srcImage = null; // 用于裁剪的源图像(Image 对象)
|
|
1196
|
+
let stream = null; // 浏览器模式下的视频流
|
|
1197
|
+
|
|
1198
|
+
// Electron 环境:用主进程截图,更稳定
|
|
1199
|
+
if (window.isElectron && window.electronAPI && window.electronAPI.screenshot) {
|
|
1200
|
+
toast('正在截取屏幕...');
|
|
1201
|
+
const result = await window.electronAPI.screenshot.capture();
|
|
1202
|
+
if (!result || !result.success) {
|
|
1203
|
+
toast('截图失败:' + (result?.error || '未知错误'));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
// 加载截图为 Image 对象
|
|
1207
|
+
srcImage = new Image();
|
|
1208
|
+
srcImage.src = result.dataUrl;
|
|
1209
|
+
await new Promise((resolve, reject) => {
|
|
1210
|
+
srcImage.onload = resolve;
|
|
1211
|
+
srcImage.onerror = () => reject(new Error('截图加载失败'));
|
|
1212
|
+
});
|
|
1213
|
+
// 把截图显示到 video 元素的位置(用一个 img 覆盖)
|
|
1214
|
+
video.style.display = 'none';
|
|
1215
|
+
let bgImg = document.getElementById('cropBgImg');
|
|
1216
|
+
if (!bgImg) {
|
|
1217
|
+
bgImg = document.createElement('img');
|
|
1218
|
+
bgImg.id = 'cropBgImg';
|
|
1219
|
+
bgImg.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;object-fit:cover;z-index:9998;';
|
|
1220
|
+
overlay.appendChild(bgImg);
|
|
1221
|
+
}
|
|
1222
|
+
bgImg.src = result.dataUrl;
|
|
1223
|
+
bgImg.style.display = 'block';
|
|
1224
|
+
} else {
|
|
1225
|
+
// 浏览器模式:用 getDisplayMedia
|
|
1226
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
1227
|
+
video.srcObject = stream;
|
|
1228
|
+
await video.play();
|
|
1229
|
+
video.style.display = 'block';
|
|
1230
|
+
const bgImg = document.getElementById('cropBgImg');
|
|
1231
|
+
if (bgImg) bgImg.style.display = 'none';
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1087
1234
|
overlay.style.display = 'block';
|
|
1088
1235
|
selection.style.display = 'none';
|
|
1089
1236
|
let startX = 0, startY = 0, drawing = false;
|
|
1237
|
+
|
|
1090
1238
|
const onDown = (e) => {
|
|
1091
1239
|
drawing = true;
|
|
1092
1240
|
startX = e.clientX; startY = e.clientY;
|
|
@@ -1114,36 +1262,59 @@
|
|
|
1114
1262
|
const x = Math.min(e.clientX, startX), y = Math.min(e.clientY, startY);
|
|
1115
1263
|
const w = Math.abs(e.clientX - startX), h = Math.abs(e.clientY - startY);
|
|
1116
1264
|
overlay.style.display = 'none';
|
|
1117
|
-
if (w < 8 || h < 8) {
|
|
1265
|
+
if (w < 8 || h < 8) {
|
|
1266
|
+
if (stream) stream.getTracks().forEach((t) => t.stop());
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1118
1269
|
try {
|
|
1119
|
-
|
|
1270
|
+
let srcW, srcH, source;
|
|
1271
|
+
if (srcImage) {
|
|
1272
|
+
// Electron 模式:从 Image 裁剪
|
|
1273
|
+
srcW = srcImage.naturalWidth;
|
|
1274
|
+
srcH = srcImage.naturalHeight;
|
|
1275
|
+
source = srcImage;
|
|
1276
|
+
} else {
|
|
1277
|
+
// 浏览器模式:从 video 裁剪
|
|
1278
|
+
srcW = video.videoWidth;
|
|
1279
|
+
srcH = video.videoHeight;
|
|
1280
|
+
source = video;
|
|
1281
|
+
}
|
|
1120
1282
|
const scaleX = srcW / window.innerWidth, scaleY = srcH / window.innerHeight;
|
|
1121
1283
|
const sx = Math.round(x * scaleX), sy = Math.round(y * scaleY);
|
|
1122
1284
|
const sw = Math.round(w * scaleX), sh = Math.round(h * scaleY);
|
|
1123
1285
|
const canvas = document.createElement('canvas');
|
|
1124
1286
|
canvas.width = sw; canvas.height = sh;
|
|
1125
|
-
canvas.getContext('2d').drawImage(
|
|
1126
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
1287
|
+
canvas.getContext('2d').drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
1288
|
+
if (stream) stream.getTracks().forEach((t) => t.stop());
|
|
1127
1289
|
const dataUrl = canvas.toDataURL('image/png');
|
|
1128
1290
|
stageFile({ name: 'screenshot.png', mime: 'image/png', dataUrl });
|
|
1129
|
-
toast('
|
|
1291
|
+
toast('截图已添加到输入框,可直接输入文字发送');
|
|
1130
1292
|
} catch (err) { toast('截图失败:' + err.message); }
|
|
1293
|
+
screenshotRunning = false;
|
|
1131
1294
|
};
|
|
1132
1295
|
const onKey = (e) => {
|
|
1133
1296
|
if (e.key === 'Escape') {
|
|
1134
1297
|
overlay.style.display = 'none';
|
|
1135
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
1298
|
+
if (stream) stream.getTracks().forEach((t) => t.stop());
|
|
1136
1299
|
window.removeEventListener('mousedown', onDown);
|
|
1137
1300
|
window.removeEventListener('mousemove', onMove);
|
|
1138
1301
|
window.removeEventListener('mouseup', finish);
|
|
1139
1302
|
window.removeEventListener('keydown', onKey);
|
|
1303
|
+
screenshotRunning = false;
|
|
1140
1304
|
}
|
|
1141
1305
|
};
|
|
1142
1306
|
window.addEventListener('mousedown', onDown);
|
|
1143
1307
|
window.addEventListener('mousemove', onMove);
|
|
1144
1308
|
window.addEventListener('mouseup', finish);
|
|
1145
1309
|
window.addEventListener('keydown', onKey);
|
|
1146
|
-
} catch (e) {
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
if (e.name === 'NotAllowedError') {
|
|
1312
|
+
toast('已取消截图。也可以用 Win+Shift+S 截图后直接粘贴到输入框');
|
|
1313
|
+
} else {
|
|
1314
|
+
toast('截图失败:' + e.message);
|
|
1315
|
+
}
|
|
1316
|
+
screenshotRunning = false;
|
|
1317
|
+
}
|
|
1147
1318
|
}
|
|
1148
1319
|
|
|
1149
1320
|
function handleUpload(input, mimePrefix) {
|
|
@@ -1225,48 +1396,336 @@
|
|
|
1225
1396
|
document.getElementById('imReset').addEventListener('click', resetInlineForm);
|
|
1226
1397
|
document.getElementById('modelManageClose')?.addEventListener('click', () => { document.getElementById('modelManage').style.display = 'none'; });
|
|
1227
1398
|
|
|
1228
|
-
/* ==========
|
|
1399
|
+
/* ========== 通话控件(语音/视频通话 + 实时语音识别) ========== */
|
|
1229
1400
|
let callStream = null;
|
|
1401
|
+
let callKind = null;
|
|
1402
|
+
|
|
1230
1403
|
async function startCall(kind) {
|
|
1231
1404
|
const status = document.getElementById('callStatus');
|
|
1232
1405
|
try {
|
|
1233
|
-
const constraints = kind === 'video'
|
|
1406
|
+
const constraints = kind === 'video'
|
|
1407
|
+
? { audio: true, video: { width: 640, height: 480 } }
|
|
1408
|
+
: { audio: true };
|
|
1234
1409
|
callStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
1235
|
-
|
|
1410
|
+
callKind = kind;
|
|
1411
|
+
|
|
1412
|
+
// 视频通话:显示摄像头画面
|
|
1413
|
+
if (kind === 'video') {
|
|
1414
|
+
const videoWindow = document.getElementById('videoCallWindow');
|
|
1415
|
+
const localVideo = document.getElementById('localVideo');
|
|
1416
|
+
localVideo.srcObject = callStream;
|
|
1417
|
+
videoWindow.style.display = 'block';
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
status.textContent = (kind === 'video' ? '视频通话中' : '语音通话中') + '(语音实时转文字)';
|
|
1236
1421
|
document.getElementById('hangupCallBtn').style.display = 'inline-block';
|
|
1237
|
-
|
|
1422
|
+
document.getElementById('voiceCallBtn').style.display = 'none';
|
|
1423
|
+
document.getElementById('videoCallBtn').style.display = 'none';
|
|
1424
|
+
|
|
1425
|
+
// 开启语音识别(复用语音输入模块)
|
|
1426
|
+
if (!voiceState.isListening) {
|
|
1427
|
+
voiceState.finalText = '';
|
|
1428
|
+
voiceState.interimText = '';
|
|
1429
|
+
voiceState.originalText = document.getElementById('goalInput').value;
|
|
1430
|
+
voiceState.manuallyStopped = false;
|
|
1431
|
+
voiceState.restartCount = 0;
|
|
1432
|
+
updateVoiceUI(true);
|
|
1433
|
+
startRecognition();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
toast('已发起' + (kind === 'video' ? '视频' : '语音') + '通话,说话内容会实时转成文字');
|
|
1238
1437
|
} catch (e) {
|
|
1239
1438
|
status.textContent = '';
|
|
1240
|
-
|
|
1439
|
+
if (e.name === 'NotAllowedError') {
|
|
1440
|
+
toast('请允许浏览器使用麦克风和摄像头权限');
|
|
1441
|
+
} else if (e.name === 'NotFoundError') {
|
|
1442
|
+
toast('未找到麦克风或摄像头设备');
|
|
1443
|
+
} else {
|
|
1444
|
+
toast('无法发起通话:' + e.message);
|
|
1445
|
+
}
|
|
1241
1446
|
}
|
|
1242
1447
|
}
|
|
1448
|
+
|
|
1243
1449
|
function hangupCall() {
|
|
1244
|
-
|
|
1450
|
+
// 先保存识别的文字到输入框(确保内容不会丢失)
|
|
1451
|
+
if (voiceState.finalText) {
|
|
1452
|
+
updateVoiceInput();
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// 停止媒体流
|
|
1456
|
+
if (callStream) {
|
|
1457
|
+
callStream.getTracks().forEach((t) => t.stop());
|
|
1458
|
+
callStream = null;
|
|
1459
|
+
}
|
|
1460
|
+
callKind = null;
|
|
1461
|
+
|
|
1462
|
+
// 隐藏视频窗口
|
|
1463
|
+
const videoWindow = document.getElementById('videoCallWindow');
|
|
1464
|
+
const localVideo = document.getElementById('localVideo');
|
|
1465
|
+
if (videoWindow) videoWindow.style.display = 'none';
|
|
1466
|
+
if (localVideo) localVideo.srcObject = null;
|
|
1467
|
+
|
|
1468
|
+
// 停止语音识别
|
|
1469
|
+
if (voiceState.isListening) {
|
|
1470
|
+
stopVoiceInput();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1245
1473
|
document.getElementById('callStatus').textContent = '';
|
|
1246
1474
|
document.getElementById('hangupCallBtn').style.display = 'none';
|
|
1247
|
-
|
|
1475
|
+
document.getElementById('voiceCallBtn').style.display = 'inline-block';
|
|
1476
|
+
document.getElementById('videoCallBtn').style.display = 'inline-block';
|
|
1477
|
+
toast('通话已结束,识别的文字已保留在输入框');
|
|
1248
1478
|
}
|
|
1479
|
+
|
|
1249
1480
|
document.getElementById('voiceCallBtn').addEventListener('click', () => startCall('voice'));
|
|
1250
1481
|
document.getElementById('videoCallBtn').addEventListener('click', () => startCall('video'));
|
|
1251
1482
|
document.getElementById('hangupCallBtn').addEventListener('click', hangupCall);
|
|
1483
|
+
document.getElementById('videoCallClose')?.addEventListener('click', hangupCall);
|
|
1252
1484
|
|
|
1253
|
-
|
|
1485
|
+
// 视频通话截图:把当前摄像头画面截取下来放到输入框
|
|
1486
|
+
document.getElementById('videoSnapshotBtn')?.addEventListener('click', () => {
|
|
1487
|
+
const video = document.getElementById('localVideo');
|
|
1488
|
+
if (!video || !video.videoWidth) {
|
|
1489
|
+
toast('视频还没准备好,请稍等');
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
const canvas = document.createElement('canvas');
|
|
1494
|
+
canvas.width = video.videoWidth;
|
|
1495
|
+
canvas.height = video.videoHeight;
|
|
1496
|
+
canvas.getContext('2d').drawImage(video, 0, 0);
|
|
1497
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
1498
|
+
stageFile({
|
|
1499
|
+
name: 'camera-' + Date.now() + '.png',
|
|
1500
|
+
mime: 'image/png',
|
|
1501
|
+
dataUrl: dataUrl,
|
|
1502
|
+
});
|
|
1503
|
+
toast('摄像头画面已添加到输入框,可直接发送');
|
|
1504
|
+
} catch (e) {
|
|
1505
|
+
toast('截图失败:' + e.message);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
/* ========== 语音输入模块 ========== */
|
|
1510
|
+
const voiceState = {
|
|
1511
|
+
recognition: null,
|
|
1512
|
+
finalText: '',
|
|
1513
|
+
interimText: '',
|
|
1514
|
+
originalText: '',
|
|
1515
|
+
isListening: false,
|
|
1516
|
+
manuallyStopped: false,
|
|
1517
|
+
restartTimer: null,
|
|
1518
|
+
restartCount: 0,
|
|
1519
|
+
maxRestarts: 10000, // 基本无限制,长时间通话也不会停
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// 优化语音识别文本:去掉多余空格、自动加标点、清理重复
|
|
1523
|
+
function optimizeVoiceText(text) {
|
|
1524
|
+
if (!text) return '';
|
|
1525
|
+
let result = text.trim();
|
|
1526
|
+
// 去掉多余空格
|
|
1527
|
+
result = result.replace(/\s+/g, ' ');
|
|
1528
|
+
// 去掉首尾空格
|
|
1529
|
+
result = result.trim();
|
|
1530
|
+
// 中文标点优化:如果末尾是中文且没有标点,自动加句号
|
|
1531
|
+
if (result && /[\u4e00-\u9fa5]$/.test(result) && !/[。!?,、;:""''()]$/.test(result)) {
|
|
1532
|
+
result += '。';
|
|
1533
|
+
}
|
|
1534
|
+
return result;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// 更新输入框显示
|
|
1538
|
+
function updateVoiceInput() {
|
|
1539
|
+
const input = document.getElementById('goalInput');
|
|
1540
|
+
const finalOptimized = optimizeVoiceText(voiceState.finalText);
|
|
1541
|
+
const displayText = voiceState.originalText
|
|
1542
|
+
? (voiceState.originalText + ' ' + finalOptimized + voiceState.interimText)
|
|
1543
|
+
: (finalOptimized + voiceState.interimText);
|
|
1544
|
+
input.value = displayText;
|
|
1545
|
+
input.scrollTop = input.scrollHeight;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// 更新UI状态
|
|
1549
|
+
function updateVoiceUI(listening) {
|
|
1550
|
+
const badge = document.getElementById('voiceBadge');
|
|
1551
|
+
const voiceBtn = document.getElementById('voiceBtn');
|
|
1552
|
+
if (listening) {
|
|
1553
|
+
badge.style.display = 'inline-flex';
|
|
1554
|
+
badge.textContent = '● 正在听…(再次点击停止)';
|
|
1555
|
+
if (voiceBtn) {
|
|
1556
|
+
voiceBtn.style.background = 'var(--brand)';
|
|
1557
|
+
voiceBtn.style.color = '#fff';
|
|
1558
|
+
voiceBtn.textContent = '● 听…';
|
|
1559
|
+
}
|
|
1560
|
+
} else {
|
|
1561
|
+
badge.style.display = 'none';
|
|
1562
|
+
if (voiceBtn) {
|
|
1563
|
+
voiceBtn.style.background = '';
|
|
1564
|
+
voiceBtn.style.color = '';
|
|
1565
|
+
voiceBtn.textContent = '🎤 语音';
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// 创建新的语音识别对象
|
|
1571
|
+
function createRecognition() {
|
|
1254
1572
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1255
|
-
if (!SpeechRecognition)
|
|
1573
|
+
if (!SpeechRecognition) return null;
|
|
1574
|
+
|
|
1256
1575
|
const rec = new SpeechRecognition();
|
|
1257
1576
|
rec.lang = 'zh-CN';
|
|
1258
|
-
rec.continuous =
|
|
1259
|
-
rec.interimResults =
|
|
1260
|
-
|
|
1261
|
-
|
|
1577
|
+
rec.continuous = true;
|
|
1578
|
+
rec.interimResults = true;
|
|
1579
|
+
rec.maxAlternatives = 1;
|
|
1580
|
+
|
|
1581
|
+
// 识别结果处理
|
|
1262
1582
|
rec.onresult = (e) => {
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1583
|
+
let interim = '';
|
|
1584
|
+
let finalDelta = '';
|
|
1585
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
1586
|
+
const transcript = e.results[i][0].transcript;
|
|
1587
|
+
if (e.results[i].isFinal) {
|
|
1588
|
+
finalDelta += transcript;
|
|
1589
|
+
} else {
|
|
1590
|
+
interim += transcript;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (finalDelta) {
|
|
1594
|
+
voiceState.finalText += finalDelta;
|
|
1595
|
+
voiceState.restartCount = 0; // 有新结果,重置重启计数
|
|
1596
|
+
}
|
|
1597
|
+
voiceState.interimText = interim;
|
|
1598
|
+
updateVoiceInput();
|
|
1266
1599
|
};
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
rec.
|
|
1600
|
+
|
|
1601
|
+
// 错误处理
|
|
1602
|
+
rec.onerror = (e) => {
|
|
1603
|
+
console.warn('[语音识别] 错误:', e.error);
|
|
1604
|
+
if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
|
|
1605
|
+
toast('请允许浏览器使用麦克风权限(点击地址栏左侧锁图标)');
|
|
1606
|
+
voiceState.manuallyStopped = true;
|
|
1607
|
+
stopVoiceInput();
|
|
1608
|
+
} else if (e.error === 'network') {
|
|
1609
|
+
toast('语音识别网络错误!Chrome用的是Google服务,国内建议用Edge浏览器,或检查网络/代理');
|
|
1610
|
+
voiceState.manuallyStopped = true;
|
|
1611
|
+
stopVoiceInput();
|
|
1612
|
+
} else if (e.error === 'audio-capture') {
|
|
1613
|
+
toast('未检测到麦克风设备,请检查麦克风连接和系统设置');
|
|
1614
|
+
voiceState.manuallyStopped = true;
|
|
1615
|
+
stopVoiceInput();
|
|
1616
|
+
} else if (e.error === 'no-speech') {
|
|
1617
|
+
// 没检测到语音,继续等待,不停止
|
|
1618
|
+
console.log('[语音识别] 未检测到语音,继续等待...');
|
|
1619
|
+
toast('没听到声音,请检查麦克风是否开启,离麦克风近一点说话');
|
|
1620
|
+
} else if (e.error === 'aborted') {
|
|
1621
|
+
// 手动停止,不处理
|
|
1622
|
+
} else {
|
|
1623
|
+
console.warn('[语音识别] 其他错误:', e.error);
|
|
1624
|
+
toast('语音识别出错: ' + e.error);
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
// 识别结束处理
|
|
1629
|
+
rec.onend = () => {
|
|
1630
|
+
voiceState.recognition = null;
|
|
1631
|
+
voiceState.interimText = '';
|
|
1632
|
+
|
|
1633
|
+
// 如果不是手动停止,自动重启(基本无限制)
|
|
1634
|
+
if (!voiceState.manuallyStopped && voiceState.restartCount < voiceState.maxRestarts) {
|
|
1635
|
+
voiceState.restartCount++;
|
|
1636
|
+
// 只在调试时打印,避免控制台太多日志
|
|
1637
|
+
if (voiceState.restartCount % 50 === 0) {
|
|
1638
|
+
console.log(`[语音识别] 自动重启 (${voiceState.restartCount})`);
|
|
1639
|
+
}
|
|
1640
|
+
voiceState.restartTimer = setTimeout(() => {
|
|
1641
|
+
if (!voiceState.manuallyStopped) {
|
|
1642
|
+
startRecognition();
|
|
1643
|
+
}
|
|
1644
|
+
}, 50); // 50ms快速重启,减少停顿感
|
|
1645
|
+
} else if (voiceState.manuallyStopped) {
|
|
1646
|
+
// 手动停止,最终处理
|
|
1647
|
+
updateVoiceUI(false);
|
|
1648
|
+
if (voiceState.finalText) {
|
|
1649
|
+
updateVoiceInput();
|
|
1650
|
+
toast('语音输入完成,文字已在输入框中,可直接发送');
|
|
1651
|
+
}
|
|
1652
|
+
} else {
|
|
1653
|
+
// 超过最大重启次数(基本不会发生)
|
|
1654
|
+
updateVoiceUI(false);
|
|
1655
|
+
toast('语音输入已自动停止,可再次点击开启');
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
return rec;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// 启动识别
|
|
1663
|
+
function startRecognition() {
|
|
1664
|
+
const rec = createRecognition();
|
|
1665
|
+
if (!rec) {
|
|
1666
|
+
toast('当前浏览器不支持语音输入,请使用 Chrome 或 Edge 浏览器');
|
|
1667
|
+
return false;
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
rec.start();
|
|
1671
|
+
voiceState.recognition = rec;
|
|
1672
|
+
voiceState.isListening = true;
|
|
1673
|
+
return true;
|
|
1674
|
+
} catch (e) {
|
|
1675
|
+
console.error('[语音识别] 启动失败:', e);
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// 停止语音输入
|
|
1681
|
+
function stopVoiceInput() {
|
|
1682
|
+
voiceState.manuallyStopped = true;
|
|
1683
|
+
voiceState.isListening = false;
|
|
1684
|
+
if (voiceState.restartTimer) {
|
|
1685
|
+
clearTimeout(voiceState.restartTimer);
|
|
1686
|
+
voiceState.restartTimer = null;
|
|
1687
|
+
}
|
|
1688
|
+
if (voiceState.recognition) {
|
|
1689
|
+
try { voiceState.recognition.stop(); } catch(e) {}
|
|
1690
|
+
voiceState.recognition = null;
|
|
1691
|
+
}
|
|
1692
|
+
updateVoiceUI(false);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// 开始/停止语音输入(入口函数)
|
|
1696
|
+
function startVoice() {
|
|
1697
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1698
|
+
if (!SpeechRecognition) {
|
|
1699
|
+
toast('当前环境不支持语音输入,请使用 Chrome 或 Edge 浏览器打开网页版');
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// 如果正在识别,就停止
|
|
1704
|
+
if (voiceState.isListening) {
|
|
1705
|
+
stopVoiceInput();
|
|
1706
|
+
toast('已停止语音输入');
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Electron 桌面版特殊提示
|
|
1711
|
+
if (window.isElectron) {
|
|
1712
|
+
toast('桌面版语音输入可能不稳定,建议用网页版(Edge浏览器)');
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// 开始识别
|
|
1716
|
+
voiceState.finalText = '';
|
|
1717
|
+
voiceState.interimText = '';
|
|
1718
|
+
voiceState.originalText = document.getElementById('goalInput').value;
|
|
1719
|
+
voiceState.manuallyStopped = false;
|
|
1720
|
+
voiceState.restartCount = 0;
|
|
1721
|
+
|
|
1722
|
+
updateVoiceUI(true);
|
|
1723
|
+
toast('语音输入已开启,请说话,再次点击停止');
|
|
1724
|
+
|
|
1725
|
+
if (!startRecognition()) {
|
|
1726
|
+
updateVoiceUI(false);
|
|
1727
|
+
toast('启动语音输入失败,请刷新页面重试');
|
|
1728
|
+
}
|
|
1270
1729
|
}
|
|
1271
1730
|
|
|
1272
1731
|
/* ========== 智能体选择 ========== */
|
|
@@ -1298,92 +1757,219 @@
|
|
|
1298
1757
|
});
|
|
1299
1758
|
|
|
1300
1759
|
let fpCurrent = '';
|
|
1301
|
-
|
|
1302
|
-
console.log('[folderPicker] 打开文件夹选择器');
|
|
1303
|
-
console.log('[folderPicker] 当前 state.workspaceDir:', state.workspaceDir);
|
|
1304
|
-
console.log('[folderPicker] 当前 state.token:', state.token ? '已登录' : '未登录');
|
|
1305
|
-
|
|
1306
|
-
// 如果 workspaceDir 为空,尝试从 localStorage 恢复或询问用户
|
|
1307
|
-
let initialDir = state.workspaceDir;
|
|
1308
|
-
if (!initialDir) {
|
|
1309
|
-
// 尝试从 localStorage 恢复
|
|
1310
|
-
const savedCwd = localStorage.getItem('fhcode.workspaceDir');
|
|
1311
|
-
if (savedCwd) {
|
|
1312
|
-
initialDir = savedCwd;
|
|
1313
|
-
console.log('[folderPicker] 从 localStorage 恢复工作区:', initialDir);
|
|
1314
|
-
} else {
|
|
1315
|
-
// 使用默认路径
|
|
1316
|
-
initialDir = '.';
|
|
1317
|
-
console.log('[folderPicker] 使用默认路径:', initialDir);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1760
|
+
let fpSelected = '';
|
|
1320
1761
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1762
|
+
async function openFolderPicker() {
|
|
1763
|
+
fpCurrent = '';
|
|
1764
|
+
fpSelected = '';
|
|
1765
|
+
document.getElementById('fpDrivesView').style.display = '';
|
|
1766
|
+
document.getElementById('fpFoldersView').style.display = 'none';
|
|
1767
|
+
document.getElementById('fpMkdirBar').style.display = 'none';
|
|
1768
|
+
document.getElementById('fpRenameBar').style.display = 'none';
|
|
1769
|
+
document.getElementById('fpConfirm').disabled = true;
|
|
1770
|
+
document.getElementById('fpRenameFolder').disabled = true;
|
|
1771
|
+
document.getElementById('fpTitle').textContent = '📂 选择驱动器';
|
|
1772
|
+
openModal('folderPickerModal');
|
|
1773
|
+
await loadDrives();
|
|
1774
|
+
}
|
|
1324
1775
|
|
|
1776
|
+
async function loadDrives() {
|
|
1325
1777
|
try {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1778
|
+
const d = await api('/api/drives');
|
|
1779
|
+
const drives = d.drives || [];
|
|
1780
|
+
const grid = document.getElementById('fpDrivesGrid');
|
|
1781
|
+
grid.innerHTML = drives.map(drv => {
|
|
1782
|
+
const label = drv.replace('\\', '');
|
|
1783
|
+
return '<div class="fp-drive-card" data-drive="' + escapeHtml(drv) + '" style="padding:16px;text-align:center;border:1px solid var(--line);border-radius:8px;cursor:pointer;transition:all .15s;">'
|
|
1784
|
+
+ '<div style="font-size:28px;margin-bottom:6px;">💽</div>'
|
|
1785
|
+
+ '<div style="font-weight:600;font-size:14px;">' + escapeHtml(label) + '</div>'
|
|
1786
|
+
+ '<div style="font-size:11px;color:var(--ink-2);margin-top:2px;">本地磁盘</div>'
|
|
1787
|
+
+ '</div>';
|
|
1788
|
+
}).join('');
|
|
1789
|
+
grid.querySelectorAll('.fp-drive-card').forEach(el => {
|
|
1790
|
+
el.addEventListener('click', () => selectDrive(el.getAttribute('data-drive')));
|
|
1791
|
+
el.addEventListener('mouseenter', () => { el.style.borderColor = 'var(--brand)'; el.style.background = 'var(--brand-soft)'; });
|
|
1792
|
+
el.addEventListener('mouseleave', () => { el.style.borderColor = 'var(--line)'; el.style.background = ''; });
|
|
1793
|
+
});
|
|
1329
1794
|
} catch (e) {
|
|
1330
|
-
|
|
1331
|
-
toast('读取目录失败:' + e.message);
|
|
1332
|
-
return;
|
|
1795
|
+
toast('加载驱动器失败:' + e.message);
|
|
1333
1796
|
}
|
|
1334
|
-
openModal('folderPickerModal');
|
|
1335
|
-
console.log('[folderPicker] 模态框已打开');
|
|
1336
1797
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1798
|
+
|
|
1799
|
+
async function selectDrive(drive) {
|
|
1800
|
+
fpCurrent = drive;
|
|
1801
|
+
fpSelected = '';
|
|
1802
|
+
document.getElementById('fpDrivesView').style.display = 'none';
|
|
1803
|
+
document.getElementById('fpFoldersView').style.display = '';
|
|
1804
|
+
document.getElementById('fpTitle').textContent = '📂 选择文件夹';
|
|
1805
|
+
document.getElementById('fpConfirm').disabled = true;
|
|
1806
|
+
document.getElementById('fpRenameFolder').disabled = true;
|
|
1807
|
+
await loadFolderPicker(drive);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function backToDrives() {
|
|
1811
|
+
fpCurrent = '';
|
|
1812
|
+
fpSelected = '';
|
|
1813
|
+
document.getElementById('fpDrivesView').style.display = '';
|
|
1814
|
+
document.getElementById('fpFoldersView').style.display = 'none';
|
|
1815
|
+
document.getElementById('fpMkdirBar').style.display = 'none';
|
|
1816
|
+
document.getElementById('fpRenameBar').style.display = 'none';
|
|
1817
|
+
document.getElementById('fpTitle').textContent = '📂 选择驱动器';
|
|
1818
|
+
document.getElementById('fpConfirm').disabled = true;
|
|
1819
|
+
document.getElementById('fpRenameFolder').disabled = true;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
async function upDir() {
|
|
1823
|
+
if (!fpCurrent) return;
|
|
1824
|
+
// 驱动器根目录不能再往上
|
|
1825
|
+
if (/^[A-Za-z]:\\$/.test(fpCurrent)) {
|
|
1826
|
+
backToDrives();
|
|
1346
1827
|
return;
|
|
1347
1828
|
}
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1829
|
+
const parent = getDirectoryName(fpCurrent);
|
|
1830
|
+
if (parent && parent !== fpCurrent) {
|
|
1831
|
+
fpCurrent = parent;
|
|
1832
|
+
fpSelected = '';
|
|
1833
|
+
document.getElementById('fpConfirm').disabled = true;
|
|
1834
|
+
document.getElementById('fpRenameFolder').disabled = true;
|
|
1835
|
+
await loadFolderPicker(fpCurrent);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function loadFolderPicker(dir) {
|
|
1840
|
+
try {
|
|
1841
|
+
const d = await api('/api/workspace/list?path=' + encodeURIComponent(dir));
|
|
1842
|
+
fpCurrent = d.cwd;
|
|
1843
|
+
const pathEl = document.getElementById('fpPath');
|
|
1844
|
+
pathEl.textContent = d.cwd;
|
|
1845
|
+
pathEl.title = d.cwd;
|
|
1846
|
+
const tree = document.getElementById('fpTree');
|
|
1847
|
+
// 只显示文件夹,不显示文件
|
|
1848
|
+
const dirs = (d.entries || []).filter(e => e.type === 'dir');
|
|
1849
|
+
if (dirs.length === 0) {
|
|
1850
|
+
tree.innerHTML = '<div style="padding:20px;text-align:center;color:var(--ink-2);font-size:13px;">此目录下没有文件夹</div>';
|
|
1851
|
+
} else {
|
|
1852
|
+
tree.innerHTML = dirs.map(e => {
|
|
1853
|
+
const selected = fpSelected === e.path ? 'background:var(--brand-soft);border-color:var(--brand);' : '';
|
|
1854
|
+
return '<div class="file-item fp-folder-item" data-path="' + escapeHtml(e.path) + '" data-name="' + escapeHtml(e.name) + '" style="' + selected + 'padding:8px 10px;border:1px solid transparent;border-radius:6px;cursor:pointer;display:flex;align-items:center;gap:8px;">'
|
|
1855
|
+
+ '<span style="font-size:16px;">📁</span>'
|
|
1856
|
+
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(e.name) + '</span>'
|
|
1857
|
+
+ '</div>';
|
|
1858
|
+
}).join('');
|
|
1859
|
+
}
|
|
1860
|
+
tree.querySelectorAll('.fp-folder-item').forEach(el => {
|
|
1861
|
+
el.addEventListener('click', (e) => {
|
|
1862
|
+
e.stopPropagation();
|
|
1863
|
+
const path = el.getAttribute('data-path');
|
|
1864
|
+
const name = el.getAttribute('data-name');
|
|
1865
|
+
// 单击选中
|
|
1866
|
+
fpSelected = path;
|
|
1867
|
+
document.getElementById('fpConfirm').disabled = false;
|
|
1868
|
+
document.getElementById('fpRenameFolder').disabled = false;
|
|
1869
|
+
document.getElementById('fpRenameName').value = name;
|
|
1870
|
+
// 高亮选中项
|
|
1871
|
+
tree.querySelectorAll('.fp-folder-item').forEach(item => {
|
|
1872
|
+
item.style.background = '';
|
|
1873
|
+
item.style.borderColor = 'transparent';
|
|
1874
|
+
});
|
|
1875
|
+
el.style.background = 'var(--brand-soft)';
|
|
1876
|
+
el.style.borderColor = 'var(--brand)';
|
|
1877
|
+
});
|
|
1878
|
+
el.addEventListener('dblclick', () => {
|
|
1879
|
+
const path = el.getAttribute('data-path');
|
|
1880
|
+
fpSelected = '';
|
|
1881
|
+
document.getElementById('fpConfirm').disabled = true;
|
|
1882
|
+
document.getElementById('fpRenameFolder').disabled = true;
|
|
1883
|
+
loadFolderPicker(path);
|
|
1884
|
+
});
|
|
1885
|
+
});
|
|
1886
|
+
} catch (e) {
|
|
1887
|
+
toast('读取目录失败:' + e.message);
|
|
1361
1888
|
}
|
|
1362
|
-
tree.innerHTML = upButtonHtml +
|
|
1363
|
-
d.entries.map((e) => '<div class="file-item ' + (e.type === 'dir' ? 'dir' : '') + '" data-path="' + escapeHtml(e.path) + '" data-type="' + e.type + '">' + (e.type === 'dir' ? '📁' : '📄') + ' ' + escapeHtml(e.name) + '</div>').join('');
|
|
1364
|
-
console.log('[folderPicker] 目录渲染完成, 条目数:', d.entries.length);
|
|
1365
1889
|
}
|
|
1366
|
-
// 驱动器列表缓存
|
|
1367
|
-
let _drivesCache = null;
|
|
1368
1890
|
|
|
1369
1891
|
document.getElementById('fpConfirm')?.addEventListener('click', async () => {
|
|
1370
|
-
|
|
1892
|
+
if (!fpSelected) { toast('请先选择一个文件夹'); return; }
|
|
1371
1893
|
try {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
state.workspaceDir = fpCurrent;
|
|
1376
|
-
// 持久化到 localStorage
|
|
1377
|
-
localStorage.setItem('fhcode.workspaceDir', fpCurrent);
|
|
1894
|
+
const result = await api('/api/workspace', 'POST', { cwd: fpSelected });
|
|
1895
|
+
state.workspaceDir = result.cwd;
|
|
1896
|
+
localStorage.setItem('fhcode.workspaceDir', result.cwd);
|
|
1378
1897
|
renderWorkspaceBar();
|
|
1379
1898
|
closeModal('folderPickerModal');
|
|
1380
|
-
toast('已切换工作区:' +
|
|
1899
|
+
toast('已切换工作区:' + result.cwd);
|
|
1381
1900
|
} catch (e) {
|
|
1382
|
-
console.error('[folderPicker] 切换失败:', e);
|
|
1383
1901
|
toast('设置失败:' + e.message);
|
|
1384
1902
|
}
|
|
1385
1903
|
});
|
|
1386
1904
|
|
|
1905
|
+
// 返回驱动器选择
|
|
1906
|
+
document.getElementById('fpBackToDrives')?.addEventListener('click', backToDrives);
|
|
1907
|
+
// 返回上级目录
|
|
1908
|
+
document.getElementById('fpUpDir')?.addEventListener('click', upDir);
|
|
1909
|
+
|
|
1910
|
+
// 新建文件夹
|
|
1911
|
+
document.getElementById('fpNewFolder')?.addEventListener('click', () => {
|
|
1912
|
+
document.getElementById('fpMkdirBar').style.display = 'flex';
|
|
1913
|
+
document.getElementById('fpRenameBar').style.display = 'none';
|
|
1914
|
+
document.getElementById('fpMkdirName').value = '';
|
|
1915
|
+
setTimeout(() => document.getElementById('fpMkdirName').focus(), 50);
|
|
1916
|
+
});
|
|
1917
|
+
document.getElementById('fpMkdirCancel')?.addEventListener('click', () => {
|
|
1918
|
+
document.getElementById('fpMkdirBar').style.display = 'none';
|
|
1919
|
+
});
|
|
1920
|
+
document.getElementById('fpMkdirConfirm')?.addEventListener('click', async () => {
|
|
1921
|
+
const name = document.getElementById('fpMkdirName').value.trim();
|
|
1922
|
+
if (!name) { toast('请输入文件夹名称'); return; }
|
|
1923
|
+
try {
|
|
1924
|
+
const result = await api('/api/workspace/mkdir', 'POST', { parent: fpCurrent, name });
|
|
1925
|
+
toast('文件夹已创建:' + name);
|
|
1926
|
+
document.getElementById('fpMkdirBar').style.display = 'none';
|
|
1927
|
+
fpSelected = result.path;
|
|
1928
|
+
await loadFolderPicker(fpCurrent);
|
|
1929
|
+
document.getElementById('fpConfirm').disabled = false;
|
|
1930
|
+
document.getElementById('fpRenameFolder').disabled = false;
|
|
1931
|
+
} catch (e) {
|
|
1932
|
+
toast('创建失败:' + e.message);
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
document.getElementById('fpMkdirName')?.addEventListener('keydown', (e) => {
|
|
1936
|
+
if (e.key === 'Enter') document.getElementById('fpMkdirConfirm').click();
|
|
1937
|
+
if (e.key === 'Escape') document.getElementById('fpMkdirCancel').click();
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
// 重命名文件夹
|
|
1941
|
+
document.getElementById('fpRenameFolder')?.addEventListener('click', () => {
|
|
1942
|
+
if (!fpSelected) { toast('请先选择一个文件夹'); return; }
|
|
1943
|
+
document.getElementById('fpRenameBar').style.display = 'flex';
|
|
1944
|
+
document.getElementById('fpMkdirBar').style.display = 'none';
|
|
1945
|
+
const name = fpSelected.split(/[\\/]/).filter(Boolean).pop() || '';
|
|
1946
|
+
document.getElementById('fpRenameName').value = name;
|
|
1947
|
+
setTimeout(() => document.getElementById('fpRenameName').focus(), 50);
|
|
1948
|
+
});
|
|
1949
|
+
document.getElementById('fpRenameCancel')?.addEventListener('click', () => {
|
|
1950
|
+
document.getElementById('fpRenameBar').style.display = 'none';
|
|
1951
|
+
});
|
|
1952
|
+
document.getElementById('fpRenameConfirm')?.addEventListener('click', async () => {
|
|
1953
|
+
const newName = document.getElementById('fpRenameName').value.trim();
|
|
1954
|
+
if (!newName) { toast('请输入新名称'); return; }
|
|
1955
|
+
if (!fpSelected) { toast('请先选择一个文件夹'); return; }
|
|
1956
|
+
try {
|
|
1957
|
+
const result = await api('/api/workspace/rename', 'POST', { path: fpSelected, newName });
|
|
1958
|
+
toast('文件夹已重命名为:' + newName);
|
|
1959
|
+
document.getElementById('fpRenameBar').style.display = 'none';
|
|
1960
|
+
fpSelected = result.path;
|
|
1961
|
+
await loadFolderPicker(fpCurrent);
|
|
1962
|
+
document.getElementById('fpConfirm').disabled = false;
|
|
1963
|
+
document.getElementById('fpRenameFolder').disabled = false;
|
|
1964
|
+
} catch (e) {
|
|
1965
|
+
toast('重命名失败:' + e.message);
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
document.getElementById('fpRenameName')?.addEventListener('keydown', (e) => {
|
|
1969
|
+
if (e.key === 'Enter') document.getElementById('fpRenameConfirm').click();
|
|
1970
|
+
if (e.key === 'Escape') document.getElementById('fpRenameCancel').click();
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1387
1973
|
/* ========== 自动化、模板、市场、办公助理 ========== */
|
|
1388
1974
|
document.getElementById('newAutoBtn')?.addEventListener('click', () => openModal('autoModal'));
|
|
1389
1975
|
document.getElementById('autoSaveBtn')?.addEventListener('click', async () => {
|
|
@@ -1423,31 +2009,183 @@
|
|
|
1423
2009
|
document.getElementById('marketSearch')?.addEventListener('input', () => { clearTimeout(marketTimer); marketTimer = setTimeout(loadMarket, 400); });
|
|
1424
2010
|
document.getElementById('marketSource')?.addEventListener('change', loadMarket);
|
|
1425
2011
|
|
|
2012
|
+
/* ========== 节点管理 & 自定义来源 ========== */
|
|
2013
|
+
document.getElementById('addNodeBtn')?.addEventListener('click', () => openNodeModal());
|
|
2014
|
+
document.getElementById('addSourceBtn')?.addEventListener('click', () => openSourceModal());
|
|
2015
|
+
document.getElementById('nodeSaveBtn')?.addEventListener('click', async () => {
|
|
2016
|
+
const id = document.getElementById('nodeEditId').value || '';
|
|
2017
|
+
const name = document.getElementById('nodeName').value.trim();
|
|
2018
|
+
const type = document.getElementById('nodeType').value;
|
|
2019
|
+
const url = document.getElementById('nodeUrl').value.trim();
|
|
2020
|
+
const apiKey = document.getElementById('nodeApiKey').value;
|
|
2021
|
+
const capabilities = [];
|
|
2022
|
+
if (document.getElementById('nodeCapTemplates').checked) capabilities.push('templates');
|
|
2023
|
+
if (document.getElementById('nodeCapSkills').checked) capabilities.push('skills');
|
|
2024
|
+
if (document.getElementById('nodeCapOffice').checked) capabilities.push('office');
|
|
2025
|
+
if (!name || !url) { toast('请填写名称和地址'); return; }
|
|
2026
|
+
if (!capabilities.length) { toast('请至少选择一种能力'); return; }
|
|
2027
|
+
const data = { name, type, url, capabilities };
|
|
2028
|
+
if (apiKey) data.apiKey = apiKey;
|
|
2029
|
+
if (id) data.id = id;
|
|
2030
|
+
await saveNode(data);
|
|
2031
|
+
closeModal('nodeModal');
|
|
2032
|
+
});
|
|
2033
|
+
document.getElementById('sourceSaveBtn')?.addEventListener('click', async () => {
|
|
2034
|
+
const id = document.getElementById('sourceEditId').value || '';
|
|
2035
|
+
const name = document.getElementById('sourceName').value.trim();
|
|
2036
|
+
const type = document.getElementById('sourceType').value;
|
|
2037
|
+
const url = document.getElementById('sourceUrl').value.trim();
|
|
2038
|
+
if (!name || !url) { toast('请填写名称和地址'); return; }
|
|
2039
|
+
const data = { name, type, url };
|
|
2040
|
+
if (id) data.id = id;
|
|
2041
|
+
await saveSource(data);
|
|
2042
|
+
closeModal('sourceModal');
|
|
2043
|
+
// 刷新相关数据
|
|
2044
|
+
if (type === 'templates') await loadTemplates();
|
|
2045
|
+
else if (type === 'skills') await loadMarket();
|
|
2046
|
+
else if (type === 'office') await loadOffice();
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
function openNodeModal(node) {
|
|
2050
|
+
document.getElementById('nodeModalTitle').textContent = node ? '编辑节点' : '添加节点';
|
|
2051
|
+
document.getElementById('nodeEditId').value = node?.id || '';
|
|
2052
|
+
document.getElementById('nodeName').value = node?.name || '';
|
|
2053
|
+
document.getElementById('nodeType').value = node?.type || 'http';
|
|
2054
|
+
document.getElementById('nodeUrl').value = node?.url || '';
|
|
2055
|
+
document.getElementById('nodeApiKey').value = '';
|
|
2056
|
+
document.getElementById('nodeCapTemplates').checked = node?.capabilities?.includes('templates') ?? true;
|
|
2057
|
+
document.getElementById('nodeCapSkills').checked = node?.capabilities?.includes('skills') ?? true;
|
|
2058
|
+
document.getElementById('nodeCapOffice').checked = node?.capabilities?.includes('office') ?? true;
|
|
2059
|
+
openModal('nodeModal');
|
|
2060
|
+
}
|
|
2061
|
+
function editNode(id) {
|
|
2062
|
+
const node = (window._nodesCache || []).find((n) => n.id === id);
|
|
2063
|
+
openNodeModal(node || { id });
|
|
2064
|
+
}
|
|
2065
|
+
function openSourceModal(source) {
|
|
2066
|
+
document.getElementById('sourceModalTitle').textContent = source ? '编辑自定义来源' : '添加自定义来源';
|
|
2067
|
+
document.getElementById('sourceEditId').value = source?.id || '';
|
|
2068
|
+
document.getElementById('sourceName').value = source?.name || '';
|
|
2069
|
+
document.getElementById('sourceType').value = source?.type || 'templates';
|
|
2070
|
+
document.getElementById('sourceUrl').value = source?.url || '';
|
|
2071
|
+
openModal('sourceModal');
|
|
2072
|
+
}
|
|
2073
|
+
function editSource(id) {
|
|
2074
|
+
const source = (window._sourcesCache || []).find((s) => s.id === id);
|
|
2075
|
+
openSourceModal(source || { id });
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1426
2078
|
|
|
1427
2079
|
document.querySelectorAll('[data-close]').forEach((el) => el.addEventListener('click', () => closeModal(el.getAttribute('data-close'))));
|
|
1428
2080
|
document.querySelectorAll('.mask').forEach((m) => m.addEventListener('click', (e) => { if (e.target === m) m.classList.remove('show'); }));
|
|
1429
|
-
//
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
2081
|
+
// 文件夹选择器事件已在 loadFolderPicker / loadDrives 内直接绑定,此处保留空壳避免旧引用报错
|
|
2082
|
+
function startRefresh() {
|
|
2083
|
+
setInterval(async () => {
|
|
2084
|
+
await loadTasks();
|
|
2085
|
+
// 如果当前有正在运行的任务,自动刷新对话流,让用户实时看到思考过程
|
|
2086
|
+
if (state.currentTaskId) {
|
|
2087
|
+
const cur = state.tasks.find((t) => t.id === state.currentTaskId);
|
|
2088
|
+
if (cur && (cur.status === 'running' || cur.status === 'queued')) {
|
|
2089
|
+
await refreshCurrentThread();
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
}, 1000); // 1秒轮询,确保思考过程实时显示
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/* ========== 电脑操作(鼠标/键盘/截图) ========== */
|
|
2096
|
+
let selectedMousePos = { x: 0, y: 0 };
|
|
2097
|
+
|
|
2098
|
+
// 截取屏幕
|
|
2099
|
+
async function captureScreen() {
|
|
2100
|
+
try {
|
|
2101
|
+
const d = await api('/api/computer/screenshot', 'POST', {});
|
|
2102
|
+
if (d.ok && d.image) {
|
|
2103
|
+
const preview = document.getElementById('screenPreview');
|
|
2104
|
+
preview.innerHTML = `<img id="screenImg" src="${d.image}" alt="屏幕截图" />`;
|
|
2105
|
+
const img = document.getElementById('screenImg');
|
|
2106
|
+
// 点击图片获取坐标
|
|
2107
|
+
img.addEventListener('click', (e) => {
|
|
2108
|
+
const rect = img.getBoundingClientRect();
|
|
2109
|
+
const scaleX = (d.width || 1920) / rect.width;
|
|
2110
|
+
const scaleY = (d.height || 1080) / rect.height;
|
|
2111
|
+
selectedMousePos.x = Math.round((e.clientX - rect.left) * scaleX);
|
|
2112
|
+
selectedMousePos.y = Math.round((e.clientY - rect.top) * scaleY);
|
|
2113
|
+
document.getElementById('mousePos').textContent = `坐标: ${selectedMousePos.x}, ${selectedMousePos.y}`;
|
|
2114
|
+
toast(`已选择坐标: ${selectedMousePos.x}, ${selectedMousePos.y},点击操作按钮执行`);
|
|
2115
|
+
});
|
|
2116
|
+
toast('屏幕截图已更新,点击图片选择坐标');
|
|
2117
|
+
} else {
|
|
2118
|
+
toast('截图失败: ' + (d.error || '未知错误'));
|
|
2119
|
+
}
|
|
2120
|
+
} catch (e) {
|
|
2121
|
+
toast('截图失败: ' + e.message);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// 鼠标点击
|
|
2126
|
+
async function mouseClick(button, doubleClick = false) {
|
|
2127
|
+
try {
|
|
2128
|
+
const d = await api('/api/computer/mouse/click', 'POST', {
|
|
2129
|
+
button,
|
|
2130
|
+
x: selectedMousePos.x,
|
|
2131
|
+
y: selectedMousePos.y,
|
|
2132
|
+
double: doubleClick,
|
|
2133
|
+
});
|
|
2134
|
+
if (d.ok) {
|
|
2135
|
+
toast(`${doubleClick ? '双击' : (button === 'right' ? '右键点击' : '左键点击')} (${selectedMousePos.x}, ${selectedMousePos.y})`);
|
|
2136
|
+
} else {
|
|
2137
|
+
toast('操作失败: ' + (d.error || '未知错误'));
|
|
2138
|
+
}
|
|
2139
|
+
} catch (e) {
|
|
2140
|
+
toast('操作失败: ' + e.message);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// 输入文字
|
|
2145
|
+
async function typeText(text) {
|
|
2146
|
+
try {
|
|
2147
|
+
const d = await api('/api/computer/keyboard/type', 'POST', { text });
|
|
2148
|
+
if (d.ok) {
|
|
2149
|
+
toast('已输入文字');
|
|
2150
|
+
} else {
|
|
2151
|
+
toast('输入失败: ' + (d.error || '未知错误'));
|
|
2152
|
+
}
|
|
2153
|
+
} catch (e) {
|
|
2154
|
+
toast('输入失败: ' + e.message);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// 按键
|
|
2159
|
+
async function pressKey(key) {
|
|
2160
|
+
try {
|
|
2161
|
+
const d = await api('/api/computer/keyboard/press', 'POST', { key });
|
|
2162
|
+
if (d.ok) {
|
|
2163
|
+
toast('已按键: ' + key);
|
|
2164
|
+
} else {
|
|
2165
|
+
toast('按键失败: ' + (d.error || '未知错误'));
|
|
2166
|
+
}
|
|
2167
|
+
} catch (e) {
|
|
2168
|
+
toast('按键失败: ' + e.message);
|
|
1438
2169
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// 绑定电脑操作事件
|
|
2173
|
+
document.getElementById('screenCaptureBtn')?.addEventListener('click', captureScreen);
|
|
2174
|
+
document.getElementById('clickLeftBtn')?.addEventListener('click', () => mouseClick('left'));
|
|
2175
|
+
document.getElementById('clickRightBtn')?.addEventListener('click', () => mouseClick('right'));
|
|
2176
|
+
document.getElementById('doubleClickBtn')?.addEventListener('click', () => mouseClick('left', true));
|
|
2177
|
+
document.getElementById('typeTextInput')?.addEventListener('keydown', (e) => {
|
|
2178
|
+
if (e.key === 'Enter') {
|
|
2179
|
+
const text = e.target.value.trim();
|
|
2180
|
+
if (text) {
|
|
2181
|
+
typeText(text);
|
|
2182
|
+
e.target.value = '';
|
|
2183
|
+
}
|
|
1448
2184
|
}
|
|
1449
2185
|
});
|
|
1450
|
-
|
|
2186
|
+
document.querySelectorAll('.computer-actions [data-key]').forEach((btn) => {
|
|
2187
|
+
btn.addEventListener('click', () => pressKey(btn.getAttribute('data-key') || ''));
|
|
2188
|
+
});
|
|
1451
2189
|
|
|
1452
2190
|
/* ========== 记忆系统 ========== */
|
|
1453
2191
|
|