From 815c2c598e195c8c51129ed7855b1f591bc4dc2a Mon Sep 17 00:00:00 2001 From: o1key <951565127@qq.com> Date: Mon, 25 May 2026 18:33:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E8=81=8A=E5=A4=A9=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=A4=9A=E6=A8=A1=E6=80=81=E5=A2=9E=E5=BC=BA=E5=8F=8A?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持视频/音频/PDF/文档等多文件类型上传(最大20MB,最多4个) - 视频用 image_url 格式传递(兼容 Gemini OpenAI 兼容层) - 发送前自动生成图片/视频缩略图,聊天记录中显示预览 - 新增消息编辑、重新生成、复制功能 - 支持 reasoning_content 思考过程折叠显示 - 历史对话支持重命名 - 修复 localStorage 存储优化(strip 大文件数据,保留缩略图) - 新增删除历史记录 API 及重启按钮前端组件 Co-Authored-By: Claude Opus 4.7 --- __init__.py | 107 +++++ web/js/assetToggle.js | 16 + web/js/chatPanel.js | 889 ++++++++++++++++++++++++++++++++++++++++ web/js/restartButton.js | 73 ++++ 4 files changed, 1085 insertions(+) create mode 100644 web/js/chatPanel.js create mode 100644 web/js/restartButton.js diff --git a/__init__.py b/__init__.py index 9de7617..e41ed44 100644 --- a/__init__.py +++ b/__init__.py @@ -432,6 +432,113 @@ try: } return web.json_response(job_detail) + @PromptServer.instance.routes.post("/o1key/delete_history") + async def delete_history_item(request): + """删除持久化历史记录及对应的输出文件""" + import os, uuid, json as _json + body = await request.json() + job_ids = body.get("delete", []) + if not job_ids: + return web.json_response({"success": False, "error": "missing ids"}, status=400) + output_dir = os.path.abspath(folder_paths.get_output_directory()) + meta_file = os.path.join(output_dir, ".o1key_history.json") + meta = {} + if os.path.isfile(meta_file): + try: + with open(meta_file, "r", encoding="utf-8") as mf: + meta = _json.load(mf) + except Exception: + pass + deleted_files = [] + for job_id in job_ids: + files_to_remove = [] + for fname, m in list(meta.items()): + if m.get("workflow_id") == job_id: + files_to_remove.append(fname) + elif str(uuid.uuid5(uuid.NAMESPACE_URL, fname)) == job_id: + files_to_remove.append(fname) + for fname in files_to_remove: + meta.pop(fname, None) + fpath = os.path.join(output_dir, fname) + if os.path.isfile(fpath): + try: + os.remove(fpath) + deleted_files.append(fname) + except Exception: + pass + try: + with open(meta_file, "w", encoding="utf-8") as mf: + _json.dump(meta, mf, ensure_ascii=False) + except Exception: + pass + return web.json_response({"success": True, "deleted": deleted_files}) + + # === AI 聊天代理(流式 SSE 透传) === + @PromptServer.instance.routes.post("/o1key/restart") + async def restart_server(request): + import sys, os as _ros, subprocess, threading + def _do_restart(): + import time + time.sleep(1.5) + skip = {"--auto-launch", "--auto_launch", "--launch", "--windows-standalone-build"} + args = [a for a in sys.argv if a not in skip] + args.append("--disable-auto-launch") + subprocess.Popen([sys.executable] + args, cwd=_ros.getcwd()) + _ros._exit(0) + threading.Thread(target=_do_restart, daemon=True).start() + return web.json_response({"success": True, "message": "正在重启..."}) + + # === AI 聊天代理(流式 SSE 透传) === + @PromptServer.instance.routes.post("/o1key/chat/completions") + async def chat_completions_proxy(request): + import aiohttp as _aiohttp + import json as _cjson + + data = await request.json() + config = load_config() + api_key = config.get("O1KEY_API_KEY", "") + if not api_key: + return web.json_response({"error": "未配置 API Key"}, status=401) + + route = data.get("route", "全球加速") + base_url = NETWORK_ROUTES.get(route, "https://cf-api.o1key.com") + model = data.get("model", "gpt-5.5") + messages = data.get("messages", []) + + url = f"{base_url}/v1/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + body = {"model": model, "messages": messages, "stream": True} + + resp = web.StreamResponse( + status=200, reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } + ) + await resp.prepare(request) + + try: + timeout = _aiohttp.ClientTimeout(total=120) + async with _aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, json=body) as upstream: + if upstream.status != 200: + err = await upstream.text() + await resp.write(f"data: {_cjson.dumps({'error': err})}\n\n".encode()) + await resp.write(b"data: [DONE]\n\n") + return resp + async for chunk in upstream.content.iter_any(): + await resp.write(chunk) + except Exception as e: + await resp.write(f"data: {_cjson.dumps({'error': str(e)})}\n\n".encode()) + await resp.write(b"data: [DONE]\n\n") + + return resp + # === 执行事件 Hook:持久化耗时元数据 === import time as _time, json as _json2, os as _os _execution_tracker = {} diff --git a/web/js/assetToggle.js b/web/js/assetToggle.js index 5dd5f96..6e9130e 100644 --- a/web/js/assetToggle.js +++ b/web/js/assetToggle.js @@ -72,6 +72,22 @@ app.registerExtension({ } }; + // --- Patch deleteItem: 同时删除 o1key 持久化记录和文件 --- + const _origDeleteItem = api.deleteItem.bind(api); + api.deleteItem = async function (type, id) { + const result = await _origDeleteItem(type, id); + if (type === "history" && enabled()) { + try { + await fetch("/o1key/delete_history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delete: [id] }), + }); + } catch (e) {} + } + return result; + }; + // --- Patch getJobDetail: 真实 API 失败时回退到本地路由 --- const _origGetJobDetail = api.getJobDetail.bind(api); api.getJobDetail = async function (jobId) { diff --git a/web/js/chatPanel.js b/web/js/chatPanel.js new file mode 100644 index 0000000..9b30a21 --- /dev/null +++ b/web/js/chatPanel.js @@ -0,0 +1,889 @@ +import { app } from "../../../scripts/app.js"; + +// ─── State ─────────────────────────────────────────────────────────────────── +const STORAGE_KEY = "o1key-chat-conversations"; +let conversations = []; +let activeConvId = null; +let currentModel = "gpt-5.5"; +let isStreaming = false; +let isThinking = false; +let abortController = null; +let pendingFiles = []; +let markedReady = false; +let chatContainer = null; +let showHistory = false; + +const MAX_FILES = 4; +const MAX_FILE_SIZE = 20 * 1024 * 1024; +const ACCEPT_STRING = "image/*,video/*,audio/*,.pdf,.txt,.md,.csv,.json,.doc,.docx"; + +function classifyFile(mimeType) { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + return "document"; +} + +function getFileIcon(category) { + switch (category) { + case "video": return "\u{1F3AC}"; + case "audio": return "\u{1F3B5}"; + case "document": return "\u{1F4C4}"; + default: return "\u{1F4CE}"; + } +} + +function classifyFileByName(filename) { + const ext = (filename.split(".").pop() || "").toLowerCase(); + if (["png","jpg","jpeg","gif","webp","svg"].includes(ext)) return "image"; + if (["mp4","webm","mov","avi"].includes(ext)) return "video"; + if (["mp3","wav","ogg","m4a","flac"].includes(ext)) return "audio"; + return "document"; +} + +const MODELS = [ + "gpt-5.5", + "gemini-3.1-pro-preview", + "deepseek-v4-pro", + "claude-opus-4-7", + "claude-opus-4-6", + "gemini-3.5-flash", + "doubao-seed-2.0-pro", +]; + +// ─── Persistence ───────────────────────────────────────────────────────────── +function loadConversations() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) conversations = JSON.parse(raw); + } catch {} +} +function saveConversations() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)); } catch {} +} +function getActiveConv() { + return conversations.find(c => c.id === activeConvId) || null; +} +function getMessages() { + const conv = getActiveConv(); + return conv ? conv.messages : []; +} +function genId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} +function formatTime(ts) { + const d = new Date(ts); + const pad = n => String(n).padStart(2, "0"); + return `${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// ─── Marked.js lazy load ───────────────────────────────────────────────────── +function ensureMarked() { + if (markedReady || window.marked) { markedReady = true; return; } + const s = document.createElement("script"); + s.src = "https://cdn.jsdelivr.net/npm/marked/marked.min.js"; + s.onload = () => { markedReady = true; }; + document.head.appendChild(s); +} + +// ─── Styles ────────────────────────────────────────────────────────────────── +const STYLE_ID = "o1key-chat-styles"; +const CSS = ` +#o1key-chat-root{display:flex;flex-direction:column;height:100%;min-height:0;color:#ddd;background:var(--comfy-menu-bg,#202020);overflow:hidden;font-family:inherit;position:absolute;inset:0} +#o1key-chat-header{padding:12px 14px 0;display:flex;align-items:center;justify-content:space-between;flex-shrink:0} +#o1key-chat-header .chat-title{font-size:14px;font-weight:600;color:#eee;letter-spacing:.3px} +#o1key-chat-header .hdr-btns{display:flex;gap:4px} +#o1key-chat-header .chat-hdr-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:transparent;color:#888;border:1px solid rgba(255,255,255,.08);border-radius:6px;cursor:pointer;transition:all .15s} +#o1key-chat-header .chat-hdr-btn:hover{color:#ddd;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.15)} +#o1key-chat-header .chat-hdr-btn.active{color:#ddd;background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)} +#o1key-chat-toolbar{padding:10px 14px;display:flex;gap:6px;flex-shrink:0} +#o1key-chat-toolbar select{flex:1;background:rgba(255,255,255,.08);color:#ddd;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 32px 8px 12px;font-size:13px;font-weight:500;outline:none;cursor:pointer;transition:all .15s;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center} +#o1key-chat-toolbar select:hover{border-color:rgba(255,255,255,.22);background:rgba(255,255,255,.1)} +#o1key-chat-toolbar select:focus{border-color:rgba(255,255,255,.3);background:rgba(255,255,255,.1)} +#o1key-chat-toolbar select option{background:#2a2a2a;color:#ddd;padding:8px} +#o1key-chat-messages{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:2px;scroll-behavior:smooth;min-height:0} +#o1key-chat-messages::-webkit-scrollbar{width:4px} +#o1key-chat-messages::-webkit-scrollbar-track{background:transparent} +#o1key-chat-messages::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:2px} +.o1k-msg-wrap{display:flex;flex-direction:column;gap:2px;position:relative;padding:4px 0} +.o1k-msg-wrap:hover .o1k-msg-actions{opacity:1} +.o1k-msg-wrap.user{align-items:flex-end} +.o1k-msg-wrap.assistant{align-items:flex-start} +.o1k-msg{max-width:90%;padding:10px 13px;border-radius:14px;font-size:13px;line-height:1.6;word-break:break-word;animation:o1k-fade .2s ease} +.o1k-msg-wrap.user .o1k-msg{background:rgba(255,255,255,.1);color:#eee;border-bottom-right-radius:4px} +.o1k-msg-wrap.assistant .o1k-msg{background:transparent;color:#ddd;border:1px solid rgba(255,255,255,.06);border-bottom-left-radius:4px} +.o1k-msg pre{background:rgba(0,0,0,.25);padding:10px 12px;border-radius:8px;overflow-x:auto;margin:8px 0;border:1px solid rgba(255,255,255,.05)} +.o1k-msg code{font-size:12px;font-family:"JetBrains Mono","Fira Code",monospace;color:#ddd} +.o1k-msg p{margin:4px 0} +.o1k-msg a{color:#7eb8f7;text-decoration:none} +.o1k-msg img{max-width:180px;max-height:140px;border-radius:8px;margin:4px 0;cursor:pointer;border:1px solid rgba(255,255,255,.06)} +.o1k-msg-time{font-size:10px;color:#555;padding:0 4px;user-select:none} +.o1k-msg-actions{position:relative;top:auto;opacity:0;transition:opacity .15s;display:flex;gap:2px;padding:2px 4px} +.o1k-msg-wrap.user .o1k-msg-actions{justify-content:flex-end} +.o1k-msg-wrap.assistant .o1k-msg-actions{justify-content:flex-start} +.o1k-msg-actions button{width:22px;height:22px;border:none;border-radius:4px;background:rgba(255,255,255,.06);color:#888;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .12s} +.o1k-msg-actions button:hover{background:rgba(255,255,255,.15);color:#ddd} +.o1k-msg-actions button.o1k-act-del:hover{background:rgba(220,60,60,.6);color:#fff} +.o1k-edit-area{display:flex;flex-direction:column;gap:6px;width:100%} +.o1k-edit-area textarea{width:100%;background:rgba(255,255,255,.06);color:#e0e0e0;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;font-size:13px;resize:none;outline:none;min-height:40px;max-height:150px;line-height:1.5;font-family:inherit} +.o1k-edit-area textarea:focus{border-color:rgba(255,255,255,.3)} +.o1k-edit-btns{display:flex;gap:6px;justify-content:flex-end} +.o1k-edit-btns button{padding:4px 12px;border:none;border-radius:6px;font-size:12px;cursor:pointer;transition:all .12s} +.o1k-edit-btns .o1k-edit-cancel{background:rgba(255,255,255,.08);color:#aaa} +.o1k-edit-btns .o1k-edit-cancel:hover{background:rgba(255,255,255,.12);color:#ddd} +.o1k-edit-btns .o1k-edit-save{background:rgba(126,184,247,.2);color:#7eb8f7} +.o1k-edit-btns .o1k-edit-save:hover{background:rgba(126,184,247,.35);color:#fff} +.o1k-msg .typing-dot{display:inline-block;width:5px;height:5px;background:#777;border-radius:50%;margin:0 2px;animation:o1k-blink 1.4s infinite} +.o1k-msg .typing-dot:nth-child(2){animation-delay:.2s} +.o1k-msg .typing-dot:nth-child(3){animation-delay:.4s} +@keyframes o1k-blink{0%,80%,100%{opacity:.2}40%{opacity:1}} +@keyframes o1k-fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} +.o1k-thinking{display:flex;align-items:center;gap:8px;padding:2px 0} +.o1k-thinking-icon{width:16px;height:16px;border:2px solid rgba(255,255,255,.15);border-top-color:#7eb8f7;border-radius:50%;animation:o1k-spin .8s linear infinite} +.o1k-thinking-text{font-size:12px;color:#888;animation:o1k-pulse 1.5s ease-in-out infinite} +@keyframes o1k-spin{to{transform:rotate(360deg)}} +@keyframes o1k-pulse{0%,100%{opacity:.6}50%{opacity:1}} +.o1k-reasoning{margin:0 0 8px;border:1px solid rgba(255,255,255,.06);border-radius:8px;overflow:hidden} +.o1k-reasoning summary{display:flex;align-items:center;gap:6px;padding:6px 10px;font-size:11px;color:#888;cursor:pointer;user-select:none;list-style:none;transition:color .12s} +.o1k-reasoning summary::-webkit-details-marker{display:none} +.o1k-reasoning summary::before{content:"";display:inline-block;width:0;height:0;border-style:solid;border-width:4px 0 4px 6px;border-color:transparent transparent transparent #666;transition:transform .15s} +.o1k-reasoning[open] summary::before{transform:rotate(90deg)} +.o1k-reasoning summary:hover{color:#bbb} +.o1k-reasoning .o1k-reasoning-body{padding:6px 10px 8px;font-size:12px;color:#777;line-height:1.5;border-top:1px solid rgba(255,255,255,.04);max-height:200px;overflow-y:auto} +.o1k-reasoning .o1k-reasoning-body::-webkit-scrollbar{width:3px} +.o1k-reasoning .o1k-reasoning-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:2px} +#o1key-chat-history{flex:1;overflow-y:auto;padding:8px 14px;display:flex;flex-direction:column;gap:4px;min-height:0} +.o1k-conv-item{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;cursor:pointer;transition:all .12s;border:1px solid transparent} +.o1k-conv-item:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.08)} +.o1k-conv-item.active{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.12)} +.o1k-conv-item .conv-info{flex:1;min-width:0} +.o1k-conv-item .conv-title{font-size:12px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.o1k-conv-item .conv-time{font-size:10px;color:#666;margin-top:2px} +.o1k-conv-item .conv-rename,.o1k-conv-item .conv-del{width:22px;height:22px;border:none;border-radius:4px;background:transparent;color:#666;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .12s;flex-shrink:0} +.o1k-conv-item:hover .conv-rename,.o1k-conv-item:hover .conv-del{opacity:1} +.o1k-conv-item .conv-rename:hover{background:rgba(126,184,247,.2);color:#7eb8f7} +.o1k-conv-item .conv-del:hover{background:rgba(220,60,60,.6);color:#fff} +#o1key-chat-input-area{flex-shrink:0;padding:10px 14px 14px;background:transparent} +#o1key-chat-previews{display:flex;gap:6px;padding:0 0 8px;flex-wrap:wrap} +#o1key-chat-previews .preview-thumb{position:relative;width:40px;height:40px;border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,.1)} +#o1key-chat-previews .preview-thumb img,#o1key-chat-previews .preview-thumb video{width:100%;height:100%;object-fit:cover} +#o1key-chat-previews .preview-thumb .remove-btn{position:absolute;top:-2px;right:-2px;width:14px;height:14px;background:rgba(200,60,60,.85);color:#fff;border:none;border-radius:50%;font-size:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1} +#o1key-chat-previews .preview-thumb.video-preview{width:60px} +#o1key-chat-previews .preview-thumb.video-preview::after{content:"";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.3);pointer-events:none} +#o1key-chat-previews .preview-thumb.video-preview .play-badge{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:16px;height:16px;background:rgba(255,255,255,.85);border-radius:50%;display:flex;align-items:center;justify-content:center;pointer-events:none} +#o1key-chat-previews .preview-thumb.video-preview .play-badge::after{content:"";border-style:solid;border-width:4px 0 4px 7px;border-color:transparent transparent transparent #333;margin-left:1px} +#o1key-chat-previews .preview-thumb.file-preview{display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.06);width:auto;min-width:40px;padding:0 8px;gap:4px} +#o1key-chat-previews .preview-thumb.file-preview .file-icon{font-size:16px;line-height:1} +#o1key-chat-previews .preview-thumb.file-preview .file-name{font-size:9px;color:#aaa;max-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.o1k-msg .file-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:6px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);font-size:11px;color:#bbb;margin:3px 2px} +.o1k-msg .file-chip .chip-icon{font-size:14px} +.o1k-msg .file-chip .chip-name{max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.o1k-msg .video-thumb{position:relative;display:inline-block;max-width:160px;border-radius:8px;overflow:hidden;margin:4px 0;border:1px solid rgba(255,255,255,.08)} +.o1k-msg .video-thumb img{width:100%;display:block;border-radius:8px} +.o1k-msg .video-thumb .play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.25)} +.o1k-msg .video-thumb .play-overlay::after{content:"";border-style:solid;border-width:8px 0 8px 14px;border-color:transparent transparent transparent rgba(255,255,255,.9)} +#o1key-chat-input-box{display:flex;align-items:flex-end;gap:0;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:6px 6px 6px 12px;transition:border-color .2s} +#o1key-chat-input-box:focus-within{border-color:rgba(255,255,255,.22)} +#o1key-chat-input-box textarea{flex:1;background:transparent;color:#e0e0e0;border:none;padding:6px 0;font-size:13px;resize:none;outline:none;min-height:20px;max-height:100px;line-height:1.5} +#o1key-chat-input-box textarea::placeholder{color:#555} +#o1key-chat-input-box button{width:30px;height:30px;border:none;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0;background:transparent;color:#666} +#o1key-chat-input-box button:hover{color:#ccc;background:rgba(255,255,255,.08)} +#o1key-chat-input-box .o1k-btn-send{color:#999;background:rgba(255,255,255,.06)} +#o1key-chat-input-box .o1k-btn-send:hover{color:#fff;background:rgba(255,255,255,.15)} +#o1key-chat-input-box .o1k-btn-stop{color:#fff;background:rgba(220,70,55,.75)} +#o1key-chat-input-box .o1k-btn-stop:hover{background:rgba(220,70,55,.95)} +.o1k-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#555;font-size:13px;gap:10px;user-select:none} +.o1k-empty svg{width:36px;height:36px;opacity:.2;stroke:#888} +`; + +function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const el = document.createElement("style"); + el.id = STYLE_ID; + el.textContent = CSS; + document.head.appendChild(el); +} + +// ─── Extension Registration ────────────────────────────────────────────────── +app.registerExtension({ + name: "o1key.chatPanel", + async setup() { + ensureMarked(); + loadConversations(); + app.extensionManager.registerSidebarTab({ + id: "o1key-chat", + title: "对话", + icon: "pi pi-comments", + type: "custom", + render: (container) => { + injectStyles(); + renderChatPanel(container); + }, + }); + }, +}); + +// ─── Render Chat Panel ─────────────────────────────────────────────────────── +function renderChatPanel(container) { + container.innerHTML = ""; + container.style.position = "relative"; + container.style.height = "100%"; + container.style.overflow = "hidden"; + const root = document.createElement("div"); + root.id = "o1key-chat-root"; + root.innerHTML = ` +
+ AI 对话 +
+ + +
+
+
+ +
+
+ +
+
+
+ + + +
+
+ + `; + container.appendChild(root); + chatContainer = root; + bindEvents(root); + renderMessages(); +} + +// ─── Event Binding ─────────────────────────────────────────────────────────── +function bindEvents(root) { + const modelSel = root.querySelector("#o1k-model-sel"); + const newBtn = root.querySelector("#o1k-new-chat"); + const histBtn = root.querySelector("#o1k-history-btn"); + const input = root.querySelector("#o1k-input"); + const sendBtn = root.querySelector("#o1k-send"); + const attachBtn = root.querySelector("#o1k-attach"); + const fileInput = root.querySelector("#o1k-file-input"); + + modelSel.addEventListener("change", () => { currentModel = modelSel.value; }); + newBtn.addEventListener("click", startNewConversation); + histBtn.addEventListener("click", toggleHistory); + + input.addEventListener("input", () => autoResize(input)); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } + }); + input.addEventListener("paste", (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { e.preventDefault(); addFile(file); } + } + } + }); + sendBtn.addEventListener("click", handleSend); + attachBtn.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => { + for (const f of fileInput.files) addFile(f); + fileInput.value = ""; + }); +} + +// ─── Conversation Management ───────────────────────────────────────────────── +function startNewConversation() { + if (showHistory) toggleHistory(); + const conv = { id: genId(), title: "新对话", messages: [], createdAt: Date.now(), updatedAt: Date.now() }; + conversations.unshift(conv); + activeConvId = conv.id; + saveConversations(); + pendingFiles = []; + renderMessages(); + renderPreviews(); +} + +function toggleHistory() { + showHistory = !showHistory; + const msgBox = chatContainer.querySelector("#o1key-chat-messages"); + const histBox = chatContainer.querySelector("#o1key-chat-history"); + const histBtn = chatContainer.querySelector("#o1k-history-btn"); + if (showHistory) { + msgBox.style.display = "none"; + histBox.style.display = "flex"; + histBtn.classList.add("active"); + renderHistory(); + } else { + msgBox.style.display = "flex"; + histBox.style.display = "none"; + histBtn.classList.remove("active"); + } +} + +function renderHistory() { + const box = chatContainer.querySelector("#o1key-chat-history"); + if (conversations.length === 0) { + box.innerHTML = `
暂无对话记录
`; + return; + } + box.innerHTML = conversations.map(c => { + const active = c.id === activeConvId ? " active" : ""; + const title = c.title || "新对话"; + const time = formatTime(c.updatedAt || c.createdAt); + return `
+
${escapeHtml(title)}
${time}
+ + +
`; + }).join(""); + box.querySelectorAll(".o1k-conv-item").forEach(el => { + el.addEventListener("click", (e) => { + if (e.target.closest(".conv-del") || e.target.closest(".conv-rename")) return; + activeConvId = el.dataset.id; + showHistory = false; + const msgBox = chatContainer.querySelector("#o1key-chat-messages"); + const histBox = chatContainer.querySelector("#o1key-chat-history"); + msgBox.style.display = "flex"; + histBox.style.display = "none"; + chatContainer.querySelector("#o1k-history-btn").classList.remove("active"); + renderMessages(); + }); + }); + box.querySelectorAll(".conv-rename").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const id = btn.dataset.id; + const conv = conversations.find(c => c.id === id); + if (!conv) return; + const newTitle = prompt("重命名对话", conv.title || ""); + if (newTitle === null || !newTitle.trim()) return; + conv.title = newTitle.trim(); + conv.updatedAt = Date.now(); + saveConversations(); + renderHistory(); + }); + }); + box.querySelectorAll(".conv-del").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + if (!confirm("确定删除这个对话?")) return; + const id = btn.dataset.id; + conversations = conversations.filter(c => c.id !== id); + if (activeConvId === id) activeConvId = conversations[0]?.id || null; + saveConversations(); + renderHistory(); + }); + }); +} + +function deleteMessage(idx) { + if (!confirm("确定删除这条消息?")) return; + const conv = getActiveConv(); + if (!conv) return; + conv.messages.splice(idx, 1); + conv.updatedAt = Date.now(); + saveConversations(); + renderMessages(); +} + +function copyMessage(idx) { + const conv = getActiveConv(); + if (!conv) return; + const msg = conv.messages[idx]; + if (!msg) return; + const text = typeof msg.content === "string" ? msg.content : extractTextFromContent(msg.content); + navigator.clipboard.writeText(text).then(() => { + const btn = chatContainer.querySelector(`.o1k-msg-wrap[data-idx="${idx}"] [data-act="copy"]`); + if (btn) { btn.title = "已复制"; setTimeout(() => { btn.title = "复制"; }, 1500); } + }); +} + +function enterEditMode(idx) { + if (isStreaming) return; + const conv = getActiveConv(); + if (!conv) return; + const msg = conv.messages[idx]; + if (!msg || msg.role !== "user") return; + const text = extractTextFromContent(msg.content); + const box = chatContainer.querySelector("#o1key-chat-messages"); + const wrap = box.querySelector(`.o1k-msg-wrap[data-idx="${idx}"]`); + if (!wrap) return; + const msgEl = wrap.querySelector(".o1k-msg"); + msgEl.innerHTML = `
+ +
+ + +
+
`; + const ta = msgEl.querySelector(".o1k-edit-input"); + ta.style.height = Math.min(ta.scrollHeight, 150) + "px"; + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + msgEl.querySelector(".o1k-edit-cancel").addEventListener("click", () => renderMessages()); + msgEl.querySelector(".o1k-edit-save").addEventListener("click", () => { + const newText = ta.value.trim(); + if (!newText) return; + conv.messages.splice(idx); + conv.updatedAt = Date.now(); + saveConversations(); + sendMessage(newText, []); + }); +} + +function retryMessage(idx) { + if (isStreaming) return; + const conv = getActiveConv(); + if (!conv) return; + const msg = conv.messages[idx]; + if (!msg || msg.role !== "user") return; + const text = extractTextFromContent(msg.content); + conv.messages.splice(idx); + conv.updatedAt = Date.now(); + saveConversations(); + sendMessage(text, []); +} + +function extractTextFromContent(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const textPart = content.find(p => p.type === "text"); + return textPart?.text || ""; + } + return ""; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── +function autoResize(textarea) { + textarea.style.height = "auto"; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px"; +} + +function captureImageThumb(dataUrl) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + try { + const maxW = 160; + const w = Math.min(img.width, maxW); + const h = Math.round(w * img.height / img.width); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(img, 0, 0, w, h); + resolve(canvas.toDataURL("image/jpeg", 0.5)); + } catch { resolve(null); } + }; + img.onerror = () => resolve(null); + img.src = dataUrl; + }); +} + +function captureVideoThumb(dataUrl) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), 5000); + const video = document.createElement("video"); + video.preload = "auto"; + video.muted = true; + video.playsInline = true; + video.crossOrigin = "anonymous"; + const done = () => { + clearTimeout(timeout); + try { + const canvas = document.createElement("canvas"); + const w = Math.min(video.videoWidth || 320, 320); + const h = video.videoHeight ? Math.round(w * video.videoHeight / video.videoWidth) : 180; + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(video, 0, 0, w, h); + resolve(canvas.toDataURL("image/jpeg", 0.6)); + } catch { resolve(null); } + video.src = ""; + video.load(); + }; + video.onloadeddata = () => { + if (video.readyState >= 2) done(); + }; + video.onerror = () => { clearTimeout(timeout); resolve(null); }; + video.src = dataUrl; + video.load(); + }); +} + +function addFile(file) { + if (!file || pendingFiles.length >= MAX_FILES) return; + if (file.size > MAX_FILE_SIZE) { + alert(`文件 "${file.name}" 超过 20MB 限制`); + return; + } + const mimeType = file.type || "application/octet-stream"; + const category = classifyFile(mimeType); + const reader = new FileReader(); + reader.onload = (e) => { + const entry = { id: genId(), name: file.name, mimeType, category, dataUrl: e.target.result, thumb: null }; + pendingFiles.push(entry); + renderPreviews(); + if (category === "video") { + captureVideoThumb(e.target.result).then(thumb => { + entry.thumb = thumb; + renderPreviews(); + }); + } + }; + reader.readAsDataURL(file); +} + +function renderPreviews() { + const box = chatContainer.querySelector("#o1key-chat-previews"); + box.innerHTML = pendingFiles.map((f, i) => { + if (f.category === "image") { + return `
`; + } + if (f.category === "video") { + const src = f.thumb || f.dataUrl; + const tag = f.thumb ? `` : ``; + return `
${tag}
`; + } + const icon = getFileIcon(f.category); + return `
${icon}${escapeHtml(f.name)}
`; + }).join(""); + box.querySelectorAll(".remove-btn").forEach(btn => { + btn.addEventListener("click", () => { pendingFiles.splice(+btn.dataset.idx, 1); renderPreviews(); }); + }); +} + +function handleSend() { + if (isStreaming) { if (abortController) abortController.abort(); return; } + const input = chatContainer.querySelector("#o1k-input"); + const text = input.value.trim(); + if (!text && pendingFiles.length === 0) return; + input.value = ""; + autoResize(input); + const filesToSend = [...pendingFiles]; + pendingFiles = []; + renderPreviews(); + if (!activeConvId) startNewConversation(); + sendMessage(text, filesToSend); +} + +// ─── Send Message (Streaming) ──────────────────────────────────────────────── +function stripFileDataForStorage(conv) { + for (const msg of conv.messages) { + if (!Array.isArray(msg.content)) continue; + msg.content = msg.content.map(part => { + if (part.type === "image_url" && part._video && part.image_url?.url?.startsWith("data:")) { + return { type: "image_url", _video: true, _stripped: true, _thumb: part._thumb || null, image_url: { url: "[video]" } }; + } + if (part.type === "image_url" && part.image_url?.url?.startsWith("data:")) { + return { type: "image_url", image_url: { url: "[image]" }, _stripped: true, _thumb: part._thumb || null }; + } + if (part.type === "input_audio" && part.input_audio?.data) { + return { type: "input_audio", input_audio: { format: part.input_audio.format }, _stripped: true }; + } + if (part.type === "file" && part.file?.file_data) { + return { type: "file", file: { filename: part.file.filename }, _stripped: true }; + } + return part; + }); + } +} + +function cleanContentForApi(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return content; + const cleaned = content.filter(p => !p._stripped).map(part => { + if (part.type === "image_url") { + return { type: "image_url", image_url: { url: part.image_url.url } }; + } + if (part.type === "input_audio") { + return { type: "input_audio", input_audio: part.input_audio }; + } + if (part.type === "file") { + return { type: "file", file: part.file }; + } + if (part.type === "text") { + return { type: "text", text: part.text }; + } + return part; + }); + if (cleaned.length === 0) return "(附件已省略)"; + const textOnly = cleaned.filter(p => p.type === "text"); + if (cleaned.length === textOnly.length && textOnly.length === 1) return textOnly[0].text; + return cleaned; +} + +function buildFileContentPart(file) { + if (file.category === "image") { + return { type: "image_url", image_url: { url: file.dataUrl }, _thumb: file.thumb || null }; + } + if (file.category === "video") { + return { type: "image_url", image_url: { url: file.dataUrl }, _video: true, _thumb: file.thumb || null }; + } + if (file.category === "audio") { + const base64 = file.dataUrl.split(",")[1] || ""; + const ext = (file.name.split(".").pop() || "").toLowerCase(); + const format = ext === "wav" ? "wav" : "mp3"; + return { type: "input_audio", input_audio: { data: base64, format } }; + } + return { type: "file", file: { filename: file.name, file_data: file.dataUrl } }; +} + +async function sendMessage(text, files) { + const conv = getActiveConv(); + if (!conv) return; + for (const f of files) { + if (f.category === "video" && !f.thumb && f.dataUrl) { + f.thumb = await captureVideoThumb(f.dataUrl); + } + if (f.category === "image" && !f.thumb && f.dataUrl) { + f.thumb = await captureImageThumb(f.dataUrl); + } + } + let content; + if (files.length > 0) { + content = []; + for (const f of files) content.push(buildFileContentPart(f)); + if (text) content.push({ type: "text", text }); + } else { content = text; } + + conv.messages.push({ role: "user", content, time: Date.now() }); + conv.messages.push({ role: "assistant", content: "", time: Date.now() }); + if (conv.title === "新对话" && text) conv.title = text.slice(0, 30); + conv.updatedAt = Date.now(); + + const reqMsgs = conv.messages.slice(0, -1).map(m => ({ role: m.role, content: cleanContentForApi(m.content) })); + stripFileDataForStorage(conv); + saveConversations(); + + const sendBtn = chatContainer.querySelector("#o1k-send"); + sendBtn.classList.remove("o1k-btn-send"); + sendBtn.classList.add("o1k-btn-stop"); + sendBtn.innerHTML = ``; + isStreaming = true; + isThinking = true; + abortController = new AbortController(); + renderMessages(); + scrollToBottom(); + + try { + const resp = await fetch("/o1key/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: abortController.signal, + body: JSON.stringify({ model: currentModel, messages: reqMsgs, stream: true }), + }); + if (!resp.ok) { + isThinking = false; + let errMsg = `请求失败 (${resp.status})`; + try { const d = await resp.json(); if (d.error) errMsg = d.error; } catch {} + conv.messages[conv.messages.length - 1].content = errMsg; + saveConversations(); updateLastMessage(); finishStream(); return; + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") break; + try { + const chunk = JSON.parse(data); + if (chunk.error) { conv.messages[conv.messages.length-1].content += `\n\n**错误:** ${chunk.error}`; updateLastMessage(); break; } + const delta = chunk.choices?.[0]?.delta; + const finish = chunk.choices?.[0]?.finish_reason; + const lastMsg = conv.messages[conv.messages.length-1]; + if (delta?.reasoning_content) { + isThinking = false; + lastMsg._reasoning = (lastMsg._reasoning || "") + delta.reasoning_content; + updateLastMessage(); + } + if (delta?.content) { + isThinking = false; + lastMsg.content += delta.content; + updateLastMessage(); + } + if (finish === "content_filter") { + lastMsg.content += "\n\n⚠️ 内容被安全过滤,未能完整输出。"; + updateLastMessage(); + } + } catch {} + } + } + } catch (e) { + if (e.name !== "AbortError") { conv.messages[conv.messages.length-1].content += `\n\n**请求失败:** ${e.message}`; updateLastMessage(); } + } + if (!conv.messages[conv.messages.length-1].content) { + conv.messages[conv.messages.length-1].content = "(模型未返回任何内容)"; + updateLastMessage(); + } + conv.messages[conv.messages.length-1].time = Date.now(); + saveConversations(); + finishStream(); +} + +function finishStream() { + isStreaming = false; isThinking = false; abortController = null; + const sendBtn = chatContainer.querySelector("#o1k-send"); + sendBtn.classList.remove("o1k-btn-stop"); + sendBtn.classList.add("o1k-btn-send"); + sendBtn.innerHTML = ``; + renderLastAsMarkdown(); +} + +// ─── Message Rendering ─────────────────────────────────────────────────────── +function renderMessages() { + const box = chatContainer.querySelector("#o1key-chat-messages"); + const msgs = getMessages(); + if (msgs.length === 0) { + box.innerHTML = `
开始新对话
`; + return; + } + box.innerHTML = msgs.map((msg, i) => { + const cls = msg.role === "user" ? "user" : "assistant"; + const html = formatMsgContent(msg, i, msgs); + const time = msg.time ? `
${formatTime(msg.time)}
` : ""; + let actions = ""; + if (msg.role === "user") { + actions = `
+ + + +
`; + } else { + actions = `
+ + +
`; + } + return `
${html}
${time}${actions}
`; + }).join(""); + box.querySelectorAll(".o1k-msg-actions button").forEach(btn => { + const idx = +btn.dataset.idx; + const act = btn.dataset.act; + btn.addEventListener("click", () => { + if (act === "del") deleteMessage(idx); + else if (act === "edit") enterEditMode(idx); + else if (act === "retry") retryMessage(idx); + else if (act === "copy") copyMessage(idx); + }); + }); + scrollToBottom(); +} + +function formatMsgContent(msg, idx, msgs) { + const c = msg.content; + let reasoningHtml = ""; + if (msg.role === "assistant" && msg._reasoning) { + const reasoningText = isStreaming && idx === msgs.length - 1 + ? escapeHtml(msg._reasoning).replace(/\n/g, "
") + : renderMd(msg._reasoning); + reasoningHtml = `
思考过程
${reasoningText}
`; + } + if (typeof c === "string") { + if (msg.role === "assistant" && !isStreaming) return reasoningHtml + renderMd(c); + if (msg.role === "assistant" && c === "" && idx === msgs.length - 1) { + if (isThinking) { + return `
思考中...
`; + } + if (msg._reasoning) { + return reasoningHtml; + } + return ``; + } + if (msg.role === "assistant") return reasoningHtml + escapeHtml(c).replace(/\n/g, "
"); + return escapeHtml(c).replace(/\n/g, "
"); + } + if (Array.isArray(c)) { + let html = ""; + for (const part of c) { + if (part.type === "text") html += `

