新增功能
This commit is contained in:
+218
-13
@@ -49,6 +49,20 @@ CONFIG_KEYS = (
|
||||
"AI_API_BASE",
|
||||
"AI_API_KEY",
|
||||
"AI_MODEL",
|
||||
"GROK_CUSTOMER_SERVICE_ENABLED",
|
||||
"GROK_CUSTOMER_SERVICE_TIMEOUT",
|
||||
"GROK_CUSTOMER_SERVICE_MAX_TURNS",
|
||||
"GROK_CUSTOMER_SERVICE_EFFORT",
|
||||
"GROK_MODEL_ENABLED",
|
||||
"GROK_API_BASE",
|
||||
"GROK_API_KEY",
|
||||
"GROK_MODEL",
|
||||
"GROK_API_BACKEND",
|
||||
"GROK_AUTH_SCHEME",
|
||||
"GROK_DIFY_INPUTS",
|
||||
"GROK_CONTEXT_WINDOW",
|
||||
"GROK_MAX_TOKENS",
|
||||
"GROK_TEMPERATURE",
|
||||
"AI_USE_VISION",
|
||||
"AI_CONTEXT_ENABLED",
|
||||
"AI_CONTEXT_MAX_ROUNDS",
|
||||
@@ -68,6 +82,8 @@ BOOL_KEYS = {
|
||||
"AI_CONTEXT_ENABLED",
|
||||
"AI_COUNTER_INSULT_ENABLED",
|
||||
"AI_MCP_ENABLED",
|
||||
"GROK_MODEL_ENABLED",
|
||||
"GROK_CUSTOMER_SERVICE_ENABLED",
|
||||
}
|
||||
|
||||
|
||||
@@ -100,16 +116,36 @@ def token_hash(token: str) -> str:
|
||||
|
||||
|
||||
def load_initial_config() -> dict[str, Any]:
|
||||
path = SCRIPT_DIR / "ai_settings.json"
|
||||
try:
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
saved = {}
|
||||
saved: Any = {}
|
||||
for path in (
|
||||
SCRIPT_DIR / "ai_settings.local.json",
|
||||
SCRIPT_DIR / "ai_settings.json",
|
||||
):
|
||||
try:
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
if isinstance(saved, dict):
|
||||
break
|
||||
defaults: dict[str, Any] = {
|
||||
"AI_ENABLED": True,
|
||||
"AI_API_BASE": "",
|
||||
"AI_API_KEY": "",
|
||||
"AI_MODEL": "",
|
||||
"GROK_CUSTOMER_SERVICE_ENABLED": True,
|
||||
"GROK_CUSTOMER_SERVICE_TIMEOUT": 180,
|
||||
"GROK_CUSTOMER_SERVICE_MAX_TURNS": 8,
|
||||
"GROK_CUSTOMER_SERVICE_EFFORT": "low",
|
||||
"GROK_MODEL_ENABLED": False,
|
||||
"GROK_API_BASE": "",
|
||||
"GROK_API_KEY": "",
|
||||
"GROK_MODEL": "",
|
||||
"GROK_API_BACKEND": "chat_completions",
|
||||
"GROK_AUTH_SCHEME": "auto",
|
||||
"GROK_DIFY_INPUTS": {},
|
||||
"GROK_CONTEXT_WINDOW": 128000,
|
||||
"GROK_MAX_TOKENS": 8192,
|
||||
"GROK_TEMPERATURE": 0.3,
|
||||
"AI_USE_VISION": False,
|
||||
"AI_CONTEXT_ENABLED": True,
|
||||
"AI_CONTEXT_MAX_ROUNDS": 5,
|
||||
@@ -218,6 +254,30 @@ class Database:
|
||||
admin["id"] if admin else None,
|
||||
),
|
||||
)
|
||||
else:
|
||||
row = db.execute(
|
||||
"SELECT config_json,version FROM model_config WHERE id=1"
|
||||
).fetchone()
|
||||
try:
|
||||
existing = json.loads(row["config_json"]) if row else {}
|
||||
except (ValueError, TypeError):
|
||||
existing = {}
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
migrated = load_initial_config()
|
||||
migrated.update(
|
||||
{key: existing[key] for key in CONFIG_KEYS if key in existing}
|
||||
)
|
||||
if migrated != existing:
|
||||
db.execute(
|
||||
"""UPDATE model_config
|
||||
SET config_json=?,version=?,updated_at=? WHERE id=1""",
|
||||
(
|
||||
json.dumps(migrated, ensure_ascii=False),
|
||||
int(row["version"] if row else 0) + 1,
|
||||
now_text(),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return created
|
||||
|
||||
@@ -1024,13 +1084,28 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
def config_card(
|
||||
user: sqlite3.Row, csrf: str, row: sqlite3.Row, config: dict[str, Any]
|
||||
) -> str:
|
||||
normalized = load_initial_config()
|
||||
normalized.update(
|
||||
{key: config[key] for key in CONFIG_KEYS if key in config}
|
||||
)
|
||||
config = normalized
|
||||
can_edit = user["role"] in ("admin", "operator")
|
||||
esc = lambda key: html.escape(str(config.get(key, "")), quote=True)
|
||||
esc = lambda key: html.escape(
|
||||
"" if config.get(key) is None else str(config.get(key, "")), quote=True
|
||||
)
|
||||
checked = lambda key: " checked" if config.get(key) else ""
|
||||
selected = lambda key, value: " selected" if config.get(key) == value else ""
|
||||
disabled = " disabled" if not can_edit else ""
|
||||
mcp = html.escape(
|
||||
json.dumps(config.get("AI_MCP_SERVERS", []), ensure_ascii=False, indent=2)
|
||||
)
|
||||
dify_inputs = html.escape(
|
||||
json.dumps(
|
||||
config.get("GROK_DIFY_INPUTS", {}),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
submit = (
|
||||
"<div class='actions'><button type='submit'>保存并发布配置</button></div>"
|
||||
if can_edit
|
||||
@@ -1045,11 +1120,23 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
<label class='check'><input type='checkbox' name='AI_USE_VISION' value='1'{checked('AI_USE_VISION')}{disabled}>启用视觉模式</label>
|
||||
<label class='check'><input type='checkbox' name='AI_COUNTER_INSULT_ENABLED' value='1'{checked('AI_COUNTER_INSULT_ENABLED')}{disabled}>启用反辱骂策略</label>
|
||||
<label class='check'><input type='checkbox' name='AI_MCP_ENABLED' value='1'{checked('AI_MCP_ENABLED')}{disabled}>启用 MCP 工具</label>
|
||||
<label class='check'><input type='checkbox' name='GROK_CUSTOMER_SERVICE_ENABLED' value='1'{checked('GROK_CUSTOMER_SERVICE_ENABLED')}{disabled}>启用 Grok Build 本地客服 Agent</label>
|
||||
<label class='check'><input type='checkbox' name='GROK_MODEL_ENABLED' value='1'{checked('GROK_MODEL_ENABLED')}{disabled}>启用 Grok Agent 自有模型(必需)</label>
|
||||
</div><div style='height:24px'></div>
|
||||
<div class='cardhead'><div><h2>模型与身份</h2><div class='muted'>API Key 留空表示保持当前值;页面永不回显密钥。</div></div></div>
|
||||
<div class='cardhead'><div><h2>Grok Build 客服 Agent</h2><div class='muted'>Grok Build 只负责 Agent 调度,回复由下方后台自有模型生成。Agent 只能调用项目内置的受控客服 MCP,不嵌入外部聊天网页,也不具备消息发送能力。</div></div></div>
|
||||
<div class='formgrid'>
|
||||
<div><label>API 地址</label><input name='AI_API_BASE' value='{esc('AI_API_BASE')}' required{disabled}></div>
|
||||
<div><label>模型名称</label><input name='AI_MODEL' value='{esc('AI_MODEL')}'{disabled}></div>
|
||||
<div><label>单次回复超时(秒)</label><input type='number' min='30' max='600' name='GROK_CUSTOMER_SERVICE_TIMEOUT' value='{esc('GROK_CUSTOMER_SERVICE_TIMEOUT')}'{disabled}></div>
|
||||
<div><label>最多 Agent 轮数</label><input type='number' min='2' max='30' name='GROK_CUSTOMER_SERVICE_MAX_TURNS' value='{esc('GROK_CUSTOMER_SERVICE_MAX_TURNS')}'{disabled}></div>
|
||||
<div><label>推理强度</label><select name='GROK_CUSTOMER_SERVICE_EFFORT'{disabled}>
|
||||
<option value='low'{selected('GROK_CUSTOMER_SERVICE_EFFORT', 'low')}>low(低延迟)</option>
|
||||
<option value='medium'{selected('GROK_CUSTOMER_SERVICE_EFFORT', 'medium')}>medium</option>
|
||||
<option value='high'{selected('GROK_CUSTOMER_SERVICE_EFFORT', 'high')}>high</option>
|
||||
</select></div>
|
||||
</div><div style='height:24px'></div>
|
||||
<div class='cardhead'><div><h2>视觉模型与客服身份</h2><div class='muted'>这一组 API 只供可选视觉能力使用,不会作为 Grok Agent 的模型回退。API Key 留空表示保持当前值;页面永不回显密钥。</div></div></div>
|
||||
<div class='formgrid'>
|
||||
<div><label>视觉 API 地址(可选)</label><input name='AI_API_BASE' value='{esc('AI_API_BASE')}'{disabled}></div>
|
||||
<div><label>视觉模型名称</label><input name='AI_MODEL' value='{esc('AI_MODEL')}'{disabled}></div>
|
||||
<div><label>API Key</label><input type='password' name='AI_API_KEY' placeholder='已保存;留空不修改'{disabled}></div>
|
||||
<div><label>客服名称</label><input name='AI_AGENT_NAME' value='{esc('AI_AGENT_NAME')}' required{disabled}></div>
|
||||
<div class='full'><label>机构名称</label><input name='AI_HOSPITAL_NAME' value='{esc('AI_HOSPITAL_NAME')}' required{disabled}></div>
|
||||
@@ -1058,6 +1145,27 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
<div><label>温度</label><input type='number' min='0' max='2' step='.05' name='AI_TEMPERATURE' value='{esc('AI_TEMPERATURE')}'{disabled}></div>
|
||||
<div><label>请求超时(秒)</label><input type='number' min='5' max='600' name='AI_TIMEOUT' value='{esc('AI_TIMEOUT')}'{disabled}></div>
|
||||
</div><div style='height:24px'></div>
|
||||
<div class='cardhead'><div><h2>Grok Agent 自有模型</h2><div class='muted'>这是 Agent 唯一允许使用的模型,不会回退到 Grok/xAI。原生接口直接传递工具调用;Dify Chat Messages 会由桌面端本地适配为 Grok 工具调用协议。远程 Dify 强烈建议使用 HTTPS;API Key 留空表示保持当前值。</div></div></div>
|
||||
<div class='formgrid'>
|
||||
<div><label>自有模型 API 基址</label><input name='GROK_API_BASE' value='{esc('GROK_API_BASE')}' placeholder='https://model.example.com/v1'{disabled}></div>
|
||||
<div><label>自有模型名称</label><input name='GROK_MODEL' value='{esc('GROK_MODEL')}' placeholder='qwen-coder'{disabled}></div>
|
||||
<div><label>自有模型 API Key</label><input type='password' name='GROK_API_KEY' placeholder='已保存;留空不修改'{disabled}></div>
|
||||
<div><label>接口协议</label><select name='GROK_API_BACKEND'{disabled}>
|
||||
<option value='chat_completions'{selected('GROK_API_BACKEND', 'chat_completions')}>OpenAI Chat Completions</option>
|
||||
<option value='responses'{selected('GROK_API_BACKEND', 'responses')}>OpenAI Responses</option>
|
||||
<option value='messages'{selected('GROK_API_BACKEND', 'messages')}>Anthropic Messages</option>
|
||||
<option value='dify'{selected('GROK_API_BACKEND', 'dify')}>Dify Chat Messages(本地工具调用适配)</option>
|
||||
</select></div>
|
||||
<div><label>认证方式</label><select name='GROK_AUTH_SCHEME'{disabled}>
|
||||
<option value='auto'{selected('GROK_AUTH_SCHEME', 'auto')}>自动(Anthropic 官方域名使用 x-api-key)</option>
|
||||
<option value='bearer'{selected('GROK_AUTH_SCHEME', 'bearer')}>Authorization Bearer</option>
|
||||
<option value='x_api_key'{selected('GROK_AUTH_SCHEME', 'x_api_key')}>x-api-key</option>
|
||||
</select></div>
|
||||
<div><label>上下文窗口 tokens</label><input type='number' min='4096' max='2000000' name='GROK_CONTEXT_WINDOW' value='{esc('GROK_CONTEXT_WINDOW')}'{disabled}></div>
|
||||
<div><label>最大输出 tokens</label><input type='number' min='64' max='262144' name='GROK_MAX_TOKENS' value='{esc('GROK_MAX_TOKENS')}'{disabled}></div>
|
||||
<div><label>自有模型温度</label><input type='number' min='0' max='2' step='.05' name='GROK_TEMPERATURE' value='{esc('GROK_TEMPERATURE')}'{disabled}></div>
|
||||
<div class='full'><label>Dify inputs JSON(可选)</label><textarea name='GROK_DIFY_INPUTS' placeholder='{{}}'{disabled}>{dify_inputs}</textarea><div class='tiny'>仅供 Dify 应用固定输入变量;请勿在此填写密钥。其他协议会忽略此项。</div></div>
|
||||
</div><div style='height:24px'></div>
|
||||
<div class='cardhead'><div><h2>MCP 服务器</h2><div class='muted'>填写 JSON 数组,与模型配置一同下发。</div></div></div>
|
||||
<textarea name='AI_MCP_SERVERS'{disabled}>{mcp}</textarea>
|
||||
<div style='max-width:240px;margin-top:12px'><label>单次最多工具轮数</label><input type='number' min='1' max='20' name='AI_MCP_MAX_ROUNDS' value='{esc('AI_MCP_MAX_ROUNDS')}'{disabled}></div>
|
||||
@@ -1101,23 +1209,113 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def validate_config_form(form: dict[str, str], current: dict[str, Any]) -> dict[str, Any]:
|
||||
config = {key: current.get(key) for key in CONFIG_KEYS}
|
||||
config = load_initial_config()
|
||||
config.update({key: current[key] for key in CONFIG_KEYS if key in current})
|
||||
for key in BOOL_KEYS:
|
||||
config[key] = form.get(key) == "1"
|
||||
for key in ("AI_API_BASE", "AI_MODEL", "AI_AGENT_NAME", "AI_HOSPITAL_NAME"):
|
||||
for key in (
|
||||
"AI_API_BASE",
|
||||
"AI_MODEL",
|
||||
"AI_AGENT_NAME",
|
||||
"AI_HOSPITAL_NAME",
|
||||
"GROK_API_BASE",
|
||||
"GROK_MODEL",
|
||||
):
|
||||
config[key] = form.get(key, "").strip()
|
||||
api_key = form.get("AI_API_KEY", "").strip()
|
||||
if api_key:
|
||||
config["AI_API_KEY"] = api_key
|
||||
if not config["AI_API_BASE"]:
|
||||
raise ValueError("API 地址不能为空")
|
||||
grok_api_key = form.get("GROK_API_KEY", "").strip()
|
||||
if grok_api_key:
|
||||
config["GROK_API_KEY"] = grok_api_key
|
||||
if config["AI_USE_VISION"] and not config["AI_API_BASE"]:
|
||||
raise ValueError("启用视觉模式时,视觉 API 地址不能为空")
|
||||
if not config["AI_AGENT_NAME"] or not config["AI_HOSPITAL_NAME"]:
|
||||
raise ValueError("客服名称和机构名称不能为空")
|
||||
customer_effort = form.get(
|
||||
"GROK_CUSTOMER_SERVICE_EFFORT", "low"
|
||||
).strip().lower()
|
||||
if customer_effort not in {"low", "medium", "high"}:
|
||||
raise ValueError("Grok Build 客服推理强度必须是 low、medium 或 high")
|
||||
config["GROK_CUSTOMER_SERVICE_EFFORT"] = customer_effort
|
||||
backend = form.get("GROK_API_BACKEND", "chat_completions").strip()
|
||||
if backend not in {"chat_completions", "responses", "messages", "dify"}:
|
||||
raise ValueError("Grok Build 接口协议无效")
|
||||
config["GROK_API_BACKEND"] = backend
|
||||
auth_scheme = form.get("GROK_AUTH_SCHEME", "auto").strip()
|
||||
if auth_scheme not in {"auto", "bearer", "x_api_key"}:
|
||||
raise ValueError("Grok Build 认证方式无效")
|
||||
config["GROK_AUTH_SCHEME"] = auth_scheme
|
||||
raw_dify_inputs = form.get("GROK_DIFY_INPUTS", "{}").strip() or "{}"
|
||||
if len(raw_dify_inputs.encode("utf-8")) > 64 * 1024:
|
||||
raise ValueError("Dify inputs JSON 超过 64 KiB 安全限制")
|
||||
try:
|
||||
dify_inputs = json.loads(raw_dify_inputs)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Dify inputs JSON 格式错误:{exc.msg}") from exc
|
||||
if not isinstance(dify_inputs, dict):
|
||||
raise ValueError("Dify inputs 必须是 JSON 对象")
|
||||
config["GROK_DIFY_INPUTS"] = dify_inputs
|
||||
if config["GROK_CUSTOMER_SERVICE_ENABLED"] and not config["GROK_MODEL_ENABLED"]:
|
||||
raise ValueError(
|
||||
"启用 Grok Build 客服 Agent 时必须同时启用并配置自有模型;"
|
||||
"系统不会回退到 Grok/xAI 模型"
|
||||
)
|
||||
if config["GROK_MODEL_ENABLED"]:
|
||||
if not config["GROK_API_BASE"]:
|
||||
raise ValueError("启用 Grok Agent 自有模型时,API 基址不能为空")
|
||||
if backend == "dify" and not config["GROK_MODEL"]:
|
||||
config["GROK_MODEL"] = "dify-app"
|
||||
elif not config["GROK_MODEL"]:
|
||||
raise ValueError("启用 Grok Agent 自有模型时,模型名称不能为空")
|
||||
if not config.get("GROK_API_KEY"):
|
||||
raise ValueError(
|
||||
"启用 Grok Agent 自有模型时必须配置独立 API Key;"
|
||||
"系统不会使用 xAI 登录凭据"
|
||||
)
|
||||
parsed = urllib.parse.urlsplit(config["GROK_API_BASE"])
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("Grok Build API 基址必须是有效的 http 或 https URL")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("Grok Build API 基址不能包含用户名或密码")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if (
|
||||
hostname == "x.ai"
|
||||
or hostname.endswith(".x.ai")
|
||||
or hostname == "grok.com"
|
||||
or hostname.endswith(".grok.com")
|
||||
):
|
||||
raise ValueError(
|
||||
"Grok Agent 必须使用后台自有模型,不能配置 xAI/Grok 模型端点"
|
||||
)
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ValueError("Grok Build API 基址暂不支持 query 或 fragment")
|
||||
path = parsed.path.rstrip("/").lower()
|
||||
if path.endswith("/chat-messages") and backend != "dify":
|
||||
raise ValueError(
|
||||
"检测到 Dify /chat-messages,请把接口协议选择为 "
|
||||
"Dify Chat Messages(本地工具调用适配)"
|
||||
)
|
||||
if backend == "dify":
|
||||
if auth_scheme == "x_api_key":
|
||||
raise ValueError("Dify Chat Messages 必须使用 Bearer 认证")
|
||||
if any(
|
||||
path.endswith(suffix)
|
||||
for suffix in ("/chat/completions", "/responses", "/messages")
|
||||
):
|
||||
raise ValueError(
|
||||
"Dify API 地址应填写到 /v1 或完整 /v1/chat-messages,"
|
||||
"不能填写 OpenAI/Anthropic 操作路径"
|
||||
)
|
||||
limits = {
|
||||
"AI_CONTEXT_MAX_ROUNDS": (1, 50),
|
||||
"AI_MAX_TOKENS": (50, 32000),
|
||||
"AI_TIMEOUT": (5, 600),
|
||||
"GROK_CUSTOMER_SERVICE_TIMEOUT": (30, 600),
|
||||
"GROK_CUSTOMER_SERVICE_MAX_TURNS": (2, 30),
|
||||
"AI_MCP_MAX_ROUNDS": (1, 20),
|
||||
"GROK_CONTEXT_WINDOW": (4096, 2_000_000),
|
||||
"GROK_MAX_TOKENS": (64, 262_144),
|
||||
}
|
||||
for key, (minimum, maximum) in limits.items():
|
||||
try:
|
||||
@@ -1134,6 +1332,13 @@ def validate_config_form(form: dict[str, str], current: dict[str, Any]) -> dict[
|
||||
if not 0 <= temperature <= 2:
|
||||
raise ValueError("温度必须在 0-2 之间")
|
||||
config["AI_TEMPERATURE"] = temperature
|
||||
try:
|
||||
grok_temperature = float(form.get("GROK_TEMPERATURE", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError("Grok Agent 自有模型温度必须是数字") from exc
|
||||
if not 0 <= grok_temperature <= 2:
|
||||
raise ValueError("Grok Agent 自有模型温度必须在 0-2 之间")
|
||||
config["GROK_TEMPERATURE"] = grok_temperature
|
||||
try:
|
||||
servers = json.loads(form.get("AI_MCP_SERVERS", "[]") or "[]")
|
||||
except ValueError as exc:
|
||||
|
||||
Reference in New Issue
Block a user