@@ -13,6 +13,10 @@ let conversations = []; // 对话列表
let currentConversation = null ; // 当前对话
let isLoading = false ;
// 功能开关
let enableThinking = false ; // 深度思考
let enableSearch = false ; // 联网搜索
// DOM 元素(初始为 null, 在 openConversation 时重新获取)
let appContainer = null ;
let messagesContainer = null ;
@@ -20,6 +24,8 @@ let messagesDiv = null;
let userInput = null ;
let sendBtn = null ;
let welcome = null ;
let thinkingBtn = null ;
let searchBtn = null ;
// 初始化
document . addEventListener ( 'DOMContentLoaded' , ( ) => {
@@ -171,6 +177,18 @@ function openConversation(id) {
<div class="messages" id="messages"></div>
</div>
<!-- 功能开关栏 -->
<div class="feature-bar">
<button class="feature-btn thinking-btn ${ enableThinking ? 'active' : '' } " id="thinkingBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>深度思考</span>
</button>
<button class="feature-btn search-btn ${ enableSearch ? 'active' : '' } " id="searchBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 7 9.5 7 14 9.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span>联网搜索</span>
</button>
</div>
<div class="input-area">
<button class="attach-btn" id="attachBtn" title="上传文件">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
@@ -211,6 +229,8 @@ function openConversation(id) {
userInput = document . getElementById ( 'userInput' ) ;
sendBtn = document . getElementById ( 'sendBtn' ) ;
welcome = document . getElementById ( 'welcome' ) ;
thinkingBtn = document . getElementById ( 'thinkingBtn' ) ;
searchBtn = document . getElementById ( 'searchBtn' ) ;
// 绑定按钮事件
const backBtn = document . getElementById ( 'backBtn' ) ;
@@ -219,6 +239,31 @@ function openConversation(id) {
const clearBtn = document . getElementById ( 'clearBtn' ) ;
if ( clearBtn ) clearBtn . addEventListener ( 'click' , clearCurrentChat ) ;
// 绑定功能开关按钮事件
if ( thinkingBtn ) {
thinkingBtn . addEventListener ( 'click' , ( ) => {
enableThinking = ! enableThinking ;
thinkingBtn . classList . toggle ( 'active' , enableThinking ) ;
// 如果开启深度思考,关闭联网搜索(智谱思考模型不支持联网)
if ( enableThinking && enableSearch ) {
enableSearch = false ;
searchBtn . classList . remove ( 'active' ) ;
}
} ) ;
}
if ( searchBtn ) {
searchBtn . addEventListener ( 'click' , ( ) => {
enableSearch = ! enableSearch ;
searchBtn . classList . toggle ( 'active' , enableSearch ) ;
// 如果开启联网搜索,关闭深度思考
if ( enableSearch && enableThinking ) {
enableThinking = false ;
thinkingBtn . classList . remove ( 'active' ) ;
}
} ) ;
}
// 绑定输入事件
userInput . addEventListener ( 'keydown' , handleKeyDown ) ;
userInput . addEventListener ( 'input' , ( e ) => autoResize ( e . target ) ) ;
@@ -349,29 +394,50 @@ async function streamGenerate(userMsgIndex) {
sendBtn . disabled = true ;
const aiMessageIndex = currentConversation . messages . length ;
currentConversation . messages . push ( { role : 'assistant' , content : '' } ) ;
// 只有开启深度思考时才添加 thinking 字段
currentConversation . messages . push ( {
role : 'assistant' ,
content : '' ,
... ( enableThinking ? { thinking : '' } : { } )
} ) ;
renderMessages ( ) ;
const lastMessageEl = messagesDiv . lastElementChild ;
const contentEl = lastMessageEl . querySelector ( '.message-content' ) ;
const thinkingEl = lastMessageEl . querySelector ( '.thinking-content' ) ;
// 深度思考模式:思考块默认展开
if ( enableThinking && thinkingEl ) {
const thinkingBlock = lastMessageEl . querySelector ( '.thinking-block' ) ;
if ( thinkingBlock ) thinkingBlock . classList . add ( 'expanded' ) ;
thinkingEl . innerHTML = '<span class="streaming-cursor">思考中...</span>' ;
}
contentEl . innerHTML = '<span class="streaming-cursor">▌</span>' ;
try {
// 构建请求体 - 统一使用 glm-4.5-air, 通过 thinking 参数控制
const requestBody = {
model : CONFIG . model ,
messages : currentConversation . messages . slice ( 0 , aiMessageIndex ) . map ( m => ( {
role : m . role ,
content : m . content
} ) ) ,
max _tokens : CONFIG . maxTokens ,
stream : true ,
thinking : {
type : enableThinking ? 'enabled' : 'disabled'
}
} ;
const response = await fetch ( CONFIG . apiUrl , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ CONFIG . apiKey } `
} ,
body : JSON . stringify ( {
model : CONFIG . model ,
messages : currentConversation . messages . slice ( 0 , aiMessageIndex ) . map ( m => ( {
role : m . role ,
content : m . content
} ) ) ,
max _tokens : CONFIG . maxTokens ,
stream : true
} )
body : JSON . stringify ( requestBody )
} ) ;
if ( ! response . ok ) {
@@ -381,6 +447,7 @@ async function streamGenerate(userMsgIndex) {
const reader = response . body . getReader ( ) ;
const decoder = new TextDecoder ( ) ;
let buffer = '' ;
let thinkingOutputStarted = false ; // 正式内容是否开始输出
while ( true ) {
const { done , value } = await reader . read ( ) ;
@@ -397,16 +464,44 @@ async function streamGenerate(userMsgIndex) {
try {
const data = JSON . parse ( jsonStr ) ;
if ( data . choices && data . choices [ 0 ] ? . delta ? . content ) {
currentConversation . messages [ aiMessageIndex ] . content += data . choices [ 0 ] . delta . content ;
contentEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . content ) + '<span class="streaming-cursor">▌</span>' ;
scrollToBottom ( ) ;
const delta = data . choices ? . [ 0 ] ? . delta ;
if ( delta ) {
// 只有开启深度思考时才处理思考内容
if ( enableThinking && ( delta . reasoning _content || delta . thinking ) ) {
const thinkingChunk = delta . reasoning _content || delta . thinking ;
currentConversation . messages [ aiMessageIndex ] . thinking += thinkingChunk ;
if ( thinkingEl ) {
thinkingEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . thinking ) + '<span class="streaming-cursor">▌</span>' ;
}
scrollToBottom ( ) ;
}
// 处理正式回复内容
if ( delta . content ) {
// 如果开启深度思考且开始输出正式内容,折叠思考块
if ( enableThinking && ! thinkingOutputStarted && currentConversation . messages [ aiMessageIndex ] . thinking ) {
thinkingOutputStarted = true ;
// 折叠思考内容
const thinkingBlock = lastMessageEl . querySelector ( '.thinking-block' ) ;
if ( thinkingBlock ) thinkingBlock . classList . remove ( 'expanded' ) ;
if ( thinkingEl ) thinkingEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . thinking ) ;
}
currentConversation . messages [ aiMessageIndex ] . content += delta . content ;
contentEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . content ) + '<span class="streaming-cursor">▌</span>' ;
scrollToBottom ( ) ;
}
}
} catch ( e ) { }
}
}
}
// 最终渲染
if ( thinkingEl && enableThinking && currentConversation . messages [ aiMessageIndex ] . thinking ) {
thinkingEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . thinking ) ;
}
contentEl . innerHTML = renderMarkdown ( currentConversation . messages [ aiMessageIndex ] . content ) ;
} catch ( error ) {
@@ -419,6 +514,58 @@ async function streamGenerate(userMsgIndex) {
currentConversation . updatedAt = Date . now ( ) ;
saveConversations ( ) ;
renderMessages ( ) ;
// 自动总结标题: 第一次对话和每隔5次对话
const totalMessages = currentConversation . messages . length ;
if ( totalMessages === 1 || totalMessages % 5 === 0 ) {
await generateConversationTitle ( ) ;
}
}
}
// 生成对话标题
async function generateConversationTitle ( ) {
if ( ! currentConversation ) return ;
// 构建对话摘要
const conversationText = currentConversation . messages . map ( m =>
` ${ m . role === 'user' ? '用户' : 'AI' } : ${ m . content . slice ( 0 , 200 ) } `
) . join ( '\n' ) ;
const titlePrompt = ` 请用不超过10个字总结以下对话的主题, 只输出标题, 不要其他内容:
${ conversationText } ` ;
try {
const response = await fetch ( CONFIG . apiUrl , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ CONFIG . apiKey } `
} ,
body : JSON . stringify ( {
model : CONFIG . model ,
messages : [ { role : 'user' , content : titlePrompt } ] ,
max _tokens : 50
} )
} ) ;
if ( response . ok ) {
const data = await response . json ( ) ;
const newTitle = data . choices ? . [ 0 ] ? . message ? . content ? . trim ( ) ;
if ( newTitle && newTitle . length > 0 && newTitle . length <= 15 ) {
currentConversation . title = newTitle ;
currentConversation . updatedAt = Date . now ( ) ;
saveConversations ( ) ;
// 更新页面标题显示
const titleEl = document . querySelector ( '.header h1' ) ;
if ( titleEl ) {
titleEl . textContent = newTitle ;
}
}
}
} catch ( error ) {
console . error ( '生成标题失败:' , error ) ;
}
}
@@ -469,21 +616,38 @@ function deleteMessage(index) {
function copyMessage ( index ) {
if ( ! currentConversation ) return ;
const content = currentConversation . messages [ index ] . content ;
const msg = currentConversation . messages [ index ] ;
// 如果是图片消息,复制图片描述或提示
let content = msg . content ;
if ( msg . image && content === '[图片]' ) {
content = '[图片: ' + ( msg . imageName || '未命名' ) + ']' ;
}
navigator. clipboard. writeText ( content ) . then ( ( ) => {
showToast ( '已复制到剪贴板' ) ;
} ) . catch ( err => {
// HTTP 环境下 navigator. clipboard 不工作,优先使用 fallback
try {
const textarea = document . createElement ( 'textarea' ) ;
textarea . value = content ;
textarea . style . position = 'fixed' ;
textarea . style . top = '0' ;
textarea . style . left = '0' ;
textarea . style . opacity = '0' ;
textarea . style . pointerEvents = 'none' ;
document . body . appendChild ( textarea ) ;
textarea . focus ( ) ;
textarea . select ( ) ;
document . execCommand ( 'copy' ) ;
const success = document . execCommand ( 'copy' ) ;
document . body . removeChild ( textarea ) ;
showToast ( '已复制到剪贴板' ) ;
} ) ;
if ( success ) {
showToast ( '已复制到剪贴板' ) ;
} else {
showToast ( '复制失败,请手动复制' ) ;
}
} catch ( err ) {
console . error ( '复制失败:' , err ) ;
showToast ( '复制失败,请手动复制' ) ;
}
}
// 清空当前对话
@@ -524,6 +688,20 @@ function renderMessages() {
contentHtml = renderMarkdown ( msg . content ) ;
}
// 思考内容块( 仅AI消息)
let thinkingHtml = '' ;
if ( ! isUser && msg . thinking ) {
thinkingHtml = `
<div class="thinking-block" onclick="toggleThinking(this)">
<div class="thinking-header">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>思考过程</span>
<svg class="thinking-arrow" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="thinking-content"> ${ renderMarkdown ( msg . thinking ) } </div>
</div> ` ;
}
const copyIcon = ` <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg> ` ;
const actions = isUser
@@ -547,6 +725,7 @@ function renderMessages() {
<div class="message ${ msg . role } " data-index=" ${ index } ">
<div class="message-avatar"> ${ avatar } </div>
<div class="message-body">
${ thinkingHtml }
<div class="message-content"> ${ contentHtml } </div>
${ actions }
</div>
@@ -568,6 +747,11 @@ function renderMessages() {
scrollToBottom ( ) ;
}
// 折叠/展开思考内容
function toggleThinking ( block ) {
block . classList . toggle ( 'expanded' ) ;
}
// ==================== 工具函数 ====================
// 渲染 Markdown