mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
超级无敌帅气到爆炸起飞的更新
This commit is contained in:
@@ -21,6 +21,7 @@ import { videoService } from './services/videoService'
|
|||||||
import { snsService } from './services/snsService'
|
import { snsService } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
import { contactExportService } from './services/contactExportService'
|
||||||
import { windowsHelloService } from './services/windowsHelloService'
|
import { windowsHelloService } from './services/windowsHelloService'
|
||||||
|
import { llamaService } from './services/llamaService'
|
||||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
|
|
||||||
|
|
||||||
@@ -800,6 +801,64 @@ function registerIpcHandlers() {
|
|||||||
return await chatService.getContact(username)
|
return await chatService.getContact(username)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Llama AI
|
||||||
|
ipcMain.handle('llama:init', async () => {
|
||||||
|
return await llamaService.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:loadModel', async (_, modelPath: string) => {
|
||||||
|
return llamaService.loadModel(modelPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => {
|
||||||
|
return llamaService.createSession(systemPrompt)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => {
|
||||||
|
// We use a callback to stream back to the renderer
|
||||||
|
const webContents = event.sender
|
||||||
|
try {
|
||||||
|
if (!webContents) return { success: false, error: 'No sender' }
|
||||||
|
|
||||||
|
const response = await llamaService.chat(message, options, (token) => {
|
||||||
|
if (!webContents.isDestroyed()) {
|
||||||
|
webContents.send('llama:token', token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { success: true, response }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => {
|
||||||
|
const webContents = event.sender
|
||||||
|
try {
|
||||||
|
await llamaService.downloadModel(url, savePath, (payload) => {
|
||||||
|
if (!webContents.isDestroyed()) {
|
||||||
|
webContents.send('llama:downloadProgress', payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:getModelsPath', async () => {
|
||||||
|
return llamaService.getModelsPath()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => {
|
||||||
|
const { existsSync } = await import('fs')
|
||||||
|
return existsSync(filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => {
|
||||||
|
return llamaService.getModelStatus(modelPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||||
return await chatService.getContactAvatar(username)
|
return await chatService.getContactAvatar(username)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -265,5 +265,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
|
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Llama AI
|
||||||
|
llama: {
|
||||||
|
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
||||||
|
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
||||||
|
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
||||||
|
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
||||||
|
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
|
||||||
|
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
|
||||||
|
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
|
||||||
|
onToken: (callback: (token: string) => void) => {
|
||||||
|
const listener = (_: any, token: string) => callback(token)
|
||||||
|
ipcRenderer.on('llama:token', listener)
|
||||||
|
return () => ipcRenderer.removeListener('llama:token', listener)
|
||||||
|
},
|
||||||
|
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => {
|
||||||
|
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
|
||||||
|
ipcRenderer.on('llama:downloadProgress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -299,3 +299,33 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Virtual Scroll */
|
||||||
|
.virtual-scroll-container {
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
/* Adjust based on header height */
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroll-spacer {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroll-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
/* Override message-list to be inside virtual scroll */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -159,7 +159,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1148,11 +1148,11 @@ class ExportService {
|
|||||||
const emojiMd5 = msg.emojiMd5
|
const emojiMd5 = msg.emojiMd5
|
||||||
|
|
||||||
if (!emojiUrl && !emojiMd5) {
|
if (!emojiUrl && !emojiMd5) {
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const key = emojiMd5 || String(msg.localId)
|
const key = emojiMd5 || String(msg.localId)
|
||||||
// 根据 URL 判断扩展名
|
// 根据 URL 判断扩展名
|
||||||
@@ -3013,6 +3013,165 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVirtualScrollScript(): string {
|
||||||
|
return `
|
||||||
|
class VirtualScroller {
|
||||||
|
constructor(container, list, data, renderItem) {
|
||||||
|
this.container = container;
|
||||||
|
this.list = list;
|
||||||
|
this.data = data;
|
||||||
|
this.renderItem = renderItem;
|
||||||
|
|
||||||
|
this.rowHeight = 80; // Estimated height
|
||||||
|
this.buffer = 5;
|
||||||
|
this.heightCache = new Map();
|
||||||
|
this.visibleItems = new Set();
|
||||||
|
|
||||||
|
this.spacer = document.createElement('div');
|
||||||
|
this.spacer.className = 'virtual-scroll-spacer';
|
||||||
|
this.content = document.createElement('div');
|
||||||
|
this.content.className = 'virtual-scroll-content';
|
||||||
|
|
||||||
|
this.container.appendChild(this.spacer);
|
||||||
|
this.container.appendChild(this.content);
|
||||||
|
|
||||||
|
this.container.addEventListener('scroll', () => this.onScroll());
|
||||||
|
window.addEventListener('resize', () => this.onScroll());
|
||||||
|
|
||||||
|
this.updateTotalHeight();
|
||||||
|
this.onScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(newData) {
|
||||||
|
this.data = newData;
|
||||||
|
this.heightCache.clear();
|
||||||
|
this.content.innerHTML = '';
|
||||||
|
this.container.scrollTop = 0;
|
||||||
|
this.updateTotalHeight();
|
||||||
|
this.onScroll();
|
||||||
|
|
||||||
|
// Show/Hide empty state
|
||||||
|
if (this.data.length === 0) {
|
||||||
|
this.content.innerHTML = '<div class="empty">暂无消息</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTotalHeight() {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
|
total += this.heightCache.get(i) || this.rowHeight;
|
||||||
|
}
|
||||||
|
this.spacer.style.height = total + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll() {
|
||||||
|
if (this.data.length === 0) return;
|
||||||
|
|
||||||
|
const scrollTop = this.container.scrollTop;
|
||||||
|
const containerHeight = this.container.clientHeight;
|
||||||
|
|
||||||
|
// Find start index
|
||||||
|
let currentY = 0;
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
|
const h = this.heightCache.get(i) || this.rowHeight;
|
||||||
|
if (currentY + h > scrollTop) {
|
||||||
|
startIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentY += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find end index
|
||||||
|
let endIndex = startIndex;
|
||||||
|
let visibleHeight = 0;
|
||||||
|
for (let i = startIndex; i < this.data.length; i++) {
|
||||||
|
const h = this.heightCache.get(i) || this.rowHeight;
|
||||||
|
visibleHeight += h;
|
||||||
|
endIndex = i;
|
||||||
|
if (visibleHeight > containerHeight) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, startIndex - this.buffer);
|
||||||
|
const end = Math.min(this.data.length - 1, endIndex + this.buffer);
|
||||||
|
|
||||||
|
this.renderRange(start, end, currentY);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRange(start, end, startY) {
|
||||||
|
// Calculate offset for start item
|
||||||
|
let topOffset = 0;
|
||||||
|
for(let i=0; i<start; i++) {
|
||||||
|
topOffset += this.heightCache.get(i) || this.rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKeys = new Set();
|
||||||
|
|
||||||
|
// Create or update items
|
||||||
|
let currentTop = topOffset;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
newKeys.add(i);
|
||||||
|
const itemData = this.data[i];
|
||||||
|
|
||||||
|
let el = this.content.querySelector(\`[data-index="\${i}"]\`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.setAttribute('data-index', i);
|
||||||
|
el.className = 'virtual-item';
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.left = '0';
|
||||||
|
el.style.width = '100%';
|
||||||
|
el.innerHTML = this.renderItem(itemData, i);
|
||||||
|
|
||||||
|
// Measure height after render
|
||||||
|
this.content.appendChild(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const actualHeight = rect.height;
|
||||||
|
|
||||||
|
if (Math.abs(actualHeight - (this.heightCache.get(i) || this.rowHeight)) > 1) {
|
||||||
|
this.heightCache.set(i, actualHeight);
|
||||||
|
// If height changed significantly, we might need to adjust total height
|
||||||
|
// But for performance, maybe just do it on next scroll or rarely?
|
||||||
|
// For now, let's keep it simple. If we update inline style top, we need to know exact previous heights.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.top = currentTop + 'px';
|
||||||
|
currentTop += (this.heightCache.get(i) || this.rowHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
Array.from(this.content.children).forEach(child => {
|
||||||
|
if (child.classList.contains('empty')) return;
|
||||||
|
const idx = parseInt(child.getAttribute('data-index'));
|
||||||
|
if (!newKeys.has(idx)) {
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateTotalHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToTime(timestamp) {
|
||||||
|
const idx = this.data.findIndex(item => item.ts >= timestamp);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.scrollToIndex(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToIndex(index) {
|
||||||
|
let top = 0;
|
||||||
|
for(let i=0; i<index; i++) {
|
||||||
|
top += this.heightCache.get(i) || this.rowHeight;
|
||||||
|
}
|
||||||
|
this.container.scrollTop = top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出单个会话为 HTML 格式
|
* 导出单个会话为 HTML 格式
|
||||||
*/
|
*/
|
||||||
@@ -3127,85 +3286,29 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
const renderedMessages = sortedMessages.map((msg, index) => {
|
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
|
||||||
const mediaItem = mediaCache.get(mediaKey) || null
|
|
||||||
|
|
||||||
const isSenderMe = msg.isSend
|
|
||||||
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
|
||||||
const senderName = isSenderMe
|
|
||||||
? (myInfo.displayName || '我')
|
|
||||||
: (isGroup
|
|
||||||
? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername)
|
|
||||||
: (sessionInfo.displayName || sessionId))
|
|
||||||
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
|
||||||
const avatarHtml = avatarData
|
|
||||||
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
|
|
||||||
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
|
||||||
|
|
||||||
const timeText = this.formatTimestamp(msg.createTime)
|
|
||||||
const typeName = this.getMessageTypeName(msg.localType)
|
|
||||||
|
|
||||||
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
|
||||||
if (msg.localType === 34 && useVoiceTranscript) {
|
|
||||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
|
||||||
}
|
|
||||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
|
||||||
textContent = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
let mediaHtml = ''
|
|
||||||
if (mediaItem?.kind === 'image') {
|
|
||||||
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
|
||||||
mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
|
||||||
} else if (mediaItem?.kind === 'emoji') {
|
|
||||||
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
|
||||||
mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
|
||||||
} else if (mediaItem?.kind === 'voice') {
|
|
||||||
mediaHtml = `<audio class="message-media audio" controls src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></audio>`
|
|
||||||
} else if (mediaItem?.kind === 'video') {
|
|
||||||
const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : ''
|
|
||||||
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
|
||||||
}
|
|
||||||
|
|
||||||
const textHtml = textContent
|
|
||||||
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
|
||||||
: ''
|
|
||||||
const senderHtml = isGroup
|
|
||||||
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
|
||||||
: ''
|
|
||||||
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
|
||||||
const messageBody = `
|
|
||||||
${timeHtml}
|
|
||||||
${senderHtml}
|
|
||||||
<div class="message-content">
|
|
||||||
${mediaHtml}
|
|
||||||
${textHtml}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="message ${isSenderMe ? 'sent' : 'received'}" data-timestamp="${msg.createTime}" data-index="${index + 1}">
|
|
||||||
<div class="message-row">
|
|
||||||
<div class="avatar">${avatarHtml}</div>
|
|
||||||
<div class="bubble">
|
|
||||||
${messageBody}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}).join('\n')
|
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 85,
|
current: 60,
|
||||||
total: 100,
|
total: 100,
|
||||||
currentSession: sessionInfo.displayName,
|
currentSession: sessionInfo.displayName,
|
||||||
phase: 'writing'
|
phase: 'writing'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ================= BEGIN STREAM WRITING =================
|
||||||
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
const htmlStyles = this.loadExportHtmlStyles()
|
const htmlStyles = this.loadExportHtmlStyles()
|
||||||
const html = `<!DOCTYPE html>
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
|
|
||||||
|
const writePromise = (str: string) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!stream.write(str)) {
|
||||||
|
stream.once('drain', resolve)
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await writePromise(`<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -3250,15 +3353,109 @@ class ExportService {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-list" id="messageList">
|
|
||||||
${renderedMessages || '<div class="empty">暂无消息</div>'}
|
<!-- Virtual Scroll Container -->
|
||||||
</div>
|
<div id="virtualScrollContainer" class="virtual-scroll-container"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="image-preview" id="imagePreview">
|
<div class="image-preview" id="imagePreview">
|
||||||
<img id="imagePreviewTarget" alt="预览" />
|
<img id="imagePreviewTarget" alt="预览" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Injection -->
|
||||||
<script>
|
<script>
|
||||||
const messages = Array.from(document.querySelectorAll('.message'))
|
window.WEFLOW_DATA = [
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Write messages in chunks
|
||||||
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
const msg = sortedMessages[i]
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
const mediaItem = mediaCache.get(mediaKey) || null
|
||||||
|
|
||||||
|
const isSenderMe = msg.isSend
|
||||||
|
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
||||||
|
const senderName = isSenderMe
|
||||||
|
? (myInfo.displayName || '我')
|
||||||
|
: (isGroup
|
||||||
|
? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername)
|
||||||
|
: (sessionInfo.displayName || sessionId))
|
||||||
|
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
||||||
|
const avatarHtml = avatarData
|
||||||
|
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
|
||||||
|
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
||||||
|
|
||||||
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
|
|
||||||
|
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
||||||
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
|
}
|
||||||
|
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||||
|
textContent = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaHtml = ''
|
||||||
|
if (mediaItem?.kind === 'image') {
|
||||||
|
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
||||||
|
mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
||||||
|
} else if (mediaItem?.kind === 'emoji') {
|
||||||
|
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
||||||
|
mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
||||||
|
} else if (mediaItem?.kind === 'voice') {
|
||||||
|
mediaHtml = `<audio class="message-media audio" controls src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></audio>`
|
||||||
|
} else if (mediaItem?.kind === 'video') {
|
||||||
|
const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : ''
|
||||||
|
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const textHtml = textContent
|
||||||
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
|
: ''
|
||||||
|
const senderNameHtml = isGroup
|
||||||
|
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||||
|
: ''
|
||||||
|
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
||||||
|
const messageBody = `
|
||||||
|
${timeHtml}
|
||||||
|
${senderNameHtml}
|
||||||
|
<div class="message-content">
|
||||||
|
${mediaHtml}
|
||||||
|
${textHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Compact JSON object
|
||||||
|
const itemObj = {
|
||||||
|
i: i + 1, // index
|
||||||
|
t: msg.createTime, // timestamp
|
||||||
|
s: isSenderMe ? 1 : 0, // isSend
|
||||||
|
a: avatarHtml, // avatar HTML
|
||||||
|
b: messageBody // body HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = JSON.stringify(itemObj)
|
||||||
|
await writePromise(jsonStr + (i < sortedMessages.length - 1 ? ',\n' : '\n'))
|
||||||
|
|
||||||
|
// Report progress occasionally
|
||||||
|
if ((i + 1) % 500 === 0) {
|
||||||
|
onProgress?.({
|
||||||
|
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'writing'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writePromise(`];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
${this.getVirtualScrollScript()}
|
||||||
|
|
||||||
const searchInput = document.getElementById('searchInput')
|
const searchInput = document.getElementById('searchInput')
|
||||||
const timeInput = document.getElementById('timeInput')
|
const timeInput = document.getElementById('timeInput')
|
||||||
const jumpBtn = document.getElementById('jumpBtn')
|
const jumpBtn = document.getElementById('jumpBtn')
|
||||||
@@ -3266,47 +3463,69 @@ class ExportService {
|
|||||||
const themeSelect = document.getElementById('themeSelect')
|
const themeSelect = document.getElementById('themeSelect')
|
||||||
const imagePreview = document.getElementById('imagePreview')
|
const imagePreview = document.getElementById('imagePreview')
|
||||||
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
|
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
|
||||||
|
const container = document.getElementById('virtualScrollContainer')
|
||||||
let imageZoom = 1
|
let imageZoom = 1
|
||||||
|
|
||||||
|
// Initial Data
|
||||||
|
let allData = window.WEFLOW_DATA || [];
|
||||||
|
let currentList = allData;
|
||||||
|
|
||||||
|
// Render Item Function
|
||||||
|
const renderItem = (item, index) => {
|
||||||
|
const isSenderMe = item.s === 1;
|
||||||
|
return \`
|
||||||
|
<div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}">
|
||||||
|
<div class="message-row">
|
||||||
|
<div class="avatar">\${item.a}</div>
|
||||||
|
<div class="bubble">
|
||||||
|
\${item.b}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scroller = new VirtualScroller(container, [], currentList, renderItem);
|
||||||
|
|
||||||
const updateCount = () => {
|
const updateCount = () => {
|
||||||
const visible = messages.filter((msg) => !msg.classList.contains('hidden'))
|
resultCount.textContent = \`共 \${currentList.length} 条\`
|
||||||
resultCount.textContent = \`共 \${visible.length} 条\`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search Logic
|
||||||
|
let searchTimeout;
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
const keyword = searchInput.value.trim().toLowerCase()
|
clearTimeout(searchTimeout);
|
||||||
messages.forEach((msg) => {
|
searchTimeout = setTimeout(() => {
|
||||||
const text = msg.textContent ? msg.textContent.toLowerCase() : ''
|
const keyword = searchInput.value.trim().toLowerCase();
|
||||||
const match = !keyword || text.includes(keyword)
|
if (!keyword) {
|
||||||
msg.classList.toggle('hidden', !match)
|
currentList = allData;
|
||||||
})
|
} else {
|
||||||
updateCount()
|
// Simplified search: check raw html content (contains body text and sender name)
|
||||||
|
// Ideally we should search raw text, but we only have pre-rendered HTML in JSON 'b' (body)
|
||||||
|
// 'b' contains message content and sender name.
|
||||||
|
currentList = allData.filter(item => {
|
||||||
|
return item.b.toLowerCase().includes(keyword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scroller.setData(currentList);
|
||||||
|
updateCount();
|
||||||
|
}, 300);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Jump Logic
|
||||||
jumpBtn.addEventListener('click', () => {
|
jumpBtn.addEventListener('click', () => {
|
||||||
const value = timeInput.value
|
const value = timeInput.value
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const target = Math.floor(new Date(value).getTime() / 1000)
|
const target = Math.floor(new Date(value).getTime() / 1000)
|
||||||
const visibleMessages = messages.filter((msg) => !msg.classList.contains('hidden'))
|
// Find in current list
|
||||||
if (visibleMessages.length === 0) return
|
scroller.scrollToTime(target);
|
||||||
let targetMessage = visibleMessages.find((msg) => {
|
|
||||||
const time = Number(msg.dataset.timestamp || 0)
|
|
||||||
return time >= target
|
|
||||||
})
|
|
||||||
if (!targetMessage) {
|
|
||||||
targetMessage = visibleMessages[visibleMessages.length - 1]
|
|
||||||
}
|
|
||||||
visibleMessages.forEach((msg) => msg.classList.remove('highlight'))
|
|
||||||
targetMessage.classList.add('highlight')
|
|
||||||
targetMessage.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
setTimeout(() => targetMessage.classList.remove('highlight'), 2000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Theme Logic
|
||||||
const applyTheme = (value) => {
|
const applyTheme = (value) => {
|
||||||
document.body.setAttribute('data-theme', value)
|
document.body.setAttribute('data-theme', value)
|
||||||
localStorage.setItem('weflow-export-theme', value)
|
localStorage.setItem('weflow-export-theme', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer'
|
const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer'
|
||||||
themeSelect.value = storedTheme
|
themeSelect.value = storedTheme
|
||||||
applyTheme(storedTheme)
|
applyTheme(storedTheme)
|
||||||
@@ -3315,16 +3534,18 @@ class ExportService {
|
|||||||
applyTheme(event.target.value)
|
applyTheme(event.target.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
document.querySelectorAll('.previewable').forEach((img) => {
|
// Image Preview (Delegation)
|
||||||
img.addEventListener('click', () => {
|
container.addEventListener('click', (e) => {
|
||||||
const full = img.getAttribute('data-full')
|
const target = e.target;
|
||||||
if (!full) return
|
if (target.classList.contains('previewable')) {
|
||||||
imagePreviewTarget.src = full
|
const full = target.getAttribute('data-full')
|
||||||
imageZoom = 1
|
if (!full) return
|
||||||
imagePreviewTarget.style.transform = 'scale(1)'
|
imagePreviewTarget.src = full
|
||||||
imagePreview.classList.add('active')
|
imageZoom = 1
|
||||||
})
|
imagePreviewTarget.style.transform = 'scale(1)'
|
||||||
})
|
imagePreview.classList.add('active')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
imagePreviewTarget.addEventListener('click', (event) => {
|
imagePreviewTarget.addEventListener('click', (event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -3351,20 +3572,24 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
updateCount()
|
updateCount()
|
||||||
|
console.log('WeFlow Export Loaded', allData.length);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`);
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, html, 'utf-8')
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.end(() => {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 100,
|
current: 100,
|
||||||
total: 100,
|
total: 100,
|
||||||
currentSession: sessionInfo.displayName,
|
currentSession: sessionInfo.displayName,
|
||||||
phase: 'complete'
|
phase: 'complete'
|
||||||
|
})
|
||||||
|
resolve({ success: true })
|
||||||
|
})
|
||||||
|
stream.on('error', reject)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
|
|||||||
371
electron/services/llamaService.ts
Normal file
371
electron/services/llamaService.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import { app, BrowserWindow } from "electron";
|
||||||
|
import path from "path";
|
||||||
|
import { ConfigService } from './config';
|
||||||
|
|
||||||
|
// Define interfaces locally to avoid static import of types that might not be available or cause issues
|
||||||
|
type LlamaModel = any;
|
||||||
|
type LlamaContext = any;
|
||||||
|
type LlamaChatSession = any;
|
||||||
|
|
||||||
|
export class LlamaService {
|
||||||
|
private _model: LlamaModel | null = null;
|
||||||
|
private _context: LlamaContext | null = null;
|
||||||
|
private _sequence: any = null;
|
||||||
|
private _session: LlamaChatSession | null = null;
|
||||||
|
private _llama: any = null;
|
||||||
|
private _nodeLlamaCpp: any = null;
|
||||||
|
private configService = new ConfigService();
|
||||||
|
private _initialized = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// 延迟初始化,只在需要时初始化
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init() {
|
||||||
|
if (this._initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import to handle ESM module in CJS context
|
||||||
|
this._nodeLlamaCpp = await import("node-llama-cpp");
|
||||||
|
this._llama = await this._nodeLlamaCpp.getLlama();
|
||||||
|
this._initialized = true;
|
||||||
|
console.log("[LlamaService] Llama initialized");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LlamaService] Failed to initialize Llama:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadModel(modelPath: string) {
|
||||||
|
if (!this._llama) await this.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[LlamaService] Loading model from:", modelPath);
|
||||||
|
if (!this._llama) {
|
||||||
|
throw new Error("Llama not initialized");
|
||||||
|
}
|
||||||
|
this._model = await this._llama.loadModel({
|
||||||
|
modelPath: modelPath,
|
||||||
|
gpuLayers: 'max', // Offload all layers to GPU if possible
|
||||||
|
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this._model) throw new Error("Failed to load model");
|
||||||
|
|
||||||
|
this._context = await this._model.createContext({
|
||||||
|
contextSize: 8192, // Balanced context size for better performance
|
||||||
|
batchSize: 2048 // Increase batch size for better prompt processing speed
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this._context) throw new Error("Failed to create context");
|
||||||
|
|
||||||
|
this._sequence = this._context.getSequence();
|
||||||
|
|
||||||
|
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||||
|
this._session = new LlamaChatSession({
|
||||||
|
contextSequence: this._sequence
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[LlamaService] Model loaded successfully");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LlamaService] Failed to load model:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSession(systemPrompt?: string) {
|
||||||
|
if (!this._context) throw new Error("Model not loaded");
|
||||||
|
if (!this._nodeLlamaCpp) await this.init();
|
||||||
|
|
||||||
|
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||||
|
|
||||||
|
if (!this._sequence) {
|
||||||
|
this._sequence = this._context.getSequence();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._session = new LlamaChatSession({
|
||||||
|
contextSequence: this._sequence,
|
||||||
|
systemPrompt: systemPrompt
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
|
||||||
|
if (!this._session) throw new Error("Session not initialized");
|
||||||
|
|
||||||
|
const thinking = options.thinking ?? false;
|
||||||
|
|
||||||
|
// Sampling parameters based on mode
|
||||||
|
const samplingParams = thinking ? {
|
||||||
|
temperature: 0.6,
|
||||||
|
topP: 0.95,
|
||||||
|
topK: 20,
|
||||||
|
repeatPenalty: 1.5 // PresencePenalty=1.5
|
||||||
|
} : {
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.8,
|
||||||
|
topK: 20,
|
||||||
|
repeatPenalty: 1.5
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this._session.prompt(message, {
|
||||||
|
...samplingParams,
|
||||||
|
onTextChunk: (chunk: string) => {
|
||||||
|
onToken(chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LlamaService] Chat error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getModelStatus(modelPath: string) {
|
||||||
|
try {
|
||||||
|
const exists = fs.existsSync(modelPath);
|
||||||
|
if (!exists) {
|
||||||
|
return { exists: false, path: modelPath };
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(modelPath);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
path: modelPath,
|
||||||
|
size: stats.size
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { exists: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveModelDir(): string {
|
||||||
|
const configured = this.configService.get('whisperModelDir') as string | undefined;
|
||||||
|
if (configured) return configured;
|
||||||
|
return path.join(app.getPath('documents'), 'WeFlow', 'models');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = path.dirname(savePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(savePath)) {
|
||||||
|
fs.unlinkSync(savePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get total size and check range support
|
||||||
|
let probeResult;
|
||||||
|
try {
|
||||||
|
probeResult = await this.probeUrl(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
|
||||||
|
return this.downloadSingleThread(url, savePath, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalSize, acceptRanges, finalUrl } = probeResult;
|
||||||
|
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
|
||||||
|
|
||||||
|
if (totalSize <= 0 || !acceptRanges) {
|
||||||
|
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
|
||||||
|
return this.downloadSingleThread(finalUrl, savePath, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadCount = 4;
|
||||||
|
const chunkSize = Math.ceil(totalSize / threadCount);
|
||||||
|
const fd = fs.openSync(savePath, 'w');
|
||||||
|
|
||||||
|
let downloadedLength = 0;
|
||||||
|
let lastDownloadedLength = 0;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
let speed = 0;
|
||||||
|
|
||||||
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const duration = (now - lastTime) / 1000;
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||||
|
lastDownloadedLength = downloadedLength;
|
||||||
|
lastTime = now;
|
||||||
|
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < threadCount; i++) {
|
||||||
|
const start = i * chunkSize;
|
||||||
|
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
|
||||||
|
|
||||||
|
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||||
|
downloadedLength += bytes;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log("[LlamaService] Multi-threaded download complete");
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LlamaService] Multi-threaded download failed:", err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearInterval(speedInterval);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||||
|
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||||
|
const options = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://www.modelscope.cn/',
|
||||||
|
'Range': 'bytes=0-0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.get(url, options, (res: any) => {
|
||||||
|
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
||||||
|
const location = res.headers.location;
|
||||||
|
const nextUrl = new URL(location, url).href;
|
||||||
|
this.probeUrl(nextUrl).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||||
|
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentRange = res.headers['content-range'];
|
||||||
|
let totalSize = 0;
|
||||||
|
if (contentRange) {
|
||||||
|
const parts = contentRange.split('/');
|
||||||
|
totalSize = parseInt(parts[parts.length - 1], 10);
|
||||||
|
} else {
|
||||||
|
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
|
||||||
|
resolve({ totalSize, acceptRanges, finalUrl: url });
|
||||||
|
res.destroy();
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||||
|
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://www.modelscope.cn/',
|
||||||
|
'Range': `bytes=${start}-${end}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.get(url, options, (res: any) => {
|
||||||
|
if (res.statusCode !== 206) {
|
||||||
|
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentOffset = start;
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
|
||||||
|
currentOffset += chunk.length;
|
||||||
|
onData(chunk.length);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => resolve());
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://www.modelscope.cn/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = protocol.get(url, options, (response: any) => {
|
||||||
|
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||||
|
const location = response.headers.location;
|
||||||
|
const nextUrl = new URL(location, url).href;
|
||||||
|
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
|
||||||
|
let downloadedLength = 0;
|
||||||
|
let lastDownloadedLength = 0;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
let speed = 0;
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(savePath);
|
||||||
|
response.pipe(fileStream);
|
||||||
|
|
||||||
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const duration = (now - lastTime) / 1000;
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||||
|
lastDownloadedLength = downloadedLength;
|
||||||
|
lastTime = now;
|
||||||
|
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
response.on('data', (chunk: any) => {
|
||||||
|
downloadedLength += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('finish', () => {
|
||||||
|
clearInterval(speedInterval);
|
||||||
|
fileStream.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('error', (err: any) => {
|
||||||
|
clearInterval(speedInterval);
|
||||||
|
fs.unlink(savePath, () => { });
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getModelsPath() {
|
||||||
|
return this.resolveModelDir();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const llamaService = new LlamaService();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
@@ -24,6 +24,7 @@ type DownloadProgress = {
|
|||||||
downloadedBytes: number
|
downloadedBytes: number
|
||||||
totalBytes?: number
|
totalBytes?: number
|
||||||
percent?: number
|
percent?: number
|
||||||
|
speed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SENSEVOICE_MODEL: ModelInfo = {
|
const SENSEVOICE_MODEL: ModelInfo = {
|
||||||
@@ -123,44 +124,44 @@ export class VoiceTranscribeService {
|
|||||||
percent: 0
|
percent: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 下载模型文件 (40%)
|
// 下载模型文件 (80% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.model,
|
MODEL_DOWNLOAD_URLS.model,
|
||||||
modelPath,
|
modelPath,
|
||||||
'model',
|
'model',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const percent = total ? (downloaded / total) * 40 : undefined
|
const percent = total ? (downloaded / total) * 80 : 0
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: downloaded,
|
downloadedBytes: downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 下载 tokens 文件 (30%)
|
// 下载 tokens 文件 (20% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.tokens,
|
MODEL_DOWNLOAD_URLS.tokens,
|
||||||
tokensPath,
|
tokensPath,
|
||||||
'tokens',
|
'tokens',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||||
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
const percent = total ? 80 + (downloaded / total) * 20 : 80
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: modelSize + downloaded,
|
downloadedBytes: modelSize + downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 模型下载完成')
|
console.info('[VoiceTranscribe] 模型下载完成')
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 所有文件下载完成')
|
|
||||||
return { success: true, modelPath, tokensPath }
|
return { success: true, modelPath, tokensPath }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
@@ -180,7 +181,7 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
* 转写 WAV 音频数据
|
||||||
*/
|
*/
|
||||||
async transcribeWavBuffer(
|
async transcribeWavBuffer(
|
||||||
wavData: Buffer,
|
wavData: Buffer,
|
||||||
@@ -197,18 +198,15 @@ export class VoiceTranscribeService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的语言列表,如果没有传入则从配置读取
|
|
||||||
let supportedLanguages = languages
|
let supportedLanguages = languages
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||||
// 如果配置中也没有或为空,使用默认值
|
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = ['zh', 'yue']
|
supportedLanguages = ['zh', 'yue']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { Worker } = require('worker_threads')
|
||||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = new Worker(workerPath, {
|
||||||
@@ -224,12 +222,10 @@ export class VoiceTranscribeService {
|
|||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
|
|
||||||
worker.on('message', (msg: any) => {
|
worker.on('message', (msg: any) => {
|
||||||
|
|
||||||
if (msg.type === 'partial') {
|
if (msg.type === 'partial') {
|
||||||
onPartial?.(msg.text)
|
onPartial?.(msg.text)
|
||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
|
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.terminate()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
@@ -239,15 +235,9 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('error', (err: Error) => {
|
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||||
resolve({ success: false, error: String(err) })
|
|
||||||
})
|
|
||||||
|
|
||||||
worker.on('exit', (code: number) => {
|
worker.on('exit', (code: number) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
|
||||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -257,121 +247,230 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文件
|
* 下载文件 (支持多线程)
|
||||||
*/
|
*/
|
||||||
private downloadToFile(
|
private async downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
onProgress?: (downloaded: number, total?: number) => void,
|
onProgress?: (downloaded: number, total?: number, speed?: number) => void
|
||||||
remainingRedirects = 5
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
if (existsSync(targetPath)) {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
unlinkSync(targetPath)
|
||||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
}
|
||||||
|
|
||||||
const options = {
|
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
// 1. 探测支持情况
|
||||||
},
|
let probeResult
|
||||||
timeout: 30000 // 30秒连接超时
|
try {
|
||||||
|
probeResult = await this.probeUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
|
||||||
|
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalSize, acceptRanges, finalUrl } = probeResult
|
||||||
|
|
||||||
|
// 如果文件太小 (< 2MB) 或者不支持 Range,使用单线程
|
||||||
|
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
|
||||||
|
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||||
|
|
||||||
|
const threadCount = 4
|
||||||
|
const chunkSize = Math.ceil(totalSize / threadCount)
|
||||||
|
const fd = openSync(targetPath, 'w')
|
||||||
|
|
||||||
|
let downloadedTotal = 0
|
||||||
|
let lastDownloaded = 0
|
||||||
|
let lastTime = Date.now()
|
||||||
|
let speed = 0
|
||||||
|
|
||||||
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
const duration = (now - lastTime) / 1000
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedTotal - lastDownloaded) / duration
|
||||||
|
lastDownloaded = downloadedTotal
|
||||||
|
lastTime = now
|
||||||
|
onProgress?.(downloadedTotal, totalSize, speed)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < threadCount; i++) {
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1
|
||||||
|
|
||||||
|
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||||
|
downloadedTotal += bytes
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = protocol.get(url, options, (response) => {
|
await Promise.all(promises)
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
// Final progress update
|
||||||
|
onProgress?.(totalSize, totalSize, 0)
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理重定向
|
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
return new Promise((resolve, reject) => {
|
||||||
if (remainingRedirects <= 0) {
|
const protocol = url.startsWith('https') ? https : http
|
||||||
reject(new Error('重定向次数过多'))
|
const options = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://modelscope.cn/',
|
||||||
|
'Range': 'bytes=0-0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = protocol.get(url, options, (res) => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
|
||||||
|
const location = res.headers.location
|
||||||
|
if (location && remainingRedirects > 0) {
|
||||||
|
const nextUrl = new URL(location, url).href
|
||||||
|
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
}
|
||||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
|
||||||
.then(resolve)
|
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||||
.catch(reject)
|
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentRange = res.headers['content-range']
|
||||||
|
let totalSize = 0
|
||||||
|
if (contentRange) {
|
||||||
|
const parts = contentRange.split('/')
|
||||||
|
totalSize = parseInt(parts[parts.length - 1], 10)
|
||||||
|
} else {
|
||||||
|
totalSize = parseInt(res.headers['content-length'] || '0', 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange
|
||||||
|
resolve({ totalSize, acceptRanges, finalUrl: url })
|
||||||
|
res.destroy()
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://modelscope.cn/',
|
||||||
|
'Range': `bytes=${start}-${end}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = protocol.get(url, options, (res) => {
|
||||||
|
if (res.statusCode !== 206) {
|
||||||
|
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentOffset = start
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
writeSync(fd, chunk, 0, chunk.length, currentOffset)
|
||||||
|
currentOffset += chunk.length
|
||||||
|
onData(chunk.length)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
res.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.on('end', () => resolve())
|
||||||
|
res.on('error', reject)
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://modelscope.cn/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = protocol.get(url, options, (response) => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
|
||||||
|
const location = response.headers.location
|
||||||
|
if (location && remainingRedirects > 0) {
|
||||||
|
const nextUrl = new URL(location, url).href
|
||||||
|
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||||
let downloadedBytes = 0
|
let downloadedBytes = 0
|
||||||
|
let lastDownloaded = 0
|
||||||
|
let lastTime = Date.now()
|
||||||
|
let speed = 0
|
||||||
|
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
const duration = (now - lastTime) / 1000
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedBytes - lastDownloaded) / duration
|
||||||
|
lastDownloaded = downloadedBytes
|
||||||
|
lastTime = now
|
||||||
|
onProgress?.(downloadedBytes, totalBytes, speed)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
const writer = createWriteStream(targetPath)
|
const writer = createWriteStream(targetPath)
|
||||||
|
|
||||||
// 设置数据接收超时(60秒没有数据则超时)
|
|
||||||
let lastDataTime = Date.now()
|
|
||||||
const dataTimeout = setInterval(() => {
|
|
||||||
if (Date.now() - lastDataTime > 60000) {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
response.destroy()
|
|
||||||
writer.close()
|
|
||||||
reject(new Error('下载超时:60秒内未收到数据'))
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
response.on('data', (chunk) => {
|
||||||
lastDataTime = Date.now()
|
|
||||||
downloadedBytes += chunk.length
|
downloadedBytes += chunk.length
|
||||||
onProgress?.(downloadedBytes, totalBytes)
|
|
||||||
})
|
|
||||||
|
|
||||||
response.on('error', (error) => {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
try { writer.close() } catch { }
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
writer.on('error', (error) => {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
try { writer.close() } catch { }
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
writer.on('finish', () => {
|
writer.on('finish', () => {
|
||||||
clearInterval(dataTimeout)
|
clearInterval(speedInterval)
|
||||||
writer.close()
|
writer.close()
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
writer.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
response.pipe(writer)
|
response.pipe(writer)
|
||||||
})
|
})
|
||||||
|
request.on('error', reject)
|
||||||
request.on('timeout', () => {
|
|
||||||
request.destroy()
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
|
||||||
reject(new Error('连接超时'))
|
|
||||||
})
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理资源
|
|
||||||
*/
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.recognizer) {
|
if (this.recognizer) {
|
||||||
try {
|
this.recognizer = null
|
||||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
|
||||||
this.recognizer = null
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||||
|
|
||||||
|
|||||||
3361
package-lock.json
generated
3361
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,10 +32,13 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"node-llama-cpp": "^3.15.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import SnsPage from './pages/SnsPage'
|
|||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
|
import AIChatPage from './pages/AIChatPage'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
@@ -429,6 +430,7 @@ function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
|
|||||||
36
src/components/MessageBubble.tsx
Normal file
36
src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Bot, User } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优化后的消息气泡组件
|
||||||
|
* 使用 React.memo 避免不必要的重新渲染
|
||||||
|
*/
|
||||||
|
export const MessageBubble = React.memo<MessageBubbleProps>(({ message }) => {
|
||||||
|
return (
|
||||||
|
<div className={`message-row ${message.role}`}>
|
||||||
|
<div className="avatar">
|
||||||
|
{message.role === 'ai' ? <Bot size={24} /> : <User size={24} />}
|
||||||
|
</div>
|
||||||
|
<div className="bubble">
|
||||||
|
<div className="content">{message.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, (prevProps, nextProps) => {
|
||||||
|
// 自定义比较函数:只有内容或ID变化时才重新渲染
|
||||||
|
return prevProps.message.content === nextProps.message.content &&
|
||||||
|
prevProps.message.id === nextProps.message.id
|
||||||
|
})
|
||||||
|
|
||||||
|
MessageBubble.displayName = 'MessageBubble'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|||||||
552
src/pages/AIChatPage.scss
Normal file
552
src/pages/AIChatPage.scss
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
// AI 对话页面 - 简约大气风格
|
||||||
|
.ai-chat-page {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 顶部 Header - 已移除 ==========
|
||||||
|
// 模型选择器现已集成到输入框
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ========== 聊天区域 ==========
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息列表
|
||||||
|
.messages-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 80%;
|
||||||
|
animation: messageIn 0.3s ease-out;
|
||||||
|
|
||||||
|
// 用户消息
|
||||||
|
&.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
color: white;
|
||||||
|
border-radius: 18px 18px 4px 18px;
|
||||||
|
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 消息
|
||||||
|
&.ai {
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 18px 18px 18px 4px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.content,
|
||||||
|
.markdown-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown 样式
|
||||||
|
.markdown-content {
|
||||||
|
p {
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1em 0 0.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-spacer {
|
||||||
|
height: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入区域
|
||||||
|
.input-area {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: calc(100% - 64px);
|
||||||
|
max-width: 800px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
|
||||||
|
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
// 模型选择器
|
||||||
|
.model-selector {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.model-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: auto;
|
||||||
|
height: 36px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: dropdownIn 0.2s ease-out;
|
||||||
|
min-width: 140px;
|
||||||
|
|
||||||
|
.model-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.check {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
src/pages/AIChatPage.tsx
Normal file
391
src/pages/AIChatPage.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
|
||||||
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
|
||||||
|
import { MessageBubble } from '../components/MessageBubble'
|
||||||
|
import './AIChatPage.scss'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息数量限制,避免内存过载
|
||||||
|
const MAX_MESSAGES = 200
|
||||||
|
|
||||||
|
export default function AIChatPage() {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
||||||
|
const [modelLoaded, setModelLoaded] = useState(false)
|
||||||
|
const [loadingModel, setLoadingModel] = useState(false)
|
||||||
|
const [isThinkingMode, setIsThinkingMode] = useState(true)
|
||||||
|
const [showModelDropdown, setShowModelDropdown] = useState(false)
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
|
||||||
|
const streamingContentRef = useRef('')
|
||||||
|
const streamingMessageIdRef = useRef<string | null>(null)
|
||||||
|
const rafIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkModelsStatus()
|
||||||
|
|
||||||
|
// 初始化Llama服务(延迟初始化,用户进入此页面时启动)
|
||||||
|
const initLlama = async () => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.llama?.init()
|
||||||
|
console.log('[AIChatPage] Llama service initialized')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AIChatPage] Failed to initialize Llama:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initLlama()
|
||||||
|
|
||||||
|
// 清理函数:组件卸载时释放所有资源
|
||||||
|
return () => {
|
||||||
|
// 取消未完成的 RAF
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
rafIdRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 engine service 的回调引用
|
||||||
|
engineService.clearCallbacks()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 监听页面卸载事件,确保资源释放
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
// 清理回调和监听器
|
||||||
|
engineService.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 点击外部关闭下拉框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowModelDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
|
||||||
|
if (virtuosoRef.current && messages.length > 0) {
|
||||||
|
virtuosoRef.current.scrollToIndex({
|
||||||
|
index: messages.length - 1,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
const checkModelsStatus = async () => {
|
||||||
|
const updatedModels = await Promise.all(models.map(async (m) => {
|
||||||
|
const exists = await engineService.checkModelExists(m.path)
|
||||||
|
return { ...m, downloaded: exists }
|
||||||
|
}))
|
||||||
|
setModels(updatedModels)
|
||||||
|
|
||||||
|
// Auto-select first available model
|
||||||
|
if (!selectedModel) {
|
||||||
|
const available = updatedModels.find(m => m.downloaded)
|
||||||
|
if (available) {
|
||||||
|
setSelectedModel(available.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动加载模型
|
||||||
|
const handleLoadModel = async (modelPath?: string) => {
|
||||||
|
const pathToLoad = modelPath || selectedModel
|
||||||
|
if (!pathToLoad) return false
|
||||||
|
|
||||||
|
setLoadingModel(true)
|
||||||
|
try {
|
||||||
|
await engineService.loadModel(pathToLoad)
|
||||||
|
// Initialize session with system prompt
|
||||||
|
await engineService.createSession("You are a helpful AI assistant.")
|
||||||
|
setModelLoaded(true)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Load failed", e)
|
||||||
|
alert("模型加载失败: " + String(e))
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setLoadingModel(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择模型(如果有多个)
|
||||||
|
const handleSelectModel = (modelPath: string) => {
|
||||||
|
setSelectedModel(modelPath)
|
||||||
|
setShowModelDropdown(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用的已下载模型
|
||||||
|
const availableModels = models.filter(m => m.downloaded)
|
||||||
|
const selectedModelInfo = models.find(m => m.path === selectedModel)
|
||||||
|
|
||||||
|
// 优化的流式更新函数:使用 RAF 批量更新
|
||||||
|
const updateStreamingMessage = useCallback(() => {
|
||||||
|
if (!streamingMessageIdRef.current) return
|
||||||
|
|
||||||
|
setMessages(prev => prev.map(msg =>
|
||||||
|
msg.id === streamingMessageIdRef.current
|
||||||
|
? { ...msg, content: streamingContentRef.current }
|
||||||
|
: msg
|
||||||
|
))
|
||||||
|
|
||||||
|
rafIdRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Token 回调:使用 RAF 批量更新 UI
|
||||||
|
const handleToken = useCallback((token: string) => {
|
||||||
|
streamingContentRef.current += token
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
|
||||||
|
if (rafIdRef.current === null) {
|
||||||
|
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
|
||||||
|
}
|
||||||
|
}, [updateStreamingMessage])
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || isTyping) return
|
||||||
|
|
||||||
|
// 如果模型未加载,先自动加载
|
||||||
|
if (!modelLoaded) {
|
||||||
|
if (!selectedModel) {
|
||||||
|
alert("请先下载模型(设置页面)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const loaded = await handleLoadModel()
|
||||||
|
if (!loaded) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: input,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => {
|
||||||
|
const newMessages = [...prev, userMsg]
|
||||||
|
// 限制消息数量,避免内存过载
|
||||||
|
return newMessages.length > MAX_MESSAGES
|
||||||
|
? newMessages.slice(-MAX_MESSAGES)
|
||||||
|
: newMessages
|
||||||
|
})
|
||||||
|
setInput('')
|
||||||
|
setIsTyping(true)
|
||||||
|
|
||||||
|
// Reset textarea height
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMsgId = (Date.now() + 1).toString()
|
||||||
|
streamingContentRef.current = ''
|
||||||
|
streamingMessageIdRef.current = aiMsgId
|
||||||
|
|
||||||
|
// Optimistic update for AI message start
|
||||||
|
setMessages(prev => {
|
||||||
|
const newMessages = [...prev, {
|
||||||
|
id: aiMsgId,
|
||||||
|
role: 'ai' as const,
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}]
|
||||||
|
return newMessages.length > MAX_MESSAGES
|
||||||
|
? newMessages.slice(-MAX_MESSAGES)
|
||||||
|
: newMessages
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append thinking command based on mode
|
||||||
|
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Chat failed", e)
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'ai',
|
||||||
|
content: "❌ Error: Failed to get response from AI.",
|
||||||
|
timestamp: Date.now()
|
||||||
|
}])
|
||||||
|
} finally {
|
||||||
|
setIsTyping(false)
|
||||||
|
streamingMessageIdRef.current = null
|
||||||
|
|
||||||
|
// 确保最终状态同步
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
updateStreamingMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染模型选择按钮(集成在输入框作为下拉项)
|
||||||
|
const renderModelSelector = () => {
|
||||||
|
// 没有可用模型
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="model-btn disabled"
|
||||||
|
title="请先在设置页面下载模型"
|
||||||
|
>
|
||||||
|
<Bot size={16} />
|
||||||
|
<span>无模型</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有一个模型,直接显示
|
||||||
|
if (availableModels.length === 1) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||||
|
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
|
||||||
|
>
|
||||||
|
{loadingModel ? (
|
||||||
|
<Loader2 size={16} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Bot size={16} />
|
||||||
|
)}
|
||||||
|
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多个模型,显示下拉选择
|
||||||
|
return (
|
||||||
|
<div className="model-selector" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||||
|
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
|
||||||
|
title="点击选择模型"
|
||||||
|
>
|
||||||
|
{loadingModel ? (
|
||||||
|
<Loader2 size={16} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Bot size={16} />
|
||||||
|
)}
|
||||||
|
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
|
||||||
|
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showModelDropdown && (
|
||||||
|
<div className="model-dropdown">
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<div
|
||||||
|
key={model.path}
|
||||||
|
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectModel(model.path)}
|
||||||
|
>
|
||||||
|
<span>{model.name}</span>
|
||||||
|
{selectedModel === model.path && (
|
||||||
|
<span className="check">✓</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-chat-page">
|
||||||
|
<div className="chat-main">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="icon">
|
||||||
|
<Bot size={40} />
|
||||||
|
</div>
|
||||||
|
<h2>AI 为你服务</h2>
|
||||||
|
<p>
|
||||||
|
{availableModels.length === 0
|
||||||
|
? "请先在设置页面下载模型"
|
||||||
|
: "输入消息开始对话,模型将自动加载"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
data={messages}
|
||||||
|
className="messages-list"
|
||||||
|
initialTopMostItemIndex={messages.length - 1}
|
||||||
|
followOutput="smooth"
|
||||||
|
itemContent={(index, message) => (
|
||||||
|
<MessageBubble key={message.id} message={message} />
|
||||||
|
)}
|
||||||
|
components={{
|
||||||
|
Footer: () => <div className="list-spacer" />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="input-area">
|
||||||
|
<div className="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => {
|
||||||
|
setInput(e.target.value)
|
||||||
|
e.target.style.height = 'auto'
|
||||||
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
// Reset height after send
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
|
||||||
|
disabled={availableModels.length === 0 || loadingModel}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<div className="input-actions">
|
||||||
|
{renderModelSelector()}
|
||||||
|
<button
|
||||||
|
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
|
||||||
|
onClick={() => setIsThinkingMode(!isThinkingMode)}
|
||||||
|
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
|
||||||
|
disabled={availableModels.length === 0}
|
||||||
|
>
|
||||||
|
<Cpu size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="send-btn"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -651,14 +651,80 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 6px;
|
margin-top: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 98%, var(--primary));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-status {
|
.log-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Premium Switch Style */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
&:checked+.switch-slider {
|
||||||
|
background-color: var(--primary);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus+.switch-slider {
|
||||||
|
box-shadow: 0 0 1px var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: var(--text-tertiary);
|
||||||
|
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.language-checkboxes {
|
.language-checkboxes {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1555,4 +1621,238 @@
|
|||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
// Add styles for the new model cards
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control.vertical.has-border {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-status-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-path {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 280px;
|
||||||
|
|
||||||
|
.status-header,
|
||||||
|
.progress-info {
|
||||||
|
// specific layout class
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center; // Align vertically
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
.speed {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-mini {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, white) 100%);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent);
|
||||||
|
animation: progress-shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-setting {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.sub-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
flex: 1;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px; // Circle
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(220, 38, 38, 0.1);
|
||||||
|
border-color: rgba(220, 38, 38, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'security' | 'about'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'notification', label: '通知', icon: Bell },
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
{ id: 'models', label: '模型管理', icon: Mic },
|
||||||
{ id: 'export', label: '导出', icon: Download },
|
{ id: 'export', label: '导出', icon: Download },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||||
@@ -76,7 +76,21 @@ function SettingsPage() {
|
|||||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||||
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||||
|
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
|
const [llamaModelStatus, setLlamaModelStatus] = useState<{ exists: boolean; path?: string; size?: number } | null>(null)
|
||||||
|
const [isLlamaDownloading, setIsLlamaDownloading] = useState(false)
|
||||||
|
const [llamaDownloadProgress, setLlamaDownloadProgress] = useState(0)
|
||||||
|
const [llamaProgressData, setLlamaProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
@@ -273,6 +287,9 @@ function SettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
|
|
||||||
|
// Load Llama status after config
|
||||||
|
void checkLlamaModelStatus()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -313,7 +330,12 @@ function SettingsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number; speed?: number }) => {
|
||||||
|
setWhisperProgressData({
|
||||||
|
downloaded: payload.downloadedBytes,
|
||||||
|
total: payload.totalBytes || 0,
|
||||||
|
speed: payload.speed || 0
|
||||||
|
})
|
||||||
if (typeof payload.percent === 'number') {
|
if (typeof payload.percent === 'number') {
|
||||||
setWhisperDownloadProgress(payload.percent)
|
setWhisperDownloadProgress(payload.percent)
|
||||||
}
|
}
|
||||||
@@ -582,6 +604,7 @@ function SettingsPage() {
|
|||||||
setWhisperModelDir(dir)
|
setWhisperModelDir(dir)
|
||||||
await configService.setWhisperModelDir(dir)
|
await configService.setWhisperModelDir(dir)
|
||||||
showMessage('已选择 Whisper 模型目录', true)
|
showMessage('已选择 Whisper 模型目录', true)
|
||||||
|
await checkLlamaModelStatus()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage('选择目录失败', false)
|
showMessage('选择目录失败', false)
|
||||||
@@ -617,6 +640,68 @@ function SettingsPage() {
|
|||||||
const handleResetWhisperModelDir = async () => {
|
const handleResetWhisperModelDir = async () => {
|
||||||
setWhisperModelDir('')
|
setWhisperModelDir('')
|
||||||
await configService.setWhisperModelDir('')
|
await configService.setWhisperModelDir('')
|
||||||
|
await checkLlamaModelStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkLlamaModelStatus = async () => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
||||||
|
if (!modelsPath) return
|
||||||
|
const modelName = "Qwen3-4B-Q4_K_M.gguf" // Hardcoded preset for now
|
||||||
|
const fullPath = `${modelsPath}\\${modelName}`
|
||||||
|
// @ts-ignore
|
||||||
|
const status = await window.electronAPI.llama?.getModelStatus(fullPath)
|
||||||
|
if (status) {
|
||||||
|
setLlamaModelStatus({
|
||||||
|
exists: status.exists,
|
||||||
|
path: status.path,
|
||||||
|
size: status.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Check llama model status failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLlamaProgress = (payload: { downloaded: number; total: number; speed: number }) => {
|
||||||
|
setLlamaProgressData(payload)
|
||||||
|
if (payload.total > 0) {
|
||||||
|
setLlamaDownloadProgress((payload.downloaded / payload.total) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
const removeListener = window.electronAPI.llama?.onDownloadProgress(handleLlamaProgress)
|
||||||
|
return () => {
|
||||||
|
if (typeof removeListener === 'function') removeListener()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDownloadLlamaModel = async () => {
|
||||||
|
if (isLlamaDownloading) return
|
||||||
|
setIsLlamaDownloading(true)
|
||||||
|
setLlamaDownloadProgress(0)
|
||||||
|
try {
|
||||||
|
const modelUrl = "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf"
|
||||||
|
// @ts-ignore
|
||||||
|
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
||||||
|
const modelName = "Qwen3-4B-Q4_K_M.gguf"
|
||||||
|
const fullPath = `${modelsPath}\\${modelName}`
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const result = await window.electronAPI.llama?.downloadModel(modelUrl, fullPath)
|
||||||
|
if (result?.success) {
|
||||||
|
showMessage('Qwen3 模型下载完成', true)
|
||||||
|
await checkLlamaModelStatus()
|
||||||
|
} else {
|
||||||
|
showMessage(`模型下载失败: ${result?.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`模型下载失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsLlamaDownloading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
@@ -1309,113 +1394,142 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
const renderWhisperTab = () => (
|
const renderModelsTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>自动语音转文字</label>
|
<label>模型管理</label>
|
||||||
<span className="form-hint">语音解密后自动转写为文字(需下载模型)</span>
|
<span className="form-hint">管理语音识别和 AI 对话模型</span>
|
||||||
<div className="log-toggle-line">
|
|
||||||
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
|
||||||
<label className="switch" htmlFor="auto-transcribe-toggle">
|
|
||||||
<input
|
|
||||||
id="auto-transcribe-toggle"
|
|
||||||
className="switch-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoTranscribeVoice}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const enabled = e.target.checked
|
|
||||||
setAutoTranscribeVoice(enabled)
|
|
||||||
await configService.setAutoTranscribeVoice(enabled)
|
|
||||||
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="switch-slider" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>支持的语言</label>
|
<label>语音识别模型 (Whisper)</label>
|
||||||
<span className="form-hint">选择需要识别的语言(至少选择一种)</span>
|
<span className="form-hint">用于语音消息转文字功能</span>
|
||||||
<div className="language-checkboxes">
|
|
||||||
{[
|
|
||||||
{ code: 'zh', name: '中文' },
|
|
||||||
{ code: 'yue', name: '粤语' },
|
|
||||||
{ code: 'en', name: '英文' },
|
|
||||||
{ code: 'ja', name: '日文' },
|
|
||||||
{ code: 'ko', name: '韩文' }
|
|
||||||
].map((lang) => (
|
|
||||||
<label key={lang.code} className="language-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={transcribeLanguages.includes(lang.code)}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const checked = e.target.checked
|
|
||||||
let newLanguages: string[]
|
|
||||||
|
|
||||||
if (checked) {
|
<div className="setting-control vertical has-border">
|
||||||
newLanguages = [...transcribeLanguages, lang.code]
|
<div className="model-status-card">
|
||||||
} else {
|
<div className="model-info">
|
||||||
if (transcribeLanguages.length <= 1) {
|
<div className="model-name">SenseVoiceSmall (245 MB)</div>
|
||||||
showMessage('至少需要选择一种语言', false)
|
<div className="model-path">
|
||||||
return
|
{whisperModelStatus?.exists ? (
|
||||||
}
|
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
||||||
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
|
) : (
|
||||||
}
|
<span className="status-indicator warning">未安装</span>
|
||||||
|
)}
|
||||||
setTranscribeLanguages(newLanguages)
|
{whisperModelDir && <div className="path-text" title={whisperModelDir}>{whisperModelDir}</div>}
|
||||||
await configService.setTranscribeLanguages(newLanguages)
|
|
||||||
showMessage(`已${checked ? '添加' : '移除'}${lang.name}`, true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="checkbox-custom">
|
|
||||||
<Check size={14} />
|
|
||||||
<span>{lang.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group whisper-section">
|
|
||||||
<label>语音识别模型 (SenseVoiceSmall)</label>
|
|
||||||
<span className="form-hint">基于 Sherpa-onnx,支持中、粤、英、日、韩及情感/事件识别</span>
|
|
||||||
<span className="form-hint">模型下载目录</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="留空使用默认目录"
|
|
||||||
value={whisperModelDir}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
setWhisperModelDir(value)
|
|
||||||
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="btn-row">
|
|
||||||
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
|
||||||
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> 默认目录</button>
|
|
||||||
</div>
|
|
||||||
<div className="whisper-status-line">
|
|
||||||
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
|
|
||||||
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
|
|
||||||
</span>
|
|
||||||
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
|
|
||||||
</div>
|
|
||||||
{isWhisperDownloading ? (
|
|
||||||
<div className="whisper-progress">
|
|
||||||
<div className="progress-info">
|
|
||||||
<span>正在准备模型文件...</span>
|
|
||||||
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar-container">
|
<div className="model-actions">
|
||||||
<div className="progress-bar">
|
{!whisperModelStatus?.exists && !isWhisperDownloading && (
|
||||||
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
<button
|
||||||
</div>
|
className="btn-download"
|
||||||
|
onClick={handleDownloadWhisperModel}
|
||||||
|
>
|
||||||
|
<Download size={16} /> 下载模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isWhisperDownloading && (
|
||||||
|
<div className="download-status">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="percent">{Math.round(whisperDownloadProgress)}%</span>
|
||||||
|
{whisperProgressData.total > 0 && (
|
||||||
|
<span className="details">
|
||||||
|
{formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
|
||||||
|
<span className="speed">({formatBytes(whisperProgressData.speed)}/s)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-mini">
|
||||||
|
<div className="fill" style={{ width: `${whisperDownloadProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
|
<div className="sub-setting">
|
||||||
<Download size={18} /> 下载模型
|
<div className="sub-label">自定义模型目录</div>
|
||||||
</button>
|
<div className="path-selector">
|
||||||
)}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={whisperModelDir}
|
||||||
|
readOnly
|
||||||
|
placeholder="默认目录"
|
||||||
|
/>
|
||||||
|
<button className="btn-icon" onClick={handleSelectWhisperModelDir} title="选择目录">
|
||||||
|
<FolderOpen size={18} />
|
||||||
|
</button>
|
||||||
|
{whisperModelDir && (
|
||||||
|
<button className="btn-icon danger" onClick={handleResetWhisperModelDir} title="重置为默认">
|
||||||
|
<RotateCcw size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>AI 对话模型 (Llama)</label>
|
||||||
|
<span className="form-hint">用于 AI 助手对话功能</span>
|
||||||
|
<div className="setting-control vertical has-border">
|
||||||
|
<div className="model-status-card">
|
||||||
|
<div className="model-info">
|
||||||
|
<div className="model-name">Qwen3 4B (Preset) (~2.6GB)</div>
|
||||||
|
<div className="model-path">
|
||||||
|
{llamaModelStatus?.exists ? (
|
||||||
|
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-indicator warning">未安装</span>
|
||||||
|
)}
|
||||||
|
{llamaModelStatus?.path && <div className="path-text" title={llamaModelStatus.path}>{llamaModelStatus.path}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="model-actions">
|
||||||
|
{!llamaModelStatus?.exists && !isLlamaDownloading && (
|
||||||
|
<button
|
||||||
|
className="btn-download"
|
||||||
|
onClick={handleDownloadLlamaModel}
|
||||||
|
>
|
||||||
|
<Download size={16} /> 下载模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isLlamaDownloading && (
|
||||||
|
<div className="download-status">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="percent">{Math.floor(llamaDownloadProgress)}%</span>
|
||||||
|
<span className="metrics">
|
||||||
|
{formatBytes(llamaProgressData.downloaded)} / {formatBytes(llamaProgressData.total)}
|
||||||
|
<span className="speed">({formatBytes(llamaProgressData.speed)}/s)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-mini">
|
||||||
|
<div className="fill" style={{ width: `${llamaDownloadProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>自动转文字</label>
|
||||||
|
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="switch-input"
|
||||||
|
checked={autoTranscribeVoice}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAutoTranscribeVoice(e.target.checked)
|
||||||
|
configService.setAutoTranscribeVoice(e.target.checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1958,7 +2072,7 @@ function SettingsPage() {
|
|||||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||||
{activeTab === 'notification' && renderNotificationTab()}
|
{activeTab === 'notification' && renderNotificationTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
{activeTab === 'whisper' && renderWhisperTab()}
|
{activeTab === 'models' && renderModelsTab()}
|
||||||
{activeTab === 'export' && renderExportTab()}
|
{activeTab === 'export' && renderExportTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'security' && renderSecurityTab()}
|
{activeTab === 'security' && renderSecurityTab()}
|
||||||
|
|||||||
108
src/services/EngineService.ts
Normal file
108
src/services/EngineService.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
downloadUrl?: string; // If it's a known preset
|
||||||
|
size?: number;
|
||||||
|
downloaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESET_MODELS: ModelInfo[] = [
|
||||||
|
{
|
||||||
|
name: "Qwen3 4B (Preset)",
|
||||||
|
path: "Qwen3-4B-Q4_K_M.gguf",
|
||||||
|
downloadUrl: "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf",
|
||||||
|
downloaded: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
class EngineService {
|
||||||
|
private onTokenCallback: ((token: string) => void) | null = null;
|
||||||
|
private onProgressCallback: ((percent: number) => void) | null = null;
|
||||||
|
private _removeTokenListener: (() => void) | null = null;
|
||||||
|
private _removeProgressListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize listeners
|
||||||
|
this._removeTokenListener = window.electronAPI.llama.onToken((token: string) => {
|
||||||
|
if (this.onTokenCallback) {
|
||||||
|
this.onTokenCallback(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._removeProgressListener = window.electronAPI.llama.onDownloadProgress((percent: number) => {
|
||||||
|
if (this.onProgressCallback) {
|
||||||
|
this.onProgressCallback(percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkModelExists(filename: string): Promise<boolean> {
|
||||||
|
const modelsPath = await window.electronAPI.llama.getModelsPath();
|
||||||
|
const fullPath = `${modelsPath}\\${filename}`; // Windows path separator
|
||||||
|
// We might need to handle path separator properly or let main process handle it
|
||||||
|
// Updated preload to take full path or handling in main?
|
||||||
|
// Let's rely on main process exposing join or just checking relative to models dir if implemented
|
||||||
|
// Actually main process `checkFileExists` takes a path.
|
||||||
|
// Let's assume we construct path here or Main helps.
|
||||||
|
// Better: getModelsPath returns the directory.
|
||||||
|
return await window.electronAPI.llama.checkFileExists(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getModelsPath(): Promise<string> {
|
||||||
|
return await window.electronAPI.llama.getModelsPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadModel(filename: string) {
|
||||||
|
const modelsPath = await this.getModelsPath();
|
||||||
|
const fullPath = `${modelsPath}\\${filename}`;
|
||||||
|
console.log("Loading model:", fullPath);
|
||||||
|
return await window.electronAPI.llama.loadModel(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSession(systemPrompt?: string) {
|
||||||
|
return await window.electronAPI.llama.createSession(systemPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async chat(message: string, onToken: (token: string) => void, options?: { thinking?: boolean }) {
|
||||||
|
this.onTokenCallback = onToken;
|
||||||
|
return await window.electronAPI.llama.chat(message, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async downloadModel(url: string, filename: string, onProgress: (percent: number) => void) {
|
||||||
|
const modelsPath = await this.getModelsPath();
|
||||||
|
const fullPath = `${modelsPath}\\${filename}`;
|
||||||
|
this.onProgressCallback = onProgress;
|
||||||
|
return await window.electronAPI.llama.downloadModel(url, fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除当前的回调函数引用
|
||||||
|
* 用于避免内存泄漏
|
||||||
|
*/
|
||||||
|
public clearCallbacks() {
|
||||||
|
this.onTokenCallback = null;
|
||||||
|
this.onProgressCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放所有资源
|
||||||
|
* 包括事件监听器和回调引用
|
||||||
|
*/
|
||||||
|
public dispose() {
|
||||||
|
// 清除回调
|
||||||
|
this.clearCallbacks();
|
||||||
|
|
||||||
|
// 移除事件监听器
|
||||||
|
if (this._removeTokenListener) {
|
||||||
|
this._removeTokenListener();
|
||||||
|
this._removeTokenListener = null;
|
||||||
|
}
|
||||||
|
if (this._removeProgressListener) {
|
||||||
|
this._removeProgressListener();
|
||||||
|
this._removeProgressListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const engineService = new EngineService();
|
||||||
11
src/types/electron.d.ts
vendored
11
src/types/electron.d.ts
vendored
@@ -459,6 +459,17 @@ export interface ElectronAPI {
|
|||||||
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||||
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
||||||
}
|
}
|
||||||
|
llama: {
|
||||||
|
loadModel: (modelPath: string) => Promise<boolean>
|
||||||
|
createSession: (systemPrompt?: string) => Promise<boolean>
|
||||||
|
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }>
|
||||||
|
downloadModel: (url: string, savePath: string) => Promise<void>
|
||||||
|
getModelsPath: () => Promise<string>
|
||||||
|
checkFileExists: (filePath: string) => Promise<boolean>
|
||||||
|
getModelStatus: (modelPath: string) => Promise<{ exists: boolean; path?: string; size?: number; error?: string }>
|
||||||
|
onToken: (callback: (token: string) => void) => () => void
|
||||||
|
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export default defineConfig({
|
|||||||
'fsevents',
|
'fsevents',
|
||||||
'whisper-node',
|
'whisper-node',
|
||||||
'shelljs',
|
'shelljs',
|
||||||
'exceljs'
|
'exceljs',
|
||||||
|
'node-llama-cpp'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user