Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
794 lines
28 KiB
HTML
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// 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 %}
|