|
@@ -0,0 +1,511 @@
|
|
|
+class ChatUI {
|
|
|
+ constructor() {
|
|
|
+ this.messageContainer = document.getElementById("chat-messages");
|
|
|
+ this.input = document.getElementById("chat-input");
|
|
|
+ this.sendButton = document.getElementById("send-button");
|
|
|
+ this.promptButton = document.getElementById("prompt-button");
|
|
|
+ this.promptPanel = document.getElementById("prompt-panel");
|
|
|
+
|
|
|
+ // 确保AI服务已经初始化
|
|
|
+ if (!window.aiService) {
|
|
|
+ throw new Error("AI Service not initialized");
|
|
|
+ }
|
|
|
+ this.aiService = window.aiService;
|
|
|
+
|
|
|
+ this.typingSpeed = 50; // 打字速度(毫秒/字符)
|
|
|
+
|
|
|
+ this.setupEventListeners();
|
|
|
+ this.adjustInputHeight();
|
|
|
+ this.init();
|
|
|
+
|
|
|
+ // 添加新消息指示器
|
|
|
+ this.createNewMessageIndicator();
|
|
|
+
|
|
|
+ // 监听滚动事件
|
|
|
+ this.messageContainer.addEventListener(
|
|
|
+ "scroll",
|
|
|
+ this.handleScroll.bind(this)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 创建打断按钮
|
|
|
+ this.stopButton = document.createElement("button");
|
|
|
+ this.stopButton.className = "stop-button";
|
|
|
+ this.stopButton.innerHTML = `
|
|
|
+ <svg viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <path d="M6 6h12v12H6z"/>
|
|
|
+ </svg>
|
|
|
+ <span>停止生成</span>
|
|
|
+ `;
|
|
|
+
|
|
|
+ // 将打断按钮添加到输入按钮区域
|
|
|
+ document.querySelector(".input-buttons").appendChild(this.stopButton);
|
|
|
+
|
|
|
+ // 添加打断按钮事件监听
|
|
|
+ this.stopButton.addEventListener("click", () => this.handleStop());
|
|
|
+
|
|
|
+ this.isTyping = false; // 添加打字状态标记
|
|
|
+ this.loadingDiv = null; // 添加加载动画的引用
|
|
|
+ this.inputWrapper = document.querySelector(".input-wrapper");
|
|
|
+ }
|
|
|
+
|
|
|
+ async init() {
|
|
|
+ try {
|
|
|
+ await this.aiService.init();
|
|
|
+ await this.checkApiKey();
|
|
|
+ } catch (error) {
|
|
|
+ console.error("ChatUI initialization failed:", error);
|
|
|
+ this.addMessage("初始化失败,请刷新页面重试。", "assistant");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setupEventListeners() {
|
|
|
+ // 发送消息
|
|
|
+ this.sendButton.addEventListener("click", () => this.handleSend());
|
|
|
+ this.input.addEventListener("keydown", (e) => {
|
|
|
+ if (e.key === "Enter" && !e.shiftKey) {
|
|
|
+ e.preventDefault();
|
|
|
+ this.handleSend();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 自动调整输入框高度
|
|
|
+ this.input.addEventListener("input", () => this.adjustInputHeight());
|
|
|
+
|
|
|
+ // 快捷指令面板
|
|
|
+ this.promptButton.addEventListener("click", () => this.togglePromptPanel());
|
|
|
+ document.querySelector(".close-prompt").addEventListener("click", () => {
|
|
|
+ this.promptPanel.classList.remove("show");
|
|
|
+ });
|
|
|
+
|
|
|
+ // 点击快捷指令
|
|
|
+ document.querySelectorAll(".prompt-item").forEach((item) => {
|
|
|
+ item.addEventListener("click", () => {
|
|
|
+ this.input.value = item.textContent;
|
|
|
+ this.promptPanel.classList.remove("show");
|
|
|
+ this.adjustInputHeight();
|
|
|
+ this.input.focus();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 点击外部关闭快捷指令面板
|
|
|
+ document.addEventListener("click", (e) => {
|
|
|
+ if (
|
|
|
+ !this.promptPanel.contains(e.target) &&
|
|
|
+ !this.promptButton.contains(e.target)
|
|
|
+ ) {
|
|
|
+ this.promptPanel.classList.remove("show");
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async handleSend() {
|
|
|
+ const message = this.input.value.trim();
|
|
|
+ if (!message) return;
|
|
|
+
|
|
|
+ // 处理命令
|
|
|
+ if (message.startsWith("/setapi ")) {
|
|
|
+ const apiKey = message.substring(8).trim();
|
|
|
+ await this.aiService.setApiKey(apiKey);
|
|
|
+
|
|
|
+ // 根据是否使用默认密钥显示不同消息
|
|
|
+ if (apiKey === CONFIG.AI_API.DEFAULT_API_KEY) {
|
|
|
+ this.addMessage("已恢复使用默认API密钥", "assistant", false);
|
|
|
+ } else {
|
|
|
+ this.addMessage("自定义API密钥已设置", "assistant", false);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.input.value = "";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加用户消息
|
|
|
+ this.addMessage(message, "user", false);
|
|
|
+
|
|
|
+ // 更新上下文
|
|
|
+ this.aiService.updateContext(message, "user");
|
|
|
+
|
|
|
+ // 清空输入框
|
|
|
+ this.input.value = "";
|
|
|
+ this.adjustInputHeight();
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.stopButton.classList.add("show");
|
|
|
+ this.setInputState(true); // 禁用输入框
|
|
|
+ this.loadingDiv = this.createLoadingMessage();
|
|
|
+
|
|
|
+ const response = await this.aiService.sendMessage(message);
|
|
|
+
|
|
|
+ if (this.loadingDiv) {
|
|
|
+ this.loadingDiv.remove();
|
|
|
+ this.loadingDiv = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!response) return;
|
|
|
+
|
|
|
+ this.isTyping = false;
|
|
|
+ await this.addMessage(response, "assistant", true);
|
|
|
+
|
|
|
+ this.stopButton.classList.remove("show");
|
|
|
+ this.setInputState(false); // 恢复输入框
|
|
|
+
|
|
|
+ this.aiService.updateContext(response, "assistant");
|
|
|
+ } catch (error) {
|
|
|
+ if (this.loadingDiv) {
|
|
|
+ this.loadingDiv.remove();
|
|
|
+ this.loadingDiv = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.stopButton.classList.remove("show");
|
|
|
+ this.setInputState(false); // 恢复输入框
|
|
|
+ this.isTyping = false;
|
|
|
+
|
|
|
+ if (error.message === "REQUEST_ABORTED") {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.addMessage("抱歉,发生了一些错误,请稍后重试。", "assistant", false);
|
|
|
+ console.error("AI response error:", error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 添加消息到聊天界面
|
|
|
+ * @param {string} content 消息内容
|
|
|
+ * @param {string} type 消息类型(user/assistant)
|
|
|
+ * @param {boolean} typing 是否使用打字效果
|
|
|
+ * @param {boolean} isInterrupted 是否是中断消息
|
|
|
+ */
|
|
|
+ async addMessage(
|
|
|
+ content,
|
|
|
+ type,
|
|
|
+ typing = type === "assistant",
|
|
|
+ isInterrupted = false
|
|
|
+ ) {
|
|
|
+ const messageDiv = document.createElement("div");
|
|
|
+ messageDiv.className = `message ${type}`;
|
|
|
+
|
|
|
+ const messageContent = document.createElement("div");
|
|
|
+ messageContent.className = "message-content";
|
|
|
+ if (isInterrupted) {
|
|
|
+ messageContent.classList.add("interrupted");
|
|
|
+ }
|
|
|
+
|
|
|
+ const paragraph = document.createElement("p");
|
|
|
+ messageContent.appendChild(paragraph);
|
|
|
+
|
|
|
+ // 创建操作栏(时间戳和复制按钮)
|
|
|
+ const actionsDiv = document.createElement("div");
|
|
|
+ actionsDiv.className = "message-actions";
|
|
|
+
|
|
|
+ // 只为非中断的AI消息添加复制按钮
|
|
|
+ if (type === "assistant" && !isInterrupted) {
|
|
|
+ const copyButton = this.createCopyButton(content);
|
|
|
+ actionsDiv.appendChild(copyButton);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加时间戳
|
|
|
+ const timestamp = document.createElement("div");
|
|
|
+ timestamp.className = "message-timestamp";
|
|
|
+ timestamp.textContent = this.formatTime(new Date());
|
|
|
+ actionsDiv.appendChild(timestamp);
|
|
|
+
|
|
|
+ messageContent.appendChild(actionsDiv);
|
|
|
+ messageDiv.appendChild(messageContent);
|
|
|
+ this.messageContainer.appendChild(messageDiv);
|
|
|
+
|
|
|
+ if (typing) {
|
|
|
+ messageContent.classList.add("typing");
|
|
|
+ await this.typeMessage(paragraph, content);
|
|
|
+ messageContent.classList.remove("typing");
|
|
|
+ } else {
|
|
|
+ paragraph.innerHTML = this.formatMessage(content);
|
|
|
+ }
|
|
|
+
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
|
|
|
+ const wasAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
|
+
|
|
|
+ if (wasAtBottom) {
|
|
|
+ this.scrollToBottom();
|
|
|
+ } else {
|
|
|
+ this.newMessageIndicator.classList.add("show");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 实现打字机效果
|
|
|
+ * @param {HTMLElement} element 要添加文字的元素
|
|
|
+ * @param {string} text 要显示的文字
|
|
|
+ */
|
|
|
+ async typeMessage(element, text) {
|
|
|
+ let index = 0;
|
|
|
+ const rawText = text;
|
|
|
+ const tempDiv = document.createElement("div");
|
|
|
+ this.isTyping = true; // 开始打字
|
|
|
+
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const type = () => {
|
|
|
+ // 检查是否被中断
|
|
|
+ if (!this.isTyping) {
|
|
|
+ resolve();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (index < rawText.length) {
|
|
|
+ const currentText = rawText.substring(0, index + 1);
|
|
|
+ tempDiv.innerHTML = this.formatMessage(currentText);
|
|
|
+ element.innerHTML = tempDiv.innerHTML;
|
|
|
+
|
|
|
+ index++;
|
|
|
+ this.scrollToBottom();
|
|
|
+ setTimeout(type, this.typingSpeed);
|
|
|
+ } else {
|
|
|
+ this.isTyping = false; // 打字结束
|
|
|
+ resolve();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ type();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ adjustInputHeight() {
|
|
|
+ this.input.style.height = "auto";
|
|
|
+ this.input.style.height = Math.min(this.input.scrollHeight, 120) + "px";
|
|
|
+ }
|
|
|
+
|
|
|
+ scrollToBottom() {
|
|
|
+ this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ togglePromptPanel() {
|
|
|
+ this.promptPanel.classList.toggle("show");
|
|
|
+ }
|
|
|
+
|
|
|
+ escapeHtml(html) {
|
|
|
+ const div = document.createElement("div");
|
|
|
+ div.textContent = html;
|
|
|
+ return div.innerHTML;
|
|
|
+ }
|
|
|
+
|
|
|
+ async checkApiKey() {
|
|
|
+ // 只有当没有任何API密钥时才显示提示
|
|
|
+ if (!this.aiService.apiKey) {
|
|
|
+ this.addMessage(
|
|
|
+ "请先设置DeepSeek API密钥。输入格式:/setapi YOUR_API_KEY",
|
|
|
+ "assistant"
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建加载动画消息
|
|
|
+ */
|
|
|
+ createLoadingMessage() {
|
|
|
+ const loadingDiv = document.createElement("div");
|
|
|
+ loadingDiv.className = "message assistant";
|
|
|
+ loadingDiv.innerHTML = `
|
|
|
+ <div class="message-content loading">
|
|
|
+ <div class="typing-indicator">
|
|
|
+ <span></span>
|
|
|
+ <span></span>
|
|
|
+ <span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ this.messageContainer.appendChild(loadingDiv);
|
|
|
+ this.scrollToBottom();
|
|
|
+ return loadingDiv;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化时间
|
|
|
+ */
|
|
|
+ formatTime(date) {
|
|
|
+ const hours = date.getHours().toString().padStart(2, "0");
|
|
|
+ const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
|
+ return `${hours}:${minutes}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ createNewMessageIndicator() {
|
|
|
+ this.newMessageIndicator = document.createElement("div");
|
|
|
+ this.newMessageIndicator.className = "new-messages-indicator";
|
|
|
+ this.newMessageIndicator.textContent = "新消息";
|
|
|
+ this.newMessageIndicator.addEventListener("click", () => {
|
|
|
+ this.scrollToBottom();
|
|
|
+ });
|
|
|
+ document
|
|
|
+ .querySelector(".chat-container")
|
|
|
+ .appendChild(this.newMessageIndicator);
|
|
|
+ }
|
|
|
+
|
|
|
+ handleScroll() {
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
|
|
|
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
|
+
|
|
|
+ if (isNearBottom) {
|
|
|
+ this.newMessageIndicator.classList.remove("show");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化消息内容
|
|
|
+ * @param {string} text 原始文本
|
|
|
+ * @returns {string} 格式化后的HTML
|
|
|
+ */
|
|
|
+ formatMessage(text) {
|
|
|
+ if (!text) return "";
|
|
|
+
|
|
|
+ return (
|
|
|
+ text
|
|
|
+ // 处理标题 (h1 ~ h6)
|
|
|
+ .replace(/^#{1,6}\s+(.+)$/gm, (match, content) => {
|
|
|
+ const level = match.trim().split("#").length - 1;
|
|
|
+ return `<h${level}>${content.trim()}</h${level}>`;
|
|
|
+ })
|
|
|
+ // 处理换行
|
|
|
+ .replace(/\n/g, "<br>")
|
|
|
+ // 处理连续空格
|
|
|
+ .replace(/ {2,}/g, (match) => " ".repeat(match.length))
|
|
|
+ // 处理代码块
|
|
|
+ .replace(
|
|
|
+ /```([\s\S]*?)```/g,
|
|
|
+ (match, code) =>
|
|
|
+ `<pre><code>${this.escapeHtml(code.trim())}</code></pre>`
|
|
|
+ )
|
|
|
+ // 处理行内代码
|
|
|
+ .replace(
|
|
|
+ /`([^`]+)`/g,
|
|
|
+ (match, code) => `<code>${this.escapeHtml(code)}</code>`
|
|
|
+ )
|
|
|
+ // 处理粗体
|
|
|
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
|
|
+ // 处理斜体
|
|
|
+ .replace(/\*(.*?)\*/g, "<em>$1</em>")
|
|
|
+ // 处理链接
|
|
|
+ .replace(
|
|
|
+ /\[([^\]]+)\]\(([^)]+)\)/g,
|
|
|
+ '<a href="$2" target="_blank">$1</a>'
|
|
|
+ )
|
|
|
+ // 处理无序列表
|
|
|
+ .replace(/^[*-]\s+(.+)$/gm, "<li>$1</li>")
|
|
|
+ .replace(/(<li>.*<\/li>)/gs, "<ul>$1</ul>")
|
|
|
+ // 处理有序列表
|
|
|
+ .replace(/^\d+\.\s+(.+)$/gm, "<li>$1</li>")
|
|
|
+ .replace(/(<li>.*<\/li>)/gs, "<ol>$1</ol>")
|
|
|
+ // 处理分隔线
|
|
|
+ .replace(/^---+$/gm, "<hr>")
|
|
|
+ // 处理引用
|
|
|
+ .replace(/^>\s+(.+)$/gm, "<blockquote>$1</blockquote>")
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建复制按钮
|
|
|
+ * @param {string} content 要复制的内容
|
|
|
+ * @returns {HTMLElement} 复制按钮元素
|
|
|
+ */
|
|
|
+ createCopyButton(content) {
|
|
|
+ const button = document.createElement("button");
|
|
|
+ button.className = "copy-button";
|
|
|
+ button.innerHTML = `
|
|
|
+ <svg viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <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"/>
|
|
|
+ </svg>
|
|
|
+ <span>复制</span>
|
|
|
+ `;
|
|
|
+
|
|
|
+ button.addEventListener("click", async () => {
|
|
|
+ try {
|
|
|
+ // 直接使用传入的原始内容
|
|
|
+ await this.copyToClipboard(content);
|
|
|
+
|
|
|
+ // 显示复制成功状态
|
|
|
+ button.classList.add("copied");
|
|
|
+ button.innerHTML = `
|
|
|
+ <svg viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
|
|
|
+ </svg>
|
|
|
+ <span>已复制</span>
|
|
|
+ `;
|
|
|
+
|
|
|
+ // 2秒后恢复原始状态
|
|
|
+ setTimeout(() => {
|
|
|
+ button.classList.remove("copied");
|
|
|
+ button.innerHTML = `
|
|
|
+ <svg viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <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"/>
|
|
|
+ </svg>
|
|
|
+ <span>复制</span>
|
|
|
+ `;
|
|
|
+ }, 2000);
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to copy text:", err);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return button;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 复制文本到剪贴板
|
|
|
+ * @param {string} text 要复制的文本
|
|
|
+ */
|
|
|
+ async copyToClipboard(text) {
|
|
|
+ try {
|
|
|
+ // 通过postMessage发送复制请求到content script
|
|
|
+ window.parent.postMessage(
|
|
|
+ {
|
|
|
+ type: "COPY_TO_CLIPBOARD",
|
|
|
+ text: text,
|
|
|
+ },
|
|
|
+ "*"
|
|
|
+ );
|
|
|
+ return true;
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to copy text:", err);
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理打断请求
|
|
|
+ async handleStop() {
|
|
|
+ try {
|
|
|
+ this.aiService.abortRequest();
|
|
|
+
|
|
|
+ if (this.isTyping) {
|
|
|
+ this.isTyping = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.loadingDiv) {
|
|
|
+ this.loadingDiv.remove();
|
|
|
+ this.loadingDiv = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.stopButton.classList.remove("show");
|
|
|
+ this.setInputState(false); // 恢复输入框状态
|
|
|
+
|
|
|
+ this.addMessage("用户手动停止生成", "assistant", false, true);
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Failed to stop generation:", error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置输入框状态
|
|
|
+ setInputState(isGenerating) {
|
|
|
+ this.input.disabled = isGenerating;
|
|
|
+ this.input.placeholder = isGenerating ? "回复生成中..." : "输入消息...";
|
|
|
+ if (isGenerating) {
|
|
|
+ this.inputWrapper.classList.add("generating");
|
|
|
+ } else {
|
|
|
+ this.inputWrapper.classList.remove("generating");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 等待DOM加载完成后再初始化
|
|
|
+document.addEventListener("DOMContentLoaded", () => {
|
|
|
+ try {
|
|
|
+ const chatUI = new ChatUI();
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Failed to initialize ChatUI:", error);
|
|
|
+ }
|
|
|
+});
|