Created
October 14, 2026 07:51
-
-
Save borgle/a40fda42aefb282f15ae8df07df6b04d to your computer and use it in GitHub Desktop.
支持多轮LLM对话的纯 python 单文件服务版本
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import logging | |
| import urllib.request | |
| import urllib.parse | |
| from http.server import HTTPServer, BaseHTTPRequestHandler | |
| from datetime import datetime | |
| import os, cgi | |
| def get_auth_header(): | |
| return {'Authorization': 'Bearer sk-xxxxxx'} | |
| def call_openai_api(method='POST', endpoint='/chat/completions', data=None, params=None): | |
| """ | |
| 调用CMS API的通用方法 | |
| Args: | |
| method: HTTP方法 ('GET' 或 'POST') | |
| endpoint: API端点路径 | |
| data: POST请求的数据 (dict) | |
| params: GET请求的参数 (dict) | |
| Returns: | |
| tuple: (success: bool, response_data: dict) | |
| """ | |
| if endpoint == '/openapi/model/getAll': | |
| return True, {"data":{"models":[{"FullName":"deepseek-v4-pro", "DisplayName":"deepseek-v4-pro"},{"FullName":"deepseek-flash", "DisplayName":"deepseek-flash"}]}} | |
| try: | |
| base_url = 'https://api.deepseek.com' | |
| url = f"{base_url}{endpoint}" if endpoint else base_url | |
| # 添加GET参数 | |
| if method.upper() == 'GET' and params: | |
| query_string = urllib.parse.urlencode(params) | |
| url = f"{url}?{query_string}" | |
| # 准备请求头 | |
| headers = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' | |
| 'Chrome/137.0.0.0 Safari/537.36' | |
| } | |
| headers.update(get_auth_header()) | |
| # 准备请求数据 | |
| request_data = None | |
| if method.upper() == 'POST' and data: | |
| request_data = json.dumps(data).encode('utf-8') | |
| headers['Content-Type'] = 'application/json' | |
| # 创建请求 | |
| req = urllib.request.Request(url, data=request_data, headers=headers) | |
| req.get_method = lambda: method.upper() | |
| # 发送请求 | |
| with urllib.request.urlopen(req, timeout=601) as response: | |
| response_text = response.read().decode('utf-8') | |
| response_json = json.loads(response_text) | |
| return True, response_json | |
| except urllib.error.HTTPError as e: | |
| try: | |
| error_response = e.read().decode('utf-8') | |
| error_data = json.loads(error_response) | |
| return False, {'error': f'HTTP错误 {e.code}', 'details': error_data} | |
| except: | |
| return False, {'error': f'HTTP错误 {e.code}: {e.reason}'} | |
| except urllib.error.URLError as e: | |
| return False, {'error': f'网络连接错误: {str(e)}'} | |
| except json.JSONDecodeError as e: | |
| return False, {'error': f'响应JSON解析失败: {str(e)}'} | |
| except Exception as e: | |
| return False, {'error': f'请求失败: {str(e)}'} | |
| class RequestHandler(BaseHTTPRequestHandler): | |
| def do_GET(self): | |
| if self.path == '/': | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html; charset=utf-8') | |
| self.end_headers() | |
| html_content = ''' | |
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AI文本处理调试工具</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background: #f0f2f5; | |
| height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 900px; | |
| height: 95vh; | |
| background: #fff; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.08); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* Layout */ | |
| .main-area { display: flex; flex: 1; overflow: hidden; } | |
| /* Sidebar */ | |
| .sidebar { | |
| width: 0; | |
| overflow: hidden; | |
| border-right: 1px solid transparent; | |
| background: #fafafa; | |
| transition: width 0.25s; | |
| display: flex; | |
| flex-direction: column; | |
| flex-shrink: 0; | |
| } | |
| .sidebar.open { width: 220px; border-right-color: #e8e8e8; } | |
| .sidebar-header { | |
| padding: 12px 14px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: #555; | |
| border-bottom: 1px solid #eee; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| white-space: nowrap; | |
| } | |
| .sidebar-list { flex: 1; overflow-y: auto; padding: 8px; } | |
| .sidebar-list::-webkit-scrollbar { width: 3px; } | |
| .sidebar-list::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 2px; } | |
| .conv-item { | |
| padding: 10px 12px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| color: #444; | |
| margin-bottom: 4px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| transition: background 0.15s; | |
| } | |
| .conv-item:hover { background: #e8e8e8; } | |
| .conv-item.active { background: #e0f0e0; color: #2e7d32; font-weight: 600; } | |
| .sidebar-new-chat { | |
| margin: 8px; | |
| padding: 8px; | |
| text-align: center; | |
| font-size: 13px; | |
| color: #4CAF50; | |
| cursor: pointer; | |
| border: 1px dashed #b0d8b0; | |
| border-radius: 8px; | |
| white-space: nowrap; | |
| transition: all 0.15s; | |
| } | |
| .sidebar-new-chat:hover { background: #f0faf0; } | |
| .content-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } | |
| /* Header */ | |
| .header { | |
| padding: 16px 24px; | |
| border-bottom: 1px solid #e8e8e8; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-shrink: 0; | |
| } | |
| .header h1 { font-size: 18px; color: #1a1a1a; } | |
| .header-right { display: flex; align-items: center; gap: 12px; } | |
| .model-badge { | |
| font-size: 12px; | |
| color: #666; | |
| background: #f0f0f0; | |
| padding: 4px 10px; | |
| border-radius: 12px; | |
| display: none; | |
| } | |
| .btn-new-chat { | |
| font-size: 13px; | |
| padding: 6px 16px; | |
| background: #fff; | |
| border: 1px solid #d9d9d9; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| color: #333; | |
| display: none; | |
| transition: all 0.2s; | |
| } | |
| .btn-new-chat:hover { border-color: #4CAF50; color: #4CAF50; } | |
| .sidebar-toggle { | |
| background: none; | |
| border: 1px solid #d9d9d9; | |
| border-radius: 6px; | |
| font-size: 16px; | |
| cursor: pointer; | |
| padding: 4px 8px; | |
| color: #666; | |
| line-height: 1; | |
| transition: all 0.2s; | |
| } | |
| .sidebar-toggle:hover { border-color: #4CAF50; color: #4CAF50; } | |
| /* Setup Panel */ | |
| .setup-panel { | |
| padding: 24px; | |
| overflow-y: auto; | |
| flex: 1; | |
| } | |
| .setup-panel .form-group { margin-bottom: 18px; } | |
| .setup-panel label { | |
| display: block; | |
| margin-bottom: 6px; | |
| font-weight: 600; | |
| color: #444; | |
| font-size: 14px; | |
| } | |
| .setup-panel select, .setup-panel textarea { | |
| width: 100%; | |
| padding: 10px 12px; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-family: inherit; | |
| transition: border-color 0.2s; | |
| background: #fafafa; | |
| } | |
| .setup-panel select:focus, .setup-panel textarea:focus { | |
| border-color: #4CAF50; | |
| outline: none; | |
| background: #fff; | |
| } | |
| .setup-panel textarea { resize: vertical; } | |
| .required { color: #f44336; } | |
| .btn-submit { | |
| display: block; | |
| margin: 20px auto 0; | |
| padding: 12px 40px; | |
| background: #4CAF50; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 16px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| .btn-submit:hover { background: #45a049; } | |
| .btn-submit:disabled { background: #ccc; cursor: not-allowed; } | |
| /* Advanced settings */ | |
| .toggle-advanced { | |
| background: none; | |
| border: none; | |
| color: #888; | |
| font-size: 13px; | |
| cursor: pointer; | |
| padding: 4px 0; | |
| margin-bottom: 8px; | |
| } | |
| .toggle-advanced:hover { color: #4CAF50; } | |
| .advanced-settings { | |
| background: #f9f9f9; | |
| border: 1px solid #eee; | |
| border-radius: 8px; | |
| padding: 16px; | |
| display: none; | |
| } | |
| .advanced-settings h3 { | |
| font-size: 14px; | |
| color: #555; | |
| margin-bottom: 12px; | |
| padding-bottom: 8px; | |
| border-bottom: 2px solid #4CAF50; | |
| } | |
| .settings-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 12px; | |
| } | |
| .setting-item { display: flex; flex-direction: column; } | |
| .setting-item label { font-size: 13px; margin-bottom: 4px; font-weight: 500; } | |
| .setting-item input { | |
| padding: 8px 10px; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| } | |
| .setting-help { font-size: 11px; color: #999; margin-top: 2px; } | |
| /* Chat Panel */ | |
| .chat-panel { | |
| display: none; | |
| flex: 1; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| .chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px 24px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .chat-messages::-webkit-scrollbar { width: 5px; } | |
| .chat-messages::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 3px; } | |
| /* System banner */ | |
| .system-banner { | |
| background: #fffbe6; | |
| border: 1px solid #ffe58f; | |
| border-radius: 8px; | |
| padding: 10px 16px; | |
| font-size: 13px; | |
| color: #8c6d00; | |
| text-align: center; | |
| flex-shrink: 0; | |
| } | |
| .system-banner .banner-label { | |
| font-weight: 700; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 2px; | |
| } | |
| /* Message bubbles */ | |
| .message-row { display: flex; } | |
| .message-row.user { justify-content: flex-end; } | |
| .message-row.assistant { justify-content: flex-start; } | |
| .message-content { | |
| display: flex; | |
| flex-direction: column; | |
| max-width: 80%; | |
| } | |
| .message-row.user .message-content { align-items: flex-end; } | |
| .message-row.assistant .message-content { align-items: flex-start; } | |
| .bubble { | |
| width: 100%; | |
| padding: 10px 16px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .message-row.user .bubble { | |
| background: #4CAF50; | |
| color: #fff; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message-row.assistant .bubble { | |
| background: #f0f0f0; | |
| color: #333; | |
| border-bottom-left-radius: 4px; | |
| } | |
| /* Chat input bar */ | |
| .chat-input-bar { | |
| padding: 12px 24px; | |
| border-top: 1px solid #e8e8e8; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-shrink: 0; | |
| background: #fff; | |
| } | |
| .chat-input-bar textarea { | |
| flex: 1; | |
| padding: 10px 14px; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-family: inherit; | |
| resize: none; | |
| min-height: 42px; | |
| max-height: 120px; | |
| transition: border-color 0.2s; | |
| } | |
| .chat-input-bar textarea:focus { border-color: #4CAF50; outline: none; } | |
| .btn-send { | |
| padding: 10px 20px; | |
| background: #4CAF50; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| transition: background 0.2s; | |
| flex-shrink: 0; | |
| } | |
| .btn-send:hover { background: #45a049; } | |
| .btn-send:disabled { background: #ccc; cursor: not-allowed; } | |
| /* Thinking indicator */ | |
| .thinking-bubble { | |
| background: #f0f0f0; | |
| color: #999; | |
| padding: 10px 16px; | |
| border-radius: 12px; | |
| border-bottom-left-radius: 4px; | |
| font-size: 14px; | |
| animation: pulse 1.4s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.4; } | |
| } | |
| .retry-btn { | |
| display: inline-block; | |
| margin-top: 4px; | |
| padding: 2px 10px; | |
| font-size: 11px; | |
| color: #999; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| background: #fff; | |
| transition: all 0.15s; | |
| } | |
| .retry-btn:hover { border-color: #4CAF50; color: #4CAF50; } | |
| /* Image preview & display */ | |
| .image-preview-bar { | |
| display: flex; | |
| gap: 8px; | |
| padding: 8px 24px 0; | |
| overflow-x: auto; | |
| flex-shrink: 0; | |
| } | |
| .image-preview-item { | |
| position: relative; | |
| width: 64px; | |
| height: 64px; | |
| border-radius: 6px; | |
| overflow: hidden; | |
| border: 1px solid #e0e0e0; | |
| flex-shrink: 0; | |
| } | |
| .image-preview-item img { width: 100%; height: 100%; object-fit: cover; } | |
| .image-preview-item .remove-btn { | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| width: 18px; | |
| height: 18px; | |
| background: rgba(0,0,0,0.5); | |
| color: #fff; | |
| border: none; | |
| border-radius: 0 0 0 6px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| line-height: 18px; | |
| text-align: center; | |
| } | |
| .bubble-img { | |
| max-width: 260px; | |
| max-height: 260px; | |
| border-radius: 8px; | |
| margin-top: 6px; | |
| cursor: pointer; | |
| } | |
| .lightbox { | |
| display: none; | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.85); | |
| z-index: 9999; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .lightbox.show { display: flex; } | |
| .lightbox img { | |
| max-width: 90vw; | |
| max-height: 90vh; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.4); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <div style="display:flex;align-items:center;gap:12px;"> | |
| <button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()" title="会话列表">☰</button> | |
| <h1>AI文本处理调试工具</h1> | |
| </div> | |
| <div class="header-right"> | |
| <span class="model-badge" id="modelBadge"></span> | |
| <button class="btn-new-chat" id="btnNewChat" onclick="newConversation()">+ 新建对话</button> | |
| </div> | |
| </div> | |
| <div class="main-area"> | |
| <!-- Sidebar --> | |
| <div class="sidebar" id="sidebar"> | |
| <div class="sidebar-header"> | |
| <span>会话列表</span> | |
| </div> | |
| <div class="sidebar-list" id="sidebarList"></div> | |
| <div class="sidebar-new-chat" onclick="newConversation()">+ 新建对话</div> | |
| </div> | |
| <!-- Content --> | |
| <div class="content-area"> | |
| <!-- Setup Panel --> | |
| <div class="setup-panel" id="setupPanel"> | |
| <div class="form-group"> | |
| <label for="templateSelect">选择模型: <span class="required">*</span></label> | |
| <select id="templateSelect"><option value="">加载中...</option></select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="systemInstruction">系统指令:</label> | |
| <textarea id="systemInstruction" rows="6" placeholder="请输入系统指令,设定AI的行为..."></textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label for="extractText">输入内容: <span class="required">*</span></label> | |
| <textarea id="extractText" rows="8" placeholder="请输入需要处理的内容..."></textarea> | |
| </div> | |
| <div class="image-preview-bar" id="setupImagePreviewBar" style="display:none;"></div> | |
| <input type="file" id="setupImageInput" accept="image/*" multiple style="display:none;" onchange="handleSetupImageSelect(event)"> | |
| <button type="button" class="btn-image" onclick="document.getElementById('setupImageInput').click()" style="background:none;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-size:13px;padding:6px 12px;margin-bottom:8px;">🖼 上传图片</button> | |
| <button type="button" class="toggle-advanced" onclick="toggleAdvanced()"> | |
| <span id="advancedToggleText">▶ 高级设置</span> | |
| </button> | |
| <div class="advanced-settings" id="advancedSettings"> | |
| <h3>高级设置</h3> | |
| <div class="settings-grid"> | |
| <div class="setting-item"> | |
| <label for="maxTokens">最大Token数:</label> | |
| <input type="number" id="maxTokens" min="1" max="32768" value="4000"> | |
| <span class="setting-help">控制生成文本的最大长度,默认4000</span> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="temperature">创造性 (temperature):</label> | |
| <input type="number" id="temperature" min="0" max="2" step="0.1" value="0.0"> | |
| <span class="setting-help">控制输出的随机性,0-2之间,默认0.0</span> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="frequencyPenalty">频率惩罚:</label> | |
| <input type="number" id="frequencyPenalty" min="-2" max="2" step="0.1" value="0"> | |
| <span class="setting-help">减少重复词汇,-2到2之间,默认0</span> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="presencePenalty">存在惩罚:</label> | |
| <input type="number" id="presencePenalty" min="-2" max="2" step="0.1" value="0"> | |
| <span class="setting-help">鼓励谈论新话题,-2到2之间,默认0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <button class="btn-submit" id="btnStartChat" onclick="startConversation()">开始对话</button> | |
| </div> | |
| <!-- Chat Panel --> | |
| <div class="chat-panel" id="chatPanel"> | |
| <div class="chat-messages" id="chatMessages"></div> | |
| <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div> | |
| <input type="file" id="imageInput" accept="image/*" multiple style="display:none;" onchange="handleImageSelect(event)"> | |
| <div class="chat-input-bar"> | |
| <button type="button" class="btn-image" onclick="document.getElementById('imageInput').click()" title="上传图片" style="flex-shrink:0;background:none;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-size:18px;padding:6px 10px;line-height:1;" id="btnImage">🖼</button> | |
| <textarea id="chatInput" rows="1" placeholder="输入消息..." onkeydown="handleChatKey(event)"></textarea> | |
| <button class="btn-send" id="btnSend" onclick="sendMessage()">发送</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="lightbox" id="lightbox" onclick="closeLightbox()"> | |
| <img id="lightboxImg" src=""> | |
| </div> | |
| <script> | |
| // ---- State ---- | |
| let conversations = []; | |
| let activeConvId = null; | |
| let messages = []; | |
| let currentModel = ''; | |
| let pendingImages = []; | |
| let advancedOpen = false; | |
| // ---- Init ---- | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| await loadModels(); | |
| }); | |
| async function loadModels() { | |
| const sel = document.getElementById('templateSelect'); | |
| try { | |
| const resp = await fetch('/api/models'); | |
| const data = await resp.json(); | |
| if (resp.ok && Array.isArray(data)) { | |
| sel.innerHTML = '<option value="">请选择模型...</option>'; | |
| data.forEach(item => { | |
| const opt = document.createElement('option'); | |
| opt.value = item.FullName; | |
| opt.textContent = item.DisplayName; | |
| sel.appendChild(opt); | |
| }); | |
| } else { | |
| sel.innerHTML = '<option value="">无可用模型</option>'; | |
| } | |
| } catch (e) { | |
| sel.innerHTML = '<option value="">加载失败</option>'; | |
| } | |
| } | |
| function toggleAdvanced() { | |
| advancedOpen = !advancedOpen; | |
| const panel = document.getElementById('advancedSettings'); | |
| const t1 = document.getElementById('advancedToggleText'); | |
| panel.style.display = advancedOpen ? 'block' : 'none'; | |
| const symbol = advancedOpen ? '▾' : '▸'; | |
| if (t1) t1.innerHTML = symbol + ' 高级设置'; | |
| } | |
| function getParams() { | |
| return { | |
| maxTokens: parseInt(document.getElementById('maxTokens').value) || 4000, | |
| temperature: parseFloat(document.getElementById('temperature').value) || 0.0, | |
| frequencyPenalty: parseFloat(document.getElementById('frequencyPenalty').value) || 0, | |
| presencePenalty: parseFloat(document.getElementById('presencePenalty').value) || 0 | |
| }; | |
| } | |
| // ---- Start Conversation ---- | |
| async function startConversation() { | |
| const model = document.getElementById('templateSelect').value; | |
| const instruction = document.getElementById('systemInstruction').value.trim(); | |
| const text = document.getElementById('extractText').value.trim(); | |
| const hasImages = pendingImages.length > 0; | |
| if (!model || (!text && !hasImages)) { | |
| alert('请填写模型和输入内容(标有*的字段为必填项)'); | |
| return; | |
| } | |
| // Create a new conversation entry | |
| const id = Date.now(); | |
| const displayText = text || (hasImages ? '[图片]' : ''); | |
| const title = displayText.length > 30 ? displayText.substring(0, 30) + '...' : displayText; | |
| conversations.unshift({id: id, model: model, messages: [], title: title}); | |
| activeConvId = id; | |
| currentModel = model; | |
| messages = []; | |
| if (instruction) { | |
| messages.push({role: 'system', content: instruction}); | |
| } | |
| let content; | |
| if (hasImages) { | |
| content = []; | |
| if (text) content.push({type: 'text', text: text}); | |
| pendingImages.forEach(url => { content.push({type: 'image_url', image_url: {url: url}}); }); | |
| pendingImages = []; | |
| renderImagePreviews(); | |
| renderSetupImagePreviews(); | |
| } else { | |
| content = text; | |
| } | |
| messages.push({role: 'user', content: content}); | |
| const btn = document.getElementById('btnStartChat'); | |
| btn.disabled = true; | |
| btn.textContent = '处理中...'; | |
| await callApi(messages); | |
| btn.disabled = false; | |
| btn.textContent = '开始对话'; | |
| switchToChat(); | |
| } | |
| // ---- Send Follow-Up ---- | |
| async function sendMessage() { | |
| const input = document.getElementById('chatInput'); | |
| const text = input.value.trim(); | |
| const hasImages = pendingImages.length > 0; | |
| if (!text && !hasImages) return; | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| let content; | |
| if (hasImages) { | |
| content = []; | |
| if (text) content.push({type: 'text', text: text}); | |
| pendingImages.forEach(url => { content.push({type: 'image_url', image_url: {url: url}}); }); | |
| pendingImages = []; | |
| renderImagePreviews(); | |
| } else { | |
| content = text; | |
| } | |
| messages.push({role: 'user', content: content}); | |
| renderMessages(); | |
| await callApi(messages); | |
| } | |
| // ---- Image handling ---- | |
| function handleImageSelect(e) { | |
| const files = Array.from(e.target.files); | |
| files.forEach(file => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { pendingImages.push(reader.result); renderImagePreviews(); }; | |
| reader.readAsDataURL(file); | |
| }); | |
| e.target.value = ''; | |
| } | |
| document.addEventListener('paste', function(e) { | |
| const chatPanel = document.getElementById('chatPanel'); | |
| if (!chatPanel || chatPanel.style.display === 'none') return; | |
| if (document.getElementById('chatInput') !== document.activeElement) return; | |
| const items = (e.clipboardData || e.clipboardData).items; | |
| if (!items) return; | |
| for (let item of items) { | |
| if (item.type.startsWith('image/')) { | |
| const blob = item.getAsFile(); | |
| const reader = new FileReader(); | |
| reader.onload = () => { pendingImages.push(reader.result); renderImagePreviews(); }; | |
| reader.readAsDataURL(blob); | |
| } | |
| } | |
| }); | |
| function renderImagePreviews() { | |
| const bar = document.getElementById('imagePreviewBar'); | |
| bar.innerHTML = ''; | |
| bar.style.display = pendingImages.length ? 'flex' : 'none'; | |
| pendingImages.forEach((url, i) => { | |
| const item = document.createElement('div'); | |
| item.className = 'image-preview-item'; | |
| item.innerHTML = '<img src="' + url + '"><button class="remove-btn" onclick="removeImage(' + i + ')">×</button>'; | |
| bar.appendChild(item); | |
| }); | |
| } | |
| function removeImage(i) { | |
| pendingImages.splice(i, 1); | |
| renderImagePreviews(); | |
| renderSetupImagePreviews(); | |
| } | |
| function handleSetupImageSelect(e) { | |
| const files = Array.from(e.target.files); | |
| files.forEach(file => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { pendingImages.push(reader.result); renderSetupImagePreviews(); }; | |
| reader.readAsDataURL(file); | |
| }); | |
| e.target.value = ''; | |
| } | |
| function renderSetupImagePreviews() { | |
| const bar = document.getElementById('setupImagePreviewBar'); | |
| bar.innerHTML = ''; | |
| bar.style.display = pendingImages.length ? 'flex' : 'none'; | |
| pendingImages.forEach((url, i) => { | |
| const item = document.createElement('div'); | |
| item.className = 'image-preview-item'; | |
| item.innerHTML = '<img src="' + url + '"><button class="remove-btn" onclick="removeImage(' + i + ')">×</button>'; | |
| bar.appendChild(item); | |
| }); | |
| } | |
| // ---- Retry from index ---- | |
| function retryFrom(idx) { | |
| messages = messages.slice(0, idx + 1); | |
| saveConv(); | |
| renderMessages(); | |
| renderSidebar(); | |
| callApi(messages); | |
| } | |
| // ---- API Call ---- | |
| async function callApi(msgArray) { | |
| const params = getParams(); | |
| const btnSend = document.getElementById('btnSend'); | |
| if (btnSend) { btnSend.disabled = true; } | |
| // Show thinking | |
| const container = document.getElementById('chatMessages'); | |
| const thinkId = 'thinking-' + Date.now(); | |
| const thinkEl = document.createElement('div'); | |
| thinkEl.id = thinkId; | |
| thinkEl.className = 'message-row assistant'; | |
| thinkEl.innerHTML = '<div class="thinking-bubble">思考中...</div>'; | |
| container.appendChild(thinkEl); | |
| container.scrollTop = container.scrollHeight; | |
| try { | |
| const resp = await fetch('/api/messages', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| template: currentModel, | |
| messages: msgArray, | |
| ...params | |
| }) | |
| }); | |
| // Remove thinking | |
| const el = document.getElementById(thinkId); | |
| if (el) el.remove(); | |
| const text = await resp.text(); | |
| if (resp.ok) { | |
| messages.push({role: 'assistant', content: text}); | |
| } else { | |
| let err; | |
| try { err = JSON.parse(text).error || text; } catch (_) { err = text; } | |
| messages.push({role: 'assistant', content: '错误: ' + err}); | |
| } | |
| } catch (e) { | |
| const el = document.getElementById(thinkId); | |
| if (el) el.remove(); | |
| messages.push({role: 'assistant', content: '网络错误: ' + e.message}); | |
| } | |
| saveConv(); | |
| renderMessages(); | |
| renderSidebar(); | |
| if (btnSend) { btnSend.disabled = false; } | |
| } | |
| // ---- Render ---- | |
| function renderMessages() { | |
| const container = document.getElementById('chatMessages'); | |
| container.innerHTML = ''; | |
| messages.forEach((msg, idx) => { | |
| if (msg.role === 'system') { | |
| const div = document.createElement('div'); | |
| div.className = 'system-banner'; | |
| div.innerHTML = '<div class="banner-label">系统指令</div>' + escapeHtml(msg.content); | |
| container.appendChild(div); | |
| } else { | |
| const row = document.createElement('div'); | |
| row.className = 'message-row ' + msg.role; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'message-content'; | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'bubble'; | |
| if (Array.isArray(msg.content)) { | |
| const texts = msg.content.filter(c => c.type === 'text').map(c => c.text); | |
| if (texts.length > 0) bubble.textContent = texts.join('\\n'); | |
| msg.content.filter(c => c.type === 'image_url').forEach(c => { | |
| const img = document.createElement('img'); | |
| img.className = 'bubble-img'; | |
| img.src = c.image_url.url; | |
| img.onclick = function() { openLightbox(c.image_url.url); }; | |
| bubble.appendChild(img); | |
| }); | |
| if (!bubble.textContent && !bubble.querySelector('img')) { | |
| bubble.textContent = '[图片]'; | |
| } | |
| } else { | |
| bubble.textContent = msg.content; | |
| } | |
| wrapper.appendChild(bubble); | |
| if (msg.role === 'user') { | |
| const retryBtn = document.createElement('button'); | |
| retryBtn.className = 'retry-btn'; | |
| retryBtn.textContent = '↻ 重试'; | |
| retryBtn.onclick = (e) => { e.stopPropagation(); retryFrom(idx); }; | |
| wrapper.appendChild(retryBtn); | |
| } | |
| row.appendChild(wrapper); | |
| container.appendChild(row); | |
| } | |
| }); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| function escapeHtml(str) { | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| // ---- Switch to Chat ---- | |
| function switchToChat() { | |
| document.getElementById('setupPanel').style.display = 'none'; | |
| document.getElementById('chatPanel').style.display = 'flex'; | |
| document.getElementById('modelBadge').style.display = 'inline'; | |
| document.getElementById('modelBadge').textContent = currentModel; | |
| document.getElementById('btnNewChat').style.display = 'inline'; | |
| renderMessages(); | |
| renderSidebar(); | |
| // Auto-open sidebar to show the new conversation | |
| document.getElementById('sidebar').classList.add('open'); | |
| } | |
| // ---- Save current ---- | |
| function saveConv() { | |
| if (activeConvId) { | |
| const conv = conversations.find(c => c.id === activeConvId); | |
| if (conv) { conv.messages = [...messages]; conv.model = currentModel; } | |
| } | |
| } | |
| // ---- New Conversation ---- | |
| function newConversation() { | |
| saveConv(); | |
| activeConvId = null; | |
| messages = []; | |
| currentModel = ''; | |
| document.getElementById('setupPanel').style.display = ''; | |
| document.getElementById('chatPanel').style.display = 'none'; | |
| document.getElementById('modelBadge').style.display = 'none'; | |
| document.getElementById('btnNewChat').style.display = 'none'; | |
| document.getElementById('extractText').value = ''; | |
| document.getElementById('systemInstruction').value = ''; | |
| document.getElementById('chatMessages').innerHTML = ''; | |
| document.getElementById('chatInput').value = ''; | |
| pendingImages = []; | |
| renderImagePreviews(); | |
| renderSidebar(); | |
| } | |
| // ---- Switch Conversation ---- | |
| function switchConversation(id) { | |
| if (id === activeConvId) return; | |
| saveConv(); | |
| const conv = conversations.find(c => c.id === id); | |
| if (!conv) return; | |
| activeConvId = conv.id; | |
| messages = [...conv.messages]; | |
| currentModel = conv.model; | |
| renderSidebar(); | |
| if (messages.length > 0) { | |
| document.getElementById('setupPanel').style.display = 'none'; | |
| document.getElementById('chatPanel').style.display = 'flex'; | |
| document.getElementById('modelBadge').style.display = 'inline'; | |
| document.getElementById('modelBadge').textContent = currentModel; | |
| document.getElementById('btnNewChat').style.display = 'inline'; | |
| renderMessages(); | |
| } else { | |
| document.getElementById('setupPanel').style.display = ''; | |
| document.getElementById('chatPanel').style.display = 'none'; | |
| document.getElementById('modelBadge').style.display = 'none'; | |
| document.getElementById('btnNewChat').style.display = 'none'; | |
| } | |
| } | |
| // ---- Sidebar ---- | |
| function toggleSidebar() { | |
| document.getElementById('sidebar').classList.toggle('open'); | |
| } | |
| function renderSidebar() { | |
| const list = document.getElementById('sidebarList'); | |
| list.innerHTML = ''; | |
| conversations.forEach(conv => { | |
| const div = document.createElement('div'); | |
| div.className = 'conv-item' + (conv.id === activeConvId ? ' active' : ''); | |
| div.textContent = conv.title; | |
| div.onclick = () => switchConversation(conv.id); | |
| list.appendChild(div); | |
| }); | |
| } | |
| // ---- Keyboard ---- | |
| function handleChatKey(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| } | |
| // ---- Lightbox ---- | |
| function openLightbox(url) { | |
| document.getElementById('lightboxImg').src = url; | |
| document.getElementById('lightbox').classList.add('show'); | |
| } | |
| function closeLightbox() { | |
| document.getElementById('lightbox').classList.remove('show'); | |
| document.getElementById('lightboxImg').src = ''; | |
| } | |
| // Auto-resize chat textarea | |
| document.addEventListener('input', function(e) { | |
| if (e.target.id === 'chatInput') { | |
| e.target.style.height = 'auto'; | |
| e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px'; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| self.wfile.write(html_content.encode('utf-8')) | |
| elif self.path == '/upload': | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html; charset=utf-8') | |
| self.end_headers() | |
| # 定义 HTML 页面,包含简单的 CSS 样式 | |
| html_content = ''' | |
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>简易文件上传服务</title> | |
| <style> | |
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f2f5; } | |
| .container { background-color: #fff; padding: 2rem; border-radius: 10px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: center; max-width: 400px; width: 100%; } | |
| h1 { color: #333; margin-bottom: 1.5rem; font-size: 1.8rem; } | |
| input[type="file"] { margin: 1rem 0; } | |
| input[type="submit"] { background-color: #007BFF; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 1rem; transition: background-color 0.3s; } | |
| input[type="submit"]:hover { background-color: #0056b3; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>文件上传</h1> | |
| <!-- 关键点:enctype 必须是 multipart/form-data --> | |
| <form action="/doUpload" method="post" enctype="multipart/form-data"> | |
| <input type="file" name="file" required> | |
| <br> | |
| <input type="submit" value="上传文件"> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| ''' | |
| self.wfile.write(html_content.encode('utf-8')) | |
| elif self.path == '/api/models': | |
| # 获取下拉框选项的API | |
| try: | |
| success, response_data = call_openai_api(method='GET', endpoint='/openapi/model/getAll') | |
| if success: | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write( | |
| json.dumps(response_data.get('data').get('models', []), ensure_ascii=False).encode('utf-8')) | |
| else: | |
| self.send_response(500) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write(json.dumps(response_data, ensure_ascii=False).encode('utf-8')) | |
| except Exception as e: | |
| self.send_response(500) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| error_response = {'error': f'获取模板失败: {str(e)}'} | |
| self.wfile.write(json.dumps(error_response, ensure_ascii=False).encode('utf-8')) | |
| elif self.path.startswith('/api/checkToken'): | |
| # 返回成功响应 | |
| result = { | |
| "code": "200", "status": "success", "data": "1", "message": "请求成功" | |
| } | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write(json.dumps(result, ensure_ascii=False).encode('utf-8')) | |
| else: | |
| self.send_error(404, "Page not found") | |
| def do_POST(self): | |
| if self.path == '/api/messages': | |
| try: | |
| # 获取请求数据 | |
| content_length = int(self.headers['Content-Length']) | |
| post_data = self.rfile.read(content_length) | |
| request_data = json.loads(post_data.decode('utf-8')) | |
| model_name = request_data.get('template', '') | |
| # 获取高级设置参数 | |
| max_tokens = request_data.get('maxTokens', 2048) | |
| temperature = request_data.get('temperature', 0.7) | |
| frequency_penalty = request_data.get('frequencyPenalty', 0) | |
| presence_penalty = request_data.get('presencePenalty', 0) | |
| # Multi-turn: use messages array if provided | |
| if 'messages' in request_data and request_data['messages']: | |
| messages = request_data['messages'] | |
| else: | |
| # Legacy single-turn mode | |
| instruction = request_data.get('systemInstruction', '') | |
| extractText = request_data.get('extractText', '') | |
| if any(x == '' or x is None or x is False for x in [model_name, extractText]): | |
| raise Exception('模型,文本必须要填写值') | |
| messages = [{'role': "user", 'content': extractText}] | |
| if instruction: | |
| messages.insert(0, {'role': 'system', 'content': instruction}) | |
| api_data = { | |
| 'model': model_name, | |
| 'messages': messages, | |
| 'tools': [], | |
| 'max_tokens': max_tokens, | |
| 'temperature': temperature, | |
| 'frequency_penalty': frequency_penalty, | |
| 'presence_penalty': presence_penalty | |
| } | |
| # 调用CMS API | |
| success, response_data = call_openai_api(method='POST', data=api_data) | |
| if success: | |
| choices = response_data.get('choices') | |
| if not choices or not isinstance(choices, list) or len(choices) == 0: | |
| # API returned no choices - extract system-level error, log, return to frontend | |
| system_msg = response_data.get('system', '') | |
| print(f"[WARN] API returned success but choices is empty/missing. system={system_msg} | raw={json.dumps(response_data, ensure_ascii=False)}") | |
| self.send_response(500) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| err_msg = system_msg if system_msg else '模型返回空结果,可能是上下文超长或服务内部错误' | |
| self.wfile.write(json.dumps({'error': err_msg}, ensure_ascii=False).encode('utf-8')) | |
| else: | |
| choice = choices[0] | |
| message = choice.get('message') or {} | |
| content = message.get('content') or '' | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write(content.encode('utf-8')) | |
| else: | |
| # API请求失败 | |
| self.send_response(500) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write(json.dumps(response_data, ensure_ascii=False).encode('utf-8')) | |
| except json.JSONDecodeError: | |
| # JSON解析错误 | |
| self.send_response(400) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| error_response = {'error': '无效的JSON数据格式'} | |
| self.wfile.write(json.dumps(error_response, ensure_ascii=False).encode('utf-8')) | |
| except Exception as e: | |
| # 其他错误 | |
| self.send_response(500) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| error_response = {'error': f'服务器内部错误: {str(e)}'} | |
| self.wfile.write(json.dumps(error_response, ensure_ascii=False).encode('utf-8')) | |
| elif self.path == '/doUpload': | |
| # 处理文件上传的 POST 请求 | |
| form = cgi.FieldStorage( | |
| fp=self.rfile, | |
| headers=self.headers, | |
| environ={'REQUEST_METHOD': 'POST', 'CONTENT_TYPE': self.headers['Content-Type']} | |
| ) | |
| file_item = form['file'] | |
| if file_item.filename: | |
| file_name = os.path.basename(file_item.filename) | |
| file_path = os.path.join('uploads', file_name) | |
| with open(file_path, 'wb') as output_file: | |
| output_file.write(file_item.file.read()) | |
| # 上传成功后,返回一个包含"返回"链接的 HTML 页面 | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html; charset=utf-8') | |
| self.end_headers() | |
| success_html = f''' | |
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>上传成功</title> | |
| <style> | |
| body {{ font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f2f5; }} | |
| .message {{ background-color: #fff; padding: 2rem; border-radius: 10px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: center; }} | |
| h1 {{ color: #28a745; }} | |
| a {{ color: #007BFF; text-decoration: none; font-weight: bold; }} | |
| a:hover {{ text-decoration: underline; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="message"> | |
| <h1>上传成功!</h1> | |
| <p>文件名: <strong>{file_name}</strong></p> | |
| <p><a href="/upload">← 返回上传页面</a></p> | |
| </div> | |
| </body> | |
| </html> | |
| ''' | |
| self.wfile.write(success_html.encode('utf-8')) | |
| else: | |
| self.send_response(400) | |
| self.send_header('Content-type', 'text/plain; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write('请求中没有找到文件。'.encode('utf-8')) | |
| elif self.path == '/api/callback': | |
| try: | |
| # 获取请求数据 | |
| content_length = int(self.headers['Content-Length']) | |
| post_data = self.rfile.read(content_length) | |
| request_data = post_data.decode('utf-8') | |
| logging.info(f"数据:(request_data)") | |
| except Exception as e: | |
| logging.error(e) | |
| # 返回成功响应 | |
| result = {"code": "0", "message": "成功"} | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/json; charset=utf-8') | |
| self.end_headers() | |
| self.wfile.write(json.dumps(result, ensure_ascii=False).encode('utf-8')) | |
| else: | |
| self.send_error(404, "Endpoint not found") | |
| def run_server(host='0.0.0.0', port=8000): | |
| server_address = (host, port) | |
| httpd = HTTPServer(server_address, RequestHandler) | |
| print(f"HTTP服务器启动在 http://{host}:{port}") | |
| print("按 Ctrl+C 停止服务器") | |
| try: | |
| httpd.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\n服务器已停止") | |
| httpd.server_close() | |
| if __name__ == '__main__': | |
| run_server() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment