@@ -6,6 +6,36 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "
const chatHistory = [ ] ;
let chatBusy = false ;
/* 简洁版当前时间 HH:MM */
function nowTime ( ) {
const d = new Date ( ) ;
return ` ${ String ( d . getHours ( ) ) . padStart ( 2 , "0" ) } : ${ String ( d . getMinutes ( ) ) . padStart ( 2 , "0" ) } ` ;
}
/* 轻提示 */
function toast ( msg ) {
let t = document . getElementById ( "toast" ) ;
if ( ! t ) { t = document . createElement ( "div" ) ; t . id = "toast" ; document . body . appendChild ( t ) ; }
t . textContent = msg ;
t . classList . add ( "show" ) ;
clearTimeout ( t . _timer ) ;
t . _timer = setTimeout ( ( ) => t . classList . remove ( "show" ) , 1800 ) ;
}
/* 复制文本(Clipboard API + 降级) */
async function copyText ( text ) {
try {
await navigator . clipboard . writeText ( text ) ;
} catch ( e ) {
const ta = document . createElement ( "textarea" ) ;
ta . value = text ; ta . style . position = "fixed" ; ta . style . opacity = "0" ;
document . body . appendChild ( ta ) ; ta . select ( ) ;
document . execCommand ( "copy" ) ;
ta . remove ( ) ;
}
toast ( "✅ 已复制" ) ;
}
/* ================= Markdown 渲染(marked 本地库,先转义防 XSS) ================= */
function md ( text ) {
if ( window . marked ) {
@@ -61,10 +91,14 @@ function loadView(v) {
}
/* ================= 对话 ================= */
function addMsg ( role , html ) {
function addMsg ( role , html , opts = { } ) {
const div = document . createElement ( "div" ) ;
div . className = ` msg ${ role } ` ;
div . innerHTML = ` <div class="avatar"> ${ role = == "user" ? "🧑" : "🤖" } </div><div class="bubble"> ${ html } </div> ` ;
if ( opts . idx ! == undefined ) div . dataset . idx = opts . idx ;
const actions = role === "bot"
? ` <div class="msg-actions"><button class="act-btn" title="复制回答">📋</button><button class="act-btn" title="重新生成">🔄</button><span class="msg-time"> ${ nowTime ( ) } </span></div> `
: ` <div class="msg-actions user-time"><span class="msg-time"> ${ nowTime ( ) } </span></div> ` ;
div . innerHTML = ` <div class="avatar"> ${ role === "user" ? "🧑" : "🤖" } </div><div class="msg-body"><div class="bubble"> ${ html } </div> ${ actions } </div> ` ;
$ ( "#chat-list" ) . appendChild ( div ) ;
$ ( "#chat-list" ) . scrollTop = $ ( "#chat-list" ) . scrollHeight ;
return div ;
@@ -72,7 +106,7 @@ function addMsg(role, html) {
function showTyping ( ) {
const div = document . createElement ( "div" ) ;
div . className = "msg bot" ;
div . innerHTML = ` <div class="avatar">🤖</div><div class="bubble"><span class="typing"><i></i><i></i><i></i></span></div> ` ;
div . innerHTML = ` <div class="avatar">🤖</div><div class="msg-body"><div class="bubble"><span class="typing"><i></i><i></i><i></i></span></div></div> ` ;
$ ( "#chat-list" ) . appendChild ( div ) ;
$ ( "#chat-list" ) . scrollTop = $ ( "#chat-list" ) . scrollHeight ;
return div ;
@@ -90,6 +124,28 @@ function cardHtml(c) {
return "" ;
}
/* 组装回答气泡 HTML(参考资讯 + markdown实体 + 卡片 + 来源) */
function buildBotHtml ( d ) {
let html = "" ;
// 需求4:参考资讯(新闻/百科链接),默认折叠,位于回答块上方
if ( d . news _refs && d . news _refs . length ) {
html += ` <details class="refs"><summary>📰 参考资讯( ${ d . news _refs . length } ) </summary><ul>` +
d . news _refs . map ( ( n ) => ` <li><a href="#" data-news=" ${ n . id } "> ${ esc ( n . title ) } </a> ` +
( n . source ? ` <span class="ref-src"> ${ esc ( n . source ) } ${ n . publish _time ? " · " + esc ( n . publish _time . slice ( 0 , 10 ) ) : "" } </span> ` : "" ) + ` </li> ` ) . join ( "" ) +
` </ul></details> ` ;
}
// 需求3: markdown 渲染 + 需求5:实体特殊标记
html += ` <div class="md"> ${ renderMdWithEntities ( d . reply , d . entities ) } </div> ` ;
// 需求5:快速查看入口卡片
if ( d . cards && d . cards . length ) {
html += ` <div class="entity-cards"> ${ d . cards . map ( cardHtml ) . join ( "" ) } </div> ` ;
}
if ( d . sources && d . sources . length ) {
html += "<div class='src-line'>" + d . sources . map ( ( s ) => ` <span class="src-tag">来源: ${ esc ( s . tool ) } </span> ` ) . join ( "" ) + "</div>" ;
}
return html ;
}
async function sendChat ( text ) {
if ( chatBusy ) return ;
chatBusy = true ;
@@ -102,26 +158,18 @@ async function sendChat(text) {
const d = await r . json ( ) ;
typing . remove ( ) ;
if ( d . error ) { addMsg ( "bot" , ` ⚠️ ${ esc ( d . error ) } ` ) ; return ; }
let html = "" ;
// 需求4:参考资讯(新闻/百科链接),默认折叠,位于回答块上方
if ( d . news _refs && d . news _refs . length ) {
html += ` <details class="refs"><summary>📰 参考资讯( ${ d . news _refs . length } ) </summary><ul>` +
d . news _refs . map ( ( n ) => ` <li><a href="#" data-news=" ${ n . id } "> ${ esc ( n . title ) } </a> ` +
( n . source ? ` <span class="ref-src"> ${ esc ( n . source ) } ${ n . publish _time ? " · " + esc ( n . publish _time . slice ( 0 , 10 ) ) : "" } </span> ` : "" ) + ` </li> ` ) . join ( "" ) +
` </ul></details> ` ;
}
// 需求3: markdown 渲染 + 需求5:实体特殊标记
html += ` <div class="md"> ${ renderMdWithEntities ( d . reply , d . entities ) } </div> ` ;
// 需求5:快速查看入口卡片
if ( d . cards && d . cards . length ) {
html += ` <div class="entity-cards"> ${ d . cards . map ( cardHtml ) . join ( "" ) } </div> ` ;
}
if ( d . sources && d . sources . length ) {
html += "<div class='src-line'>" + d . sources . map ( ( s ) => ` <span class="src-tag">来源: ${ esc ( s . tool ) } </span> ` ) . join ( "" ) + "</div>" ;
}
addMsg ( "bot" , html ) ;
chatHistory . push ( { user : text , assistant : d . reply } ) ;
if ( chatHistory . length > 20 ) chatHistory . splice ( 0 , chatHistory . length - 20 ) ;
const idx = chatHistory . length - 1 ;
addMsg ( "bot" , buildBotHtml ( d ) , { idx } ) ;
if ( chatHistory . length > 20 ) {
const removed = chatHistory . length - 20 ;
chatHistory . splice ( 0 , removed ) ;
document . querySelectorAll ( "#chat-list .msg[data-idx]" ) . forEach ( ( m ) => {
const i = parseInt ( m . dataset . idx ) ;
if ( i < removed ) m . remove ( ) ;
else m . dataset . idx = i - removed ;
} ) ;
}
// 大模型预测下一轮快捷问题(异步刷新底部 chips)
const mark = chatHistory . length ;
refreshChips ( mark ) ;
@@ -136,6 +184,79 @@ async function sendChat(text) {
$ ( "#send-btn" ) . addEventListener ( "click" , ( ) => { const v = $ ( "#chat-input" ) . value . trim ( ) ; if ( v ) { $ ( "#chat-input" ) . value = "" ; sendChat ( v ) ; } } ) ;
$ ( "#chat-input" ) . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) $ ( "#send-btn" ) . click ( ) ; } ) ;
/* 复制当前回答(纯文本) */
function copyMsg ( btn ) {
const bubble = btn . closest ( ".msg" ) . querySelector ( ".bubble" ) ;
copyText ( bubble . innerText . trim ( ) ) ;
}
/* 重新生成:以该轮之前的上下文重新提问,替换本条回答,截断后续对话 */
async function regenerate ( btn ) {
const msgEl = btn . closest ( ".msg" ) ;
const idx = parseInt ( msgEl . dataset . idx ) ;
if ( isNaN ( idx ) || chatBusy ) return ;
if ( idx >= chatHistory . length ) return ;
// 截断:删除该条之后的对话(上下文已变)
chatHistory . splice ( idx + 1 ) ;
document . querySelectorAll ( "#chat-list .msg[data-idx]" ) . forEach ( ( m ) => {
if ( parseInt ( m . dataset . idx ) > idx ) m . remove ( ) ;
} ) ;
const userText = chatHistory [ idx ] . user ;
const body = msgEl . querySelector ( ".msg-body" ) ;
const bubble = msgEl . querySelector ( ".bubble" ) ;
bubble . innerHTML = ` <span class="typing"><i></i><i></i><i></i></span> ` ;
msgEl . querySelector ( ".msg-actions" ) ? . remove ( ) ;
chatBusy = true ;
try {
const r = await fetch ( "/api/chat" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { message : userText , history : chatHistory . slice ( 0 , idx ) . map ( ( h ) => ( { user : h . user , assistant : h . assistant } ) ) } ) } ) ;
const d = await r . json ( ) ;
if ( d . error ) { bubble . innerHTML = ` ⚠️ ${ esc ( d . error ) } ` ; return ; }
bubble . innerHTML = buildBotHtml ( d ) ;
const actions = document . createElement ( "div" ) ;
actions . className = "msg-actions" ;
actions . innerHTML = ` <button class="act-btn" title="复制回答">📋</button><button class="act-btn" title="重新生成">🔄</button><span class="msg-time"> ${ nowTime ( ) } </span> ` ;
body . appendChild ( actions ) ;
chatHistory [ idx ] = { user : userText , assistant : d . reply } ;
refreshChips ( chatHistory . length ) ;
} catch ( e ) {
bubble . innerHTML = "⚠️ 网络异常,请稍后再试。" ;
} finally {
chatBusy = false ;
}
}
/* 分享:弹窗展示对话全文 + 一键复制 */
function shareChat ( ) {
if ( ! chatHistory . length ) { toast ( "还没有对话内容" ) ; return ; }
const text = chatHistory . map ( ( h ) => ` 🧑 ${ h . user } \n 🤖 ${ h . assistant } ` ) . join ( "\n\n" ) ;
openModal ( `
<h2>🔗 分享对话</h2>
<div class="en">共 ${ chatHistory . length } 轮 · 复制后粘贴到任意聊天或文档</div>
<textarea readonly class="share-box" style="width:100%;height:280px;margin-top:10px;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:12px;color:var(--txt);font-size:13px;line-height:1.7;resize:vertical"> ${ esc ( text ) } </textarea>
<div style="margin-top:12px;text-align:right"><button class="btn" onclick="copyShare()">📋 复制全文</button></div>
` ) ;
}
function copyShare ( ) {
const ta = document . querySelector ( ".share-box" ) ;
if ( ta ) copyText ( ta . value ) ;
}
/* 清空对话从头开始 */
function clearChat ( ) {
if ( ! chatHistory . length ) { toast ( "对话已经是空的" ) ; return ; }
if ( ! confirm ( "确定清空当前对话吗?将从头开始。" ) ) return ;
chatHistory . length = 0 ;
const list = $ ( "#chat-list" ) ;
const welcome = document . getElementById ( "welcome-msg" ) ;
const w = welcome ? welcome . outerHTML : "" ;
list . innerHTML = w ;
loadBoot ( ) ;
toast ( "🗑️ 对话已清空" ) ;
}
$ ( "#share-btn" ) . addEventListener ( "click" , shareChat ) ;
$ ( "#clear-btn" ) . addEventListener ( "click" , clearChat ) ;
/* 快捷问题:点击 → 自动填入输入框并自动提交(需求2) */
function askQuick ( q ) {
$ ( "#chat-input" ) . value = q ;
@@ -155,8 +276,14 @@ async function refreshChips(mark) {
} catch ( e ) { }
}
/* 聊天区事件委托:快捷语句 / 实体标记 / 新闻链接 / 卡片 */
/* 聊天区事件委托:快捷语句 / 操作按钮(复制·重新生成) / 实体标记 / 新闻链接 / 卡片 */
$ ( "#chat-list" ) . addEventListener ( "click" , ( e ) => {
const act = e . target . closest ( ".act-btn" ) ;
if ( act ) {
if ( act . title . includes ( "复制" ) ) copyMsg ( act ) ;
else if ( act . title . includes ( "重新生成" ) ) regenerate ( act ) ;
return ;
}
const q = e . target . closest ( ".quick-q" ) ;
if ( q ) { e . preventDefault ( ) ; askQuick ( q . textContent ) ; return ; }
const ent = e . target . closest ( ".entity, .ecard" ) ;