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 - 修复: 自动回退/显式有序模型列表路由, 空列表回退提供商能力选择
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
|
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
|
||||||
|
|
||||||
**版本:v2.1.0**
|
**版本:v2.1.1**
|
||||||
|
|
||||||
## 功能特点
|
## 功能特点
|
||||||
|
|
||||||
@@ -73,6 +73,8 @@ curl http://localhost:16003/v1/chat/completions \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后台「对话」页选择 `auto-image` 或 `Qwen-Image` 可直接在对话中生成并展示图片。
|
||||||
|
|
||||||
### 列出模型(含能力标签)
|
### 列出模型(含能力标签)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -103,13 +105,13 @@ curl http://localhost:16003/v1/images/generations \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auto 配置(按能力固定功能)
|
## Auto 配置(按能力固定功能 + 具体模型排序)
|
||||||
|
|
||||||
每个 Auto 配置固定一个**功能类型**,调用时 `model="配置名称"` 即自动选择具备该能力的模型:
|
每个 Auto 配置固定一个**功能类型**,内部是一个**有序的具体模型列表**(可在后台拖动排序),请求时按序选择第一个可用模型。调用时 `model="配置名称"`:
|
||||||
|
|
||||||
| 配置名称 | 功能 | 说明 |
|
| 配置名称 | 功能 | 说明 |
|
||||||
|----------|------|------|
|
|----------|------|------|
|
||||||
| `auto` | 文本推理 | 默认,按优先级自动选择 |
|
| `auto` | 文本推理 | 默认,按模型列表顺序自动选择 |
|
||||||
| `auto-text` | 文本推理 | 纯文本模型 |
|
| `auto-text` | 文本推理 | 纯文本模型 |
|
||||||
| `auto-vision` | 视觉能力 | 多模态视觉模型 |
|
| `auto-vision` | 视觉能力 | 多模态视觉模型 |
|
||||||
| `auto-image` | 图片生成 | 生图模型 |
|
| `auto-image` | 图片生成 | 生图模型 |
|
||||||
@@ -117,7 +119,14 @@ curl http://localhost:16003/v1/images/generations \
|
|||||||
| `auto-voice-in` | 语音输入 | ASR 模型 |
|
| `auto-voice-in` | 语音输入 | ASR 模型 |
|
||||||
| `auto-video` | 视频生成 | 视频模型 |
|
| `auto-video` | 视频生成 | 视频模型 |
|
||||||
|
|
||||||
可在后台「Auto配置」页创建/修改,从模型管理中选择具备对应能力的模型。
|
在后台「Auto配置」页可**拖动具体模型**调整优先级(粒度是模型,不是提供商),勾选参与自动选择。
|
||||||
|
|
||||||
|
## 历史上下文缓存(可配置)
|
||||||
|
|
||||||
|
带历史上下文的 `auto` 请求会**优先复用上次使用的模型**(按首条消息识别会话),命中上游前缀缓存、节省成本。可在「系统配置」页开关:
|
||||||
|
|
||||||
|
- `prefer_cache_model`:是否启用(默认开)
|
||||||
|
- `cache_ttl_seconds`:记忆有效期(默认 3600 秒)
|
||||||
|
|
||||||
## 模型管理
|
## 模型管理
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import requests
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,17 +26,18 @@ import uuid
|
|||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from config.settings import (
|
from config.settings import (
|
||||||
get_providers, get_model_aliases, get_auto_profiles, get_auto_profile,
|
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,
|
load_config, save_config, get_provider, add_provider, update_provider,
|
||||||
delete_provider, update_priority, update_model_alias, delete_model_alias,
|
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,
|
||||||
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
|
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Flask(__name__, template_folder='templates')
|
app = Flask(__name__, template_folder='templates')
|
||||||
|
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
VERSION = "2.1.0"
|
VERSION = "2.1.1"
|
||||||
|
|
||||||
# 数据目录和统计文件
|
# 数据目录和统计文件
|
||||||
DATA_DIR = Path(__file__).parent / 'data'
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
@@ -49,6 +51,56 @@ LOGS_DIR.mkdir(exist_ok=True)
|
|||||||
stats_lock = threading.Lock()
|
stats_lock = threading.Lock()
|
||||||
chats_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()}
|
||||||
|
|
||||||
# 提供商状态缓存
|
# 提供商状态缓存
|
||||||
provider_status = {}
|
provider_status = {}
|
||||||
|
|
||||||
@@ -307,14 +359,34 @@ def find_provider_for_model(model_name, capability=None, exclude=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_auto_provider(profile_name='auto', capability=None, exclude=None):
|
def get_auto_provider(profile_name='auto', capability=None, exclude=None):
|
||||||
"""获取auto模式下可用的提供商与模型(按能力过滤)"""
|
"""获取auto模式下可用的提供商与模型
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
1. 配置了显式有序 models 列表 → 按列表顺序逐个找能托管该模型的提供商
|
||||||
|
2. 否则按提供商能力 + 优先级选择(旧逻辑回退)
|
||||||
|
"""
|
||||||
refresh_config()
|
refresh_config()
|
||||||
exclude = exclude or set()
|
exclude = exclude or set()
|
||||||
profile = _cached_auto_profiles.get(profile_name, _cached_auto_profiles.get('auto', {}))
|
profile = _cached_auto_profiles.get(profile_name, _cached_auto_profiles.get('auto', {}))
|
||||||
req_cap = profile.get('capability') or capability or 'text'
|
req_cap = profile.get('capability') or capability or 'text'
|
||||||
allowed_providers = profile.get('providers', ['*'])
|
allowed_providers = profile.get('providers', ['*'])
|
||||||
strategy = profile.get('strategy', 'priority')
|
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 = []
|
candidates = []
|
||||||
for provider in sorted_providers():
|
for provider in sorted_providers():
|
||||||
if not provider['enabled']:
|
if not provider['enabled']:
|
||||||
@@ -383,6 +455,14 @@ def detect_capability(data):
|
|||||||
return 'text'
|
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):
|
def build_headers(provider, content_type='application/json', extra=None):
|
||||||
@@ -563,6 +643,23 @@ def chat_completions():
|
|||||||
request_model = model
|
request_model = model
|
||||||
capability = detect_capability(data)
|
capability = detect_capability(data)
|
||||||
|
|
||||||
|
# 历史上下文缓存: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)
|
provider, resolved_model = get_provider_for_model(model, capability)
|
||||||
|
|
||||||
if not provider:
|
if not provider:
|
||||||
@@ -570,6 +667,7 @@ def chat_completions():
|
|||||||
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
|
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
|
||||||
|
|
||||||
request_provider = provider['name']
|
request_provider = provider['name']
|
||||||
|
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}")
|
logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}")
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
@@ -585,6 +683,7 @@ def chat_completions():
|
|||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
increment_stats(model, provider['name'], success=True, tokens=0)
|
increment_stats(model, provider['name'], success=True, tokens=0)
|
||||||
|
remember_history_model(history_fp, provider['name'], resolved_model)
|
||||||
return Response(
|
return Response(
|
||||||
stream_with_context(stream_response(response)),
|
stream_with_context(stream_response(response)),
|
||||||
content_type='text/event-stream',
|
content_type='text/event-stream',
|
||||||
@@ -595,6 +694,7 @@ def chat_completions():
|
|||||||
usage = result.get('usage', {})
|
usage = result.get('usage', {})
|
||||||
request_tokens = usage.get('total_tokens', 0)
|
request_tokens = usage.get('total_tokens', 0)
|
||||||
increment_stats(model, provider['name'], success=True, tokens=request_tokens)
|
increment_stats(model, provider['name'], success=True, tokens=request_tokens)
|
||||||
|
remember_history_model(history_fp, provider['name'], resolved_model)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -1384,20 +1484,38 @@ def api_admin_delete_alias(alias):
|
|||||||
# ============ 后台管理 API:Auto配置 ============
|
# ============ 后台管理 API:Auto配置 ============
|
||||||
|
|
||||||
def _auto_profile_public(name, profile):
|
def _auto_profile_public(name, profile):
|
||||||
"""Auto配置对外结构(含能力与匹配模型预览)"""
|
"""Auto配置对外结构(含能力、有序模型列表与可挑选模型详情)"""
|
||||||
providers = get_providers()
|
providers = get_providers()
|
||||||
allowed_providers = profile.get('providers', ['*'])
|
allowed_providers = profile.get('providers', ['*'])
|
||||||
capability = profile.get('capability', 'text')
|
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']):
|
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):
|
if not ('*' in allowed_providers or p.get('id') in allowed_providers or p['name'] in allowed_providers):
|
||||||
continue
|
continue
|
||||||
models = provider_capability_models(p, capability)
|
for m in provider_models(p):
|
||||||
if models:
|
if model_has_capability(p, m['name'], capability) and m['name'] not in seen:
|
||||||
provider_details.append({'id': p.get('id'), 'name': p['name'], 'priority': p['priority'], 'selected': True, 'matched_models': models})
|
seen.add(m['name'])
|
||||||
matched_models.extend(models)
|
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 {
|
return {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -1407,8 +1525,9 @@ def _auto_profile_public(name, profile):
|
|||||||
'capability_label': CAPABILITY_DEFS.get(capability, capability),
|
'capability_label': CAPABILITY_DEFS.get(capability, capability),
|
||||||
'strategy': profile.get('strategy', 'priority'),
|
'strategy': profile.get('strategy', 'priority'),
|
||||||
'providers': allowed_providers,
|
'providers': allowed_providers,
|
||||||
'provider_details': provider_details,
|
'models': ordered_models, # 有序的具体模型列表(优先级顺序)
|
||||||
'matched_models': matched_models,
|
'matched_models': [x['name'] for x in matched],
|
||||||
|
'matched_models_detail': sorted_matched, # 每个模型的提供商详情(编辑器用)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1452,6 +1571,7 @@ def api_admin_add_auto_profile():
|
|||||||
'name': data.get('display_name', data['name']),
|
'name': data.get('display_name', data['name']),
|
||||||
'description': data.get('description', ''),
|
'description': data.get('description', ''),
|
||||||
'capability': capability,
|
'capability': capability,
|
||||||
|
'models': data.get('models', []), # 有序的具体模型列表(优先级顺序)
|
||||||
'providers': data.get('providers', ['*']),
|
'providers': data.get('providers', ['*']),
|
||||||
'strategy': data.get('strategy', 'priority'),
|
'strategy': data.get('strategy', 'priority'),
|
||||||
}
|
}
|
||||||
@@ -1478,6 +1598,8 @@ def api_admin_update_auto_profile(profile_name):
|
|||||||
if data['capability'] not in CAPABILITY_DEFS:
|
if data['capability'] not in CAPABILITY_DEFS:
|
||||||
return jsonify({'error': f'Invalid capability: {data["capability"]}'}), 400
|
return jsonify({'error': f'Invalid capability: {data["capability"]}'}), 400
|
||||||
profile_data['capability'] = data['capability']
|
profile_data['capability'] = data['capability']
|
||||||
|
if 'models' in data:
|
||||||
|
profile_data['models'] = data['models']
|
||||||
if 'providers' in data:
|
if 'providers' in data:
|
||||||
profile_data['providers'] = data['providers']
|
profile_data['providers'] = data['providers']
|
||||||
if 'strategy' in data:
|
if 'strategy' in data:
|
||||||
@@ -1538,11 +1660,41 @@ def api_admin_config():
|
|||||||
'model_aliases': aliases,
|
'model_aliases': aliases,
|
||||||
'auto_profiles': get_auto_profiles(),
|
'auto_profiles': get_auto_profiles(),
|
||||||
'retry_config': RETRY_CONFIG,
|
'retry_config': RETRY_CONFIG,
|
||||||
|
'routing_config': load_routing_config(),
|
||||||
'capabilities': CAPABILITY_DEFS,
|
'capabilities': CAPABILITY_DEFS,
|
||||||
'server_config': {'port': SERVER_CONFIG['port']}
|
'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:对话 ============
|
# ============ 后台管理 API:对话 ============
|
||||||
|
|
||||||
def load_chats():
|
def load_chats():
|
||||||
@@ -1665,6 +1817,57 @@ def api_admin_chat_send():
|
|||||||
try:
|
try:
|
||||||
proxy_url = f"http://localhost:{SERVER_CONFIG['port']}/v1/chat/completions"
|
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 = []
|
messages = []
|
||||||
for msg in chat['messages'][-20:]:
|
for msg in chat['messages'][-20:]:
|
||||||
messages.append({'role': msg['role'], 'content': msg['content']})
|
messages.append({'role': msg['role'], 'content': msg['content']})
|
||||||
|
|||||||
+54
-7
@@ -114,59 +114,106 @@ DEFAULT_MODEL_ALIASES = {
|
|||||||
|
|
||||||
# 默认Auto配置:每个auto配置固定绑定一个能力
|
# 默认Auto配置:每个auto配置固定绑定一个能力
|
||||||
# capability: 固定此auto的功能类型(取自 CAPABILITY_DEFS)
|
# capability: 固定此auto的功能类型(取自 CAPABILITY_DEFS)
|
||||||
|
# models: 有序的**具体模型**列表(按优先级,可拖动排序);为空时回退为按提供商能力选择
|
||||||
# providers: 候选提供商(* 表示所有启用的、且具备该能力模型的提供商)
|
# providers: 候选提供商(* 表示所有启用的、且具备该能力模型的提供商)
|
||||||
DEFAULT_AUTO_PROFILES = {
|
DEFAULT_AUTO_PROFILES = {
|
||||||
"auto": {
|
"auto": {
|
||||||
"name": "默认Auto",
|
"name": "默认Auto",
|
||||||
"description": "文本推理 - 自动选择可用提供商",
|
"description": "文本推理 - 按模型列表顺序自动选择",
|
||||||
"capability": "text",
|
"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": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-text": {
|
"auto-text": {
|
||||||
"name": "文本推理",
|
"name": "文本推理",
|
||||||
"description": "纯文本推理,自动选择文本模型",
|
"description": "纯文本推理,按模型列表顺序自动选择",
|
||||||
"capability": "text",
|
"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": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-vision": {
|
"auto-vision": {
|
||||||
"name": "视觉能力",
|
"name": "视觉能力",
|
||||||
"description": "多模态视觉理解,自动选择视觉模型",
|
"description": "多模态视觉理解,按模型列表顺序自动选择",
|
||||||
"capability": "vision",
|
"capability": "vision",
|
||||||
|
"models": [
|
||||||
|
"unsloth/Qwen3.8-27B-Q6_K",
|
||||||
|
"unsloth/Qwen3.8-27B-Q4_K_M",
|
||||||
|
"qwen3.6-plus",
|
||||||
|
"GLM-5.3-flash",
|
||||||
|
],
|
||||||
"providers": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-image": {
|
"auto-image": {
|
||||||
"name": "图片生成",
|
"name": "图片生成",
|
||||||
"description": "文生图,自动选择生图模型",
|
"description": "文生图,按模型列表顺序自动选择",
|
||||||
"capability": "image_gen",
|
"capability": "image_gen",
|
||||||
|
"models": ["Qwen-Image"],
|
||||||
"providers": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-voice-out": {
|
"auto-voice-out": {
|
||||||
"name": "语音输出",
|
"name": "语音输出",
|
||||||
"description": "语音合成(TTS),自动选择语音输出模型",
|
"description": "语音合成(TTS),按模型列表顺序自动选择",
|
||||||
"capability": "audio_out",
|
"capability": "audio_out",
|
||||||
|
"models": [],
|
||||||
"providers": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-voice-in": {
|
"auto-voice-in": {
|
||||||
"name": "语音输入",
|
"name": "语音输入",
|
||||||
"description": "语音识别(ASR),自动选择语音输入模型",
|
"description": "语音识别(ASR),按模型列表顺序自动选择",
|
||||||
"capability": "audio_in",
|
"capability": "audio_in",
|
||||||
|
"models": [],
|
||||||
"providers": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
"auto-video": {
|
"auto-video": {
|
||||||
"name": "视频生成",
|
"name": "视频生成",
|
||||||
"description": "文生视频,自动选择视频生成模型",
|
"description": "文生视频,按模型列表顺序自动选择",
|
||||||
"capability": "video_gen",
|
"capability": "video_gen",
|
||||||
|
"models": [],
|
||||||
"providers": ["*"],
|
"providers": ["*"],
|
||||||
"strategy": "priority",
|
"strategy": "priority",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 路由缓存配置(历史上下文优先复用上次模型,命中前缀缓存节省成本)
|
||||||
|
ROUTING_CONFIG = {
|
||||||
|
"prefer_cache_model": True, # 开启后:带历史上下文的 auto 请求优先复用上次使用的模型
|
||||||
|
"cache_ttl_seconds": 3600, # 记忆有效期(秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def load_config():
|
||||||
"""加载配置"""
|
"""加载配置"""
|
||||||
|
|||||||
+39
-7
@@ -131,8 +131,16 @@
|
|||||||
"auto_profiles": {
|
"auto_profiles": {
|
||||||
"auto": {
|
"auto": {
|
||||||
"name": "默认Auto",
|
"name": "默认Auto",
|
||||||
"description": "文本推理 - 自动选择可用提供商",
|
"description": "文本推理 - 按模型列表顺序自动选择",
|
||||||
"capability": "text",
|
"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": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -140,8 +148,16 @@
|
|||||||
},
|
},
|
||||||
"auto-text": {
|
"auto-text": {
|
||||||
"name": "文本推理",
|
"name": "文本推理",
|
||||||
"description": "纯文本推理,自动选择文本模型",
|
"description": "纯文本推理,按模型列表顺序自动选择",
|
||||||
"capability": "text",
|
"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": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -149,8 +165,14 @@
|
|||||||
},
|
},
|
||||||
"auto-vision": {
|
"auto-vision": {
|
||||||
"name": "视觉能力",
|
"name": "视觉能力",
|
||||||
"description": "多模态视觉理解,自动选择视觉模型",
|
"description": "多模态视觉理解,按模型列表顺序自动选择",
|
||||||
"capability": "vision",
|
"capability": "vision",
|
||||||
|
"models": [
|
||||||
|
"unsloth/Qwen3.8-27B-Q6_K",
|
||||||
|
"unsloth/Qwen3.8-27B-Q4_K_M",
|
||||||
|
"qwen3.6-plus",
|
||||||
|
"GLM-5.3-flash"
|
||||||
|
],
|
||||||
"providers": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -158,8 +180,11 @@
|
|||||||
},
|
},
|
||||||
"auto-image": {
|
"auto-image": {
|
||||||
"name": "图片生成",
|
"name": "图片生成",
|
||||||
"description": "文生图,自动选择生图模型",
|
"description": "文生图,按模型列表顺序自动选择",
|
||||||
"capability": "image_gen",
|
"capability": "image_gen",
|
||||||
|
"models": [
|
||||||
|
"Qwen-Image"
|
||||||
|
],
|
||||||
"providers": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -167,8 +192,9 @@
|
|||||||
},
|
},
|
||||||
"auto-voice-out": {
|
"auto-voice-out": {
|
||||||
"name": "语音输出",
|
"name": "语音输出",
|
||||||
"description": "语音合成(TTS),自动选择语音输出模型",
|
"description": "语音合成(TTS),按模型列表顺序自动选择",
|
||||||
"capability": "audio_out",
|
"capability": "audio_out",
|
||||||
|
"models": [],
|
||||||
"providers": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -176,8 +202,9 @@
|
|||||||
},
|
},
|
||||||
"auto-voice-in": {
|
"auto-voice-in": {
|
||||||
"name": "语音输入",
|
"name": "语音输入",
|
||||||
"description": "语音识别(ASR),自动选择语音输入模型",
|
"description": "语音识别(ASR),按模型列表顺序自动选择",
|
||||||
"capability": "audio_in",
|
"capability": "audio_in",
|
||||||
|
"models": [],
|
||||||
"providers": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
@@ -185,12 +212,17 @@
|
|||||||
},
|
},
|
||||||
"auto-video": {
|
"auto-video": {
|
||||||
"name": "视频生成",
|
"name": "视频生成",
|
||||||
"description": "文生视频,自动选择视频生成模型",
|
"description": "文生视频,按模型列表顺序自动选择",
|
||||||
"capability": "video_gen",
|
"capability": "video_gen",
|
||||||
|
"models": [],
|
||||||
"providers": [
|
"providers": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
"strategy": "priority"
|
"strategy": "priority"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"routing_config": {
|
||||||
|
"prefer_cache_model": true,
|
||||||
|
"cache_ttl_seconds": 3600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+140
-102
@@ -8,7 +8,9 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen">
|
<body class="bg-gray-50 min-h-screen">
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-800">Auto配置管理</h1>
|
<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>
|
</div>
|
||||||
<button onclick="showCreateModal()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
|
<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配置
|
<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">
|
<div class="mb-6 p-4 bg-indigo-50 rounded-lg text-sm text-indigo-600">
|
||||||
<i class="ri-information-line mr-1"></i>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 配置列表 -->
|
<!-- 配置列表 -->
|
||||||
@@ -82,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 overflow-y-auto flex-1">
|
<div class="p-6 overflow-y-auto flex-1">
|
||||||
<form id="profileForm">
|
<form id="profileForm" onsubmit="return false;">
|
||||||
<input type="hidden" id="profileId">
|
<input type="hidden" id="profileId">
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<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">
|
<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>
|
<option value="">请选择功能类型...</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1">文本推理 / 视觉能力 / 语音输出 / 语音输入 / 图片生成 / 视频生成</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -116,24 +117,20 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">选择策略</label>
|
<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">
|
<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>
|
<option value="random">随机选择</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">候选提供商(仅列出具备所选能力模型的提供商,可拖拽调整优先级)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<div id="providerList" class="border border-gray-200 rounded-lg p-3 space-y-2 min-h-[100px] bg-gray-50">
|
候选模型(勾选参与 · 拖动调整优先级顺序):
|
||||||
|
<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>
|
<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">勾选参与自动选择的提供商,拖拽调整优先级</p>
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,21 +144,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let providers = [];
|
let allModels = [];
|
||||||
let profiles = [];
|
let profiles = [];
|
||||||
let capabilityDefs = {};
|
let capabilityDefs = {};
|
||||||
|
let draggedRow = null;
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [providersRes, profilesRes, capsRes] = await Promise.all([
|
const [modelsRes, profilesRes, capsRes] = await Promise.all([
|
||||||
fetch('/api/admin/providers'),
|
fetch('/api/admin/models'),
|
||||||
fetch('/api/admin/auto-profiles'),
|
fetch('/api/admin/auto-profiles'),
|
||||||
fetch('/api/admin/capabilities')
|
fetch('/api/admin/capabilities')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
providers = await providersRes.json();
|
allModels = await modelsRes.json();
|
||||||
profiles = await profilesRes.json();
|
profiles = await profilesRes.json();
|
||||||
capabilityDefs = await capsRes.json();
|
capabilityDefs = await capsRes.json();
|
||||||
|
|
||||||
@@ -175,7 +172,13 @@
|
|||||||
Object.entries(capabilityDefs).map(([key, label]) => `<option value="${key}">${label}</option>`).join('');
|
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() {
|
function renderProfiles() {
|
||||||
const container = document.getElementById('profilesList');
|
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>' : ''}
|
${profile.name === 'auto' ? '<span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">默认</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1">
|
<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>
|
<i class="ri-pencil-line"></i>
|
||||||
</button>
|
</button>
|
||||||
${profile.name !== 'auto' ? `
|
${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>
|
<i class="ri-delete-bin-line"></i>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -218,21 +221,20 @@
|
|||||||
<i class="ri-focus-3-line mr-1"></i>${profile.capability_label || profile.capability || 'text'}
|
<i class="ri-focus-3-line mr-1"></i>${profile.capability_label || profile.capability || 'text'}
|
||||||
</span>
|
</span>
|
||||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
||||||
${profile.strategy === 'priority' ? '按优先级' : '随机'}
|
${profile.strategy === 'priority' ? '按顺序' : '随机'}
|
||||||
</span>
|
|
||||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
|
||||||
${profile.provider_details?.length || 0} 个提供商
|
|
||||||
</span>
|
</span>
|
||||||
<span class="px-2 py-1 bg-green-50 text-green-600 text-xs rounded">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap gap-1">
|
<div class="mt-3 space-y-1">
|
||||||
${(profile.matched_models || []).slice(0, 5).map(m =>
|
${(profile.models || []).slice(0, 5).map((m, i) => `
|
||||||
`<span class="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">${m}</span>`
|
<div class="flex items-center gap-2 text-xs">
|
||||||
).join('') || ''}
|
<span class="w-4 h-4 bg-indigo-100 text-indigo-600 rounded flex items-center justify-center font-bold">${i + 1}</span>
|
||||||
${(profile.matched_models?.length || 0) > 5 ?
|
<code class="text-slate-600 truncate">${m}</code>
|
||||||
`<span class="text-xs text-gray-400">+${profile.matched_models.length - 5}</span>` : ''}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,9 +248,8 @@
|
|||||||
document.getElementById('profileForm').reset();
|
document.getElementById('profileForm').reset();
|
||||||
document.getElementById('profileName').disabled = false;
|
document.getElementById('profileName').disabled = false;
|
||||||
document.getElementById('profileCapability').value = '';
|
document.getElementById('profileCapability').value = '';
|
||||||
|
document.getElementById('modelList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
|
||||||
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
|
document.getElementById('modelListHint').textContent = '';
|
||||||
document.getElementById('matchedModels').innerHTML = '<span class="text-gray-400 text-xs">请先选择功能类型</span>';
|
|
||||||
|
|
||||||
document.getElementById('editModal').classList.remove('hidden');
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
document.getElementById('editModal').classList.add('flex');
|
document.getElementById('editModal').classList.add('flex');
|
||||||
@@ -268,8 +269,7 @@
|
|||||||
document.getElementById('profileStrategy').value = profile.strategy;
|
document.getElementById('profileStrategy').value = profile.strategy;
|
||||||
document.getElementById('profileCapability').value = profile.capability || 'text';
|
document.getElementById('profileCapability').value = profile.capability || 'text';
|
||||||
|
|
||||||
renderProviderList(profile.provider_details || [], profile.capability || 'text');
|
renderModelList(profile.capability || 'text', profile.models || []);
|
||||||
renderMatchedModels(profile.matched_models || []);
|
|
||||||
|
|
||||||
document.getElementById('editModal').classList.remove('hidden');
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
document.getElementById('editModal').classList.add('flex');
|
document.getElementById('editModal').classList.add('flex');
|
||||||
@@ -281,91 +281,122 @@
|
|||||||
document.getElementById('editModal').classList.remove('flex');
|
document.getElementById('editModal').classList.remove('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 能力切换时重新渲染提供商列表
|
// 能力切换时重新渲染模型列表
|
||||||
function onCapabilityChange() {
|
function onCapabilityChange() {
|
||||||
const capability = document.getElementById('profileCapability').value;
|
const capability = document.getElementById('profileCapability').value;
|
||||||
if (!capability) {
|
if (!capability) {
|
||||||
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
|
document.getElementById('modelList').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('modelListHint').textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderProviderList([], capability);
|
renderModelList(capability, []);
|
||||||
renderMatchedModels([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供商是否具备该能力
|
// 渲染可拖拽模型列表
|
||||||
function providerHasCapability(provider, capability) {
|
function renderModelList(capability, savedOrder) {
|
||||||
if (!provider.models) return false;
|
const container = document.getElementById('modelList');
|
||||||
return provider.models.some(m => (m.capabilities || []).includes(capability));
|
const avail = modelsForCapability(capability);
|
||||||
}
|
|
||||||
|
|
||||||
function providerMatchedModels(provider, capability) {
|
// 已保存的排前面(注意:保存的是模型名字符串,需映射回对象)
|
||||||
return (provider.models || [])
|
const saved = (savedOrder || []).map(n => avail.find(m => m.name === n)).filter(Boolean);
|
||||||
.filter(m => (m.capabilities || []).includes(capability))
|
const savedNames = saved.map(o => o.name);
|
||||||
.map(m => m.name);
|
const rest = avail.filter(m => !savedNames.includes(m.name));
|
||||||
}
|
const ordered = saved.concat(rest);
|
||||||
|
|
||||||
// 渲染提供商列表(只展示具备所选能力的提供商)
|
if (ordered.length === 0) {
|
||||||
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) {
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-4">
|
<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>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
document.getElementById('modelListHint').textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = capable.map(p => {
|
document.getElementById('modelListHint').textContent = `共 ${ordered.length} 个可挑选模型,已选 ${saved.length} 个`;
|
||||||
const isSelected = selectedIds.includes(p.id) || (selectedIds.includes('*') && p.id !== '*');
|
|
||||||
const matched = providerMatchedModels(p, capability);
|
container.innerHTML = ordered.map(m => `
|
||||||
const allSelected = selectedIds.includes('*');
|
<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' : ''}"
|
||||||
return `
|
data-model="${m.name}"
|
||||||
<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"
|
draggable="true"
|
||||||
data-id="${p.id}" data-priority="${p.priority}">
|
ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"
|
||||||
<i class="ri-drag-move-2 text-gray-400 cursor-grab"></i>
|
ondragover="handleDragOver(event)" ondrop="handleDrop(event)">
|
||||||
<input type="checkbox" class="provider-check w-4 h-4 text-indigo-600 rounded"
|
<i class="ri-drag-move-2 text-gray-400 cursor-grab" title="拖动排序"></i>
|
||||||
id="provider-${p.id}" ${isSelected || allSelected ? 'checked' : ''}
|
<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>
|
||||||
onchange="this.closest('[data-id]').classList.toggle('border-indigo-500', this.checked);this.closest('[data-id]').classList.toggle('bg-indigo-50', this.checked)">
|
<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">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-gray-800 truncate">${p.name}</div>
|
<code class="text-sm text-gray-800 font-medium truncate block">${m.name}</code>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
优先级 ${p.priority} · 匹配模型: ${matched.join(', ') || '无'}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
${p.available ?
|
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-xs">${capabilityDefs[capability] || capability}</span>
|
||||||
'<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>
|
</div>
|
||||||
`;
|
`).join('');
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// 初始化拖拽排序
|
renumber();
|
||||||
new Sortable(container, {
|
}
|
||||||
animation: 150,
|
|
||||||
handle: '.ri-drag-move-2',
|
// 重新编号 + 更新已选计数
|
||||||
ghostClass: 'dragging',
|
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) {
|
||||||
function renderMatchedModels(models) {
|
const row = cb.closest('.model-row');
|
||||||
const container = document.getElementById('matchedModels');
|
if (cb.checked) {
|
||||||
if (!models || models.length === 0) {
|
row.classList.add('border-indigo-400', 'bg-indigo-50/50');
|
||||||
container.innerHTML = '<span class="text-gray-400 text-xs">当前选择下暂无匹配模型</span>';
|
} else {
|
||||||
return;
|
row.classList.remove('border-indigo-400', 'bg-indigo-50/50');
|
||||||
}
|
}
|
||||||
container.innerHTML = models.map(m =>
|
renumber();
|
||||||
`<span class="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">${m}</span>`
|
}
|
||||||
).join('');
|
|
||||||
|
// 原生拖拽排序
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedProviders = [];
|
// 收集勾选的模型(按当前DOM顺序 = 优先级顺序)
|
||||||
document.querySelectorAll('.provider-check:checked').forEach(c => {
|
const models = [];
|
||||||
selectedProviders.push(c.id.replace('provider-', ''));
|
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 = {
|
const data = {
|
||||||
name: profileName,
|
name: profileName,
|
||||||
display_name: displayName || profileName,
|
display_name: displayName || profileName,
|
||||||
description: description,
|
description: description,
|
||||||
strategy: strategy,
|
strategy: strategy,
|
||||||
capability: capability,
|
capability: capability,
|
||||||
providers: selectedProviders.length > 0 ? selectedProviders : ['*']
|
models: models,
|
||||||
|
providers: ['*']
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileId = document.getElementById('profileId').value;
|
const profileId = document.getElementById('profileId').value;
|
||||||
|
|||||||
+2
-1
@@ -249,6 +249,7 @@
|
|||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<div class="message-assistant px-4 py-3 rounded-2xl rounded-bl-md max-w-[80%]">
|
<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>
|
<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>` : ''}
|
${msg.model ? `<div class="text-xs text-gray-400 mt-2">模型: ${msg.model}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +312,7 @@
|
|||||||
currentChatId = data.chat_id;
|
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);
|
container.innerHTML += renderMessage(assistantMsg);
|
||||||
|
|
||||||
// 更新标题
|
// 更新标题
|
||||||
|
|||||||
@@ -84,6 +84,32 @@
|
|||||||
</div>
|
</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-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">
|
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -127,6 +153,45 @@
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 || ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user