feat: 添加聊天最大输出 Token 和流式输出选项,优化 AI 聊天体验

This commit is contained in:
2026-03-14 12:48:31 +08:00
parent 21ad22a105
commit f97fad81e6
5 changed files with 694 additions and 50 deletions

364
app.py
View File

@@ -221,6 +221,8 @@ AI_SETTINGS_DEFAULT = {
), ),
"api_key": os.environ.get("SILICONFLOW_API_KEY", ""), "api_key": os.environ.get("SILICONFLOW_API_KEY", ""),
"timeout": int(os.environ.get("SILICONFLOW_TIMEOUT", "30") or "30"), "timeout": int(os.environ.get("SILICONFLOW_TIMEOUT", "30") or "30"),
"chat_max_tokens": int(os.environ.get("SILICONFLOW_CHAT_MAX_TOKENS", "4096") or "4096"),
"chat_stream_enabled": True,
"restock_threshold": LOW_STOCK_THRESHOLD, "restock_threshold": LOW_STOCK_THRESHOLD,
"restock_limit": 24, "restock_limit": 24,
"lcsc_timeout": int(os.environ.get("LCSC_TIMEOUT", "20") or "20"), "lcsc_timeout": int(os.environ.get("LCSC_TIMEOUT", "20") or "20"),
@@ -426,6 +428,11 @@ def _get_ai_settings() -> dict:
except (TypeError, ValueError): except (TypeError, ValueError):
settings["timeout"] = 30 settings["timeout"] = 30
try:
settings["chat_max_tokens"] = max(256, int(settings.get("chat_max_tokens", 4096)))
except (TypeError, ValueError):
settings["chat_max_tokens"] = 4096
try: try:
settings["restock_threshold"] = max(0, int(settings.get("restock_threshold", LOW_STOCK_THRESHOLD))) settings["restock_threshold"] = max(0, int(settings.get("restock_threshold", LOW_STOCK_THRESHOLD)))
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -449,6 +456,7 @@ def _get_ai_settings() -> dict:
settings["lcsc_app_id"] = (settings.get("lcsc_app_id") or "").strip() settings["lcsc_app_id"] = (settings.get("lcsc_app_id") or "").strip()
settings["lcsc_access_key"] = (settings.get("lcsc_access_key") or "").strip() settings["lcsc_access_key"] = (settings.get("lcsc_access_key") or "").strip()
settings["lcsc_secret_key"] = (settings.get("lcsc_secret_key") or "").strip() settings["lcsc_secret_key"] = (settings.get("lcsc_secret_key") or "").strip()
settings["chat_stream_enabled"] = bool(settings.get("chat_stream_enabled", True))
settings["lock_storage_mode"] = bool(settings.get("lock_storage_mode", False)) settings["lock_storage_mode"] = bool(settings.get("lock_storage_mode", False))
return settings return settings
@@ -2928,6 +2936,7 @@ def _call_siliconflow_chat(
model: str, model: str,
api_key: str, api_key: str,
timeout: int, timeout: int,
max_tokens: int = 4096, # 默认 4096SQL规划等短回复场景可传 700
) -> str: ) -> str:
api_key = (api_key or "").strip() api_key = (api_key or "").strip()
if not api_key: if not api_key:
@@ -2940,7 +2949,7 @@ def _call_siliconflow_chat(
payload = { payload = {
"model": model, "model": model,
"temperature": 0.2, "temperature": 0.2,
"max_tokens": 700, "max_tokens": max_tokens,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
@@ -2975,6 +2984,74 @@ def _call_siliconflow_chat(
raise RuntimeError("AI 返回格式无法解析") from exc raise RuntimeError("AI 返回格式无法解析") from exc
def _call_siliconflow_chat_stream(
system_prompt: str,
user_prompt: str,
*,
api_url: str,
model: str,
api_key: str,
timeout: int,
max_tokens: int = 4096,
):
"""流式调用 SiliconFlow chat API按 SSE 协议逐片 yield 文本内容。"""
api_key = (api_key or "").strip()
if not api_key:
raise RuntimeError("SILICONFLOW_API_KEY 未配置")
if not api_url:
raise RuntimeError("AI API URL 未配置")
if not model:
raise RuntimeError("AI 模型名称未配置")
payload = {
"model": model,
"temperature": 0.2,
"max_tokens": max_tokens,
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
api_url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)
try:
resp = urllib.request.urlopen(req, timeout=timeout)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"AI 服务返回 HTTP {exc.code}: {detail[:200]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"AI 服务连接失败: {exc.reason}") from exc
except (TimeoutError, socket.timeout) as exc:
raise RuntimeError(f"AI 服务连接超时(>{timeout}秒)") from exc
try:
# HTTP 响应逐行读取,每行格式: "data: {...}" 或 "data: [DONE]"
for line_bytes in resp:
line = line_bytes.decode("utf-8").rstrip("\r\n")
if not line or line == "data: [DONE]":
continue
if line.startswith("data: "):
data_str = line[6:]
try:
chunk_obj = json.loads(data_str)
delta = chunk_obj["choices"][0]["delta"].get("content") or ""
if delta:
yield delta
except Exception:
pass
finally:
resp.close()
DB_CHAT_ALLOWED_TABLES = {"boxes", "components", "inventory_events"} DB_CHAT_ALLOWED_TABLES = {"boxes", "components", "inventory_events"}
DB_CHAT_FORBIDDEN_KEYWORDS = { DB_CHAT_FORBIDDEN_KEYWORDS = {
"insert", "insert",
@@ -3113,6 +3190,7 @@ def _build_db_chat_sql_plan(
model=settings["model"], model=settings["model"],
api_key=settings["api_key"], api_key=settings["api_key"],
timeout=settings["timeout"], timeout=settings["timeout"],
max_tokens=700, # SQL规划只需短 JSON 输出
) )
parsed = json.loads(_extract_json_object_text(raw_text)) parsed = json.loads(_extract_json_object_text(raw_text))
sql = str(parsed.get("sql", "") or "").strip() sql = str(parsed.get("sql", "") or "").strip()
@@ -3320,6 +3398,7 @@ def _build_db_chat_answer(
if web_context: if web_context:
user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False) user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False)
chat_max_tokens = int(settings.get("chat_max_tokens", 4096) or 4096)
return _call_siliconflow_chat( return _call_siliconflow_chat(
system_prompt, system_prompt,
user_prompt, user_prompt,
@@ -3327,6 +3406,51 @@ def _build_db_chat_answer(
model=settings["model"], model=settings["model"],
api_key=settings["api_key"], api_key=settings["api_key"],
timeout=settings["timeout"], timeout=settings["timeout"],
max_tokens=chat_max_tokens,
)
def _build_db_chat_answer_stream(
question: str,
sql: str,
sql_reason: str,
rows: list[dict],
web_context: list[dict],
settings: dict,
):
"""流式版数据库查询回答,逐片 yield 文本内容。"""
if not rows:
yield "查询已执行,但没有匹配数据。你可以换个条件,例如指定料号、时间范围或盒型。"
return
system_prompt = (
"你是库存分析助手。"
"请仅根据提供的 SQL 结果回答,禁止虚构不存在的数据。"
"回答用简明中文,优先给结论,再给关键证据。"
"若提供了联网参考,请明确标注为参考信息,不得当作数据库事实。"
)
user_prompt = (
"用户问题:\n"
+ question
+ "\n\n执行SQL:\n"
+ sql
+ "\n\nSQL说明:\n"
+ sql_reason
+ "\n\n查询结果(JSON):\n"
+ json.dumps(rows, ensure_ascii=False)
)
if web_context:
user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False)
chat_max_tokens = int(settings.get("chat_max_tokens", 4096) or 4096)
yield from _call_siliconflow_chat_stream(
system_prompt,
user_prompt,
api_url=settings["api_url"],
model=settings["model"],
api_key=settings["api_key"],
timeout=settings["timeout"],
max_tokens=chat_max_tokens,
) )
@@ -3405,6 +3529,7 @@ def _build_general_chat_answer(
if web_context: if web_context:
user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False) user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False)
chat_max_tokens = int(settings.get("chat_max_tokens", 4096) or 4096)
return _call_siliconflow_chat( return _call_siliconflow_chat(
system_prompt, system_prompt,
user_prompt, user_prompt,
@@ -3412,12 +3537,68 @@ def _build_general_chat_answer(
model=settings["model"], model=settings["model"],
api_key=settings["api_key"], api_key=settings["api_key"],
timeout=settings["timeout"], timeout=settings["timeout"],
max_tokens=chat_max_tokens,
)
def _build_general_chat_answer_stream(
question: str,
history: list[dict],
web_context: list[dict],
memory_text: str,
settings: dict,
):
"""流式版通用聊天回答,逐片 yield 文本内容。"""
q = (question or "").strip()
q_lower = q.lower()
# 模型名询问直接返回,无需调 API
if "模型" in q or "model" in q_lower:
model_name = str(settings.get("model", "") or "未配置")
yield f"当前系统配置的 AI 模型是:{model_name}"
return
history_lines = []
for row in history[-8:]:
role = str(row.get("role", "user") or "user")
content = str(row.get("content", "") or "").strip()
if not content:
continue
history_lines.append(f"[{role}] {content[:220]}")
system_prompt = (
"你是系统内置通用助手。"
"请直接回答用户问题,不要把自己限制为仅库存问题。"
"对于时效性问题(如天气、新闻、实时价格),若提供了联网参考就优先依据参考;"
"若没有联网参考,需明确说明可能不够实时。"
"回答保持简洁中文。"
)
user_prompt = (
"对话历史:\n"
+ "\n".join(history_lines or ["(无)"])
+ "\n\n用户当前问题:\n"
+ question
)
if memory_text:
user_prompt += "\n\n本地记忆摘要:\n" + memory_text
if web_context:
user_prompt += "\n\n联网参考(JSON):\n" + json.dumps(web_context, ensure_ascii=False)
chat_max_tokens = int(settings.get("chat_max_tokens", 4096) or 4096)
yield from _call_siliconflow_chat_stream(
system_prompt,
user_prompt,
api_url=settings["api_url"],
model=settings["model"],
api_key=settings["api_key"],
timeout=settings["timeout"],
max_tokens=chat_max_tokens,
) )
@app.route("/ai/chat") @app.route("/ai/chat")
def ai_chat_page(): def ai_chat_page():
return render_template("ai_chat.html") settings = _get_ai_settings()
return render_template("ai_chat.html", settings=settings)
@app.route("/ai/chat/memory/clear", methods=["POST"]) @app.route("/ai/chat/memory/clear", methods=["POST"])
@@ -3644,6 +3825,171 @@ def ai_chat_query():
} }
# ──────────────────────────────────────────────────────────────────────────────
# SSE 流式聊天接口 /ai/chat/stream
# 与 ai_chat_query 执行相同的预处理逻辑,但最终 AI 回答改为流式 SSE 输出
# ──────────────────────────────────────────────────────────────────────────────
@app.route("/ai/chat/stream", methods=["POST"])
def ai_chat_stream():
"""SSE 流式聊天接口,先同步完成预处理(联网/SQL再以 SSE 逐片推送 AI 回答。"""
payload = request.get_json(silent=True) or {}
question = str(payload.get("question", "") or "").strip()
history = payload.get("history", [])
allow_web_search = _is_truthy_form_value(str(payload.get("allow_web_search", "")))
allow_db_query = _is_truthy_form_value(str(payload.get("allow_db_query", "")))
if not isinstance(history, list):
history = []
_SSE_HEADERS = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # 防止 Nginx 缓冲
"Connection": "keep-alive",
}
def _sse_err(msg: str):
"""单条错误事件的 Response"""
evt = json.dumps({"type": "error", "message": msg}, ensure_ascii=False)
return Response(iter([f"data: {evt}\n\n"]), mimetype="text/event-stream", headers=_SSE_HEADERS)
if not question:
return _sse_err("请输入问题")
username = (session.get("username") or "guest").strip() or "guest"
memory_payload = _get_ai_chat_memory(username)
memory_text = _memory_context_text(memory_payload)
settings = _get_ai_settings()
if not settings.get("api_key") or not settings.get("api_url") or not settings.get("model"):
return _sse_err("AI 参数不完整,请先到参数页配置")
if not bool(settings.get("chat_stream_enabled", True)):
return _sse_err("当前已关闭流式输出,请在 AI 参数页开启后重试")
# ── 预处理:与 ai_chat_query 相同逻辑 ────────────────────────────────────
chat_mode = "general"
sql = ""
planner_reason = ""
planner_raw = ""
row_count = 0
web_context = []
serialized_rows = []
answer_gen = None # 最终的流式生成器
if not allow_db_query:
# 通用问答模式
if allow_web_search:
web_context = _build_db_chat_web_context(question, [], timeout=settings.get("timeout", 30), max_queries=1)
if not web_context and ("天气" in question or "weather" in question.lower()):
weather_ctx = _fetch_weather_context(question, timeout=settings.get("timeout", 30))
if weather_ctx:
web_context = [weather_ctx]
planner_reason = "未启用数据库查询,按通用问答模式处理"
chat_mode = "general"
answer_gen = _build_general_chat_answer_stream(question, history, web_context, memory_text, settings)
else:
# 数据库查询模式
try:
sql_raw, planner_reason, planner_raw = _build_db_chat_sql_plan(question, history, memory_text, settings)
except Exception as exc:
_log_event(logging.WARNING, "ai_chat_stream_plan_error", error=str(exc), question=question)
if not _is_general_chat_question(question):
return _sse_err(f"SQL 规划失败: {exc}")
# 回退通用
if allow_web_search:
web_context = _build_db_chat_web_context(question, [], timeout=settings.get("timeout", 30), max_queries=1)
if not web_context and ("天气" in question or "weather" in question.lower()):
weather_ctx = _fetch_weather_context(question, timeout=settings.get("timeout", 30))
if weather_ctx:
web_context = [weather_ctx]
planner_reason = "已切换到通用对话模式"
chat_mode = "general"
answer_gen = _build_general_chat_answer_stream(question, history, web_context, memory_text, settings)
else:
safe, reject_reason, safe_sql = _is_safe_readonly_sql(sql_raw)
if not safe:
_log_event(logging.WARNING, "ai_chat_stream_sql_rejected", reason=reject_reason, sql=sql_raw)
can_fallback = "未包含可识别的数据表" in reject_reason or _is_general_chat_question(question)
if not can_fallback:
return _sse_err(f"SQL 被安全策略拒绝: {reject_reason}")
# 回退通用
if allow_web_search:
web_context = _build_db_chat_web_context(question, [], timeout=settings.get("timeout", 30), max_queries=1)
if not web_context and ("天气" in question or "weather" in question.lower()):
weather_ctx = _fetch_weather_context(question, timeout=settings.get("timeout", 30))
if weather_ctx:
web_context = [weather_ctx]
planner_reason = "已切换到通用对话模式"
chat_mode = "general"
answer_gen = _build_general_chat_answer_stream(question, history, web_context, memory_text, settings)
else:
# 执行 SQL需要在路由函数里做生成器无 db.session 保证)
final_sql = _ensure_query_limit(safe_sql, row_limit=80)
sql = final_sql
try:
query_result = db.session.execute(db.text(final_sql))
columns = list(query_result.keys())
rows_data = query_result.fetchall()
serialized_rows = _serialize_sql_rows(rows_data, columns)
row_count = len(serialized_rows)
except Exception as exc:
_log_event(logging.ERROR, "ai_chat_stream_exec_error", error=str(exc), sql=final_sql)
return _sse_err("SQL 执行失败,请调整问题后重试")
if allow_web_search:
try:
web_context = _build_db_chat_web_context(question, serialized_rows, timeout=settings.get("timeout", 30))
except Exception:
web_context = []
chat_mode = "db"
answer_gen = _build_db_chat_answer_stream(question, final_sql, planner_reason, serialized_rows, web_context, settings)
# ── SSE 生成器 ────────────────────────────────────────────────────────────
meta = {
"type": "meta",
"sql": sql,
"planner_reason": planner_reason,
"planner_raw": planner_raw,
"row_count": row_count,
"rows_preview": serialized_rows[:8],
"web_context": web_context,
"chat_mode": chat_mode,
"allow_web_search": allow_web_search,
"allow_db_query": allow_db_query,
}
def _generate():
# 先推送元数据SQL、模式、联网来源等
yield f"data: {json.dumps(meta, ensure_ascii=False)}\n\n"
accumulated = []
try:
for chunk in answer_gen:
accumulated.append(chunk)
evt = json.dumps({"type": "chunk", "text": chunk}, ensure_ascii=False)
yield f"data: {evt}\n\n"
except Exception as exc:
err_evt = json.dumps({"type": "error", "message": str(exc)}, ensure_ascii=False)
yield f"data: {err_evt}\n\n"
return
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
# 流结束后保存记忆
full_answer = "".join(accumulated)
_append_ai_chat_memory(username, question, full_answer)
_log_event(
logging.INFO,
"ai_chat_stream_success",
question=question,
chat_mode=chat_mode,
allow_web_search=allow_web_search,
allow_db_query=allow_db_query,
web_sources=sum(len(item.get("sources", [])) for item in web_context),
)
return Response(_generate(), mimetype="text/event-stream", headers=_SSE_HEADERS)
def _is_safe_next_path(path: str) -> bool: def _is_safe_next_path(path: str) -> bool:
candidate = (path or "").strip() candidate = (path or "").strip()
if not candidate: if not candidate:
@@ -5302,6 +5648,7 @@ def ai_settings_page():
api_url = request.form.get("api_url", "").strip() api_url = request.form.get("api_url", "").strip()
model = request.form.get("model", "").strip() model = request.form.get("model", "").strip()
api_key = request.form.get("api_key", "").strip() api_key = request.form.get("api_key", "").strip()
chat_stream_enabled = _is_truthy_form_value(request.form.get("chat_stream_enabled", ""))
lcsc_app_id = request.form.get("lcsc_app_id", "").strip() lcsc_app_id = request.form.get("lcsc_app_id", "").strip()
lcsc_access_key = request.form.get("lcsc_access_key", "").strip() lcsc_access_key = request.form.get("lcsc_access_key", "").strip()
lcsc_secret_key = request.form.get("lcsc_secret_key", "").strip() lcsc_secret_key = request.form.get("lcsc_secret_key", "").strip()
@@ -5315,6 +5662,15 @@ def ai_settings_page():
error = "超时时间必须是大于等于 5 的整数" error = "超时时间必须是大于等于 5 的整数"
timeout = settings["timeout"] timeout = settings["timeout"]
try:
chat_max_tokens = int((request.form.get("chat_max_tokens", "4096") or "4096").strip())
if chat_max_tokens < 256:
raise ValueError
except ValueError:
if not error:
error = "聊天最大输出 token 必须是大于等于 256 的整数"
chat_max_tokens = settings["chat_max_tokens"]
try: try:
restock_threshold = int((request.form.get("restock_threshold", "5") or "5").strip()) restock_threshold = int((request.form.get("restock_threshold", "5") or "5").strip())
if restock_threshold < 0: if restock_threshold < 0:
@@ -5355,6 +5711,8 @@ def ai_settings_page():
"model": model, "model": model,
"api_key": api_key, "api_key": api_key,
"timeout": timeout, "timeout": timeout,
"chat_max_tokens": chat_max_tokens,
"chat_stream_enabled": chat_stream_enabled,
"restock_threshold": restock_threshold, "restock_threshold": restock_threshold,
"restock_limit": restock_limit, "restock_limit": restock_limit,
"lcsc_base_url": LCSC_BASE_URL, "lcsc_base_url": LCSC_BASE_URL,
@@ -5374,6 +5732,8 @@ def ai_settings_page():
"model": model, "model": model,
"api_key": api_key, "api_key": api_key,
"timeout": timeout, "timeout": timeout,
"chat_max_tokens": chat_max_tokens,
"chat_stream_enabled": chat_stream_enabled,
"restock_threshold": restock_threshold, "restock_threshold": restock_threshold,
"restock_limit": restock_limit, "restock_limit": restock_limit,
"lcsc_base_url": LCSC_BASE_URL, "lcsc_base_url": LCSC_BASE_URL,

View File

@@ -3,6 +3,8 @@
"model": "Pro/moonshotai/Kimi-K2.5", "model": "Pro/moonshotai/Kimi-K2.5",
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo", "api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
"timeout": 120, "timeout": 120,
"chat_max_tokens": 4096,
"chat_stream_enabled": true,
"restock_threshold": 2, "restock_threshold": 2,
"restock_limit": 24, "restock_limit": 24,
"lcsc_base_url": "https://open-api.jlc.com", "lcsc_base_url": "https://open-api.jlc.com",

View File

@@ -667,6 +667,38 @@ body {
padding-left: 20px; padding-left: 20px;
} }
.md-content .md-table-wrap {
width: 100%;
overflow-x: auto;
margin: 10px 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 92%, var(--card-alt));
}
.md-content table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
}
.md-content th,
.md-content td {
border-bottom: 1px solid var(--line);
padding: 8px 10px;
vertical-align: top;
text-align: left;
}
.md-content thead th {
background: color-mix(in srgb, var(--card-alt) 88%, var(--line));
font-weight: 700;
}
.md-content tbody tr:last-child td {
border-bottom: 0;
}
.md-content li { .md-content li {
margin: 4px 0; margin: 4px 0;
} }
@@ -693,6 +725,31 @@ body {
text-decoration: underline; text-decoration: underline;
} }
.md-content .md-math-block {
margin: 10px 0;
padding: 8px 10px;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--card) 94%, var(--card-alt));
overflow-x: auto;
}
.md-content .katex-display {
margin: 0.6em 0;
}
/* 打字光标:流式输出时显示的闪烁竖线 */
.ai-typing-cursor {
display: inline-block;
width: 2px;
background: currentColor;
margin-left: 1px;
vertical-align: text-bottom;
animation: ai-cursor-blink 0.9s step-end infinite;
}
@keyframes ai-cursor-blink {
50% { opacity: 0; }
}
.ai-chat-form-wrap { .ai-chat-form-wrap {
border-top: 1px dashed var(--line); border-top: 1px dashed var(--line);
padding-top: var(--space-2); padding-top: var(--space-2);

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI数据库聊天</title> <title>AI数据库聊天</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
</head> </head>
<body> <body>
<header class="hero slim"> <header class="hero slim">
@@ -21,6 +24,7 @@
</header> </header>
<main class="container"> <main class="container">
<div id="ai-chat-config" data-stream-enabled="{{ '1' if settings.chat_stream_enabled else '0' }}" hidden></div>
<section class="panel ai-chat-shell"> <section class="panel ai-chat-shell">
<div class="ai-chat-messages" id="ai-chat-messages"></div> <div class="ai-chat-messages" id="ai-chat-messages"></div>
<div class="ai-chat-form-wrap"> <div class="ai-chat-form-wrap">
@@ -53,7 +57,9 @@
var dbQueryToggle = document.getElementById('ai-chat-db-query'); var dbQueryToggle = document.getElementById('ai-chat-db-query');
var statusNode = document.getElementById('ai-chat-status'); var statusNode = document.getElementById('ai-chat-status');
var messagesNode = document.getElementById('ai-chat-messages'); var messagesNode = document.getElementById('ai-chat-messages');
var configNode = document.getElementById('ai-chat-config');
var history = []; var history = [];
var streamEnabled = !!(configNode && configNode.getAttribute('data-stream-enabled') === '1');
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '').replace(/[&<>"']/g, function (ch) { return String(text || '').replace(/[&<>"']/g, function (ch) {
@@ -69,9 +75,44 @@
return html; return html;
} }
function splitTableRow(line) {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(function (cell) { return cell.trim(); });
}
function isTableDelimiterLine(line) {
var cells = splitTableRow(line);
if (!cells.length) {
return false;
}
return cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderMathInNode(node) {
if (!node || typeof window.renderMathInElement !== 'function') {
return;
}
window.renderMathInElement(node, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false }
],
throwOnError: false,
strict: 'ignore',
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
});
}
function renderMarkdown(text) { function renderMarkdown(text) {
var source = String(text || '').replace(/\r\n?/g, '\n'); var source = String(text || '').replace(/\r\n?/g, '\n');
var codeBlocks = []; var codeBlocks = [];
var mathBlocks = [];
source = source.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, function (_, lang, code) { source = source.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var language = escapeHtml(lang || 'text'); var language = escapeHtml(lang || 'text');
@@ -81,12 +122,68 @@
return token; return token;
}); });
source = source.replace(/\$\$([\s\S]*?)\$\$/g, function (_, expr) {
var token = '@@MATHBLOCK_' + mathBlocks.length + '@@';
mathBlocks.push('<div class="md-math-block">$$' + escapeHtml((expr || '').trim()) + '$$</div>');
return token;
});
var escaped = escapeHtml(source); var escaped = escapeHtml(source);
var lines = escaped.split('\n'); var lines = escaped.split('\n');
var htmlParts = []; var htmlParts = [];
var paragraph = []; var paragraph = [];
var inList = false; var inList = false;
function flushTableFrom(startIdx) {
var headerCells = splitTableRow(lines[startIdx]);
var alignCells = splitTableRow(lines[startIdx + 1]);
var tableHtml = ['<div class="md-table-wrap"><table><thead><tr>'];
headerCells.forEach(function (cell, idx) {
var align = alignCells[idx] || '';
var style = '';
if (/^:-+:$/.test(align)) {
style = ' style="text-align:center"';
} else if (/^-+:$/.test(align)) {
style = ' style="text-align:right"';
} else if (/^:-+$/.test(align)) {
style = ' style="text-align:left"';
}
tableHtml.push('<th' + style + '>' + renderInlineMarkdown(cell) + '</th>');
});
tableHtml.push('</tr></thead><tbody>');
var idx = startIdx + 2;
while (idx < lines.length) {
var rowLine = (lines[idx] || '').trim();
if (!rowLine || rowLine.indexOf('|') === -1) {
break;
}
var rowCells = splitTableRow(lines[idx]);
if (rowCells.length < 2) {
break;
}
tableHtml.push('<tr>');
rowCells.forEach(function (cell, cidx) {
var align = alignCells[cidx] || '';
var style = '';
if (/^:-+:$/.test(align)) {
style = ' style="text-align:center"';
} else if (/^-+:$/.test(align)) {
style = ' style="text-align:right"';
} else if (/^:-+$/.test(align)) {
style = ' style="text-align:left"';
}
tableHtml.push('<td' + style + '>' + renderInlineMarkdown(cell) + '</td>');
});
tableHtml.push('</tr>');
idx += 1;
}
tableHtml.push('</tbody></table></div>');
htmlParts.push(tableHtml.join(''));
return idx;
}
function flushParagraph() { function flushParagraph() {
if (!paragraph.length) { if (!paragraph.length) {
return; return;
@@ -102,12 +199,26 @@
} }
} }
lines.forEach(function (line) { var i = 0;
while (i < lines.length) {
var line = lines[i];
var trimmed = line.trim(); var trimmed = line.trim();
if (!trimmed) { if (!trimmed) {
flushParagraph(); flushParagraph();
closeList(); closeList();
return; i += 1;
continue;
}
var isTableStart = i + 1 < lines.length
&& trimmed.indexOf('|') !== -1
&& (lines[i + 1] || '').trim().indexOf('|') !== -1
&& isTableDelimiterLine(lines[i + 1]);
if (isTableStart) {
flushParagraph();
closeList();
i = flushTableFrom(i);
continue;
} }
var heading = trimmed.match(/^(#{1,3})\s+(.+)$/); var heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
@@ -116,7 +227,8 @@
closeList(); closeList();
var level = heading[1].length; var level = heading[1].length;
htmlParts.push('<h' + level + '>' + renderInlineMarkdown(heading[2]) + '</h' + level + '>'); htmlParts.push('<h' + level + '>' + renderInlineMarkdown(heading[2]) + '</h' + level + '>');
return; i += 1;
continue;
} }
var listItem = trimmed.match(/^[-*]\s+(.+)$/); var listItem = trimmed.match(/^[-*]\s+(.+)$/);
@@ -127,12 +239,14 @@
inList = true; inList = true;
} }
htmlParts.push('<li>' + renderInlineMarkdown(listItem[1]) + '</li>'); htmlParts.push('<li>' + renderInlineMarkdown(listItem[1]) + '</li>');
return; i += 1;
continue;
} }
closeList(); closeList();
paragraph.push(trimmed); paragraph.push(trimmed);
}); i += 1;
}
flushParagraph(); flushParagraph();
closeList(); closeList();
@@ -141,6 +255,9 @@
codeBlocks.forEach(function (block, index) { codeBlocks.forEach(function (block, index) {
html = html.replace('@@CODEBLOCK_' + index + '@@', block); html = html.replace('@@CODEBLOCK_' + index + '@@', block);
}); });
mathBlocks.forEach(function (block, index) {
html = html.replace('@@MATHBLOCK_' + index + '@@', block);
});
return html || '<p></p>'; return html || '<p></p>';
} }
@@ -155,6 +272,9 @@
bodyHtml + bodyHtml +
(extraHtml || ''); (extraHtml || '');
messagesNode.appendChild(card); messagesNode.appendChild(card);
if (role === 'assistant') {
renderMathInNode(card.querySelector('.md-content'));
}
messagesNode.scrollTop = messagesNode.scrollHeight; messagesNode.scrollTop = messagesNode.scrollHeight;
} }
@@ -207,6 +327,28 @@
return '<details class="box-overview"><summary>联网来源明细</summary><div class="ai-source-list">' + blocks + '</div></details>'; return '<details class="box-overview"><summary>联网来源明细</summary><div class="ai-source-list">' + blocks + '</div></details>';
} }
function buildExtraHtml(data) {
var extra = '';
if (data.sql) {
extra += '<details class="box-overview"><summary>本次SQL</summary><pre class="ai-panel-content">' + escapeHtml(data.sql) + '</pre></details>';
}
if (data.planner_reason && data.chat_mode !== 'general') {
extra += '<p class="hint">SQL思路: ' + escapeHtml(data.planner_reason) + '</p>';
}
if (typeof data.row_count === 'number' && data.chat_mode !== 'general') {
extra += '<p class="hint">返回行数: ' + data.row_count + '</p>';
}
if (data.chat_mode === 'general') {
extra += '<p class="hint">模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '</p>';
} else if (data.allow_web_search) {
extra += '<p class="hint">模式: 数据库 + 联网补充</p>';
} else {
extra += '<p class="hint">模式: 仅数据库</p>';
}
extra += renderWebContextHtml(data.web_context || []);
return extra;
}
sendBtn.addEventListener('click', function () { sendBtn.addEventListener('click', function () {
var question = (input.value || '').trim(); var question = (input.value || '').trim();
if (!question) { if (!question) {
@@ -218,69 +360,144 @@
input.value = ''; input.value = '';
var useWeb = !!(webSearchToggle && webSearchToggle.checked); var useWeb = !!(webSearchToggle && webSearchToggle.checked);
var useDb = !!(dbQueryToggle && dbQueryToggle.checked); var useDb = !!(dbQueryToggle && dbQueryToggle.checked);
var requestBody = {
question: question,
history: toHistoryPayload(),
allow_web_search: useWeb,
allow_db_query: useDb
};
if (useDb && useWeb) { if (useDb && useWeb) {
statusNode.textContent = 'AI 正在查询数据库并联网补充,请稍候...'; statusNode.textContent = 'AI 正在查询数据库并联网补充,请稍候...';
} else if (useDb) { } else if (useDb) {
statusNode.textContent = 'AI 正在查询数据库,请稍候...'; statusNode.textContent = 'AI 正在查询数据库,请稍候...';
} else if (useWeb) { } else if (useWeb) {
statusNode.textContent = 'AI 正在通用问答并联网补充,请稍候...'; statusNode.textContent = streamEnabled
? 'AI 正在通用问答并联网补充(流式输出中)...'
: 'AI 正在通用问答并联网补充,请稍候...';
} else { } else {
statusNode.textContent = 'AI 正在通用问答,请稍候...'; statusNode.textContent = streamEnabled
? 'AI 正在通用问答(流式输出中)...'
: 'AI 正在通用问答,请稍候...';
} }
appendMessage('user', '你', question, ''); appendMessage('user', '你', question, '');
fetch('{{ url_for("ai_chat_query") }}', { if (!streamEnabled) {
fetch('{{ url_for("ai_chat_query") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(function (resp) {
return resp.json().then(function (data) {
return { ok: resp.ok, data: data || {} };
});
})
.then(function (result) {
var data = result.data || {};
if (!result.ok || !data.ok) {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', data.message || '请求失败,请稍后重试。', '');
return;
}
appendMessage('assistant', 'AI助手', data.answer || '已完成查询。', buildExtraHtml(data));
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: data.answer || '' });
statusNode.textContent = '已完成本轮查询。';
})
.catch(function () {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', '网络请求失败,请稍后重试。', '');
})
.finally(function () {
sendBtn.disabled = false;
});
return;
}
// 流式输出模式
var card = document.createElement('article');
card.className = 'ai-chat-item assistant';
var contentDiv = document.createElement('div');
contentDiv.className = 'md-content';
contentDiv.innerHTML = '<span class="ai-typing-cursor">▍</span>';
card.innerHTML = '<h3>AI助手</h3>';
card.appendChild(contentDiv);
messagesNode.appendChild(card);
messagesNode.scrollTop = messagesNode.scrollHeight;
var accumulatedText = '';
var metaData = null;
var sseBuffer = '';
var streamFinished = false;
function processSSELine(line) {
if (!line.startsWith('data: ')) return;
var dataStr = line.slice(6).trim();
if (!dataStr || dataStr === '[DONE]') return;
var evt;
try { evt = JSON.parse(dataStr); } catch (e) { return; }
if (evt.type === 'meta') {
metaData = evt;
} else if (evt.type === 'chunk') {
accumulatedText += (evt.text || '');
contentDiv.innerHTML = escapeHtml(accumulatedText) + '<span class="ai-typing-cursor">▍</span>';
messagesNode.scrollTop = messagesNode.scrollHeight;
} else if (evt.type === 'done') {
streamFinished = true;
contentDiv.innerHTML = renderMarkdown(accumulatedText || '已完成查询。');
renderMathInNode(contentDiv);
if (metaData) {
card.insertAdjacentHTML('beforeend', buildExtraHtml(metaData));
}
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: accumulatedText });
statusNode.textContent = '已完成本轮查询。';
messagesNode.scrollTop = messagesNode.scrollHeight;
} else if (evt.type === 'error') {
streamFinished = true;
contentDiv.innerHTML = '<p>' + escapeHtml(evt.message || '请求失败,请稍后重试。') + '</p>';
statusNode.textContent = '提问失败';
}
}
fetch('{{ url_for("ai_chat_stream") }}', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(requestBody)
question: question,
history: toHistoryPayload(),
allow_web_search: useWeb,
allow_db_query: useDb
})
}) })
.then(function (resp) { .then(function (resp) {
return resp.json().then(function (data) { if (!resp.ok || !resp.body) {
return { ok: resp.ok, data: data || {} }; throw new Error('HTTP ' + resp.status);
});
})
.then(function (result) {
var data = result.data || {};
if (!result.ok || !data.ok) {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', data.message || '请求失败,请稍后重试。', '');
return;
} }
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var extra = ''; function pump() {
if (data.sql) { return reader.read().then(function (result) {
extra += '<details class="box-overview"><summary>本次SQL</summary><pre class="ai-panel-content">' + escapeHtml(data.sql) + '</pre></details>'; if (result.done) {
if (sseBuffer.trim()) processSSELine(sseBuffer.trim());
return;
}
sseBuffer += decoder.decode(result.value, { stream: true });
var lines = sseBuffer.split('\n');
sseBuffer = lines.pop();
lines.forEach(processSSELine);
return pump();
});
} }
if (data.planner_reason && data.chat_mode !== 'general') { return pump();
extra += '<p class="hint">SQL思路: ' + escapeHtml(data.planner_reason) + '</p>';
}
if (typeof data.row_count === 'number' && data.chat_mode !== 'general') {
extra += '<p class="hint">返回行数: ' + data.row_count + '</p>';
}
if (data.chat_mode === 'general') {
extra += '<p class="hint">模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '</p>';
} else if (data.allow_web_search) {
extra += '<p class="hint">模式: 数据库 + 联网补充</p>';
} else {
extra += '<p class="hint">模式: 仅数据库</p>';
}
extra += renderWebContextHtml(data.web_context || []);
appendMessage('assistant', 'AI助手', data.answer || '已完成查询。', extra);
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: data.answer || '' });
statusNode.textContent = '已完成本轮查询。';
}) })
.catch(function () { .catch(function () {
statusNode.textContent = '提问失败'; if (!streamFinished) {
appendMessage('assistant', 'AI助手', '网络请求失败,请稍后重试。', ''); contentDiv.innerHTML = '<p>网络请求失败,请稍后重试。</p>';
statusNode.textContent = '提问失败';
}
}) })
.finally(function () { .finally(function () {
var cursor = contentDiv.querySelector('.ai-typing-cursor');
if (cursor) cursor.remove();
sendBtn.disabled = false; sendBtn.disabled = false;
}); });
}); });

View File

@@ -46,6 +46,14 @@
超时(秒) 超时(秒)
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}"> <input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
</label> </label>
<label>
聊天最大输出 Token
<input type="number" name="chat_max_tokens" min="256" value="{{ settings.chat_max_tokens }}">
</label>
<label class="full">
<input type="checkbox" name="chat_stream_enabled" value="1" {% if settings.chat_stream_enabled %}checked{% endif %}>
启用聊天流式输出(边生成边展示)
</label>
<p class="hint full">若使用较慢模型(如 GLM-5、较大推理模型生成补货建议超时可先将这里调到 60-90 秒再重试。</p> <p class="hint full">若使用较慢模型(如 GLM-5、较大推理模型生成补货建议超时可先将这里调到 60-90 秒再重试。</p>
<label> <label>
低库存阈值 低库存阈值