Skip to content

Instantly share code, notes, and snippets.

@borgle
Created October 14, 2026 07:51
Show Gist options
  • Select an option

  • Save borgle/a40fda42aefb282f15ae8df07df6b04d to your computer and use it in GitHub Desktop.

Select an option

Save borgle/a40fda42aefb282f15ae8df07df6b04d to your computer and use it in GitHub Desktop.
支持多轮LLM对话的纯 python 单文件服务版本
#!/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="会话列表">&#9776;</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">&#9654; 高级设置</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">&#x1F5BC;</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