feat: 添加聊天最大输出 Token 和流式输出选项,优化 AI 聊天体验
This commit is contained in:
364
app.py
364
app.py
@@ -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, # 默认 4096,SQL规划等短回复场景可传 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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
低库存阈值
|
低库存阈值
|
||||||
|
|||||||
Reference in New Issue
Block a user