Files
report-admin/templates/era2/components/chat_widget.html
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 71db82393a Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-13 15:38:41 +08:00

794 lines
28 KiB
HTML

{# ===== AI 对话助手组件 ===== #}
{# 需要模板传入: chat_config = { api_key_encoded, api_base_url, model, school_name } #}
{# 以及: report_data_json (完整 report_data 的 JSON 字符串) #}
{% if chat_config %}
<style>
/* ====== Chat Widget Styles ====== */
.chat-fab {
position: fixed;
bottom: 32px;
right: 32px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
border: none;
cursor: pointer;
z-index: 10000;
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s, box-shadow 0.3s;
font-size: 24px;
}
.chat-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 28px rgba(37, 99, 235, 0.55);
}
.chat-fab .badge {
position: absolute;
top: -2px; right: -2px;
width: 18px; height: 18px;
background: #ef4444;
border-radius: 50%;
font-size: 11px;
display: flex; align-items: center; justify-content: center;
font-weight: 700;
display: none;
}
/* Chat Dialog */
.chat-dialog {
position: fixed;
bottom: 100px;
right: 32px;
width: 420px;
max-height: 600px;
background: white;
border-radius: 16px;
box-shadow: 0 12px 48px rgba(0,0,0,0.18);
z-index: 10001;
display: none;
flex-direction: column;
overflow: hidden;
animation: chatSlideUp 0.3s ease;
}
.chat-dialog.open {
display: flex;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.chat-header {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.chat-header-left .avatar {
width: 32px; height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
.chat-header-left .title {
font-size: 15px;
font-weight: 600;
}
.chat-header-left .subtitle {
font-size: 11px;
opacity: 0.75;
}
.chat-header-actions {
display: flex;
gap: 8px;
}
.chat-header-actions button {
background: rgba(255,255,255,0.15);
border: none;
color: white;
width: 28px; height: 28px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.chat-header-actions button:hover {
background: rgba(255,255,255,0.3);
}
/* Messages Area */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 400px;
min-height: 200px;
background: #f8fafc;
}
.chat-messages::-webkit-scrollbar { width: 4px; }
.chat-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
.chat-msg {
display: flex;
gap: 8px;
max-width: 88%;
animation: msgFadeIn 0.3s ease;
}
@keyframes msgFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-msg.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-msg .msg-avatar {
width: 28px; height: 28px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.chat-msg.assistant .msg-avatar {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.chat-msg.user .msg-avatar {
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
}
.chat-msg .msg-bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 13.5px;
line-height: 1.7;
word-break: break-word;
}
.chat-msg.assistant .msg-bubble {
background: white;
color: #1f2937;
border: 1px solid #e5e7eb;
border-top-left-radius: 4px;
}
.chat-msg.user .msg-bubble {
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
border-top-right-radius: 4px;
}
.chat-msg .msg-bubble p { text-indent: 0; margin: 4px 0; }
.chat-msg .msg-bubble strong { color: #1a56db; }
.chat-msg.user .msg-bubble strong { color: #bfdbfe; }
.chat-msg .msg-bubble ul, .chat-msg .msg-bubble ol {
margin: 4px 0 4px 1.2em;
padding: 0;
}
.chat-msg .msg-bubble li { margin: 2px 0; }
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 14px;
}
.typing-indicator span {
width: 6px; height: 6px;
background: #9ca3af;
border-radius: 50%;
animation: typingBounce 1.2s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
/* Quick Suggestions */
.chat-suggestions {
padding: 8px 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
}
.chat-suggestions button {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 5px 12px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.chat-suggestions button:hover {
background: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
/* Input Area */
.chat-input-area {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
align-items: flex-end;
background: white;
}
.chat-input-area textarea {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 10px;
padding: 8px 12px;
font-size: 13.5px;
font-family: inherit;
line-height: 1.5;
resize: none;
max-height: 80px;
min-height: 36px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.chat-input-area textarea::placeholder {
color: #9ca3af;
}
.chat-input-area .send-btn {
width: 36px; height: 36px;
border-radius: 10px;
background: #2563eb;
color: white;
border: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
transition: background 0.2s, transform 0.1s;
flex-shrink: 0;
}
.chat-input-area .send-btn:hover { background: #1d4ed8; }
.chat-input-area .send-btn:active { transform: scale(0.95); }
.chat-input-area .send-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
/* Mobile responsive */
@media (max-width: 768px) {
.chat-dialog {
width: calc(100vw - 24px);
right: 12px;
bottom: 80px;
max-height: calc(100vh - 120px);
}
.chat-fab {
bottom: 20px;
right: 20px;
}
}
@media print {
.chat-fab, .chat-dialog { display: none !important; }
}
</style>
<!-- Chat FAB Button -->
<button class="chat-fab" id="chatFab" onclick="window.chatWidget.toggle()" title="{{ t.chat_fab_title }}">
🤖
<span class="badge" id="chatBadge"></span>
</button>
<!-- Chat Dialog -->
<div class="chat-dialog" id="chatDialog">
<div class="chat-header">
<div class="chat-header-left">
<div class="avatar">🤖</div>
<div>
<div class="title">{{ t.chat_title }}</div>
<div class="subtitle">{{ t.chat_subtitle_prefix }} {{ chat_config.school_name }} {{ t.chat_subtitle_suffix }}</div>
</div>
</div>
<div class="chat-header-actions">
<button onclick="window.chatWidget.clear()" title="{{ t.chat_clear }}">🗑</button>
<button onclick="window.chatWidget.toggle()" title="{{ t.chat_close }}"></button>
</div>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Welcome message -->
<div class="chat-msg assistant">
<div class="msg-avatar">🤖</div>
<div class="msg-bubble">
<p>{{ t.chat_welcome_p1_a }}<strong>{{ chat_config.school_name }}</strong>{{ t.chat_welcome_p1_b }}</p>
<p>{{ t.chat_welcome_p2 }}</p>
<ul>
<li>{{ t.chat_welcome_li1 }}</li>
<li>{{ t.chat_welcome_li2 }}</li>
<li>{{ t.chat_welcome_li3 }}</li>
<li>{{ t.chat_welcome_li4 }}</li>
</ul>
<p>{{ t.chat_welcome_p3 }}</p>
</div>
</div>
</div>
<div class="chat-suggestions" id="chatSuggestions">
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_overall_q | tojson }})">📊 {{ t.chat_sg_overall }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_strengths_q | tojson }})">💡 {{ t.chat_sg_strengths }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_gap_q | tojson }})">📈 {{ t.chat_sg_gap }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_advice_q | tojson }})">🎯 {{ t.chat_sg_advice }}</button>
</div>
<div class="chat-input-area">
<textarea id="chatInput" placeholder="{{ t.chat_input_placeholder }}" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.chatWidget.send()}"></textarea>
<button class="send-btn" id="chatSendBtn" onclick="window.chatWidget.send()" title="{{ t.chat_send_title }}"></button>
</div>
</div>
<!-- Chat Widget Script -->
<script>
(function() {
// ===== API Routing =====
// Priority: local proxy (http://localhost:PORT/proxy) > direct API
// Local proxy avoids CORS entirely; direct only works if server has CORS headers
var _EK = '{{ chat_config.api_key_encoded }}';
var _XK = '{{ chat_config.xor_key }}';
function _dk(encoded, xorKey) {
try {
var raw = atob(encoded);
var result = '';
for (var i = 0; i < raw.length; i++) {
result += String.fromCharCode(raw.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length));
}
return result;
} catch(e) { return ''; }
}
// ===== Report Data Context =====
var REPORT_DATA = {{ report_data_json | safe }};
var DIRECT_API_BASE = '{{ chat_config.api_base_url }}';
var API_MODEL = '{{ chat_config.model }}';
var SCHOOL_NAME = '{{ chat_config.school_name }}';
// Direct API mode — CORS must be enabled on server side
// ===== Language flag (injected by template) =====
var CHAT_LANG = '{{ lang | default("zh") }}';
// ===== Build System Prompt with Report Context =====
function buildSystemPrompt() {
var o = REPORT_DATA.overall || {};
var dims = REPORT_DATA.dimensions || {};
var subDims = REPORT_DATA.sub_dimensions || {};
var ctx = '';
if (CHAT_LANG === 'en') {
ctx += 'You are the AI analyst for the Curriculum Implementation Monitoring Report of ' + SCHOOL_NAME + '.\n\n';
ctx += '## Core Report Data\n\n';
ctx += '### Overall Performance\n';
ctx += '- Overall score: ' + o.score + '\n';
ctx += '- District average: ' + o.district_avg + '\n';
ctx += '- District rank: ' + o.rank_in_district + ' of ' + o.total_schools + '\n';
if (o.rank_in_city && o.total_schools_in_city) {
ctx += '- Municipal rank: ' + o.rank_in_city + ' of ' + o.total_schools_in_city + '\n';
}
ctx += '- Cluster: ' + (o.cluster || '') + '\n\n';
ctx += '### Seven Secondary Dimensions\n';
ctx += '| Dimension | Score | District Avg | Δ | District Rank | Municipal Rank | Cluster |\n';
ctx += '|-----------|-------|--------------|---|---------------|----------------|---------|\n';
for (var dName in dims) {
var d = dims[dName];
ctx += '| ' + dName + ' | ' + d.score.toFixed(2) + ' | ' + d.district_avg.toFixed(2);
ctx += ' | ' + (d.diff_district >= 0 ? '+' : '') + d.diff_district.toFixed(2);
ctx += ' | ' + d.rank_in_district + '/' + o.total_schools;
ctx += ' | ' + (d.rank_in_city != null ? (d.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-');
ctx += ' | ' + (d.cluster || '') + ' |\n';
}
ctx += '\n';
ctx += '### Twenty Tertiary Indicators\n';
ctx += '| Indicator | Score | District Avg | Δ | Level | District Rank | Municipal Rank |\n';
ctx += '|-----------|-------|--------------|---|-------|---------------|----------------|\n';
for (var sdName in subDims) {
var sd = subDims[sdName];
ctx += '| ' + sdName + ' | ' + (sd.score ? sd.score.toFixed(2) : 'N/A');
ctx += ' | ' + (sd.district_avg ? sd.district_avg.toFixed(2) : 'N/A');
ctx += ' | ' + (sd.diff_district != null ? ((sd.diff_district >= 0 ? '+' : '') + sd.diff_district.toFixed(2)) : 'N/A');
ctx += ' | Level ' + (sd.level || '?');
ctx += ' | ' + (sd.rank_in_district || '?') + '/' + o.total_schools;
ctx += ' | ' + (sd.rank_in_city != null ? (sd.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-') + ' |\n';
}
ctx += '\n';
ctx += '## Response Guidelines\n';
ctx += '1. Ground every response in the data above and cite specific numerical values.\n';
ctx += '2. Refer to the analysed school as "your school" rather than by its proper name.\n';
ctx += '3. Maintain a formal, objective, restrained, OECD/PISA-aligned register.\n';
ctx += '4. Use phrasing such as "X.XX points above/below the district average" for comparisons.\n';
ctx += '5. When suggesting improvements, be concrete and actionable.\n';
ctx += '6. If a question falls outside the available data, acknowledge this honestly.\n';
ctx += '7. Keep responses concise (around 200-500 words).\n';
ctx += '8. Output plain text only (no Markdown), since responses are rendered inside chat bubbles.\n';
ctx += '9. Always answer in English.\n';
} else {
ctx += '你是' + SCHOOL_NAME + '课程实施监测报告的AI分析助手。\n\n';
ctx += '## 报告核心数据\n\n';
ctx += '### 总体表现\n';
ctx += '- 总体得分: ' + o.score + '分\n';
ctx += '- 区均值: ' + o.district_avg + '分\n';
ctx += '- 区内排名: 第' + o.rank_in_district + '/' + o.total_schools + '名\n';
if (o.rank_in_city && o.total_schools_in_city) {
ctx += '- 全市排名: 第' + o.rank_in_city + '/' + o.total_schools_in_city + '名\n';
}
ctx += '- 聚类类型: 课程实施' + (o.cluster || '') + '类\n\n';
ctx += '### 七大维度得分\n';
ctx += '| 维度 | 得分 | 区均值 | 差值 | 区排名 | 全市排名 | 聚类 |\n';
ctx += '|------|------|--------|------|--------|----------|------|\n';
for (var dName2 in dims) {
var d2 = dims[dName2];
ctx += '| ' + dName2 + ' | ' + d2.score.toFixed(2) + ' | ' + d2.district_avg.toFixed(2);
ctx += ' | ' + (d2.diff_district >= 0 ? '+' : '') + d2.diff_district.toFixed(2);
ctx += ' | ' + d2.rank_in_district + '/' + o.total_schools;
ctx += ' | ' + (d2.rank_in_city != null ? (d2.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-');
ctx += ' | ' + (d2.cluster || '') + ' |\n';
}
ctx += '\n';
ctx += '### 二十个三级维度详情\n';
ctx += '| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 | 全市排名 |\n';
ctx += '|----------|------|--------|------|------|--------|----------|\n';
for (var sdName2 in subDims) {
var sd2 = subDims[sdName2];
ctx += '| ' + sdName2 + ' | ' + (sd2.score ? sd2.score.toFixed(2) : 'N/A');
ctx += ' | ' + (sd2.district_avg ? sd2.district_avg.toFixed(2) : 'N/A');
ctx += ' | ' + (sd2.diff_district != null ? ((sd2.diff_district >= 0 ? '+' : '') + sd2.diff_district.toFixed(2)) : 'N/A');
ctx += ' | 水平' + (sd2.level || '?');
ctx += ' | ' + (sd2.rank_in_district || '?') + '/' + o.total_schools;
ctx += ' | ' + (sd2.rank_in_city != null ? (sd2.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-') + ' |\n';
}
ctx += '\n';
ctx += '## 回答规范\n';
ctx += '1. 始终基于上述数据回答,引用具体数值\n';
ctx += '2. 称呼被分析学校为"贵校"\n';
ctx += '3. 语言风格:专业、客观、平实\n';
ctx += '4. 使用"高于/低于XX均值X.XX分"句式进行对比\n';
ctx += '5. 给出改进建议时要具体可操作\n';
ctx += '6. 如果用户问的内容不在数据范围内,诚实告知\n';
ctx += '7. 回答控制在200-500字以内,避免冗长\n';
ctx += '8. 输出纯文本,不使用markdown格式(因为显示在聊天气泡中)\n';
}
return ctx;
}
// ===== Chat State =====
var messages = []; // {role, content}
var isStreaming = false;
var systemPrompt = null; // lazy init
// ===== DOM References =====
var dialog = document.getElementById('chatDialog');
var messagesEl = document.getElementById('chatMessages');
var inputEl = document.getElementById('chatInput');
var sendBtn = document.getElementById('chatSendBtn');
var suggestionsEl = document.getElementById('chatSuggestions');
// ===== Widget API =====
window.chatWidget = {
toggle: function() {
dialog.classList.toggle('open');
if (dialog.classList.contains('open')) {
inputEl.focus();
scrollToBottom();
}
},
send: function() {
var text = inputEl.value.trim();
if (!text || isStreaming) return;
inputEl.value = '';
inputEl.style.height = 'auto';
doSend(text);
},
sendSuggestion: function(text) {
if (isStreaming) return;
doSend(text);
// Hide suggestions after first use
suggestionsEl.style.display = 'none';
},
clear: function() {
messages = [];
// Keep only welcome message
var welcome = messagesEl.querySelector('.chat-msg');
messagesEl.innerHTML = '';
if (welcome) messagesEl.appendChild(welcome.cloneNode(true));
suggestionsEl.style.display = 'flex';
systemPrompt = null;
}
};
// ===== Core Send Logic =====
function doSend(text) {
// Lazy init system prompt
if (!systemPrompt) {
systemPrompt = buildSystemPrompt();
}
// Add user message
messages.push({ role: 'user', content: text });
appendMessage('user', text);
scrollToBottom();
// Show typing
var typingEl = showTyping();
isStreaming = true;
sendBtn.disabled = true;
// Prepare messages for API
// NOTE: system role is stripped by some API proxies (e.g. cdr.digiman.live),
// so we inject context as a user+assistant pair instead.
var apiMessages;
if (CHAT_LANG === 'en') {
apiMessages = [
{ role: 'user', content: '[System Instruction] ' + systemPrompt + '\n\nPlease confirm that you have understood the above data and guidelines; subsequent responses must be grounded in this material.' },
{ role: 'assistant', content: 'Understood. I have full access to the curriculum-implementation monitoring data of your school (' + SCHOOL_NAME + '), including the overall score, the seven secondary dimensions, and the twenty tertiary indicators. I will respond strictly on this basis, in a formal and objective register. How may I assist you?' }
];
} else {
apiMessages = [
{ role: 'user', content: '[系统指令] ' + systemPrompt + '\n\n请确认你已了解以上数据和规范,后续将基于这些数据回答问题。' },
{ role: 'assistant', content: '我已了解贵校(' + SCHOOL_NAME + ')课程实施监测的全部数据,包括总体得分、七大维度和二十个三级维度的详细数据。我将严格基于这些数据,以专业、客观的风格回答您的问题。请问有什么想了解的?' }
];
}
// Keep last 10 messages for context window management
var recentMessages = messages.slice(-10);
for (var i = 0; i < recentMessages.length; i++) {
apiMessages.push({
role: recentMessages[i].role,
content: recentMessages[i].content
});
}
// Stream call
callLLMStream(apiMessages, function(chunk) {
// First chunk: remove typing indicator, create assistant bubble
if (typingEl) {
typingEl.remove();
typingEl = null;
appendMessage('assistant', '');
}
// Append chunk to last assistant bubble
var bubbles = messagesEl.querySelectorAll('.chat-msg.assistant .msg-bubble');
var lastBubble = bubbles[bubbles.length - 1];
if (lastBubble) {
lastBubble._rawText = (lastBubble._rawText || '') + chunk;
lastBubble.innerHTML = formatText(lastBubble._rawText);
}
scrollToBottom();
}, function(fullText) {
// Done
if (typingEl) typingEl.remove();
messages.push({ role: 'assistant', content: fullText });
isStreaming = false;
sendBtn.disabled = false;
inputEl.focus();
}, function(error) {
// Error
if (typingEl) typingEl.remove();
appendMessage('assistant', '⚠️ ' + {{ t.chat_request_failed | tojson }} + ': ' + error + '\n' + {{ t.chat_retry | tojson }});
isStreaming = false;
sendBtn.disabled = false;
});
}
// ===== LLM Streaming API Call =====
function callLLMStream(apiMessages, onChunk, onDone, onError) {
// Always direct to the real API (CORS must be enabled on server side)
var apiKey = _dk(_EK, _XK);
if (!apiKey) {
onError({{ t.chat_apikey_failed | tojson }});
return;
}
var url = DIRECT_API_BASE + '/chat/completions';
var headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
};
var payload = {
model: API_MODEL,
messages: apiMessages,
stream: true,
max_tokens: 2000,
temperature: 0.7
};
// Debug: log what we're sending
console.log('[ChatWidget] Sending to:', url);
console.log('[ChatWidget] Model:', API_MODEL);
console.log('[ChatWidget] Messages count:', apiMessages.length);
console.log('[ChatWidget] System prompt length:', apiMessages[0] ? apiMessages[0].content.length : 0, 'chars');
var fullText = '';
fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
})
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
function processStream() {
return reader.read().then(function(result) {
if (result.done) {
onDone(fullText);
return;
}
buffer += decoder.decode(result.value, { stream: true });
// Process complete SSE lines
var lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || !line.startsWith('data: ')) continue;
var data = line.substring(6);
if (data === '[DONE]') {
onDone(fullText);
return;
}
try {
var json = JSON.parse(data);
var delta = json.choices && json.choices[0] && json.choices[0].delta;
if (delta && delta.content) {
fullText += delta.content;
onChunk(delta.content);
}
} catch(e) { /* skip malformed */ }
}
return processStream();
});
}
return processStream();
})
.catch(function(err) {
var msg = err.message || '网络错误';
console.error('[ChatWidget] Fetch error:', err);
// Detect CORS error and give actionable advice
if (msg.indexOf('Failed to fetch') >= 0 || msg.indexOf('NetworkError') >= 0) {
msg = '无法连接 AI 服务(可能是 CORS 限制或网络问题)。\n\n'
+ '方案1: 确认 API 服务端已启用 CORS\n'
+ '方案2: python3 serve_report.py 本地代理打开';
}
onError(msg);
});
}
// ===== UI Helpers =====
function appendMessage(role, text) {
var msg = document.createElement('div');
msg.className = 'chat-msg ' + role;
var avatar = document.createElement('div');
avatar.className = 'msg-avatar';
avatar.textContent = role === 'user' ? '👤' : '🤖';
var bubble = document.createElement('div');
bubble.className = 'msg-bubble';
bubble._rawText = text;
bubble.innerHTML = text ? formatText(text) : '';
msg.appendChild(avatar);
msg.appendChild(bubble);
messagesEl.appendChild(msg);
}
function showTyping() {
var msg = document.createElement('div');
msg.className = 'chat-msg assistant';
msg.innerHTML = '<div class="msg-avatar">🤖</div>' +
'<div class="msg-bubble"><div class="typing-indicator">' +
'<span></span><span></span><span></span></div></div>';
messagesEl.appendChild(msg);
scrollToBottom();
return msg;
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function formatText(text) {
// Simple text formatting: handle newlines and basic markdown-like syntax
var escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Bold: **text** or __text__
escaped = escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
escaped = escaped.replace(/__(.*?)__/g, '<strong>$1</strong>');
// Convert numbered lists: "1. xxx\n2. xxx" → <ol>
escaped = escaped.replace(/(?:^|\n)(\d+\.\s+.+(?:\n\d+\.\s+.+)*)/g, function(match) {
var items = match.trim().split('\n').map(function(line) {
return '<li>' + line.replace(/^\d+\.\s+/, '') + '</li>';
}).join('');
return '<ol>' + items + '</ol>';
});
// Convert bullet lists: "- xxx" → <ul>
escaped = escaped.replace(/(?:^|\n)(-\s+.+(?:\n-\s+.+)*)/g, function(match) {
var items = match.trim().split('\n').map(function(line) {
return '<li>' + line.replace(/^-\s+/, '') + '</li>';
}).join('');
return '<ul>' + items + '</ul>';
});
// Paragraphs
escaped = escaped.replace(/\n\n/g, '</p><p>');
escaped = escaped.replace(/\n/g, '<br>');
if (!escaped.startsWith('<')) {
escaped = '<p>' + escaped + '</p>';
}
return escaped;
}
// ===== Auto-resize textarea =====
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
});
})();
</script>
{% endif %}