3 Commits
Author SHA1 Message Date
hz4th_coder ddcf97a441 v2.1.3: 修复邮件被退信-补Date头
- 根因: mail.tphai.com 的 amavisd 内容过滤器强制要求 Date 头
  (554 5.6.0 BAD HEADER - Missing required header field: Date)
- 之前 llm-proxy 发的测试邮件/auto失败通知全被过滤退信(8封退信在 hz4th_coder 邮箱)
- 工作区标准 send_email.py 一直带 msg['Date']=formatdate() 所以能收到
- 修复: send_email() 补 msg['Date'] = formatdate(localtime=True), 与标准脚本一致
- 验证: 修复后测试邮件无退信, 已送达 wlq@tphai.com
2026-08-28 14:54:07 +08:00
hz4th_coder 904a4d0d6c v2.1.2: auto调用全部失败时邮件通知 + 网页配置邮箱
- 新增邮件通知: auto 配置被调用后所有提供商都失败(含无可用提供商)时自动发邮件
- 通知路径: chat_completions 400/503 + 通用能力端点(生图/语音/视频) + ASR端点
- 防轰炸: cooldown_seconds(默认300s)最小间隔, 连续失败只发一封(实测3次失败1封)
- 网页配置: 系统配置页新增「邮件通知」卡片, 支持 SMTP服务器/端口/加密(plain/starttls/ssl)/账号密码/发件人/收件人/间隔 + 发送测试邮件按钮
- API: GET/PUT /api/admin/email(密码掩码), POST /api/admin/email/test
- 默认SMTP: mail.tphai.com:587 plain, 收件人 wlq@tphai.com
- 修复: config页loadEmailConfig竞态(卡片未渲染就填充); 邮件失败不影响API响应
2026-08-28 13:17:31 +08:00
hz4th_coder d387d5ba09 v2.1.1: Auto配置改为模型粒度拖动排序 + 历史缓存复用 + 对话生图
- Auto配置编辑: 粒度从提供商改为具体模型, 原生HTML5拖动排序, 修复已保存模型不显示勾选(字符串/对象混拼bug)
- 历史缓存: 带历史上下文的auto请求按首条消息识别会话, 优先复用上次模型命中上游前缀缓存
  (可配置: prefer_cache_model + cache_ttl_seconds, 系统配置页开关 + /api/admin/routing)
