fix: stabilize AI coach chat against truncation and empty replies
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -85,9 +85,12 @@ HUB_TRUST_LAN=true
|
||||
# 与四实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama)
|
||||
# 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md
|
||||
AI_TIMEOUT_SECONDS=120
|
||||
# AI 教练聊天:单次输出 token 上限与截断自动续写次数(默认 8192 / 3)
|
||||
# AI 教练聊天(默认:输出 8192 token、续写 4 次、快照约 2 万字符、历史单条 1500 字)
|
||||
# CHAT_MAX_OUTPUT_TOKENS=8192
|
||||
# CHAT_MAX_CONTINUATIONS=8
|
||||
# CHAT_MAX_CONTINUATIONS=4
|
||||
# CHAT_CONTEXT_MAX_CHARS=20000
|
||||
# CHAT_SUMMARY_EXCERPT_MAX_CHARS=2000
|
||||
# CHAT_HISTORY_MAX_CHARS_PER_MSG=1500
|
||||
# CHAT_AI_TIMEOUT_SECONDS=300
|
||||
|
||||
# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama)
|
||||
|
||||
@@ -7,6 +7,7 @@ from hub_ai.attachments import parse_chat_attachments
|
||||
from hub_ai.client import generate_text, model_label
|
||||
from hub_ai.config import (
|
||||
CHAT_CONTEXT_MAX_CHARS,
|
||||
CHAT_HISTORY_MAX_CHARS_PER_MSG,
|
||||
CHAT_MAX_CONTINUATIONS,
|
||||
CHAT_MAX_HISTORY_TURNS,
|
||||
CHAT_MAX_OUTPUT_TOKENS,
|
||||
@@ -25,17 +26,31 @@ from hub_ai.store import (
|
||||
)
|
||||
|
||||
|
||||
def _history_lines(messages: list[dict], max_turns: int = CHAT_MAX_HISTORY_TURNS) -> str:
|
||||
def _is_ai_error_reply(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
return t.startswith("AI 调用失败") or t.startswith("AI 生成失败")
|
||||
|
||||
|
||||
def _history_lines(
|
||||
messages: list[dict],
|
||||
max_turns: int = CHAT_MAX_HISTORY_TURNS,
|
||||
*,
|
||||
max_chars_per_msg: int = 1500,
|
||||
) -> str:
|
||||
rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
|
||||
rows = rows[-max_turns * 2 :]
|
||||
lines = []
|
||||
for m in rows:
|
||||
role = "用户" if m.get("role") == "user" else "搭档"
|
||||
content = m.get("content") or ""
|
||||
content = str(m.get("content") or "").strip()
|
||||
if m.get("role") == "assistant" and _is_ai_error_reply(content):
|
||||
continue
|
||||
att = m.get("attachments") or []
|
||||
if att:
|
||||
names = "、".join(str(a.get("name") or "附件") for a in att[:3])
|
||||
content = f"{content} [附件: {names}]".strip()
|
||||
if len(content) > max_chars_per_msg:
|
||||
content = content[: max_chars_per_msg - 1].rstrip() + "…"
|
||||
lines.append(f"{role}:{content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -79,7 +94,10 @@ def send_chat_message(
|
||||
day = ctx["trading_day"]
|
||||
session = ensure_active_session(trading_day=day)
|
||||
sid = session["id"]
|
||||
history = _history_lines(session.get("messages") or [])
|
||||
history = _history_lines(
|
||||
session.get("messages") or [],
|
||||
max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG,
|
||||
)
|
||||
|
||||
append_chat_message(
|
||||
sid,
|
||||
@@ -110,7 +128,7 @@ def send_chat_message(
|
||||
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
|
||||
max_continuations=CHAT_MAX_CONTINUATIONS,
|
||||
)
|
||||
if reply.startswith("AI 调用失败"):
|
||||
if _is_ai_error_reply(reply):
|
||||
return {"ok": False, "msg": reply, "session_id": sid}
|
||||
|
||||
session = append_chat_message(sid, "assistant", reply)
|
||||
|
||||
@@ -17,9 +17,10 @@ SUMMARY_TEMPERATURE = 0.15
|
||||
CHAT_TEMPERATURE = 0.5
|
||||
CHAT_MAX_HISTORY_TURNS = 40
|
||||
CHAT_MAX_OUTPUT_TOKENS = _int_env("CHAT_MAX_OUTPUT_TOKENS", 8192)
|
||||
CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 8)
|
||||
CHAT_CONTEXT_MAX_CHARS = 128_000
|
||||
CHAT_SUMMARY_EXCERPT_MAX_CHARS = 8000
|
||||
CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 4)
|
||||
CHAT_CONTEXT_MAX_CHARS = _int_env("CHAT_CONTEXT_MAX_CHARS", 20_000)
|
||||
CHAT_SUMMARY_EXCERPT_MAX_CHARS = _int_env("CHAT_SUMMARY_EXCERPT_MAX_CHARS", 2000)
|
||||
CHAT_HISTORY_MAX_CHARS_PER_MSG = _int_env("CHAT_HISTORY_MAX_CHARS_PER_MSG", 1500)
|
||||
SUMMARY_RETENTION_DAYS = 90
|
||||
CHAT_SESSION_RETENTION_DAYS = 60
|
||||
FUND_HISTORY_DAYS = 180
|
||||
|
||||
@@ -767,7 +767,7 @@ def format_chat_context_for_chat(
|
||||
max_chars: int = CHAT_CONTEXT_MAX_CHARS,
|
||||
) -> str:
|
||||
overview = format_chat_position_overview(payload)
|
||||
body = str(payload.get("text") or "").strip() or format_context_text(payload)
|
||||
body = format_chat_context_slim(payload)
|
||||
text = overview + "\n\n" + body
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
|
||||
@@ -4327,6 +4327,10 @@ body.hub-page-ai #page-ai {
|
||||
font-style: italic;
|
||||
animation: ai-think-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-bubble-error {
|
||||
border-color: color-mix(in srgb, var(--red) 55%, var(--border-soft));
|
||||
color: var(--red);
|
||||
}
|
||||
@keyframes ai-think-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -990,10 +990,7 @@
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
if (mobile && active === "chat") {
|
||||
const box = document.getElementById("ai-chat-messages");
|
||||
if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
|
||||
}
|
||||
if (mobile && active === "chat") scrollAiChatToEnd();
|
||||
}
|
||||
|
||||
function initAiMobileTabs() {
|
||||
@@ -3271,12 +3268,34 @@
|
||||
].join("");
|
||||
}
|
||||
|
||||
function scrollAiChatToEnd() {
|
||||
const box = document.getElementById("ai-chat-messages");
|
||||
if (!box) return;
|
||||
const run = () => {
|
||||
box.scrollTop = box.scrollHeight;
|
||||
const rows = box.querySelectorAll(".ai-msg-row");
|
||||
const last = rows[rows.length - 1];
|
||||
if (last && last.scrollIntoView) {
|
||||
try {
|
||||
last.scrollIntoView({ block: "end", behavior: "auto" });
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(run));
|
||||
}
|
||||
|
||||
function renderAiChatRow(role, content, extraClass, attachments) {
|
||||
const isUser = role === "user";
|
||||
const label = isUser ? "主人" : "AI教练";
|
||||
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
|
||||
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
|
||||
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
|
||||
const isError =
|
||||
!isUser &&
|
||||
!isThinking &&
|
||||
/^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim());
|
||||
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
|
||||
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
|
||||
const attList = Array.isArray(attachments) ? attachments : [];
|
||||
@@ -3289,7 +3308,7 @@
|
||||
`<div class="ai-msg-row ${rowCls}">` +
|
||||
`<span class="ai-msg-role">${label}</span>` +
|
||||
`${attHtml}` +
|
||||
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
||||
`<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
@@ -3327,7 +3346,7 @@
|
||||
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
|
||||
}
|
||||
box.innerHTML = html;
|
||||
box.scrollTop = box.scrollHeight;
|
||||
scrollAiChatToEnd();
|
||||
}
|
||||
|
||||
function setAiChatBusy(busy) {
|
||||
@@ -3452,7 +3471,11 @@
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
renderAiChatMessages(aiChatSessionCache);
|
||||
try {
|
||||
await loadAiChatSession();
|
||||
} catch (_) {
|
||||
renderAiChatMessages(aiChatSessionCache);
|
||||
}
|
||||
} finally {
|
||||
setAiChatBusy(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user