${escapeHtml(part.text)}

`; + else if (part.type === "image_url") { + if (part._video) { + if (part._thumb) { + html += `
`; + } else { + html += `\u{1F3AC}视频`; + } + } else if (part._stripped) { + if (part._thumb) { + html += ``; + } else { + html += `\u{1F5BC}图片`; + } + } else { + html += ``; + } + } + else if (part.type === "video_url") { + if (part._thumb) { + html += `
`; + } else { + html += `\u{1F3AC}视频`; + } + } + else if (part.type === "input_audio") { + html += `\u{1F3B5}音频`; + } + else if (part.type === "file" && part.file) { + const name = part.file.filename || "file"; + const cat = classifyFileByName(name); + const icon = getFileIcon(cat); + html += `${icon}${escapeHtml(name)}`; + } + } + return html; + } + return ""; +} + +function updateLastMessage() { + const box = chatContainer.querySelector("#o1key-chat-messages"); + const msgs = getMessages(); + const last = box.querySelector(`.o1k-msg-wrap[data-idx="${msgs.length - 1}"] .o1k-msg`); + if (last) last.innerHTML = escapeHtml(msgs[msgs.length - 1].content).replace(/\n/g, "
"); + scrollToBottom(); +} + +function renderLastAsMarkdown() { + const box = chatContainer.querySelector("#o1key-chat-messages"); + const msgs = getMessages(); + const last = box.querySelector(`.o1k-msg-wrap[data-idx="${msgs.length - 1}"] .o1k-msg`); + if (last && msgs[msgs.length - 1]?.role === "assistant") { + last.innerHTML = renderMd(msgs[msgs.length - 1].content); + } +} + +function scrollToBottom() { + const box = chatContainer.querySelector("#o1key-chat-messages"); + if (box) box.scrollTop = box.scrollHeight; +} + +function renderMd(text) { + if (!text) return ""; + if (window.marked) { try { return window.marked.parse(text); } catch {} } + return escapeHtml(text).replace(/\n/g, "
"); +} + +function escapeHtml(str) { + if (!str) return ""; + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} \ No newline at end of file diff --git a/web/js/restartButton.js b/web/js/restartButton.js new file mode 100644 index 0000000..1c3a712 --- /dev/null +++ b/web/js/restartButton.js @@ -0,0 +1,73 @@ +import { app } from "../../../scripts/app.js"; + +app.registerExtension({ + name: "o1key.restartButton", + async setup() { + let injected = false; + + function inject() { + if (injected) return; + if (document.querySelector("#o1k-restart-btn")) { injected = true; return; } + + const allBtns = document.querySelectorAll("button, .p-togglebutton, .side-bar-button"); + let logBtn = null; + for (const btn of allBtns) { + const label = (btn.getAttribute("aria-label") || "") + (btn.textContent || ""); + if (label.includes("日志") || label.includes("Console") || label.includes("控制台") || label.includes("Logs")) { + logBtn = btn; + break; + } + } + if (!logBtn || !logBtn.parentNode) return; + + const btn = logBtn.cloneNode(false); + btn.id = "o1k-restart-btn"; + btn.setAttribute("aria-label", "重启"); + btn.title = "重启 ComfyUI"; + + const logStyle = window.getComputedStyle(logBtn); + btn.style.display = "flex"; + btn.style.flexDirection = "column"; + btn.style.alignItems = "center"; + btn.style.justifyContent = "center"; + btn.style.gap = logStyle.gap || "4px"; + + const iconSpan = document.createElement("span"); + iconSpan.innerHTML = ``; + const textSpan = document.createElement("span"); + textSpan.textContent = "重启"; + btn.appendChild(iconSpan); + btn.appendChild(textSpan); + + btn.addEventListener("click", async () => { + if (!confirm("确定要重启 ComfyUI 吗?")) return; + btn.style.opacity = "0.5"; + btn.style.pointerEvents = "none"; + try { await fetch("/o1key/restart", { method: "POST" }); } catch {} + pollUntilReady(); + }); + + logBtn.parentNode.insertBefore(btn, logBtn); + injected = true; + } + + function pollUntilReady() { + let attempts = 0; + const maxAttempts = 40; + const interval = setInterval(async () => { + attempts++; + if (attempts > maxAttempts) { clearInterval(interval); location.reload(); return; } + try { + const r = await fetch("/api/system_stats", { signal: AbortSignal.timeout(2000) }); + if (r.ok) { clearInterval(interval); location.reload(); } + } catch {} + }, 1500); + } + + const observer = new MutationObserver(inject); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(inject, 2000); + setTimeout(inject, 4000); + setTimeout(inject, 8000); + }, +});