- 对话生图: chat页选 auto-image/Qwen-Image 直接调生图接口并在对话内展示图片
- 修复: Flask模板缓存导致改模板不生效 -> TEMPLATES_AUTO_RELOAD
- 修复: 自动回退/显式有序模型列表路由, 空列表回退提供商能力选择
2026-08-28 12:16:25 +08:00
7 changed files with 915 additions and 143 deletions
+24 -5
View File
@@ -2,7 +2,7 @@
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
**版本:v2.1.0**
**版本:v2.1.2**
## 功能特点
@@ -73,6 +73,8 @@ curl http://localhost:16003/v1/chat/completions \
}'
```
后台「对话」页选择 `auto-image``Qwen-Image` 可直接在对话中生成并展示图片。
### 列出模型(含能力标签)
```bash
@@ -103,13 +105,13 @@ curl http://localhost:16003/v1/images/generations \
}'
```
## Auto 配置(按能力固定功能)
## Auto 配置(按能力固定功能 + 具体模型排序
每个 Auto 配置固定一个**功能类型**,调用时 `model="配置名称"` 即自动选择具备该能力的模型
每个 Auto 配置固定一个**功能类型**,内部是一个**有序的具体模型列表**(可在后台拖动排序),请求时按序选择第一个可用模型。调用时 `model="配置名称"`
| 配置名称 | 功能 | 说明 |
|----------|------|------|
| `auto` | 文本推理 | 默认,按优先级自动选择 |
| `auto` | 文本推理 | 默认,按模型列表顺序自动选择 |
| `auto-text` | 文本推理 | 纯文本模型 |
| `auto-vision` | 视觉能力 | 多模态视觉模型 |
| `auto-image` | 图片生成 | 生图模型 |
@@ -117,7 +119,14 @@ curl http://localhost:16003/v1/images/generations \
| `auto-voice-in` | 语音输入 | ASR 模型 |
| `auto-video` | 视频生成 | 视频模型 |
在后台「Auto配置」页创建/修改,从模型管理中选择具备对应能力的模型
在后台「Auto配置」页可**拖动具体模型**调整优先级(粒度是模型,不是提供商),勾选参与自动选择
## 历史上下文缓存(可配置)
带历史上下文的 `auto` 请求会**优先复用上次使用的模型**(按首条消息识别会话),命中上游前缀缓存、节省成本。可在「系统配置」页开关:
- `prefer_cache_model`:是否启用(默认开)
- `cache_ttl_seconds`:记忆有效期(默认 3600 秒)
## 模型管理
@@ -193,6 +202,16 @@ MODEL_ALIASES = {
3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变)
4. 连续失败3次的提供商被熔断,冷却期(默认60秒)后自动半开恢复
## 邮件通知(auto 调用全部失败时)
某个 auto 配置被调用后所有提供商都失败(如全部熔断/停用),自动发邮件通知,方便及时处理。
- **配置入口**:后台「系统配置」页 → 邮件通知(SMTP 服务器/账号/密码/收件人/加密方式,均可在网页配置)
- **防轰炸**:默认最小间隔 300 秒,短时间内连续失败只发一封
- **测试**:配置页有「发送测试邮件」按钮
- **端点**`/api/admin/email`GET/PUT)、`/api/admin/email/test`POST
- **默认配置**`mail.tphai.com:587`plain)、收件人 `wlq@tphai.com`
## 项目结构
```
+397 -15
View File
@@ -14,7 +14,12 @@ import requests
import json
import time
import random
import hashlib
import logging
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr, formatdate
from datetime import datetime, date
from pathlib import Path
import sys
@@ -25,17 +30,19 @@ import uuid
sys.path.insert(0, str(Path(__file__).parent))
from config.settings import (
get_providers, get_model_aliases, get_auto_profiles, get_auto_profile,
SERVER_CONFIG, LOG_CONFIG, RETRY_CONFIG, CAPABILITY_DEFS,
SERVER_CONFIG, LOG_CONFIG, RETRY_CONFIG, CAPABILITY_DEFS, ROUTING_CONFIG,
load_config, save_config, get_provider, add_provider, update_provider,
delete_provider, update_priority, update_model_alias, delete_model_alias,
add_auto_profile, update_auto_profile, delete_auto_profile,
add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config,
load_email_config, save_email_config,
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
)
app = Flask(__name__, template_folder='templates')
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
CORS(app)
VERSION = "2.1.0"
VERSION = "2.1.2"
# 数据目录和统计文件
DATA_DIR = Path(__file__).parent / 'data'
@@ -49,6 +56,137 @@ LOGS_DIR.mkdir(exist_ok=True)
stats_lock = threading.Lock()
chats_lock = threading.Lock()
# 历史上下文→模型缓存(用于 auto 请求优先复用上次模型,命中前缀缓存)
history_model_cache = {}
history_cache_lock = threading.Lock()
def history_fingerprint(data):
"""计算会话指纹:用首条消息作为会话锚点(同一对话各轮次首条消息不变,可跨轮次复用模型)"""
messages = data.get('messages', []) if isinstance(data, dict) else []
if not messages:
return None
try:
s = json.dumps(messages[0], ensure_ascii=False, sort_keys=True)
except Exception:
return None
return hashlib.md5(s.encode('utf-8')).hexdigest()
def has_history(data):
"""请求是否带历史上下文"""
messages = data.get('messages', []) if isinstance(data, dict) else []
return len(messages) >= 2
def get_cached_history_model(fp, capability=None):
"""获取历史前缀对应的上次模型(未过期且可用才返回)"""
if not fp:
return None
with history_cache_lock:
entry = history_model_cache.get(fp)
if not entry:
return None
routing = load_routing_config()
ttl = routing.get('cache_ttl_seconds', 3600)
if time.time() - entry.get('ts', 0) > ttl:
with history_cache_lock:
history_model_cache.pop(fp, None)
return None
provider = find_provider_for_model(entry.get('model', ''), capability)
if not provider:
return None
return {'provider': provider, 'model': entry['model']}
def remember_history_model(fp, provider_name, model):
"""记录某历史前缀使用的模型"""
if not fp:
return
with history_cache_lock:
history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()}
# ============ 邮件通知(auto 调用全部失败时发邮件) ============
_email_last_sent = 0
_email_lock = threading.Lock()
def _smtp_connect(cfg):
"""建立 SMTP 连接(支持 plain / starttls / ssl"""
host = cfg.get('smtp_host', '')
port = int(cfg.get('smtp_port', 25) or 25)
mode = cfg.get('smtp_mode', 'plain')
if mode == 'ssl':
server = smtplib.SMTP_SSL(host, port, timeout=15)
else:
server = smtplib.SMTP(host, port, timeout=15)
if mode == 'starttls':
server.starttls()
if cfg.get('smtp_user'):
server.login(cfg.get('smtp_user'), cfg.get('smtp_password', ''))
return server
def send_email(subject, body, cfg=None, html=False):
"""发送邮件,返回 {'success': bool, 'error'?: str}"""
cfg = cfg or load_email_config()
to_addrs = cfg.get('to_addrs') or []
if isinstance(to_addrs, str):
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
if not to_addrs:
return {'success': False, 'error': '未配置收件人'}
msg = MIMEText(body, 'html' if html else 'plain', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')
msg['Date'] = formatdate(localtime=True) # 必须带 Date 头,否则 mail.tphai.com 的 amavisd 会拒收(554 BAD HEADER)
from_addr = cfg.get('from_addr') or cfg.get('smtp_user') or ''
msg['From'] = formataddr((str(Header(cfg.get('from_name', ''), 'utf-8')), from_addr))
msg['To'] = ', '.join(to_addrs)
server = _smtp_connect(cfg)
try:
server.sendmail(from_addr, to_addrs, msg.as_string())
return {'success': True}
finally:
try:
server.quit()
except Exception:
pass
def notify_auto_failure(model, capability, last_error):
"""auto 调用全部失败时发送邮件通知(带冷却防轰炸)"""
global _email_last_sent
cfg = load_email_config()
if not cfg.get('enabled') or not cfg.get('notify_on_auto_failure'):
return
with _email_lock:
now = time.time()
if now - _email_last_sent < int(cfg.get('cooldown_seconds', 300) or 300):
return
_email_last_sent = now
try:
cap_label = CAPABILITY_DEFS.get(capability, capability)
subject = f"[LLM Proxy] Auto 调用全部失败: {model}"
body = (
f"时间: {datetime.now().isoformat()}\n"
f"Auto 配置: {model}\n"
f"能力: {cap_label}\n"
f"最后错误: {last_error}\n\n"
f"提示: 系统已尝试所有可用提供商仍未成功,请登录后台 (http://<IP>:16003/admin) 检查提供商状态。"
)
r = send_email(subject, body, cfg)
if r['success']:
logger.warning(f"Auto failure notification sent for {model} (capability={capability})")
else:
logger.error(f"Auto failure email send failed: {r.get('error')}")
except Exception as e:
logger.error(f"Failed to send auto failure email: {e}")
# 提供商状态缓存
provider_status = {}
@@ -307,14 +445,34 @@ def find_provider_for_model(model_name, capability=None, exclude=None):
def get_auto_provider(profile_name='auto', capability=None, exclude=None):
"""获取auto模式下可用的提供商与模型(按能力过滤)"""
"""获取auto模式下可用的提供商与模型
优先级:
1. 配置了显式有序 models 列表 → 按列表顺序逐个找能托管该模型的提供商
2. 否则按提供商能力 + 优先级选择(旧逻辑回退)
"""
refresh_config()
exclude = exclude or set()
profile = _cached_auto_profiles.get(profile_name, _cached_auto_profiles.get('auto', {}))
req_cap = profile.get('capability') or capability or 'text'
allowed_providers = profile.get('providers', ['*'])
strategy = profile.get('strategy', 'priority')
ordered_models = profile.get('models') or []
# 方式1:显式有序模型列表(粒度=具体模型,用户可拖动排序)
if ordered_models:
candidates = []
for mname in ordered_models:
provider = find_provider_for_model(mname, req_cap, exclude=exclude)
if provider:
candidates.append((provider, mname))
if candidates:
if strategy == 'random':
return random.choice(candidates)
return candidates[0]
return None, None # 显式列表全不可用时,不悄悄换成别的模型
# 方式2:按提供商能力 + 优先级
candidates = []
for provider in sorted_providers():
if not provider['enabled']:
@@ -383,6 +541,14 @@ def detect_capability(data):
return 'text'
def is_image_gen_request(model):
"""判断请求是否为图片生成(auto-image 配置或具备 image_gen 能力的模型)"""
resolved = resolve_model_name(model)
if is_auto_model(resolved):
return _cached_auto_profiles.get(resolved, {}).get('capability') == 'image_gen'
return find_provider_for_model(resolved, 'image_gen') is not None
# ============ 上游转发 ============
def build_headers(provider, content_type='application/json', extra=None):
@@ -563,14 +729,34 @@ def chat_completions():
request_model = model
capability = detect_capability(data)
provider, resolved_model = get_provider_for_model(model, capability)
# 历史上下文缓存:auto 请求始终记录会话锚点(首条消息),带历史时优先复用上次模型
history_fp = None
routing_cfg = load_routing_config()
if routing_cfg.get('prefer_cache_model', True) and is_auto_model(model):
history_fp = history_fingerprint(data)
if has_history(data):
cached = get_cached_history_model(history_fp, capability)
if cached:
provider = cached['provider']
resolved_model = cached['model']
request_provider = provider['name']
logger.info(f"History cache hit: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}")
else:
provider, resolved_model = get_provider_for_model(model, capability)
else:
provider, resolved_model = get_provider_for_model(model, capability)
else:
provider, resolved_model = get_provider_for_model(model, capability)
if not provider:
increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}')
if is_auto_model(model):
notify_auto_failure(model, capability, f"No available provider (capability: {capability})")
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
request_provider = provider['name']
logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}")
if not history_fp or not routing_cfg.get('prefer_cache_model', True):
logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}")
last_error = None
tried_providers = set()
@@ -585,6 +771,7 @@ def chat_completions():
if stream:
increment_stats(model, provider['name'], success=True, tokens=0)
remember_history_model(history_fp, provider['name'], resolved_model)
return Response(
stream_with_context(stream_response(response)),
content_type='text/event-stream',
@@ -595,6 +782,7 @@ def chat_completions():
usage = result.get('usage', {})
request_tokens = usage.get('total_tokens', 0)
increment_stats(model, provider['name'], success=True, tokens=request_tokens)
remember_history_model(history_fp, provider['name'], resolved_model)
return jsonify(result)
else:
@@ -643,6 +831,8 @@ def chat_completions():
break
increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error))
if is_auto_model(model):
notify_auto_failure(model, capability, str(last_error))
return jsonify({"error": {"message": f"All providers failed. Last error: {last_error}", "type": "api_error"}}), 503
except Exception as e:
@@ -711,6 +901,8 @@ def _generic_capability_endpoint(capability, path, error_msg="No available provi
break
if not provider:
if is_auto_model(model):
notify_auto_failure(model, capability, error_msg)
return jsonify({"error": {"message": f"{error_msg} (capability: {capability})", "type": "invalid_request_error"}}), 400
data['model'] = resolved_model if resolved_model else data.get('model')
@@ -757,6 +949,8 @@ def audio_transcriptions():
break
if not provider:
if is_auto_model(model):
notify_auto_failure(model, 'audio_in', "No ASR provider available")
return jsonify({"error": {"message": "No ASR provider available (capability: audio_in)", "type": "invalid_request_error"}}), 400
response = proxy_raw(provider, 'audio/transcriptions')
@@ -1384,20 +1578,38 @@ def api_admin_delete_alias(alias):
# ============ 后台管理 APIAuto配置 ============
def _auto_profile_public(name, profile):
"""Auto配置对外结构(含能力与匹配模型预览"""
"""Auto配置对外结构(含能力、有序模型列表与可挑选模型详情"""
providers = get_providers()
allowed_providers = profile.get('providers', ['*'])
capability = profile.get('capability', 'text')
ordered_models = profile.get('models') or []
provider_details = []
matched_models = []
# 收集所有具备该能力、且在候选提供商范围内的模型(供编辑器挑选)
matched = []
seen = set()
for p in sorted(providers, key=lambda x: x['priority']):
if not ('*' in allowed_providers or p.get('id') in allowed_providers or p['name'] in allowed_providers):
continue
models = provider_capability_models(p, capability)
if models:
provider_details.append({'id': p.get('id'), 'name': p['name'], 'priority': p['priority'], 'selected': True, 'matched_models': models})
matched_models.extend(models)
for m in provider_models(p):
if model_has_capability(p, m['name'], capability) and m['name'] not in seen:
seen.add(m['name'])
matched.append({
'name': m['name'],
'provider_id': p.get('id'),
'provider_name': p['name'],
'provider_priority': p['priority'],
'capabilities': m['capabilities'],
})
# 已保存的有序模型排前面,其余可挑选模型按提供商优先级追加
by_name = {x['name']: x for x in matched}
sorted_matched = []
for mname in ordered_models:
if mname in by_name:
sorted_matched.append(by_name[mname])
for x in matched:
if x['name'] not in ordered_models:
sorted_matched.append(x)
return {
'name': name,
@@ -1407,8 +1619,9 @@ def _auto_profile_public(name, profile):
'capability_label': CAPABILITY_DEFS.get(capability, capability),
'strategy': profile.get('strategy', 'priority'),
'providers': allowed_providers,
'provider_details': provider_details,
'matched_models': matched_models,
'models': ordered_models, # 有序的具体模型列表(优先级顺序)
'matched_models': [x['name'] for x in matched],
'matched_models_detail': sorted_matched, # 每个模型的提供商详情(编辑器用)
}
@@ -1452,6 +1665,7 @@ def api_admin_add_auto_profile():
'name': data.get('display_name', data['name']),
'description': data.get('description', ''),
'capability': capability,
'models': data.get('models', []), # 有序的具体模型列表(优先级顺序)
'providers': data.get('providers', ['*']),
'strategy': data.get('strategy', 'priority'),
}
@@ -1478,6 +1692,8 @@ def api_admin_update_auto_profile(profile_name):
if data['capability'] not in CAPABILITY_DEFS:
return jsonify({'error': f'Invalid capability: {data["capability"]}'}), 400
profile_data['capability'] = data['capability']
if 'models' in data:
profile_data['models'] = data['models']
if 'providers' in data:
profile_data['providers'] = data['providers']
if 'strategy' in data:
@@ -1538,11 +1754,126 @@ def api_admin_config():
'model_aliases': aliases,
'auto_profiles': get_auto_profiles(),
'retry_config': RETRY_CONFIG,
'routing_config': load_routing_config(),
'capabilities': CAPABILITY_DEFS,
'server_config': {'port': SERVER_CONFIG['port']}
})
@app.route('/api/admin/routing', methods=['GET'])
def api_admin_routing_get():
"""获取路由缓存配置"""
return jsonify(load_routing_config())
@app.route('/api/admin/routing', methods=['PUT'])
def api_admin_routing_put():
"""更新路由缓存配置"""
data = request.get_json()
if not data:
return jsonify({'error': 'Invalid request body'}), 400
update = {}
if 'prefer_cache_model' in data:
update['prefer_cache_model'] = bool(data['prefer_cache_model'])
if 'cache_ttl_seconds' in data:
try:
update['cache_ttl_seconds'] = max(0, int(data['cache_ttl_seconds']))
except:
pass
if not update:
return jsonify({'error': 'No valid fields'}), 400
result = save_routing_config(update)
return jsonify({'success': True, 'routing_config': result})
# ============ 后台管理 API:邮件通知 ============
def _mask_email_config(cfg):
"""返回给前端时掩码密码"""
out = dict(cfg)
out['smtp_password'] = '********' if cfg.get('smtp_password') else ''
return out
@app.route('/api/admin/email', methods=['GET'])
def api_admin_email_get():
"""获取邮件通知配置(密码掩码)"""
return jsonify(_mask_email_config(load_email_config()))
@app.route('/api/admin/email', methods=['PUT'])
def api_admin_email_put():
"""更新邮件通知配置"""
data = request.get_json()
if not data:
return jsonify({'error': 'Invalid request body'}), 400
cfg = load_email_config()
update = {}
for key in ['enabled', 'notify_on_auto_failure', 'smtp_host', 'smtp_port', 'smtp_mode',
'smtp_user', 'from_name', 'from_addr', 'to_addrs', 'cooldown_seconds']:
if key not in data:
continue
v = data[key]
if key in ('enabled', 'notify_on_auto_failure'):
v = bool(v)
elif key == 'smtp_port':
try:
v = int(v)
except:
continue
elif key == 'cooldown_seconds':
try:
v = max(0, int(v))
except:
continue
elif key == 'to_addrs':
if isinstance(v, str):
v = [a.strip() for a in v.split(',') if a.strip()]
elif isinstance(v, list):
v = [str(a).strip() for a in v if str(a).strip()]
elif key == 'smtp_mode':
if v not in ('plain', 'starttls', 'ssl'):
continue
update[key] = v
# 密码:掩码占位表示未修改
if 'smtp_password' in data and data['smtp_password'] and data['smtp_password'] != '********':
update['smtp_password'] = data['smtp_password']
if not update:
return jsonify({'error': 'No valid fields'}), 400
result = save_email_config(update)
return jsonify({'success': True, 'email_config': _mask_email_config(result)})
@app.route('/api/admin/email/test', methods=['POST'])
def api_admin_email_test():
"""发送测试邮件(使用当前配置)"""
cfg = load_email_config()
to_addrs = cfg.get('to_addrs') or []
if isinstance(to_addrs, str):
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
if not to_addrs:
return jsonify({'success': False, 'error': '请先配置收件人'}), 400
try:
r = send_email(
"[LLM Proxy] 测试邮件",
"这是一封来自 LLM Proxy 的测试邮件,说明邮件通知配置正常。",
cfg
)
if r['success']:
return jsonify({'success': True, 'message': f'测试邮件已发送到 {to_addrs}'})
return jsonify({'success': False, 'error': r.get('error', '发送失败')}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
# ============ 后台管理 API:对话 ============
def load_chats():
@@ -1665,6 +1996,57 @@ def api_admin_chat_send():
try:
proxy_url = f"http://localhost:{SERVER_CONFIG['port']}/v1/chat/completions"
# 图片生成模型(auto-image / Qwen-Image)→ 走生图接口
if is_image_gen_request(model):
img_resp = requests.post(f"http://localhost:{SERVER_CONFIG['port']}/v1/images/generations", json={
'model': model,
'prompt': user_message,
'n': 1,
}, timeout=180)
if img_resp.status_code == 200:
img_result = img_resp.json()
img_url = None
img_b64 = None
if img_result.get('data') and len(img_result['data']) > 0:
img_url = img_result['data'][0].get('url')
img_b64 = img_result['data'][0].get('b64_json')
used_model = img_result.get('model', model)
with chats_lock:
data = load_chats()
for c in data['chats']:
if c['id'] == chat_id:
c['messages'].append({
'role': 'assistant',
'content': '🖼️ 图片生成成功',
'image_url': img_url,
'image_b64': img_b64,
'model': used_model,
'time': datetime.now().isoformat()
})
if len(c['messages']) == 2:
c['title'] = user_message[:30] + ('...' if len(user_message) > 30 else '')
c['updated_at'] = datetime.now().isoformat()
break
save_chats(data)
return jsonify({
'success': True,
'chat_id': chat_id,
'response': '🖼️ 图片生成成功',
'model': used_model,
'image_url': img_url,
'image_b64': img_b64,
'title': chat.get('title', '新对话')
})
else:
try:
error_msg = img_resp.json().get('error', {}).get('message', 'Unknown error')
except:
error_msg = f'HTTP {img_resp.status_code}'
return jsonify({'error': error_msg}), img_resp.status_code
messages = []
for msg in chat['messages'][-20:]:
messages.append({'role': msg['role'], 'content': msg['content']})
+84 -7
View File
@@ -114,59 +114,136 @@ DEFAULT_MODEL_ALIASES = {
# 默认Auto配置:每个auto配置固定绑定一个能力
# capability: 固定此auto的功能类型(取自 CAPABILITY_DEFS
# models: 有序的**具体模型**列表(按优先级,可拖动排序);为空时回退为按提供商能力选择
# providers: 候选提供商(* 表示所有启用的、且具备该能力模型的提供商)
DEFAULT_AUTO_PROFILES = {
"auto": {
"name": "默认Auto",
"description": "文本推理 - 自动选择可用提供商",
"description": "文本推理 - 按模型列表顺序自动选择",
"capability": "text",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"deepseek-ai/DeepSeek-V4-Flash",
"meituan-longcat/LongCat-2.0",
"qwen3.6-plus",
"GLM-5.3-flash",
],
"providers": ["*"],
"strategy": "priority",
},
"auto-text": {
"name": "文本推理",
"description": "纯文本推理,自动选择文本模型",
"description": "纯文本推理,按模型列表顺序自动选择",
"capability": "text",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"deepseek-ai/DeepSeek-V4-Flash",
"meituan-longcat/LongCat-2.0",
"qwen3.6-plus",
"GLM-5.3-flash",
],
"providers": ["*"],
"strategy": "priority",
},
"auto-vision": {
"name": "视觉能力",
"description": "多模态视觉理解,自动选择视觉模型",
"description": "多模态视觉理解,按模型列表顺序自动选择",
"capability": "vision",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"qwen3.6-plus",
"GLM-5.3-flash",
],
"providers": ["*"],
"strategy": "priority",
},
"auto-image": {
"name": "图片生成",
"description": "文生图,自动选择生图模型",
"description": "文生图,按模型列表顺序自动选择",
"capability": "image_gen",
"models": ["Qwen-Image"],
"providers": ["*"],
"strategy": "priority",
},
"auto-voice-out": {
"name": "语音输出",
"description": "语音合成(TTS),自动选择语音输出模型",
"description": "语音合成(TTS)按模型列表顺序自动选择",
"capability": "audio_out",
"models": [],
"providers": ["*"],
"strategy": "priority",
},
"auto-voice-in": {
"name": "语音输入",
"description": "语音识别(ASR),自动选择语音输入模型",
"description": "语音识别(ASR)按模型列表顺序自动选择",
"capability": "audio_in",
"models": [],
"providers": ["*"],
"strategy": "priority",
},
"auto-video": {
"name": "视频生成",
"description": "文生视频,自动选择视频生成模型",
"description": "文生视频,按模型列表顺序自动选择",
"capability": "video_gen",
"models": [],
"providers": ["*"],
"strategy": "priority",
},
}
# 路由缓存配置(历史上下文优先复用上次模型,命中前缀缓存节省成本)
ROUTING_CONFIG = {
"prefer_cache_model": True, # 开启后:带历史上下文的 auto 请求优先复用上次使用的模型
"cache_ttl_seconds": 3600, # 记忆有效期(秒)
}
# 邮件通知配置(auto 调用全部失败时发邮件通知)
EMAIL_CONFIG = {
"enabled": True, # 总开关
"notify_on_auto_failure": True, # auto 调用全部失败时发邮件
"smtp_host": "mail.tphai.com",
"smtp_port": 587,
"smtp_mode": "plain", # plain | starttls | ssl
"smtp_user": "hz4th_coder@tphai.com",
"smtp_password": "hz4th_coder@!",
"from_name": "LLM Proxy",
"from_addr": "hz4th_coder@tphai.com",
"to_addrs": ["wlq@tphai.com"],
"cooldown_seconds": 300, # 失败通知最小间隔(防轰炸)
}
def load_email_config():
"""加载邮件通知配置(运行时配置优先)"""
config = load_config()
return {**EMAIL_CONFIG, **config.get("email_config", {})}
def save_email_config(data):
"""保存邮件通知配置"""
config = load_config()
merged = {**EMAIL_CONFIG, **data}
config["email_config"] = merged
save_config(config)
return merged
def load_routing_config():
"""加载路由缓存配置(运行时配置优先)"""
config = load_config()
return {**ROUTING_CONFIG, **config.get("routing_config", {})}
def save_routing_config(data):
"""保存路由缓存配置"""
config = load_config()
config["routing_config"] = {**ROUTING_CONFIG, **data}
save_config(config)
return config["routing_config"]
def load_config():
"""加载配置"""
+39 -7
View File
@@ -131,8 +131,16 @@
"auto_profiles": {
"auto": {
"name": "默认Auto",
"description": "文本推理 - 自动选择可用提供商",
"description": "文本推理 - 按模型列表顺序自动选择",
"capability": "text",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"deepseek-ai/DeepSeek-V4-Flash",
"meituan-longcat/LongCat-2.0",
"qwen3.6-plus",
"GLM-5.3-flash"
],
"providers": [
"*"
],
@@ -140,8 +148,16 @@
},
"auto-text": {
"name": "文本推理",
"description": "纯文本推理,自动选择文本模型",
"description": "纯文本推理,按模型列表顺序自动选择",
"capability": "text",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"deepseek-ai/DeepSeek-V4-Flash",
"meituan-longcat/LongCat-2.0",
"qwen3.6-plus",
"GLM-5.3-flash"
],
"providers": [
"*"
],
@@ -149,8 +165,14 @@
},
"auto-vision": {
"name": "视觉能力",
"description": "多模态视觉理解,自动选择视觉模型",
"description": "多模态视觉理解,按模型列表顺序自动选择",
"capability": "vision",
"models": [
"unsloth/Qwen3.8-27B-Q6_K",
"unsloth/Qwen3.8-27B-Q4_K_M",
"qwen3.6-plus",
"GLM-5.3-flash"
],
"providers": [
"*"
],
@@ -158,8 +180,11 @@
},
"auto-image": {
"name": "图片生成",
"description": "文生图,自动选择生图模型",
"description": "文生图,按模型列表顺序自动选择",
"capability": "image_gen",
"models": [
"Qwen-Image"
],
"providers": [
"*"
],
@@ -167,8 +192,9 @@
},
"auto-voice-out": {
"name": "语音输出",
"description": "语音合成(TTS),自动选择语音输出模型",
"description": "语音合成(TTS)按模型列表顺序自动选择",
"capability": "audio_out",
"models": [],
"providers": [
"*"
],
@@ -176,8 +202,9 @@
},
"auto-voice-in": {
"name": "语音输入",
"description": "语音识别(ASR),自动选择语音输入模型",
"description": "语音识别(ASR)按模型列表顺序自动选择",
"capability": "audio_in",
"models": [],
"providers": [
"*"
],
@@ -185,12 +212,17 @@
},
"auto-video": {
"name": "视频生成",
"description": "文生视频,自动选择视频生成模型",
"description": "文生视频,按模型列表顺序自动选择",
"capability": "video_gen",
"models": [],
"providers": [
"*"
],
"strategy": "priority"
}
},
"routing_config": {
"prefer_cache_model": true,
"cache_ttl_seconds": 3600
}
}
+146 -108
View File
@@ -8,7 +8,9 @@
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.dragging { opacity: 0.5; transform: scale(1.02); }
.dragging { opacity: 0.5; transform: scale(1.02); border: 2px dashed #6366f1; }
.drag-over { border: 2px dashed #6366f1; }
.model-row { transition: all 0.15s; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
@@ -51,7 +53,7 @@
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">Auto配置管理</h1>
<p class="text-gray-500 text-sm mt-1">每个Auto配置固定一个功能类型,并从模型管理中挑选具备该能力的模型</p>
<p class="text-gray-500 text-sm mt-1">每个Auto配置固定一个功能类型,按「具体模型」的顺序自动选择(可拖动排序)</p>
</div>
<button onclick="showCreateModal()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
<i class="ri-add-line mr-1"></i> 创建Auto配置
@@ -61,7 +63,7 @@
<!-- 说明卡片 -->
<div class="mb-6 p-4 bg-indigo-50 rounded-lg text-sm text-indigo-600">
<i class="ri-information-line mr-1"></i>
<strong>Auto配置:</strong>例如 <code>model="auto-vision"</code> 只选择具备<strong>视觉能力</strong>的模型,<code>model="auto-image"</code> 只选择<strong>图片生成</strong>模型。调用时使用 <code>model="配置名称"</code>
<strong>Auto配置:</strong>例如 <code>model="auto-vision"</code> 只选择具备<strong>视觉能力</strong>的模型,<code>model="auto-image"</code> 只选择<strong>图片生成</strong>模型。每个 Auto 配置内部是一个<strong>有序的具体模型列表</strong>,请求时按顺序选择第一个可用的模型;带历史上下文的请求会优先复用上次使用的模型(缓存命中)。可在「系统配置」页开关
</div>
<!-- 配置列表 -->
@@ -82,7 +84,7 @@
</div>
<div class="p-6 overflow-y-auto flex-1">
<form id="profileForm">
<form id="profileForm" onsubmit="return false;">
<input type="hidden" id="profileId">
<div class="grid grid-cols-2 gap-4 mb-4">
@@ -104,7 +106,6 @@
<select id="profileCapability" onchange="onCapabilityChange()" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="">请选择功能类型...</option>
</select>
<p class="text-xs text-gray-500 mt-1">文本推理 / 视觉能力 / 语音输出 / 语音输入 / 图片生成 / 视频生成</p>
</div>
<div class="mb-4">
@@ -116,24 +117,20 @@
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">选择策略</label>
<select id="profileStrategy" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="priority">优先级选择(推荐)</option>
<option value="priority">顺序选择(推荐)</option>
<option value="random">随机选择</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">候选提供商(仅列出具备所选能力模型的提供商,可拖拽调整优先级)</label>
<div id="providerList" class="border border-gray-200 rounded-lg p-3 space-y-2 min-h-[100px] bg-gray-50">
<label class="block text-sm font-medium text-gray-700 mb-2">
候选模型(勾选参与 · 拖动调整优先级顺序):
<span class="text-red-500 text-xs" id="modelListHint"></span>
</label>
<div id="modelList" class="border border-gray-200 rounded-lg p-3 space-y-2 min-h-[100px] bg-gray-50">
<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>
</div>
<p class="text-xs text-gray-500 mt-1">勾选参与自动选择的提供商,拖拽调整优先级</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">将匹配到以下模型(该能力下各提供商将选择的模型)</label>
<div id="matchedModels" class="flex flex-wrap gap-1">
<span class="text-gray-400 text-xs">请先选择功能类型</span>
</div>
<p class="text-xs text-gray-500 mt-1">⬍ 拖动左侧手柄调整顺序,勾选后保存。顺序即优先级,请求时按序选择第一个可用模型</p>
</div>
</form>
</div>
@@ -147,21 +144,21 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
let providers = [];
let allModels = [];
let profiles = [];
let capabilityDefs = {};
let draggedRow = null;
// 加载数据
async function loadData() {
const [providersRes, profilesRes, capsRes] = await Promise.all([
fetch('/api/admin/providers'),
const [modelsRes, profilesRes, capsRes] = await Promise.all([
fetch('/api/admin/models'),
fetch('/api/admin/auto-profiles'),
fetch('/api/admin/capabilities')
]);
providers = await providersRes.json();
allModels = await modelsRes.json();
profiles = await profilesRes.json();
capabilityDefs = await capsRes.json();
@@ -175,7 +172,13 @@
Object.entries(capabilityDefs).map(([key, label]) => `<option value="${key}">${label}</option>`).join('');
}
// 渲染配置列表
// 某能力的可用模型
function modelsForCapability(cap) {
return allModels.filter(m => (m.capabilities || []).includes(cap))
.sort((a, b) => (a.provider_priority || 99) - (b.provider_priority || 99));
}
// 渲染配置卡片
function renderProfiles() {
const container = document.getElementById('profilesList');
@@ -200,11 +203,11 @@
${profile.name === 'auto' ? '<span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">默认</span>' : ''}
</div>
<div class="flex gap-1">
<button onclick="editProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
<button onclick="editProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition" title="编辑">
<i class="ri-pencil-line"></i>
</button>
${profile.name !== 'auto' ? `
<button onclick="deleteProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
<button onclick="deleteProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition" title="删除">
<i class="ri-delete-bin-line"></i>
</button>
` : ''}
@@ -218,21 +221,20 @@
<i class="ri-focus-3-line mr-1"></i>${profile.capability_label || profile.capability || 'text'}
</span>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
${profile.strategy === 'priority' ? '按优先级' : '随机'}
</span>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
${profile.provider_details?.length || 0} 个提供商
${profile.strategy === 'priority' ? '按顺序' : '随机'}
</span>
<span class="px-2 py-1 bg-green-50 text-green-600 text-xs rounded">
${profile.matched_models?.length || 0}匹配模型
${(profile.models || []).length || 0} 个模型
</span>
</div>
<div class="mt-3 flex flex-wrap gap-1">
${(profile.matched_models || []).slice(0, 5).map(m =>
`<span class="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">${m}</span>`
).join('') || ''}
${(profile.matched_models?.length || 0) > 5 ?
`<span class="text-xs text-gray-400">+${profile.matched_models.length - 5}</span>` : ''}
<div class="mt-3 space-y-1">
${(profile.models || []).slice(0, 5).map((m, i) => `
<div class="flex items-center gap-2 text-xs">
<span class="w-4 h-4 bg-indigo-100 text-indigo-600 rounded flex items-center justify-center font-bold">${i + 1}</span>
<code class="text-slate-600 truncate">${m}</code>
</div>
`).join('') || '<span class="text-xs text-gray-400">未配置模型</span>'}
${(profile.models || []).length > 5 ? `<div class="text-xs text-gray-400">+${profile.models.length - 5} 个</div>` : ''}
</div>
</div>
</div>
@@ -246,9 +248,8 @@
document.getElementById('profileForm').reset();
document.getElementById('profileName').disabled = false;
document.getElementById('profileCapability').value = '';
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('matchedModels').innerHTML = '<span class="text-gray-400 text-xs">请先选择功能类型</span>';
document.getElementById('modelList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('modelListHint').textContent = '';
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
@@ -268,8 +269,7 @@
document.getElementById('profileStrategy').value = profile.strategy;
document.getElementById('profileCapability').value = profile.capability || 'text';
renderProviderList(profile.provider_details || [], profile.capability || 'text');
renderMatchedModels(profile.matched_models || []);
renderModelList(profile.capability || 'text', profile.models || []);
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
@@ -281,91 +281,122 @@
document.getElementById('editModal').classList.remove('flex');
}
// 能力切换时重新渲染提供商列表
// 能力切换时重新渲染模型列表
function onCapabilityChange() {
const capability = document.getElementById('profileCapability').value;
if (!capability) {
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('matchedModels').innerHTML = '<span class="text-gray-400 text-xs">请先选择功能类型</span>';
document.getElementById('modelList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('modelListHint').textContent = '';
return;
}
renderProviderList([], capability);
renderMatchedModels([]);
renderModelList(capability, []);
}
// 提供商是否具备该能力
function providerHasCapability(provider, capability) {
if (!provider.models) return false;
return provider.models.some(m => (m.capabilities || []).includes(capability));
}
// 渲染可拖拽模型列表
function renderModelList(capability, savedOrder) {
const container = document.getElementById('modelList');
const avail = modelsForCapability(capability);
function providerMatchedModels(provider, capability) {
return (provider.models || [])
.filter(m => (m.capabilities || []).includes(capability))
.map(m => m.name);
}
// 已保存的排前面(注意:保存的是模型名字符串,需映射回对象)
const saved = (savedOrder || []).map(n => avail.find(m => m.name === n)).filter(Boolean);
const savedNames = saved.map(o => o.name);
const rest = avail.filter(m => !savedNames.includes(m.name));
const ordered = saved.concat(rest);
// 渲染提供商列表(只展示具备所选能力的提供商)
function renderProviderList(selectedProviders, capability) {
const container = document.getElementById('providerList');
const selectedIds = selectedProviders.map(p => p.id);
const sortedProviders = [...providers].sort((a, b) => a.priority - b.priority);
const capable = sortedProviders.filter(p => providerHasCapability(p, capability));
if (capable.length === 0) {
if (ordered.length === 0) {
container.innerHTML = `
<div class="text-center py-4">
<p class="text-gray-500 text-sm">暂无提供商具备该能力</p>
<p class="text-gray-500 text-sm">暂无具备该能力的模型</p>
<p class="text-xs text-gray-400 mt-1">请先在 <a href="/admin/providers" class="text-indigo-500 underline">提供商管理</a> 中配置,或在 <a href="/admin/models" class="text-indigo-500 underline">模型管理</a> 中为模型添加能力标签</p>
</div>
`;
document.getElementById('modelListHint').textContent = '';
return;
}
container.innerHTML = capable.map(p => {
const isSelected = selectedIds.includes(p.id) || (selectedIds.includes('*') && p.id !== '*');
const matched = providerMatchedModels(p, capability);
const allSelected = selectedIds.includes('*');
return `
<div class="flex items-center gap-3 p-3 bg-white rounded-lg border ${isSelected || allSelected ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200'} hover:border-indigo-300 transition cursor-move"
data-id="${p.id}" data-priority="${p.priority}">
<i class="ri-drag-move-2 text-gray-400 cursor-grab"></i>
<input type="checkbox" class="provider-check w-4 h-4 text-indigo-600 rounded"
id="provider-${p.id}" ${isSelected || allSelected ? 'checked' : ''}
onchange="this.closest('[data-id]').classList.toggle('border-indigo-500', this.checked);this.closest('[data-id]').classList.toggle('bg-indigo-50', this.checked)">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 truncate">${p.name}</div>
<div class="text-xs text-gray-500">
优先级 ${p.priority} · 匹配模型: ${matched.join(', ') || '无'}
</div>
</div>
${p.available ?
'<span class="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded">可用</span>' :
'<span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded">不可用</span>'}
</div>
`;
}).join('');
document.getElementById('modelListHint').textContent = `${ordered.length} 个可挑选模型,已选 ${saved.length}`;
// 初始化拖拽排序
new Sortable(container, {
animation: 150,
handle: '.ri-drag-move-2',
ghostClass: 'dragging',
});
container.innerHTML = ordered.map(m => `
<div class="model-row flex items-center gap-3 p-3 bg-white rounded-lg border border-gray-200 ${savedNames.includes(m.name) ? 'border-indigo-400 bg-indigo-50/50' : ''}"
data-model="${m.name}"
draggable="true"
ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"
ondragover="handleDragOver(event)" ondrop="handleDrop(event)">
<i class="ri-drag-move-2 text-gray-400 cursor-grab" title="拖动排序"></i>
<span class="row-num w-5 h-5 bg-indigo-100 text-indigo-600 rounded text-xs flex items-center justify-center font-bold"></span>
<input type="checkbox" class="model-check w-4 h-4 text-indigo-600 rounded" ${savedNames.includes(m.name) ? 'checked' : ''}
onchange="onCheckChange(this)">
<div class="flex-1 min-w-0">
<code class="text-sm text-gray-800 font-medium truncate block">${m.name}</code>
<div class="text-xs text-gray-500">
${m.provider_name} · 优先级 ${m.provider_priority}
${m.is_default ? '<span class="ml-1 px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px]">默认</span>' : ''}
</div>
</div>
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-xs">${capabilityDefs[capability] || capability}</span>
</div>
`).join('');
renumber();
}
// 渲染匹配模型预览
function renderMatchedModels(models) {
const container = document.getElementById('matchedModels');
if (!models || models.length === 0) {
container.innerHTML = '<span class="text-gray-400 text-xs">当前选择下暂无匹配模型</span>';
return;
// 重新编号 + 更新已选计数
function renumber() {
document.querySelectorAll('#modelList .model-row').forEach((row, i) => {
row.querySelector('.row-num').textContent = i + 1;
});
const total = document.querySelectorAll('#modelList .model-row').length;
const checked = document.querySelectorAll('#modelList .model-check:checked').length;
document.getElementById('modelListHint').textContent = `${total} 个可挑选模型,已选 ${checked}`;
}
function onCheckChange(cb) {
const row = cb.closest('.model-row');
if (cb.checked) {
row.classList.add('border-indigo-400', 'bg-indigo-50/50');
} else {
row.classList.remove('border-indigo-400', 'bg-indigo-50/50');
}
container.innerHTML = models.map(m =>
`<span class="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">${m}</span>`
).join('');
renumber();
}
// 原生拖拽排序
function handleDragStart(e) {
draggedRow = e.target.closest('.model-row');
draggedRow.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragEnd(e) {
const row = e.target.closest('.model-row');
if (row) row.classList.remove('dragging');
document.querySelectorAll('.model-row').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
}
function handleDragOver(e) {
e.preventDefault();
const row = e.target.closest('.model-row');
if (row && row !== draggedRow) row.classList.add('drag-over');
}
function handleDrop(e) {
e.preventDefault();
const targetRow = e.target.closest('.model-row');
if (!targetRow || targetRow === draggedRow) return;
targetRow.classList.remove('drag-over');
const container = document.getElementById('modelList');
const rows = [...container.querySelectorAll('.model-row')];
const draggedIndex = rows.indexOf(draggedRow);
const targetIndex = rows.indexOf(targetRow);
if (draggedIndex < targetIndex) {
targetRow.after(draggedRow);
} else {
targetRow.before(draggedRow);
}
renumber();
}
// 保存配置
@@ -385,18 +416,25 @@
return;
}
const selectedProviders = [];
document.querySelectorAll('.provider-check:checked').forEach(c => {
selectedProviders.push(c.id.replace('provider-', ''));
// 收集勾选的模型(按当前DOM顺序 = 优先级顺序)
const models = [];
document.querySelectorAll('#modelList .model-row').forEach(row => {
if (row.querySelector('.model-check').checked) models.push(row.dataset.model);
});
if (models.length === 0) {
alert('请至少勾选一个模型');
return;
}
const data = {
name: profileName,
display_name: displayName || profileName,
description: description,
strategy: strategy,
capability: capability,
providers: selectedProviders.length > 0 ? selectedProviders : ['*']
models: models,
providers: ['*']
};
const profileId = document.getElementById('profileId').value;
+2 -1
View File
@@ -249,6 +249,7 @@
<div class="flex justify-start">
<div class="message-assistant px-4 py-3 rounded-2xl rounded-bl-md max-w-[80%]">
<div class="whitespace-pre-wrap text-gray-800">${escapeHtml(msg.content)}</div>
${msg.image_url ? `<img src="${msg.image_url}" class="mt-2 rounded-lg max-w-full border border-gray-200" />` : ''}
${msg.model ? `<div class="text-xs text-gray-400 mt-2">模型: ${msg.model}</div>` : ''}
</div>
</div>
@@ -311,7 +312,7 @@
currentChatId = data.chat_id;
// 添加助手回复
const assistantMsg = { role: 'assistant', content: data.response, model: data.model };
const assistantMsg = { role: 'assistant', content: data.response, model: data.model, image_url: data.image_url };
container.innerHTML += renderMessage(assistantMsg);
// 更新标题
+223
View File
@@ -84,6 +84,40 @@
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">路由缓存(历史上下文优先复用上次模型)</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p class="font-medium text-gray-800">优先复用上次模型</p>
<p class="text-sm text-gray-500 mt-1">带历史上下文的 auto 请求,优先使用上一次成功响应的模型,命中上游前缀缓存、节省成本</p>
</div>
<button id="cacheToggle" onclick="toggleCacheModel()" class="relative w-12 h-6 rounded-full transition ${data.routing_config.prefer_cache_model ? 'bg-green-500' : 'bg-gray-300'}">
<span id="cacheKnob" class="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition ${data.routing_config.prefer_cache_model ? 'left-6' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-end gap-3 p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">记忆有效期(秒)</label>
<input type="number" id="cacheTtl" value="${data.routing_config.cache_ttl_seconds}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<p class="text-xs text-gray-500 mt-1">超过该时长后不再强制复用,重新按顺序选择</p>
</div>
<button onclick="saveRouting()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-1">邮件通知(auto 调用全部失败时通知)</h2>
<p class="text-sm text-gray-500 mb-4">某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人</p>
<div id="emailConfigContent" class="space-y-4">
<p class="text-gray-500 text-sm">加载中...</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
<div class="space-y-4">
@@ -125,6 +159,195 @@
</p>
</div>
`;
loadEmailConfig();
}
async function toggleCacheModel() {
const data = await (await fetch('/api/admin/routing')).json();
const newVal = !data.prefer_cache_model;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefer_cache_model: newVal })
});
const result = await res.json();
if (result.success) {
const btn = document.getElementById('cacheToggle');
const knob = document.getElementById('cacheKnob');
if (newVal) {
btn.classList.remove('bg-gray-300'); btn.classList.add('bg-green-500');
knob.classList.remove('left-0.5'); knob.classList.add('left-6');
} else {
btn.classList.remove('bg-green-500'); btn.classList.add('bg-gray-300');
knob.classList.remove('left-6'); knob.classList.add('left-0.5');
}
} else {
alert('保存失败: ' + (result.error || ''));
}
}
async function saveRouting() {
const ttl = parseInt(document.getElementById('cacheTtl').value) || 3600;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cache_ttl_seconds: ttl })
});
const result = await res.json();
if (result.success) {
alert('✅ 已保存');
} else {
alert('保存失败: ' + (result.error || ''));
}
}
async function loadEmailConfig() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const container = document.getElementById('emailConfigContent');
container.innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">启用邮件通知</span>
<button id="emailEnabledToggle" onclick="toggleEmailEnabled()" class="relative w-10 h-5 rounded-full transition ${e.enabled ? 'bg-green-500' : 'bg-gray-300'}">
<span id="emailEnabledKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.enabled ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">auto 失败时通知</span>
<button id="notifyToggle" onclick="toggleNotify()" class="relative w-10 h-5 rounded-full transition ${e.notify_on_auto_failure ? 'bg-green-500' : 'bg-gray-300'}">
<span id="notifyKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.notify_on_auto_failure ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
<input type="text" id="emailHost" value="${e.smtp_host}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="emailPort" value="${e.smtp_port}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
<select id="emailMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="plain" ${e.smtp_mode==='plain'?'selected':''}>plain(无加密)</option>
<option value="starttls" ${e.smtp_mode==='starttls'?'selected':''}>STARTTLS</option>
<option value="ssl" ${e.smtp_mode==='ssl'?'selected':''}>SSL</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<input type="text" id="emailUser" value="${e.smtp_user}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码(留空不改)</label>
<input type="password" id="emailPass" value="${e.smtp_password}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
<input type="text" id="emailFromName" value="${e.from_name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件地址</label>
<input type="text" id="emailFromAddr" value="${e.from_addr}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">收件人(逗号分隔)</label>
<input type="text" id="emailTo" value="${(e.to_addrs||[]).join(', ')}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">失败通知最小间隔(秒)</label>
<input type="number" id="emailCooldown" value="${e.cooldown_seconds}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<button onclick="saveEmail()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
<button onclick="testEmail(this)" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
<i class="ri-mail-send-line mr-1"></i> 发送测试邮件
</button>
</div>
`;
}
async function toggleEmailEnabled() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.enabled;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('emailEnabledToggle');
const knob = document.getElementById('emailEnabledKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function toggleNotify() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.notify_on_auto_failure;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_on_auto_failure: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('notifyToggle');
const knob = document.getElementById('notifyKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function saveEmail() {
const data = {
smtp_host: document.getElementById('emailHost').value.trim(),
smtp_port: parseInt(document.getElementById('emailPort').value) || 25,
smtp_mode: document.getElementById('emailMode').value,
smtp_user: document.getElementById('emailUser').value.trim(),
from_name: document.getElementById('emailFromName').value.trim(),
from_addr: document.getElementById('emailFromAddr').value.trim(),
to_addrs: document.getElementById('emailTo').value,
cooldown_seconds: parseInt(document.getElementById('emailCooldown').value) || 300
};
const pass = document.getElementById('emailPass').value;
if (pass && pass !== '********') data.smtp_password = pass;
const res = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const d = await res.json();
if (d.success) {
alert('✅ 邮件配置已保存');
loadEmailConfig();
} else { alert('保存失败: ' + (d.error || '')); }
}
async function testEmail(btn) {
if (!btn) btn = event.target.closest('button');
btn.disabled = true; btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 发送中...';
try {
const res = await fetch('/api/admin/email/test', { method: 'POST' });
const d = await res.json();
if (d.success) { alert('✅ ' + d.message); }
else { alert('❌ ' + (d.error || '发送失败')); }
} catch (e) {
alert('❌ ' + e.message);
}
btn.disabled = false; btn.innerHTML = '<i class="ri-mail-send-line mr-1"></i> 发送测试邮件';
}
loadConfig();