mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 15:08:44 +00:00
Compare commits
6 Commits
nightly-de
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c754baf0 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 |
26
.github/workflows/dev-daily-fixed.yml
vendored
26
.github/workflows/dev-daily-fixed.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: dev-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -329,9 +333,21 @@ jobs:
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body dev_release_notes.md \
|
||||
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
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
|
||||
|
||||
26
.github/workflows/preview-nightly-main.yml
vendored
26
.github/workflows/preview-nightly-main.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -371,9 +375,21 @@ jobs:
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body preview_release_notes.md \
|
||||
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
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
|
||||
|
||||
@@ -182,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||
autoUpdater.channel = nextUpdaterChannel
|
||||
lastAppliedUpdaterChannel = nextUpdaterChannel
|
||||
lastAppliedUpdaterFeedUrl = nextFeedUrl
|
||||
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||
}
|
||||
|
||||
applyAutoUpdateChannel('startup')
|
||||
@@ -1619,6 +1618,7 @@ function registerIpcHandlers() {
|
||||
applyAutoUpdateChannel('settings')
|
||||
}
|
||||
void messagePushService.handleConfigChanged(key)
|
||||
void insightService.handleConfigChanged(key)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -1644,6 +1644,7 @@ function registerIpcHandlers() {
|
||||
}
|
||||
configService?.clear()
|
||||
messagePushService.handleConfigCleared()
|
||||
insightService.handleConfigCleared()
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
219
electron/services/avatarFileCacheService.ts
Normal file
219
electron/services/avatarFileCacheService.ts
Normal 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();
|
||||
@@ -38,6 +38,13 @@ const API_TIMEOUT_MS = 45_000
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiInsightEnabled',
|
||||
'aiInsightScanIntervalHours',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -234,15 +241,57 @@ class InsightService {
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
insightLog('INFO', '已启动')
|
||||
this.scheduleSilenceScan()
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
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.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
this.lastActivityAnalysis.clear()
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
@@ -255,7 +304,6 @@ class InsightService {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,9 +500,12 @@ class InsightService {
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSilenceScan(): void {
|
||||
this.clearTimers()
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started) return
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
@@ -474,7 +525,6 @@ class InsightService {
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
insightLog('INFO', '沉默扫描:AI 见解未启用,跳过')
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
@@ -502,6 +552,7 @@ class InsightService {
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
if (!this.isEnabled()) return
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
@@ -654,6 +705,7 @@ class InsightService {
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
@@ -747,6 +799,7 @@ class InsightService {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { app, Notification } from "electron";
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
@@ -19,11 +16,6 @@ let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = 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 {
|
||||
const id = notificationCounter;
|
||||
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 {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
@@ -149,7 +56,7 @@ export async function showLinuxNotification(
|
||||
try {
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
iconPath = (await downloadAvatarToLocal(data.avatarUrl)) || undefined;
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
@@ -248,3 +155,20 @@ export async function initLinuxNotificationService(): Promise<void> {
|
||||
const caps = await getCapabilities();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ export function destroyNotificationWindow() {
|
||||
}
|
||||
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()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"koffi": "^2.15.6",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -44,7 +44,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
}
|
||||
},
|
||||
@@ -6537,9 +6537,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/koffi": {
|
||||
"version": "2.15.2",
|
||||
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz",
|
||||
"integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==",
|
||||
"version": "2.15.6",
|
||||
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.6.tgz",
|
||||
"integrity": "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10140,9 +10140,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-electron": {
|
||||
"version": "0.28.8",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
|
||||
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
|
||||
"version": "0.29.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
|
||||
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"koffi": "^2.15.6",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -58,7 +58,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
15
src/App.tsx
15
src/App.tsx
@@ -339,6 +339,21 @@ function App() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
|
||||
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
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(() => {
|
||||
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||
if (!isConnected || isConnecting) {
|
||||
|
||||
@@ -1672,7 +1672,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>新消息通知</label>
|
||||
<span className="form-hint">开启后,收<EFBFBD><EFBFBD><EFBFBD>新消息时将显示桌面弹窗通知</span>
|
||||
<span className="form-hint">开启后,收到新消息时将显示桌面弹窗通知</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="notification-enabled-toggle">
|
||||
@@ -3676,7 +3676,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="updates-hero-main">
|
||||
<span className="updates-chip">当前版本</span>
|
||||
<h2>{appVersion || '...'}</h2>
|
||||
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p>
|
||||
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
|
||||
</div>
|
||||
<div className="updates-hero-action">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -78,6 +78,7 @@ export interface ElectronAPI {
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
|
||||
}
|
||||
log: {
|
||||
getPath: () => Promise<string>
|
||||
|
||||
Reference in New Issue
Block a user