mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-23 23:01:21 +08:00
超级无敌帅气到爆炸起飞的更新
This commit is contained in:
@@ -21,6 +21,7 @@ import { videoService } from './services/videoService'
|
||||
import { snsService } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { llamaService } from './services/llamaService'
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
|
||||
|
||||
@@ -800,6 +801,64 @@ function registerIpcHandlers() {
|
||||
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) => {
|
||||
return await chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
@@ -265,5 +265,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', 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);
|
||||
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 cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
@@ -1148,11 +1148,11 @@ class ExportService {
|
||||
const emojiMd5 = msg.emojiMd5
|
||||
|
||||
if (!emojiUrl && !emojiMd5) {
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const key = emojiMd5 || String(msg.localId)
|
||||
// 根据 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 格式
|
||||
*/
|
||||
@@ -3127,85 +3286,29 @@ class ExportService {
|
||||
)
|
||||
: 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?.({
|
||||
current: 85,
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
})
|
||||
|
||||
// ================= BEGIN STREAM WRITING =================
|
||||
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
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">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -3250,15 +3353,109 @@ class ExportService {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-list" id="messageList">
|
||||
${renderedMessages || '<div class="empty">暂无消息</div>'}
|
||||
</div>
|
||||
|
||||
<!-- Virtual Scroll Container -->
|
||||
<div id="virtualScrollContainer" class="virtual-scroll-container"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="image-preview" id="imagePreview">
|
||||
<img id="imagePreviewTarget" alt="预览" />
|
||||
</div>
|
||||
|
||||
<!-- Data Injection -->
|
||||
<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 timeInput = document.getElementById('timeInput')
|
||||
const jumpBtn = document.getElementById('jumpBtn')
|
||||
@@ -3266,47 +3463,69 @@ class ExportService {
|
||||
const themeSelect = document.getElementById('themeSelect')
|
||||
const imagePreview = document.getElementById('imagePreview')
|
||||
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
|
||||
const container = document.getElementById('virtualScrollContainer')
|
||||
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 visible = messages.filter((msg) => !msg.classList.contains('hidden'))
|
||||
resultCount.textContent = \`共 \${visible.length} 条\`
|
||||
resultCount.textContent = \`共 \${currentList.length} 条\`
|
||||
}
|
||||
|
||||
// Search Logic
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', () => {
|
||||
const keyword = searchInput.value.trim().toLowerCase()
|
||||
messages.forEach((msg) => {
|
||||
const text = msg.textContent ? msg.textContent.toLowerCase() : ''
|
||||
const match = !keyword || text.includes(keyword)
|
||||
msg.classList.toggle('hidden', !match)
|
||||
})
|
||||
updateCount()
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const keyword = searchInput.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
currentList = allData;
|
||||
} else {
|
||||
// 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', () => {
|
||||
const value = timeInput.value
|
||||
if (!value) return
|
||||
const target = Math.floor(new Date(value).getTime() / 1000)
|
||||
const visibleMessages = messages.filter((msg) => !msg.classList.contains('hidden'))
|
||||
if (visibleMessages.length === 0) return
|
||||
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)
|
||||
// Find in current list
|
||||
scroller.scrollToTime(target);
|
||||
})
|
||||
|
||||
// Theme Logic
|
||||
const applyTheme = (value) => {
|
||||
document.body.setAttribute('data-theme', value)
|
||||
localStorage.setItem('weflow-export-theme', value)
|
||||
}
|
||||
|
||||
const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer'
|
||||
themeSelect.value = storedTheme
|
||||
applyTheme(storedTheme)
|
||||
@@ -3315,16 +3534,18 @@ class ExportService {
|
||||
applyTheme(event.target.value)
|
||||
})
|
||||
|
||||
document.querySelectorAll('.previewable').forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
const full = img.getAttribute('data-full')
|
||||
if (!full) return
|
||||
imagePreviewTarget.src = full
|
||||
imageZoom = 1
|
||||
imagePreviewTarget.style.transform = 'scale(1)'
|
||||
imagePreview.classList.add('active')
|
||||
})
|
||||
})
|
||||
// Image Preview (Delegation)
|
||||
container.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
if (target.classList.contains('previewable')) {
|
||||
const full = target.getAttribute('data-full')
|
||||
if (!full) return
|
||||
imagePreviewTarget.src = full
|
||||
imageZoom = 1
|
||||
imagePreviewTarget.style.transform = 'scale(1)'
|
||||
imagePreview.classList.add('active')
|
||||
}
|
||||
});
|
||||
|
||||
imagePreviewTarget.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
@@ -3351,20 +3572,24 @@ class ExportService {
|
||||
})
|
||||
|
||||
updateCount()
|
||||
console.log('WeFlow Export Loaded', allData.length);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
</html>`);
|
||||
|
||||
fs.writeFileSync(outputPath, html, 'utf-8')
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.end(() => {
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
})
|
||||
resolve({ success: true })
|
||||
})
|
||||
stream.on('error', reject)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (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 { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as https from 'https'
|
||||
import * as http from 'http'
|
||||
@@ -24,6 +24,7 @@ type DownloadProgress = {
|
||||
downloadedBytes: number
|
||||
totalBytes?: number
|
||||
percent?: number
|
||||
speed?: number
|
||||
}
|
||||
|
||||
const SENSEVOICE_MODEL: ModelInfo = {
|
||||
@@ -123,44 +124,44 @@ export class VoiceTranscribeService {
|
||||
percent: 0
|
||||
})
|
||||
|
||||
// 下载模型文件 (40%)
|
||||
// 下载模型文件 (80% 权重)
|
||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.model,
|
||||
modelPath,
|
||||
'model',
|
||||
(downloaded, total) => {
|
||||
const percent = total ? (downloaded / total) * 40 : undefined
|
||||
(downloaded, total, speed) => {
|
||||
const percent = total ? (downloaded / total) * 80 : 0
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
percent,
|
||||
speed
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 下载 tokens 文件 (30%)
|
||||
// 下载 tokens 文件 (20% 权重)
|
||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.tokens,
|
||||
tokensPath,
|
||||
'tokens',
|
||||
(downloaded, total) => {
|
||||
(downloaded, total, speed) => {
|
||||
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?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: modelSize + downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
percent,
|
||||
speed
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.info('[VoiceTranscribe] 模型下载完成')
|
||||
|
||||
console.info('[VoiceTranscribe] 所有文件下载完成')
|
||||
return { success: true, modelPath, tokensPath }
|
||||
} catch (error) {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
@@ -180,7 +181,7 @@ export class VoiceTranscribeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
||||
* 转写 WAV 音频数据
|
||||
*/
|
||||
async transcribeWavBuffer(
|
||||
wavData: Buffer,
|
||||
@@ -197,18 +198,15 @@ export class VoiceTranscribeService {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配置的语言列表,如果没有传入则从配置读取
|
||||
let supportedLanguages = languages
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||
// 如果配置中也没有或为空,使用默认值
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = ['zh', 'yue']
|
||||
}
|
||||
}
|
||||
|
||||
const { Worker } = require('worker_threads')
|
||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
@@ -224,12 +222,10 @@ export class VoiceTranscribeService {
|
||||
let finalTranscript = ''
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
@@ -239,15 +235,9 @@ export class VoiceTranscribeService {
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => {
|
||||
resolve({ success: false, error: String(err) })
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
}
|
||||
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
@@ -257,121 +247,230 @@ export class VoiceTranscribeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* 下载文件 (支持多线程)
|
||||
*/
|
||||
private downloadToFile(
|
||||
private async downloadToFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
fileName: string,
|
||||
onProgress?: (downloaded: number, total?: number) => void,
|
||||
remainingRedirects = 5
|
||||
onProgress?: (downloaded: number, total?: number, speed?: number) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
||||
if (existsSync(targetPath)) {
|
||||
unlinkSync(targetPath)
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
timeout: 30000 // 30秒连接超时
|
||||
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
|
||||
|
||||
// 1. 探测支持情况
|
||||
let probeResult
|
||||
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) => {
|
||||
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
||||
await Promise.all(promises)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理重定向
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||
if (remainingRedirects <= 0) {
|
||||
reject(new Error('重定向次数过多'))
|
||||
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : 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://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
|
||||
}
|
||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
}
|
||||
|
||||
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> {
|
||||
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) {
|
||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||
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)
|
||||
|
||||
// 设置数据接收超时(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) => {
|
||||
lastDataTime = Date.now()
|
||||
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', () => {
|
||||
clearInterval(dataTimeout)
|
||||
clearInterval(speedInterval)
|
||||
writer.close()
|
||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
||||
resolve()
|
||||
})
|
||||
|
||||
writer.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
reject(err)
|
||||
})
|
||||
response.pipe(writer)
|
||||
})
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy()
|
||||
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
||||
reject(new Error('连接超时'))
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
||||
reject(error)
|
||||
})
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
dispose() {
|
||||
if (this.recognizer) {
|
||||
try {
|
||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
||||
this.recognizer = null
|
||||
} catch (error) {
|
||||
}
|
||||
this.recognizer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"node-llama-cpp": "^3.15.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
|
||||
@@ -22,6 +22,7 @@ import SnsPage from './pages/SnsPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AIChatPage from './pages/AIChatPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
@@ -429,6 +430,7 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<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 { 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 * as configService from '../services/config'
|
||||
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;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1555,4 +1621,238 @@
|
||||
min-height: 100px;
|
||||
font-size: 13px;
|
||||
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 './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 }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||
@@ -76,7 +76,21 @@ function SettingsPage() {
|
||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||
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 [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 [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
@@ -273,6 +287,9 @@ function SettingsPage() {
|
||||
|
||||
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
|
||||
// Load Llama status after config
|
||||
void checkLlamaModelStatus()
|
||||
} catch (e: any) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
@@ -313,7 +330,12 @@ function SettingsPage() {
|
||||
}, [])
|
||||
|
||||
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') {
|
||||
setWhisperDownloadProgress(payload.percent)
|
||||
}
|
||||
@@ -582,6 +604,7 @@ function SettingsPage() {
|
||||
setWhisperModelDir(dir)
|
||||
await configService.setWhisperModelDir(dir)
|
||||
showMessage('已选择 Whisper 模型目录', true)
|
||||
await checkLlamaModelStatus()
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage('选择目录失败', false)
|
||||
@@ -617,6 +640,68 @@ function SettingsPage() {
|
||||
const handleResetWhisperModelDir = async () => {
|
||||
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 () => {
|
||||
@@ -1309,113 +1394,142 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const renderWhisperTab = () => (
|
||||
const renderModelsTab = () => (
|
||||
<div className="tab-content">
|
||||
<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" 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>
|
||||
<label>模型管理</label>
|
||||
<span className="form-hint">管理语音识别和 AI 对话模型</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>支持的语言</label>
|
||||
<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[]
|
||||
<label>语音识别模型 (Whisper)</label>
|
||||
<span className="form-hint">用于语音消息转文字功能</span>
|
||||
|
||||
if (checked) {
|
||||
newLanguages = [...transcribeLanguages, lang.code]
|
||||
} else {
|
||||
if (transcribeLanguages.length <= 1) {
|
||||
showMessage('至少需要选择一种语言', false)
|
||||
return
|
||||
}
|
||||
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
|
||||
}
|
||||
|
||||
setTranscribeLanguages(newLanguages)
|
||||
await configService.setTranscribeLanguages(newLanguages)
|
||||
showMessage(`已${checked ? '添加' : '移除'}${lang.name}`, true)
|
||||
}}
|
||||
/>
|
||||
<div className="checkbox-custom">
|
||||
<Check size={14} />
|
||||
<span>{lang.name}</span>
|
||||
<div className="setting-control vertical has-border">
|
||||
<div className="model-status-card">
|
||||
<div className="model-info">
|
||||
<div className="model-name">SenseVoiceSmall (245 MB)</div>
|
||||
<div className="model-path">
|
||||
{whisperModelStatus?.exists ? (
|
||||
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
||||
) : (
|
||||
<span className="status-indicator warning">未安装</span>
|
||||
)}
|
||||
{whisperModelDir && <div className="path-text" title={whisperModelDir}>{whisperModelDir}</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 className="progress-bar-container">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
||||
</div>
|
||||
<div className="model-actions">
|
||||
{!whisperModelStatus?.exists && !isWhisperDownloading && (
|
||||
<button
|
||||
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>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
|
||||
<Download size={18} /> 下载模型
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="sub-setting">
|
||||
<div className="sub-label">自定义模型目录</div>
|
||||
<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>
|
||||
)
|
||||
@@ -1958,7 +2072,7 @@ function SettingsPage() {
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'whisper' && renderWhisperTab()}
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{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 }>
|
||||
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 {
|
||||
|
||||
@@ -33,7 +33,8 @@ export default defineConfig({
|
||||
'fsevents',
|
||||
'whisper-node',
|
||||
'shelljs',
|
||||
'exceljs'
|
||||
'exceljs',
|
||||
'node-llama-cpp'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user