Compare commits

..

6 Commits

Author SHA1 Message Date
dependabot[bot]
75c754baf0 chore(deps): bump koffi from 2.15.2 to 2.15.6
Bumps [koffi](https://github.com/Koromix/koffi) from 2.15.2 to 2.15.6.
- [Commits](https://github.com/Koromix/koffi/commits)

---
updated-dependencies:
- dependency-name: koffi
  dependency-version: 2.15.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 11:44:46 +00:00
cc
5b6117ec28 修复见解意外启动的问题 2026-04-08 19:32:44 +08:00
cc
33188485b7 修复一些乱码问题 2026-04-08 19:26:48 +08:00
cc
08bd5e5435 Merge branch 'dev' into dev 2026-04-08 19:21:42 +08:00
cc
714827a36d 修复了一些问题 2026-04-08 19:20:30 +08:00
fatfathao
7a51d8cf64 fix: 将通知头像存储在缓存文件中,通过LRU缓存维护头像缓存数量,点击通知后可以跳转到对应的会话窗口(fixes #654) 2026-04-08 13:11:27 +08:00
14 changed files with 397 additions and 125 deletions

View File

@@ -6,6 +6,10 @@ on:
- cron: "0 16 * * *" - cron: "0 16 * * *"
workflow_dispatch: workflow_dispatch:
concurrency:
group: dev-nightly-fixed-release
cancel-in-progress: true
permissions: permissions:
contents: write contents: write
@@ -329,9 +333,21 @@ jobs:
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF EOF
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" update_release_notes() {
jq -n --rawfile body dev_release_notes.md \ local attempts=5
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \ local delay_seconds=2
> release_update_payload.json local i
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url

View File

@@ -6,6 +6,10 @@ on:
- cron: "0 16 * * *" - cron: "0 16 * * *"
workflow_dispatch: workflow_dispatch:
concurrency:
group: preview-nightly-fixed-release
cancel-in-progress: true
permissions: permissions:
contents: write contents: write
@@ -371,9 +375,21 @@ jobs:
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF EOF
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" update_release_notes() {
jq -n --rawfile body preview_release_notes.md \ local attempts=5
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \ local delay_seconds=2
> release_update_payload.json local i
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url

View File

@@ -182,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
autoUpdater.channel = nextUpdaterChannel autoUpdater.channel = nextUpdaterChannel
lastAppliedUpdaterChannel = nextUpdaterChannel lastAppliedUpdaterChannel = nextUpdaterChannel
lastAppliedUpdaterFeedUrl = nextFeedUrl lastAppliedUpdaterFeedUrl = nextFeedUrl
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel}feed=${nextFeedUrl}allowDowngrade=${autoUpdater.allowDowngrade}`)
} }
applyAutoUpdateChannel('startup') applyAutoUpdateChannel('startup')
@@ -1619,6 +1618,7 @@ function registerIpcHandlers() {
applyAutoUpdateChannel('settings') applyAutoUpdateChannel('settings')
} }
void messagePushService.handleConfigChanged(key) void messagePushService.handleConfigChanged(key)
void insightService.handleConfigChanged(key)
return result return result
}) })
@@ -1644,6 +1644,7 @@ function registerIpcHandlers() {
} }
configService?.clear() configService?.clear()
messagePushService.handleConfigCleared() messagePushService.handleConfigCleared()
insightService.handleConfigCleared()
return true return true
}) })

View File

@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
onShow: (callback: (event: any, data: any) => void) => { onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback) ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show') return () => ipcRenderer.removeAllListeners('notification:show')
}, // 监听原本发送出来的navigate-to-session事件跳转到具体的会话
onNavigateToSession: (callback: (sessionId: string) => void) => {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('navigate-to-session', listener)
} }
}, },

View File

@@ -0,0 +1,219 @@
import https from "https";
import http, { IncomingMessage } from "http";
import { promises as fs } from "fs";
import { join } from "path";
import { ConfigService } from "./config";
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
export class AvatarFileCacheService {
private static instance: AvatarFileCacheService | null = null;
// 头像文件缓存目录
private readonly cacheDir: string;
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
private readonly pendingDownloads: Map<string, Promise<string | null>> =
new Map();
// LRU 追踪:文件路径->最后访问时间
private readonly lruOrder: string[] = [];
private readonly maxCacheFiles = 100;
private constructor() {
const basePath = ConfigService.getInstance().getCacheBasePath();
this.cacheDir = join(basePath, "avatar-files");
this.ensureCacheDir();
this.loadLruOrder();
}
public static getInstance(): AvatarFileCacheService {
if (!AvatarFileCacheService.instance) {
AvatarFileCacheService.instance = new AvatarFileCacheService();
}
return AvatarFileCacheService.instance;
}
private ensureCacheDir(): void {
// 同步确保目录存在(构造函数调用)
try {
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
} catch {}
}
private async ensureCacheDirAsync(): Promise<void> {
try {
await fs.mkdir(this.cacheDir, { recursive: true });
} catch {}
}
private getFilePath(url: string): string {
// 使用URL的hash作为文件名避免特殊字符问题
const hash = this.hashString(url);
return join(this.cacheDir, `avatar_${hash}.png`);
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash).toString(16);
}
private async loadLruOrder(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
// 按修改时间排序(旧的在前)
const filesWithTime: { file: string; mtime: number }[] = [];
for (const entry of entries) {
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
try {
const stat = await fs.stat(join(this.cacheDir, entry));
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
} catch {}
}
filesWithTime.sort((a, b) => a.mtime - b.mtime);
this.lruOrder.length = 0;
this.lruOrder.push(...filesWithTime.map((f) => f.file));
} catch {}
}
private updateLru(fileName: string): void {
const index = this.lruOrder.indexOf(fileName);
if (index > -1) {
this.lruOrder.splice(index, 1);
}
this.lruOrder.push(fileName);
}
private async evictIfNeeded(): Promise<void> {
while (this.lruOrder.length >= this.maxCacheFiles) {
const oldest = this.lruOrder.shift();
if (oldest) {
try {
await fs.rm(join(this.cacheDir, oldest));
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
} catch {}
}
}
}
private async downloadAvatar(url: string): Promise<string | null> {
const localPath = this.getFilePath(url);
// 检查文件是否已存在
try {
await fs.access(localPath);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
return localPath;
} catch {}
await this.ensureCacheDirAsync();
await this.evictIfNeeded();
return new Promise<string | null>((resolve) => {
const options = {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
Referer: "https://servicewechat.com/",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
Connection: "keep-alive",
},
};
const callback = (res: IncomingMessage) => {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
await fs.writeFile(localPath, buffer);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
console.log(
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
);
resolve(localPath);
} catch {
resolve(null);
}
});
res.on("error", () => resolve(null));
};
const req = url.startsWith("https")
? https.get(url, options, callback)
: http.get(url, options, callback);
req.on("error", () => resolve(null));
req.setTimeout(10000, () => {
req.destroy();
resolve(null);
});
});
}
/**
* 获取头像本地文件路径,如果需要会下载
* 同一URL并发调用会复用同一个下载任务
*/
async getAvatarPath(url: string): Promise<string | null> {
if (!url) return null;
// 检查是否有正在进行的下载
const pending = this.pendingDownloads.get(url);
if (pending) {
return pending;
}
// 发起新下载
const downloadPromise = this.downloadAvatar(url);
this.pendingDownloads.set(url, downloadPromise);
try {
const result = await downloadPromise;
return result;
} finally {
this.pendingDownloads.delete(url);
}
}
// 清理所有缓存文件App退出时调用
async clearCache(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
for (const entry of entries) {
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
try {
await fs.rm(join(this.cacheDir, entry));
} catch {}
}
}
this.lruOrder.length = 0;
console.log("[AvatarFileCache] Cache cleared");
} catch {}
}
// 获取当前缓存的文件数量
async getCacheCount(): Promise<number> {
try {
const entries = await fs.readdir(this.cacheDir);
return entries.filter(
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
).length;
} catch {
return 0;
}
}
}
export const avatarFileCache = AvatarFileCacheService.getInstance();

View File

@@ -38,6 +38,13 @@ const API_TIMEOUT_MS = 45_000
/** 沉默天数阈值默认值 */ /** 沉默天数阈值默认值 */
const DEFAULT_SILENCE_DAYS = 3 const DEFAULT_SILENCE_DAYS = 3
const INSIGHT_CONFIG_KEYS = new Set([
'aiInsightEnabled',
'aiInsightScanIntervalHours',
'dbPath',
'decryptKey',
'myWxid'
])
// ─── 类型 ──────────────────────────────────────────────────────────────────── // ─── 类型 ────────────────────────────────────────────────────────────────────
@@ -234,15 +241,57 @@ class InsightService {
start(): void { start(): void {
if (this.started) return if (this.started) return
this.started = true this.started = true
insightLog('INFO', '已启动') void this.refreshConfiguration('startup')
this.scheduleSilenceScan()
} }
stop(): void { stop(): void {
this.started = false this.started = false
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
insightLog('INFO', '已停止')
}
async handleConfigChanged(key: string): Promise<void> {
const normalizedKey = String(key || '').trim()
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
// 数据库相关配置变更后,丢弃缓存并强制下次重连
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
this.clearRuntimeCache()
}
await this.refreshConfiguration(`config:${normalizedKey}`)
}
handleConfigCleared(): void {
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
}
private async refreshConfiguration(_reason: string): Promise<void> {
if (!this.started) return
if (!this.isEnabled()) {
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
return
}
this.scheduleSilenceScan()
}
private clearRuntimeCache(): void {
this.dbConnected = false this.dbConnected = false
this.sessionCache = null this.sessionCache = null
this.sessionCacheAt = 0 this.sessionCacheAt = 0
this.lastActivityAnalysis.clear()
this.lastSeenTimestamp.clear()
this.todayTriggers.clear()
this.todayDate = getStartOfDay()
}
private clearTimers(): void {
if (this.dbDebounceTimer !== null) { if (this.dbDebounceTimer !== null) {
clearTimeout(this.dbDebounceTimer) clearTimeout(this.dbDebounceTimer)
this.dbDebounceTimer = null this.dbDebounceTimer = null
@@ -255,7 +304,6 @@ class InsightService {
clearTimeout(this.silenceInitialDelayTimer) clearTimeout(this.silenceInitialDelayTimer)
this.silenceInitialDelayTimer = null this.silenceInitialDelayTimer = null
} }
insightLog('INFO', '已停止')
} }
/** /**
@@ -452,9 +500,12 @@ class InsightService {
// ── 沉默联系人扫描 ────────────────────────────────────────────────────────── // ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
private scheduleSilenceScan(): void { private scheduleSilenceScan(): void {
this.clearTimers()
if (!this.started || !this.isEnabled()) return
// 等待扫描完成后再安排下一次,避免并发堆积 // 等待扫描完成后再安排下一次,避免并发堆积
const scheduleNext = () => { const scheduleNext = () => {
if (!this.started) return if (!this.started || !this.isEnabled()) return
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4 const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000 const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`) insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
@@ -474,7 +525,6 @@ class InsightService {
private async runSilenceScan(): Promise<void> { private async runSilenceScan(): Promise<void> {
if (!this.isEnabled()) { if (!this.isEnabled()) {
insightLog('INFO', '沉默扫描AI 见解未启用,跳过')
return return
} }
if (this.processing) { if (this.processing) {
@@ -502,6 +552,7 @@ class InsightService {
let silentCount = 0 let silentCount = 0
for (const session of sessions) { for (const session of sessions) {
if (!this.isEnabled()) return
const sessionId = session.username?.trim() || '' const sessionId = session.username?.trim() || ''
if (!sessionId || sessionId.endsWith('@chatroom')) continue if (!sessionId || sessionId.endsWith('@chatroom')) continue
if (sessionId.toLowerCase().includes('placeholder')) continue if (sessionId.toLowerCase().includes('placeholder')) continue
@@ -654,6 +705,7 @@ class InsightService {
}): Promise<void> { }): Promise<void> {
const { sessionId, displayName, triggerReason, silentDays } = params const { sessionId, displayName, triggerReason, silentDays } = params
if (!sessionId) return if (!sessionId) return
if (!this.isEnabled()) return
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string const apiKey = this.config.get('aiInsightApiKey') as string
@@ -747,6 +799,7 @@ class InsightService {
insightLog('INFO', `模型选择跳过 ${displayName}`) insightLog('INFO', `模型选择跳过 ${displayName}`)
return return
} }
if (!this.isEnabled()) return
const insight = result.slice(0, 120) const insight = result.slice(0, 120)
const notifTitle = `见解 · ${displayName}` const notifTitle = `见解 · ${displayName}`

View File

@@ -1,8 +1,5 @@
import https from "https"; import { Notification } from "electron";
import http, { IncomingMessage } from "http"; import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
import { promises as fs } from "fs";
import { join } from "path";
import { app, Notification } from "electron";
export interface LinuxNotificationData { export interface LinuxNotificationData {
sessionId?: string; sessionId?: string;
@@ -19,11 +16,6 @@ let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map(); const activeNotifications: Map<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map(); const closeTimers: Map<number, NodeJS.Timeout> = new Map();
// 头像缓存url->localFilePath
const avatarCache: Map<string, string> = new Map();
// 缓存目录
let avatarCacheDir: string | null = null;
function nextNotificationId(): number { function nextNotificationId(): number {
const id = notificationCounter; const id = notificationCounter;
notificationCounter += 1; notificationCounter += 1;
@@ -39,91 +31,6 @@ function clearNotificationState(notificationId: number): void {
} }
} }
// 确保缓存目录存在
async function ensureCacheDir(): Promise<string> {
if (!avatarCacheDir) {
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
try {
await fs.mkdir(avatarCacheDir, { recursive: true });
} catch (error) {
console.error(
"[LinuxNotification] Failed to create avatar cache dir:",
error,
);
}
}
return avatarCacheDir;
}
// 下载头像到本地临时文件
async function downloadAvatarToLocal(url: string): Promise<string | null> {
// 检查缓存
if (avatarCache.has(url)) {
return avatarCache.get(url) || null;
}
try {
const cacheDir = await ensureCacheDir();
// 生成唯一文件名
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
const localPath = join(cacheDir, fileName);
await new Promise<void>((resolve, reject) => {
// 微信 CDN 需要特殊的请求头才能下载图片
const options = {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
Referer: "https://servicewechat.com/",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
Connection: "keep-alive",
},
};
const callback = (res: IncomingMessage) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
await fs.writeFile(localPath, buffer);
avatarCache.set(url, localPath);
resolve();
} catch (err) {
reject(err);
}
});
res.on("error", reject);
};
const req = url.startsWith("https")
? https.get(url, options, callback)
: http.get(url, options, callback);
req.on("error", reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error("Download timeout"));
});
});
console.log(
`[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`,
);
return localPath;
} catch (error) {
console.error("[LinuxNotification] Failed to download avatar:", error);
return null;
}
}
function triggerNotificationCallback(sessionId: string): void { function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) { for (const callback of notificationCallbacks) {
try { try {
@@ -149,7 +56,7 @@ export async function showLinuxNotification(
try { try {
let iconPath: string | undefined; let iconPath: string | undefined;
if (data.avatarUrl) { if (data.avatarUrl) {
iconPath = (await downloadAvatarToLocal(data.avatarUrl)) || undefined; iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
} }
const notification = new Notification({ const notification = new Notification({
@@ -248,3 +155,20 @@ export async function initLinuxNotificationService(): Promise<void> {
const caps = await getCapabilities(); const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps); console.log("[LinuxNotification] Service initialized with native API:", caps);
} }
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
for (const [id, notification] of activeNotifications) {
try {
notification.close();
} catch {}
clearNotificationState(id);
}
// 清理头像文件缓存
try {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
}

View File

@@ -27,6 +27,14 @@ export function destroyNotificationWindow() {
} }
lastNotificationData = null; lastNotificationData = null;
// Linux:关闭通知服务并清理缓存fire-and-forget不阻塞退出
if (isLinux && linuxNotificationService) {
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
});
linuxNotificationService = null;
}
if (!notificationWindow || notificationWindow.isDestroyed()) { if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null; notificationWindow = null;
return; return;

16
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.6",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -44,7 +44,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-electron": "^0.28.8", "vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6" "vite-plugin-electron-renderer": "^0.14.6"
} }
}, },
@@ -6537,9 +6537,9 @@
} }
}, },
"node_modules/koffi": { "node_modules/koffi": {
"version": "2.15.2", "version": "2.15.6",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.6.tgz",
"integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", "integrity": "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -10140,9 +10140,9 @@
} }
}, },
"node_modules/vite-plugin-electron": { "node_modules/vite-plugin-electron": {
"version": "0.28.8", "version": "0.29.1",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==", "integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {

View File

@@ -34,7 +34,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.6",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -58,7 +58,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-electron": "^0.28.8", "vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6" "vite-plugin-electron-renderer": "^0.14.6"
}, },
"pnpm": { "pnpm": {

View File

@@ -339,6 +339,21 @@ function App() {
} }
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 监听通知点击导航事件
useEffect(() => {
if (isNotificationWindow) return
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
if (!sessionId) return
// 导航到聊天页面通过URL参数让ChatPage接收sessionId
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
})
return () => {
removeListener?.()
}
}, [navigate, isNotificationWindow])
// 解锁后显示暂存的更新弹窗 // 解锁后显示暂存的更新弹窗
useEffect(() => { useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { const {
isConnected, isConnected,
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
selectSessionById selectSessionById
]) ])
// 监听URL参数中的sessionId用于通知点击导航
useEffect(() => {
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
const params = new URLSearchParams(location.search)
const urlSessionId = params.get('sessionId')
if (!urlSessionId) return
if (!isConnected || isConnecting) return
if (currentSessionId === urlSessionId) return
selectSessionById(urlSessionId)
// 选中后清除URL参数避免影响后续用户手动切换会话
navigate('/chat', { replace: true })
}, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate])
useEffect(() => { useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) { if (!isConnected || isConnecting) {

View File

@@ -1672,7 +1672,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span> <span className="form-hint"></span>
<div className="log-toggle-line"> <div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span> <span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle"> <label className="switch" htmlFor="notification-enabled-toggle">
@@ -3676,7 +3676,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="updates-hero-main"> <div className="updates-hero-main">
<span className="updates-chip"></span> <span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2> <h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p> <p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更'}</p>
</div> </div>
<div className="updates-hero-action"> <div className="updates-hero-action">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (

View File

@@ -78,6 +78,7 @@ export interface ElectronAPI {
ready: () => void ready: () => void
resize: (width: number, height: number) => void resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
} }
log: { log: {
getPath: () => Promise<string> getPath: () => Promise<string>