@@ -36,6 +36,8 @@ const mobileSidebarBackdropEl = document.getElementById('mobileSidebarBackdrop')
const fileInputEl = document . getElementById ( 'fileInput' ) ;
const attachBtnEl = document . getElementById ( 'attachBtn' ) ;
const attachmentListEl = document . getElementById ( 'attachmentList' ) ;
const stopBtnEl = document . getElementById ( 'stopBtn' ) ;
const exportBtnEl = document . getElementById ( 'exportBtn' ) ;
boot ( ) ;
@@ -130,6 +132,16 @@ function bindEvents() {
fileInputEl . value = '' ;
} ) ;
stopBtnEl . addEventListener ( 'click' , ( ) => {
if ( state . abortController ) {
state . abortController . abort ( ) ;
}
} ) ;
exportBtnEl . addEventListener ( 'click' , ( ) => {
exportConversation ( ) ;
} ) ;
messageInputEl . addEventListener ( 'input' , ( ) => {
autoResizeTextarea ( ) ;
} ) ;
@@ -244,8 +256,10 @@ async function loginWithKey(key, surfaceError = true) {
localStorage . removeItem ( MODEL _STORAGE ) ;
localStorage . removeItem ( ACCESS _KEY _STORAGE ) ;
renderModelDropdown ( ) ;
if ( surfaceError ) setLoginError ( error . message || '登录失败' ) ;
showLogin ( ) ;
if ( surfaceError ) {
setLoginError ( error . message || '登录失败' ) ;
showLogin ( ) ;
}
return false ;
}
}
@@ -322,6 +336,67 @@ async function addPendingFiles(files) {
continue ;
}
const kind = getAttachmentKind ( file ) ;
let parsedText = null ;
// Office 文件:前端解析提取文本
if ( kind === 'docx' ) {
try {
const arrayBuffer = await readFileAsArrayBuffer ( file ) ;
const result = await mammoth . extractRawText ( { arrayBuffer } ) ;
parsedText = result . value || '' ;
} catch ( err ) {
console . warn ( 'mammoth 解析失败' , err ) ;
}
} else if ( kind === 'xlsx' ) {
try {
const arrayBuffer = await readFileAsArrayBuffer ( file ) ;
const workbook = XLSX . read ( arrayBuffer , { type : 'array' } ) ;
const lines = [ ] ;
workbook . SheetNames . forEach ( ( sheetName ) => {
const sheet = workbook . Sheets [ sheetName ] ;
const csv = XLSX . utils . sheet _to _csv ( sheet ) ;
if ( csv . trim ( ) ) lines . push ( ` ## Sheet: ${ sheetName } \n ${ csv } ` ) ;
} ) ;
parsedText = lines . join ( '\n\n' ) ;
} catch ( err ) {
console . warn ( 'SheetJS 解析失败' , err ) ;
}
} else if ( kind === 'pptx' ) {
try {
const arrayBuffer = await readFileAsArrayBuffer ( file ) ;
// pptx 本质是 zip, 用 XLSX 的 zip 工具提取文本节点
const zip = XLSX . read ( arrayBuffer , { type : 'array' } ) ;
const textParts = [ ] ;
Object . keys ( zip . Strings || { } ) . forEach ( ( k ) => {
const v = zip . Strings [ k ] ;
if ( typeof v === 'string' && v . trim ( ) ) textParts . push ( v . trim ( ) ) ;
} ) ;
// 更可靠的方式:直接用 JSZip-like 解包( XLSX 内置 CFB/ZIP)
// 若 zip.Strings 为空则给提示
parsedText = textParts . length ? textParts . join ( '\n' ) : '( PPT 文本提取失败,内容可能为空)' ;
} catch ( err ) {
console . warn ( 'pptx 解析失败' , err ) ;
}
} else if ( kind === 'pdf' ) {
try {
const arrayBuffer = await readFileAsArrayBuffer ( file ) ;
const pdfjsLib = window [ 'pdfjs-dist/build/pdf' ] ;
pdfjsLib . GlobalWorkerOptions . workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js' ;
const pdf = await pdfjsLib . getDocument ( { data : arrayBuffer } ) . promise ;
const pageTexts = [ ] ;
for ( let i = 1 ; i <= pdf . numPages ; i ++ ) {
const page = await pdf . getPage ( i ) ;
const textContent = await page . getTextContent ( ) ;
const pageText = textContent . items . map ( ( item ) => item . str ) . join ( ' ' ) ;
if ( pageText . trim ( ) ) pageTexts . push ( ` [第 ${ i } 页] \n ${ pageText } ` ) ;
}
parsedText = pageTexts . join ( '\n\n' ) || '( PDF 文本提取为空,可能是扫描件)' ;
} catch ( err ) {
console . warn ( 'pdf.js 解析失败' , err ) ;
}
}
const dataUrl = await readFileAsDataURL ( file ) ;
const base64 = dataUrl . split ( ',' ) [ 1 ] || '' ;
state . pendingAttachments . push ( {
@@ -329,15 +404,25 @@ async function addPendingFiles(files) {
name : file . name ,
type : file . type || 'application/octet-stream' ,
size : file . size ,
kind : getAttachmentKind ( file ) ,
kind ,
dataUrl ,
base64 ,
parsedText ,
} ) ;
}
renderAttachments ( ) ;
}
function readFileAsArrayBuffer ( file ) {
return new Promise ( ( resolve , reject ) => {
const reader = new FileReader ( ) ;
reader . onload = ( ) => resolve ( reader . result ) ;
reader . onerror = ( ) => reject ( new Error ( ` 读取文件失败: ${ file . name } ` ) ) ;
reader . readAsArrayBuffer ( file ) ;
} ) ;
}
function renderAttachments ( ) {
attachmentListEl . innerHTML = '' ;
if ( ! state . pendingAttachments . length ) return ;
@@ -363,14 +448,24 @@ function renderAttachments() {
async function sendChat ( accessKey , model , conversation , assistantMessage , attachments ) {
const payload = buildChatPayload ( model , conversation , assistantMessage , attachments ) ;
const response = await fetch ( ` ${ API _BASE _URL } /api/chat ` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ accessKey } ` ,
} ,
b ody : JSON . stringify ( payload ) ,
} ) ;
state . abortController = new AbortController ( ) ;
const signal = state . abortController . signal ;
let response ;
try {
response = await fetch ( ` ${ API _BASE _URL } /api/chat ` , {
meth od : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ accessKey } ` ,
} ,
body : JSON . stringify ( payload ) ,
signal ,
} ) ;
} catch ( err ) {
if ( err . name === 'AbortError' ) return ;
throw err ;
}
if ( ! response . ok ) {
const data = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
@@ -389,41 +484,52 @@ async function sendChat(accessKey, model, conversation, assistantMessage, attach
const decoder = new TextDecoder ( ) ;
let buffer = '' ;
while ( true ) {
const { value , done } = await reader . read ( ) ;
if ( done ) break ;
try {
while ( true ) {
const { value , done } = await reader . read ( ) ;
if ( done ) break ;
buffer += decoder . decode ( value , { stream : true } ) ;
const parts = buffer . split ( '\n\n' ) ;
buffer = parts . pop ( ) || '' ;
buffer += decoder . decode ( value , { stream : true } ) ;
const parts = buffer . split ( '\n\n' ) ;
buffer = parts . pop ( ) || '' ;
for ( const part of parts ) {
const lines = part . split ( '\n' ) ;
for ( const line of lines ) {
if ( ! line . startsWith ( 'data:' ) ) continue ;
const data = line . slice ( 5 ) . trim ( ) ;
if ( ! data || data === '[DONE]' ) continue ;
for ( const part of parts ) {
const lines = part . split ( '\n' ) ;
for ( const line of lines ) {
if ( ! line . startsWith ( 'data:' ) ) continue ;
const data = line . slice ( 5 ) . trim ( ) ;
if ( ! data || data === '[DONE]' ) continue ;
try {
const json = JSON . parse ( data ) ;
const delta = json . choices ? . [ 0 ] ? . delta ? . content ;
if ( typeof delta === 'string' && delta ) {
assistantMessage . content += delta ;
updateThinkTiming ( assistantMessage ) ;
renderMessages ( ) ;
} else if ( Array . isArray ( delta ) ) {
const text = delta . map ( ( item ) => item ? . text || '' ) . join ( '' ) ;
if ( text ) {
assistantMessage . content += text ;
try {
const json = JSON . parse ( data ) ;
const delta = json . choices ? . [ 0 ] ? . delta ? . content ;
if ( typeof delta === 'string' && delta ) {
assistantMessage . content += delta ;
updateThinkTiming ( assistantMessage ) ;
renderMessages ( ) ;
} else if ( Array . isArray ( delta ) ) {
const text = delta . map ( ( item ) => item ? . text || '' ) . join ( '' ) ;
if ( text ) {
assistantMessage . content += text ;
updateThinkTiming ( assistantMessage ) ;
renderMessages ( ) ;
}
}
} catch {
// ignore invalid chunk
}
} catch {
// ignore invalid chunk
}
}
}
} catch ( err ) {
if ( err . name === 'AbortError' ) {
// 用户主动停止,保留已生成内容
updateThinkTiming ( assistantMessage , true ) ;
return ;
}
throw err ;
} finally {
state . abortController = null ;
}
}
@@ -452,31 +558,130 @@ function buildUserMessage(message, attachments) {
return { role : 'user' , content : message . content || '' } ;
}
const attachmentText = attachments
. map ( ( file ) => ` - ${ file . name } ( ${ file . type || 'application/octet-stream' } , ${ formatBytes ( file . size ) } ) ` )
. join ( '\n' ) ;
const promptText = message . content || '请读取并处理这些附件。' ;
const content = [
{
type : 'text' ,
text : ` ${ promptText } \n \n 附件列表: \n ${ attachmentText } ` ,
} ,
] ;
const content = [ ] ;
// 先放主文本
if ( promptText ) {
content . push ( { type : 'text' , text : promptText } ) ;
}
const nonInlineable = [ ] ;
attachments . forEach ( ( file ) => {
content . push ( {
type : 'image_url' ,
image _url : {
url : file . dataU rl,
} ,
} ) ;
if ( file . kind === 'image' ) {
// 图片:用 image_url + base64 data URL( OpenAI 标准)
content . push ( {
type : 'image_u rl' ,
image _url : { url : file . dataUrl } ,
} ) ;
} else if ( file . kind === 'text' ) {
// 文本类文件:解码 base64, 内联为 text block
let decoded = '' ;
try {
decoded = atob ( file . base64 ) ;
// 尝试 UTF-8 解码(处理多字节字符)
decoded = new TextDecoder ( 'utf-8' ) . decode (
Uint8Array . from ( atob ( file . base64 ) , ( c ) => c . charCodeAt ( 0 ) )
) ;
} catch {
decoded = file . base64 ;
}
content . push ( {
type : 'text' ,
text : ` 文件名: ${ file . name } \n 内容: \n \` \` \` \n ${ decoded } \n \` \` \` ` ,
} ) ;
} else if ( ( file . kind === 'docx' || file . kind === 'xlsx' || file . kind === 'pptx' || file . kind === 'pdf' ) && file . parsedText != null ) {
// Office 文档 / PDF: 使用前端解析出的文本内联
content . push ( {
type : 'text' ,
text : ` 文件名: ${ file . name } \n 内容: \n \` \` \` \n ${ file . parsedText } \n \` \` \` ` ,
} ) ;
} else {
// 其他二进制:记录下来,后面统一追加描述
nonInlineable . push ( file ) ;
}
} ) ;
return {
role : 'user' ,
content ,
} ;
// 不可内联的文件:追加描述性文本
if ( nonInlineable . length ) {
const desc = nonInlineable
. map ( ( f ) => ` - ${ f . name } ( ${ f . type || 'application/octet-stream' } , ${ formatBytes ( f . size ) } ) ` )
. join ( '\n' ) ;
content . push ( {
type : 'text' ,
text : ` 以下附件无法直接内联,仅供参考: \n ${ desc } ` ,
} ) ;
}
return { role : 'user' , content } ;
}
async function regenLastAssistant ( ) {
if ( state . sending ) return ;
const conversation = getActiveConversation ( ) ;
if ( ! conversation ) return ;
// 找到最后一条 assistant 消息,删掉它,重新发
const lastIdx = conversation . messages . map ( ( m ) => m . role ) . lastIndexOf ( 'assistant' ) ;
if ( lastIdx === - 1 ) return ;
conversation . messages . splice ( lastIdx , 1 ) ;
const accessKey = localStorage . getItem ( ACCESS _KEY _STORAGE ) || '' ;
const model = state . selectedModel ;
if ( ! accessKey || ! model ) return ;
const assistantMessage = { role : 'assistant' , content : '' , createdAt : Date . now ( ) } ;
conversation . messages . push ( assistantMessage ) ;
conversation . updatedAt = Date . now ( ) ;
state . sending = true ;
updateSendingState ( ) ;
renderMessages ( ) ;
try {
await sendChat ( accessKey , model , conversation , assistantMessage , [ ] ) ;
} catch ( err ) {
assistantMessage . content = ` 请求失败: ${ err . message } ` ;
} finally {
state . sending = false ;
updateSendingState ( ) ;
conversation . updatedAt = Date . now ( ) ;
persistConversations ( ) ;
renderConversationList ( ) ;
renderMessages ( ) ;
focusComposer ( ) ;
}
}
function exportConversation ( ) {
const conversation = getActiveConversation ( ) ;
if ( ! conversation || ! conversation . messages . length ) {
alert ( '当前会话没有内容可以导出。' ) ;
return ;
}
const lines = [ ` # ${ conversation . title || '新会话' } ` , '' ] ;
conversation . messages . forEach ( ( msg ) => {
const role = msg . role === 'assistant' ? '**NekoAI**' : '**用户**' ;
const content = String ( msg . content || '' )
. replace ( /<think>[\s\S]*?<\/think>/gi , '' )
. replace ( /<think>[\s\S]*$/gi , '' )
. trim ( ) ;
lines . push ( ` ${ role } \n \n ${ content } ` , '' ) ;
if ( msg . attachments ? . length ) {
msg . attachments . forEach ( ( f ) => lines . push ( ` > 附件: ${ f . name } ( ${ formatBytes ( f . size ) } ) ` , '' ) ) ;
}
lines . push ( '---' , '' ) ;
} ) ;
const blob = new Blob ( [ lines . join ( '\n' ) ] , { type : 'text/markdown;charset=utf-8' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = ` ${ ( conversation . title || '会话' ) . slice ( 0 , 40 ) } .md ` ;
a . click ( ) ;
URL . revokeObjectURL ( url ) ;
}
function extractAssistantText ( data ) {
@@ -678,9 +883,51 @@ function renderMessages() {
} ) ;
}
// 复制按钮(所有消息)
const copyBtn = document . createElement ( 'button' ) ;
copyBtn . className = 'message-copy-btn' ;
copyBtn . type = 'button' ;
copyBtn . textContent = '复制' ;
copyBtn . addEventListener ( 'click' , ( ) => {
const plainText = String ( message . content || '' )
. replace ( /<think>[\s\S]*?<\/think>/gi , '' )
. replace ( /<think>[\s\S]*$/gi , '' )
. trim ( ) ;
navigator . clipboard . writeText ( plainText ) . then ( ( ) => {
copyBtn . textContent = '已复制' ;
copyBtn . classList . add ( 'copied' ) ;
setTimeout ( ( ) => { copyBtn . textContent = '复制' ; copyBtn . classList . remove ( 'copied' ) ; } , 1500 ) ;
} ) ;
} ) ;
row . querySelector ( '.message-bubble' ) . appendChild ( copyBtn ) ;
// 重新生成按钮(仅 assistant 最后一条)
const isLastAssistant = message . role === 'assistant' &&
conversation . messages [ conversation . messages . length - 1 ] === message ;
if ( isLastAssistant && ! state . sending ) {
const regenBtn = document . createElement ( 'button' ) ;
regenBtn . className = 'message-regen-btn' ;
regenBtn . type = 'button' ;
regenBtn . textContent = '重新生成' ;
regenBtn . addEventListener ( 'click' , ( ) => regenLastAssistant ( ) ) ;
row . querySelector ( '.message-bubble' ) . appendChild ( regenBtn ) ;
}
messagesEl . appendChild ( row ) ;
} ) ;
// 代码块复制按钮事件委托
messagesEl . querySelectorAll ( '.copy-code-btn' ) . forEach ( ( btn ) => {
btn . addEventListener ( 'click' , ( ) => {
const code = btn . previousElementSibling ? . querySelector ( 'code' ) ? . textContent || '' ;
navigator . clipboard . writeText ( code ) . then ( ( ) => {
btn . textContent = '已复制' ;
btn . classList . add ( 'copied' ) ;
setTimeout ( ( ) => { btn . textContent = '复制' ; btn . classList . remove ( 'copied' ) ; } , 1500 ) ;
} ) ;
} ) ;
} ) ;
if ( thinkingMsg ) startLiveThinkTicker ( ) ;
else stopLiveThinkTicker ( ) ;
@@ -732,39 +979,52 @@ function renderMessageContent(container, message) {
container . appendChild ( details ) ;
}
// 主文本
const images = extractImageDataUrls ( mainText ) ;
const textOnly = removeImageDataUrls ( mainText ) . trim ( ) ;
if ( textOnly ) {
// 主文本( marked.js 会自动处理图片)
if ( mainText ) {
const div = document . createElement ( 'div' ) ;
div . innerHTML = renderMarkdown ( textOnly ) ;
container . appendChild ( div ) ;
}
if ( images . length ) {
const gallery = document . createElement ( 'div' ) ;
gallery . className = 'message-image-gallery' ;
images . forEach ( ( src ) => {
const img = document . createElement ( 'img' ) ;
img . src = src ;
div . innerHTML = renderMarkdown ( mainText ) ;
// 给 marked 渲染出来的 img 加上样式类
div . querySelectorAll ( 'img' ) . forEach ( ( img ) => {
img . className = 'message-inline-image' ;
gallery . appendChild ( img ) ;
} ) ;
container . appendChild ( gallery ) ;
container . appendChild ( div ) ;
}
}
function renderMarkdown ( input ) {
return marked . parse ( String ( input || '' ) , {
renderer : ( ( ) => {
const r = new marked . Renderer ( ) ;
r . code = ( { text , lang } ) => {
const escaped = text . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
return ` <pre class="md-pre"><code> ${ escaped } </code></pre> ` ;
} ;
return r ;
} ) ( ) ,
breaks : true ,
} ) ;
const renderer = new marked . Renderer ( ) ;
renderer . code = ( { text , lang } ) => {
const escaped = text . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
return ` <div class="md-pre-wrap"><pre class="md-pre"><code> ${ escaped } </code></pre><button class="copy-code-btn" type="button">复制</button></div> ` ;
} ;
renderer . image = ( { href , title , text } ) => {
if ( ! href ) return '' ;
const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i . test ( href ) || /video/i . test ( href ) ;
if ( isVideo ) {
return ` <video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src=" ${ href } ">
<a href=" ${ href } " target="_blank">点击查看视频</a>
</video> ` ;
}
const alt = text || title || '' ;
return ` <img class="message-inline-image" src=" ${ href } " alt=" ${ alt } "> ` ;
} ;
renderer . link = ( { href , title , text } ) => {
if ( ! href ) return text ;
const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i . test ( href ) || /video/i . test ( href ) ;
if ( isVideo ) {
return ` <video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src=" ${ href } ">
<a href=" ${ href } " target="_blank">点击查看视频</a>
</video> ` ;
}
return ` <a href=" ${ href } " target="_blank" rel="noopener noreferrer"> ${ text } </a> ` ;
} ;
return marked . parse ( String ( input || '' ) , { renderer , breaks : true } ) ;
}
function extractImageDataUrls ( text ) {
@@ -800,6 +1060,13 @@ function stopLiveThinkTicker() {
function updateSendingState ( ) {
sendBtnEl . disabled = state . sending ;
sendBtnEl . textContent = state . sending ? '发送中...' : '发送' ;
if ( state . sending ) {
stopBtnEl . classList . remove ( 'hidden' ) ;
sendBtnEl . classList . add ( 'hidden' ) ;
} else {
stopBtnEl . classList . add ( 'hidden' ) ;
sendBtnEl . classList . remove ( 'hidden' ) ;
}
}
function autoResizeTextarea ( ) {
@@ -839,6 +1106,35 @@ function loadConversations() {
function getAttachmentKind ( file ) {
if ( file . type ? . startsWith ( 'image/' ) ) return 'image' ;
const textTypes = [
'text/' ,
'application/json' ,
'application/xml' ,
'application/javascript' ,
'application/typescript' ,
'application/x-yaml' ,
'application/x-sh' ,
'application/x-python' ,
] ;
if ( textTypes . some ( ( t ) => file . type ? . startsWith ( t ) ) ) return 'text' ;
const textExts = /\.(txt|md|markdown|csv|json|xml|yaml|yml|toml|ini|cfg|conf|log|sh|bash|zsh|py|js|ts|jsx|tsx|java|c|cpp|h|hpp|cs|go|rs|rb|php|swift|kt|scala|r|sql|html|htm|css|scss|sass|less|vue|svelte|astro|diff|patch)$/i ;
if ( textExts . test ( file . name ) ) return 'text' ;
if ( file . type === 'application/pdf' ) return 'pdf' ;
const docxTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ,
'application/msword' ,
] ;
const xlsxTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ,
'application/vnd.ms-excel' ,
] ;
const pptxTypes = [
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ,
'application/vnd.ms-powerpoint' ,
] ;
if ( docxTypes . includes ( file . type ) || /\.(docx|doc)$/i . test ( file . name ) ) return 'docx' ;
if ( xlsxTypes . includes ( file . type ) || /\.(xlsx|xls)$/i . test ( file . name ) ) return 'xlsx' ;
if ( pptxTypes . includes ( file . type ) || /\.(pptx|ppt)$/i . test ( file . name ) ) return 'pptx' ;
return 'file' ;
}