chat-ui.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. class ChatUI {
  2. constructor() {
  3. this.messageContainer = document.getElementById("chat-messages");
  4. this.input = document.getElementById("chat-input");
  5. this.sendButton = document.getElementById("send-button");
  6. this.promptButton = document.getElementById("prompt-button");
  7. this.promptPanel = document.getElementById("prompt-panel");
  8. // 确保AI服务已经初始化
  9. if (!window.aiService) {
  10. throw new Error("AI Service not initialized");
  11. }
  12. this.aiService = window.aiService;
  13. this.typingSpeed = 50; // 打字速度(毫秒/字符)
  14. this.setupEventListeners();
  15. this.adjustInputHeight();
  16. this.init();
  17. // 添加新消息指示器
  18. this.createNewMessageIndicator();
  19. // 监听滚动事件
  20. this.messageContainer.addEventListener(
  21. "scroll",
  22. this.handleScroll.bind(this)
  23. );
  24. // 创建打断按钮
  25. this.stopButton = document.createElement("button");
  26. this.stopButton.className = "stop-button";
  27. this.stopButton.innerHTML = `
  28. <svg viewBox="0 0 24 24" fill="currentColor">
  29. <path d="M6 6h12v12H6z"/>
  30. </svg>
  31. <span>停止生成</span>
  32. `;
  33. // 将打断按钮添加到输入按钮区域
  34. document.querySelector(".input-buttons").appendChild(this.stopButton);
  35. // 添加打断按钮事件监听
  36. this.stopButton.addEventListener("click", () => this.handleStop());
  37. this.isTyping = false; // 添加打字状态标记
  38. this.loadingDiv = null; // 添加加载动画的引用
  39. this.inputWrapper = document.querySelector(".input-wrapper");
  40. // 初始化页面信息卡片
  41. this.initPageInfoCard();
  42. }
  43. async init() {
  44. try {
  45. await this.aiService.init();
  46. await this.checkApiKey();
  47. } catch (error) {
  48. console.error("ChatUI initialization failed:", error);
  49. this.addMessage("初始化失败,请刷新页面重试。", "assistant");
  50. }
  51. }
  52. setupEventListeners() {
  53. // 发送消息
  54. this.sendButton.addEventListener("click", () => this.handleSend());
  55. this.input.addEventListener("keydown", (e) => {
  56. if (e.key === "Enter" && !e.shiftKey) {
  57. e.preventDefault();
  58. this.handleSend();
  59. }
  60. });
  61. // 自动调整输入框高度
  62. this.input.addEventListener("input", () => this.adjustInputHeight());
  63. // 快捷指令面板
  64. this.promptButton.addEventListener("click", () => this.togglePromptPanel());
  65. document.querySelector(".close-prompt").addEventListener("click", () => {
  66. this.promptPanel.classList.remove("show");
  67. });
  68. // 点击快捷指令
  69. document.querySelectorAll(".prompt-item").forEach((item) => {
  70. item.addEventListener("click", () => {
  71. this.input.value = item.textContent;
  72. this.promptPanel.classList.remove("show");
  73. this.adjustInputHeight();
  74. this.input.focus();
  75. });
  76. });
  77. // 点击外部关闭快捷指令面板
  78. document.addEventListener("click", (e) => {
  79. if (
  80. !this.promptPanel.contains(e.target) &&
  81. !this.promptButton.contains(e.target)
  82. ) {
  83. this.promptPanel.classList.remove("show");
  84. }
  85. });
  86. }
  87. async handleSend() {
  88. const message = this.input.value.trim();
  89. if (!message) return;
  90. // 处理命令
  91. if (message.startsWith("/setapi ")) {
  92. const apiKey = message.substring(8).trim();
  93. await this.aiService.setApiKey(apiKey);
  94. // 根据是否使用默认密钥显示不同消息
  95. if (apiKey === CONFIG.AI_API.DEFAULT_API_KEY) {
  96. this.addMessage("已恢复使用默认API密钥", "assistant", false);
  97. } else {
  98. this.addMessage("自定义API密钥已设置", "assistant", false);
  99. }
  100. this.input.value = "";
  101. return;
  102. }
  103. // 添加用户消息
  104. this.addMessage(message, "user", false);
  105. // 更新上下文
  106. this.aiService.updateContext(message, "user");
  107. // 清空输入框
  108. this.input.value = "";
  109. this.adjustInputHeight();
  110. try {
  111. this.stopButton.classList.add("show");
  112. this.setInputState(true); // 禁用输入框
  113. this.loadingDiv = this.createLoadingMessage();
  114. const response = await this.aiService.sendMessage(message);
  115. if (this.loadingDiv) {
  116. this.loadingDiv.remove();
  117. this.loadingDiv = null;
  118. }
  119. if (!response) return;
  120. this.isTyping = false;
  121. await this.addMessage(response, "assistant", true);
  122. this.stopButton.classList.remove("show");
  123. this.setInputState(false); // 这里会自动聚焦输入框
  124. this.aiService.updateContext(response, "assistant");
  125. } catch (error) {
  126. if (this.loadingDiv) {
  127. this.loadingDiv.remove();
  128. this.loadingDiv = null;
  129. }
  130. this.stopButton.classList.remove("show");
  131. this.setInputState(false); // 这里也会自动聚焦输入框
  132. this.isTyping = false;
  133. if (error.message === "REQUEST_ABORTED") {
  134. return;
  135. }
  136. this.addMessage("抱歉,发生了一些错误,请稍后重试。", "assistant", false);
  137. console.error("AI response error:", error);
  138. }
  139. }
  140. /**
  141. * 添加消息到聊天界面
  142. * @param {string} content 消息内容
  143. * @param {string} type 消息类型(user/assistant)
  144. * @param {boolean} typing 是否使用打字效果
  145. * @param {boolean} isInterrupted 是否是中断消息
  146. */
  147. async addMessage(
  148. content,
  149. type,
  150. typing = type === "assistant",
  151. isInterrupted = false
  152. ) {
  153. const messageDiv = document.createElement("div");
  154. messageDiv.className = `message ${type}`;
  155. const messageContent = document.createElement("div");
  156. messageContent.className = "message-content";
  157. if (isInterrupted) {
  158. messageContent.classList.add("interrupted");
  159. }
  160. const paragraph = document.createElement("p");
  161. messageContent.appendChild(paragraph);
  162. // 创建操作栏(时间戳和复制按钮)
  163. const actionsDiv = document.createElement("div");
  164. actionsDiv.className = "message-actions";
  165. // 只为非中断的AI消息添加复制按钮
  166. if (type === "assistant" && !isInterrupted) {
  167. const copyButton = this.createCopyButton(content);
  168. actionsDiv.appendChild(copyButton);
  169. }
  170. // 添加时间戳
  171. const timestamp = document.createElement("div");
  172. timestamp.className = "message-timestamp";
  173. timestamp.textContent = this.formatTime(new Date());
  174. actionsDiv.appendChild(timestamp);
  175. messageContent.appendChild(actionsDiv);
  176. messageDiv.appendChild(messageContent);
  177. this.messageContainer.appendChild(messageDiv);
  178. if (typing) {
  179. messageContent.classList.add("typing");
  180. await this.typeMessage(paragraph, content);
  181. messageContent.classList.remove("typing");
  182. } else {
  183. paragraph.innerHTML = this.formatMessage(content);
  184. }
  185. const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
  186. const wasAtBottom = scrollHeight - scrollTop - clientHeight < 100;
  187. if (wasAtBottom) {
  188. this.scrollToBottom();
  189. } else {
  190. this.newMessageIndicator.classList.add("show");
  191. }
  192. }
  193. /**
  194. * 实现打字机效果
  195. * @param {HTMLElement} element 要添加文字的元素
  196. * @param {string} text 要显示的文字
  197. */
  198. async typeMessage(element, text) {
  199. let index = 0;
  200. const rawText = text;
  201. const tempDiv = document.createElement("div");
  202. this.isTyping = true; // 开始打字
  203. return new Promise((resolve) => {
  204. const type = () => {
  205. // 检查是否被中断
  206. if (!this.isTyping) {
  207. resolve();
  208. return;
  209. }
  210. if (index < rawText.length) {
  211. const currentText = rawText.substring(0, index + 1);
  212. tempDiv.innerHTML = this.formatMessage(currentText);
  213. element.innerHTML = tempDiv.innerHTML;
  214. index++;
  215. this.scrollToBottom();
  216. setTimeout(type, this.typingSpeed);
  217. } else {
  218. this.isTyping = false; // 打字结束
  219. resolve();
  220. }
  221. };
  222. type();
  223. });
  224. }
  225. adjustInputHeight() {
  226. this.input.style.height = "auto";
  227. this.input.style.height = Math.min(this.input.scrollHeight, 120) + "px";
  228. }
  229. scrollToBottom() {
  230. this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
  231. }
  232. togglePromptPanel() {
  233. this.promptPanel.classList.toggle("show");
  234. }
  235. escapeHtml(html) {
  236. const div = document.createElement("div");
  237. div.textContent = html;
  238. return div.innerHTML;
  239. }
  240. async checkApiKey() {
  241. // 只有当没有任何API密钥时才显示提示
  242. if (!this.aiService.apiKey) {
  243. this.addMessage(
  244. "请先设置DeepSeek API密钥。输入格式:/setapi YOUR_API_KEY",
  245. "assistant"
  246. );
  247. }
  248. }
  249. /**
  250. * 创建加载动画消息
  251. */
  252. createLoadingMessage() {
  253. const loadingDiv = document.createElement("div");
  254. loadingDiv.className = "message assistant";
  255. loadingDiv.innerHTML = `
  256. <div class="message-content loading">
  257. <div class="typing-indicator">
  258. <span></span>
  259. <span></span>
  260. <span></span>
  261. </div>
  262. </div>
  263. `;
  264. this.messageContainer.appendChild(loadingDiv);
  265. this.scrollToBottom();
  266. return loadingDiv;
  267. }
  268. /**
  269. * 格式化时间
  270. */
  271. formatTime(date) {
  272. const hours = date.getHours().toString().padStart(2, "0");
  273. const minutes = date.getMinutes().toString().padStart(2, "0");
  274. return `${hours}:${minutes}`;
  275. }
  276. createNewMessageIndicator() {
  277. this.newMessageIndicator = document.createElement("div");
  278. this.newMessageIndicator.className = "new-messages-indicator";
  279. this.newMessageIndicator.textContent = "新消息";
  280. this.newMessageIndicator.addEventListener("click", () => {
  281. this.scrollToBottom();
  282. });
  283. document
  284. .querySelector(".chat-container")
  285. .appendChild(this.newMessageIndicator);
  286. }
  287. handleScroll() {
  288. const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
  289. const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
  290. if (isNearBottom) {
  291. this.newMessageIndicator.classList.remove("show");
  292. }
  293. }
  294. /**
  295. * 格式化消息内容
  296. * @param {string} text 原始文本
  297. * @returns {string} 格式化后的HTML
  298. */
  299. formatMessage(text) {
  300. if (!text) return "";
  301. return (
  302. text
  303. // 处理标题 (h1 ~ h6)
  304. .replace(/^#{1,6}\s+(.+)$/gm, (match, content) => {
  305. const level = match.trim().split("#").length - 1;
  306. return `<h${level}>${content.trim()}</h${level}>`;
  307. })
  308. // 处理换行
  309. .replace(/\n/g, "<br>")
  310. // 处理连续空格
  311. .replace(/ {2,}/g, (match) => "&nbsp;".repeat(match.length))
  312. // 处理代码块
  313. .replace(
  314. /```([\s\S]*?)```/g,
  315. (match, code) =>
  316. `<pre><code>${this.escapeHtml(code.trim())}</code></pre>`
  317. )
  318. // 处理行内代码
  319. .replace(
  320. /`([^`]+)`/g,
  321. (match, code) => `<code>${this.escapeHtml(code)}</code>`
  322. )
  323. // 处理粗体
  324. .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
  325. // 处理斜体
  326. .replace(/\*(.*?)\*/g, "<em>$1</em>")
  327. // 处理链接
  328. .replace(
  329. /\[([^\]]+)\]\(([^)]+)\)/g,
  330. '<a href="$2" target="_blank">$1</a>'
  331. )
  332. // 处理无序列表
  333. .replace(/^[*-]\s+(.+)$/gm, "<li>$1</li>")
  334. .replace(/(<li>.*<\/li>)/gs, "<ul>$1</ul>")
  335. // 处理有序列表
  336. .replace(/^\d+\.\s+(.+)$/gm, "<li>$1</li>")
  337. .replace(/(<li>.*<\/li>)/gs, "<ol>$1</ol>")
  338. // 处理分隔线
  339. .replace(/^---+$/gm, "<hr>")
  340. // 处理引用
  341. .replace(/^>\s+(.+)$/gm, "<blockquote>$1</blockquote>")
  342. );
  343. }
  344. /**
  345. * 创建复制按钮
  346. * @param {string} content 要复制的内容
  347. * @returns {HTMLElement} 复制按钮元素
  348. */
  349. createCopyButton(content) {
  350. const button = document.createElement("button");
  351. button.className = "copy-button";
  352. button.innerHTML = `
  353. <svg viewBox="0 0 24 24" fill="currentColor">
  354. <path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
  355. </svg>
  356. <span>复制</span>
  357. `;
  358. button.addEventListener("click", async () => {
  359. try {
  360. // 直接使用传入的原始内容
  361. await this.copyToClipboard(content);
  362. // 显示复制成功状态
  363. button.classList.add("copied");
  364. button.innerHTML = `
  365. <svg viewBox="0 0 24 24" fill="currentColor">
  366. <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
  367. </svg>
  368. <span>已复制</span>
  369. `;
  370. // 2秒后恢复原始状态
  371. setTimeout(() => {
  372. button.classList.remove("copied");
  373. button.innerHTML = `
  374. <svg viewBox="0 0 24 24" fill="currentColor">
  375. <path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
  376. </svg>
  377. <span>复制</span>
  378. `;
  379. }, 2000);
  380. } catch (err) {
  381. console.error("Failed to copy text:", err);
  382. }
  383. });
  384. return button;
  385. }
  386. /**
  387. * 复制文本到剪贴板
  388. * @param {string} text 要复制的文本
  389. */
  390. async copyToClipboard(text) {
  391. try {
  392. // 通过postMessage发送复制请求到content script
  393. window.parent.postMessage(
  394. {
  395. type: "COPY_TO_CLIPBOARD",
  396. text: text,
  397. },
  398. "*"
  399. );
  400. return true;
  401. } catch (err) {
  402. console.error("Failed to copy text:", err);
  403. throw err;
  404. }
  405. }
  406. // 处理打断请求
  407. async handleStop() {
  408. try {
  409. this.aiService.abortRequest();
  410. if (this.isTyping) {
  411. this.isTyping = false;
  412. }
  413. if (this.loadingDiv) {
  414. this.loadingDiv.remove();
  415. this.loadingDiv = null;
  416. }
  417. this.stopButton.classList.remove("show");
  418. this.setInputState(false); // 中断后也自动聚焦输入框
  419. this.addMessage("用户手动停止生成", "assistant", false, true);
  420. } catch (error) {
  421. console.error("Failed to stop generation:", error);
  422. }
  423. }
  424. // 设置输入框状态
  425. setInputState(isGenerating) {
  426. this.input.disabled = isGenerating;
  427. this.input.placeholder = isGenerating ? "回复生成中..." : "输入消息...";
  428. if (isGenerating) {
  429. this.inputWrapper.classList.add("generating");
  430. } else {
  431. this.inputWrapper.classList.remove("generating");
  432. // AI回复完成后,自动聚焦到输入框
  433. this.input.focus();
  434. }
  435. }
  436. /**
  437. * 初始化页面信息卡片
  438. */
  439. initPageInfoCard() {
  440. // 从父窗口获取页面信息
  441. window.addEventListener("message", (event) => {
  442. if (event.data.type === "PAGE_INFO") {
  443. const pageInfo = event.data.pageInfo;
  444. // 更新卡片内容
  445. const favicon = document.querySelector(".page-favicon");
  446. const title = document.querySelector(".page-title");
  447. // 设置网站图标
  448. favicon.src = pageInfo.favicon;
  449. favicon.onerror = () => {
  450. // 如果图标加载失败,使用默认图标
  451. favicon.src = chrome.runtime.getURL("images/icon16.png");
  452. };
  453. // 设置页面标题
  454. title.textContent = pageInfo.title;
  455. }
  456. });
  457. }
  458. /**
  459. * 获取页面favicon
  460. * @returns {string} favicon URL
  461. */
  462. getPageFavicon() {
  463. // 尝试获取页面favicon
  464. const iconLink = document.querySelector('link[rel*="icon"]');
  465. if (iconLink) return iconLink.href;
  466. // 如果没有找到,返回网站根目录的favicon.ico
  467. const url = new URL(window.location.href);
  468. return `${url.protocol}//${url.hostname}/favicon.ico`;
  469. }
  470. /**
  471. * 处理总结请求
  472. */
  473. async handleSummarize() {
  474. try {
  475. // 显示加载状态
  476. this.setInputState(true);
  477. this.loadingDiv = this.createLoadingMessage();
  478. // 获取页面分析结果
  479. const pageContext = window.pageAnalyzer.analyzePage();
  480. // 构建提示词
  481. const prompt = `请总结当前页面的主要内容:
  482. 标题:${pageContext.title}
  483. URL:${pageContext.url}
  484. 主要内容:${pageContext.mainContent}`;
  485. // 发送分析请求
  486. const response = await this.aiService.sendMessage(prompt);
  487. // 显示分析结果
  488. if (this.loadingDiv) {
  489. this.loadingDiv.remove();
  490. this.loadingDiv = null;
  491. }
  492. await this.addMessage(response, "assistant", true);
  493. this.setInputState(false); // 总结完成后自动聚焦输入框
  494. } catch (error) {
  495. console.error("Failed to summarize page:", error);
  496. this.addMessage(
  497. "抱歉,页面总结过程中出现错误,请稍后重试。",
  498. "assistant",
  499. false
  500. );
  501. this.setInputState(false); // 错误后也自动聚焦输入框
  502. }
  503. }
  504. }
  505. // 等待DOM加载完成后再初始化
  506. document.addEventListener("DOMContentLoaded", () => {
  507. try {
  508. const chatUI = new ChatUI();
  509. } catch (error) {
  510. console.error("Failed to initialize ChatUI:", error);
  511. }
  512. });