diff --git a/app.py b/app.py index 8d9f2c8..3c1cb77 100644 --- a/app.py +++ b/app.py @@ -221,6 +221,8 @@ AI_SETTINGS_DEFAULT = { ), "api_key": os.environ.get("SILICONFLOW_API_KEY", ""), "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_limit": 24, "lcsc_timeout": int(os.environ.get("LCSC_TIMEOUT", "20") or "20"), @@ -426,6 +428,11 @@ def _get_ai_settings() -> dict: except (TypeError, ValueError): 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: settings["restock_threshold"] = max(0, int(settings.get("restock_threshold", LOW_STOCK_THRESHOLD))) 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_access_key"] = (settings.get("lcsc_access_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)) return settings @@ -2928,6 +2936,7 @@ def _call_siliconflow_chat( model: str, api_key: str, timeout: int, + max_tokens: int = 4096, # 默认 4096,SQL规划等短回复场景可传 700 ) -> str: api_key = (api_key or "").strip() if not api_key: @@ -2940,7 +2949,7 @@ def _call_siliconflow_chat( payload = { "model": model, "temperature": 0.2, - "max_tokens": 700, + "max_tokens": max_tokens, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, @@ -2975,6 +2984,74 @@ def _call_siliconflow_chat( 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_FORBIDDEN_KEYWORDS = { "insert", @@ -3113,6 +3190,7 @@ def _build_db_chat_sql_plan( model=settings["model"], api_key=settings["api_key"], timeout=settings["timeout"], + max_tokens=700, # SQL规划只需短 JSON 输出 ) parsed = json.loads(_extract_json_object_text(raw_text)) sql = str(parsed.get("sql", "") or "").strip() @@ -3320,6 +3398,7 @@ def _build_db_chat_answer( 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) return _call_siliconflow_chat( system_prompt, user_prompt, @@ -3327,6 +3406,51 @@ def _build_db_chat_answer( model=settings["model"], api_key=settings["api_key"], 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: 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( system_prompt, user_prompt, @@ -3412,12 +3537,68 @@ def _build_general_chat_answer( model=settings["model"], api_key=settings["api_key"], 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") 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"]) @@ -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: candidate = (path or "").strip() if not candidate: @@ -5302,6 +5648,7 @@ def ai_settings_page(): api_url = request.form.get("api_url", "").strip() model = request.form.get("model", "").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_access_key = request.form.get("lcsc_access_key", "").strip() lcsc_secret_key = request.form.get("lcsc_secret_key", "").strip() @@ -5315,6 +5662,15 @@ def ai_settings_page(): error = "超时时间必须是大于等于 5 的整数" 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: restock_threshold = int((request.form.get("restock_threshold", "5") or "5").strip()) if restock_threshold < 0: @@ -5355,6 +5711,8 @@ def ai_settings_page(): "model": model, "api_key": api_key, "timeout": timeout, + "chat_max_tokens": chat_max_tokens, + "chat_stream_enabled": chat_stream_enabled, "restock_threshold": restock_threshold, "restock_limit": restock_limit, "lcsc_base_url": LCSC_BASE_URL, @@ -5374,6 +5732,8 @@ def ai_settings_page(): "model": model, "api_key": api_key, "timeout": timeout, + "chat_max_tokens": chat_max_tokens, + "chat_stream_enabled": chat_stream_enabled, "restock_threshold": restock_threshold, "restock_limit": restock_limit, "lcsc_base_url": LCSC_BASE_URL, diff --git a/data/ai_settings.json b/data/ai_settings.json index 2c33b7f..cb511f6 100644 --- a/data/ai_settings.json +++ b/data/ai_settings.json @@ -3,6 +3,8 @@ "model": "Pro/moonshotai/Kimi-K2.5", "api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo", "timeout": 120, + "chat_max_tokens": 4096, + "chat_stream_enabled": true, "restock_threshold": 2, "restock_limit": 24, "lcsc_base_url": "https://open-api.jlc.com", diff --git a/static/css/style.css b/static/css/style.css index 1ce36ea..c3b04d5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -667,6 +667,38 @@ body { 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 { margin: 4px 0; } @@ -693,6 +725,31 @@ body { 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 { border-top: 1px dashed var(--line); padding-top: var(--space-2); diff --git a/templates/ai_chat.html b/templates/ai_chat.html index 4275421..dd97167 100644 --- a/templates/ai_chat.html +++ b/templates/ai_chat.html @@ -5,6 +5,9 @@ AI数据库聊天 + + +
@@ -21,6 +24,7 @@
+
@@ -53,7 +57,9 @@ var dbQueryToggle = document.getElementById('ai-chat-db-query'); var statusNode = document.getElementById('ai-chat-status'); var messagesNode = document.getElementById('ai-chat-messages'); + var configNode = document.getElementById('ai-chat-config'); var history = []; + var streamEnabled = !!(configNode && configNode.getAttribute('data-stream-enabled') === '1'); function escapeHtml(text) { return String(text || '').replace(/[&<>"']/g, function (ch) { @@ -69,9 +75,44 @@ 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) { var source = String(text || '').replace(/\r\n?/g, '\n'); var codeBlocks = []; + var mathBlocks = []; source = source.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, function (_, lang, code) { var language = escapeHtml(lang || 'text'); @@ -81,12 +122,68 @@ return token; }); + source = source.replace(/\$\$([\s\S]*?)\$\$/g, function (_, expr) { + var token = '@@MATHBLOCK_' + mathBlocks.length + '@@'; + mathBlocks.push('
$$' + escapeHtml((expr || '').trim()) + '$$
'); + return token; + }); + var escaped = escapeHtml(source); var lines = escaped.split('\n'); var htmlParts = []; var paragraph = []; var inList = false; + function flushTableFrom(startIdx) { + var headerCells = splitTableRow(lines[startIdx]); + var alignCells = splitTableRow(lines[startIdx + 1]); + var tableHtml = ['
']; + + 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('' + renderInlineMarkdown(cell) + ''); + }); + + tableHtml.push(''); + 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(''); + 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('' + renderInlineMarkdown(cell) + ''); + }); + tableHtml.push(''); + idx += 1; + } + tableHtml.push('
'); + htmlParts.push(tableHtml.join('')); + return idx; + } + function flushParagraph() { if (!paragraph.length) { return; @@ -102,12 +199,26 @@ } } - lines.forEach(function (line) { + var i = 0; + while (i < lines.length) { + var line = lines[i]; var trimmed = line.trim(); if (!trimmed) { flushParagraph(); 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+(.+)$/); @@ -116,7 +227,8 @@ closeList(); var level = heading[1].length; htmlParts.push('' + renderInlineMarkdown(heading[2]) + ''); - return; + i += 1; + continue; } var listItem = trimmed.match(/^[-*]\s+(.+)$/); @@ -127,12 +239,14 @@ inList = true; } htmlParts.push('
  • ' + renderInlineMarkdown(listItem[1]) + '
  • '); - return; + i += 1; + continue; } closeList(); paragraph.push(trimmed); - }); + i += 1; + } flushParagraph(); closeList(); @@ -141,6 +255,9 @@ codeBlocks.forEach(function (block, index) { html = html.replace('@@CODEBLOCK_' + index + '@@', block); }); + mathBlocks.forEach(function (block, index) { + html = html.replace('@@MATHBLOCK_' + index + '@@', block); + }); return html || '

    '; } @@ -155,6 +272,9 @@ bodyHtml + (extraHtml || ''); messagesNode.appendChild(card); + if (role === 'assistant') { + renderMathInNode(card.querySelector('.md-content')); + } messagesNode.scrollTop = messagesNode.scrollHeight; } @@ -207,6 +327,28 @@ return '
    联网来源明细
    ' + blocks + '
    '; } + function buildExtraHtml(data) { + var extra = ''; + if (data.sql) { + extra += '
    本次SQL
    ' + escapeHtml(data.sql) + '
    '; + } + if (data.planner_reason && data.chat_mode !== 'general') { + extra += '

    SQL思路: ' + escapeHtml(data.planner_reason) + '

    '; + } + if (typeof data.row_count === 'number' && data.chat_mode !== 'general') { + extra += '

    返回行数: ' + data.row_count + '

    '; + } + if (data.chat_mode === 'general') { + extra += '

    模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '

    '; + } else if (data.allow_web_search) { + extra += '

    模式: 数据库 + 联网补充

    '; + } else { + extra += '

    模式: 仅数据库

    '; + } + extra += renderWebContextHtml(data.web_context || []); + return extra; + } + sendBtn.addEventListener('click', function () { var question = (input.value || '').trim(); if (!question) { @@ -218,69 +360,144 @@ input.value = ''; var useWeb = !!(webSearchToggle && webSearchToggle.checked); var useDb = !!(dbQueryToggle && dbQueryToggle.checked); + var requestBody = { + question: question, + history: toHistoryPayload(), + allow_web_search: useWeb, + allow_db_query: useDb + }; + if (useDb && useWeb) { statusNode.textContent = 'AI 正在查询数据库并联网补充,请稍候...'; } else if (useDb) { statusNode.textContent = 'AI 正在查询数据库,请稍候...'; } else if (useWeb) { - statusNode.textContent = 'AI 正在通用问答并联网补充,请稍候...'; + statusNode.textContent = streamEnabled + ? 'AI 正在通用问答并联网补充(流式输出中)...' + : 'AI 正在通用问答并联网补充,请稍候...'; } else { - statusNode.textContent = 'AI 正在通用问答,请稍候...'; + statusNode.textContent = streamEnabled + ? 'AI 正在通用问答(流式输出中)...' + : 'AI 正在通用问答,请稍候...'; } 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 = ''; + card.innerHTML = '

    AI助手

    '; + 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) + ''; + 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 = '

    ' + escapeHtml(evt.message || '请求失败,请稍后重试。') + '

    '; + statusNode.textContent = '提问失败'; + } + } + + fetch('{{ url_for("ai_chat_stream") }}', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - question: question, - history: toHistoryPayload(), - allow_web_search: useWeb, - allow_db_query: useDb - }) + 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; + if (!resp.ok || !resp.body) { + throw new Error('HTTP ' + resp.status); } + var reader = resp.body.getReader(); + var decoder = new TextDecoder(); - var extra = ''; - if (data.sql) { - extra += '
    本次SQL
    ' + escapeHtml(data.sql) + '
    '; + function pump() { + return reader.read().then(function (result) { + 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') { - extra += '

    SQL思路: ' + escapeHtml(data.planner_reason) + '

    '; - } - if (typeof data.row_count === 'number' && data.chat_mode !== 'general') { - extra += '

    返回行数: ' + data.row_count + '

    '; - } - if (data.chat_mode === 'general') { - extra += '

    模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '

    '; - } else if (data.allow_web_search) { - extra += '

    模式: 数据库 + 联网补充

    '; - } else { - extra += '

    模式: 仅数据库

    '; - } - 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 = '已完成本轮查询。'; + return pump(); }) .catch(function () { - statusNode.textContent = '提问失败'; - appendMessage('assistant', 'AI助手', '网络请求失败,请稍后重试。', ''); + if (!streamFinished) { + contentDiv.innerHTML = '

    网络请求失败,请稍后重试。

    '; + statusNode.textContent = '提问失败'; + } }) .finally(function () { + var cursor = contentDiv.querySelector('.ai-typing-cursor'); + if (cursor) cursor.remove(); sendBtn.disabled = false; }); }); diff --git a/templates/ai_settings.html b/templates/ai_settings.html index e37a783..5fb81ca 100644 --- a/templates/ai_settings.html +++ b/templates/ai_settings.html @@ -46,6 +46,14 @@ 超时(秒) + +

    若使用较慢模型(如 GLM-5、较大推理模型)生成补货建议超时,可先将这里调到 60-90 秒再重试。