app.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. // 主应用入口
  2. import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js?v=0205';
  3. import { getAudioPlayer } from './core/audio/player.js?v=0205';
  4. import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0205';
  5. import { initMcpTools } from './core/mcp/tools.js?v=0205';
  6. import { uiController } from './ui/controller.js?v=0205';
  7. import { log } from './utils/logger.js?v=0205';
  8. // 辅助函数:将Base64数据转换为Blob
  9. function dataURItoBlob(dataURI) {
  10. const byteString = atob(dataURI.split(',')[1]);
  11. const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
  12. const ab = new ArrayBuffer(byteString.length);
  13. const ia = new Uint8Array(ab);
  14. for (let i = 0; i < byteString.length; i++) {
  15. ia[i] = byteString.charCodeAt(i);
  16. }
  17. return new Blob([ab], { type: mimeString });
  18. }
  19. // 应用类
  20. class App {
  21. constructor() {
  22. this.uiController = null;
  23. this.audioPlayer = null;
  24. this.live2dManager = null;
  25. this.cameraStream = null;
  26. this.currentFacingMode = 'user';
  27. }
  28. // 初始化应用
  29. async init() {
  30. log('正在初始化应用...', 'info');
  31. // 初始化UI控制器
  32. this.uiController = uiController;
  33. this.uiController.init();
  34. // 检查Opus库
  35. checkOpusLoaded();
  36. // 初始化Opus编码器
  37. initOpusEncoder();
  38. // 初始化音频播放器
  39. this.audioPlayer = getAudioPlayer();
  40. await this.audioPlayer.start();
  41. // 初始化MCP工具
  42. initMcpTools();
  43. // 检查麦克风可用性
  44. await this.checkMicrophoneAvailability();
  45. // 检查摄像头可用性
  46. this.checkCameraAvailability();
  47. // 初始化Live2D
  48. await this.initLive2D();
  49. // 初始化摄像头
  50. this.initCamera();
  51. // 关闭加载loading
  52. this.setModelLoadingStatus(false);
  53. log('应用初始化完成', 'success');
  54. }
  55. // 初始化Live2D
  56. async initLive2D() {
  57. try {
  58. // 检查Live2DManager是否已加载
  59. if (typeof window.Live2DManager === 'undefined') {
  60. throw new Error('Live2DManager未加载,请检查脚本引入顺序');
  61. }
  62. this.live2dManager = new window.Live2DManager();
  63. await this.live2dManager.initializeLive2D();
  64. // 更新UI状态
  65. const live2dStatus = document.getElementById('live2dStatus');
  66. if (live2dStatus) {
  67. live2dStatus.textContent = '● 已加载';
  68. live2dStatus.className = 'status loaded';
  69. }
  70. log('Live2D初始化完成', 'success');
  71. } catch (error) {
  72. log(`Live2D初始化失败: ${error.message}`, 'error');
  73. // 更新UI状态
  74. const live2dStatus = document.getElementById('live2dStatus');
  75. if (live2dStatus) {
  76. live2dStatus.textContent = '● 加载失败';
  77. live2dStatus.className = 'status error';
  78. }
  79. }
  80. }
  81. // 设置model加载状态
  82. setModelLoadingStatus(isLoading) {
  83. const modelLoading = document.getElementById('modelLoading');
  84. if (modelLoading) {
  85. modelLoading.style.display = isLoading ? 'flex' : 'none';
  86. }
  87. }
  88. /**
  89. * 检查麦克风可用性
  90. * 在应用初始化时调用,检查麦克风是否可用并更新UI状态
  91. */
  92. async checkMicrophoneAvailability() {
  93. try {
  94. const isAvailable = await checkMicrophoneAvailability();
  95. const isHttp = isHttpNonLocalhost();
  96. // 保存可用性状态到全局变量
  97. window.microphoneAvailable = isAvailable;
  98. window.isHttpNonLocalhost = isHttp;
  99. // 更新UI
  100. if (this.uiController) {
  101. this.uiController.updateMicrophoneAvailability(isAvailable, isHttp);
  102. }
  103. log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning');
  104. } catch (error) {
  105. log(`检查麦克风可用性失败: ${error.message}`, 'error');
  106. // 默认设置为不可用
  107. window.microphoneAvailable = false;
  108. window.isHttpNonLocalhost = isHttpNonLocalhost();
  109. if (this.uiController) {
  110. this.uiController.updateMicrophoneAvailability(false, window.isHttpNonLocalhost);
  111. }
  112. }
  113. }
  114. // 检查摄像头可用性
  115. checkCameraAvailability() {
  116. window.cameraAvailable = true;
  117. log('摄像头可用性检查完成: 默认已绑定验证码', 'success');
  118. }
  119. // 初始化摄像头
  120. async initCamera() {
  121. const cameraContainer = document.getElementById('cameraContainer');
  122. const cameraVideo = document.getElementById('cameraVideo');
  123. const cameraSwitch = document.getElementById('cameraSwitch');
  124. const cameraSwitchMask = document.getElementById('cameraSwitchMask');
  125. const dialBtn = document.getElementById('dialBtn');
  126. if (!cameraContainer || !cameraVideo) {
  127. log('摄像头元素未找到,跳过初始化', 'warning');
  128. return Promise.resolve(false);
  129. }
  130. let isDragging = false;
  131. let currentX, currentY, initialX, initialY;
  132. let xOffset = 0, yOffset = 0;
  133. cameraContainer.addEventListener('mousedown', dragStart);
  134. document.addEventListener('mousemove', drag);
  135. document.addEventListener('mouseup', dragEnd);
  136. cameraContainer.addEventListener('touchstart', dragStart, { passive: false });
  137. document.addEventListener('touchmove', drag, { passive: false });
  138. document.addEventListener('touchend', dragEnd);
  139. function dragStart(e) {
  140. if (e.type === 'touchstart') {
  141. initialX = e.touches[0].clientX - xOffset;
  142. initialY = e.touches[0].clientY - yOffset;
  143. } else {
  144. initialX = e.clientX - xOffset;
  145. initialY = e.clientY - yOffset;
  146. }
  147. isDragging = true;
  148. cameraContainer.classList.add('dragging');
  149. }
  150. function drag(e) {
  151. if (isDragging) {
  152. e.preventDefault();
  153. if (e.type === 'touchmove') {
  154. currentX = e.touches[0].clientX - initialX;
  155. currentY = e.touches[0].clientY - initialY;
  156. } else {
  157. currentX = e.clientX - initialX;
  158. currentY = e.clientY - initialY;
  159. }
  160. xOffset = currentX;
  161. yOffset = currentY;
  162. cameraContainer.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
  163. }
  164. }
  165. function dragEnd() {
  166. initialX = currentX;
  167. initialY = currentY;
  168. isDragging = false;
  169. cameraContainer.classList.remove('dragging');
  170. }
  171. return new Promise((resolve) => {
  172. window.startCamera = async () => {
  173. try {
  174. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  175. log('浏览器不支持摄像头API', 'warning');
  176. return false;
  177. }
  178. log('正在请求摄像头权限...', 'info');
  179. this.cameraStream = await navigator.mediaDevices.getUserMedia({
  180. video: { width: 180, height: 240, facingMode: this.currentFacingMode },
  181. audio: false
  182. });
  183. cameraVideo.srcObject = this.cameraStream;
  184. const devices = await navigator.mediaDevices.enumerateDevices();
  185. const videoDevices = devices.filter(device => device.kind === 'videoinput');
  186. if (videoDevices.length > 1) {
  187. if (cameraSwitch) cameraSwitch.classList.add('active');
  188. }
  189. cameraContainer.classList.add('active');
  190. // 切换时挂断情况
  191. const hasActive = dialBtn.classList.contains('dial-active');
  192. if (!hasActive) {
  193. cameraContainer.classList.remove('active');
  194. cameraSwitch.classList.remove('active');
  195. window.stopCamera();
  196. }
  197. log('摄像头已启动', 'success');
  198. return true;
  199. } catch (error) {
  200. log(`启动摄像头失败: ${error.name} - ${error.message}`, 'error');
  201. if (error.name === 'NotAllowedError') {
  202. log('摄像头权限被拒绝,请检查浏览器设置', 'warning');
  203. } else if (error.name === 'NotFoundError') {
  204. log('未找到摄像头设备', 'warning');
  205. } else if (error.name === 'NotReadableError') {
  206. log('摄像头已被其他程序占用', 'warning');
  207. }
  208. return false;
  209. }
  210. };
  211. window.stopCamera = () => {
  212. if (this.cameraStream) {
  213. this.cameraStream.getTracks().forEach(track => track.stop());
  214. this.cameraStream = null;
  215. cameraVideo.srcObject = null;
  216. log('摄像头已关闭', 'info');
  217. }
  218. };
  219. window.switchCamera = async() => {
  220. if (window.switchCameraTimer) return;
  221. if (this.cameraStream) {
  222. const currentTransform = window.getComputedStyle(cameraContainer).transform;
  223. const originalTransform = currentTransform === 'none' ? 'translate(0px, 0px)' : currentTransform;
  224. cameraContainer.style.setProperty('--original-transform', originalTransform);
  225. cameraContainer.classList.add('flip');
  226. if (cameraSwitchMask) cameraSwitchMask.style.opacity = 0;
  227. this.currentFacingMode = this.currentFacingMode === 'user' ? 'environment' : 'user';
  228. window.stopCamera();
  229. window.startCamera();
  230. window.switchCameraTimer = setTimeout(() => {
  231. if (this.currentFacingMode === 'user') {
  232. cameraVideo.style.transform = 'scaleX(-1)';
  233. } else {
  234. cameraVideo.style.transform = 'scaleX(1)';
  235. }
  236. window.switchCameraTimer = null;
  237. cameraContainer.classList.remove('flip');
  238. cameraContainer.style.removeProperty('--original-transform');
  239. if (cameraSwitchMask) cameraSwitchMask.style.opacity = 1;
  240. }, 500);
  241. }
  242. };
  243. window.takePhoto = (question = '描述一下看到的物品') => {
  244. return new Promise(async (resolve) => {
  245. const canvas = document.createElement('canvas');
  246. const video = cameraVideo;
  247. if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) {
  248. log('无法拍照:摄像头未就绪', 'warning');
  249. resolve({
  250. success: false,
  251. error: '摄像头未就绪,请确保已连接且摄像头已启动'
  252. });
  253. return;
  254. }
  255. canvas.width = video.videoWidth || 180;
  256. canvas.height = video.videoHeight || 240;
  257. const ctx = canvas.getContext('2d');
  258. ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  259. const photoData = canvas.toDataURL('image/jpeg', 0.8);
  260. log(`拍照成功,图像数据长度: ${photoData.length}`, 'success');
  261. try {
  262. const xz_tester_vision = localStorage.getItem('xz_tester_vision');
  263. if (xz_tester_vision) {
  264. let visionInfo = null;
  265. try {
  266. visionInfo = JSON.parse(xz_tester_vision);
  267. } catch (err) {
  268. throw new Error(`视觉配置解析失败`);
  269. }
  270. const { url, token } = visionInfo || {};
  271. if (!url || !token) {
  272. throw new Error('视觉分析失败:配置缺少接口地址(url)或令牌(token)');
  273. }
  274. log(`正在发送图片到视觉分析接口: ${url}`, 'info');
  275. const deviceId = document.getElementById('deviceMac')?.value || '';
  276. const clientId = document.getElementById('clientId')?.value || 'web_test_client';
  277. const formData = new FormData();
  278. formData.append('question', question);
  279. formData.append('image', dataURItoBlob(photoData), 'photo.jpg');
  280. const response = await fetch(url, {
  281. method: 'POST',
  282. body: formData,
  283. headers: {
  284. 'Device-Id': deviceId,
  285. 'Client-Id': clientId,
  286. 'Authorization': `Bearer ${token}`
  287. }
  288. });
  289. if (!response.ok) {
  290. throw new Error(`HTTP error! status: ${response.status}`);
  291. }
  292. const analysisResult = await response.json();
  293. log(`视觉分析完成: ${JSON.stringify(analysisResult).substring(0, 200)}...`, 'success');
  294. resolve({
  295. success: true,
  296. message: question,
  297. photo_data: photoData,
  298. photo_width: canvas.width,
  299. photo_height: canvas.height,
  300. vision_analysis: analysisResult
  301. });
  302. } else {
  303. log('未配置视觉分析服务', 'warning');
  304. }
  305. } catch (error) {
  306. log(`视觉分析失败: ${error.message}`, 'error');
  307. resolve({
  308. success: true,
  309. message: question,
  310. photo_data: photoData,
  311. photo_width: canvas.width,
  312. photo_height: canvas.height,
  313. vision_analysis: {
  314. success: false,
  315. error: error.message,
  316. fallback: '无法连接到视觉分析服务'
  317. }
  318. });
  319. }
  320. });
  321. };
  322. log('摄像头初始化完成', 'success');
  323. resolve(true);
  324. });
  325. }
  326. }
  327. // 创建并启动应用
  328. const app = new App();
  329. // 将应用实例暴露到全局,供其他模块访问
  330. window.chatApp = app;
  331. document.addEventListener('DOMContentLoaded', () => {
  332. // 初始化应用
  333. app.init();
  334. });
  335. export default app;