+ {!selectedGroup ? (
+
+ ) : (
+ <>
+
+
+
+ {selectedGroup.name || selectedGroup.talker}
+
+
+
+
+
+
分析所选时间段全部消息
+
+ {ANALYZE_PRESETS.map((preset) => (
+
applyAnalyzePreset(preset)}
+ >
+ {preset.label}
+
+ ))}
+
+
+ { setAnalyzeStartDate(e.target.value); setAnalyzePreset('') }}
+ style={{ fontSize: 11, flex: 1, minWidth: 0 }}
+ />
+ 至
+ { setAnalyzeEndDate(e.target.value); setAnalyzePreset('') }}
+ style={{ fontSize: 11, flex: 1, minWidth: 0 }}
+ />
+
+
+
+
+
+
+
+
+ {analyzing && (
+
+
+ 分析中...
+
+ {selectedInitState.progress
+ ? `${selectedInitState.progress.processed ?? 0} / ${selectedInitState.progress.total > 0 ? selectedInitState.progress.total : '?'}`
+ : '准备中...'}
+
+
+
+
0
+ ? `${Math.min(100, (selectedInitState.progress.processed / selectedInitState.progress.total) * 100)}%`
+ : '0%',
+ background: 'var(--accent)',
+ borderRadius: 2,
+ transition: 'width 0.5s ease',
+ }} />
+
+
+ )}
+
+
+
+
setNewTopicTitle(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateTopic()}
+ />
+
+
+
+
+ {loading && (
+
加载中...
+ )}
+ {!loading && topics.length === 0 && (
+
+ 暂无话题。
+
+ )}
+ {topics.map((topic) => (
+
handleSelectTopic(topic)}
+ style={{
+ padding: '10px 14px',
+ cursor: 'pointer',
+ borderBottom: '1px solid var(--border)',
+ background: selectedTopic?.id === topic.id ? 'var(--surface-2)' : 'transparent',
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 8,
+ }}
+ >
+
+
+ {topic.title}
+
+
+ {dayjs(topic.created_at).format('MM-DD HH:mm')} / {topic.status}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ {!selectedTopic ? (
+
+ ) : (
+ <>
+
+
{selectedTopic.title}
+
+
+
+
+ {summarizing && (
+
+
+ 处理中...
+ {summarizeProgress ? `${summarizeProgress.processed ?? 0}/${summarizeProgress.total > 0 ? summarizeProgress.total : '?'}` : '准备中...'}
+
+
+
0
+ ? `${Math.min(100, (summarizeProgress.processed / summarizeProgress.total) * 100)}%`
+ : '30%',
+ background: 'var(--accent)',
+ borderRadius: 2,
+ transition: 'width 0.5s ease',
+ }} />
+
+
+ )}
+
+
+
+
+
+ {topicDetail?.knowledge_doc && (
+
+
+
+
+ 更新于 {dayjs(topicDetail.knowledge_doc.updated_at).format('YYYY-MM-DD HH:mm')}
+
+
+ )}
+
+ {topicDetail && (
+
+
+ 关联消息({topicDetail.messages?.length ?? 0} 条)
+
+ {(topicDetail.messages || []).length === 0 && (
+
该话题暂无消息。
+ )}
+ {(topicDetail.messages || []).map((message, index) => (
+
+ ))}
+
+ )}
+
+ {!topicDetail && (
+
加载中...
+ )}
+
+ >
+ )}
+
+
+
+ {showMsgManager && selectedTopic && (
+
+
+
+ {selectedTopic.title} / 消息管理
+
+
+
+
+ setMsgStartDate(e.target.value)} style={{ fontSize: 12 }} />
+ 至
+ setMsgEndDate(e.target.value)} style={{ fontSize: 12 }} />
+
+ setMsgSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fetchMsgs()} />
+
+
+
+
+
+
+ 聊天记录消息
+
+
+ {msgLoading &&
加载中...
}
+ {!msgLoading && (() => {
+ const topicSeqs = new Set((topicDetail?.messages || []).map((m) => Number(m.seq)))
+ return allMsgs.map((message) => (
+
+
+
+
+
+ {topicSeqs.has(Number(message.id)) ? (
+
+ ) : (
+
+ )}
+
+
+ ))
+ })()}
+
+
+
+
+
+ 话题消息
+
+
+ {(topicDetail?.messages || []).length === 0 && (
+
暂无已选消息。
+ )}
+ {(topicDetail?.messages || []).map((message) => (
+
+
+
{message.sender_name || message.sender}
+
+ {topicMsgPreview(message)}
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {showGroupPrompt && selectedGroup && (
+
+
+
+
+
AI 分析提示词
+
{selectedGroup.name || selectedGroup.talker}
+
+
+
+
+
+ 留空则使用设置页的全局默认提示词;填写后只对当前群生效。
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/chatlab-web/frontend/src/utils/wordExport.js b/chatlab-web/frontend/src/utils/wordExport.js
new file mode 100644
index 0000000..4c03c14
--- /dev/null
+++ b/chatlab-web/frontend/src/utils/wordExport.js
@@ -0,0 +1,427 @@
+import {
+ Document,
+ Packer,
+ Paragraph,
+ TextRun,
+ HeadingLevel,
+ AlignmentType,
+ Table,
+ TableRow,
+ TableCell,
+ WidthType,
+ BorderStyle,
+ ShadingType,
+ ImageRun,
+} from 'docx'
+
+// 与 ReportDocumentView 的 WORD_PAGE_CSS 对齐的关键样式常量。
+// docx 用半点(half-point)表示字号,1pt=2 half-point;用 twips 表示长度,1in=1440 twips。
+const FONT_FAMILY = 'Microsoft YaHei'
+const FONT_BODY_HALF = 28 // 14pt 正文
+const FONT_TABLE_HALF = 26 // 13pt 表格
+const FONT_H1_HALF = 48 // 24pt
+const FONT_H2_HALF = 34 // 17pt
+const FONT_H3_HALF = 30 // 15pt
+const COLOR_TITLE = '111827'
+const COLOR_BODY = '1F2937'
+const COLOR_TABLE_BORDER = '9CA3AF'
+const COLOR_TH_BG = 'F3F4F6'
+const COLOR_HR = 'D1D5DB'
+const IMAGE_MAX_WIDTH = 480
+const IMAGE_MAX_HEIGHT = 320
+
+const PAGE_MARGIN_VERT = 1134 // ~2 cm
+const PAGE_MARGIN_HORI = 1296 // ~2.25 cm
+
+const SOLID_BORDER = (color = COLOR_TABLE_BORDER, size = 6) => ({
+ style: BorderStyle.SINGLE,
+ size,
+ color,
+})
+
+function sanitizeFileName(value = '售后报告') {
+ const cleaned = String(value)
+ .replace(/[\\/:*?"<>|]/g, '_')
+ .replace(/\s+/g, ' ')
+ .trim()
+ return cleaned.slice(0, 80) || '售后报告'
+}
+
+function splitTableRow(line = '') {
+ return line
+ .trim()
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map((cell) => cell.trim())
+}
+
+function isTableSeparator(line = '') {
+ return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
+}
+
+// 行内 markdown:**bold**、`code`
+function parseInlineRuns(text, baseProps = {}) {
+ const runs = []
+ const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g
+ let last = 0
+ let m
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last) {
+ runs.push(new TextRun({ text: text.slice(last, m.index), font: FONT_FAMILY, ...baseProps }))
+ }
+ if (m[2] != null) {
+ runs.push(new TextRun({ text: m[2], font: FONT_FAMILY, bold: true, ...baseProps }))
+ } else if (m[3] != null) {
+ runs.push(new TextRun({ text: m[3], font: 'Consolas', ...baseProps }))
+ }
+ last = m.index + m[0].length
+ }
+ if (last < text.length) {
+ runs.push(new TextRun({ text: text.slice(last), font: FONT_FAMILY, ...baseProps }))
+ }
+ if (runs.length === 0) {
+ runs.push(new TextRun({ text: '', font: FONT_FAMILY, ...baseProps }))
+ }
+ return runs
+}
+
+function makeHeadingParagraph(level, text) {
+ if (level === 1) {
+ return new Paragraph({
+ alignment: AlignmentType.CENTER,
+ heading: HeadingLevel.HEADING_1,
+ spacing: { before: 0, after: 240 },
+ border: {
+ bottom: { style: BorderStyle.SINGLE, size: 12, color: COLOR_TITLE, space: 4 },
+ },
+ children: parseInlineRuns(text, {
+ bold: true,
+ size: FONT_H1_HALF,
+ color: COLOR_TITLE,
+ }),
+ })
+ }
+ if (level === 2) {
+ return new Paragraph({
+ heading: HeadingLevel.HEADING_2,
+ spacing: { before: 320, after: 120 },
+ border: {
+ bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
+ },
+ children: parseInlineRuns(text, {
+ bold: true,
+ size: FONT_H2_HALF,
+ color: COLOR_TITLE,
+ }),
+ })
+ }
+ return new Paragraph({
+ heading: HeadingLevel.HEADING_3,
+ spacing: { before: 200, after: 80 },
+ children: parseInlineRuns(text, {
+ bold: true,
+ size: FONT_H3_HALF,
+ color: COLOR_BODY,
+ }),
+ })
+}
+
+function makeBodyParagraph(text) {
+ return new Paragraph({
+ spacing: { before: 0, after: 80, line: 360 },
+ children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
+ })
+}
+
+function makeListParagraph(text, ordered) {
+ return new Paragraph({
+ spacing: { before: 0, after: 60, line: 360 },
+ indent: { left: 480, hanging: 240 },
+ bullet: ordered ? undefined : { level: 0 },
+ numbering: ordered ? { reference: 'ordered-default', level: 0 } : undefined,
+ children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
+ })
+}
+
+function makeHrParagraph() {
+ return new Paragraph({
+ spacing: { before: 200, after: 200 },
+ border: {
+ bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
+ },
+ children: [new TextRun({ text: '', font: FONT_FAMILY })],
+ })
+}
+
+function normalizeImageUrl(url = '') {
+ if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url
+ return `/${url.replace(/^\.?\//, '')}`
+}
+
+function inferImageType(contentType = '', url = '') {
+ const lower = `${contentType} ${url}`.toLowerCase()
+ if (lower.includes('png')) return 'png'
+ if (lower.includes('gif')) return 'gif'
+ if (lower.includes('bmp')) return 'bmp'
+ if (lower.includes('webp')) return 'jpg'
+ return 'jpg'
+}
+
+function getImageSize(blob) {
+ return new Promise((resolve) => {
+ const objectUrl = URL.createObjectURL(blob)
+ const img = new Image()
+ img.onload = () => {
+ const naturalWidth = img.naturalWidth || IMAGE_MAX_WIDTH
+ const naturalHeight = img.naturalHeight || IMAGE_MAX_HEIGHT
+ URL.revokeObjectURL(objectUrl)
+ const scale = Math.min(IMAGE_MAX_WIDTH / naturalWidth, IMAGE_MAX_HEIGHT / naturalHeight, 1)
+ resolve({
+ width: Math.max(1, Math.round(naturalWidth * scale)),
+ height: Math.max(1, Math.round(naturalHeight * scale)),
+ })
+ }
+ img.onerror = () => {
+ URL.revokeObjectURL(objectUrl)
+ resolve({ width: IMAGE_MAX_WIDTH, height: Math.round(IMAGE_MAX_WIDTH * 0.62) })
+ }
+ img.src = objectUrl
+ })
+}
+
+async function makeImageParagraph(alt, url) {
+ const normalizedUrl = normalizeImageUrl(url)
+ try {
+ const resp = await fetch(normalizedUrl)
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+ const blob = await resp.blob()
+ const data = await blob.arrayBuffer()
+ const size = await getImageSize(blob)
+ const type = inferImageType(resp.headers.get('content-type') || '', normalizedUrl)
+ return new Paragraph({
+ spacing: { before: 120, after: 80 },
+ children: [
+ new ImageRun({
+ data,
+ type,
+ transformation: size,
+ altText: {
+ title: alt || '售后图片',
+ description: alt || '售后图片',
+ name: alt || '售后图片',
+ },
+ }),
+ ],
+ })
+ } catch {
+ return makeBodyParagraph(`[图片无法导出:${normalizedUrl}]`)
+ }
+}
+
+function makeCell(text, isHeader) {
+ return new TableCell({
+ margins: { top: 80, bottom: 80, left: 120, right: 120 },
+ shading: isHeader
+ ? { type: ShadingType.CLEAR, color: 'auto', fill: COLOR_TH_BG }
+ : undefined,
+ borders: {
+ top: SOLID_BORDER(),
+ bottom: SOLID_BORDER(),
+ left: SOLID_BORDER(),
+ right: SOLID_BORDER(),
+ },
+ children: [
+ new Paragraph({
+ spacing: { before: 0, after: 0, line: 320 },
+ children: parseInlineRuns(text, {
+ size: FONT_TABLE_HALF,
+ color: COLOR_TITLE,
+ bold: !!isHeader,
+ }),
+ }),
+ ],
+ })
+}
+
+function makeTable(headerCells, rows) {
+ const widthPct = headerCells.length > 0 ? Math.floor(10000 / headerCells.length) : 0
+ return new Table({
+ width: { size: 100, type: WidthType.PERCENTAGE },
+ columnWidths: headerCells.map(() => widthPct),
+ rows: [
+ new TableRow({
+ tableHeader: true,
+ children: headerCells.map((cell) => makeCell(cell, true)),
+ }),
+ ...rows.map(
+ (row) =>
+ new TableRow({
+ children: headerCells.map((_, idx) => makeCell(row[idx] ?? '', false)),
+ })
+ ),
+ ],
+ })
+}
+
+// 把 markdown 解析为 docx 子元素数组
+async function buildChildrenFromMarkdown(markdown) {
+ const lines = String(markdown).replace(/\r\n/g, '\n').split('\n')
+ const children = []
+ let paragraph = []
+ let listBuf = []
+ let listOrdered = false
+
+ const flushParagraph = () => {
+ if (!paragraph.length) return
+ children.push(makeBodyParagraph(paragraph.join(' ')))
+ paragraph = []
+ }
+ const flushList = () => {
+ if (!listBuf.length) return
+ for (const item of listBuf) {
+ children.push(makeListParagraph(item, listOrdered))
+ }
+ listBuf = []
+ }
+
+ for (let i = 0; i < lines.length; i += 1) {
+ const raw = lines[i]
+ const line = raw.trim()
+
+ if (!line) {
+ flushParagraph()
+ flushList()
+ continue
+ }
+
+ if (/^[-*_]{3,}$/.test(line)) {
+ flushParagraph()
+ flushList()
+ children.push(makeHrParagraph())
+ continue
+ }
+
+ const image = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(line)
+ if (image) {
+ flushParagraph()
+ flushList()
+ children.push(await makeImageParagraph(image[1], image[2]))
+ continue
+ }
+
+ if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
+ flushParagraph()
+ flushList()
+ const header = splitTableRow(lines[i])
+ let cursor = i + 2
+ const rows = []
+ while (
+ cursor < lines.length &&
+ lines[cursor].includes('|') &&
+ lines[cursor].trim() &&
+ !isTableSeparator(lines[cursor])
+ ) {
+ rows.push(splitTableRow(lines[cursor]))
+ cursor += 1
+ }
+ children.push(makeTable(header, rows))
+ i = cursor - 1
+ continue
+ }
+
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
+ if (heading) {
+ flushParagraph()
+ flushList()
+ children.push(makeHeadingParagraph(heading[1].length, heading[2]))
+ continue
+ }
+
+ const unordered = /^[-*]\s+(.+)$/.exec(line)
+ if (unordered) {
+ flushParagraph()
+ if (listBuf.length && listOrdered) flushList()
+ listOrdered = false
+ listBuf.push(unordered[1])
+ continue
+ }
+
+ const ordered = /^\d+\.\s+(.+)$/.exec(line)
+ if (ordered) {
+ flushParagraph()
+ if (listBuf.length && !listOrdered) flushList()
+ listOrdered = true
+ listBuf.push(ordered[1])
+ continue
+ }
+
+ flushList()
+ paragraph.push(line)
+ }
+
+ flushParagraph()
+ flushList()
+ return children
+}
+
+export async function exportWordDoc(title = '售后报告', content = '') {
+ const safeTitle = sanitizeFileName(title)
+ const md = content || `# ${safeTitle}\n\n(暂无报告内容)`
+ const children = await buildChildrenFromMarkdown(md)
+
+ const doc = new Document({
+ creator: 'ChatLab',
+ title: safeTitle,
+ styles: {
+ default: {
+ document: {
+ run: { font: FONT_FAMILY, size: FONT_BODY_HALF, color: COLOR_BODY },
+ },
+ },
+ },
+ numbering: {
+ config: [
+ {
+ reference: 'ordered-default',
+ levels: [
+ {
+ level: 0,
+ format: 'decimal',
+ text: '%1.',
+ alignment: AlignmentType.START,
+ style: {
+ paragraph: { indent: { left: 480, hanging: 240 } },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ sections: [
+ {
+ properties: {
+ page: {
+ margin: {
+ top: PAGE_MARGIN_VERT,
+ bottom: PAGE_MARGIN_VERT,
+ left: PAGE_MARGIN_HORI,
+ right: PAGE_MARGIN_HORI,
+ },
+ },
+ },
+ children,
+ },
+ ],
+ })
+
+ const blob = await Packer.toBlob(doc)
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `售后报告_${safeTitle}.docx`
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(url)
+}
diff --git a/chatlab-web/frontend/vite.config.js b/chatlab-web/frontend/vite.config.js
new file mode 100644
index 0000000..209cd6e
--- /dev/null
+++ b/chatlab-web/frontend/vite.config.js
@@ -0,0 +1,34 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+const FASTAPI_PORT = 8000
+const CHATLOG_PORT = 5030
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ host: '127.0.0.1', // 强制绑定到 IPv4,避免 localhost 解析到 IPv6 导致连接失败
+ proxy: {
+ // chatlog_fastAPI Python 后端:所有业务接口
+ '/api/search': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/groups': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/topics': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/knowledge': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/tasks': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/ai': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/sse': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/settings': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ '/api/files': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
+ // chatlog Go 后端:基础通信接口
+ '/api/v1': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ // chatlog Go 后端:媒体文件直接代理
+ '/image': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ '/voice': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ '/video': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ '/file': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ '/data': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
+ },
+ },
+})
diff --git a/chatlog.exe b/chatlog.exe
new file mode 100644
index 0000000..676b092
Binary files /dev/null and b/chatlog.exe differ
diff --git a/chatlog_fastAPI/ChatLabBackend.spec b/chatlog_fastAPI/ChatLabBackend.spec
new file mode 100644
index 0000000..c5a97c7
--- /dev/null
+++ b/chatlog_fastAPI/ChatLabBackend.spec
@@ -0,0 +1,56 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules
+
+datas = []
+datas += collect_data_files("jieba")
+
+hiddenimports = []
+hiddenimports += collect_submodules("uvicorn")
+hiddenimports += collect_submodules("fastapi")
+hiddenimports += collect_submodules("pydantic_settings")
+hiddenimports += collect_submodules("aiosqlite")
+hiddenimports += collect_submodules("apscheduler")
+
+a = Analysis(
+ ["run_backend.py"],
+ pathex=[],
+ binaries=[],
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+ optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="ChatLabBackend",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=True,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+)
+
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name="ChatLabBackend",
+)
diff --git a/chatlog_fastAPI/config.py b/chatlog_fastAPI/config.py
new file mode 100644
index 0000000..0869c66
--- /dev/null
+++ b/chatlog_fastAPI/config.py
@@ -0,0 +1,55 @@
+import os
+import tempfile
+from pathlib import Path
+from pydantic_settings import BaseSettings
+from typing import List
+
+
+def _default_data_dir() -> str:
+ configured = os.environ.get("CHATLAB_DATA_DIR")
+ if configured:
+ return str(Path(configured).expanduser())
+ appdata = os.environ.get("APPDATA")
+ if appdata:
+ return str(Path(appdata) / "ChatLab")
+ return str(Path.home() / ".chatlab")
+
+
+def _default_static_dir() -> str:
+ configured = os.environ.get("CHATLAB_STATIC_DIR")
+ if configured:
+ return str(Path(configured).expanduser())
+ return str((Path(__file__).resolve().parents[1] / "chatlab-web" / "frontend" / "dist"))
+
+
+class Settings(BaseSettings):
+ chatlog_base_url: str = "http://127.0.0.1:5030"
+ ai_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+ ai_api_key: str = ""
+ ai_model: str = "" # 不设默认值,必须由用户在设置页配置
+ summary_model: str = "" # 不设默认值,必须由用户在设置页配置
+ voice_model: str = "" # 不设默认值,必须由用户在设置页配置
+ vision_model: str = "" # 不设默认值,必须由用户在设置页配置
+ data_dir: str = _default_data_dir()
+ static_dir: str = _default_static_dir()
+ db_path: str = str(Path(_default_data_dir()) / "data" / "knowledge.db")
+ cors_origins: List[str] = [
+ "http://127.0.0.1:5173",
+ "http://localhost:5173",
+ "http://localhost:3000",
+ ]
+
+ class Config:
+ env_file = ".env"
+
+settings = Settings()
+
+try:
+ Path(settings.data_dir).mkdir(parents=True, exist_ok=True)
+ Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
+except PermissionError:
+ fallback_dir = Path(tempfile.gettempdir()) / "ChatLab"
+ fallback_dir.mkdir(parents=True, exist_ok=True)
+ settings.data_dir = str(fallback_dir)
+ settings.db_path = str(fallback_dir / "data" / "knowledge.db")
+ Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
diff --git a/chatlog_fastAPI/database.py b/chatlog_fastAPI/database.py
new file mode 100644
index 0000000..f6c96ee
--- /dev/null
+++ b/chatlog_fastAPI/database.py
@@ -0,0 +1,301 @@
+import aiosqlite
+import asyncio
+import httpx
+import logging
+import time
+from pathlib import Path
+from config import settings
+
+log = logging.getLogger(__name__)
+
+_data_db_dir = Path(settings.db_path).resolve().parent
+_data_db_dir.mkdir(parents=True, exist_ok=True)
+_current_db_path = str(Path(settings.db_path).resolve())
+_initialized_dbs = set()
+
+_resolved_wxid: str | None = None
+_wxid_last_resolved: float = 0.0
+_WXID_TTL = 60.0 # 60 秒后强制重新检测,确保账号切换能被感知
+STALE_SUMMARIZE_ERROR = "AI 报告生成任务超过 15 分钟未完成,已自动标记为失败,可重新生成"
+
+def _db_path_for_wxid(wxid: str) -> str:
+ if wxid and wxid != "default":
+ safe = "".join(c for c in wxid if c.isalnum() or c in ("_", "-"))
+ return str((_data_db_dir / f"knowledge_{safe}.db").resolve())
+ return str(Path(settings.db_path).resolve())
+
+
+def reset_wxid_cache():
+ global _resolved_wxid, _wxid_last_resolved
+ _resolved_wxid = None
+ _wxid_last_resolved = 0.0
+
+
+async def get_current_wxid(force: bool = False):
+ global _resolved_wxid, _wxid_last_resolved
+ now = time.time()
+ # 已有有效缓存且未超时,直接返回
+ if (
+ not force
+ and _resolved_wxid
+ and _resolved_wxid != "default"
+ and (now - _wxid_last_resolved) < _WXID_TTL
+ ):
+ return _resolved_wxid
+ # 重新解析当前 wxid
+ base = settings.chatlog_base_url
+ async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
+ try:
+ r = await client.get(f"{base}/api/v1/chatlog", params={"talker": "filehelper", "limit": 100, "time": "1970-01-01,2099-12-31", "format": "json"})
+ if r.status_code == 200:
+ data = r.json()
+ for msg in data.get("items", []):
+ if msg.get("isSelf"):
+ _resolved_wxid = msg.get("sender")
+ _wxid_last_resolved = time.time()
+ return _resolved_wxid
+ except Exception:
+ pass
+
+ try:
+ r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"})
+ if r.status_code == 200:
+ rooms = r.json().get("items", [])
+ for room in rooms:
+ room_id = room.get("name")
+ r2 = await client.get(f"{base}/api/v1/chatlog", params={"talker": room_id, "limit": 50, "time": "1970-01-01,2099-12-31", "format": "json"})
+ if r2.status_code == 200:
+ data2 = r2.json()
+ for msg in data2.get("items", []):
+ if msg.get("isSelf"):
+ _resolved_wxid = msg.get("sender")
+ _wxid_last_resolved = time.time()
+ return _resolved_wxid
+ except Exception:
+ pass
+ if force:
+ reset_wxid_cache()
+ return "default"
+
+async def update_db_path(force: bool = False):
+ global _current_db_path
+ wxid = await get_current_wxid(force=force)
+ new_path = _db_path_for_wxid(wxid)
+ if new_path != _current_db_path:
+ log.info(f"Switching database to {new_path}")
+ _current_db_path = new_path
+ await init_db(new_path)
+ return _current_db_path
+
+def get_active_db_path():
+ return _current_db_path
+
+async def get_db():
+ path = get_active_db_path()
+ if path not in _initialized_dbs:
+ await init_db(path)
+ async with aiosqlite.connect(path) as db:
+ db.row_factory = aiosqlite.Row
+ yield db
+
+async def init_db(path=None):
+ if path is None:
+ path = get_active_db_path()
+ async with aiosqlite.connect(path) as db:
+ await db.executescript("""
+CREATE TABLE IF NOT EXISTS groups (
+ id INTEGER PRIMARY KEY,
+ talker TEXT UNIQUE NOT NULL,
+ name TEXT,
+ analysis_prompt TEXT DEFAULT '',
+ cursor_seq INTEGER DEFAULT 0,
+ initialized INTEGER DEFAULT 0,
+ poll_interval INTEGER DEFAULT 300,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS topics (
+ id INTEGER PRIMARY KEY,
+ group_id INTEGER REFERENCES groups(id),
+ title TEXT NOT NULL,
+ source TEXT DEFAULT 'manual',
+ status TEXT DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS topic_messages (
+ topic_id INTEGER REFERENCES topics(id),
+ msg_seq INTEGER,
+ talker TEXT,
+ added_by TEXT DEFAULT 'ai',
+ message_json TEXT,
+ PRIMARY KEY (topic_id, msg_seq)
+);
+CREATE TABLE IF NOT EXISTS knowledge_docs (
+ id INTEGER PRIMARY KEY,
+ topic_id INTEGER UNIQUE REFERENCES topics(id),
+ content TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ curated_at DATETIME
+);
+CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
+ doc_id UNINDEXED,
+ title,
+ content
+);
+CREATE TABLE IF NOT EXISTS ai_tasks (
+ id INTEGER PRIMARY KEY,
+ group_id INTEGER REFERENCES groups(id),
+ type TEXT,
+ status TEXT,
+ progress TEXT,
+ error TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT ''
+);
+""")
+ await db.execute(
+ """
+ UPDATE ai_tasks
+ SET status='error', error=?, updated_at=CURRENT_TIMESTAMP
+ WHERE type='summarize'
+ AND status='running'
+ AND datetime(updated_at) <= datetime('now', '-15 minutes')
+ """,
+ (STALE_SUMMARIZE_ERROR,),
+ )
+ await db.execute(
+ """
+ UPDATE topics
+ SET status='error', updated_at=CURRENT_TIMESTAMP
+ WHERE status='processing'
+ AND datetime(updated_at) <= datetime('now', '-15 minutes')
+ """
+ )
+ await db.commit()
+
+ async with db.execute("PRAGMA table_info(topic_messages)") as cur:
+ topic_message_cols = {row[1] for row in await cur.fetchall()}
+ if "message_json" not in topic_message_cols:
+ await db.execute("ALTER TABLE topic_messages ADD COLUMN message_json TEXT")
+ await db.commit()
+ log.info(f"[init_db] added topic_messages.message_json in {path}")
+
+ async with db.execute("PRAGMA table_info(groups)") as cur:
+ group_cols = {row[1] for row in await cur.fetchall()}
+ if "analysis_prompt" not in group_cols:
+ await db.execute("ALTER TABLE groups ADD COLUMN analysis_prompt TEXT DEFAULT ''")
+ await db.commit()
+ log.info(f"[init_db] added groups.analysis_prompt in {path}")
+
+ async with db.execute("PRAGMA table_info(topics)") as cur:
+ topic_cols = {row[1] for row in await cur.fetchall()}
+ if "source" not in topic_cols:
+ await db.execute("ALTER TABLE topics ADD COLUMN source TEXT DEFAULT 'manual'")
+ await db.execute(
+ """
+ UPDATE topics
+ SET source = CASE
+ WHEN EXISTS (
+ SELECT 1 FROM topic_messages tm
+ WHERE tm.topic_id = topics.id AND tm.added_by = 'user'
+ ) THEN 'manual'
+ WHEN EXISTS (
+ SELECT 1 FROM topic_messages tm
+ WHERE tm.topic_id = topics.id AND COALESCE(tm.added_by, 'ai') = 'ai'
+ ) THEN 'ai'
+ ELSE 'manual'
+ END
+ """
+ )
+ await db.commit()
+ log.info(f"[init_db] added topics.source in {path}")
+
+ async with db.execute("PRAGMA table_info(knowledge_docs)") as cur:
+ knowledge_cols = {row[1] for row in await cur.fetchall()}
+ if "curated_at" not in knowledge_cols:
+ await db.execute("ALTER TABLE knowledge_docs ADD COLUMN curated_at DATETIME")
+ await db.execute(
+ """
+ UPDATE knowledge_docs
+ SET curated_at = updated_at
+ WHERE updated_at IS NOT NULL
+ AND created_at IS NOT NULL
+ AND updated_at > created_at
+ """
+ )
+ await db.commit()
+ log.info(f"[init_db] added knowledge_docs.curated_at in {path}")
+
+ # 迁移 topics 表到 AUTOINCREMENT,防止 SQLite rowid 复用导致旧 knowledge_docs
+ # 被新建话题"接"上(跨群串报告的根因)。每次启动检测一次,已迁移则跳过。
+ async with db.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'"
+ ) as cur:
+ has_seq_tbl = await cur.fetchone() is not None
+ needs_migrate = True
+ if has_seq_tbl:
+ async with db.execute(
+ "SELECT 1 FROM sqlite_sequence WHERE name='topics'"
+ ) as cur:
+ if await cur.fetchone():
+ needs_migrate = False
+ if needs_migrate:
+ await db.executescript("""
+BEGIN;
+CREATE TABLE topics_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER REFERENCES groups(id),
+ title TEXT NOT NULL,
+ source TEXT DEFAULT 'manual',
+ status TEXT DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+INSERT INTO topics_new (id, group_id, title, source, status, created_at, updated_at)
+ SELECT id, group_id, title, COALESCE(source, 'manual'), status, created_at, updated_at FROM topics;
+DROP TABLE topics;
+ALTER TABLE topics_new RENAME TO topics;
+COMMIT;
+""")
+ await db.execute(
+ "INSERT OR REPLACE INTO sqlite_sequence(name, seq) "
+ "SELECT 'topics', COALESCE(MAX(id), 0) FROM topics"
+ )
+ await db.commit()
+ log.info(f"[init_db] migrated topics table to AUTOINCREMENT in {path}")
+
+ # 孤儿数据清理:删除 topic_id 不存在于 topics 的 knowledge_docs 及其 FTS。
+ # 历史上删群时遗漏过这两张表,需要每次启动幂等修复。
+ await db.execute("""
+ DELETE FROM knowledge_fts WHERE doc_id IN (
+ SELECT id FROM knowledge_docs
+ WHERE topic_id NOT IN (SELECT id FROM topics)
+ )
+ """)
+ await db.execute("""
+ DELETE FROM knowledge_docs
+ WHERE topic_id NOT IN (SELECT id FROM topics)
+ """)
+ # 错绑数据清理:doc 创建时间早于其指向的 topic 创建时间,说明 doc 是历史残留、
+ # topic 是后建的(rowid 复用),doc 应清掉。合法 doc 必然在 topic 之后生成。
+ await db.execute("""
+ DELETE FROM knowledge_fts WHERE doc_id IN (
+ SELECT d.id FROM knowledge_docs d
+ JOIN topics t ON t.id = d.topic_id
+ WHERE d.created_at < t.created_at
+ )
+ """)
+ await db.execute("""
+ DELETE FROM knowledge_docs WHERE id IN (
+ SELECT d.id FROM knowledge_docs d
+ JOIN topics t ON t.id = d.topic_id
+ WHERE d.created_at < t.created_at
+ )
+ """)
+ await db.commit()
+ _initialized_dbs.add(path)
diff --git a/chatlog_fastAPI/main.py b/chatlog_fastAPI/main.py
new file mode 100644
index 0000000..d48d663
--- /dev/null
+++ b/chatlog_fastAPI/main.py
@@ -0,0 +1,151 @@
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from contextlib import asynccontextmanager
+import asyncio
+import logging
+from pathlib import Path
+import httpx
+from database import get_active_db_path, get_current_wxid, init_db, reset_wxid_cache, update_db_path
+from scheduler import start_scheduler
+from config import settings
+from routers import search, groups, topics, knowledge, ai, sse, files, chatlog_proxy
+from routers import settings as settings_router
+from services.chatlog_context import get_chatlog_context, update_chatlog_context
+from services.media_resolver import diagnose_media
+
+log = logging.getLogger(__name__)
+
+
+class ChatlogContextRequest(BaseModel):
+ account: str = ""
+ workDir: str = ""
+ dataDir: str = ""
+ platform: str = "windows"
+ version: int = 4
+ chatlogExe: str = ""
+ chatlogVersion: str = ""
+
+async def _account_watch_loop():
+ """每 60 秒检测一次当前微信账号,如账号切换则自动切换数据库。"""
+ while True:
+ await asyncio.sleep(60)
+ try:
+ await update_db_path()
+ except Exception as e:
+ log.warning(f"[account_watch] update_db_path error: {e}")
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_db()
+ await start_scheduler()
+ # 启动后台账号监控任务
+ task = asyncio.create_task(_account_watch_loop())
+ yield
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+app = FastAPI(lifespan=lifespan)
+
+@app.exception_handler(RuntimeError)
+async def runtime_error_handler(request: Request, exc: RuntimeError):
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(search.router)
+app.include_router(groups.router)
+app.include_router(topics.router)
+app.include_router(knowledge.router)
+app.include_router(ai.router)
+app.include_router(sse.router)
+app.include_router(files.router)
+app.include_router(settings_router.router)
+app.include_router(chatlog_proxy.router)
+
+
+@app.get("/health")
+async def health():
+ chatlog_ok = False
+ chatlog_error = ""
+ try:
+ async with httpx.AsyncClient(timeout=3.0, trust_env=False) as client:
+ resp = await client.get(f"{settings.chatlog_base_url}/api/v1/session", params={"limit": 1, "format": "json"})
+ chatlog_ok = resp.status_code == 200
+ if not chatlog_ok:
+ chatlog_error = f"HTTP {resp.status_code}"
+ except Exception as e:
+ chatlog_error = str(e)
+
+ wxid = await get_current_wxid() if chatlog_ok else "default"
+ return {
+ "ok": True,
+ "chatlog_ok": chatlog_ok,
+ "chatlog_error": chatlog_error,
+ "wxid": wxid,
+ "db_path": get_active_db_path(),
+ "data_dir": settings.data_dir,
+ }
+
+
+@app.post("/api/system/refresh-account")
+async def refresh_account():
+ reset_wxid_cache()
+ db_path = await update_db_path(force=True)
+ wxid = await get_current_wxid()
+ return {"ok": True, "wxid": wxid, "db_path": db_path}
+
+
+@app.post("/api/system/chatlog-context")
+async def set_chatlog_context(body: ChatlogContextRequest):
+ return {"ok": True, "context": update_chatlog_context(body.model_dump())}
+
+
+@app.get("/api/system/chatlog-context")
+async def read_chatlog_context():
+ return {"ok": True, "context": get_chatlog_context()}
+
+
+@app.get("/api/system/media-diagnostics")
+async def media_diagnostics(kind: str = "voice", key: str = ""):
+ return await diagnose_media(kind, key)
+
+
+static_dir = Path(settings.static_dir)
+if static_dir.exists():
+ assets_dir = static_dir / "assets"
+ if assets_dir.exists():
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
+ for static_name in ("favicon.svg", "icons.svg"):
+ static_file = static_dir / static_name
+
+ if static_file.exists():
+ @app.get(f"/{static_name}", include_in_schema=False)
+ async def _serve_static_file(name=static_name):
+ return FileResponse(static_dir / name)
+
+ @app.get("/", include_in_schema=False)
+ async def spa_index():
+ return FileResponse(static_dir / "index.html")
+
+ @app.get("/{full_path:path}", include_in_schema=False)
+ async def spa_fallback(full_path: str):
+ path = static_dir / full_path
+ if path.exists() and path.is_file():
+ return FileResponse(path)
+ return FileResponse(static_dir / "index.html")
+
+if __name__ == "__main__":
+ import uvicorn
+ # 为了在使用 PyInstaller 打包时也能正常运行
+ uvicorn.run(app, host="127.0.0.1", port=8000, reload=False)
diff --git a/chatlog_fastAPI/requirements.txt b/chatlog_fastAPI/requirements.txt
new file mode 100644
index 0000000..fbc4804
--- /dev/null
+++ b/chatlog_fastAPI/requirements.txt
@@ -0,0 +1,8 @@
+fastapi
+uvicorn
+httpx>=0.27.0,<0.28.0
+openai>=1.56.1,<3.0.0
+apscheduler
+jieba
+aiosqlite
+pydantic-settings
diff --git a/chatlog_fastAPI/routers/__init__.py b/chatlog_fastAPI/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/chatlog_fastAPI/routers/ai.py b/chatlog_fastAPI/routers/ai.py
new file mode 100644
index 0000000..6d1c35d
--- /dev/null
+++ b/chatlog_fastAPI/routers/ai.py
@@ -0,0 +1,116 @@
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from typing import Optional, Literal
+import aiosqlite, json, logging
+import httpx
+from database import get_db
+from config import settings
+from services.ai_client import get_openai_client
+from services.runtime_settings import get_ai_settings
+from services.media_parser import parse_media
+
+router = APIRouter(prefix="/api", tags=["ai"])
+log = logging.getLogger(__name__)
+
+
+async def _get_ai_client():
+ return await get_openai_client()
+
+
+class SummarizeRequest(BaseModel):
+ context: str # 已组装好的对话文本(含媒体描述)
+ room_name: Optional[str] = ""
+ messages: Optional[list] = None # 兼容旧调用,忽略
+
+
+class ParseRequest(BaseModel):
+ type: Literal["voice", "image", "video"]
+ key: str # voice: ServerID string; image/video: md5
+
+
+@router.post("/ai/parse")
+async def ai_parse(body: ParseRequest):
+ """
+ 通过 FastAPI 代理 AI 媒体解析:
+ - voice: 从 chatlog 下载音频 → DashScope Paraformer ASR 转文字
+ - image/video: 从 chatlog 下载媒体 → base64 → 视觉模型描述
+ """
+ try:
+ return await parse_media(body.type, body.key)
+ except HTTPException:
+ raise
+ except Exception as e:
+ log.error(f"[ai/parse] 媒体解析失败: {e}", exc_info=True)
+ raise HTTPException(500, f"媒体解析失败: {e}")
+
+
+@router.post("/ai/summarize/stream")
+async def summarize_stream(body: SummarizeRequest):
+ """
+ 接收前端已处理好的对话上下文,调用 AI 模型流式输出总结。
+ 前端负责先把媒体(图片/语音/视频)解析成文字再拼进 context。
+ """
+ _ai = await get_ai_settings()
+ if not _ai.get("ai_api_key"):
+ async def err_gen():
+ yield 'data: {"error": "AI 服务未配置,请在「设置」页面填入 AI API Key"}\n\n'
+ return StreamingResponse(err_gen(), media_type="text/event-stream")
+ if not _ai.get("ai_model"):
+ async def err_gen():
+ yield 'data: {"error": "知识总结模型未配置,请在「设置」页面填入模型名称(如 qwen-max)"}\n\n'
+ return StreamingResponse(err_gen(), media_type="text/event-stream")
+
+ context = body.context.strip()
+ if not context:
+ async def err_gen():
+ yield 'data: {"error": "对话内容为空"}\n\n'
+ return StreamingResponse(err_gen(), media_type="text/event-stream")
+
+ room = body.room_name or "会话"
+
+ system_prompt = (
+ "你是一位专业的对话分析助手。"
+ "请根据提供的聊天记录(可能包含图片描述、语音转文字、视频描述等多媒体内容)"
+ "生成一份结构清晰的 Markdown 总结。"
+ "总结应包含:主要话题、关键信息点、媒体内容要点、待办事项(如有)。"
+ "只输出 Markdown 格式内容,不要有任何额外说明。"
+ )
+ user_prompt = (
+ f"群聊:{room}\n\n"
+ f"以下是聊天记录(含多媒体内容描述):\n\n"
+ f"{context[:12000]}\n\n" # 限制 token 数
+ f"请生成总结:"
+ )
+
+ async def generate():
+ try:
+ _client, _ai = await _get_ai_client()
+ stream = await _client.chat.completions.create(
+ model=_ai["ai_model"],
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ stream=True,
+ temperature=0.3,
+ )
+ async for chunk in stream:
+ delta = chunk.choices[0].delta.content if chunk.choices else None
+ if delta:
+ yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
+ yield 'data: {"done": true}\n\n'
+ except Exception as e:
+ log.error(f"[summarize/stream] LLM 调用失败: {e}", exc_info=True)
+ yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
+
+ return StreamingResponse(generate(), media_type="text/event-stream")
+
+
+@router.get("/tasks/{task_id}")
+async def get_task(task_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT * FROM ai_tasks WHERE id=?", (task_id,)) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "not found")
+ return dict(row)
diff --git a/chatlog_fastAPI/routers/chatlog_proxy.py b/chatlog_fastAPI/routers/chatlog_proxy.py
new file mode 100644
index 0000000..40628f2
--- /dev/null
+++ b/chatlog_fastAPI/routers/chatlog_proxy.py
@@ -0,0 +1,93 @@
+from urllib.parse import quote
+
+import httpx
+from fastapi import APIRouter, Request
+from fastapi.responses import Response, StreamingResponse
+
+from config import settings
+
+router = APIRouter(tags=["chatlog-proxy"])
+
+HOP_BY_HOP_HEADERS = {
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailers",
+ "transfer-encoding",
+ "upgrade",
+}
+
+
+def _copy_headers(headers: httpx.Headers) -> dict[str, str]:
+ copied: dict[str, str] = {}
+ for key, value in headers.items():
+ if key.lower() not in HOP_BY_HOP_HEADERS:
+ copied[key] = value
+ return copied
+
+
+async def _proxy_chatlog(request: Request, upstream_path: str) -> Response:
+ query = request.url.query
+ target = f"{settings.chatlog_base_url}{upstream_path}"
+ if query:
+ target = f"{target}?{query}"
+
+ body = await request.body()
+ headers = {
+ key: value
+ for key, value in request.headers.items()
+ if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
+ }
+
+ async with httpx.AsyncClient(timeout=None, trust_env=False, follow_redirects=True) as client:
+ upstream = await client.request(
+ request.method,
+ target,
+ content=body if body else None,
+ headers=headers,
+ )
+
+ response_headers = _copy_headers(upstream.headers)
+ return StreamingResponse(
+ iter([upstream.content]),
+ status_code=upstream.status_code,
+ media_type=upstream.headers.get("content-type"),
+ headers=response_headers,
+ )
+
+
+@router.api_route("/api/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
+async def proxy_api_v1(path: str, request: Request):
+ return await _proxy_chatlog(request, f"/api/v1/{path}")
+
+
+async def _proxy_media(kind: str, path: str, request: Request):
+ safe_path = "/".join(quote(part, safe="") for part in path.split("/"))
+ return await _proxy_chatlog(request, f"/{kind}/{safe_path}")
+
+
+@router.api_route("/image/{path:path}", methods=["GET", "POST", "OPTIONS"])
+async def proxy_image(path: str, request: Request):
+ return await _proxy_media("image", path, request)
+
+
+@router.api_route("/voice/{path:path}", methods=["GET", "POST", "OPTIONS"])
+async def proxy_voice(path: str, request: Request):
+ return await _proxy_media("voice", path, request)
+
+
+@router.api_route("/video/{path:path}", methods=["GET", "POST", "OPTIONS"])
+async def proxy_video(path: str, request: Request):
+ return await _proxy_media("video", path, request)
+
+
+@router.api_route("/file/{path:path}", methods=["GET", "POST", "OPTIONS"])
+async def proxy_file(path: str, request: Request):
+ return await _proxy_media("file", path, request)
+
+
+@router.api_route("/data/{path:path}", methods=["GET", "POST", "OPTIONS"])
+async def proxy_data(path: str, request: Request):
+ return await _proxy_media("data", path, request)
diff --git a/chatlog_fastAPI/routers/files.py b/chatlog_fastAPI/routers/files.py
new file mode 100644
index 0000000..dd4400e
--- /dev/null
+++ b/chatlog_fastAPI/routers/files.py
@@ -0,0 +1,190 @@
+import mimetypes
+import os
+import re
+import shutil
+import sqlite3
+import tempfile
+from pathlib import Path
+from urllib.parse import quote
+
+import httpx
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import FileResponse, StreamingResponse
+
+from config import settings
+from services.chatlog_client import chatlog_client
+
+router = APIRouter(prefix="/api/files", tags=["files"])
+
+
+OFFICE_MEDIA_TYPES = {
+ ".xls": "application/vnd.ms-excel",
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ".ppt": "application/vnd.ms-powerpoint",
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ".doc": "application/msword",
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".pdf": "application/pdf",
+ ".dwg": "application/acad",
+}
+
+
+def _connect_hardlink_db(hardlink_db: Path) -> sqlite3.Connection:
+ """
+ chatlog may keep hardlink.db open. Copying a tiny snapshot avoids transient
+ "unable to open database file" errors on Windows while keeping reads safe.
+ """
+ tmp = Path(tempfile.gettempdir()) / f"chatlab_hardlink_{os.getpid()}_{hardlink_db.stat().st_mtime_ns}.db"
+ if not tmp.exists() or tmp.stat().st_size != hardlink_db.stat().st_size:
+ shutil.copy2(hardlink_db, tmp)
+ con = sqlite3.connect(tmp)
+ con.row_factory = sqlite3.Row
+ return con
+
+
+def _safe_download_name(name: str, fallback: str) -> str:
+ name = (name or fallback).replace("\r", "").replace("\n", "").strip()
+ return name or fallback
+
+
+def _content_disposition(filename: str) -> str:
+ quoted = quote(filename)
+ ascii_fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", filename) or "download"
+ return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quoted}"
+
+
+def _guess_media_type(filename: str, fallback: str = "") -> str:
+ ext = Path(filename or "").suffix.lower()
+ return OFFICE_MEDIA_TYPES.get(ext) or mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream"
+
+
+async def _proxy_chatlog_file(md5: str, filename: str = ""):
+ url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}"
+ try:
+ async with httpx.AsyncClient(timeout=30, trust_env=False, follow_redirects=True) as client:
+ resp = await client.get(url)
+ except Exception:
+ return None
+
+ if resp.status_code != 200 or resp.content == b'"media not found"':
+ return None
+
+ headers = {
+ "Content-Length": str(len(resp.content)),
+ "X-ChatLab-File-Source": "chatlog",
+ }
+ if filename:
+ headers["Content-Disposition"] = _content_disposition(filename)
+ media_type = _guess_media_type(filename, resp.headers.get("content-type") or "")
+ return StreamingResponse(iter([resp.content]), media_type=media_type, headers=headers)
+
+
+def _xwechat_roots_from_hardlink_db(hardlink_db: Path) -> list[Path]:
+ roots: list[Path] = []
+ try:
+ con = _connect_hardlink_db(hardlink_db)
+ row = con.execute("SELECT ValueStdStr FROM db_info WHERE Key='uuid'").fetchone()
+ raw = row["ValueStdStr"] if row else ""
+ except Exception:
+ raw = ""
+
+ if raw:
+ m = re.search(r"([A-Za-z]:\\[^|]+?xwechat_files)", raw)
+ if m:
+ roots.append(Path(m.group(1)))
+
+ roots.extend([
+ Path.home() / "xwechat_files",
+ Path.home() / "Documents" / "WeChat Files",
+ ])
+ uniq: list[Path] = []
+ seen = set()
+ for root in roots:
+ s = str(root).lower()
+ if s not in seen:
+ uniq.append(root)
+ seen.add(s)
+ return uniq
+
+
+def _find_local_file(hardlink_db: Path, md5: str, requested_name: str = "") -> Path | None:
+ try:
+ con = _connect_hardlink_db(hardlink_db)
+ row = con.execute(
+ """
+ SELECT md5, file_name, file_size, dir1, dir2
+ FROM file_hardlink_info_v4
+ WHERE md5=?
+ ORDER BY _rowid_ DESC
+ LIMIT 1
+ """,
+ (md5,),
+ ).fetchone()
+ except Exception:
+ row = None
+ if not row:
+ return None
+
+ names = [requested_name, row["file_name"]]
+ names = [n for n in names if n]
+ size = int(row["file_size"] or 0)
+ roots = _xwechat_roots_from_hardlink_db(hardlink_db)
+
+ for root in roots:
+ if not root.exists():
+ continue
+ for name in names:
+ for candidate in root.rglob(name):
+ try:
+ if candidate.is_file() and (not size or candidate.stat().st_size == size):
+ return candidate
+ except Exception:
+ continue
+ if size:
+ # Fallback by size in the common file store. This is intentionally limited
+ # to msg/file to avoid scanning unrelated huge trees for every request.
+ for file_root in root.glob("*/msg/file"):
+ if not file_root.exists():
+ continue
+ for candidate in file_root.rglob("*"):
+ try:
+ if candidate.is_file() and candidate.stat().st_size == size:
+ if not names or candidate.name in names:
+ return candidate
+ except Exception:
+ continue
+ return None
+
+
+@router.get("/{md5}")
+async def get_file(md5: str, filename: str = Query("")):
+ md5 = md5.strip()
+ if not re.fullmatch(r"[0-9a-fA-F]{8,64}", md5):
+ raise HTTPException(400, "文件 md5 不合法")
+
+ filename = _safe_download_name(filename, md5)
+ proxied = await _proxy_chatlog_file(md5, filename)
+ if proxied:
+ return proxied
+
+ db_paths = await chatlog_client.get_db_paths()
+ hardlink_paths = db_paths.get("media") or []
+ for raw_path in hardlink_paths:
+ hardlink_db = Path(raw_path)
+ if not hardlink_db.exists():
+ continue
+ local_file = _find_local_file(hardlink_db, md5, filename)
+ if local_file:
+ media_type = _guess_media_type(filename or local_file.name)
+ return FileResponse(
+ path=str(local_file),
+ filename=filename or local_file.name,
+ media_type=media_type,
+ headers={
+ "Content-Disposition": _content_disposition(filename or local_file.name),
+ "Content-Length": str(local_file.stat().st_size),
+ "X-ChatLab-File-Source": "local-hardlink",
+ },
+ )
+
+ raise HTTPException(404, "原文件未找到,可能未解密或已清理")
diff --git a/chatlog_fastAPI/routers/groups.py b/chatlog_fastAPI/routers/groups.py
new file mode 100644
index 0000000..30b3e6c
--- /dev/null
+++ b/chatlog_fastAPI/routers/groups.py
@@ -0,0 +1,138 @@
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+import aiosqlite, json
+from database import get_db
+
+router = APIRouter(prefix="/api/groups", tags=["groups"])
+
+class GroupCreate(BaseModel):
+ talker: str
+ name: Optional[str] = ""
+ poll_interval: int = 300
+
+class GroupPatch(BaseModel):
+ analysis_prompt: Optional[str] = None
+
+class InitParams(BaseModel):
+ start_time: int # unix 秒
+ end_time: int # unix 秒
+
+@router.post("")
+async def create_group(body: GroupCreate, db: aiosqlite.Connection = Depends(get_db)):
+ try:
+ await db.execute(
+ "INSERT INTO groups (talker, name, poll_interval) VALUES (?, ?, ?)",
+ (body.talker, body.name, body.poll_interval)
+ )
+ await db.commit()
+ async with db.execute("SELECT * FROM groups WHERE talker=?", (body.talker,)) as cur:
+ row = await cur.fetchone()
+ return dict(row)
+ except aiosqlite.IntegrityError:
+ raise HTTPException(409, "talker already exists")
+
+@router.get("")
+async def list_groups(db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT * FROM groups") as cur:
+ rows = await cur.fetchall()
+ return [dict(r) for r in rows]
+
+@router.patch("/{group_id}")
+async def patch_group(group_id: int, body: GroupPatch, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT id FROM groups WHERE id=?", (group_id,)) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "group not found")
+ updates = body.model_dump(exclude_none=True)
+ if "analysis_prompt" in updates:
+ await db.execute(
+ "UPDATE groups SET analysis_prompt=? WHERE id=?",
+ (updates["analysis_prompt"], group_id),
+ )
+ await db.commit()
+ async with db.execute("SELECT * FROM groups WHERE id=?", (group_id,)) as cur:
+ return dict(await cur.fetchone())
+
+@router.post("/{group_id}/init")
+async def trigger_init(
+ group_id: int,
+ body: InitParams,
+ db: aiosqlite.Connection = Depends(get_db),
+):
+ """对指定时间区间内全部消息做一次性 AI 分类。串行执行。"""
+ async with db.execute("SELECT * FROM groups WHERE id=?", (group_id,)) as cur:
+ group = await cur.fetchone()
+ if not group:
+ raise HTTPException(404, "group not found")
+
+ from services.topic_engine import get_classifying_group
+ busy = get_classifying_group()
+ if busy is not None:
+ raise HTTPException(409, f"已有群正在分析(group_id={busy}),请等待完成后再试")
+
+ if body.end_time <= body.start_time:
+ raise HTTPException(400, "end_time 必须大于 start_time")
+
+ await db.execute(
+ "INSERT INTO ai_tasks (group_id, type, status, progress) VALUES (?, 'classify_window', 'running', ?)",
+ (group_id, json.dumps({"processed": 0, "total": 0})),
+ )
+ await db.commit()
+ async with db.execute("SELECT last_insert_rowid()") as cur:
+ task = await cur.fetchone()
+ task_id = task[0]
+
+ from services.topic_engine import run_classify_window
+ import asyncio
+ asyncio.create_task(
+ run_classify_window(group_id, task_id, dict(group), body.start_time, body.end_time)
+ )
+ return {"task_id": task_id}
+
+@router.get("/{group_id}/task")
+async def get_task(group_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT * FROM ai_tasks WHERE group_id=? ORDER BY id DESC LIMIT 1", (group_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "no task found")
+ return dict(row)
+
+@router.delete("/{group_id}")
+async def delete_group(group_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT id FROM groups WHERE id=?", (group_id,)) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "group not found")
+ # 级联删除关联数据(FTS → 知识库报告 → 话题消息 → 话题 → 任务 → 群组)
+ # 注意:必须先清 knowledge_fts/knowledge_docs,否则 SQLite 复用 topic_id 时
+ # 残留报告会"接"到新建话题上,造成跨群串报告。
+ await db.execute(
+ """
+ DELETE FROM knowledge_fts WHERE doc_id IN (
+ SELECT id FROM knowledge_docs WHERE topic_id IN (
+ SELECT id FROM topics WHERE group_id=?
+ )
+ )
+ """,
+ (group_id,)
+ )
+ await db.execute(
+ """
+ DELETE FROM knowledge_docs WHERE topic_id IN (
+ SELECT id FROM topics WHERE group_id=?
+ )
+ """,
+ (group_id,)
+ )
+ await db.execute(
+ "DELETE FROM topic_messages WHERE topic_id IN (SELECT id FROM topics WHERE group_id=?)",
+ (group_id,)
+ )
+ await db.execute("DELETE FROM topics WHERE group_id=?", (group_id,))
+ await db.execute("DELETE FROM ai_tasks WHERE group_id=?", (group_id,))
+ await db.execute("DELETE FROM groups WHERE id=?", (group_id,))
+ await db.commit()
+ return {"ok": True}
diff --git a/chatlog_fastAPI/routers/knowledge.py b/chatlog_fastAPI/routers/knowledge.py
new file mode 100644
index 0000000..9df4879
--- /dev/null
+++ b/chatlog_fastAPI/routers/knowledge.py
@@ -0,0 +1,67 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from typing import Optional
+import aiosqlite
+from database import get_db
+
+router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
+
+class KnowledgePatch(BaseModel):
+ content: str
+
+@router.get("")
+async def list_knowledge(
+ keyword: Optional[str] = None,
+ db: aiosqlite.Connection = Depends(get_db)
+):
+ if keyword:
+ # FTS5 查询前先用 jieba 分词,提高中文召回率
+ from services.fts import build_match_query
+ fts_query = build_match_query(keyword)
+ if not fts_query:
+ return []
+ async with db.execute(
+ "SELECT k.id, k.topic_id, k.created_at, k.updated_at, t.title, t.group_id, g.name as group_name "
+ "FROM knowledge_docs k JOIN topics t ON k.topic_id=t.id "
+ "LEFT JOIN groups g ON t.group_id=g.id "
+ "WHERE k.id IN (SELECT doc_id FROM knowledge_fts WHERE knowledge_fts MATCH ?)",
+ (fts_query,)
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+ async with db.execute(
+ "SELECT k.id, k.topic_id, k.created_at, k.updated_at, t.title, t.group_id, g.name as group_name "
+ "FROM knowledge_docs k JOIN topics t ON k.topic_id=t.id "
+ "LEFT JOIN groups g ON t.group_id=g.id "
+ "ORDER BY g.name, k.updated_at DESC"
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+@router.get("/{doc_id}")
+async def get_knowledge(doc_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT * FROM knowledge_docs WHERE id=?", (doc_id,)) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "not found")
+ return dict(row)
+
+@router.patch("/{doc_id}")
+async def patch_knowledge(doc_id: int, body: KnowledgePatch, db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute(
+ "UPDATE knowledge_docs SET content=?, updated_at=CURRENT_TIMESTAMP, curated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (body.content, doc_id)
+ )
+ await db.commit()
+ # update FTS
+ async with db.execute("SELECT topic_id FROM knowledge_docs WHERE id=?", (doc_id,)) as cur:
+ row = await cur.fetchone()
+ if row:
+ async with db.execute("SELECT title FROM topics WHERE id=?", (row["topic_id"],)) as cur:
+ topic = await cur.fetchone()
+ await db.execute("DELETE FROM knowledge_fts WHERE doc_id=?", (doc_id,))
+ from services.fts import tokenize
+ await db.execute(
+ "INSERT INTO knowledge_fts (doc_id, title, content) VALUES (?, ?, ?)",
+ (doc_id, tokenize(topic["title"]), tokenize(body.content))
+ )
+ await db.commit()
+ return {"ok": True}
diff --git a/chatlog_fastAPI/routers/search.py b/chatlog_fastAPI/routers/search.py
new file mode 100644
index 0000000..1d60fb2
--- /dev/null
+++ b/chatlog_fastAPI/routers/search.py
@@ -0,0 +1,144 @@
+from fastapi import APIRouter, HTTPException, Query
+from urllib.parse import quote
+from services.chatlog_client import MessageIndexNotReady, chatlog_client
+from services.message_formatter import extract_quote
+
+router = APIRouter(prefix="/api/search", tags=["search"])
+
+
+@router.get("")
+async def search(
+ talker: str = Query(..., description="群/联系人 ID"),
+ time: str = Query("", description="时间范围,如 2024-01-01,2024-01-31"),
+ sender: str = Query("", description="发送者 ID,可选"),
+ keyword: str = Query("", description="关键词,可选"),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=500),
+):
+ """透传 chatlog /api/v1/chatlog,返回 {"total": N, "items": [...]}"""
+ offset = (page - 1) * page_size
+ try:
+ data = await chatlog_client.get_messages(
+ talker,
+ time=time,
+ sender=sender,
+ keyword=keyword,
+ limit=page_size,
+ offset=offset,
+ )
+ except MessageIndexNotReady as e:
+ raise HTTPException(status_code=503, detail=str(e)) from e
+ for item in data.get("items", []) or []:
+ contents = item.get("contents") or item.get("Contents") or {}
+ if not isinstance(contents, dict):
+ contents = {}
+ try:
+ is_file = int(item.get("type") or item.get("Type") or 0) == 49 and int(
+ item.get("subType") or item.get("sub_type") or item.get("SubType") or 0
+ ) == 6
+ except Exception:
+ is_file = False
+ file_md5 = str(contents.get("md5") or "") if is_file else ""
+ item["is_file"] = is_file
+ item["file_name"] = (
+ contents.get("title") or contents.get("fileName") or contents.get("filename") or ""
+ ) if is_file else ""
+ item["file_md5"] = file_md5
+ item["quote"] = item.get("quote") or extract_quote(item)
+ file_name = item["file_name"]
+ item["file_url"] = f"/api/files/{quote(file_md5, safe='')}?filename={quote(file_name or file_md5, safe='')}" if file_md5 else ""
+ return data
+
+
+@router.get("/chatrooms")
+async def chatrooms(
+ keyword: str = Query("", description="关键词搜索"),
+ limit: int = Query(100, ge=1, le=500),
+ offset: int = Query(0, ge=0),
+):
+ """获取所有可用的微信群聊列表"""
+ fetch_limit = min(2000, offset + limit)
+ rooms_data = await chatlog_client.get_chatrooms(keyword=keyword, limit=fetch_limit, offset=0)
+ if isinstance(rooms_data, list):
+ room_items = rooms_data
+ total = len(room_items)
+ else:
+ room_items = rooms_data.get("items") or rooms_data.get("data") or []
+ total = rooms_data.get("total", len(room_items))
+
+ merged = []
+ seen = set()
+
+ def get_room_id(item: dict) -> str:
+ return str(item.get("name") or item.get("Name") or item.get("userName") or item.get("UserName") or "")
+
+ def add_room(item: dict):
+ room_id = get_room_id(item)
+ if not room_id or not room_id.endswith("@chatroom") or room_id in seen:
+ return
+ seen.add(room_id)
+ merged.append(item)
+
+ for item in room_items:
+ if isinstance(item, dict):
+ add_room(item)
+
+ # Freshly imported phone records may exist in sessions/messages before
+ # chatroom metadata is populated. Merge @chatroom sessions as fallback.
+ try:
+ session_items = await chatlog_client.get_sessions(keyword="", limit=2000)
+ except Exception:
+ session_items = []
+
+ lowered_keyword = (keyword or "").lower()
+ for session in session_items:
+ if not isinstance(session, dict):
+ continue
+ user_name = str(session.get("userName") or session.get("UserName") or "")
+ if not user_name.endswith("@chatroom"):
+ continue
+ nick_name = session.get("nickName") or session.get("NickName") or ""
+ remark = session.get("remark") or session.get("Remark") or ""
+ if lowered_keyword:
+ haystack = f"{user_name} {nick_name} {remark}".lower()
+ if lowered_keyword not in haystack:
+ continue
+ add_room({
+ "name": user_name,
+ "nickName": nick_name,
+ "remark": remark,
+ "source": "session",
+ })
+
+ return {"total": max(total, len(merged)), "items": merged[offset:offset + limit]}
+
+@router.get("/avatar")
+async def avatar(wxid: str = Query(...)):
+ url = await chatlog_client.get_avatar_url(wxid)
+ return {"url": url}
+
+
+@router.get("/members")
+async def members(
+ talker: str = Query(..., description="群 ID"),
+ time: str = Query("", description="统计时间范围,可选"),
+):
+ """
+ 获取群成员列表(按发言量降序)
+ 返回 {"members": [...], "total": N}
+ 每个成员:userName, displayName, msgCount, lastSpeakTime
+ """
+ return await chatlog_client.get_chatroom_members(talker, time=time)
+
+
+@router.get("/sessions")
+async def sessions(
+ keyword: str = Query("", description="关键词搜索"),
+ limit: int = Query(500, ge=1, le=2000),
+):
+ """
+ 获取所有会话列表,含最新一条消息预览和时间(来自微信原生 Session 表)。
+ 返回:[{ userName, nickName, remark, content, nTime, nOrder }]
+ """
+ items = await chatlog_client.get_sessions(keyword=keyword, limit=limit)
+ return items
diff --git a/chatlog_fastAPI/routers/settings.py b/chatlog_fastAPI/routers/settings.py
new file mode 100644
index 0000000..98b5aaa
--- /dev/null
+++ b/chatlog_fastAPI/routers/settings.py
@@ -0,0 +1,64 @@
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from typing import Optional
+import aiosqlite
+from database import get_db
+
+router = APIRouter(prefix="/api/settings", tags=["settings"])
+
+EDITABLE_KEYS = [
+ "ai_base_url", "ai_api_key", "ai_model", "summary_model",
+ "vision_model", "voice_model", "topic_analysis_prompt",
+]
+
+
+def _mask_key(value: str) -> str:
+ if not value or len(value) <= 8:
+ return "*" * len(value) if value else ""
+ return value[:3] + "*" * (len(value) - 7) + value[-4:]
+
+
+@router.get("")
+async def get_settings(db: aiosqlite.Connection = Depends(get_db)):
+ result = {}
+ placeholders = ",".join("?" for _ in EDITABLE_KEYS)
+ async with db.execute(
+ f"SELECT key, value FROM app_settings WHERE key IN ({placeholders})",
+ EDITABLE_KEYS,
+ ) as cur:
+ rows = await cur.fetchall()
+ for row in rows:
+ k, v = row["key"], row["value"]
+ result[k] = _mask_key(v) if k == "ai_api_key" else v
+ for k in EDITABLE_KEYS:
+ if k not in result:
+ result[k] = ""
+ return result
+
+
+class SettingsUpdate(BaseModel):
+ ai_base_url: Optional[str] = None
+ ai_api_key: Optional[str] = None
+ ai_model: Optional[str] = None
+ summary_model: Optional[str] = None
+ vision_model: Optional[str] = None
+ voice_model: Optional[str] = None
+ topic_analysis_prompt: Optional[str] = None
+
+
+@router.put("")
+async def update_settings(body: SettingsUpdate, db: aiosqlite.Connection = Depends(get_db)):
+ updates = body.model_dump(exclude_none=True)
+ for k, v in updates.items():
+ if k not in EDITABLE_KEYS:
+ continue
+ if k == "ai_api_key" and "*" in v:
+ continue
+ await db.execute(
+ "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
+ (k, v, v),
+ )
+ await db.commit()
+ from services.runtime_settings import invalidate_cache
+ invalidate_cache()
+ return {"status": "ok"}
diff --git a/chatlog_fastAPI/routers/sse.py b/chatlog_fastAPI/routers/sse.py
new file mode 100644
index 0000000..a83d2aa
--- /dev/null
+++ b/chatlog_fastAPI/routers/sse.py
@@ -0,0 +1,40 @@
+import asyncio, json, logging
+from fastapi import APIRouter, Query
+from fastapi.responses import StreamingResponse
+from services.chatlog_client import chatlog_client
+from services.message_formatter import attach_quote
+
+router = APIRouter(prefix="/api/sse", tags=["sse"])
+log = logging.getLogger(__name__)
+
+
+@router.get("/chatlog")
+async def sse_chatlog(talker: str = Query(...)):
+ async def generate():
+ try:
+ data = await chatlog_client.get_messages(talker, limit=1, offset=0)
+ last_total = data.get("total", 0)
+ except Exception:
+ last_total = 0
+
+ while True:
+ await asyncio.sleep(2)
+ try:
+ data = await chatlog_client.get_messages(talker, limit=50, offset=last_total)
+ msgs = data.get("messages") or data.get("items") or []
+ new_total = data.get("total", last_total)
+ for msg in msgs:
+ attach_quote(msg)
+ yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
+ if new_total > last_total:
+ last_total = new_total
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ log.warning(f"[sse] poll error: {e}")
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
diff --git a/chatlog_fastAPI/routers/topics.py b/chatlog_fastAPI/routers/topics.py
new file mode 100644
index 0000000..4da9d42
--- /dev/null
+++ b/chatlog_fastAPI/routers/topics.py
@@ -0,0 +1,435 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from typing import Optional
+import aiosqlite, json
+from datetime import datetime
+from urllib.parse import quote
+from database import get_db
+from services.message_formatter import extract_quote
+
+router = APIRouter(prefix="/api/topics", tags=["topics"])
+CHATLOG_BATCH_SIZE = 80
+NAME_LOOKUP_PAGE_SIZE = 500
+NAME_LOOKUP_MAX_ITEMS = 5000
+STALE_SUMMARIZE_MINUTES = 15
+
+class TopicCreate(BaseModel):
+ group_id: int
+ title: str
+
+class TopicPatch(BaseModel):
+ title: Optional[str] = None
+ status: Optional[str] = None
+
+class MessageAdd(BaseModel):
+ msg_seq: int
+ talker: str
+
+
+async def _mark_stale_summarize_tasks(db: aiosqlite.Connection, group_id: int, topic_id: int) -> None:
+ error = "AI 报告生成任务超过 15 分钟未完成,已自动标记为失败,可重新生成"
+ stale_window = f"-{STALE_SUMMARIZE_MINUTES} minutes"
+ await db.execute(
+ """
+ UPDATE ai_tasks
+ SET status='error', error=?, updated_at=CURRENT_TIMESTAMP
+ WHERE group_id=?
+ AND type='summarize'
+ AND status='running'
+ AND datetime(updated_at) <= datetime('now', ?)
+ """,
+ (error, group_id, stale_window),
+ )
+ await db.execute(
+ """
+ UPDATE topics
+ SET status='error', updated_at=CURRENT_TIMESTAMP
+ WHERE id=?
+ AND status='processing'
+ AND datetime(updated_at) <= datetime('now', ?)
+ """,
+ (topic_id, stale_window),
+ )
+
+
+def _normalize_chatlog_message(item: dict, fallback_seq: int = 0) -> dict:
+ contents = item.get("contents") or item.get("Contents") or {}
+ if not isinstance(contents, dict):
+ contents = {}
+ media_key = (
+ contents.get("rawmd5")
+ or contents.get("md5")
+ or contents.get("path")
+ or item.get("media_key")
+ or item.get("mediaKey")
+ or ""
+ )
+ voice_key = (
+ str(contents.get("voice"))
+ if contents.get("voice")
+ else item.get("voice_key") or item.get("voiceKey") or ""
+ )
+ raw_type = item.get("type") or item.get("Type") or 1
+ raw_sub_type = item.get("sub_type") or item.get("subType") or item.get("SubType") or 0
+ try:
+ is_file = int(raw_type) == 49 and int(raw_sub_type) == 6
+ except Exception:
+ is_file = False
+ file_md5 = str(contents.get("md5") or item.get("file_md5") or item.get("fileMd5") or "") if is_file else ""
+ file_name = (
+ contents.get("title")
+ or contents.get("fileName")
+ or contents.get("filename")
+ or item.get("file_name")
+ or item.get("fileName")
+ or ""
+ ) if is_file else ""
+ file_url = f"/api/files/{quote(file_md5, safe='')}?filename={quote(file_name or file_md5, safe='')}" if file_md5 else ""
+ return {
+ "seq": item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0,
+ "sender": item.get("sender") or item.get("Sender") or "",
+ "sender_name": (
+ item.get("sender_name")
+ or item.get("senderName")
+ or item.get("SenderName")
+ or item.get("sender")
+ or item.get("Sender")
+ or ""
+ ),
+ "create_time": item.get("create_time") or item.get("time") or item.get("CreateTime") or "",
+ "content": item.get("content") or item.get("Content") or "",
+ "type": raw_type,
+ "sub_type": raw_sub_type,
+ "contents": contents,
+ "media_key": media_key,
+ "voice_key": voice_key,
+ "image_path": media_key,
+ "voice_path": voice_key,
+ "video_path": media_key,
+ "file_path": media_key,
+ "link_url": contents.get("url") or item.get("link_url") or "",
+ "link_title": contents.get("title") or item.get("link_title") or "",
+ "link_desc": contents.get("desc") or item.get("link_desc") or "",
+ "link_thumb": contents.get("thumbUrl") or contents.get("thumb_url") or item.get("link_thumb") or "",
+ "link_source": contents.get("sourceName") or contents.get("source_name") or item.get("link_source") or "",
+ "quote": item.get("quote") or extract_quote(item),
+ "is_file": is_file,
+ "file_name": file_name,
+ "file_md5": file_md5,
+ "file_url": file_url,
+ }
+
+
+def _message_from_snapshot(raw: str | None, fallback_seq: int) -> dict | None:
+ if not raw:
+ return None
+ try:
+ item = json.loads(raw)
+ except Exception:
+ return None
+ if not isinstance(item, dict):
+ return None
+ return _normalize_chatlog_message(item, fallback_seq)
+
+
+def _looks_like_raw_sender_id(value: str | None) -> bool:
+ value = (value or "").strip()
+ return (
+ not value
+ or value.startswith("wxid_")
+ or value.startswith("gh_")
+ or value.endswith("@chatroom")
+ or value.startswith("chatroom_")
+ )
+
+
+def _sender_display_name(item: dict, sender: str = "") -> str:
+ for key in (
+ "sender_name",
+ "senderName",
+ "SenderName",
+ "accountName",
+ "groupNickname",
+ "displayName",
+ "nickName",
+ "remark",
+ ):
+ value = str(item.get(key) or "").strip()
+ if value and value != sender and not _looks_like_raw_sender_id(value):
+ return value
+ return ""
+
+
+def _message_date(value: str | None) -> str | None:
+ value = (value or "").strip()
+ if not value:
+ return None
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d")
+ except Exception:
+ return value[:10] if len(value) >= 10 and value[4:5] == "-" else None
+
+
+async def _build_sender_name_map(talker: str, messages: list[dict]) -> dict[str, str]:
+ names: dict[str, str] = {}
+
+ for msg in messages:
+ sender = str(msg.get("sender") or "").strip()
+ name = _sender_display_name(msg, sender)
+ if sender and name:
+ names[sender] = name
+
+ missing = {
+ str(msg.get("sender") or "").strip()
+ for msg in messages
+ if str(msg.get("sender") or "").strip()
+ and _looks_like_raw_sender_id(str(msg.get("sender_name") or "").strip())
+ }
+ missing = {sender for sender in missing if sender not in names}
+ if not missing:
+ return names
+
+ from services.chatlog_client import chatlog_client
+
+ dates = sorted({
+ date
+ for msg in messages
+ for date in [_message_date(str(msg.get("create_time") or ""))]
+ if date
+ })
+ if dates:
+ time_range = f"{dates[0]},{dates[-1]}"
+ offset = 0
+ seen = 0
+ while missing and seen < NAME_LOOKUP_MAX_ITEMS:
+ try:
+ data = await chatlog_client.get_messages(
+ talker,
+ time=time_range,
+ limit=NAME_LOOKUP_PAGE_SIZE,
+ offset=offset,
+ )
+ except Exception as e:
+ print(f"Failed to fetch sender names talker={talker}: {e}")
+ break
+ items = data.get("items", []) or []
+ if not items:
+ break
+ for item in items:
+ sender = str(item.get("sender") or item.get("Sender") or "").strip()
+ if sender not in missing:
+ continue
+ name = _sender_display_name(item, sender)
+ if name:
+ names[sender] = name
+ missing.discard(sender)
+ seen += len(items)
+ offset += len(items)
+ total = int(data.get("total") or 0)
+ if total and offset >= total:
+ break
+ if len(items) < NAME_LOOKUP_PAGE_SIZE:
+ break
+
+ if missing:
+ try:
+ members = await chatlog_client.get_chatroom_members(talker)
+ raw_members = members.get("members", []) if isinstance(members, dict) else []
+ for member in raw_members:
+ sender = str(member.get("userName") or member.get("UserName") or "").strip()
+ if sender not in missing:
+ continue
+ name = _sender_display_name(
+ {
+ "displayName": member.get("displayName") or member.get("DisplayName"),
+ "nickName": member.get("nickName") or member.get("NickName"),
+ "remark": member.get("remark") or member.get("Remark"),
+ },
+ sender,
+ )
+ if name:
+ names[sender] = name
+ missing.discard(sender)
+ except Exception as e:
+ print(f"Failed to fetch chatroom members talker={talker}: {e}")
+
+ return names
+
+
+async def _fill_sender_names(talker: str, messages: list[dict]) -> None:
+ if not talker or not messages:
+ return
+ names = await _build_sender_name_map(talker, messages)
+ if not names:
+ return
+ for msg in messages:
+ sender = str(msg.get("sender") or "").strip()
+ if not sender or sender not in names:
+ continue
+ current = str(msg.get("sender_name") or "").strip()
+ if not current or current == sender or _looks_like_raw_sender_id(current):
+ msg["sender_name"] = names[sender]
+ msg["senderName"] = names[sender]
+
+@router.get("")
+async def list_topics(
+ group_id: Optional[int] = None,
+ status: Optional[str] = None,
+ keyword: Optional[str] = None,
+ db: aiosqlite.Connection = Depends(get_db)
+):
+ sql = "SELECT * FROM topics WHERE 1=1"
+ params = []
+ if group_id:
+ sql += " AND group_id=?"; params.append(group_id)
+ if status:
+ sql += " AND status=?"; params.append(status)
+ if keyword:
+ sql += " AND title LIKE ?"; params.append(f"%{keyword}%")
+ async with db.execute(sql, params) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+@router.post("")
+async def create_topic(body: TopicCreate, db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute(
+ "INSERT INTO topics (group_id, title, source) VALUES (?, ?, 'manual')",
+ (body.group_id, body.title),
+ )
+ await db.commit()
+ async with db.execute("SELECT * FROM topics ORDER BY id DESC LIMIT 1") as cur:
+ return dict(await cur.fetchone())
+
+@router.get("/{topic_id}")
+async def get_topic(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "not found")
+
+ # 拿到该话题下关联的所有 seq
+ async with db.execute("SELECT * FROM topic_messages WHERE topic_id=? ORDER BY msg_seq ASC", (topic_id,)) as cur:
+ msg_rows = await cur.fetchall()
+
+ msgs = []
+ if msg_rows:
+ # 获取群聊 ID
+ talker = msg_rows[0]["talker"]
+ seq_list = [r["msg_seq"] for r in msg_rows]
+
+ # 分批调用 5030 接口获取真实消息内容,避免大批量 batch 触发 500。
+ from services.chatlog_client import chatlog_client
+ fetched_by_seq: dict[int, dict] = {}
+ for i in range(0, len(seq_list), CHATLOG_BATCH_SIZE):
+ chunk = seq_list[i: i + CHATLOG_BATCH_SIZE]
+ try:
+ msgs_data = await chatlog_client.get_messages_batch(talker, chunk)
+ raw_items = msgs_data.get("items", []) or msgs_data.get("Items", [])
+ for item in raw_items:
+ normalized = _normalize_chatlog_message(item)
+ seq = normalized.get("seq")
+ if seq:
+ fetched_by_seq[int(seq)] = normalized
+ except Exception as e:
+ print(f"Failed to fetch real messages chunk topic={topic_id}: {e}")
+
+ for r in msg_rows:
+ seq = int(r["msg_seq"])
+ if seq in fetched_by_seq:
+ msgs.append(fetched_by_seq[seq])
+ continue
+ snap = _message_from_snapshot(r["message_json"] if "message_json" in r.keys() else None, seq)
+ if snap:
+ msgs.append(snap)
+ continue
+ msgs.append({
+ "seq": seq,
+ "sender": "",
+ "sender_name": "系统提示",
+ "create_time": "",
+ "content": f"原始消息无法从 chatlog 找回 (seq: {seq})",
+ "type": 1,
+ "sub_type": 0,
+ })
+
+ # 获取知识文档
+ if msg_rows:
+ await _fill_sender_names(talker, msgs)
+
+ async with db.execute("SELECT id, topic_id, content, created_at, updated_at FROM knowledge_docs WHERE topic_id=?", (topic_id,)) as cur:
+ doc = await cur.fetchone()
+
+ return {**dict(row), "messages": msgs, "knowledge_doc": dict(doc) if doc else None}
+
+@router.patch("/{topic_id}")
+async def patch_topic(topic_id: int, body: TopicPatch, db: aiosqlite.Connection = Depends(get_db)):
+ if body.title:
+ await db.execute(
+ "UPDATE topics SET title=?, source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (body.title, topic_id),
+ )
+ if body.status:
+ await db.execute(
+ "UPDATE topics SET status=?, source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (body.status, topic_id),
+ )
+ await db.commit()
+ async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
+ return dict(await cur.fetchone())
+
+@router.delete("/{topic_id}")
+async def delete_topic(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ # 先拿到 doc_id,用于清 FTS
+ async with db.execute("SELECT id FROM knowledge_docs WHERE topic_id=?", (topic_id,)) as cur:
+ doc_row = await cur.fetchone()
+ if doc_row:
+ await db.execute("DELETE FROM knowledge_fts WHERE doc_id=?", (doc_row["id"],))
+ await db.execute("DELETE FROM topic_messages WHERE topic_id=?", (topic_id,))
+ await db.execute("DELETE FROM knowledge_docs WHERE topic_id=?", (topic_id,))
+ await db.execute("DELETE FROM topics WHERE id=?", (topic_id,))
+ await db.commit()
+ return {"ok": True}
+
+@router.post("/{topic_id}/messages")
+async def add_message(topic_id: int, body: MessageAdd, db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute(
+ "INSERT OR IGNORE INTO topic_messages (topic_id, msg_seq, talker, added_by) VALUES (?, ?, ?, 'user')",
+ (topic_id, body.msg_seq, body.talker)
+ )
+ await db.execute(
+ "UPDATE topics SET source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (topic_id,),
+ )
+ await db.commit()
+ return {"ok": True}
+
+@router.delete("/{topic_id}/messages/{seq}")
+async def remove_message(topic_id: int, seq: int, db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute("DELETE FROM topic_messages WHERE topic_id=? AND msg_seq=?", (topic_id, seq))
+ await db.execute(
+ "UPDATE topics SET source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (topic_id,),
+ )
+ await db.commit()
+ return {"ok": True}
+
+@router.post("/{topic_id}/summarize")
+async def summarize(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
+ topic = await cur.fetchone()
+ if not topic:
+ raise HTTPException(404, "not found")
+ topic_data = dict(topic)
+ await _mark_stale_summarize_tasks(db, topic_data["group_id"], topic_id)
+ # 创建 ai_tasks 记录以追踪进度
+ await db.execute(
+ "INSERT INTO ai_tasks (group_id, type, status, progress) VALUES (?, 'summarize', 'running', ?)",
+ (topic_data["group_id"], json.dumps({"processed": 0, "total": 1}))
+ )
+ await db.commit()
+ async with db.execute("SELECT last_insert_rowid() AS id") as cur:
+ task_row = await cur.fetchone()
+ task_id = task_row["id"]
+ from services.summary_engine import run_summarize
+ import asyncio
+ asyncio.create_task(run_summarize(topic_id, topic_data, task_id))
+ return {"ok": True, "task_id": task_id}
diff --git a/chatlog_fastAPI/run_backend.py b/chatlog_fastAPI/run_backend.py
new file mode 100644
index 0000000..acca3ea
--- /dev/null
+++ b/chatlog_fastAPI/run_backend.py
@@ -0,0 +1,13 @@
+import os
+
+import uvicorn
+from main import app
+
+
+def main():
+ port = int(os.environ.get("CHATLAB_BACKEND_PORT", "8000"))
+ uvicorn.run(app, host="127.0.0.1", port=port, reload=False, log_level="info")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/chatlog_fastAPI/scheduler.py b/chatlog_fastAPI/scheduler.py
new file mode 100644
index 0000000..b0c9ba8
--- /dev/null
+++ b/chatlog_fastAPI/scheduler.py
@@ -0,0 +1,49 @@
+"""
+APScheduler — 仅保留 wxid/数据库切换检测。
+(不再运行任何 AI 分类轮询:AI 分析改为用户手动按时间窗口触发)
+"""
+
+import logging
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from database import update_db_path
+
+log = logging.getLogger(__name__)
+scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
+_sync_failures = 0
+
+
+def register_poll_job(group_id: int, poll_interval: int):
+ """已废弃。保留空函数避免其他模块旧引用炸。"""
+ log.debug(f"[scheduler] register_poll_job called (no-op now): group={group_id}")
+
+
+def _reschedule_sync(seconds: int):
+ if scheduler.get_job("sync_jobs"):
+ scheduler.remove_job("sync_jobs")
+ scheduler.add_job(_sync_jobs, "interval", seconds=seconds, id="sync_jobs")
+
+
+async def _sync_jobs():
+ """定期触发 wxid 重新检测,让账号切换能自动切换数据库。"""
+ global _sync_failures
+ try:
+ await update_db_path()
+ if _sync_failures > 0:
+ _sync_failures = 0
+ _reschedule_sync(10)
+ except Exception as e:
+ _sync_failures += 1
+ log.error(f"[scheduler] sync error (consecutive={_sync_failures}): {e}")
+ if _sync_failures == 3:
+ _reschedule_sync(60)
+ log.warning("[scheduler] sync backoff to 60s after 3 failures")
+
+
+async def start_scheduler():
+ scheduler.add_job(_sync_jobs, "interval", seconds=10, id="sync_jobs")
+ scheduler.start()
+ # Do not block FastAPI startup on chatlog. Electron starts the backend
+ # before chatlog, so the first account sync must happen in the background.
+ scheduler.add_job(_sync_jobs, "date", id="sync_jobs_initial")
+ log.info("[scheduler] started (db-path watcher only, no poll jobs)")
diff --git a/chatlog_fastAPI/services/__init__.py b/chatlog_fastAPI/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/chatlog_fastAPI/services/ai_client.py b/chatlog_fastAPI/services/ai_client.py
new file mode 100644
index 0000000..5df70ff
--- /dev/null
+++ b/chatlog_fastAPI/services/ai_client.py
@@ -0,0 +1,31 @@
+import httpx
+from openai import AsyncOpenAI
+
+from services.runtime_settings import get_ai_settings
+
+_client_cache: dict[tuple[str, str], AsyncOpenAI] = {}
+_http_client_cache: dict[tuple[str, str], httpx.AsyncClient] = {}
+
+
+async def get_openai_client() -> tuple[AsyncOpenAI, dict]:
+ settings = await get_ai_settings()
+ cache_key = (
+ settings.get("ai_base_url") or "",
+ settings.get("ai_api_key") or "",
+ )
+
+ if cache_key not in _client_cache:
+ for http_client in _http_client_cache.values():
+ await http_client.aclose()
+ _client_cache.clear()
+ _http_client_cache.clear()
+
+ http_client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
+ _http_client_cache[cache_key] = http_client
+ _client_cache[cache_key] = AsyncOpenAI(
+ api_key=settings.get("ai_api_key") or "missing",
+ base_url=settings.get("ai_base_url"),
+ http_client=http_client,
+ )
+
+ return _client_cache[cache_key], settings
diff --git a/chatlog_fastAPI/services/chatlog_client.py b/chatlog_fastAPI/services/chatlog_client.py
new file mode 100644
index 0000000..f188c7a
--- /dev/null
+++ b/chatlog_fastAPI/services/chatlog_client.py
@@ -0,0 +1,203 @@
+import httpx
+import asyncio
+from typing import List
+from config import settings
+
+
+class ChatlogHTTPError(RuntimeError):
+ def __init__(self, status_code: int, method: str, path: str, detail: str):
+ self.status_code = status_code
+ self.method = method
+ self.path = path
+ self.detail = detail
+ super().__init__(f"chatlog HTTP {status_code}: {method} {path} body={detail!r}")
+
+
+class MessageIndexNotReady(RuntimeError):
+ """Raised when chatlog has sessions but its message time index is not usable yet."""
+
+
+class ChatlogClient:
+ def __init__(self):
+ self.base = settings.chatlog_base_url
+ self._contact_db_file = None
+
+ async def _get(self, path: str, params: dict, timeout: float = 30.0) -> dict:
+ try:
+ async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
+ r = await client.get(f"{self.base}{path}", params=params)
+ r.raise_for_status()
+ return r.json()
+ except httpx.TimeoutException:
+ raise RuntimeError(f"chatlog timeout: GET {path}")
+ except httpx.HTTPStatusError as e:
+ detail = self._response_detail(e.response)
+ raise ChatlogHTTPError(e.response.status_code, "GET", path, detail)
+ except Exception as e:
+ raise RuntimeError(f"chatlog request failed: {e}")
+
+ async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict:
+ try:
+ async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
+ r = await client.post(f"{self.base}{path}", json=body)
+ r.raise_for_status()
+ return r.json()
+ except httpx.TimeoutException:
+ raise RuntimeError(f"chatlog timeout: POST {path}")
+ except httpx.HTTPStatusError as e:
+ detail = self._response_detail(e.response)
+ raise ChatlogHTTPError(e.response.status_code, "POST", path, detail)
+ except Exception as e:
+ raise RuntimeError(f"chatlog request failed: {e}")
+
+ def _response_detail(self, response: httpx.Response) -> str:
+ try:
+ body = response.json()
+ if isinstance(body, dict):
+ return str(body.get("error") or body.get("detail") or body)
+ return str(body)
+ except Exception:
+ return response.text
+
+ async def get_messages(
+ self,
+ talker: str,
+ time: str = "",
+ sender: str = "",
+ keyword: str = "",
+ min_seq: int = 0,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> dict:
+ params: dict = {
+ "talker": talker,
+ "limit": limit,
+ "offset": offset,
+ "format": "json",
+ }
+ if time:
+ params["time"] = time
+ else:
+ params["time"] = "1970-01-01,2099-12-31"
+ if sender:
+ params["sender"] = sender
+ if keyword:
+ params["keyword"] = keyword
+ if min_seq > 0:
+ params["min_seq"] = min_seq
+
+ try:
+ data = await self._get("/api/v1/chatlog", params)
+ except ChatlogHTTPError as e:
+ detail = e.detail.lower()
+ if e.status_code == 404 and "time range not found" in detail:
+ await asyncio.sleep(0.2)
+ try:
+ data = await self._get("/api/v1/chatlog", params)
+ except ChatlogHTTPError as retry_error:
+ if (
+ retry_error.status_code == 404
+ and "time range not found" in retry_error.detail.lower()
+ ):
+ raise MessageIndexNotReady(
+ "自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
+ ) from retry_error
+ raise
+ elif e.status_code == 404 and "not found" in detail:
+ # chatlog sometimes reports a valid date window as missing while it is warming/querying.
+ await asyncio.sleep(0.2)
+ try:
+ data = await self._get("/api/v1/chatlog", params)
+ except ChatlogHTTPError as retry_error:
+ retry_detail = retry_error.detail.lower()
+ if (
+ retry_error.status_code == 404
+ and "time range not found" in retry_detail
+ ):
+ raise MessageIndexNotReady(
+ "自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
+ ) from retry_error
+ if retry_error.status_code == 404 and "not found" in retry_detail:
+ return {"total": 0, "items": []}
+ raise
+ else:
+ raise
+ if isinstance(data, dict):
+ return data
+ return {"total": len(data), "items": data}
+
+ async def get_message(self, talker: str, seq: int) -> dict | None:
+ try:
+ async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
+ r = await client.get(
+ f"{self.base}/api/v1/chatlog/message",
+ params={"talker": talker, "seq": seq},
+ )
+ if r.status_code == 404:
+ return None
+ r.raise_for_status()
+ return r.json()
+ except httpx.TimeoutException:
+ raise RuntimeError("chatlog timeout: get_message")
+ except Exception as e:
+ raise RuntimeError(f"chatlog request failed: {e}")
+
+ async def get_messages_batch(self, talker: str, seqs: List[int]) -> dict:
+ return await self._post("/api/v1/chatlog/batch", {"talker": talker, "seqs": seqs})
+
+ async def get_chatrooms(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
+ params: dict = {"limit": limit, "offset": offset, "format": "json"}
+ if keyword:
+ params["keyword"] = keyword
+ return await self._get("/api/v1/chatroom", params, timeout=10.0)
+
+ async def get_contacts(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
+ params: dict = {"limit": limit, "offset": offset, "format": "json"}
+ if keyword:
+ params["keyword"] = keyword
+ return await self._get("/api/v1/contact", params, timeout=10.0)
+
+ async def get_chatroom_members(self, talker: str, time: str = "") -> dict:
+ params: dict = {"talker": talker}
+ if time:
+ params["time"] = time
+ return await self._get("/api/v1/chatroom/members", params)
+
+ async def get_sessions(self, keyword: str = "", limit: int = 500) -> list:
+ params: dict = {"limit": limit, "format": "json"}
+ if keyword:
+ params["keyword"] = keyword
+ data = await self._get("/api/v1/session", params, timeout=15.0)
+ if isinstance(data, list):
+ return data
+ return data.get("items", data.get("data", []))
+
+
+ async def get_avatar_url(self, wxid: str) -> str:
+ if self._contact_db_file is None:
+ try:
+ db_list = await self._get("/api/v1/db", {})
+ self._contact_db_file = (db_list.get("contact") or [""])[0]
+ except Exception:
+ self._contact_db_file = ""
+ if not self._contact_db_file:
+ return ""
+ safe_wxid = wxid.replace("'", "''")
+ sql = f"SELECT small_head_url, big_head_url FROM contact WHERE username='{safe_wxid}' LIMIT 1"
+ params = {"group": "contact", "file": self._contact_db_file, "sql": sql}
+ try:
+ rows = await self._get("/api/v1/db/query", params, timeout=5.0)
+ if rows:
+ url = rows[0].get("small_head_url") or rows[0].get("big_head_url") or ""
+ if url:
+ return url
+ except Exception:
+ pass
+ return ""
+
+ async def get_db_paths(self) -> dict:
+ data = await self._get("/api/v1/db", {}, timeout=10.0)
+ return data if isinstance(data, dict) else {}
+
+
+chatlog_client = ChatlogClient()
diff --git a/chatlog_fastAPI/services/chatlog_context.py b/chatlog_fastAPI/services/chatlog_context.py
new file mode 100644
index 0000000..8a8cacc
--- /dev/null
+++ b/chatlog_fastAPI/services/chatlog_context.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+
+
+@dataclass
+class ChatlogContext:
+ account: str = ""
+ work_dir: str = ""
+ data_dir: str = ""
+ platform: str = "windows"
+ version: int = 4
+ chatlog_exe: str = ""
+ chatlog_version: str = ""
+
+
+_context = ChatlogContext()
+
+
+def update_chatlog_context(payload: dict) -> dict:
+ global _context
+ _context = ChatlogContext(
+ account=str(payload.get("account") or ""),
+ work_dir=str(payload.get("workDir") or payload.get("work_dir") or ""),
+ data_dir=str(payload.get("dataDir") or payload.get("data_dir") or ""),
+ platform=str(payload.get("platform") or "windows"),
+ version=int(payload.get("version") or 4),
+ chatlog_exe=str(payload.get("chatlogExe") or payload.get("chatlog_exe") or ""),
+ chatlog_version=str(payload.get("chatlogVersion") or payload.get("chatlog_version") or ""),
+ )
+ return get_chatlog_context()
+
+
+def get_chatlog_context() -> dict:
+ return asdict(_context)
diff --git a/chatlog_fastAPI/services/fts.py b/chatlog_fastAPI/services/fts.py
new file mode 100644
index 0000000..95b3200
--- /dev/null
+++ b/chatlog_fastAPI/services/fts.py
@@ -0,0 +1,25 @@
+import jieba
+import re
+
+def tokenize(text: str) -> str:
+ return " ".join(jieba.cut(text))
+
+
+def build_match_query(text: str, limit: int = 12) -> str:
+ """Build a safe FTS5 MATCH query from user/model text."""
+ terms: list[str] = []
+ seen: set[str] = set()
+ for token in tokenize(text or "").split():
+ token = token.strip()
+ if not token or not re.search(r"\w", token, flags=re.UNICODE):
+ continue
+ upper = token.upper()
+ if upper in {"AND", "OR", "NOT", "NEAR"}:
+ continue
+ if token in seen:
+ continue
+ seen.add(token)
+ terms.append('"' + token.replace('"', '""') + '"')
+ if len(terms) >= limit:
+ break
+ return " OR ".join(terms)
diff --git a/chatlog_fastAPI/services/media_parser.py b/chatlog_fastAPI/services/media_parser.py
new file mode 100644
index 0000000..b32b351
--- /dev/null
+++ b/chatlog_fastAPI/services/media_parser.py
@@ -0,0 +1,142 @@
+import base64
+import logging
+
+import httpx
+from fastapi import HTTPException
+
+from services.ai_client import get_openai_client
+from services.media_resolver import resolve_media
+from services.runtime_settings import get_ai_settings
+
+log = logging.getLogger(__name__)
+
+
+async def _get_ai_client():
+ return await get_openai_client()
+
+
+async def parse_media(kind: str, key: str) -> dict:
+ """
+ Parse one chatlog media object into text.
+
+ kind: voice, image, or video.
+ key: chatlog media key.
+ """
+ if kind not in {"voice", "image", "video"}:
+ raise HTTPException(400, "不支持的媒体类型")
+ if not key:
+ raise HTTPException(400, "媒体 key 不能为空")
+
+ ai = await get_ai_settings()
+ if not ai.get("ai_api_key"):
+ raise HTTPException(503, "AI 服务未配置,请在设置页填写 AI API Key")
+ if kind == "voice" and not ai.get("voice_model"):
+ raise HTTPException(503, "语音模型未配置,请在设置页填写语音模型名称,例如 paraformer-v2")
+ if kind in ("image", "video") and not ai.get("vision_model"):
+ raise HTTPException(503, "视觉模型未配置,请在设置页填写视觉模型名称,例如 qwen-vl-plus")
+
+ media = await resolve_media(kind, key)
+ if kind == "voice":
+ return {"text": await _parse_voice(media.bytes, media.content_type)}
+ return {"text": await _parse_visual(kind, media.bytes, media.content_type)}
+
+
+async def _parse_voice(media_bytes: bytes, content_type: str) -> str:
+ b64_audio = base64.b64encode(media_bytes).decode()
+ audio_ct = content_type.lower()
+ if "silk" in audio_ct or "x-silk" in audio_ct:
+ audio_mime = "audio/silk"
+ elif "amr" in audio_ct:
+ audio_mime = "audio/amr"
+ elif "ogg" in audio_ct or "opus" in audio_ct:
+ audio_mime = "audio/ogg"
+ elif "wav" in audio_ct:
+ audio_mime = "audio/wav"
+ else:
+ audio_mime = "audio/mpeg"
+
+ data_uri = f"data:{audio_mime};base64,{b64_audio}"
+ _, ai = await _get_ai_client()
+ asr_headers = {
+ "Authorization": f"Bearer {ai['ai_api_key']}",
+ "Content-Type": "application/json",
+ }
+
+ async with httpx.AsyncClient(timeout=60) as http:
+ submit = await http.post(
+ "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
+ headers={**asr_headers, "X-DashScope-Async": "enable"},
+ json={
+ "model": ai["voice_model"],
+ "input": {"file_urls": [data_uri]},
+ "parameters": {"language_hints": ["zh", "en"]},
+ },
+ timeout=30,
+ )
+ submit_data = submit.json()
+ if submit.status_code not in (200, 201):
+ raise HTTPException(500, f"提交识别任务失败: {submit_data.get('message', submit_data)}")
+
+ task_id = submit_data.get("output", {}).get("task_id")
+ if not task_id:
+ raise HTTPException(500, f"未获取到 task_id: {submit_data}")
+
+ for _ in range(30):
+ import asyncio
+
+ await asyncio.sleep(1)
+ poll = await http.get(
+ f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}",
+ headers=asr_headers,
+ timeout=10,
+ )
+ poll_data = poll.json()
+ status = poll_data.get("output", {}).get("task_status", "")
+ if status == "SUCCEEDED":
+ results = poll_data.get("output", {}).get("results", [])
+ log.info("[media_parser] ASR SUCCEEDED results: %s", results)
+ if not results:
+ return "(识别结果为空)"
+ trans_url = results[0].get("transcription_url", "")
+ if trans_url:
+ trans_resp = await http.get(trans_url, timeout=10)
+ trans_data = trans_resp.json()
+ log.info("[media_parser] transcription_url content: %s", str(trans_data)[:500])
+ transcripts = trans_data.get("transcripts", [])
+ text = transcripts[0].get("text", "") if transcripts else ""
+ else:
+ text = results[0].get("transcription", "")
+ return text or "(识别结果为空)"
+ if status in ("FAILED", "CANCELLED"):
+ raise HTTPException(500, f"识别任务失败: {poll_data.get('output', {}).get('message', status)}")
+
+ raise HTTPException(500, "语音识别超时(30秒)")
+
+
+async def _parse_visual(kind: str, media_bytes: bytes, content_type: str) -> str:
+ b64 = base64.b64encode(media_bytes).decode()
+ ct = content_type.lower()
+ if "png" in ct:
+ mime = "image/png"
+ elif "webp" in ct:
+ mime = "image/webp"
+ else:
+ mime = "image/jpeg"
+ data_url = f"data:{mime};base64,{b64}"
+ prompt = "请用中文简洁描述这张图片的内容。" if kind == "image" else "请用中文简洁描述这个视频截图的内容。"
+
+ client, ai = await _get_ai_client()
+ resp_ai = await client.chat.completions.create(
+ model=ai["vision_model"],
+ messages=[
+ {
+ "role": "user",
+ "content": [
+ {"type": "image_url", "image_url": {"url": data_url}},
+ {"type": "text", "text": prompt},
+ ],
+ }
+ ],
+ max_tokens=300,
+ )
+ return resp_ai.choices[0].message.content or ""
diff --git a/chatlog_fastAPI/services/media_resolver.py b/chatlog_fastAPI/services/media_resolver.py
new file mode 100644
index 0000000..3b8d373
--- /dev/null
+++ b/chatlog_fastAPI/services/media_resolver.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import logging
+import sqlite3
+from dataclasses import dataclass
+from pathlib import Path
+
+import httpx
+from fastapi import HTTPException
+
+from config import settings
+from services.chatlog_context import get_chatlog_context
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class ResolvedMedia:
+ bytes: bytes
+ content_type: str
+ url: str
+
+
+def _media_url(kind: str, key: str, thumb: bool = False) -> str:
+ url = f"{settings.chatlog_base_url}/{kind}/{key}"
+ if thumb:
+ url += "?thumb=1"
+ return url
+
+
+def _read_voice_resource_status(key: str) -> dict:
+ ctx = get_chatlog_context()
+ work_dir = ctx.get("work_dir") or ""
+ if not work_dir:
+ return {"checked": False, "reason": "missing_work_dir"}
+
+ db_path = Path(work_dir) / "db_storage" / "message" / "message_resource.db"
+ if not db_path.exists():
+ return {"checked": False, "reason": "message_resource_db_missing", "path": str(db_path)}
+
+ try:
+ conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)
+ conn.row_factory = sqlite3.Row
+ try:
+ info = conn.execute(
+ "SELECT * FROM MessageResourceInfo WHERE message_svr_id=?",
+ (int(key),),
+ ).fetchone()
+ if not info:
+ return {
+ "checked": True,
+ "found": False,
+ "path": str(db_path),
+ "message": "当前已解密资源库里没有这条语音的媒体资源记录",
+ }
+ details = conn.execute(
+ "SELECT type,size,status,data_index FROM MessageResourceDetail WHERE message_id=?",
+ (info["message_id"],),
+ ).fetchall()
+ return {
+ "checked": True,
+ "found": True,
+ "path": str(db_path),
+ "message_id": info["message_id"],
+ "resources": [dict(row) for row in details],
+ }
+ finally:
+ conn.close()
+ except Exception as exc:
+ return {"checked": False, "reason": "resource_db_read_failed", "error": str(exc), "path": str(db_path)}
+
+
+def _download_failure_message(kind: str, key: str, status_code: int | None, body: str = "") -> str:
+ if kind == "voice":
+ base = "底层语音文件未读取成功"
+ if status_code:
+ base += f"(chatlog /voice 返回 HTTP {status_code})"
+ return (
+ f"{base}。请先确认已安装新版程序并重新识别当前微信账号;"
+ "如果仍失败,说明当前 chatlog 版本还不能解析该 WeChat 4.x 语音资源。"
+ )
+ if status_code:
+ return f"从 chatlog 下载媒体失败: HTTP {status_code}"
+ return f"从 chatlog 下载媒体失败: {body or 'unknown error'}"
+
+
+async def diagnose_media(kind: str, key: str) -> dict:
+ if kind not in {"voice", "image", "video"}:
+ raise HTTPException(400, "不支持的媒体类型")
+ if not key:
+ raise HTTPException(400, "媒体 key 不能为空")
+
+ url = _media_url(kind, key, thumb=kind in {"image", "video"})
+ result = {
+ "ok": False,
+ "kind": kind,
+ "key": key,
+ "url": url,
+ "chatlog_base_url": settings.chatlog_base_url,
+ "chatlog_context": get_chatlog_context(),
+ }
+
+ async with httpx.AsyncClient(timeout=20, trust_env=False, follow_redirects=True) as client:
+ try:
+ resp = await client.get(url)
+ content_type = resp.headers.get("content-type", "")
+ result.update(
+ {
+ "status_code": resp.status_code,
+ "content_type": content_type,
+ "content_length": len(resp.content or b""),
+ "ok": resp.status_code < 400 and bool(resp.content),
+ }
+ )
+ if resp.status_code >= 400:
+ result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500])
+ result["response_preview"] = resp.text[:500]
+ elif not resp.content:
+ result["error"] = "chatlog 返回了空媒体文件"
+ except Exception as exc:
+ result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)})
+
+ if kind == "voice":
+ result["resource_db"] = _read_voice_resource_status(key)
+ return result
+
+
+async def resolve_media(kind: str, key: str) -> ResolvedMedia:
+ if kind not in {"voice", "image", "video"}:
+ raise HTTPException(400, "不支持的媒体类型")
+ if not key:
+ raise HTTPException(400, "媒体 key 不能为空")
+
+ url = _media_url(kind, key, thumb=kind in {"image", "video"})
+ async with httpx.AsyncClient(timeout=60, trust_env=False, follow_redirects=True) as client:
+ try:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ diagnostics = await diagnose_media(kind, key)
+ log.warning("[media_resolver] media download failed: %s", diagnostics)
+ raise HTTPException(
+ 502,
+ {
+ "message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]),
+ "diagnostics": diagnostics,
+ },
+ )
+ except Exception as exc:
+ diagnostics = await diagnose_media(kind, key)
+ log.warning("[media_resolver] media download exception: %s", diagnostics)
+ raise HTTPException(
+ 502,
+ {
+ "message": _download_failure_message(kind, key, None, str(exc)),
+ "diagnostics": diagnostics,
+ },
+ )
+
+ if not resp.content:
+ diagnostics = await diagnose_media(kind, key)
+ raise HTTPException(
+ 502,
+ {
+ "message": "chatlog 返回了空媒体文件",
+ "diagnostics": diagnostics,
+ },
+ )
+
+ return ResolvedMedia(
+ bytes=resp.content,
+ content_type=resp.headers.get("content-type", "application/octet-stream"),
+ url=url,
+ )
diff --git a/chatlog_fastAPI/services/message_formatter.py b/chatlog_fastAPI/services/message_formatter.py
new file mode 100644
index 0000000..8bbb352
--- /dev/null
+++ b/chatlog_fastAPI/services/message_formatter.py
@@ -0,0 +1,253 @@
+import html
+import json
+import re
+import xml.etree.ElementTree as ET
+from typing import Any
+
+
+QUOTE_CONTENT_LIMIT = 600
+
+
+def extract_contents(item: dict) -> dict:
+ contents = item.get("contents") or item.get("Contents") or {}
+ return contents if isinstance(contents, dict) else {}
+
+
+def clean_message_text(value: Any) -> str:
+ text = html.unescape(str(value or "")).strip()
+ text = re.sub(r"\s+", " ", text)
+ if len(text) > QUOTE_CONTENT_LIMIT:
+ text = text[:QUOTE_CONTENT_LIMIT] + "..."
+ return text
+
+
+def _local_name(tag: str) -> str:
+ return tag.rsplit("}", 1)[-1]
+
+
+def _safe_int(value: Any) -> int | None:
+ if value in (None, ""):
+ return None
+ try:
+ return int(str(value).strip())
+ except Exception:
+ return None
+
+
+def _first(data: dict, *keys: str) -> Any:
+ for key in keys:
+ value = data.get(key)
+ if value not in (None, ""):
+ return value
+ return None
+
+
+def _has_quote_indicator(data: dict) -> bool:
+ keys = {str(key) for key in data.keys()}
+ indicators = {
+ "quote",
+ "refermsg",
+ "referMsg",
+ "refer",
+ "recordInfo",
+ "recordinfo",
+ "fromusr",
+ "fromUser",
+ "chatusr",
+ "chatUser",
+ "displayname",
+ "displayName",
+ "referContent",
+ "svrid",
+ "newmsgid",
+ "newMsgId",
+ }
+ return bool(keys & indicators)
+
+
+def _decode_json(value: str) -> Any:
+ try:
+ return json.loads(value)
+ except Exception:
+ return None
+
+
+def _xml_node_text(node: ET.Element, names: set[str]) -> str:
+ for child in node.iter():
+ if _local_name(child.tag) in names:
+ text = "".join(child.itertext()).strip()
+ if text:
+ return text
+ return ""
+
+
+def _quote_from_xml(value: str) -> dict | None:
+ text = html.unescape(value or "").strip()
+ if "<" not in text or ">" not in text:
+ return None
+ try:
+ root = ET.fromstring(text)
+ except Exception:
+ try:
+ root = ET.fromstring(f"
{text}")
+ except Exception:
+ return None
+
+ refer_node = None
+ for node in root.iter():
+ if _local_name(node.tag).lower() == "refermsg":
+ refer_node = node
+ break
+ if refer_node is None:
+ return None
+
+ content = _xml_node_text(refer_node, {"content", "title", "desc"})
+ sender_name = _xml_node_text(refer_node, {"displayname", "nickname", "fromnickname"})
+ sender = _xml_node_text(refer_node, {"fromusr", "chatusr", "sender"})
+ msg_type = _safe_int(_xml_node_text(refer_node, {"type"}))
+ seq = _safe_int(_xml_node_text(refer_node, {"seq", "msgid", "newmsgid", "svrid"}))
+
+ return _normalize_quote(
+ {
+ "sender": sender,
+ "sender_name": sender_name,
+ "content": content,
+ "type": msg_type,
+ "seq": seq,
+ }
+ )
+
+
+def _find_quote_payload(value: Any, allow_plain_text: bool = False) -> dict | None:
+ if value in (None, ""):
+ return None
+
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return None
+ decoded = _decode_json(text) if text[:1] in ("{", "[") else None
+ if decoded is not None:
+ return _find_quote_payload(decoded, allow_plain_text=allow_plain_text)
+ xml_quote = _quote_from_xml(text)
+ if xml_quote:
+ return xml_quote
+ if allow_plain_text:
+ return _normalize_quote({"content": text})
+ return None
+
+ if isinstance(value, list):
+ for item in value:
+ quote = _find_quote_payload(item, allow_plain_text=allow_plain_text)
+ if quote:
+ return quote
+ return None
+
+ if not isinstance(value, dict):
+ return None
+
+ for key in ("quote", "refermsg", "referMsg", "refer", "recordInfo", "recordinfo"):
+ if key in value:
+ quote = _find_quote_payload(value.get(key), allow_plain_text=True)
+ if quote:
+ return quote
+
+ quote = _normalize_quote(value) if allow_plain_text or _has_quote_indicator(value) else None
+ if quote:
+ return quote
+
+ for nested in value.values():
+ quote = _find_quote_payload(nested, allow_plain_text=False)
+ if quote:
+ return quote
+ return None
+
+
+def _normalize_quote(data: dict) -> dict | None:
+ content = clean_message_text(
+ _first(
+ data,
+ "content",
+ "Content",
+ "text",
+ "title",
+ "desc",
+ "digest",
+ "displayContent",
+ "referContent",
+ )
+ )
+ if not content:
+ return None
+
+ sender = clean_message_text(
+ _first(data, "sender", "Sender", "fromusr", "fromUser", "chatusr", "chatUser", "from")
+ )
+ sender_name = clean_message_text(
+ _first(data, "sender_name", "senderName", "SenderName", "displayname", "displayName", "nickname", "nickName")
+ )
+ msg_type = _safe_int(_first(data, "type", "Type", "msgType", "subType"))
+ seq = _safe_int(_first(data, "seq", "Seq", "sort_seq", "msgid", "msgId", "newmsgid", "newMsgId", "svrid"))
+
+ return {
+ "sender": sender,
+ "sender_name": sender_name,
+ "content": content,
+ "type": msg_type,
+ "seq": seq,
+ }
+
+
+def extract_quote(item: dict | None) -> dict | None:
+ if not isinstance(item, dict):
+ return None
+
+ contents = extract_contents(item)
+ explicit_sources = (
+ item.get("quote"),
+ item.get("Quote"),
+ item.get("refer"),
+ item.get("recordInfo"),
+ contents.get("quote"),
+ contents.get("refer"),
+ contents.get("refermsg"),
+ contents.get("referMsg"),
+ contents.get("recordInfo"),
+ contents.get("recordinfo"),
+ )
+ for source in explicit_sources:
+ quote = _find_quote_payload(source, allow_plain_text=True)
+ if quote:
+ return quote
+
+ for source in (
+ contents.get("appmsg"),
+ item.get("content"),
+ item.get("Content"),
+ ):
+ quote = _find_quote_payload(source, allow_plain_text=False)
+ if quote:
+ return quote
+ return None
+
+
+def attach_quote(item: dict) -> dict:
+ item["quote"] = extract_quote(item)
+ return item
+
+
+def quote_to_text(quote: dict | None) -> str:
+ if not quote:
+ return ""
+ sender = quote.get("sender_name") or quote.get("sender") or "未知"
+ seq = quote.get("seq")
+ seq_text = f" seq={seq}" if seq else ""
+ return f"[引用消息{seq_text}] {sender}: {quote.get('content') or ''}".strip()
+
+
+def append_quote_text(base_text: str, item: dict) -> str:
+ parts = [base_text.strip()] if base_text and base_text.strip() else []
+ quote_text = quote_to_text(extract_quote(item))
+ if quote_text:
+ parts.append(quote_text)
+ return ";".join(parts)
diff --git a/chatlog_fastAPI/services/report_learning.py b/chatlog_fastAPI/services/report_learning.py
new file mode 100644
index 0000000..6277ffb
--- /dev/null
+++ b/chatlog_fastAPI/services/report_learning.py
@@ -0,0 +1,139 @@
+import re
+import aiosqlite
+
+from services.fts import build_match_query
+
+MAX_EXAMPLES = 3
+MAX_EXAMPLE_CHARS = 1800
+MAX_CONTEXT_CHARS = 5200
+
+
+def _compact(text: str, limit: int = MAX_EXAMPLE_CHARS) -> str:
+ text = re.sub(r"\n{3,}", "\n\n", (text or "").strip())
+ if len(text) <= limit:
+ return text
+ return text[:limit].rstrip() + "\n..."
+
+
+def _format_examples(rows: list[aiosqlite.Row], purpose: str) -> str:
+ if not rows:
+ return ""
+ heading = {
+ "topic": "历史人工修订报告参考(用于学习话题命名和分类口径)",
+ "summary": "历史人工修订报告参考(只学习结构、措辞和关注点,不得照抄历史事实)",
+ }.get(purpose, "历史人工修订报告参考")
+ parts = [heading]
+ total = len(parts[0])
+ for idx, row in enumerate(rows, 1):
+ block = (
+ f"\n\n--- 示例 {idx} ---\n"
+ f"群聊:{row['group_name'] or row['talker'] or row['group_id']}\n"
+ f"话题标题:{row['title']}\n"
+ f"报告内容:\n{_compact(row['content'])}"
+ )
+ if total + len(block) > MAX_CONTEXT_CHARS:
+ break
+ parts.append(block)
+ total += len(block)
+ return "".join(parts).strip()
+
+
+async def build_report_learning_context(
+ db: aiosqlite.Connection,
+ *,
+ group_id: int | None,
+ query: str = "",
+ exclude_topic_id: int | None = None,
+ purpose: str = "summary",
+ limit: int = MAX_EXAMPLES,
+) -> str:
+ params: list[object] = []
+ exclude_sql = ""
+ if exclude_topic_id is not None:
+ exclude_sql = " AND t.id<>?"
+ params.append(exclude_topic_id)
+
+ selected: list[aiosqlite.Row] = []
+ seen_doc_ids: set[int] = set()
+
+ if group_id is not None:
+ async with db.execute(
+ f"""
+ SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
+ g.name AS group_name, g.talker
+ FROM knowledge_docs k
+ JOIN topics t ON t.id = k.topic_id
+ LEFT JOIN groups g ON g.id = t.group_id
+ WHERE k.curated_at IS NOT NULL
+ AND t.group_id=?
+ {exclude_sql}
+ ORDER BY k.curated_at DESC, k.updated_at DESC
+ LIMIT ?
+ """,
+ [group_id, *params, limit],
+ ) as cur:
+ rows = await cur.fetchall()
+ for row in rows:
+ selected.append(row)
+ seen_doc_ids.add(int(row["id"]))
+
+ if len(selected) < limit:
+ remaining = limit - len(selected)
+ fts_query = build_match_query(query or "")
+ if fts_query:
+ async with db.execute(
+ f"""
+ SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
+ g.name AS group_name, g.talker
+ FROM knowledge_docs k
+ JOIN topics t ON t.id = k.topic_id
+ LEFT JOIN groups g ON g.id = t.group_id
+ WHERE k.curated_at IS NOT NULL
+ AND k.id IN (SELECT doc_id FROM knowledge_fts WHERE knowledge_fts MATCH ?)
+ {exclude_sql}
+ ORDER BY CASE WHEN t.group_id=? THEN 0 ELSE 1 END,
+ k.curated_at DESC,
+ k.updated_at DESC
+ LIMIT ?
+ """,
+ [fts_query, *params, group_id or -1, remaining * 3],
+ ) as cur:
+ rows = await cur.fetchall()
+ for row in rows:
+ doc_id = int(row["id"])
+ if doc_id in seen_doc_ids:
+ continue
+ selected.append(row)
+ seen_doc_ids.add(doc_id)
+ if len(selected) >= limit:
+ break
+
+ if len(selected) < limit:
+ remaining = limit - len(selected)
+ async with db.execute(
+ f"""
+ SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
+ g.name AS group_name, g.talker
+ FROM knowledge_docs k
+ JOIN topics t ON t.id = k.topic_id
+ LEFT JOIN groups g ON g.id = t.group_id
+ WHERE k.curated_at IS NOT NULL
+ {exclude_sql}
+ ORDER BY CASE WHEN t.group_id=? THEN 0 ELSE 1 END,
+ k.curated_at DESC,
+ k.updated_at DESC
+ LIMIT ?
+ """,
+ [*params, group_id or -1, remaining * 3],
+ ) as cur:
+ rows = await cur.fetchall()
+ for row in rows:
+ doc_id = int(row["id"])
+ if doc_id in seen_doc_ids:
+ continue
+ selected.append(row)
+ seen_doc_ids.add(doc_id)
+ if len(selected) >= limit:
+ break
+
+ return _format_examples(selected[:limit], purpose)
diff --git a/chatlog_fastAPI/services/runtime_settings.py b/chatlog_fastAPI/services/runtime_settings.py
new file mode 100644
index 0000000..3a10b5c
--- /dev/null
+++ b/chatlog_fastAPI/services/runtime_settings.py
@@ -0,0 +1,45 @@
+import logging
+import aiosqlite
+from config import settings as default_settings
+from database import get_active_db_path
+
+log = logging.getLogger(__name__)
+
+_cache: dict | None = None
+
+
+def invalidate_cache():
+ global _cache
+ _cache = None
+
+
+async def get_ai_settings() -> dict:
+ global _cache
+ if _cache is not None:
+ return _cache
+
+ # ai_base_url 保留默认值(阿里云兼容 OpenAI 格式地址),其余字段必须由用户在设置页配置
+ result = {
+ "ai_base_url": default_settings.ai_base_url,
+ "ai_api_key": "",
+ "ai_model": "",
+ "summary_model": "",
+ "vision_model": "",
+ "voice_model": "",
+ "topic_analysis_prompt": "",
+ }
+
+ try:
+ path = get_active_db_path()
+ async with aiosqlite.connect(path) as db:
+ db.row_factory = aiosqlite.Row
+ async with db.execute("SELECT key, value FROM app_settings") as cur:
+ rows = await cur.fetchall()
+ for row in rows:
+ if row["key"] in result and row["value"]:
+ result[row["key"]] = row["value"]
+ except Exception as e:
+ log.warning(f"Failed to read runtime settings: {e}")
+
+ _cache = result
+ return result
diff --git a/chatlog_fastAPI/services/summary_engine.py b/chatlog_fastAPI/services/summary_engine.py
new file mode 100644
index 0000000..bed9e71
--- /dev/null
+++ b/chatlog_fastAPI/services/summary_engine.py
@@ -0,0 +1,476 @@
+"""
+售后报告生成引擎
+- 从 topic_messages 拿到所有 msg_seq
+- 通过 chatlog batch 接口批量拉回消息原文
+- 用配置的总结模型生成 Markdown 售后事件报告
+- 写入 knowledge_docs + knowledge_fts(jieba 分词)
+"""
+
+import asyncio
+import logging
+import json
+import aiosqlite
+from urllib.parse import quote
+
+from database import get_active_db_path
+from services.ai_client import get_openai_client
+from services.fts import tokenize
+from services.message_formatter import append_quote_text, extract_contents, extract_quote
+from services.report_learning import build_report_learning_context
+
+log = logging.getLogger(__name__)
+
+CHATLOG_BATCH_SIZE = 80
+SUMMARY_LLM_TIMEOUT_SECONDS = 300
+
+
+async def _get_client():
+ return await get_openai_client()
+
+
+def _message_line(item: dict, fallback_seq: int = 0) -> tuple[int, str] | None:
+ if not item:
+ return None
+ seq = item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0
+ time_str = item.get("create_time") or item.get("time") or item.get("CreateTime") or ""
+ sender = (
+ item.get("sender_name")
+ or item.get("senderName")
+ or item.get("SenderName")
+ or item.get("sender")
+ or item.get("Sender")
+ or ""
+ )
+ content = _message_text(item)
+ if not content:
+ return None
+ return int(seq), f"[{time_str}] {sender}: {content}"
+
+
+def _message_meta(item: dict, fallback_seq: int = 0) -> dict:
+ return {
+ "seq": int(item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0),
+ "time": item.get("create_time") or item.get("time") or item.get("CreateTime") or "",
+ "sender": (
+ item.get("sender_name")
+ or item.get("senderName")
+ or item.get("SenderName")
+ or item.get("sender")
+ or item.get("Sender")
+ or ""
+ ),
+ "type": item.get("type") or item.get("Type") or 1,
+ }
+
+
+def _extract_contents(item: dict) -> dict:
+ return extract_contents(item)
+
+
+def _message_text(item: dict) -> str:
+ content = item.get("content") or item.get("Content") or ""
+ contents = _extract_contents(item)
+ if isinstance(content, str) and content.lstrip().startswith("<") and extract_quote(item):
+ content = ""
+
+ link_title = contents.get("title") or item.get("link_title") or ""
+ link_desc = contents.get("desc") or item.get("link_desc") or ""
+ link_source = contents.get("sourceName") or contents.get("source_name") or item.get("link_source") or ""
+ link_url = contents.get("url") or item.get("link_url") or ""
+
+ if link_title:
+ parts = [f"[链接卡片] {link_title}"]
+ if link_desc:
+ parts.append(link_desc)
+ if link_source:
+ parts.append(f"来源:{link_source}")
+ if link_url:
+ parts.append(f"URL:{link_url}")
+ if content and content not in parts:
+ parts.append(content)
+ return append_quote_text(";".join(parts), item)
+
+ return append_quote_text(content, item)
+
+
+def _extract_image_key(item: dict) -> str:
+ contents = _extract_contents(item)
+ key = (
+ contents.get("rawmd5")
+ or contents.get("md5")
+ or contents.get("path")
+ or item.get("media_key")
+ or item.get("mediaKey")
+ or item.get("image_path")
+ or ""
+ )
+ return str(key).replace("\\", "/")
+
+
+def _is_image_message(item: dict) -> bool:
+ try:
+ return int(item.get("type") or item.get("Type") or 0) == 3
+ except Exception:
+ return False
+
+
+def _media_path(kind: str, key: str) -> str:
+ return f"/{kind}/" + "/".join(quote(part) for part in key.split("/"))
+
+
+def _image_url(key: str) -> str:
+ return f"{_media_path('image', key)}?thumb=1"
+
+
+def _collect_image_evidence(messages: list[dict]) -> tuple[list[dict], list[dict]]:
+ images: list[dict] = []
+ failures: list[dict] = []
+
+ for item in messages:
+ if not _is_image_message(item):
+ continue
+ meta = _message_meta(item)
+ key = _extract_image_key(item)
+ if not key:
+ failures.append({**meta, "url": "", "reason": "图片无法展示,缺少图片文件标识"})
+ continue
+
+ url = _image_url(key)
+ images.append({**meta, "key": key, "url": url})
+
+ return images, failures
+
+
+def _image_evidence_context(images: list[dict], failures: list[dict]) -> str:
+ lines: list[str] = []
+ if images:
+ lines.append("系统将作为原始材料插入报告的现场图片:")
+ for img in images:
+ lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']} url={img['url']}")
+ if failures:
+ lines.append("无法展示的图片清单:")
+ for img in failures:
+ link = f",查看图片:{img['url']}" if img.get("url") else ""
+ lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']}:{img['reason']}{link}")
+ return "\n".join(lines)
+
+
+def _image_success_markdown(images: list[dict]) -> str:
+ if not images:
+ return ""
+ blocks = ["### 现场图片"]
+ for img in images:
+ alt = f"现场图片 - {img['time']} {img['sender']}".strip()
+ blocks.extend(
+ [
+ f"",
+ f"来源:{img['time']} {img['sender']} seq={img['seq']}",
+ "",
+ ]
+ )
+ return "\n".join(blocks).strip()
+
+
+def _image_failure_markdown(failures: list[dict]) -> str:
+ if not failures:
+ return ""
+ lines = ["## 图片展示提示"]
+ for img in failures:
+ link = f",查看图片:{img['url']}" if img.get("url") else ""
+ lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']}:{img['reason']}{link}")
+ return "\n".join(lines)
+
+
+def _insert_after_heading(content: str, heading: str, addition: str) -> str:
+ if not addition:
+ return content
+ lines = content.splitlines()
+ for i, line in enumerate(lines):
+ if line.strip() == heading:
+ return "\n".join(lines[: i + 1] + ["", addition, ""] + lines[i + 1 :]).strip()
+ for i, line in enumerate(lines):
+ if line.startswith("# "):
+ return "\n".join(lines[: i + 1] + ["", heading, "", addition, ""] + lines[i + 1 :]).strip()
+ return f"{heading}\n\n{addition}\n\n{content}".strip()
+
+
+def _merge_image_sections(content: str, successes: list[dict], failures: list[dict]) -> str:
+ result = _insert_after_heading(content, "## 关键聊天依据", _image_success_markdown(successes))
+ failure_md = _image_failure_markdown(failures)
+ if failure_md:
+ result = f"{result.rstrip()}\n\n{failure_md}"
+ return result.strip()
+
+
+def _line_from_snapshot(raw: str | None, fallback_seq: int) -> str | None:
+ if not raw:
+ return None
+ try:
+ item = json.loads(raw)
+ except Exception:
+ return None
+ line = _message_line(item, fallback_seq)
+ return line[1] if line else None
+
+MARKDOWN_TEMPLATE = """\
+# {title}
+
+请按聊天记录中的实际内容生成一份【具体售后问题点】报告,不要照抄固定字段,也不要输出占位文案。
+
+必须围绕以下结构组织,按内容决定是否保留章节,不要输出空章节:
+## 问题摘要
+## 关键聊天依据
+## 当前处理状态
+## 是否解决
+## AI 建议/解决方法
+
+输出规则:
+- 只写聊天记录中能直接识别或合理归纳的信息。
+- 没有识别到的客户、门店、联系人、合同、订单、物流、日期、价格、原因等信息直接省略。
+- 不要写“未从聊天记录中识别”“待补充”“未知”“无”等占位内容。
+- “是否解决”只能从聊天记录判断,取值限定为:已解决、未解决、处理中、待确认。
+- 如果聊天内容不足以形成明确售后问题点,仍然按当前话题内容整理,但用更保守的“待确认”结论。
+- “AI 建议/解决方法”必须放在文档下方,并附注:注:此方法由 AI 生成,仅供参考,请以人工复核和现场实际情况为准。
+- 只输出 Markdown 报告,不要输出这些规则本身。
+"""
+
+
+async def _mark_summarize_failed(topic_id: int, task_id: int | None, error: str):
+ path = get_active_db_path()
+ message = error or "AI 报告生成失败"
+ try:
+ async with aiosqlite.connect(path) as db:
+ await db.execute(
+ "UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (topic_id,),
+ )
+ if task_id is not None:
+ await db.execute(
+ """
+ UPDATE ai_tasks
+ SET status='error', progress=?, error=?, updated_at=CURRENT_TIMESTAMP
+ WHERE id=?
+ """,
+ (json.dumps({"processed": 0, "total": 1}), message, task_id),
+ )
+ await db.commit()
+ except Exception as exc:
+ log.warning(f"[summarize] 标记失败状态失败 topic={topic_id} task={task_id}: {exc}")
+
+
+async def _run_summarize_impl(topic_id: int, topic: dict, task_id: int | None = None):
+ """
+ 为指定话题生成/更新 Markdown 售后事件报告。
+ 由 POST /api/topics/{id}/summarize(手动触发)调用。
+ task_id: 若提供,则更新 ai_tasks 表的状态和进度。
+ """
+ path = get_active_db_path()
+
+ async def _update_task(status: str, processed: int = 0, total: int = 1, error: str = ""):
+ """辅助函数:更新 ai_tasks 状态和进度"""
+ if task_id is None:
+ return
+ try:
+ async with aiosqlite.connect(path) as _db:
+ _db.row_factory = aiosqlite.Row
+ await _db.execute(
+ """
+ UPDATE ai_tasks
+ SET status=?, progress=?, error=?, updated_at=CURRENT_TIMESTAMP
+ WHERE id=?
+ """,
+ (status, json.dumps({"processed": processed, "total": total}), error or None, task_id)
+ )
+ await _db.commit()
+ except Exception as e:
+ log.warning(f"[summarize] 更新 task {task_id} 失败: {e}")
+ path = get_active_db_path()
+ async with aiosqlite.connect(path) as db:
+ db.row_factory = aiosqlite.Row
+
+ # 将话题状态置为 processing
+ await db.execute("UPDATE topics SET status = 'processing', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("running", 0, 1)
+
+ # 1. 拿到该话题的所有消息 seq 和群 talker
+ async with db.execute(
+ """
+ SELECT tm.msg_seq, tm.talker, tm.message_json
+ FROM topic_messages tm
+ WHERE tm.topic_id = ?
+ ORDER BY tm.msg_seq
+ """,
+ (topic_id,),
+ ) as cur:
+ msg_rows = await cur.fetchall()
+
+ if not msg_rows:
+ log.warning(f"[summarize] topic={topic_id} 没有消息,跳过")
+ error = "该话题没有关联消息,无法生成 AI 报告"
+ await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("error", 0, 1, error)
+ return
+
+ seqs = [r["msg_seq"] for r in msg_rows]
+ # talker 在 topic_messages 里存的是群 ID(chatlog 叫 talker)
+ group_talker = msg_rows[0]["talker"]
+
+ # 2. 批量从 chatlog 拉取消息原文(最多 100 条/批)
+ from services.chatlog_client import chatlog_client
+ messages_text: list[str] = []
+ message_items: dict[int, dict] = {}
+
+ fetched_lines: dict[int, str] = {}
+ for i in range(0, len(seqs), CHATLOG_BATCH_SIZE):
+ chunk_seqs = seqs[i: i + CHATLOG_BATCH_SIZE]
+ try:
+ result = await chatlog_client.get_messages_batch(group_talker, chunk_seqs)
+ for m in result.get("items", []):
+ meta = _message_meta(m)
+ if meta["seq"]:
+ message_items[meta["seq"]] = m
+ line = _message_line(m)
+ if line:
+ fetched_lines[line[0]] = line[1]
+ except Exception as e:
+ log.error(f"[summarize] batch 拉取失败 topic={topic_id}: {e}")
+
+ for r in msg_rows:
+ seq = int(r["msg_seq"])
+ if seq in fetched_lines:
+ messages_text.append(fetched_lines[seq])
+ continue
+ snap_raw = r["message_json"] if "message_json" in r.keys() else None
+ if seq not in message_items and snap_raw:
+ try:
+ snap_item = json.loads(snap_raw)
+ if isinstance(snap_item, dict):
+ message_items[seq] = snap_item
+ except Exception:
+ pass
+ snap_line = _line_from_snapshot(snap_raw, seq)
+ if snap_line:
+ messages_text.append(snap_line)
+
+ image_successes, image_failures = _collect_image_evidence(
+ [message_items[seq] for seq in seqs if seq in message_items]
+ )
+
+ if not messages_text and not image_successes and not image_failures:
+ log.warning(f"[summarize] topic={topic_id} 从 chatlog 获取到 0 条有效消息")
+ error = "未能从 chatlog 获取到有效消息,无法生成 AI 报告"
+ await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("error", 0, 1, error)
+ return
+
+ chat_text = "\n".join(messages_text) if messages_text else "无文字消息,仅有图片或媒体证据。"
+ image_context = _image_evidence_context(image_successes, image_failures)
+ learning_context = await build_report_learning_context(
+ db,
+ group_id=topic.get("group_id"),
+ query=f"{topic.get('title', '')}\n{chat_text[:2000]}",
+ exclude_topic_id=topic_id,
+ purpose="summary",
+ )
+
+ # 3. 构建 Prompt
+ template_filled = MARKDOWN_TEMPLATE.format(title=topic["title"])
+ prompt = (
+ f"售后问题点话题:{topic['title']}\n\n"
+ f"以下是该售后问题点关联的完整微信群聊天记录(按时间顺序):\n\n"
+ f"{chat_text}\n\n"
+ f"以下是系统将插入报告的现场图片信息(如有):\n\n{image_context or '无现场图片。'}\n\n"
+ "请根据上述聊天记录输出一份 Markdown 报告。\n"
+ "报告要求:\n"
+ "1. 保持售后问题点口径,优先提炼问题现象、涉及产品/部件、现场材料、处理过程和处理结果。\n"
+ "2. 只能使用聊天记录中能直接识别或合理归纳的信息,不要编造客户、合同、订单、物流、日期、价格、原因或处理结果。\n"
+ "3. 不要输出空字段、空项目、空章节、空表格;某个章节没有有效内容时整段省略。\n"
+ "4. 「是否解决」必须写在文档中,并使用:已解决 / 未解决 / 处理中 / 待确认。\n"
+ "5. 「AI 建议/解决方法」必须写在文档中,且在段末附上固定注释:注:此方法由 AI 生成,仅供参考,请以人工复核和现场实际情况为准。\n"
+ "6. 如果聊天内容不足以形成明确售后问题点,也不要编造结论;只按聊天中已有事实给出保守的待确认判断。\n"
+ "7. 图片会由系统作为「现场图片」原始材料插入「关键聊天依据」;你不要猜测图片内容,也不要自行输出图片 Markdown 或图片说明。\n"
+ "8. 如果聊天文字中有人描述图片内容,可以引用这些文字;但不要根据图片本身编造故障细节。\n"
+ "9. 聊天记录中的「[引用消息]」属于当前回复的上下文证据,可以用于理解被回复的问题和处理过程。\n"
+ "10. 只输出 Markdown 报告,不要输出模板说明或额外解释。\n\n"
+ f"以下是本企业报告库中人工修订过的历史报告示例(如有)。请只学习它们的栏目结构、措辞风格、问题关注点和结论表达方式;不得复制历史事实、客户名、设备状态或处理结果到当前报告:\n\n{learning_context or '暂无可学习的人工修订报告。'}\n\n"
+ f"{template_filled}"
+ )
+
+ # 4. 调用 LLM
+ try:
+ _client, _ai = await _get_client()
+ async with asyncio.timeout(SUMMARY_LLM_TIMEOUT_SECONDS):
+ resp = await _client.chat.completions.create(
+ model=_ai["summary_model"],
+ messages=[
+ {
+ "role": "system",
+ "content": (
+ "你是资深售后运营与设备服务工程师,负责根据微信群聊天记录整理具体售后问题点报告。"
+ "你必须忠实依据聊天记录,只输出已识别到的有效信息,缺失信息直接省略,不得编造。"
+ "你要在文档中明确给出是否解决结论,并给出 AI 建议/解决方法和免责声明。只输出 Markdown 报告,不要有任何额外说明。"
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ temperature=0.2,
+ )
+ content = resp.choices[0].message.content.strip()
+ content = _merge_image_sections(content, image_successes, image_failures)
+ except TimeoutError:
+ error = "AI 报告生成超时,请检查模型/API或稍后重试"
+ log.error(f"[summarize] LLM 调用超时 topic={topic_id}")
+ await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("error", 0, 1, error)
+ return
+ except Exception as e:
+ log.error(f"[summarize] LLM 调用失败 topic={topic_id}: {e}", exc_info=True)
+ await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("error", 0, 1, str(e) or "LLM 调用失败")
+ return
+
+ # 5. 写入 knowledge_docs
+ async with db.execute(
+ "SELECT id FROM knowledge_docs WHERE topic_id = ?", (topic_id,)
+ ) as cur:
+ existing = await cur.fetchone()
+
+ if existing:
+ doc_id = existing["id"]
+ await db.execute(
+ "UPDATE knowledge_docs SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (content, doc_id),
+ )
+ else:
+ await db.execute(
+ "INSERT INTO knowledge_docs (topic_id, content) VALUES (?, ?)",
+ (topic_id, content),
+ )
+ async with db.execute("SELECT last_insert_rowid() AS id") as cur:
+ doc_id = (await cur.fetchone())["id"]
+
+ # 6. 更新 FTS(先删后插)
+ await db.execute("DELETE FROM knowledge_fts WHERE doc_id = ?", (doc_id,))
+ await db.execute(
+ "INSERT INTO knowledge_fts (doc_id, title, content) VALUES (?, ?, ?)",
+ (doc_id, tokenize(topic["title"]), tokenize(content)),
+ )
+
+ await db.execute("UPDATE topics SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
+ await db.commit()
+ await _update_task("done", 1, 1)
+ log.info(f"[summarize] topic={topic_id} doc={doc_id} 生成完成({len(content)} 字符)")
+
+
+async def run_summarize(topic_id: int, topic: dict, task_id: int | None = None):
+ try:
+ await _run_summarize_impl(topic_id, topic, task_id)
+ except Exception as e:
+ error = str(e) or e.__class__.__name__
+ log.error(f"[summarize] 未捕获异常 topic={topic_id}: {error}", exc_info=True)
+ await _mark_summarize_failed(topic_id, task_id, error)
diff --git a/chatlog_fastAPI/services/topic_engine.py b/chatlog_fastAPI/services/topic_engine.py
new file mode 100644
index 0000000..5eba15e
--- /dev/null
+++ b/chatlog_fastAPI/services/topic_engine.py
@@ -0,0 +1,1094 @@
+"""
+话题分类引擎
+
+用户点击 AI 分析后,按所选时间段全量分页拉取消息,解析可用媒体证据,
+再分批抽取、跨批合并并校验消息归属。
+"""
+
+import asyncio
+import json
+import logging
+import re
+from datetime import datetime
+import aiosqlite
+from fastapi import HTTPException
+
+from database import get_active_db_path
+from services.ai_client import get_openai_client
+from services.chatlog_client import chatlog_client
+from services.message_formatter import append_quote_text, extract_quote
+from services.media_parser import parse_media
+from services.report_learning import build_report_learning_context
+from services.runtime_settings import get_ai_settings
+
+log = logging.getLogger(__name__)
+
+_classify_lock = asyncio.Lock()
+_classifying_group: int | None = None
+
+FETCH_PAGE_SIZE = 500
+CLASSIFY_BATCH_SIZE = 40
+CONTEXT_BEFORE = 4
+CONTEXT_AFTER = 4
+CLASSIFY_CONTENT_LIMIT = 900
+MEDIA_PARSE_CONCURRENCY = 2
+UNCATEGORIZED_BUSINESS = "未归类设备问题(需人工复核)"
+DAILY_TOPIC = "日常交流/待确认"
+DEVICE_ISSUE_EXAMPLES = (
+ "报警/故障代码", "主轴异常", "刀库/换刀异常", "回零/限位异常",
+ "伺服/驱动异常", "加工精度异常", "气压/液压异常", "润滑/冷却异常",
+ "电气线路/IO异常", "系统参数/程序操作问题",
+)
+
+BUSINESS_KEYWORDS = (
+ "设备", "机床", "数控", "加工中心", "CNC", "cnc", "大铁", "客户", "公司", "厂家", "现场",
+ "报警", "警报", "报错", "故障", "故障码", "代码", "异常", "报修", "维修", "售后", "工程师",
+ "主轴", "转速", "拉刀", "松刀", "刀库", "换刀", "刀臂", "刀盘", "刀号", "卡刀", "掉刀",
+ "回零", "原点", "限位", "行程", "伺服", "驱动", "驱动器", "电机", "编码器", "变频器",
+ "丝杆", "导轨", "精度", "尺寸", "跑偏", "震刀", "振刀", "圆度", "平面度", "加工",
+ "气压", "液压", "油压", "润滑", "冷却", "水泵", "切削液", "漏油", "漏水", "异响",
+ "电柜", "线路", "电源", "跳闸", "断电", "IO", "I/O", "PLC", "系统", "参数", "程序",
+ "G代码", "M代码", "面板", "手轮", "急停", "排屑", "卡住", "调试", "安装", "处理",
+)
+
+
+async def _get_client():
+ return await get_openai_client()
+
+
+def get_classifying_group() -> int | None:
+ """返回正在分析的 group_id,没有则 None。供 routers 检查串行状态。"""
+ return _classifying_group
+
+
+def _msg_seq(m: dict) -> int:
+ return int(m.get("sort_seq") or m.get("seq") or m.get("Seq") or m.get("id") or 0)
+
+
+def _msg_time(m: dict) -> str:
+ return m.get("create_time") or m.get("time") or m.get("CreateTime") or ""
+
+
+def _msg_sender(m: dict) -> str:
+ return (
+ m.get("sender_name")
+ or m.get("senderName")
+ or m.get("SenderName")
+ or m.get("sender")
+ or m.get("Sender")
+ or ""
+ )
+
+
+def _raw_sender_id(m: dict) -> str:
+ return str(m.get("sender") or m.get("Sender") or "").strip()
+
+
+def _looks_like_raw_sender_id(value: str | None) -> bool:
+ value = (value or "").strip()
+ return (
+ not value
+ or value.startswith("wxid_")
+ or value.startswith("gh_")
+ or value.endswith("@chatroom")
+ or value.startswith("chatroom_")
+ )
+
+
+def _sender_display_name(m: dict, sender: str = "") -> str:
+ for key in (
+ "sender_name",
+ "senderName",
+ "SenderName",
+ "accountName",
+ "groupNickname",
+ "displayName",
+ "nickName",
+ "remark",
+ ):
+ value = str(m.get(key) or "").strip()
+ if value and value != sender and not _looks_like_raw_sender_id(value):
+ return value
+ return ""
+
+
+def _fill_message_sender_names(messages: list[dict]) -> None:
+ names: dict[str, str] = {}
+ for m in messages:
+ sender = _raw_sender_id(m)
+ name = _sender_display_name(m, sender)
+ if sender and name:
+ names[sender] = name
+ if not names:
+ return
+ for m in messages:
+ sender = _raw_sender_id(m)
+ if not sender or sender not in names:
+ continue
+ current = str(m.get("sender_name") or m.get("senderName") or m.get("SenderName") or "").strip()
+ if not current or current == sender or _looks_like_raw_sender_id(current):
+ m["sender_name"] = names[sender]
+ m["senderName"] = names[sender]
+
+
+def _msg_type(m: dict) -> int:
+ try:
+ return int(m.get("type") or m.get("Type") or 1)
+ except Exception:
+ return 1
+
+
+def _msg_sub_type(m: dict) -> int:
+ try:
+ return int(m.get("sub_type") or m.get("subType") or m.get("SubType") or 0)
+ except Exception:
+ return 0
+
+
+def _contents(m: dict) -> dict:
+ contents = m.get("contents") or m.get("Contents") or {}
+ return contents if isinstance(contents, dict) else {}
+
+
+def _media_key(m: dict) -> str:
+ contents = _contents(m)
+ key = (
+ contents.get("rawmd5")
+ or contents.get("md5")
+ or contents.get("path")
+ or m.get("media_key")
+ or m.get("mediaKey")
+ or m.get("image_path")
+ or ""
+ )
+ return str(key).replace("\\", "/")
+
+
+def _voice_key(m: dict) -> str:
+ contents = _contents(m)
+ if contents.get("voice"):
+ return str(contents.get("voice"))
+ return str(m.get("voice_key") or m.get("voiceKey") or "")
+
+
+def _message_snapshot(m: dict) -> str:
+ """保存分类时拿到的原始消息快照,供后续 chatlog batch 查不到时兜底显示。"""
+ return json.dumps(m, ensure_ascii=False, separators=(",", ":"))
+
+
+def _base_msg_content(m: dict) -> str:
+ content = m.get("content") or m.get("Content") or ""
+ contents = _contents(m)
+ if isinstance(content, str) and content.lstrip().startswith("<") and extract_quote(m):
+ content = ""
+
+ link_title = contents.get("title") or m.get("link_title") or ""
+ link_desc = contents.get("desc") or m.get("link_desc") or ""
+ link_source = contents.get("sourceName") or contents.get("source_name") or m.get("link_source") or ""
+ link_url = contents.get("url") or m.get("link_url") or ""
+ file_name = contents.get("fileName") or contents.get("filename") or contents.get("title") or ""
+
+ parts: list[str] = []
+ if link_title:
+ parts.append(f"[链接/文件] {link_title}")
+ if file_name and file_name not in parts:
+ parts.append(f"文件名:{file_name}")
+ if link_desc:
+ parts.append(f"描述:{link_desc}")
+ if link_source:
+ parts.append(f"来源:{link_source}")
+ if link_url:
+ parts.append(f"URL:{link_url}")
+ if content and content not in parts:
+ parts.append(content)
+ return append_quote_text(";".join(parts) if parts else content, m)
+
+
+def _is_image_message(m: dict) -> bool:
+ return _msg_type(m) == 3
+
+
+def _is_voice_message(m: dict) -> bool:
+ return _msg_type(m) == 34
+
+
+def _is_video_message(m: dict) -> bool:
+ return _msg_type(m) == 43
+
+
+def _is_file_or_link_message(m: dict) -> bool:
+ return _msg_type(m) == 49
+
+
+def _is_file_message(m: dict) -> bool:
+ return _msg_type(m) == 49 and _msg_sub_type(m) == 6
+
+
+def _media_kind(m: dict) -> str | None:
+ if _is_image_message(m):
+ return "image"
+ if _is_voice_message(m):
+ return "voice"
+ if _is_video_message(m):
+ return "video"
+ return None
+
+
+def _media_parse_key(m: dict) -> str:
+ kind = _media_kind(m)
+ if kind == "voice":
+ return _voice_key(m)
+ if kind in {"image", "video"}:
+ return _media_key(m)
+ return ""
+
+
+def _message_analysis_text(m: dict) -> str:
+ base = _base_msg_content(m).strip()
+ parsed = str(m.get("_ai_media_text") or "").strip()
+ parse_error = str(m.get("_ai_media_error") or "").strip()
+ parts = []
+ if base:
+ parts.append(base)
+
+ kind = _media_kind(m)
+ if parsed:
+ label = {"image": "图片描述", "voice": "语音转写", "video": "视频截图描述"}.get(kind or "", "媒体解析")
+ parts.append(f"[{label}] {parsed}")
+ elif parse_error:
+ parts.append(f"[媒体解析失败] {parse_error}")
+ elif kind:
+ parts.append(f"[{kind}消息] key={_media_parse_key(m) or '无'}")
+
+ if _is_file_or_link_message(m) and not parts:
+ parts.append("[文件/链接消息]")
+
+ neighbor_context = str(m.get("_neighbor_context") or "").strip()
+ if neighbor_context and (kind or _is_file_or_link_message(m) or parse_error):
+ parts.append(f"[附近上下文] {neighbor_context}")
+
+ text = ";".join(parts).strip()
+ if len(text) > CLASSIFY_CONTENT_LIMIT:
+ text = text[:CLASSIFY_CONTENT_LIMIT] + "..."
+ return text
+
+
+def _prompt_item(m: dict, core: bool = True) -> dict:
+ return {
+ "seq": _msg_seq(m),
+ "time": _msg_time(m),
+ "sender": _msg_sender(m),
+ "core": core,
+ "type": _msg_type(m),
+ "content": _message_analysis_text(m),
+ }
+
+
+def _extract_json_array(text: str) -> list:
+ array_start = text.find("[")
+ object_start = text.find("{")
+ if array_start >= 0 and (object_start < 0 or array_start < object_start):
+ end = text.rfind("]") + 1
+ if end <= 0:
+ raise ValueError(f"LLM 返回内容无法解析为 JSON 数组: {text[:200]}")
+ data = json.loads(text[array_start:end])
+ elif object_start >= 0:
+ end = text.rfind("}") + 1
+ if end <= 0:
+ raise ValueError(f"LLM 返回内容无法解析为 JSON 对象: {text[:200]}")
+ data = json.loads(text[object_start:end])
+ else:
+ raise ValueError(f"LLM 返回内容无法解析为 JSON: {text[:200]}")
+ if isinstance(data, dict):
+ data = data.get("topics") or data.get("items") or []
+ if not isinstance(data, list):
+ raise ValueError("LLM JSON 不是数组")
+ return data
+
+
+def _parse_msg_time(raw: str) -> float | None:
+ """把 chatlog 的 ISO 时间字符串解析为 unix 秒。失败返回 None。"""
+ if not raw:
+ return None
+ try:
+ s = raw.replace("/", "-")
+ if "T" not in s and " " in s:
+ s = s.replace(" ", "T", 1)
+ return datetime.fromisoformat(s).timestamp()
+ except Exception:
+ return None
+
+
+def _looks_business_message(m: dict) -> bool:
+ text = _message_analysis_text(m)
+ if _is_file_or_link_message(m) or _is_voice_message(m):
+ return True
+ if (_is_image_message(m) or _is_video_message(m)) and str(m.get("_neighbor_context") or "").strip():
+ return True
+ if (_is_image_message(m) or _is_video_message(m)) and str(m.get("_ai_media_text") or "").strip():
+ return True
+ low = text.lower()
+ if any(k.lower() in low for k in BUSINESS_KEYWORDS):
+ return True
+ if re.search(r"\b(a20\d+|ap\d+|sp\d+|\d+p|[a-z]{2,}\d{2,})\b", low):
+ return True
+ if re.search(r"\.(dwg|xlsx?|pdf|docx?|zip|rar)\b", low):
+ return True
+ return False
+
+
+def _has_text_signal(m: dict) -> bool:
+ text = _base_msg_content(m).strip()
+ if text:
+ return True
+ contents = _contents(m)
+ return bool(contents.get("desc") or contents.get("title") or contents.get("refer") or contents.get("recordInfo"))
+
+
+def _attach_neighbor_context(messages: list[dict], radius: int = 4) -> None:
+ """给图片/视频/语音/文件消息补充临近文本,供无法解析媒体时按上下文归类。"""
+ for idx, m in enumerate(messages):
+ if not (_media_kind(m) or _is_file_or_link_message(m)):
+ continue
+ lines: list[str] = []
+ sender = _msg_sender(m)
+ for j in range(max(0, idx - radius), min(len(messages), idx + radius + 1)):
+ if j == idx:
+ continue
+ n = messages[j]
+ if not _has_text_signal(n):
+ continue
+ # 同一发送者或时间邻近的文本最能解释媒体;不同发送者也保留少量上下文。
+ prefix = "前文" if j < idx else "后文"
+ relation = "同发送人" if sender and _msg_sender(n) == sender else "邻近"
+ text = _base_msg_content(n).strip()
+ if not text:
+ text = _message_analysis_text(n).strip()
+ if not text:
+ continue
+ if len(text) > 180:
+ text = text[:180] + "..."
+ lines.append(f"{prefix}/{relation} seq={_msg_seq(n)} {text}")
+ if lines:
+ m["_neighbor_context"] = " | ".join(lines[:6])
+
+
+async def _fetch_window_messages(talker: str, start_ts: int, end_ts: int) -> list[dict]:
+ start_dt = datetime.fromtimestamp(start_ts)
+ end_dt = datetime.fromtimestamp(end_ts)
+ time_str = f"{start_dt.strftime('%Y-%m-%d')},{end_dt.strftime('%Y-%m-%d')}"
+
+ all_items: list[dict] = []
+ offset = 0
+ seen: set[int] = set()
+ while True:
+ data = await chatlog_client.get_messages(
+ talker,
+ time=time_str,
+ limit=FETCH_PAGE_SIZE,
+ offset=offset,
+ )
+ items = data.get("items", []) or []
+ total = int(data.get("total") or 0)
+ if not items:
+ break
+ for m in items:
+ seq = _msg_seq(m)
+ if seq and seq in seen:
+ continue
+ t = _msg_time(m)
+ ts = _parse_msg_time(t)
+ if ts is None or (start_ts <= ts <= end_ts):
+ all_items.append(m)
+ if seq:
+ seen.add(seq)
+ offset += len(items)
+ if total and offset >= total:
+ break
+ if len(items) < FETCH_PAGE_SIZE:
+ break
+
+ all_items.sort(key=lambda m: (_parse_msg_time(_msg_time(m)) or 0, _msg_seq(m)))
+ return all_items
+
+
+def _validate_media_settings(messages: list[dict], ai_settings: dict) -> str | None:
+ has_visual = any(_media_kind(m) in {"image", "video"} for m in messages)
+ has_voice = any(_media_kind(m) == "voice" for m in messages)
+ if has_visual and not ai_settings.get("vision_model"):
+ return "所选时间段包含图片/视频消息,请先在「设置」页面填入视觉模型"
+ if has_voice and not ai_settings.get("voice_model"):
+ return "所选时间段包含语音消息,请先在「设置」页面填入语音模型"
+ return None
+
+
+async def _parse_message_media(messages: list[dict], update_progress, base_processed: int, total: int) -> int:
+ semaphore = asyncio.Semaphore(MEDIA_PARSE_CONCURRENCY)
+ media_messages = [m for m in messages if _media_kind(m) and _media_parse_key(m)]
+ parsed_count = 0
+ cache: dict[tuple[str, str], str] = {}
+
+ async def parse_one(m: dict):
+ nonlocal parsed_count
+ kind = _media_kind(m)
+ key = _media_parse_key(m)
+ if not kind or not key:
+ return
+ async with semaphore:
+ try:
+ cache_key = (kind, key)
+ if cache_key not in cache:
+ parsed = await parse_media(kind, key)
+ cache[cache_key] = parsed.get("text") or ""
+ m["_ai_media_text"] = cache[cache_key]
+ except HTTPException as e:
+ m["_ai_media_error"] = str(e.detail)
+ except Exception as e:
+ log.warning(f"[classify] media parse failed seq={_msg_seq(m)}: {e}", exc_info=True)
+ m["_ai_media_error"] = str(e)
+ finally:
+ parsed_count += 1
+ await update_progress("running", base_processed + parsed_count, total)
+
+ await asyncio.gather(*(parse_one(m) for m in media_messages))
+ return len(media_messages)
+
+
+async def _manual_assigned_seqs(db: aiosqlite.Connection, group_id: int) -> set[int]:
+ async with db.execute(
+ """
+ SELECT DISTINCT tm.msg_seq
+ FROM topic_messages tm
+ JOIN topics t ON t.id = tm.topic_id
+ WHERE t.group_id = ? AND COALESCE(t.source, 'manual') = 'manual'
+ """,
+ (group_id,),
+ ) as cur:
+ return {int(row["msg_seq"]) for row in await cur.fetchall()}
+
+
+async def _delete_ai_topics(db: aiosqlite.Connection, group_id: int) -> None:
+ await db.execute(
+ """
+ DELETE FROM knowledge_fts WHERE doc_id IN (
+ SELECT d.id FROM knowledge_docs d
+ JOIN topics t ON t.id = d.topic_id
+ WHERE t.group_id=? AND COALESCE(t.source, 'manual')='ai'
+ )
+ """,
+ (group_id,),
+ )
+ await db.execute(
+ """
+ DELETE FROM knowledge_docs
+ WHERE topic_id IN (
+ SELECT id FROM topics WHERE group_id=? AND COALESCE(source, 'manual')='ai'
+ )
+ """,
+ (group_id,),
+ )
+ await db.execute(
+ """
+ DELETE FROM topic_messages
+ WHERE topic_id IN (
+ SELECT id FROM topics WHERE group_id=? AND COALESCE(source, 'manual')='ai'
+ )
+ """,
+ (group_id,),
+ )
+ await db.execute("DELETE FROM topics WHERE group_id=? AND COALESCE(source, 'manual')='ai'", (group_id,))
+ await db.commit()
+
+
+def _chunk_messages(messages: list[dict]) -> list[tuple[list[dict], list[dict]]]:
+ chunks = []
+ for start in range(0, len(messages), CLASSIFY_BATCH_SIZE):
+ end = min(len(messages), start + CLASSIFY_BATCH_SIZE)
+ ctx_start = max(0, start - CONTEXT_BEFORE)
+ ctx_end = min(len(messages), end + CONTEXT_AFTER)
+ chunks.append((messages[start:end], messages[ctx_start:ctx_end]))
+ return chunks
+
+
+def _batch_classify_prompt(core_messages: list[dict], context_messages: list[dict], guidance: str = "") -> str:
+ core_seqs = {_msg_seq(m) for m in core_messages}
+ payload = [_prompt_item(m, _msg_seq(m) in core_seqs) for m in context_messages]
+ guidance_block = f"\n\n{guidance.strip()}\n\n" if guidance and guidance.strip() else ""
+ prompt = (
+ "你是广东大铁数控机械有限公司设备售后群话题分析助手。这个微信群里主要是大铁设备客户反馈问题,售后工程师对接处理。\n"
+ "请只为 core=true 的消息按【设备问题/故障现象】聚类,而不是按客户公司、地域、日期、工程师或单次对话聚类。\n"
+ "同类设备问题即使来自不同公司、不同地区,也要合并到同一话题;不同设备问题必须准确分开。\n"
+ f"常见问题口径包括:{', '.join(DEVICE_ISSUE_EXAMPLES)}。\n\n"
+ f"消息列表:\n{json.dumps(payload, ensure_ascii=False)}\n\n"
+ "规则:\n"
+ "1. 只输出 core=true 消息的归属,context 只用来理解上下文。\n"
+ "2. 标题必须体现设备部件和问题现象,建议如「主轴报警/故障代码问题」「刀库换刀卡顿」「回零/限位异常」。不要把客户公司名作为标题核心。\n"
+ "3. 不同公司出现相同设备问题时合并;同一公司出现不同问题时拆开。\n"
+ "4. [引用消息] 是当前回复的强上下文,必须用于理解当前消息含义,但归类对象仍然只能是当前 core seq。\n"
+ "5. 工程师回复、客户补充说明、图片、语音、视频、文件,必须跟随其对应的设备问题归类。\n"
+ "6. 图片/视频无法识别时,不要编造内容,但必须根据附近上下文判断所属设备问题。\n"
+ "7. 真正寒暄、入群通知、撤回、无设备售后含义短句才可归入「日常交流/待确认」;设备咨询和报修不得归入日常交流。\n"
+ "8. 每条 core 消息最多归入一个话题,尽量覆盖所有 core seq。\n\n"
+ "输出严格 JSON 数组,不要解释。格式:\n"
+ '[{"topic_key":"稳定短键","title":"具体话题标题","seqs":[1,2],"reason":"分类依据"}]'
+ )
+ return prompt + guidance_block
+
+
+async def _classify_batches(
+ messages: list[dict],
+ update_progress,
+ base_processed: int,
+ total: int,
+ guidance: str = "",
+) -> list[dict]:
+ chunks = _chunk_messages(messages)
+ results: list[dict] = []
+ _client, _ai = await _get_client()
+
+ for i, (core, context) in enumerate(chunks, start=1):
+ prompt = _batch_classify_prompt(core, context, guidance)
+ try:
+ resp = await _client.chat.completions.create(
+ model=_ai["ai_model"],
+ messages=[
+ {
+ "role": "system",
+ "content": "你是广东大铁数控机械有限公司设备售后问题分类器。你只输出 JSON 数组,不输出解释、Markdown 或额外文字。",
+ },
+ {"role": "user", "content": prompt},
+ ],
+ temperature=0.05,
+ )
+ batch_items = _extract_json_array(resp.choices[0].message.content.strip())
+ for item in batch_items:
+ if isinstance(item, dict):
+ item["_batch"] = i
+ results.append(item)
+ except Exception as e:
+ log.warning(f"[classify] batch {i} classify failed: {e}", exc_info=True)
+ await update_progress("running", base_processed + i, total)
+ return results
+
+
+def _sanitize_topic_items(items: list[dict], seq_to_msg: dict[int, dict], allowed_seqs: set[int]) -> list[dict]:
+ assigned: set[int] = set()
+ clean: list[dict] = []
+ for item in items:
+ title = str(item.get("title") or item.get("new_topic") or item.get("topic") or "").strip()
+ seqs = item.get("seqs") or item.get("message_seqs") or item.get("messages") or []
+ topic_key = str(item.get("topic_key") or title).strip()
+ reason = str(item.get("reason") or item.get("evidence") or "").strip()
+ clean_seqs: list[int] = []
+ for seq in seqs:
+ try:
+ n = int(seq)
+ except Exception:
+ continue
+ if n in allowed_seqs and n in seq_to_msg and n not in assigned:
+ clean_seqs.append(n)
+ if not title or not clean_seqs:
+ continue
+ if title == DAILY_TOPIC:
+ business = [n for n in clean_seqs if _looks_business_message(seq_to_msg[n])]
+ non_business = [n for n in clean_seqs if n not in business]
+ if business:
+ clean.append({
+ "topic_key": UNCATEGORIZED_BUSINESS,
+ "title": UNCATEGORIZED_BUSINESS,
+ "seqs": business,
+ "reason": "模型归入日常交流但消息含业务信号,转入人工复核。",
+ })
+ assigned.update(business)
+ clean_seqs = non_business
+ if not clean_seqs:
+ continue
+ if title != DAILY_TOPIC and len(title) > 80:
+ title = title[:80]
+ clean.append({"topic_key": topic_key, "title": title, "seqs": clean_seqs, "reason": reason})
+ assigned.update(clean_seqs)
+ return clean
+
+
+def _merge_prompt(candidates: list[dict]) -> str:
+ compact = [
+ {
+ "id": i,
+ "topic_key": c.get("topic_key", ""),
+ "title": c.get("title", ""),
+ "seqs": c.get("seqs", []),
+ "reason": c.get("reason", ""),
+ }
+ for i, c in enumerate(candidates)
+ ]
+ return (
+ "你是广东大铁数控机械有限公司设备售后话题合并审核员。请把分批识别出的候选话题做跨批次合并。\n"
+ "合并粒度是【设备问题/故障现象】。不要按客户公司、地域、日期、工程师或单次对话合并。\n"
+ "不同公司出现相同设备问题时必须合并,例如两个客户都反馈主轴报警,应归为同一类主轴报警问题。\n"
+ "不同设备部件、不同故障现象、不同处理路径必须分开,例如主轴报警、刀库卡刀、回零限位异常、加工精度异常不能互相合并。\n\n"
+ f"候选话题:\n{json.dumps(compact, ensure_ascii=False)}\n\n"
+ "输出严格 JSON 数组,不要解释。格式:\n"
+ '[{"title":"合并后的具体话题标题","candidate_ids":[0,1],"reason":"合并依据"}]'
+ )
+
+
+async def _merge_candidates(candidates: list[dict], update_progress, processed: int, total: int) -> list[dict]:
+ if not candidates:
+ return []
+ if len(candidates) == 1:
+ await update_progress("running", processed + 1, total)
+ return candidates
+
+ _client, _ai = await _get_client()
+ resp = await _client.chat.completions.create(
+ model=_ai["ai_model"],
+ messages=[
+ {
+ "role": "system",
+ "content": "你是广东大铁数控机械有限公司设备售后话题合并器。你只输出 JSON 数组,不输出解释、Markdown 或额外文字。",
+ },
+ {"role": "user", "content": _merge_prompt(candidates)},
+ ],
+ temperature=0.05,
+ )
+ merged_raw = _extract_json_array(resp.choices[0].message.content.strip())
+ used: set[int] = set()
+ merged: list[dict] = []
+ for item in merged_raw:
+ if not isinstance(item, dict):
+ continue
+ ids = item.get("candidate_ids") or item.get("ids") or []
+ valid_ids = []
+ for raw_id in ids:
+ try:
+ idx = int(raw_id)
+ except Exception:
+ continue
+ if 0 <= idx < len(candidates) and idx not in used:
+ valid_ids.append(idx)
+ if not valid_ids:
+ continue
+ title = str(item.get("title") or candidates[valid_ids[0]].get("title") or "").strip()
+ if not title:
+ continue
+ seqs: list[int] = []
+ for idx in valid_ids:
+ used.add(idx)
+ seqs.extend(candidates[idx].get("seqs", []))
+ merged.append({
+ "title": title[:80] if title != DAILY_TOPIC else title,
+ "seqs": seqs,
+ "reason": str(item.get("reason") or "").strip(),
+ })
+
+ for idx, candidate in enumerate(candidates):
+ if idx not in used:
+ merged.append(candidate)
+
+ await update_progress("running", processed + 1, total)
+ return merged
+
+
+def _supplement_prompt(unassigned: list[dict], topics: list[dict]) -> str:
+ payload = [_prompt_item(m, True) for m in unassigned]
+ topic_payload = [{"index": i, "title": t["title"], "seqs": t.get("seqs", [])[:8]} for i, t in enumerate(topics)]
+ return (
+ "你是广东大铁数控机械有限公司设备售后消息补充分配审核员。请把未归属消息分配到已有话题,或新建设备问题话题。\n"
+ "分配粒度是设备问题/故障现象,不是客户公司、地域、日期、工程师或单次对话。\n"
+ "不同公司出现相同设备问题时分配到同一话题;不同设备部件、故障现象或处理路径要新建不同话题。\n"
+ "[引用消息] 是当前回复的强上下文,必须用于理解当前消息含义,但归类对象仍然只能是当前消息 seq。\n"
+ "图片/视频内容无法识别时,必须优先使用消息里的[附近上下文]归入最相关已有设备问题;只有完全孤立且无上下文线索才新建「未归类设备问题(需人工复核)」。\n"
+ "设备咨询、报修、售后处理消息不得归入「日常交流/待确认」。只有寒暄、通知、撤回等无售后含义消息才可归入日常交流。\n\n"
+ f"已有话题:\n{json.dumps(topic_payload, ensure_ascii=False)}\n\n"
+ f"未归属消息:\n{json.dumps(payload, ensure_ascii=False)}\n\n"
+ "输出严格 JSON 数组,不要解释。格式:\n"
+ '[{"seq":1,"topic_index":0,"new_topic":"","reason":"依据"}]\n'
+ '若需要新建话题,topic_index 用 null,并填写 new_topic。'
+ )
+
+
+async def _supplement_assignments(
+ topics: list[dict],
+ messages: list[dict],
+ seq_to_msg: dict[int, dict],
+ allowed_seqs: set[int],
+ update_progress,
+ processed: int,
+ total: int,
+) -> list[dict]:
+ assigned = {seq for t in topics for seq in t.get("seqs", [])}
+ missing = [seq_to_msg[s] for s in allowed_seqs if s not in assigned]
+ if not missing:
+ await update_progress("running", processed + 1, total)
+ return topics
+
+ try:
+ _client, _ai = await _get_client()
+ resp = await _client.chat.completions.create(
+ model=_ai["ai_model"],
+ messages=[
+ {
+ "role": "system",
+ "content": "你是广东大铁数控机械有限公司设备售后消息补充分配器。你只输出 JSON 数组,不输出解释、Markdown 或额外文字。",
+ },
+ {"role": "user", "content": _supplement_prompt(missing, topics)},
+ ],
+ temperature=0.05,
+ )
+ results = _extract_json_array(resp.choices[0].message.content.strip())
+ except Exception as e:
+ log.warning(f"[classify] supplement assignment failed: {e}", exc_info=True)
+ results = []
+
+ topic_by_title = {t["title"]: t for t in topics}
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ try:
+ seq = int(item.get("seq"))
+ except Exception:
+ continue
+ if seq not in allowed_seqs or seq in assigned:
+ continue
+ title = ""
+ idx = item.get("topic_index")
+ try:
+ if idx is not None and 0 <= int(idx) < len(topics):
+ title = topics[int(idx)]["title"]
+ except Exception:
+ title = ""
+ if not title:
+ title = str(item.get("new_topic") or "").strip()
+ if not title or title == DAILY_TOPIC:
+ title = UNCATEGORIZED_BUSINESS if _looks_business_message(seq_to_msg[seq]) else DAILY_TOPIC
+ if title != DAILY_TOPIC and len(title) > 80:
+ title = title[:80]
+ if title not in topic_by_title:
+ topic_by_title[title] = {"title": title, "seqs": [], "reason": str(item.get("reason") or "").strip()}
+ topics.append(topic_by_title[title])
+ topic_by_title[title]["seqs"].append(seq)
+ assigned.add(seq)
+
+ for m in missing:
+ seq = _msg_seq(m)
+ if seq in assigned:
+ continue
+ title = UNCATEGORIZED_BUSINESS if _looks_business_message(m) else DAILY_TOPIC
+ if title not in topic_by_title:
+ topic_by_title[title] = {"title": title, "seqs": [], "reason": "补充分配后仍无法归入具体话题"}
+ topics.append(topic_by_title[title])
+ topic_by_title[title]["seqs"].append(seq)
+ assigned.add(seq)
+
+ await update_progress("running", processed + 1, total)
+ return topics
+
+
+def _finalize_topics(topics: list[dict], seq_to_msg: dict[int, dict], allowed_seqs: set[int]) -> list[dict]:
+ assigned: set[int] = set()
+ grouped: dict[str, dict] = {}
+ for topic in topics:
+ title = str(topic.get("title") or "").strip()
+ if not title:
+ continue
+ seqs: list[int] = []
+ for seq in topic.get("seqs", []):
+ try:
+ n = int(seq)
+ except Exception:
+ continue
+ if n not in allowed_seqs or n not in seq_to_msg or n in assigned:
+ continue
+ if title == DAILY_TOPIC and _looks_business_message(seq_to_msg[n]):
+ title = UNCATEGORIZED_BUSINESS
+ seqs.append(n)
+ assigned.add(n)
+ if not seqs:
+ continue
+ title = title[:80] if title != DAILY_TOPIC else title
+ if title not in grouped:
+ grouped[title] = {"title": title, "seqs": [], "reason": str(topic.get("reason") or "")}
+ grouped[title]["seqs"].extend(seqs)
+
+ for seq in allowed_seqs:
+ if seq in assigned:
+ continue
+ title = UNCATEGORIZED_BUSINESS if _looks_business_message(seq_to_msg[seq]) else DAILY_TOPIC
+ if title not in grouped:
+ grouped[title] = {"title": title, "seqs": [], "reason": "最终兜底归属"}
+ grouped[title]["seqs"].append(seq)
+
+ result = list(grouped.values())
+ result.sort(key=lambda t: min(t["seqs"]) if t["seqs"] else 0)
+ return result
+
+
+def _topic_text(topic: dict, seq_to_msg: dict[int, dict]) -> str:
+ parts = [str(topic.get("title") or ""), str(topic.get("reason") or "")]
+ for seq in topic.get("seqs", []):
+ try:
+ m = seq_to_msg[int(seq)]
+ except Exception:
+ continue
+ parts.append(_message_analysis_text(m))
+ return "\n".join(parts)
+
+
+def _strip_customer_prefix(title: str) -> str:
+ title = title.strip()
+ patterns = (
+ r"^[\u4e00-\u9fa5A-Za-z0-9()()##_-]{2,30}(?:公司|工厂|厂|客户|现场|车间)[-—::/]+",
+ r"^(?:客户|厂家|现场|公司)[\u4e00-\u9fa5A-Za-z0-9()()##_-]{0,20}[-—::/]+",
+ )
+ for pattern in patterns:
+ title = re.sub(pattern, "", title).strip()
+ return title
+
+
+def _infer_device_issue_title(topic: dict, seq_to_msg: dict[int, dict]) -> str:
+ original = str(topic.get("title") or "").strip()
+ if not original or original == DAILY_TOPIC:
+ return original
+
+ original = _strip_customer_prefix(original)
+ text = _topic_text(topic, seq_to_msg)
+ low = text.lower()
+
+ broad_titles = (
+ UNCATEGORIZED_BUSINESS,
+ "现场故障/售后处理",
+ "业务事项跟进",
+ "沟通响应跟进",
+ )
+ is_broad = original in broad_titles or any(original.endswith(f"-{title}") for title in broad_titles)
+ if not is_broad:
+ return original[:80] if original != DAILY_TOPIC else original
+
+ if any(k in text for k in ("主轴", "转速", "拉刀", "松刀")):
+ if any(k in text for k in ("报警", "警报", "报错", "故障码", "代码")):
+ return "主轴报警/故障代码问题"
+ return "主轴异常问题"
+
+ if any(k in text for k in ("刀库", "换刀", "刀臂", "刀盘", "刀号", "卡刀", "掉刀")):
+ return "刀库/换刀异常问题"
+
+ if any(k in text for k in ("回零", "原点", "限位", "行程")):
+ return "回零/限位异常问题"
+
+ if any(k in text for k in ("伺服", "驱动", "驱动器", "电机", "编码器", "变频器")):
+ return "伺服/驱动异常问题"
+
+ if any(k in text for k in ("精度", "尺寸", "跑偏", "震刀", "振刀", "圆度", "平面度")):
+ return "加工精度异常问题"
+
+ if any(k in text for k in ("气压", "液压", "油压", "漏油")):
+ return "气压/液压异常问题"
+
+ if any(k in text for k in ("润滑", "冷却", "水泵", "切削液", "漏水")):
+ return "润滑/冷却异常问题"
+
+ if any(k in text for k in ("电柜", "线路", "电源", "跳闸", "断电", "IO", "I/O", "PLC", "急停")):
+ return "电气线路/IO异常问题"
+
+ if any(k in text for k in ("系统", "参数", "程序", "G代码", "M代码", "面板", "手轮")):
+ return "系统参数/程序操作问题"
+
+ if "报警" in text or "警报" in text or "报错" in text or "故障码" in text or re.search(r"\b(error|alarm|err|e\d+)\b", low):
+ return "设备报警/故障代码问题"
+
+ if any(k in text for k in ("异响", "卡住", "排屑")):
+ return "设备机械异常问题"
+
+ return original
+
+
+def _coalesce_device_issue_topics(topics: list[dict], seq_to_msg: dict[int, dict]) -> list[dict]:
+ grouped: dict[str, dict] = {}
+ for topic in topics:
+ title = _infer_device_issue_title(topic, seq_to_msg)
+ if not title:
+ continue
+ if title not in grouped:
+ grouped[title] = {"title": title, "seqs": [], "reason": "按设备问题/故障现象自动合并"}
+ grouped[title]["seqs"].extend(topic.get("seqs", []))
+
+ result: list[dict] = []
+ seen_global: set[int] = set()
+ for title, topic in grouped.items():
+ seqs: list[int] = []
+ for seq in topic["seqs"]:
+ try:
+ n = int(seq)
+ except Exception:
+ continue
+ if n in seen_global:
+ continue
+ seqs.append(n)
+ seen_global.add(n)
+ if seqs:
+ result.append({"title": title[:80] if title != DAILY_TOPIC else title, "seqs": seqs, "reason": topic["reason"]})
+ result.sort(key=lambda t: min(t["seqs"]) if t["seqs"] else 0)
+ return result
+
+
+async def _save_topics(db: aiosqlite.Connection, group_id: int, talker: str, topics: list[dict], seq_to_msg: dict[int, dict]) -> None:
+ for topic in topics:
+ title = topic["title"]
+ seqs = topic.get("seqs", [])
+ if not seqs:
+ continue
+ await db.execute(
+ "INSERT INTO topics (group_id, title, source) VALUES (?, ?, 'ai')",
+ (group_id, title),
+ )
+ await db.commit()
+ async with db.execute("SELECT last_insert_rowid() AS id") as cur:
+ row = await cur.fetchone()
+ topic_id = row["id"]
+ for seq in seqs:
+ await db.execute(
+ """
+ INSERT OR IGNORE INTO topic_messages
+ (topic_id, msg_seq, talker, added_by, message_json)
+ VALUES (?, ?, ?, 'ai', ?)
+ """,
+ (topic_id, seq, talker, _message_snapshot(seq_to_msg[seq])),
+ )
+ await db.commit()
+
+
+async def run_classify_window(
+ group_id: int,
+ task_id: int,
+ group: dict,
+ start_ts: int,
+ end_ts: int,
+):
+ """
+ 对指定时间区间内全部消息做 AI 话题分类。
+
+ - 串行执行:同一时间只允许一个分类任务。
+ - 重跑时删除旧 AI 话题,保留人工话题。
+ """
+ global _classifying_group
+
+ path = get_active_db_path()
+
+ async def _update_task(status: str, processed: int = 0, total: int = 0, error: str = ""):
+ try:
+ async with aiosqlite.connect(path) as _db:
+ _db.row_factory = aiosqlite.Row
+ if error:
+ await _db.execute(
+ "UPDATE ai_tasks SET status=?, progress=?, error=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (status, json.dumps({"processed": processed, "total": total}), error, task_id),
+ )
+ else:
+ await _db.execute(
+ "UPDATE ai_tasks SET status=?, progress=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (status, json.dumps({"processed": processed, "total": total}), task_id),
+ )
+ await _db.commit()
+ except Exception as e:
+ log.warning(f"[classify] 更新 task {task_id} 失败: {e}")
+
+ _s = await get_ai_settings()
+ if not _s.get("ai_api_key") or not _s.get("ai_model"):
+ await _update_task("error", 0, 0, "AI 未配置,请在「设置」页面填入 API Key 和话题分析模型")
+ log.warning(f"[classify] group={group_id} aborted: AI not configured")
+ return
+
+ async with _classify_lock:
+ _classifying_group = group_id
+ try:
+ try:
+ messages = await _fetch_window_messages(group["talker"], start_ts, end_ts)
+ except Exception as e:
+ log.error(f"[classify] group={group_id} fetch error: {e}", exc_info=True)
+ await _update_task("error", 0, 0, f"拉取聊天记录失败:{e}")
+ return
+
+ total_messages = len(messages)
+ log.info(f"[classify] group={group_id} got {total_messages} msgs in selected window")
+ if total_messages == 0:
+ await _update_task("done", 0, 0)
+ return
+ _fill_message_sender_names(messages)
+
+ media_error = _validate_media_settings(messages, _s)
+ if media_error:
+ await _update_task("error", 0, total_messages, media_error)
+ return
+
+ _attach_neighbor_context(messages)
+
+ media_count = sum(1 for m in messages if _media_kind(m) and _media_parse_key(m))
+ chunks_count = len(_chunk_messages(messages))
+ total_units = total_messages + media_count + chunks_count + 2
+ await _update_task("running", total_messages, total_units)
+
+ async with aiosqlite.connect(path) as db:
+ db.row_factory = aiosqlite.Row
+ manual_seqs = await _manual_assigned_seqs(db, group_id)
+ await _delete_ai_topics(db, group_id)
+
+ parsed_units = await _parse_message_media(messages, _update_task, total_messages, total_units)
+ classifiable = [m for m in messages if _msg_seq(m) and _msg_seq(m) not in manual_seqs]
+ seq_to_msg = {_msg_seq(m): m for m in classifiable if _msg_seq(m)}
+ allowed_seqs = set(seq_to_msg)
+ if not classifiable:
+ await _update_task("done", total_units, total_units)
+ return
+
+ configured_prompt = (group.get("analysis_prompt") or _s.get("topic_analysis_prompt") or "").strip()
+ sample_query = "\n".join(_message_analysis_text(m) for m in classifiable[:80])
+ learning_context = await build_report_learning_context(
+ db,
+ group_id=group_id,
+ query=sample_query,
+ purpose="topic",
+ )
+ guidance_parts = []
+ if configured_prompt:
+ guidance_parts.append(f"客户自定义话题分析提示词:\n{configured_prompt}")
+ if learning_context:
+ guidance_parts.append(
+ "报告库学习参考:以下是人工修订过的历史报告,请学习其话题命名、分类颗粒度和关注点;不要复制历史事实。\n"
+ f"{learning_context}"
+ )
+ analysis_guidance = "\n\n".join(guidance_parts)
+
+ base = total_messages + parsed_units
+ candidates_raw = await _classify_batches(
+ classifiable,
+ _update_task,
+ base,
+ total_units,
+ analysis_guidance,
+ )
+ candidates = _sanitize_topic_items(candidates_raw, seq_to_msg, allowed_seqs)
+ processed_after_batches = base + chunks_count
+ merged = await _merge_candidates(candidates, _update_task, processed_after_batches, total_units)
+ merged = _finalize_topics(merged, seq_to_msg, allowed_seqs)
+ supplemented = await _supplement_assignments(
+ merged,
+ classifiable,
+ seq_to_msg,
+ allowed_seqs,
+ _update_task,
+ processed_after_batches + 1,
+ total_units,
+ )
+ final_topics = _finalize_topics(supplemented, seq_to_msg, allowed_seqs)
+ final_topics = _coalesce_device_issue_topics(final_topics, seq_to_msg)
+ final_topics = _finalize_topics(final_topics, seq_to_msg, allowed_seqs)
+ await _save_topics(db, group_id, group["talker"], final_topics, seq_to_msg)
+
+ await _update_task("done", total_units, total_units)
+ log.info(f"[classify] group={group_id} done, messages={total_messages}, topics={len(final_topics)}")
+
+ except Exception as e:
+ log.error(f"[classify] group={group_id} error: {e}", exc_info=True)
+ await _update_task("error", 0, 0, str(e))
+ finally:
+ _classifying_group = None
diff --git a/electron-launcher/electron-builder.config.cjs b/electron-launcher/electron-builder.config.cjs
new file mode 100644
index 0000000..885e593
--- /dev/null
+++ b/electron-launcher/electron-builder.config.cjs
@@ -0,0 +1,101 @@
+const path = require('path');
+
+function envText(name) {
+ const value = process.env[name];
+ if (value == null) return '';
+ return String(value).trim();
+}
+
+function envFlag(name) {
+ return /^(1|true|yes|y|on)$/i.test(envText(name));
+}
+
+const pfxFile = envText('CHATLAB_PFX_FILE');
+const pfxPassword = envText('CHATLAB_PFX_PASSWORD');
+const publisherName = envText('CHATLAB_CERT_PUBLISHER_NAME');
+const forceSigning = envFlag('CHATLAB_FORCE_SIGN');
+const timestampServer = envText('CHATLAB_TIMESTAMP_SERVER') || 'http://timestamp.digicert.com';
+const shouldSign = Boolean(pfxFile);
+const buildLabel = envText('CHATLAB_BUILD_LABEL')
+ || new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
+
+if (forceSigning && !shouldSign) {
+ throw new Error('CHATLAB_FORCE_SIGN=1 requires CHATLAB_PFX_FILE to point to a .pfx/.p12 code signing certificate.');
+}
+
+if (pfxPassword) {
+ process.env.WIN_CSC_KEY_PASSWORD = pfxPassword;
+ process.env.CSC_KEY_PASSWORD = pfxPassword;
+}
+
+const win = {
+ target: 'nsis',
+ icon: 'build/icon.ico',
+ artifactName: `ChatLab-Setup-\${version}-${buildLabel}.\${ext}`,
+ signAndEditExecutable: shouldSign,
+ forceCodeSigning: forceSigning,
+};
+
+if (publisherName) {
+ win.publisherName = publisherName;
+}
+
+if (shouldSign) {
+ win.signtoolOptions = {
+ certificateFile: pfxFile,
+ signingHashAlgorithms: ['sha256'],
+ rfc3161TimeStampServer: timestampServer,
+ };
+
+ if (publisherName) {
+ win.signtoolOptions.publisherName = publisherName;
+ }
+}
+
+module.exports = {
+ appId: 'com.chatlab.desktop',
+ productName: 'ChatLab售后智能助手',
+ icon: 'build/icon.ico',
+ directories: {
+ output: 'dist',
+ },
+ files: [
+ 'main.js',
+ 'preload.js',
+ 'index.html',
+ 'build/company-logo.jpg',
+ 'build/icon.ico',
+ 'build/icon.png',
+ 'package.json',
+ ],
+ extraResources: [
+ {
+ from: path.join(__dirname, 'build-resources'),
+ to: '.',
+ filter: [
+ '**/*',
+ '!**/.env',
+ '!**/knowledge*.db',
+ '!**/__pycache__/**',
+ '!**/*.pfx',
+ '!**/*.p12',
+ '!**/*.pvk',
+ '!**/*.cer',
+ '!**/*.crt',
+ '!**/*.key',
+ '!**/certs/**',
+ ],
+ },
+ ],
+ win,
+ nsis: {
+ oneClick: false,
+ installerIcon: 'build/icon.ico',
+ uninstallerIcon: 'build/icon.ico',
+ allowToChangeInstallationDirectory: true,
+ perMachine: false,
+ createDesktopShortcut: true,
+ createStartMenuShortcut: true,
+ shortcutName: 'ChatLab售后智能助手',
+ },
+};
diff --git a/electron-launcher/index.html b/electron-launcher/index.html
new file mode 100644
index 0000000..bb45aec
--- /dev/null
+++ b/electron-launcher/index.html
@@ -0,0 +1,967 @@
+
+
+
+
+
+
微信知识库 - 服务控制台
+
+
+
+
+
请先停止所有运行中的服务再退出。
+
+
+

+
灵泽万川 ChatLab
+
+
+
+ 智能售后知识库服务控制台
+ Next-Generation Intelligent Service Knowledge Platform
+
+
+
+
+
+
+
+
+
+
+
+
+
检测到新账号,正在解密数据
+
+ 系统正在为此微信账号首次解密数据库和图片,同时已开启自动解密。
+
+ - 请切换到微信窗口
+ - 随意点击任意一个聊天对话
+ - 翻看一下历史消息记录
+ - 等待本窗口自动关闭即可
+
+
这与您在 chatlog.exe 界面中手动解密的操作完全相同,只需配合操作微信一次,后续将全程自动化。
+
+
+
+
+
+
+
+
+
diff --git a/electron-launcher/main.js b/electron-launcher/main.js
new file mode 100644
index 0000000..7c2c4af
--- /dev/null
+++ b/electron-launcher/main.js
@@ -0,0 +1,1336 @@
+const { app, BrowserWindow, ipcMain } = require('electron');
+const fs = require('fs');
+const http = require('http');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+let mainWindow;
+let isQuitting = false;
+let isInViewMode = false;
+let decryptPollTimer = null;
+let backendPort = null;
+let backendUrl = null;
+let lastAccountFingerprint = '';
+let activeChatlogConfig = null;
+let malformedRepairTimer = null;
+let malformedRepairInProgress = false;
+let messageQueryRestartInProgress = false;
+let startupValidationInProgress = false;
+const pendingMalformedDbPaths = new Set();
+const malformedRepairAttempts = new Set();
+const malformedRepairNotified = new Set();
+const messageQueryRestartAttempts = new Set();
+
+const processes = {
+ chatlog: null,
+ fastapi: null,
+};
+
+function isPackaged() {
+ return app.isPackaged;
+}
+
+function projectRoot() {
+ return isPackaged() ? process.resourcesPath : path.resolve(__dirname, '..');
+}
+
+function resourcePath(...parts) {
+ return path.join(projectRoot(), ...parts);
+}
+
+function chatlogExePath() {
+ return resourcePath('chatlog.exe');
+}
+
+function backendExePath() {
+ return resourcePath('backend', 'ChatLabBackend.exe');
+}
+
+function backendSourceDir() {
+ return resourcePath('chatlog_fastAPI');
+}
+
+function frontendDistDir() {
+ return isPackaged()
+ ? resourcePath('frontend')
+ : resourcePath('chatlab-web', 'frontend', 'dist');
+}
+
+function appIconPath() {
+ const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
+ const candidate = path.join(__dirname, 'build', iconName);
+ return fs.existsSync(candidate) ? candidate : undefined;
+}
+
+function appDataDir() {
+ const dir = path.join(app.getPath('appData'), 'ChatLab');
+ fs.mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function send(channel, payload) {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(channel, payload);
+ }
+}
+
+function log(source, text) {
+ send('log', { source, text: sanitizeLog(String(text || '')) });
+}
+
+function stripAnsi(text) {
+ return String(text || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
+}
+
+function sanitizeLog(text) {
+ return stripAnsi(text)
+ .replace(/("(?:data_key|dataKey|img_key|imgKey)"\s*:\s*")([^"]+)(")/gi, '$1********$3')
+ .replace(/("(?:APIKey|apiKey|api_key)"\s*:\s*")([^"]+)(")/g, '$1sk-****$3')
+ .replace(/\b(data_key|dataKey|DataKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
+ .replace(/\b(img_key|imgKey|ImgKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
+ .replace(/Data\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Data Key: [$1****$2]')
+ .replace(/Image\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Image Key: [$1****$2]')
+ .replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-****');
+}
+
+function normalizePathForCompare(value) {
+ return String(value || '')
+ .trim()
+ .replace(/[\\/]+$/, '')
+ .replace(/\//g, '\\')
+ .toLowerCase();
+}
+
+function parseDetectedDataDir(text) {
+ const matches = [...stripAnsi(text).matchAll(/DataDir\s*[::]\s*([A-Za-z]:\\[^\r\n]+)/gi)];
+ if (matches.length === 0) return '';
+ return matches[matches.length - 1][1].trim();
+}
+
+function extractKeyFromOutput(text, label) {
+ const regex = new RegExp(`${label}\\s*Key\\s*:\\s*\\[?([a-fA-F0-9]{64})\\]?`, 'gi');
+ const matches = [...stripAnsi(text).matchAll(regex)];
+ if (matches.length === 0) return '';
+ return matches[matches.length - 1][1];
+}
+
+function hasRunningServices() {
+ return Object.values(processes).some(Boolean);
+}
+
+function createWindow() {
+ const icon = appIconPath();
+ mainWindow = new BrowserWindow({
+ width: 1360,
+ height: 860,
+ minWidth: 1180,
+ minHeight: 760,
+ ...(icon ? { icon } : {}),
+ title: 'ChatLab 售后智能助手',
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ });
+
+ mainWindow.loadFile('index.html');
+ isInViewMode = false;
+ mainWindow.setMenuBarVisibility(false);
+
+ mainWindow.on('close', async (e) => {
+ if (isQuitting) return;
+ e.preventDefault();
+
+ if (isInViewMode) {
+ isInViewMode = false;
+ try {
+ if (mainWindow.isMaximized()) mainWindow.unmaximize();
+ mainWindow.setSize(1360, 860);
+ mainWindow.center();
+ await mainWindow.loadFile('index.html');
+ } catch (err) {
+ log('App(Error)', `Failed to return to launcher: ${err.message}`);
+ }
+ return;
+ }
+
+ if (hasRunningServices()) {
+ send('show-close-warning');
+ return;
+ }
+
+ isQuitting = true;
+ app.quit();
+ });
+}
+
+function waitForClose(proc, timeoutMs = 5000) {
+ return new Promise((resolve) => {
+ if (!proc) return resolve();
+ let done = false;
+ const timer = setTimeout(() => {
+ if (!done) resolve();
+ }, timeoutMs);
+ proc.once('close', () => {
+ done = true;
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+}
+
+function killProcessTree(proc) {
+ if (!proc) return;
+ try {
+ if (process.platform === 'win32') {
+ spawn('taskkill', ['/pid', String(proc.pid), '/f', '/t'], { windowsHide: true });
+ } else {
+ proc.kill('SIGTERM');
+ }
+ } catch (e) {
+ console.error('Failed to kill process tree:', e);
+ }
+}
+
+async function stopProcess(key) {
+ const proc = processes[key];
+ if (!proc) return;
+ killProcessTree(proc);
+ await waitForClose(proc);
+ processes[key] = null;
+ send('process-stopped', key);
+}
+
+async function cleanupProcesses() {
+ stopDecryptPolling();
+ await Promise.all(Object.keys(processes).map((key) => stopProcess(key)));
+}
+
+function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address();
+ const port = address.port;
+ server.close(() => resolve(port));
+ });
+ server.on('error', reject);
+ });
+}
+
+function httpJson(url, timeoutMs = 5000, method = 'GET') {
+ return new Promise((resolve, reject) => {
+ const req = http.request(url, { method, timeout: timeoutMs }, (res) => {
+ let body = '';
+ res.on('data', (chunk) => { body += chunk; });
+ res.on('end', () => {
+ try {
+ resolve({ statusCode: res.statusCode, data: body ? JSON.parse(body) : {} });
+ } catch {
+ resolve({ statusCode: res.statusCode, data: body });
+ }
+ });
+ });
+ req.on('error', reject);
+ req.on('timeout', () => {
+ req.destroy(new Error('request timeout'));
+ });
+ req.end();
+ });
+}
+
+function postJson(url, payload = {}, timeoutMs = 5000) {
+ return new Promise((resolve, reject) => {
+ const body = JSON.stringify(payload);
+ const req = http.request(url, {
+ method: 'POST',
+ timeout: timeoutMs,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ },
+ }, (res) => {
+ let responseBody = '';
+ res.on('data', (chunk) => { responseBody += chunk; });
+ res.on('end', () => {
+ try {
+ resolve({ statusCode: res.statusCode, data: responseBody ? JSON.parse(responseBody) : {} });
+ } catch {
+ resolve({ statusCode: res.statusCode, data: responseBody });
+ }
+ });
+ });
+ req.on('error', reject);
+ req.on('timeout', () => {
+ req.destroy(new Error('request timeout'));
+ });
+ req.write(body);
+ req.end();
+ });
+}
+
+function buildUrl(base, params = {}) {
+ const url = new URL(base);
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null && value !== '') {
+ url.searchParams.set(key, String(value));
+ }
+ }
+ return url.toString();
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function waitForHealth(timeoutMs = 30000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ try {
+ const res = await httpJson(`${backendUrl}/health`, 3000);
+ if (res.statusCode === 200 && res.data && res.data.ok) {
+ return res.data;
+ }
+ } catch {}
+ await new Promise((r) => setTimeout(r, 800));
+ }
+ throw new Error('后端服务启动超时');
+}
+
+async function waitForChatlogReady(timeoutMs = 120000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ let ready = false;
+ try {
+ const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
+ const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
+ ready = res.statusCode === 200 && !body.includes('decrypting');
+ } catch {}
+ if (ready) {
+ await sleep(1200);
+ const validation = await validateChatlogMessageQuery({ repair: true });
+ if (
+ validation.pending ||
+ malformedRepairInProgress ||
+ malformedRepairTimer ||
+ messageQueryRestartInProgress
+ ) {
+ await sleep(800);
+ continue;
+ }
+ if (!validation.ok) {
+ throw new Error(validation.message || '消息索引未就绪,请回到启动界面点击“重新识别当前微信账号”。');
+ }
+ send('decrypt-ready');
+ try {
+ if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
+ } catch {}
+ return validation;
+ }
+ const elapsed = Math.floor((Date.now() - started) / 1000);
+ send('decrypt-status', { elapsed });
+ await sleep(1500);
+ }
+ throw new Error('等待微信数据解密超时,请确认 PC 微信已登录,并在微信里点击聊天窗口翻看历史消息。');
+}
+
+function responseBodyText(data) {
+ return typeof data === 'string' ? data : JSON.stringify(data || {});
+}
+
+function isTimeRangeNotFoundResponse(response) {
+ return response
+ && response.statusCode === 404
+ && /time range not found/i.test(responseBodyText(response.data));
+}
+
+function extractItems(data) {
+ if (Array.isArray(data)) return data;
+ if (data && Array.isArray(data.items)) return data.items;
+ if (data && Array.isArray(data.data)) return data.data;
+ return [];
+}
+
+function getSessionTalker(item) {
+ return String(item.userName || item.UserName || item.name || item.Name || '');
+}
+
+function parseSessionTime(item) {
+ const value = item.nTime
+ || item.NTime
+ || item.time
+ || item.Time
+ || item.updateTime
+ || item.UpdateTime
+ || item.lastTime
+ || item.LastTime
+ || '';
+ if (!value) return 0;
+ if (typeof value === 'number' || /^\d+$/.test(String(value))) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) return 0;
+ return n > 1e12 ? n : n * 1000;
+ }
+ const parsed = Date.parse(String(value).replace(' ', 'T'));
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function formatDateParam(date) {
+ const yyyy = date.getFullYear();
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
+ const dd = String(date.getDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function buildValidationTimeRange(session) {
+ const ts = parseSessionTime(session || {});
+ if (!ts) return '1970-01-01,2099-12-31';
+ const start = new Date(ts);
+ start.setHours(0, 0, 0, 0);
+ start.setDate(start.getDate() - 1);
+ const end = new Date(ts);
+ end.setHours(0, 0, 0, 0);
+ end.setDate(end.getDate() + 1);
+ return `${formatDateParam(start)},${formatDateParam(end)}`;
+}
+
+function buildRecentValidationTimeRange(days) {
+ const start = new Date();
+ start.setHours(0, 0, 0, 0);
+ start.setDate(start.getDate() - days);
+ const end = new Date();
+ end.setHours(0, 0, 0, 0);
+ end.setDate(end.getDate() + 1);
+ return `${formatDateParam(start)},${formatDateParam(end)}`;
+}
+
+async function waitForChatlogHttpReady(timeoutMs = 120000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ try {
+ const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
+ const body = responseBodyText(res.data);
+ if (res.statusCode === 200 && !body.includes('decrypting')) return true;
+ } catch {}
+ const elapsed = Math.floor((Date.now() - started) / 1000);
+ send('decrypt-status', { elapsed });
+ await sleep(1500);
+ }
+ throw new Error('等待底层服务重建消息索引超时,请确认 PC 微信已登录,并在微信里打开聊天窗口翻看历史消息。');
+}
+
+async function checkChatlogMessageQuery() {
+ await sleep(1500);
+ const sessions = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/session', {
+ limit: 50,
+ format: 'json',
+ }), 5000);
+ if (sessions.statusCode !== 200) {
+ return {
+ ok: false,
+ pending: true,
+ message: `chatlog session HTTP ${sessions.statusCode}`,
+ };
+ }
+ const chatrooms = extractItems(sessions.data)
+ .filter((item) => getSessionTalker(item).endsWith('@chatroom'))
+ .sort((a, b) => parseSessionTime(b) - parseSessionTime(a));
+
+ if (chatrooms.length === 0) {
+ return {
+ ok: false,
+ pending: true,
+ message: '尚未读取到最近群聊会话,等待 chatlog 自动解密消息库。',
+ };
+ }
+
+ const ranges = [
+ buildRecentValidationTimeRange(7),
+ buildRecentValidationTimeRange(30),
+ ];
+ let lastStatus = '';
+ for (const session of chatrooms.slice(0, 10)) {
+ const talker = getSessionTalker(session);
+ if (!talker) continue;
+
+ for (const time of ranges) {
+ const res = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/chatlog', {
+ talker,
+ limit: 20,
+ time,
+ format: 'json',
+ }), 5000);
+
+ if (res.statusCode === 200) {
+ return {
+ ok: true,
+ messageIndexReady: true,
+ talker,
+ time,
+ sampleCount: extractItems(res.data).length,
+ chatroomCount: chatrooms.length,
+ };
+ }
+
+ lastStatus = `talker=${talker} time=${time} HTTP ${res.statusCode}: ${responseBodyText(res.data)}`;
+ if (!isTimeRangeNotFoundResponse(res) && res.statusCode !== 404) {
+ return {
+ ok: false,
+ pending: true,
+ message: `消息查询暂未就绪:${lastStatus}`,
+ };
+ }
+ }
+ }
+
+ return {
+ ok: false,
+ pending: true,
+ timeRangeNotFound: true,
+ message: `最近 ${Math.min(chatrooms.length, 10)} 个群聊消息仍返回 404,等待 chatlog 自动解密完成。${lastStatus}`,
+ chatroomCount: chatrooms.length,
+ };
+}
+
+async function validateChatlogMessageQuery({ repair = true } = {}) {
+ if (startupValidationInProgress || messageQueryRestartInProgress) {
+ return { ok: false, pending: true };
+ }
+ startupValidationInProgress = true;
+ try {
+ const result = await checkChatlogMessageQuery();
+ if (result.ok || !result.timeRangeNotFound) return result;
+ log(
+ 'Chatlog(Validate)',
+ `${result.message || '消息抽样查询返回 time range not found。'} 会话接口已就绪,本次不再把它当作账号识别失败;如具体聊天记录为空,再在聊天页提示处理。`
+ );
+ return {
+ ...result,
+ ok: true,
+ pending: false,
+ messageIndexReady: false,
+ message: 'chatlog 会话接口已就绪;部分聊天记录索引暂不可用,进入系统后在聊天页按具体群聊提示处理。',
+ };
+ } catch (e) {
+ log('Chatlog(Validate)', `消息查询自检未完成: ${e.message || e}`);
+ return { ok: false, pending: true, error: e.message || String(e) };
+ } finally {
+ startupValidationInProgress = false;
+ }
+}
+
+async function repairChatlogMessageIndex(validationResult) {
+ const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
+ const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
+ const message = validationResult.message || '消息索引未就绪,chatlog 返回 time range not found。';
+ if (!config || !fingerprint) {
+ log('Chatlog(Validate)', `${message} 当前账号配置为空,无法自动重建消息缓存。`);
+ return {
+ ...validationResult,
+ ok: false,
+ message: '消息索引未就绪,且当前账号配置为空。请回到启动界面点击“重新识别当前微信账号”。',
+ };
+ }
+
+ if (messageQueryRestartAttempts.has(fingerprint)) {
+ const failedMessage = '当前账号消息索引仍未就绪,已自动重建过一次。本次不会进入系统;请在启动界面点击“重新识别当前微信账号”后重试。';
+ log('Chatlog(Validate)', failedMessage);
+ send('process-error', { key: 'chatlog', message: failedMessage });
+ return {
+ ...validationResult,
+ ok: false,
+ message: failedMessage,
+ removedMessageCache: 0,
+ };
+ }
+
+ messageQueryRestartAttempts.add(fingerprint);
+ messageQueryRestartInProgress = true;
+ let removed = [];
+ try {
+ log('Chatlog(Validate)', message);
+ log('Chatlog(Validate)', '正在清理当前账号生成的消息缓存并重启底层服务;不会删除微信原始数据。');
+ await stopProcess('chatlog');
+ removed = removeGeneratedMessageCache(config.workDir);
+ if (removed.length > 0) {
+ log('Chatlog(Repair)', `已清理 ${removed.length} 个当前账号消息缓存目录/文件,接下来会自动重建。`);
+ } else {
+ log('Chatlog(Repair)', '未找到可清理的当前账号消息缓存,继续重新识别并等待 chatlog 重建索引。');
+ }
+ send('decrypt-reset');
+ send('show-decrypt-dialog');
+ await startChatlog({ forceRefresh: false, startPolling: false });
+ await waitForChatlogHttpReady(120000);
+ const retry = await checkChatlogMessageQuery();
+ if (retry.ok) {
+ messageQueryRestartAttempts.delete(fingerprint);
+ return {
+ ...retry,
+ repaired: true,
+ removedMessageCache: removed.length,
+ };
+ }
+ const failedMessage = '消息索引重建后仍返回 time range not found。请在启动界面点击“重新识别当前微信账号”,并确认微信里已打开聊天窗口、能翻看到历史消息。';
+ log('Chatlog(Validate)', failedMessage);
+ send('process-error', { key: 'chatlog', message: failedMessage });
+ return {
+ ...retry,
+ ok: false,
+ message: failedMessage,
+ removedMessageCache: removed.length,
+ };
+ } catch (e) {
+ const failedMessage = `重建底层消息索引失败: ${e.message || e}`;
+ log('Chatlog(Validate)', failedMessage);
+ send('process-error', { key: 'chatlog', message: failedMessage });
+ return {
+ ...validationResult,
+ ok: false,
+ message: failedMessage,
+ removedMessageCache: removed.length,
+ };
+ } finally {
+ messageQueryRestartInProgress = false;
+ }
+}
+
+async function startBackend() {
+ if (processes.fastapi) return;
+ backendPort = backendPort || await getFreePort();
+ backendUrl = `http://127.0.0.1:${backendPort}`;
+
+ const env = {
+ ...process.env,
+ CHATLAB_DATA_DIR: appDataDir(),
+ CHATLAB_STATIC_DIR: frontendDistDir(),
+ CHATLAB_BACKEND_PORT: String(backendPort),
+ };
+
+ let command;
+ let args;
+ let cwd;
+ let shell = false;
+
+ if (isPackaged()) {
+ command = backendExePath();
+ args = [];
+ cwd = path.dirname(command);
+ } else {
+ command = 'python';
+ args = ['run_backend.py'];
+ cwd = backendSourceDir();
+ shell = true;
+ }
+
+ log('FastAPI', `启动业务后端: ${isPackaged() ? command : `${command} ${args.join(' ')}`} (port=${backendPort})`);
+ const proc = spawn(command, args, { cwd, env, shell, windowsHide: true });
+ processes.fastapi = proc;
+
+ proc.stdout.on('data', (data) => log('FastAPI', data.toString()));
+ proc.stderr.on('data', (data) => log('FastAPI', data.toString()));
+ proc.on('close', (code) => {
+ processes.fastapi = null;
+ send('process-stopped', 'fastapi');
+ log('FastAPI', `进程退出,退出码 ${code}`);
+ });
+
+ send('process-started', 'fastapi');
+ await waitForHealth(45000);
+}
+
+function getWeChatProcesses() {
+ return new Promise((resolve) => {
+ if (process.platform !== 'win32') return resolve([]);
+ const ps = spawn('powershell.exe', [
+ '-NoProfile',
+ '-Command',
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(Weixin|WeChat)\\.exe$' } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
+ ], { windowsHide: true });
+ let output = '';
+ let errorOutput = '';
+ ps.stdout.on('data', (d) => { output += d.toString(); });
+ ps.stderr.on('data', (d) => { errorOutput += d.toString(); });
+ ps.on('close', () => {
+ try {
+ const parsed = JSON.parse(output || '[]');
+ const list = (Array.isArray(parsed) ? parsed : [parsed])
+ .map((item) => ({
+ pid: Number(item.ProcessId),
+ name: String(item.Name || ''),
+ commandLine: String(item.CommandLine || ''),
+ }))
+ .filter((item) => item.pid);
+ resolve(list);
+ } catch {
+ const ids = output.split(/\s+/).map((x) => Number(x)).filter(Boolean);
+ resolve(ids.map((pid) => ({ pid, name: '', commandLine: '' })));
+ }
+ });
+ ps.on('error', () => {
+ if (errorOutput) log('Chatlog(Init)', `读取微信进程失败: ${errorOutput.trim()}`);
+ resolve([]);
+ });
+ });
+}
+
+function selectMainWeChatProcess(processes) {
+ const candidates = processes
+ .filter((item) => /^(Weixin|WeChat)\.exe$/i.test(item.name))
+ .filter((item) => !/\s--type=/i.test(item.commandLine));
+ return candidates.find((item) => /\s--scene=desktop\b/i.test(item.commandLine))
+ || candidates.find((item) => /Weixin\.exe/i.test(item.name))
+ || candidates[0]
+ || null;
+}
+
+function runChatlogKey(args, label) {
+ return new Promise((resolve, reject) => {
+ const exePath = chatlogExePath();
+ const keyProcess = spawn(exePath, args, {
+ cwd: projectRoot(),
+ windowsHide: true,
+ });
+ let extractedDataKey = '';
+ let extractedImgKey = '';
+ let detectedDataDir = '';
+ let rawOutput = '';
+
+ const handleOutput = (data) => {
+ const output = data.toString();
+ rawOutput += output;
+ log('Chatlog(Init)', output);
+ extractedDataKey = extractKeyFromOutput(rawOutput, 'Data') || extractedDataKey;
+ extractedImgKey = extractKeyFromOutput(rawOutput, 'Image') || extractedImgKey;
+ detectedDataDir = parseDetectedDataDir(rawOutput) || detectedDataDir;
+ };
+
+ keyProcess.stdout.on('data', handleOutput);
+ keyProcess.stderr.on('data', handleOutput);
+ keyProcess.on('error', reject);
+ keyProcess.on('close', (code) => {
+ const partial = !extractedDataKey && /Data\s*Key\s*:\s*\[\s*\]/i.test(stripAnsi(rawOutput));
+ if (code !== 0 && !extractedDataKey && !extractedImgKey && !detectedDataDir) {
+ reject(new Error(`${label} 未能识别当前微信账号,请确认微信已登录,必要时用管理员权限启动。退出码 ${code}。${sanitizeLog(rawOutput).slice(-500)}`));
+ return;
+ }
+ resolve({
+ dataKey: extractedDataKey,
+ imgKey: extractedImgKey,
+ detectedDataDir,
+ rawOutput,
+ partial,
+ exitCode: code,
+ label,
+ });
+ });
+ });
+}
+
+function runChatlogDecrypt(config, dataKey) {
+ return new Promise((resolve) => {
+ if (!config || !dataKey || !config.workDir || !config.dataDir) {
+ resolve(false);
+ return;
+ }
+ const args = ['decrypt', '-k', dataKey];
+ args.push('-w', config.workDir);
+ args.push('-d', config.dataDir);
+ args.push('-p', config.platform || 'windows');
+ args.push('-v', String(config.version || 4));
+
+ log('Chatlog(Init)', '正在同步解密消息与语音媒体库...');
+ const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
+ let output = '';
+ const handleOutput = (data) => {
+ output += data.toString();
+ };
+ proc.stdout.on('data', handleOutput);
+ proc.stderr.on('data', handleOutput);
+ proc.on('error', (e) => {
+ log('Chatlog(Init)', `同步解密启动失败: ${e.message || e}`);
+ resolve(false);
+ });
+ proc.on('close', (code) => {
+ const text = sanitizeLog(output).trim();
+ if (code === 0) {
+ log('Chatlog(Init)', text || '同步解密完成');
+ resolve(true);
+ } else {
+ log('Chatlog(Init)', `同步解密失败,后续继续启动自动解密服务。退出码 ${code}。${text.slice(-500)}`);
+ resolve(false);
+ }
+ });
+ });
+}
+
+async function runChatlogKeyForce(mainWeChatProcess = null) {
+ const attempts = [];
+ if (mainWeChatProcess?.pid) {
+ attempts.push({
+ label: `key --force --pid ${mainWeChatProcess.pid}`,
+ args: ['key', '--force', '--pid', String(mainWeChatProcess.pid)],
+ });
+ }
+ attempts.push({ label: 'key --force', args: ['key', '--force'] });
+
+ let best = null;
+ let lastError = null;
+ for (const attempt of attempts) {
+ log('Chatlog(Init)', `执行 ${attempt.label}`);
+ try {
+ const result = await runChatlogKey(attempt.args, attempt.label);
+ best = mergeKeyResults(best, result);
+ if (best.dataKey) return best;
+ if (attempts.length > 1) {
+ log('Chatlog(Init)', `${attempt.label} 未输出 Data Key,继续尝试备用识别方式。`);
+ }
+ } catch (e) {
+ lastError = e;
+ log('Chatlog(Init)', `${attempt.label} 失败: ${e.message}`);
+ }
+ }
+
+ if (best) return best;
+ throw lastError || new Error('未能识别当前微信账号。');
+}
+
+function getChatlogVersionText() {
+ return new Promise((resolve) => {
+ const proc = spawn(chatlogExePath(), ['version'], { cwd: projectRoot(), windowsHide: true });
+ let output = '';
+ proc.stdout.on('data', (data) => { output += data.toString(); });
+ proc.stderr.on('data', (data) => { output += data.toString(); });
+ proc.on('error', () => resolve(''));
+ proc.on('close', () => resolve(sanitizeLog(output).trim()));
+ });
+}
+
+async function syncChatlogContextToBackend(config) {
+ if (!backendUrl || !config) return;
+ const payload = {
+ account: config.account || '',
+ workDir: config.workDir || '',
+ dataDir: config.dataDir || '',
+ platform: config.platform || 'windows',
+ version: config.version || 4,
+ chatlogExe: chatlogExePath(),
+ chatlogVersion: await getChatlogVersionText(),
+ };
+ try {
+ await postJson(`${backendUrl}/api/system/chatlog-context`, payload, 5000);
+ } catch (e) {
+ log('Chatlog(Init)', `同步 chatlog 诊断上下文失败: ${e.message || e}`);
+ }
+}
+
+function mergeKeyResults(prev, next) {
+ if (!prev) return next;
+ return {
+ ...next,
+ dataKey: next.dataKey || prev.dataKey || '',
+ imgKey: next.imgKey || prev.imgKey || '',
+ detectedDataDir: next.detectedDataDir || prev.detectedDataDir || '',
+ rawOutput: `${prev.rawOutput || ''}\n${next.rawOutput || ''}`,
+ partial: Boolean(next.partial || prev.partial),
+ };
+}
+
+function singleMatchOrThrow(matches, matchedBy) {
+ if (matches.length <= 1) return matches[0] || null;
+ throw new Error(`chatlog 配置中有多个账号匹配同一个 ${matchedBy},为避免打开错误账号,请点击“重新识别当前微信账号”后重试。`);
+}
+
+function readCurrentChatlogConfig(hints = {}) {
+ const configPath = path.join(os.homedir(), '.chatlog', 'chatlog.json');
+ if (!fs.existsSync(configPath)) {
+ throw new Error('未找到 chatlog 配置文件,请确认当前微信账号已完成密钥识别');
+ }
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
+ const history = Array.isArray(config.history) ? config.history : [];
+ const lastAccount = config.last_account || '';
+ const detectedDataDir = normalizePathForCompare(hints.detectedDataDir);
+ const imgKey = String(hints.imgKey || '').trim().toLowerCase();
+ const dataKey = String(hints.dataKey || '').trim().toLowerCase();
+ const hasOnlyPartialKey = !dataKey && Boolean(detectedDataDir || imgKey || hints.partial);
+
+ let matched = null;
+ let matchedBy = '';
+
+ if (detectedDataDir) {
+ matched = history.find((item) => normalizePathForCompare(item.data_dir) === detectedDataDir) || null;
+ if (matched) matchedBy = 'data_dir';
+ }
+
+ if (!matched && imgKey) {
+ const matches = history.filter((item) => String(item.img_key || '').trim().toLowerCase() === imgKey);
+ matched = singleMatchOrThrow(matches, 'img_key');
+ if (matched) matchedBy = 'img_key';
+ }
+
+ if (!matched && dataKey) {
+ const matches = history.filter((item) => String(item.data_key || '').trim().toLowerCase() === dataKey);
+ matched = singleMatchOrThrow(matches, 'data_key');
+ if (matched) matchedBy = 'data_key';
+ }
+
+ if (!matched) {
+ if (detectedDataDir || hasOnlyPartialKey) {
+ throw new Error('本次微信识别结果未能匹配 chatlog 配置中的账号。为避免启动旧账号数据,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
+ }
+ matched = history.find((item) => item.account === lastAccount) || history[history.length - 1];
+ if (matched) matchedBy = matched.account === lastAccount ? 'last_account' : 'history_tail';
+ }
+
+ if (!matched) {
+ throw new Error('chatlog 配置中没有账号历史记录');
+ }
+
+ if (detectedDataDir && ['last_account', 'history_tail'].includes(matchedBy) && normalizePathForCompare(matched.data_dir) !== detectedDataDir) {
+ throw new Error('本次微信识别到的数据目录与 chatlog 配置中的账号不一致。为避免显示旧账号记录,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
+ }
+
+ return {
+ account: matched.account || lastAccount || '',
+ workDir: matched.work_dir || '',
+ dataDir: matched.data_dir || '',
+ platform: matched.platform || 'windows',
+ version: matched.version || 4,
+ imgKey: matched.img_key || '',
+ dataKey: matched.data_key || '',
+ matchedBy,
+ lastAccount,
+ };
+}
+
+function hasDecryptedDb(workDir) {
+ try {
+ if (!workDir || !fs.existsSync(workDir)) return false;
+ const root = path.resolve(workDir);
+ const stack = [{ dir: root, depth: 0 }];
+ let visited = 0;
+ while (stack.length > 0 && visited < 2000) {
+ const current = stack.pop();
+ visited += 1;
+ for (const entry of fs.readdirSync(current.dir, { withFileTypes: true })) {
+ const fullPath = path.join(current.dir, entry.name);
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.db')) {
+ return true;
+ }
+ if (entry.isDirectory() && current.depth < 5) {
+ stack.push({ dir: fullPath, depth: current.depth + 1 });
+ }
+ }
+ }
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+function pathStartsWith(childPath, parentPath) {
+ const child = path.resolve(childPath).toLowerCase();
+ const parent = path.resolve(parentPath).toLowerCase();
+ return child === parent || child.startsWith(`${parent}${path.sep}`);
+}
+
+function collectMalformedDbPaths(text) {
+ const clean = stripAnsi(text);
+ if (!/database disk image is malformed/i.test(clean)) return [];
+ return [...clean.matchAll(/([A-Za-z]:\\[^\r\n"]+?\.db)(?=[\s"']|$|[^\r\n]*database disk image is malformed)/gi)]
+ .map((match) => match[1].trim())
+ .filter(Boolean);
+}
+
+function removeGeneratedDbFiles(dbPaths, workDir) {
+ const removed = [];
+ const generatedDirs = new Set();
+ const candidates = new Set();
+ const normalizedWorkDir = normalizePathForCompare(workDir);
+ const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
+
+ for (const dbPath of dbPaths) {
+ if (!dbPath || !workDir || !pathStartsWith(dbPath, workDir)) continue;
+ const normalizedDbPath = normalizePathForCompare(dbPath);
+ if (rawWeChatMarkers.some((marker) => normalizedDbPath.includes(marker))) continue;
+ if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) continue;
+ const parentDir = path.dirname(dbPath);
+ if (normalizePathForCompare(parentDir).endsWith('\\db_storage\\message')) {
+ generatedDirs.add(parentDir);
+ }
+ candidates.add(dbPath);
+ candidates.add(`${dbPath}-wal`);
+ candidates.add(`${dbPath}-shm`);
+ }
+
+ for (const dirPath of generatedDirs) {
+ try {
+ if (!pathStartsWith(dirPath, workDir)) continue;
+ if (fs.existsSync(dirPath)) {
+ fs.rmSync(dirPath, { recursive: true, force: true });
+ removed.push(dirPath);
+ }
+ } catch (e) {
+ log('Chatlog(Repair)', `清理损坏解密缓存目录失败: ${dirPath} (${e.message})`);
+ }
+ }
+
+ for (const filePath of candidates) {
+ try {
+ if (!pathStartsWith(filePath, workDir)) continue;
+ if (fs.existsSync(filePath)) {
+ fs.rmSync(filePath, { force: true });
+ removed.push(filePath);
+ }
+ } catch (e) {
+ log('Chatlog(Repair)', `清理损坏解密缓存失败: ${filePath} (${e.message})`);
+ }
+ }
+
+ return removed;
+}
+
+function removeGeneratedMessageCache(workDir) {
+ if (!workDir) return [];
+ const normalizedWorkDir = normalizePathForCompare(workDir);
+ const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
+ if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) return [];
+
+ const messageDir = path.join(workDir, 'db_storage', 'message');
+ try {
+ if (!pathStartsWith(messageDir, workDir) || !fs.existsSync(messageDir)) return [];
+ fs.rmSync(messageDir, { recursive: true, force: true });
+ return [messageDir];
+ } catch (e) {
+ log('Chatlog(Repair)', `清理解密消息缓存目录失败: ${messageDir} (${e.message})`);
+ return [];
+ }
+}
+
+function scheduleMalformedCacheRepair(text) {
+ const paths = collectMalformedDbPaths(text);
+ if (paths.length === 0) return;
+ for (const dbPath of paths) pendingMalformedDbPaths.add(dbPath);
+ if (malformedRepairTimer || malformedRepairInProgress) return;
+ malformedRepairTimer = setTimeout(() => {
+ malformedRepairTimer = null;
+ repairMalformedCacheAndRestart().catch((e) => {
+ malformedRepairInProgress = false;
+ log('Chatlog(Repair)', e.message || String(e));
+ });
+ }, 800);
+}
+
+async function repairMalformedCacheAndRestart() {
+ if (malformedRepairInProgress) return;
+ const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
+ const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
+ const dbPaths = [...pendingMalformedDbPaths];
+ pendingMalformedDbPaths.clear();
+
+ if (!config || !config.workDir || dbPaths.length === 0) return;
+
+ if (malformedRepairAttempts.has(fingerprint)) {
+ if (!malformedRepairNotified.has(fingerprint)) {
+ malformedRepairNotified.add(fingerprint);
+ log('Chatlog(Repair)', '当前账号解密缓存再次损坏,已自动修复过一次。请在微信打开聊天窗口并翻看历史消息后,点击“重新识别当前微信账号”;必要时用管理员权限启动。');
+ send('show-decrypt-dialog');
+ }
+ return;
+ }
+
+ malformedRepairInProgress = true;
+ malformedRepairAttempts.add(fingerprint);
+
+ try {
+ log('Chatlog(Repair)', '检测到当前账号的解密缓存库损坏,正在自动清理本账号生成缓存并重新解密。');
+ await stopProcess('chatlog');
+ const removed = removeGeneratedDbFiles(dbPaths, config.workDir);
+ if (removed.length > 0) {
+ log('Chatlog(Repair)', `已清理 ${removed.length} 个损坏缓存文件;不会删除微信原始数据。`);
+ } else {
+ log('Chatlog(Repair)', '未找到可安全清理的缓存文件,已停止避免误删微信原始数据。');
+ }
+ send('decrypt-reset');
+ send('show-decrypt-dialog');
+ await startChatlog({ forceRefresh: true });
+ try {
+ await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
+ } catch {}
+ } finally {
+ malformedRepairInProgress = false;
+ }
+}
+
+function fingerprintConfig(config) {
+ return `${config.account || ''}|${config.workDir || ''}|${config.dataDir || ''}`;
+}
+
+function startDecryptPolling() {
+ if (decryptPollTimer) clearInterval(decryptPollTimer);
+ const startTime = Date.now();
+ let validationTriggered = false;
+ let tickInProgress = false;
+
+ decryptPollTimer = setInterval(async () => {
+ if (tickInProgress) return;
+ tickInProgress = true;
+ try {
+ const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
+ const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
+ if (res.statusCode === 200 && !body.includes('decrypting')) {
+ await new Promise((r) => setTimeout(r, 1200));
+ let validation = { ok: true };
+ if (!validationTriggered) {
+ validationTriggered = true;
+ validation = await validateChatlogMessageQuery({ repair: true });
+ }
+ if (validation.pending) {
+ validationTriggered = false;
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ send('decrypt-status', { elapsed });
+ return;
+ }
+ if (malformedRepairInProgress || malformedRepairTimer || messageQueryRestartInProgress) {
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ send('decrypt-status', { elapsed });
+ return;
+ }
+ if (!validation.ok) {
+ stopDecryptPolling();
+ send('process-error', {
+ key: 'chatlog',
+ message: validation.message || '消息索引未就绪,请点击“重新识别当前微信账号”后重试。',
+ });
+ return;
+ }
+ stopDecryptPolling();
+ send('decrypt-ready');
+ try {
+ if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
+ } catch {}
+ return;
+ }
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ send('decrypt-status', { elapsed });
+ } catch {
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ send('decrypt-status', { elapsed });
+ } finally {
+ tickInProgress = false;
+ }
+ }, 2000);
+}
+
+function stopDecryptPolling() {
+ if (decryptPollTimer) {
+ clearInterval(decryptPollTimer);
+ decryptPollTimer = null;
+ }
+}
+
+async function startChatlog({ forceRefresh = false, startPolling = true } = {}) {
+ if (processes.chatlog) return;
+
+ const wechatPids = await getWeChatProcesses();
+ if (wechatPids.length === 0) {
+ throw new Error('未检测到 PC 微信进程,请先登录 PC 微信后再启动。');
+ }
+
+ let keys = {};
+ if (forceRefresh) {
+ const mainWeChatProcess = selectMainWeChatProcess(wechatPids);
+ if (mainWeChatProcess) {
+ log('Chatlog(Init)', `已选择微信主进程用于 Data Key 识别: ${mainWeChatProcess.name || 'WeChat'} pid=${mainWeChatProcess.pid}`);
+ } else {
+ log('Chatlog(Init)', '未能确认微信主进程,将使用 chatlog 默认识别方式。');
+ }
+ log('Chatlog(Init)', '正在强制重新识别当前微信账号...');
+ keys = await runChatlogKeyForce(mainWeChatProcess);
+ } else {
+ log('Chatlog(Init)', '使用当前账号配置启动自动解密服务;如需切换账号,请点击“重新识别当前微信账号”。');
+ }
+
+ const config = readCurrentChatlogConfig(keys);
+ const effectiveDataKey = keys.dataKey || config.dataKey || '';
+ const effectiveImgKey = keys.imgKey || config.imgKey || '';
+ activeChatlogConfig = config;
+ await syncChatlogContextToBackend(config);
+
+ if (!effectiveDataKey) {
+ send('show-decrypt-dialog');
+ throw new Error('未能获取当前微信账号 Data Key,且 chatlog 配置中没有同账号缓存密钥。请确认微信已登录,打开任意聊天窗口并向上翻看历史消息,必要时用管理员权限启动后点击“重新识别当前微信账号”。');
+ }
+
+ await runChatlogDecrypt(config, effectiveDataKey);
+
+ if (!keys.dataKey && config.dataKey) {
+ log('Chatlog(Init)', `本次 key --force 未输出 Data Key,已使用当前账号配置缓存的 Data Key 启动自动解密服务(匹配方式: ${config.matchedBy})。后续消息库解密交给 chatlog server --auto-decrypt 持续处理。`);
+ }
+
+ if (keys.detectedDataDir && config.matchedBy === 'data_dir') {
+ log('Chatlog(Init)', '已按本次识别到的微信数据目录匹配当前账号。');
+ } else if (config.matchedBy) {
+ log('Chatlog(Init)', `已匹配当前账号配置(匹配方式: ${config.matchedBy})。`);
+ }
+
+ lastAccountFingerprint = fingerprintConfig(config);
+
+ if (!hasDecryptedDb(config.workDir)) {
+ log('Chatlog(Init)', '检测到当前账号尚未完成解密,将显示首次解密引导。');
+ send('show-decrypt-dialog');
+ } else {
+ log('Chatlog(Init)', `当前账号已识别,工作目录已就绪: ${config.account || '(unknown account)'}`);
+ }
+
+ const args = ['server', '--auto-decrypt', '-k', effectiveDataKey];
+ if (config.workDir) args.push('-w', config.workDir);
+ if (config.dataDir) args.push('-d', config.dataDir);
+ args.push('-p', config.platform || 'windows');
+ args.push('-v', String(config.version || 4));
+ if (effectiveImgKey) args.push('-i', effectiveImgKey);
+ log('Chatlog(Init)', `使用已匹配账号参数启动底层服务(匹配方式: ${config.matchedBy || 'unknown'})。`);
+
+ log('Chatlog(Init)', `启动底层服务,账号指纹: ${maskFingerprint(lastAccountFingerprint)}`);
+ const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
+ processes.chatlog = proc;
+
+ const handleServerOutput = (data) => {
+ const text = data.toString();
+ log('Chatlog(Go)', text);
+ scheduleMalformedCacheRepair(text);
+ };
+
+ proc.stdout.on('data', handleServerOutput);
+ proc.stderr.on('data', handleServerOutput);
+ proc.on('close', (code) => {
+ processes.chatlog = null;
+ stopDecryptPolling();
+ send('process-stopped', 'chatlog');
+ if (!malformedRepairInProgress) send('decrypt-reset');
+ log('Chatlog(Go)', `进程退出,退出码 ${code}`);
+ });
+
+ send('process-started', 'chatlog');
+ if (startPolling) startDecryptPolling();
+}
+
+function maskFingerprint(value) {
+ if (!value) return '';
+ return value.length <= 16 ? value : `${value.slice(0, 8)}...${value.slice(-8)}`;
+}
+
+async function openAppWindow() {
+ await startBackend();
+ if (!processes.chatlog) {
+ await startChatlog({ forceRefresh: false, startPolling: false });
+ }
+ await waitForChatlogReady();
+ await waitForHealth(30000);
+ if (!mainWindow.isMaximized()) mainWindow.maximize();
+ mainWindow.loadURL(backendUrl);
+ isInViewMode = true;
+}
+
+async function refreshCurrentAccount() {
+ const fingerprint = lastAccountFingerprint || (activeChatlogConfig ? fingerprintConfig(activeChatlogConfig) : '');
+ let removed = [];
+
+ await stopProcess('chatlog');
+
+ if (fingerprint) {
+ messageQueryRestartAttempts.delete(fingerprint);
+ malformedRepairAttempts.delete(fingerprint);
+ malformedRepairNotified.delete(fingerprint);
+ }
+ if (activeChatlogConfig?.workDir) {
+ removed = removeGeneratedMessageCache(activeChatlogConfig.workDir);
+ if (removed.length > 0) {
+ log('Chatlog(Repair)', '已清理当前账号的消息解密缓存,接下来会重新识别并自动重建消息索引。');
+ }
+ }
+ send('decrypt-reset');
+ await startChatlog({ forceRefresh: true, startPolling: false });
+ const validation = await waitForChatlogReady(120000);
+ try {
+ if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
+ } catch {}
+ return {
+ ok: true,
+ fingerprint: lastAccountFingerprint,
+ messageIndexReady: Boolean(validation?.ok),
+ removedMessageCache: removed.length + (validation?.removedMessageCache || 0),
+ };
+}
+
+app.whenReady().then(() => {
+ createWindow();
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+ });
+});
+
+app.on('before-quit', () => {
+ cleanupProcesses();
+});
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') app.quit();
+});
+
+ipcMain.handle('start-all', async () => {
+ await openAppWindow();
+ return { ok: true, backendUrl };
+});
+
+ipcMain.on('start-chatlog', async (event) => {
+ try {
+ await startChatlog({ forceRefresh: false });
+ } catch (e) {
+ log('Chatlog(Error)', e.message);
+ event.sender.send('process-error', { key: 'chatlog', message: e.message });
+ }
+});
+
+ipcMain.on('start-fastapi', async (event) => {
+ try {
+ await startBackend();
+ } catch (e) {
+ log('FastAPI(Error)', e.message);
+ event.sender.send('process-error', { key: 'fastapi', message: e.message });
+ }
+});
+
+ipcMain.on('start-frontend', async (event) => {
+ try {
+ await openAppWindow();
+ } catch (e) {
+ log('App(Error)', e.message);
+ event.sender.send('process-error', { key: 'frontend', message: e.message });
+ }
+});
+
+ipcMain.on('stop-chatlog', () => stopProcess('chatlog'));
+ipcMain.on('stop-fastapi', () => stopProcess('fastapi'));
+ipcMain.on('stop-frontend', () => {});
+
+ipcMain.handle('refresh-current-account', async () => refreshCurrentAccount());
+
+ipcMain.handle('get-process-status', () => ({
+ chatlog: processes.chatlog !== null,
+ fastapi: processes.fastapi !== null,
+ frontend: isInViewMode,
+ backendUrl,
+ accountFingerprint: lastAccountFingerprint,
+}));
+
+ipcMain.on('open-in-app', async (event) => {
+ try {
+ await openAppWindow();
+ } catch (e) {
+ log('App(Error)', e.message);
+ event.sender.send('process-error', { key: 'app', message: e.message });
+ }
+});
diff --git a/electron-launcher/package-lock.json b/electron-launcher/package-lock.json
new file mode 100644
index 0000000..4fd9211
--- /dev/null
+++ b/electron-launcher/package-lock.json
@@ -0,0 +1,3643 @@
+{
+ "name": "electron-launcher",
+ "version": "1.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "electron-launcher",
+ "version": "1.0.1",
+ "license": "ISC",
+ "devDependencies": {
+ "electron": "^42.0.0",
+ "electron-builder": "^26.8.1"
+ }
+ },
+ "node_modules/@develar/schema-utils": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
+ "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.0",
+ "ajv-keywords": "^3.4.1"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@electron/asar": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz",
+ "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^5.0.0",
+ "glob": "^7.1.6",
+ "minimatch": "^3.0.4"
+ },
+ "bin": {
+ "asar": "bin/asar.js"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/@electron/asar/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@electron/asar/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@electron/asar/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@electron/fuses": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz",
+ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.1",
+ "fs-extra": "^9.0.1",
+ "minimist": "^1.2.5"
+ },
+ "bin": {
+ "electron-fuses": "dist/bin.js"
+ }
+ },
+ "node_modules/@electron/fuses/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/@electron/get/-/get-5.0.0.tgz",
+ "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^3.0.0",
+ "graceful-fs": "^4.2.11",
+ "progress": "^2.0.3",
+ "semver": "^7.6.3",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "optionalDependencies": {
+ "undici": "^7.24.4"
+ }
+ },
+ "node_modules/@electron/notarize": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz",
+ "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "fs-extra": "^9.0.1",
+ "promise-retry": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/notarize/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/osx-sign": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.3.tgz",
+ "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "compare-version": "^0.1.2",
+ "debug": "^4.3.4",
+ "fs-extra": "^10.0.0",
+ "isbinaryfile": "^4.0.8",
+ "minimist": "^1.2.6",
+ "plist": "^3.0.5"
+ },
+ "bin": {
+ "electron-osx-flat": "bin/electron-osx-flat.js",
+ "electron-osx-sign": "bin/electron-osx-sign.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@electron/osx-sign/node_modules/isbinaryfile": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
+ "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/gjtorikian/"
+ }
+ },
+ "node_modules/@electron/rebuild": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-4.0.4.tgz",
+ "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@malept/cross-spawn-promise": "^2.0.0",
+ "debug": "^4.1.1",
+ "node-abi": "^4.2.0",
+ "node-api-version": "^0.2.1",
+ "node-gyp": "^12.2.0",
+ "read-binary-file-arch": "^1.0.6"
+ },
+ "bin": {
+ "electron-rebuild": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@electron/universal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.3.tgz",
+ "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/asar": "^3.3.1",
+ "@malept/cross-spawn-promise": "^2.0.0",
+ "debug": "^4.3.1",
+ "dir-compare": "^4.2.0",
+ "fs-extra": "^11.1.1",
+ "minimatch": "^9.0.3",
+ "plist": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16.4"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@electron/universal/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/fs-extra": {
+ "version": "11.3.4",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.4.tgz",
+ "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@electron/windows-sign": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
+ "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "cross-dirname": "^0.1.0",
+ "debug": "^4.3.4",
+ "fs-extra": "^11.1.1",
+ "minimist": "^1.2.8",
+ "postject": "^1.0.0-alpha.6"
+ },
+ "bin": {
+ "electron-windows-sign": "bin/electron-windows-sign.js"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@electron/windows-sign/node_modules/fs-extra": {
+ "version": "11.3.4",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.4.tgz",
+ "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@malept/cross-spawn-promise": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
+ "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/malept"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz",
+ "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "fs-extra": "^9.0.0",
+ "lodash": "^4.17.15",
+ "tmp-promise": "^3.0.2"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/fs-extra": {
+ "version": "9.0.13",
+ "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz",
+ "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.12.2",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/plist": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz",
+ "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*",
+ "xmlbuilder": ">=11.0.1"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/verror": {
+ "version": "1.10.11",
+ "resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz",
+ "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.13",
+ "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
+ "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/7zip-bin": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
+ "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/abbrev": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz",
+ "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/app-builder-bin": {
+ "version": "5.0.0-alpha.12",
+ "resolved": "https://registry.npmmirror.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz",
+ "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/app-builder-lib": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-26.8.1.tgz",
+ "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@develar/schema-utils": "~2.6.5",
+ "@electron/asar": "3.4.1",
+ "@electron/fuses": "^1.8.0",
+ "@electron/get": "^3.0.0",
+ "@electron/notarize": "2.5.0",
+ "@electron/osx-sign": "1.3.3",
+ "@electron/rebuild": "^4.0.3",
+ "@electron/universal": "2.0.3",
+ "@malept/flatpak-bundler": "^0.4.0",
+ "@types/fs-extra": "9.0.13",
+ "async-exit-hook": "^2.0.1",
+ "builder-util": "26.8.1",
+ "builder-util-runtime": "9.5.1",
+ "chromium-pickle-js": "^0.2.0",
+ "ci-info": "4.3.1",
+ "debug": "^4.3.4",
+ "dotenv": "^16.4.5",
+ "dotenv-expand": "^11.0.6",
+ "ejs": "^3.1.8",
+ "electron-publish": "26.8.1",
+ "fs-extra": "^10.1.0",
+ "hosted-git-info": "^4.1.0",
+ "isbinaryfile": "^5.0.0",
+ "jiti": "^2.4.2",
+ "js-yaml": "^4.1.0",
+ "json5": "^2.2.3",
+ "lazy-val": "^1.0.5",
+ "minimatch": "^10.0.3",
+ "plist": "3.1.0",
+ "proper-lockfile": "^4.1.2",
+ "resedit": "^1.7.0",
+ "semver": "~7.7.3",
+ "tar": "^7.5.7",
+ "temp-file": "^3.4.0",
+ "tiny-async-pool": "1.3.0",
+ "which": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "dmg-builder": "26.8.1",
+ "electron-builder-squirrel-windows": "26.8.1"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/@electron/get": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@electron/get/-/get-3.1.0.tgz",
+ "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^8.1.0",
+ "got": "^11.8.5",
+ "progress": "^2.0.3",
+ "semver": "^6.2.0",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "optionalDependencies": {
+ "global-agent": "^3.0.0"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/ci-info": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz",
+ "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/assert-plus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz",
+ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-exit-hook": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz",
+ "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/builder-util": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-26.8.1.tgz",
+ "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.1.6",
+ "7zip-bin": "~5.2.0",
+ "app-builder-bin": "5.0.0-alpha.12",
+ "builder-util-runtime": "9.5.1",
+ "chalk": "^4.1.2",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.4",
+ "fs-extra": "^10.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "js-yaml": "^4.1.0",
+ "sanitize-filename": "^1.6.3",
+ "source-map-support": "^0.5.19",
+ "stat-mode": "^1.0.0",
+ "temp-file": "^3.4.0",
+ "tiny-async-pool": "1.3.0"
+ }
+ },
+ "node_modules/builder-util-runtime": {
+ "version": "9.5.1",
+ "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
+ "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "sax": "^1.2.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz",
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chromium-pickle-js": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz",
+ "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ci-info": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz",
+ "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-2.1.0.tgz",
+ "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "slice-ansi": "^3.0.0",
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz",
+ "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/compare-version": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz",
+ "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/crc": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz",
+ "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "buffer": "^5.1.0"
+ }
+ },
+ "node_modules/cross-dirname": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz",
+ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cross-spawn/node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/cross-spawn/node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/dir-compare": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz",
+ "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimatch": "^3.0.5",
+ "p-limit": "^3.1.0 "
+ }
+ },
+ "node_modules/dir-compare/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dir-compare/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/dir-compare/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/dmg-builder": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-26.8.1.tgz",
+ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
+ "fs-extra": "^10.1.0",
+ "iconv-lite": "^0.6.2",
+ "js-yaml": "^4.1.0"
+ },
+ "optionalDependencies": {
+ "dmg-license": "^1.0.11"
+ }
+ },
+ "node_modules/dmg-license": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz",
+ "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "dependencies": {
+ "@types/plist": "^3.0.1",
+ "@types/verror": "^1.10.3",
+ "ajv": "^6.10.0",
+ "crc": "^3.8.0",
+ "iconv-corefoundation": "^1.1.7",
+ "plist": "^3.0.4",
+ "smart-buffer": "^4.0.2",
+ "verror": "^1.10.0"
+ },
+ "bin": {
+ "dmg-license": "bin/dmg-license.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "11.0.7",
+ "resolved": "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
+ "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dotenv": "^16.4.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/electron": {
+ "version": "42.0.0",
+ "resolved": "https://registry.npmmirror.com/electron/-/electron-42.0.0.tgz",
+ "integrity": "sha512-in5jnW/Ywy3Rh3FPr4MR80exPEkywKYAmDJRZ/gIKlr8VXEi3zXgiAZbf0Si7KRccHTF2y8euVMRz7M6HqTjMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0",
+ "extract-zip": "^2.0.1"
+ },
+ "bin": {
+ "electron": "cli.js",
+ "install-electron": "install.js"
+ },
+ "engines": {
+ "node": ">= 22.12.0"
+ }
+ },
+ "node_modules/electron-builder": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.8.1.tgz",
+ "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
+ "builder-util-runtime": "9.5.1",
+ "chalk": "^4.1.2",
+ "ci-info": "^4.2.0",
+ "dmg-builder": "26.8.1",
+ "fs-extra": "^10.1.0",
+ "lazy-val": "^1.0.5",
+ "simple-update-notifier": "2.0.0",
+ "yargs": "^17.6.2"
+ },
+ "bin": {
+ "electron-builder": "cli.js",
+ "install-app-deps": "install-app-deps.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/electron-builder-squirrel-windows": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz",
+ "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
+ "electron-winstaller": "5.4.0"
+ }
+ },
+ "node_modules/electron-publish": {
+ "version": "26.8.1",
+ "resolved": "https://registry.npmmirror.com/electron-publish/-/electron-publish-26.8.1.tgz",
+ "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/fs-extra": "^9.0.11",
+ "builder-util": "26.8.1",
+ "builder-util-runtime": "9.5.1",
+ "chalk": "^4.1.2",
+ "form-data": "^4.0.5",
+ "fs-extra": "^10.1.0",
+ "lazy-val": "^1.0.5",
+ "mime": "^2.5.2"
+ }
+ },
+ "node_modules/electron-winstaller": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
+ "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@electron/asar": "^3.2.1",
+ "debug": "^4.1.1",
+ "fs-extra": "^7.0.1",
+ "lodash": "^4.17.21",
+ "temp": "^0.9.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@electron/windows-sign": "^1.1.2"
+ }
+ },
+ "node_modules/electron-winstaller/node_modules/fs-extra": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz",
+ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/electron-winstaller/node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/electron-winstaller/node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/exponential-backoff": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
+ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/extsprintf": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz",
+ "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
+ "dev": true,
+ "engines": [
+ "node >=0.6.0"
+ ],
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+ "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-corefoundation": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmmirror.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
+ "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "dependencies": {
+ "cli-truncate": "^2.1.0",
+ "node-addon-api": "^1.6.3"
+ },
+ "engines": {
+ "node": "^8.11.2 || >=10"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isbinaryfile": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
+ "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/gjtorikian/"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz",
+ "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/lazy-val": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz",
+ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "4.30.0",
+ "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.30.0.tgz",
+ "integrity": "sha512-D7v3slBz2jgsBLCY9FnFsD0nlXvuKd49Pl9L+Vj+7ZlivHsLK2mkekQo7vZwYco+kyb4OJlaWfYdr3+27vfYtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-1.7.2.tgz",
+ "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-api-version": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz",
+ "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/node-gyp": {
+ "version": "12.3.0",
+ "resolved": "https://registry.npmmirror.com/node-gyp/-/node-gyp-12.3.0.tgz",
+ "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "graceful-fs": "^4.2.6",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "tar": "^7.5.4",
+ "tinyglobby": "^0.2.12",
+ "undici": "^6.25.0",
+ "which": "^6.0.0"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/node-gyp/node_modules/isexe": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-4.0.0.tgz",
+ "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/node-gyp/node_modules/undici": {
+ "version": "6.25.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/node-gyp/node_modules/which": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmmirror.com/which/-/which-6.0.1.tgz",
+ "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^4.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmmirror.com/nopt/-/nopt-9.0.0.tgz",
+ "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^4.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pe-library": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz",
+ "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jet2jet"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/plist": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz",
+ "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.8",
+ "base64-js": "^1.5.1",
+ "xmlbuilder": "^15.1.1"
+ },
+ "engines": {
+ "node": ">=10.4.0"
+ }
+ },
+ "node_modules/postject": {
+ "version": "1.0.0-alpha.6",
+ "resolved": "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz",
+ "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "commander": "^9.4.0"
+ },
+ "bin": {
+ "postject": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/postject/node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz",
+ "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proper-lockfile": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+ "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "retry": "^0.12.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-binary-file-arch": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
+ "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "bin": {
+ "read-binary-file-arch": "cli.js"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resedit": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz",
+ "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pe-library": "^0.4.1"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jet2jet"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.6.3.tgz",
+ "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sanitize-filename": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz",
+ "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==",
+ "dev": true,
+ "license": "WTFPL OR ISC",
+ "dependencies": {
+ "truncate-utf8-bytes": "^1.0.0"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/slice-ansi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-3.0.0.tgz",
+ "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/stat-mode": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
+ "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.14",
+ "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.14.tgz",
+ "integrity": "sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar/node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/temp": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmmirror.com/temp/-/temp-0.9.4.tgz",
+ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mkdirp": "^0.5.1",
+ "rimraf": "~2.6.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/temp-file": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz",
+ "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-exit-hook": "^2.0.1",
+ "fs-extra": "^10.0.0"
+ }
+ },
+ "node_modules/tiny-async-pool": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
+ "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^5.5.0"
+ }
+ },
+ "node_modules/tiny-async-pool/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tmp": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
+ "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/tmp-promise": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz",
+ "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tmp": "^0.2.0"
+ }
+ },
+ "node_modules/truncate-utf8-bytes": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
+ "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==",
+ "dev": true,
+ "license": "WTFPL",
+ "dependencies": {
+ "utf8-byte-length": "^1.0.1"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/utf8-byte-length": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
+ "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)"
+ },
+ "node_modules/verror": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
+ "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "assert-plus": "^1.0.0",
+ "core-util-is": "1.0.2",
+ "extsprintf": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/which/-/which-5.0.0.tgz",
+ "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/xmlbuilder": {
+ "version": "15.1.1",
+ "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/electron-launcher/package.json b/electron-launcher/package.json
new file mode 100644
index 0000000..83518d4
--- /dev/null
+++ b/electron-launcher/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "chatlab-desktop",
+ "version": "1.0.1",
+ "main": "main.js",
+ "scripts": {
+ "start": "electron .",
+ "build": "electron-builder --win --config electron-builder.config.cjs",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "devDependencies": {
+ "electron": "^42.0.0",
+ "electron-builder": "^26.8.1"
+ }
+}
diff --git a/electron-launcher/preload.js b/electron-launcher/preload.js
new file mode 100644
index 0000000..c094052
--- /dev/null
+++ b/electron-launcher/preload.js
@@ -0,0 +1,24 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+contextBridge.exposeInMainWorld('electronAPI', {
+ startAll: () => ipcRenderer.invoke('start-all'),
+ startChatlog: () => ipcRenderer.send('start-chatlog'),
+ stopChatlog: () => ipcRenderer.send('stop-chatlog'),
+ startFastapi: () => ipcRenderer.send('start-fastapi'),
+ stopFastapi: () => ipcRenderer.send('stop-fastapi'),
+ startFrontend: () => ipcRenderer.send('start-frontend'),
+ stopFrontend: () => ipcRenderer.send('stop-frontend'),
+ openInApp: () => ipcRenderer.send('open-in-app'),
+ getProcessStatus: () => ipcRenderer.invoke('get-process-status'),
+ refreshCurrentAccount: () => ipcRenderer.invoke('refresh-current-account'),
+
+ onLog: (callback) => ipcRenderer.on('log', (_event, value) => callback(value)),
+ onProcessStarted: (callback) => ipcRenderer.on('process-started', (_event, value) => callback(value)),
+ onProcessStopped: (callback) => ipcRenderer.on('process-stopped', (_event, value) => callback(value)),
+ onProcessError: (callback) => ipcRenderer.on('process-error', (_event, value) => callback(value)),
+ onShowCloseWarning: (callback) => ipcRenderer.on('show-close-warning', () => callback()),
+ onDecryptStatus: (callback) => ipcRenderer.on('decrypt-status', (_event, value) => callback(value)),
+ onDecryptReady: (callback) => ipcRenderer.on('decrypt-ready', () => callback()),
+ onDecryptReset: (callback) => ipcRenderer.on('decrypt-reset', () => callback()),
+ onShowDecryptDialog: (callback) => ipcRenderer.on('show-decrypt-dialog', () => callback())
+});
diff --git a/lib/windows_x64/wx_key.dll b/lib/windows_x64/wx_key.dll
new file mode 100644
index 0000000..8952a4b
Binary files /dev/null and b/lib/windows_x64/wx_key.dll differ
diff --git a/license_server/正式上线打包与授权流程.md b/license_server/正式上线打包与授权流程.md
new file mode 100644
index 0000000..35371a8
--- /dev/null
+++ b/license_server/正式上线打包与授权流程.md
@@ -0,0 +1,777 @@
+# ChatLab 正式上线、打包、联网授权、离线授权操作手册
+
+这份文档按实际销售流程写,不讲太多专业词。你可以把它理解成:
+
+```text
+先把授权服务放到你的服务器上
+再把桌面软件里的授权地址改成你的服务器地址
+然后重新打包安装包
+客户安装后输入授权码,就会连你的服务器激活
+```
+
+---
+
+## 1. 正式上线前你要准备什么
+
+你需要准备 4 样东西:
+
+```text
+1. 一台云服务器
+2. 一个域名,比如 https://license.xxx.com
+3. 授权后台 license_server
+4. 打包好的客户安装包
+```
+
+建议你正式卖客户时使用:
+
+```text
+默认:联网授权
+备用:离线授权
+```
+
+联网授权适合大多数客户。
+离线授权只给网络限制很严的客户用。
+
+---
+
+## 2. 修改客户软件里的授权服务器地址
+
+正式上线后,先打开这个文件:
+
+```text
+electron-launcher/license-config.json
+```
+
+本地测试时通常是:
+
+```json
+{
+ "licenseServerUrl": "http://127.0.0.1:8787"
+}
+```
+
+正式上线后改成你的公网授权地址:
+
+```json
+{
+ "licenseServerUrl": "https://license.你的域名.com"
+}
+```
+
+例子:
+
+```json
+{
+ "licenseServerUrl": "https://license.chatlab.cn"
+}
+```
+
+注意:
+
+```text
+不要给客户正式包继续使用 http://127.0.0.1:8787
+```
+
+因为 `127.0.0.1` 代表客户自己的电脑。
+客户电脑上没有你的授权服务,就会激活失败。
+
+---
+
+## 3. 修改授权地址后怎么打包
+
+在你自己的开发电脑上打开 PowerShell。
+
+进入项目根目录:
+
+```powershell
+cd C:\Users\12138\Desktop\get_wechat_new\用户使用版本\第二版\get_wechat_me
+```
+
+推荐使用完整打包命令:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+这个命令会重新打包前端、后端和 Electron 安装包。
+
+打包完成后,正式发给客户的是:
+
+```text
+release\ChatLab-Setup-1.0.0.exe
+```
+
+如果你只是临时测试 Electron 打包,也可以执行:
+
+```powershell
+cd .\electron-launcher
+npm.cmd run build
+```
+
+临时测试输出一般在:
+
+```text
+electron-launcher\dist\ChatLab-Setup-1.0.0.exe
+```
+
+但正式发客户,建议用:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+---
+
+## 4. 如果你有代码签名证书
+
+正式商业销售建议做代码签名,否则客户电脑可能提示未知发布者。
+
+有证书时使用:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 `
+ -Sign `
+ -CertificateFile "D:\certs\ChatLab-CodeSigning.pfx" `
+ -CertificatePassword "证书密码" `
+ -PublisherName "你的公司名称" `
+ -ForceSign
+```
+
+如果暂时没有证书,也可以先用普通打包命令:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+只是客户安装时可能会看到系统提醒。
+
+---
+
+## 5. 部署联网授权服务
+
+下面以 Ubuntu 云服务器为例。
+
+### 5.1 上传授权后台
+
+把本地这个目录上传到服务器:
+
+```text
+license_server
+```
+
+建议服务器上放到:
+
+```text
+/opt/chatlab-license-server
+```
+
+服务器上的目录大概是:
+
+```text
+/opt/chatlab-license-server
+ admin_cli.py
+ main.py
+ license_core.py
+ requirements.txt
+ dev_private_key.pem
+```
+
+注意:
+
+```text
+不要把整个桌面软件项目都发到客户电脑。
+license_server 是你自己服务器用的,不发给客户。
+```
+
+---
+
+### 5.2 安装运行环境
+
+登录服务器后执行:
+
+```bash
+cd /opt/chatlab-license-server
+python3 -m venv .venv
+. .venv/bin/activate
+pip install -r requirements.txt
+```
+
+先手动测试运行:
+
+```bash
+python main.py
+```
+
+如果看到类似:
+
+```text
+Uvicorn running on http://127.0.0.1:8787
+```
+
+说明授权服务已经启动。
+
+再测试:
+
+```bash
+curl http://127.0.0.1:8787/health
+```
+
+正常会返回:
+
+```json
+{"ok":true}
+```
+
+---
+
+## 6. 绑定域名和 HTTPS
+
+假设你的域名是:
+
+```text
+license.你的域名.com
+```
+
+先在域名解析后台添加:
+
+```text
+类型:A
+主机记录:license
+记录值:你的服务器公网 IP
+```
+
+然后服务器安装 Nginx 和证书工具:
+
+```bash
+sudo apt update
+sudo apt install nginx certbot python3-certbot-nginx -y
+```
+
+开放网页端口:
+
+```bash
+sudo ufw allow 80
+sudo ufw allow 443
+```
+
+申请 HTTPS 证书:
+
+```bash
+sudo certbot --nginx -d license.你的域名.com
+```
+
+成功后,在浏览器打开:
+
+```text
+https://license.你的域名.com/health
+```
+
+如果看到:
+
+```json
+{"ok":true}
+```
+
+说明公网授权服务已经通了。
+
+---
+
+## 7. 让授权服务一直运行
+
+手动执行 `python main.py` 只适合测试。
+正式上线要让它一直在服务器后台运行。
+
+创建服务文件:
+
+```bash
+sudo nano /etc/systemd/system/chatlab-license.service
+```
+
+写入:
+
+```ini
+[Unit]
+Description=ChatLab License Server
+After=network.target
+
+[Service]
+WorkingDirectory=/opt/chatlab-license-server
+ExecStart=/opt/chatlab-license-server/.venv/bin/python /opt/chatlab-license-server/main.py
+Restart=always
+RestartSec=3
+Environment=CHATLAB_LICENSE_DB=/opt/chatlab-license-server/license_server.db
+Environment=CHATLAB_LICENSE_PRIVATE_KEY_FILE=/opt/chatlab-license-server/dev_private_key.pem
+Environment=CHATLAB_LICENSE_ADMIN_TOKEN=请改成一串很长的后台管理密码
+
+[Install]
+WantedBy=multi-user.target
+```
+
+启动服务:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable chatlab-license
+sudo systemctl start chatlab-license
+```
+
+查看状态:
+
+```bash
+sudo systemctl status chatlab-license
+```
+
+如果看到 `active` 或 `running`,说明授权服务正在运行。
+
+查看日志:
+
+```bash
+sudo journalctl -u chatlab-license -f
+```
+
+---
+
+## 8. 正式环境私钥怎么处理
+
+私钥可以理解成:
+
+```text
+你的授权盖章工具
+```
+
+谁拿到私钥,谁就可以自己生成授权。
+
+所以正式上线要记住:
+
+```text
+私钥只放你的服务器
+不要发给客户
+不要放进安装包
+不要上传到公开仓库
+不要放到网盘分享
+```
+
+如果你换了正式私钥,就必须保证:
+
+```text
+服务器用新私钥签授权
+客户软件里内置的新公钥能验证这个授权
+```
+
+如果私钥和公钥不是一对,客户会看到:
+
+```text
+授权签名校验失败
+```
+
+简单理解:
+
+```text
+服务器负责盖章
+客户软件负责验章
+两边必须是一套章
+```
+
+---
+
+## 9. 联网授权怎么做
+
+联网授权是正式销售最推荐的方式。
+
+### 9.1 你先给客户创建授权码
+
+登录服务器:
+
+```bash
+cd /opt/chatlab-license-server
+. .venv/bin/activate
+```
+
+创建一年授权:
+
+```bash
+python admin_cli.py create-license --customer-name "某某公司" --days 365
+```
+
+如果是 7 天试用:
+
+```bash
+python admin_cli.py create-license --customer-name "某某公司-试用" --days 7
+```
+
+它会输出类似:
+
+```json
+{
+ "customer_id": "cus_xxx",
+ "license_id": "lic_xxx",
+ "license_key": "CL-2026-XXXX-XXXXXX-XXXXXX",
+ "expires_at": "2027-05-18T00:00:00Z"
+}
+```
+
+你要发给客户的是:
+
+```text
+license_key
+```
+
+也就是类似:
+
+```text
+CL-2026-XXXX-XXXXXX-XXXXXX
+```
+
+---
+
+### 9.2 你发给客户两个东西
+
+发给客户:
+
+```text
+1. 安装包:ChatLab-Setup-1.0.0.exe
+2. 授权码:CL-2026-XXXX-XXXXXX-XXXXXX
+```
+
+客户操作:
+
+```text
+1. 安装软件
+2. 打开软件
+3. 输入授权码
+4. 点“激活并进入”
+5. 软件自动连接你的授权服务器
+6. 激活成功后绑定当前电脑
+```
+
+客户第一次激活成功后,这个授权默认绑定这台电脑。
+
+---
+
+### 9.3 你给客户的话术
+
+可以直接复制:
+
+```text
+您好,这是 ChatLab 安装包和授权码。
+
+安装后打开软件,输入授权码即可在线激活。
+首次激活会绑定当前电脑。
+如果后续更换电脑,请联系我处理换机。
+授权期内包含软件维护、问题修复和基础使用支持。
+```
+
+---
+
+## 10. 离线授权怎么做
+
+离线授权只建议作为备用方案。
+
+适合这些情况:
+
+```text
+客户公司网络拦截你的授权服务器
+客户电脑不能访问外网
+客户临时无法联网激活
+```
+
+不建议默认给客户一年离线授权。
+建议:
+
+```text
+普通离线授权:30 天或 90 天
+非常稳定客户:180 天
+一年离线授权谨慎使用
+```
+
+---
+
+### 10.1 客户先复制离线请求码
+
+客户打开软件后:
+
+```text
+1. 点“离线授权”
+2. 复制“离线请求码”
+3. 发给你
+```
+
+离线请求码通常是一长串字符,例如:
+
+```text
+eyJsaWNlbnNlX2tleSI6IiIsImRldmljZV9maW5nZXJwcmludF9zaGEyNTYiOi...
+```
+
+---
+
+### 10.2 你生成离线授权文件
+
+在服务器或你的授权管理电脑上执行:
+
+```bash
+cd /opt/chatlab-license-server
+. .venv/bin/activate
+```
+
+生成 90 天离线授权:
+
+```bash
+python admin_cli.py offline-grant \
+ --license-key "CL-2026-XXXX-XXXXXX-XXXXXX" \
+ --request-code "客户发来的离线请求码" \
+ --days 90 \
+ --output customer.chatlab-license
+```
+
+如果你在 Windows PowerShell 里执行,可以写成一行:
+
+```powershell
+python .\admin_cli.py offline-grant --license-key "CL-2026-XXXX-XXXXXX-XXXXXX" --request-code "客户发来的离线请求码" --days 90 --output customer.chatlab-license
+```
+
+注意:
+
+```text
+--license-key 后面必须是真实授权码
+不能写 CL-...
+```
+
+错误写法:
+
+```powershell
+--license-key "CL-..."
+```
+
+正确写法:
+
+```powershell
+--license-key "CL-2026-C6D0-4A9B22-F1B5B7"
+```
+
+---
+
+### 10.3 把离线授权内容发给客户
+
+命令成功后,会生成:
+
+```text
+customer.chatlab-license
+```
+
+打开这个文件,把里面全部内容发给客户。
+
+客户操作:
+
+```text
+1. 打开软件
+2. 点“离线授权”
+3. 把你发的授权内容粘贴进去
+4. 点“导入离线授权”
+5. 成功后进入系统
+```
+
+---
+
+## 11. 联网授权和离线授权怎么选
+
+建议你这样定规则:
+
+```text
+默认使用联网授权
+客户网络有限制时,再使用离线授权
+```
+
+推荐策略:
+
+| 场景 | 用哪种 |
+|---|---|
+| 普通客户电脑能联网 | 联网授权 |
+| 客户需要年费续费 | 联网授权 |
+| 客户可能换电脑 | 联网授权 |
+| 客户公司限制外网 | 离线授权 |
+| 客户临时无法访问授权服务器 | 离线授权 |
+| 试用客户 | 联网授权,或短期离线授权 |
+
+---
+
+## 12. 常见错误和处理方法
+
+### 12.1 connect ECONNREFUSED
+
+错误类似:
+
+```text
+connect ECONNREFUSED 127.0.0.1:8787
+```
+
+意思是:
+
+```text
+软件找不到授权服务
+```
+
+常见原因:
+
+```text
+1. 授权服务没启动
+2. 软件里的授权地址还是 127.0.0.1
+3. 客户电脑访问不到你的服务器
+4. 域名或 HTTPS 没配置好
+```
+
+处理:
+
+```text
+1. 浏览器打开 https://license.你的域名.com/health
+2. 确认返回 {"ok":true}
+3. 检查 electron-launcher/license-config.json
+4. 修改后重新打包安装包
+```
+
+---
+
+### 12.2 授权密钥不存在
+
+错误类似:
+
+```text
+KeyError: '授权密钥不存在。'
+```
+
+常见原因:
+
+```text
+1. 你用了 CL-... 这种占位符
+2. 授权码输错
+3. 你连的是另一份授权数据库
+4. 这个客户授权还没有创建
+```
+
+处理:
+
+```text
+先创建真实授权码,再生成离线授权。
+```
+
+创建授权码:
+
+```bash
+python admin_cli.py create-license --customer-name "客户名称" --days 365
+```
+
+---
+
+### 12.3 授权签名校验失败
+
+意思是:
+
+```text
+客户软件验不过授权文件
+```
+
+常见原因:
+
+```text
+1. 客户改过授权文件
+2. 服务器私钥和客户端公钥不是一对
+3. 你换了服务器私钥,但没有重新打包客户端
+```
+
+处理:
+
+```text
+1. 不要让客户手动改授权文件
+2. 确保服务器私钥和客户端公钥匹配
+3. 换密钥后重新打包客户安装包
+```
+
+---
+
+### 12.4 授权文件不属于当前电脑
+
+意思是:
+
+```text
+这个离线授权是给另一台电脑生成的
+```
+
+处理:
+
+```text
+让客户在当前电脑重新复制离线请求码
+你重新生成离线授权文件
+```
+
+---
+
+## 13. 你每天真实销售时怎么操作
+
+### 联网授权客户
+
+```text
+1. 客户付款或试用
+2. 你在服务器创建授权码
+3. 你发安装包和授权码
+4. 客户安装并输入授权码
+5. 软件自动激活并绑定电脑
+6. 你记录客户信息和到期时间
+```
+
+### 离线授权客户
+
+```text
+1. 客户付款或试用
+2. 你创建授权码
+3. 客户复制离线请求码发给你
+4. 你生成 customer.chatlab-license
+5. 客户导入离线授权
+6. 你记录离线授权到期时间
+```
+
+---
+
+## 14. 建议你的客户记录表
+
+你可以建一个 Excel,字段如下:
+
+```text
+客户名称
+联系人
+微信
+电话
+授权码
+授权类型:联网 / 离线
+购买日期
+到期日期
+离线授权到期日期
+绑定电脑备注
+是否已安装
+是否已培训
+备注
+```
+
+这样续费、换机、售后会清楚很多。
+
+---
+
+## 15. 最推荐的正式流程
+
+最终建议你采用这个流程:
+
+```text
+1. 授权服务部署到公网服务器
+2. 域名使用 HTTPS
+3. electron-launcher/license-config.json 改成公网授权地址
+4. 重新打包安装包
+5. 普通客户使用联网授权
+6. 特殊客户使用离线授权
+7. 离线授权尽量给 30 天或 90 天
+8. 私钥只放服务器,不发给客户
+```
+
+一句话总结:
+
+```text
+正式销售用联网授权,离线授权只做备用。
+改完授权域名后,用 build-desktop.ps1 重新打包,再把 release 里的安装包发给客户。
+```
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..cc5bfc3
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,527 @@
+# ChatLab 接入公司 AgentBox 与内网平台方案
+
+## 1. 目标
+
+当前 ChatLab 项目是独立运行的 Windows 本地应用,主要能力包括:
+
+- 读取 PC 微信聊天记录
+- 群聊检索
+- 话题分类
+- 售后问题归档
+- AI 总结与报告生成
+- 本地知识库管理
+
+公司已有 AgentBox 主机,主机内置:
+
+- 7B Qwen 智能体
+- 类 Dify 的平台系统
+- 业务系统
+- 数据库
+- 工作流
+- MCP 工具体系
+- 内网部署能力
+
+本方案目标是把 ChatLab 接回公司平台体系,并部署到 AgentBox 内网环境中,让员工只能在公司内网访问,同时让平台工作流和智能体能够调用 ChatLab 的售后分析能力。
+
+## 2. 推荐方案概览
+
+推荐采用:
+
+**Windows 采集端 + AgentBox 业务端 + MCP 工具接入 + 平台统一鉴权**
+
+整体形态如下:
+
+```text
+员工浏览器
+ -> 公司平台 / 内网网关
+ -> AgentBox 上的 ChatLab Web
+ -> AgentBox 上的 ChatLab FastAPI
+ -> AgentBox 上的 MCP Server
+ -> AgentBox 上的 Qwen 7B 模型服务
+ -> 公司平台业务 API / 平台数据库
+ -> Windows 微信采集端
+ -> PC 微信
+ -> chatlog.exe
+```
+
+不建议第一版把微信采集能力完全搬到 AgentBox 上,因为当前项目依赖 PC 微信、Windows 进程、chatlog.exe 和本地微信数据解密。AgentBox 更适合承载平台、模型、业务系统、MCP、数据库和内网 Web 服务。
+
+## 3. 模块分工
+
+### 3.1 Windows 采集端
+
+Windows 采集端继续负责和微信强相关的能力:
+
+- 登录 PC 微信
+- 启动并维护 `chatlog.exe`
+- 读取群聊、会话、消息、图片、语音、文件等微信数据
+- 识别当前微信账号
+- 将消息增量同步给 AgentBox
+- 响应 AgentBox 的按需查询任务
+
+采集端只负责取数据,不再作为主要业务入口。
+
+### 3.2 AgentBox 业务端
+
+AgentBox 负责承载 ChatLab 的核心业务能力:
+
+- ChatLab 后端 FastAPI
+- ChatLab Web 页面
+- MCP 工具服务
+- AI 总结、分类、报告生成
+- 与公司平台业务 API 对接
+- 与 AgentBox 上的 Qwen 7B 模型对接
+- 内网访问控制
+- 平台数据库写入
+
+AgentBox 是正式的业务入口和平台集成点。
+
+### 3.3 公司平台
+
+公司平台负责:
+
+- 用户登录
+- 权限控制
+- 菜单入口
+- 工作流编排
+- 智能体调用
+- 业务对象管理
+- 数据库存储
+- 审计日志
+- 报告流转与人工确认
+
+ChatLab 不单独做完整账号体系,而是接入公司平台统一身份体系。
+
+## 4. 部署方式
+
+### 4.1 AgentBox 上部署的服务
+
+AgentBox 上建议部署以下服务:
+
+```text
+chatlab-api
+chatlab-web
+chatlab-mcp
+qwen-openai-compatible-api
+```
+
+其中:
+
+- `chatlab-api` 是当前 `chatlog_fastAPI` 改造后的服务端版本
+- `chatlab-web` 是当前 React 前端构建后的静态页面
+- `chatlab-mcp` 是给公司平台或智能体调用的 MCP 工具服务
+- `qwen-openai-compatible-api` 是 AgentBox 本地 Qwen 7B 的 OpenAI 兼容接口
+
+### 4.2 内网访问方式
+
+推荐访问地址:
+
+```text
+http://agentbox内网IP/chatlab
+```
+
+或者由公司平台菜单进入:
+
+```text
+公司平台 -> 售后智能助手 -> ChatLab
+```
+
+对外只开放内网访问,不开放公网访问。
+
+推荐网关路径:
+
+```text
+/chatlab/ -> ChatLab Web
+/chatlab/api/ -> ChatLab FastAPI
+/chatlab/mcp/ -> ChatLab MCP Server
+```
+
+### 4.3 端口建议
+
+```text
+80 / 443 公司平台网关或 Nginx
+8000 ChatLab FastAPI,仅内网或网关访问
+8001 ChatLab MCP,仅平台服务访问
+模型端口 只允许 AgentBox 本机访问
+5030 chatlog.exe,仅 Windows 采集端本机访问
+```
+
+## 5. 数据流设计
+
+### 5.1 聊天数据采集流程
+
+```text
+PC 微信
+ -> chatlog.exe
+ -> Windows 采集端
+ -> AgentBox ChatLab API
+ -> 平台 API
+ -> 平台数据库
+```
+
+采集端不直接暴露给用户访问,只负责把微信数据同步给 AgentBox。
+
+### 5.2 AI 报告生成流程
+
+```text
+用户选择群聊 / 平台工作流触发
+ -> ChatLab 检索聊天记录
+ -> 创建话题
+ -> 调用 AgentBox Qwen 7B
+ -> 生成 Markdown 报告
+ -> 写入公司平台业务对象
+ -> 人工确认 / 工作流流转
+```
+
+### 5.3 MCP 调用流程
+
+```text
+平台智能体 / 工作流
+ -> MCP Tool
+ -> ChatLab API
+ -> 消息检索 / 话题生成 / 报告生成
+ -> 平台业务数据
+```
+
+## 6. 平台数据库对接方式
+
+推荐不让 ChatLab 直接连接平台数据库,而是通过公司平台 API 写入业务对象。
+
+原因:
+
+- 保留平台权限控制
+- 保留审计日志
+- 保留工作流触发能力
+- 避免绕过平台业务规则
+- 后续平台升级时耦合更低
+
+建议平台侧提供以下业务对象。
+
+### 6.1 售后群对象
+
+```json
+{
+ "external_id": "微信群ID",
+ "name": "群名称",
+ "source": "wechat",
+ "collector_id": "采集端ID",
+ "status": "active"
+}
+```
+
+### 6.2 话题对象
+
+```json
+{
+ "external_id": "ChatLab话题ID",
+ "group_external_id": "微信群ID",
+ "title": "话题标题",
+ "source": "manual|ai|workflow",
+ "status": "pending|processing|done|failed",
+ "message_refs": [123, 124, 125]
+}
+```
+
+### 6.3 售后报告对象
+
+```json
+{
+ "external_id": "报告ID",
+ "topic_external_id": "话题ID",
+ "title": "报告标题",
+ "content_markdown": "Markdown报告正文",
+ "evidence": [],
+ "ai_model": "qwen-7b",
+ "review_status": "pending|approved|rejected"
+}
+```
+
+### 6.4 任务对象
+
+```json
+{
+ "external_id": "任务ID",
+ "type": "sync|summarize|topic_detect",
+ "status": "queued|running|success|failed",
+ "progress": {
+ "processed": 10,
+ "total": 100
+ },
+ "error": ""
+}
+```
+
+## 7. MCP 工具设计
+
+第一版建议提供以下 MCP 工具:
+
+```text
+chatlab_list_groups
+chatlab_search_messages
+chatlab_get_message_context
+chatlab_create_topic
+chatlab_add_topic_messages
+chatlab_summarize_topic
+chatlab_get_report
+chatlab_search_reports
+```
+
+### 7.1 群列表工具
+
+```text
+chatlab_list_groups
+```
+
+用途:
+
+- 获取当前可分析的微信群
+- 给平台智能体或工作流选择分析对象
+
+### 7.2 消息检索工具
+
+```text
+chatlab_search_messages
+```
+
+参数示例:
+
+```json
+{
+ "group_id": "微信群ID",
+ "keyword": "退款",
+ "start_date": "2026-05-01",
+ "end_date": "2026-05-18",
+ "limit": 50
+}
+```
+
+用途:
+
+- 按关键词查找聊天记录
+- 支持售后问题溯源
+- 支持工作流自动筛选问题片段
+
+### 7.3 上下文获取工具
+
+```text
+chatlab_get_message_context
+```
+
+用途:
+
+- 根据某条消息获取前后文
+- 避免 AI 只看单条消息误判
+
+### 7.4 创建话题工具
+
+```text
+chatlab_create_topic
+```
+
+用途:
+
+- 把一组消息整理成售后话题
+- 可由人工触发,也可由工作流触发
+
+### 7.5 生成报告工具
+
+```text
+chatlab_summarize_topic
+```
+
+参数示例:
+
+```json
+{
+ "topic_id": "话题ID",
+ "write_to_platform": true
+}
+```
+
+用途:
+
+- 调用 AgentBox 上的 Qwen 7B
+- 生成售后问题报告
+- 写入平台业务对象
+- 进入人工确认流程
+
+## 8. AI 模型对接方案
+
+当前项目已经使用 OpenAI-compatible 调用方式,因此 AgentBox 的 Qwen 7B 只需要提供兼容接口:
+
+```text
+POST /v1/chat/completions
+```
+
+ChatLab 后端配置为:
+
+```text
+AI_BASE_URL=http://127.0.0.1:模型服务端口/v1
+AI_API_KEY=agentbox-local-key
+AI_MODEL=qwen-7b
+```
+
+第一版优先支持:
+
+- 文本聊天总结
+- 售后问题提取
+- 话题分类
+- Markdown 报告生成
+- AI 建议生成
+
+图片、语音、视频解析可以作为第二阶段增强能力。如果 AgentBox 暂时没有视觉模型或语音模型,系统应允许跳过这些内容,并在报告中提示媒体内容需人工查看。
+
+## 9. 权限与安全
+
+### 9.1 访问边界
+
+- 只允许内网访问
+- 不暴露公网
+- AgentBox 服务只接受公司平台网关或内网指定 IP 访问
+- MCP 服务只允许平台服务账号调用
+- 采集端只允许带签名的请求写入数据
+
+### 9.2 用户鉴权
+
+推荐使用公司平台统一登录。
+
+平台网关向 ChatLab 注入用户身份:
+
+```text
+X-Platform-User-Id
+X-Platform-User-Name
+X-Platform-Roles
+```
+
+ChatLab 根据这些身份头判断用户权限。
+
+### 9.3 角色建议
+
+```text
+普通用户:
+- 查看授权群
+- 查看报告
+
+售后人员:
+- 创建话题
+- 生成报告
+- 提交人工确认
+
+管理员:
+- 管理采集端
+- 管理模型配置
+- 管理平台 API 配置
+- 查看同步状态
+
+工作流服务账号:
+- 调用 MCP 工具
+- 写入报告和任务状态
+```
+
+### 9.4 采集端安全
+
+采集端和 AgentBox 通信使用 HMAC 签名。
+
+请求头建议:
+
+```text
+X-Collector-Id
+X-Timestamp
+X-Signature
+```
+
+签名方式:
+
+```text
+HMAC_SHA256(secret, method + path + timestamp + body_sha256)
+```
+
+AgentBox 拒绝:
+
+- 无签名请求
+- 签名错误请求
+- 时间偏差超过 5 分钟的请求
+- 未注册采集端请求
+
+## 10. 数据存储策略
+
+第一版建议:
+
+- 平台数据库存正式业务数据
+- ChatLab 本地 SQLite 只作为缓存或临时任务状态
+- 微信原始数据库不上传 AgentBox
+- 消息正文按业务需要同步
+- 图片、语音、文件按需同步或按需代理访问
+
+正式进入平台数据库的数据包括:
+
+- 群信息
+- 话题信息
+- 关键消息引用
+- AI 报告
+- 任务状态
+- 人工确认状态
+- 审计记录
+
+不建议直接保存所有微信原始数据,除非公司合规要求允许并且平台已有对应权限模型。
+
+## 11. 推荐落地阶段
+
+### 第一阶段:平台接入最小闭环
+
+目标:
+
+- AgentBox 能访问 ChatLab Web
+- 平台能通过 MCP 调用 ChatLab
+- ChatLab 能调用 Qwen 7B 生成报告
+- 报告能写入平台业务对象
+- 员工只能内网访问
+
+第一阶段完成后,就可以在公司内部演示完整售后分析闭环。
+
+### 第二阶段:采集端服务化
+
+目标:
+
+- Windows 采集端自动心跳
+- 自动增量同步消息
+- AgentBox 显示采集端在线状态
+- 支持多采集端绑定
+- 支持多个微信账号隔离
+
+### 第三阶段:工作流深度融合
+
+目标:
+
+- 平台工作流自动发现售后问题
+- 自动生成待确认报告
+- 人工确认后进入业务系统
+- 报告和任务进入平台审计体系
+- 支持通知、派单、复盘等业务流转
+
+### 第四阶段:多模态增强
+
+目标:
+
+- 图片内容识别
+- 语音转文字
+- 视频摘要
+- 文件内容解析
+- 多模态证据进入售后报告
+
+## 12. 推荐结论
+
+第一版不要追求把整个项目完全搬进 AgentBox。
+
+最稳妥的路线是:
+
+```text
+微信采集继续留在 Windows
+业务系统迁到 AgentBox
+AI 调用切到 AgentBox Qwen
+数据写入公司平台
+能力通过 MCP 暴露给工作流和智能体
+Web 入口通过内网平台访问
+```
+
+这样可以最大程度复用当前项目,同时快速接回公司平台体系,并且避免微信采集在 AgentBox 上不可控的问题。
diff --git a/release/ChatLab-Setup-1.0.1-202605310641.exe b/release/ChatLab-Setup-1.0.1-202605310641.exe
new file mode 100644
index 0000000..3d45d56
Binary files /dev/null and b/release/ChatLab-Setup-1.0.1-202605310641.exe differ
diff --git a/release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap b/release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap
new file mode 100644
index 0000000..a395de8
Binary files /dev/null and b/release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap differ
diff --git a/release/manifest.txt b/release/manifest.txt
new file mode 100644
index 0000000..3893cf7
--- /dev/null
+++ b/release/manifest.txt
@@ -0,0 +1,766 @@
+
+FullName
+--------
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\chatlog.exe
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\DISCLAIMER.md
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\LICENSE
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\ChatLabBacke...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ba...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\sq...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\uc...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\un...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\VC...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\VC...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_a...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_b...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_c...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_d...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_e...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_h...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_l...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_m...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_o...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_q...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_u...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_w...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_z...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ce...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ce...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ya...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\company-log...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\favicon.svg
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\icons.svg
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\index.html
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\assets\inde...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\assets\inde...
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\lib\windows_x64\wx_k...
+
+
diff --git a/scripts/build-desktop.ps1 b/scripts/build-desktop.ps1
new file mode 100644
index 0000000..df43a3b
--- /dev/null
+++ b/scripts/build-desktop.ps1
@@ -0,0 +1,234 @@
+param(
+ [string]$PythonLauncher = "py",
+ [string]$PythonVersion = "-3.12",
+ [switch]$SkipIcon,
+ [switch]$SkipBackend,
+ [switch]$SkipFrontend,
+ [switch]$SkipInstaller,
+ [switch]$Sign,
+ [string]$CertificateFile,
+ [string]$CertificatePassword,
+ [string]$PublisherName,
+ [string]$TimestampServer = "http://timestamp.digicert.com",
+ [string[]]$VoiceSmokeKeys = @(),
+ [switch]$ForceSign
+)
+
+$ErrorActionPreference = "Stop"
+
+$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
+$Frontend = Join-Path $Root "chatlab-web\frontend"
+$Backend = Join-Path $Root "chatlog_fastAPI"
+$Electron = Join-Path $Root "electron-launcher"
+$Resources = Join-Path $Electron "build-resources"
+$Release = Join-Path $Root "release"
+
+function Invoke-Python312($Arguments) {
+ $pyCommand = Get-Command $PythonLauncher -ErrorAction SilentlyContinue
+ if ($pyCommand) {
+ try {
+ & $PythonLauncher $PythonVersion -V | Out-Null
+ if ($LASTEXITCODE -eq 0) {
+ & $PythonLauncher $PythonVersion @Arguments
+ return
+ }
+ } catch {
+ Write-Host "Python launcher $PythonLauncher $PythonVersion is not available, falling back to user Python312."
+ }
+ }
+
+ $fallback = Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"
+ if (-not (Test-Path $fallback)) {
+ throw "Python 3.12 was not found. Install it first or pass -PythonLauncher/-PythonVersion."
+ }
+
+ & $fallback @Arguments
+}
+
+function Reset-Dir($Path) {
+ if (Test-Path $Path) {
+ $resolved = Resolve-Path $Path
+ if (-not $resolved.Path.StartsWith($Root.Path)) {
+ throw "Refusing to remove path outside project: $($resolved.Path)"
+ }
+ Remove-Item -LiteralPath $resolved.Path -Recurse -Force
+ }
+ New-Item -ItemType Directory -Force -Path $Path | Out-Null
+}
+
+function Copy-Dir($Source, $Dest) {
+ if (-not (Test-Path $Source)) {
+ throw "Missing required source: $Source"
+ }
+ New-Item -ItemType Directory -Force -Path (Split-Path $Dest) | Out-Null
+ Copy-Item -LiteralPath $Source -Destination $Dest -Recurse -Force
+}
+
+function Set-OptionalEnv($Name, $Value) {
+ if ([string]::IsNullOrWhiteSpace($Value)) {
+ Remove-Item -Path "Env:$Name" -ErrorAction SilentlyContinue
+ } else {
+ Set-Item -Path "Env:$Name" -Value $Value
+ }
+}
+
+function Test-ForbiddenReleaseFile($File) {
+ return $File.Name -eq ".env" -or
+ $File.Name -like "knowledge*.db" -or
+ $File.FullName -match "\\__pycache__\\" -or
+ $File.Name -like "*.pfx" -or
+ $File.Name -like "*.p12" -or
+ $File.Name -like "*.pvk" -or
+ $File.Name -like "*.cer" -or
+ $File.Name -like "*.crt" -or
+ $File.Name -like "*.key" -or
+ $File.FullName -match "\\certs\\"
+}
+
+Set-Location $Root
+
+$resolvedCertificateFile = $null
+if ($CertificateFile) {
+ if (-not (Test-Path -LiteralPath $CertificateFile)) {
+ throw "Certificate file was not found: $CertificateFile"
+ }
+ $resolvedCertificateFile = (Resolve-Path -LiteralPath $CertificateFile).Path
+ if ($resolvedCertificateFile.StartsWith($Root.Path)) {
+ throw "For safety, keep the code signing certificate outside the project folder: $resolvedCertificateFile"
+ }
+}
+
+$envCertificateFile = if ($resolvedCertificateFile) { $resolvedCertificateFile } else { $env:CHATLAB_PFX_FILE }
+$envCertificateFile = if ($envCertificateFile) { $envCertificateFile.Trim() } else { "" }
+$shouldSign = $Sign -or -not [string]::IsNullOrWhiteSpace($envCertificateFile)
+
+if ($ForceSign -and -not $shouldSign) {
+ throw "-ForceSign requires -CertificateFile or CHATLAB_PFX_FILE."
+}
+
+if ($shouldSign) {
+ if (-not $envCertificateFile) {
+ throw "Signing was requested, but no certificate was provided. Use -CertificateFile or CHATLAB_PFX_FILE."
+ }
+ if (-not (Test-Path -LiteralPath $envCertificateFile)) {
+ throw "Certificate file was not found: $envCertificateFile"
+ }
+ $envCertificateFile = (Resolve-Path -LiteralPath $envCertificateFile).Path
+ if ($envCertificateFile.StartsWith($Root.Path)) {
+ throw "For safety, keep the code signing certificate outside the project folder: $envCertificateFile"
+ }
+ Set-OptionalEnv "CHATLAB_PFX_FILE" $envCertificateFile
+ if ($CertificatePassword) {
+ Set-OptionalEnv "CHATLAB_PFX_PASSWORD" $CertificatePassword
+ Set-OptionalEnv "WIN_CSC_KEY_PASSWORD" $CertificatePassword
+ Set-OptionalEnv "CSC_KEY_PASSWORD" $CertificatePassword
+ }
+ if ($PublisherName) { Set-OptionalEnv "CHATLAB_CERT_PUBLISHER_NAME" $PublisherName }
+ Set-OptionalEnv "CHATLAB_TIMESTAMP_SERVER" $TimestampServer
+ if ($ForceSign) { Set-OptionalEnv "CHATLAB_FORCE_SIGN" "1" }
+ Write-Host "Code signing enabled. Certificate: $envCertificateFile"
+} else {
+ Remove-Item -Path "Env:CHATLAB_PFX_FILE" -ErrorAction SilentlyContinue
+ Remove-Item -Path "Env:CHATLAB_PFX_PASSWORD" -ErrorAction SilentlyContinue
+ Remove-Item -Path "Env:CHATLAB_CERT_PUBLISHER_NAME" -ErrorAction SilentlyContinue
+ Remove-Item -Path "Env:CHATLAB_FORCE_SIGN" -ErrorAction SilentlyContinue
+ Remove-Item -Path "Env:WIN_CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
+ Remove-Item -Path "Env:CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
+ Write-Host "Code signing disabled. Unsigned installer build is allowed."
+}
+
+if (-not $SkipIcon) {
+ & node (Join-Path $Root "scripts\make-icon.cjs")
+}
+
+if (-not $SkipFrontend) {
+ $dist = Join-Path $Frontend "dist"
+ if (Test-Path $dist) {
+ Remove-Item -LiteralPath (Resolve-Path $dist).Path -Recurse -Force
+ }
+ Push-Location $Frontend
+ & npm.cmd run build
+ Pop-Location
+}
+
+if (-not $SkipBackend) {
+ Push-Location $Backend
+ Invoke-Python312 @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm", "--clean")
+ Pop-Location
+}
+
+Reset-Dir $Resources
+New-Item -ItemType Directory -Force -Path $Release | Out-Null
+
+Copy-Item -LiteralPath (Join-Path $Root "chatlog.exe") -Destination (Join-Path $Resources "chatlog.exe") -Force
+$previousErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "Continue"
+$chatlogVersionOutput = & (Join-Path $Resources "chatlog.exe") version 2>&1
+$chatlogVersionExitCode = $LASTEXITCODE
+$ErrorActionPreference = $previousErrorActionPreference
+if ($chatlogVersionExitCode -ne 0) {
+ throw "chatlog.exe version check failed:`n$chatlogVersionOutput"
+}
+Write-Host "chatlog.exe version: $chatlogVersionOutput"
+
+foreach ($voiceKey in $VoiceSmokeKeys) {
+ if ([string]::IsNullOrWhiteSpace($voiceKey)) { continue }
+ try {
+ $response = Invoke-WebRequest -Uri "http://127.0.0.1:5030/voice/$voiceKey" -UseBasicParsing -TimeoutSec 15
+ if ($response.StatusCode -ge 400 -or $response.RawContentLength -le 0) {
+ throw "HTTP $($response.StatusCode), length=$($response.RawContentLength)"
+ }
+ Write-Host "voice smoke passed: $voiceKey ($($response.RawContentLength) bytes)"
+ } catch {
+ throw "voice smoke failed for $voiceKey. Do not ship this installer until chatlog can read WeChat voice media. $($_.Exception.Message)"
+ }
+}
+Copy-Dir (Join-Path $Root "lib") (Join-Path $Resources "lib")
+Copy-Dir (Join-Path $Frontend "dist") (Join-Path $Resources "frontend")
+Copy-Dir (Join-Path $Backend "dist\ChatLabBackend") (Join-Path $Resources "backend")
+Copy-Item -LiteralPath (Join-Path $Root "DISCLAIMER.md") -Destination (Join-Path $Resources "DISCLAIMER.md") -Force
+Copy-Item -LiteralPath (Join-Path $Root "LICENSE") -Destination (Join-Path $Resources "LICENSE") -Force
+
+$forbidden = Get-ChildItem -LiteralPath $Resources -Recurse -Force |
+ Where-Object { Test-ForbiddenReleaseFile $_ }
+
+if ($forbidden) {
+ $names = ($forbidden | Select-Object -ExpandProperty FullName) -join "`n"
+ throw "Sensitive or cache files found in release resources:`n$names"
+}
+
+Get-ChildItem -LiteralPath $Resources -Recurse -File |
+ Select-Object FullName, Length |
+ Out-File -Encoding UTF8 (Join-Path $Release "manifest.txt")
+
+if (-not $SkipInstaller) {
+ Push-Location $Electron
+ & npm.cmd run build
+ Pop-Location
+ Copy-Item -Path (Join-Path $Electron "dist\*.exe") -Destination $Release -Force
+ Copy-Item -Path (Join-Path $Electron "dist\*.blockmap") -Destination $Release -Force -ErrorAction SilentlyContinue
+
+ $releaseForbidden = Get-ChildItem -LiteralPath $Release -Recurse -Force |
+ Where-Object { Test-ForbiddenReleaseFile $_ }
+ if ($releaseForbidden) {
+ $names = ($releaseForbidden | Select-Object -ExpandProperty FullName) -join "`n"
+ throw "Sensitive certificate, private data, or cache files found in release output:`n$names"
+ }
+
+ $installer = Get-ChildItem -LiteralPath $Release -Filter "ChatLab-Setup-*.exe" -File |
+ Sort-Object LastWriteTime -Descending |
+ Select-Object -First 1
+ if ($installer) {
+ $signature = Get-AuthenticodeSignature -FilePath $installer.FullName
+ if ($shouldSign -or $ForceSign) {
+ if ($signature.Status -ne "Valid") {
+ throw "Installer signing verification failed: $($signature.Status) $($signature.StatusMessage)"
+ }
+ Write-Host "Installer signature verified: $($signature.SignerCertificate.Subject)"
+ } else {
+ Write-Host "Installer is unsigned by design for this build."
+ }
+ }
+}
+
+Write-Host "Desktop build completed. Output: $Release"
diff --git a/scripts/make-icon.cjs b/scripts/make-icon.cjs
new file mode 100644
index 0000000..9b2c7ed
--- /dev/null
+++ b/scripts/make-icon.cjs
@@ -0,0 +1,226 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const root = path.resolve(__dirname, "..");
+const sourceImagePath = path.join(root, "chatlab-web", "frontend", "public", "company-logo.jpg");
+const outputDir = path.join(root, "electron-launcher", "build");
+const outputIco = path.join(outputDir, "icon.ico");
+const outputPng = path.join(outputDir, "icon.png");
+const sizes = [16, 24, 32, 48, 64, 128, 256];
+const electronNodeModules = path.join(root, "electron-launcher", "node_modules");
+const electronUserData =
+ process.env.CHATLAB_ICON_USER_DATA || path.join(os.tmpdir(), "chatlab-icon-renderer-user-data");
+
+function electronApi() {
+ if (process.versions.electron) {
+ return require("electron");
+ }
+ return require(path.join(electronNodeModules, "electron"));
+}
+
+function resolveElectronBinary() {
+ return electronApi();
+}
+
+function writeIco(entries, destination) {
+ const headerSize = 6;
+ const entrySize = 16;
+ let offset = headerSize + entries.length * entrySize;
+ const header = Buffer.alloc(offset);
+
+ header.writeUInt16LE(0, 0);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(entries.length, 4);
+
+ for (const [index, entry] of entries.entries()) {
+ const pos = headerSize + index * entrySize;
+ header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos);
+ header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos + 1);
+ header.writeUInt8(0, pos + 2);
+ header.writeUInt8(0, pos + 3);
+ header.writeUInt16LE(1, pos + 4);
+ header.writeUInt16LE(32, pos + 6);
+ header.writeUInt32LE(entry.png.length, pos + 8);
+ header.writeUInt32LE(offset, pos + 12);
+ offset += entry.png.length;
+ }
+
+ fs.writeFileSync(destination, Buffer.concat([header, ...entries.map((entry) => entry.png)]));
+}
+
+async function renderSourcePng() {
+ const { app, BrowserWindow, nativeImage } = electronApi();
+ await app.whenReady();
+
+ const imageBytes = fs.readFileSync(sourceImagePath);
+ const dataUri = `data:image/jpeg;base64,${imageBytes.toString("base64")}`;
+ const html = `
+
+
+
+
+
+
+

+
+`;
+
+ const win = new BrowserWindow({
+ show: false,
+ width: 256,
+ height: 256,
+ transparent: true,
+ backgroundColor: "#00000000",
+ resizable: false,
+ webPreferences: {
+ backgroundThrottling: false,
+ contextIsolation: true,
+ offscreen: true,
+ sandbox: true,
+ },
+ });
+
+ await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
+ await win.webContents.executeJavaScript(`
+ new Promise((resolve, reject) => {
+ const image = document.querySelector("img");
+ if (!image) {
+ reject(new Error("Icon image element was not created"));
+ return;
+ }
+ if (image.complete && image.naturalWidth > 0) {
+ setTimeout(resolve, 80);
+ return;
+ }
+ image.onload = () => setTimeout(resolve, 80);
+ image.onerror = () => reject(new Error("Icon SVG failed to render"));
+ })
+ `);
+
+ const captured = await win.webContents.capturePage({ x: 0, y: 0, width: 256, height: 256 });
+ win.destroy();
+
+ const normalized = nativeImage
+ .createFromBuffer(captured.toPNG())
+ .resize({ width: 256, height: 256, quality: "best" });
+
+ return normalized.toPNG();
+}
+
+async function mainElectron() {
+ const { app } = electronApi();
+ app.disableHardwareAcceleration();
+ app.setPath("userData", electronUserData);
+ app.commandLine.appendSwitch("disable-gpu");
+ app.commandLine.appendSwitch("disable-gpu-compositing");
+ app.commandLine.appendSwitch("disable-software-rasterizer");
+ app.commandLine.appendSwitch("disk-cache-dir", path.join(electronUserData, "cache"));
+
+ if (!fs.existsSync(sourceImagePath)) {
+ throw new Error(`Missing icon source: ${sourceImagePath}`);
+ }
+
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ const { nativeImage } = electronApi();
+ const sourcePng = await renderSourcePng();
+ const sourceImage = nativeImage.createFromBuffer(sourcePng);
+ const pngEntries = sizes.map((size) => ({
+ size,
+ png: sourceImage.resize({ width: size, height: size, quality: "best" }).toPNG(),
+ }));
+
+ fs.writeFileSync(outputPng, sourcePng);
+ writeIco(pngEntries, outputIco);
+
+ console.log(`Generated ${path.relative(root, outputIco)} (${sizes.join(", ")} px)`);
+ console.log(`Generated ${path.relative(root, outputPng)} (256 px)`);
+ app.quit();
+}
+
+function mainNode() {
+ const rendererDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-renderer-"));
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-user-data-"));
+ fs.mkdirSync(rendererDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(rendererDir, "package.json"),
+ JSON.stringify({ name: "chatlab-icon-renderer", main: "main.js" }, null, 2),
+ );
+ fs.writeFileSync(
+ path.join(rendererDir, "main.js"),
+ `process.env.CHATLAB_ICON_RENDER = "1";\nrequire(${JSON.stringify(__filename)});\n`,
+ );
+
+ const electronBinary = resolveElectronBinary();
+ const env = { ...process.env };
+ delete env.ELECTRON_RUN_AS_NODE;
+ env.NODE_PATH = [electronNodeModules, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
+ env.ELECTRON_DISABLE_CRASHPAD = "1";
+ env.ELECTRON_ENABLE_LOGGING = "0";
+ env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
+ env.CHATLAB_ICON_USER_DATA = userDataDir;
+
+ const result = spawnSync(
+ electronBinary,
+ [
+ rendererDir,
+ "--disable-crash-reporter",
+ "--disable-gpu",
+ "--disable-gpu-compositing",
+ `--user-data-dir=${userDataDir}`,
+ `--disk-cache-dir=${path.join(userDataDir, "cache")}`,
+ ],
+ {
+ cwd: root,
+ stdio: "inherit",
+ windowsHide: true,
+ env,
+ },
+ );
+
+ fs.rmSync(rendererDir, { recursive: true, force: true });
+ fs.rmSync(userDataDir, { recursive: true, force: true });
+
+ if (result.error) {
+ throw result.error;
+ }
+ process.exit(result.status ?? 1);
+}
+
+if (process.versions.electron && process.env.CHATLAB_ICON_RENDER === "1") {
+ mainElectron().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ try {
+ electronApi().app.quit();
+ } catch {
+ process.exit(1);
+ }
+ });
+} else {
+ mainNode();
+}
diff --git a/售后开发底座二次开发交付手册.md b/售后开发底座二次开发交付手册.md
new file mode 100644
index 0000000..57d2c25
--- /dev/null
+++ b/售后开发底座二次开发交付手册.md
@@ -0,0 +1,1144 @@
+# 售后开发底座二次开发与交付手册
+
+> 内部资料,仅供公司开发、交付、售前技术同事使用。本文档用于帮助同事基于当前项目快速完成客户场景二次开发、打包交付和现场排错,不建议直接转发给客户。
+
+## 1. 文档定位
+
+本项目是一个面向售后、设备运维、技术支持团队的本地化开发底座,核心能力可以概括为:
+
+```text
+微信售后数据采集 + AI 话题分析 + 知识库沉淀 + Windows 桌面化交付
+```
+
+典型使用场景是:客户的售后工程师长期在微信群里处理设备故障、安装调试、备件更换、参数配置、软件升级等问题。项目通过读取本机 PC 微信聊天记录,把群消息、图片、语音、文件等内容接入到本地业务系统,再调用 AI 进行话题归类、问题总结和知识文档生成,最终形成可搜索、可编辑、可持续沉淀的售后知识库。
+
+这套底座适合用于以下客户项目:
+
+- 设备制造企业售后知识库
+- 工业现场运维问题沉淀
+- 客户服务群自动归档
+- 技术支持经验库
+- 多客户微信群统一管理
+- 企业内部故障案例库
+- 针对某个行业的垂直售后 Agent
+
+本文档面向两类同事:
+
+- 开发同事:理解目录结构、接口边界、数据流、二次开发位置、打包方式。
+- 交付同事:理解安装部署、AI 配置、客户现场验证、常见问题排查。
+
+## 2. 项目总览
+
+当前项目采用“Electron 桌面壳 + React 前端 + FastAPI 后端 + chatlog 微信数据服务”的组合架构。业务逻辑主要留在前端和 Python 后端,Electron 主要负责桌面运行、进程管理和安装包交付。
+
+### 2.1 核心目录
+
+| 模块 | 路径 | 技术栈 | 主要职责 |
+|---|---|---|---|
+| 桌面壳 | `electron-launcher/` | Electron | 启动窗口、启动本地服务、管理子进程、打包配置 |
+| 前端页面 | `chatlab-web/frontend/` | React + Vite | 聊天记录、AI 话题分析、知识库、设置页面 |
+| 业务后端 | `chatlog_fastAPI/` | Python + FastAPI | API 接口、AI 调用、SQLite 数据、定时任务、文件代理 |
+| 微信数据服务 | `chatlog.exe` | Windows 可执行程序 | 读取本机 PC 微信数据,提供 `127.0.0.1:5030` API |
+| 本地 DLL | `lib/windows_x64/wx_key.dll` | Windows DLL | 配合微信数据能力 |
+| 构建脚本 | `scripts/build-desktop.ps1` | PowerShell | 串联前端构建、后端打包、资源复制、安装包生成 |
+| 发布目录 | `release/` | 安装包输出 | 存放最终交付的 `ChatLab-Setup-*.exe` |
+
+### 2.2 技术架构
+
+```text
+用户打开桌面应用
+ |
+ v
+Electron 主进程
+ |
+ +--> 启动 chatlog.exe
+ | 默认端口: 127.0.0.1:5030
+ |
+ +--> 启动 FastAPI 后端
+ | 开发默认端口: 8000
+ | 桌面打包后由 Electron 动态分配本地端口
+ |
+ +--> 等待 /health 和 chatlog 就绪
+ |
+ v
+Electron 窗口加载业务页面
+ |
+ v
+React 前端调用 FastAPI
+ |
+ v
+FastAPI 调用 chatlog API / AI 模型 / SQLite 数据库
+```
+
+### 2.3 运行端口
+
+| 端口 | 服务 | 说明 |
+|---|---|---|
+| `5030` | `chatlog.exe` | 微信数据服务,提供聊天记录、群聊、图片、语音、文件等底层数据 |
+| `8000` | FastAPI | 开发环境常用后端端口,提供业务 API |
+| `5173` | Vite | 前端开发服务端口,仅开发环境使用 |
+
+桌面安装包运行时,用户通常不会看到 `5173`,因为生产环境下前端静态资源由 FastAPI 托管。Electron 会启动后端并加载后端地址。
+
+## 3. 环境要求
+
+### 3.1 基础环境
+
+| 软件 | 建议版本 | 说明 |
+|---|---|---|
+| Windows | Windows 10/11 64 位 | 当前项目优先面向 Windows 桌面交付 |
+| PC 微信 | v4 版本 | 需要先登录 PC 微信,系统才能读取本地聊天数据 |
+| Node.js | 20+ LTS | 用于前端开发和 Electron 打包 |
+| Python | 3.10+ | 用于运行 FastAPI 后端 |
+| Python 打包版本 | 3.12 | `scripts/build-desktop.ps1` 默认优先使用 `py -3.12` |
+
+### 3.2 首次依赖安装
+
+后端依赖:
+
+```powershell
+cd chatlog_fastAPI
+python -m pip install -r requirements.txt
+```
+
+前端依赖:
+
+```powershell
+cd chatlab-web\frontend
+npm install
+```
+
+桌面壳依赖:
+
+```powershell
+cd electron-launcher
+npm install
+```
+
+## 4. 本地开发启动
+
+开发时建议按下面顺序启动,方便定位问题。
+
+### 4.1 启动前检查
+
+1. 确认 PC 微信已经登录。
+2. 确认当前项目根目录存在 `chatlog.exe`。
+3. 确认 `lib/windows_x64/wx_key.dll` 存在。
+4. 确认 Node.js、Python 已安装并加入 PATH。
+5. 如果客户电脑安全软件拦截 `chatlog.exe`,需要先加入信任或用管理员权限运行。
+
+### 4.2 启动 chatlog 微信数据服务
+
+在项目根目录执行:
+
+```powershell
+.\chatlog.exe server
+```
+
+服务正常后,默认监听:
+
+```text
+http://127.0.0.1:5030
+```
+
+可以用以下方式检查版本:
+
+```powershell
+.\chatlog.exe version
+```
+
+### 4.3 启动 FastAPI 后端
+
+在项目根目录执行:
+
+```powershell
+cd chatlog_fastAPI
+python main.py
+```
+
+开发环境默认地址:
+
+```text
+http://127.0.0.1:8000
+```
+
+健康检查地址:
+
+```text
+http://127.0.0.1:8000/health
+```
+
+`/health` 返回中的关键字段:
+
+| 字段 | 说明 |
+|---|---|
+| `ok` | FastAPI 是否正常 |
+| `chatlog_ok` | FastAPI 是否能连通 `chatlog.exe` |
+| `chatlog_error` | chatlog 连接失败时的错误信息 |
+| `wxid` | 当前识别到的微信账号 |
+| `db_path` | 当前使用的知识库数据库路径 |
+| `data_dir` | 当前运行数据目录 |
+
+### 4.4 启动 React 前端
+
+在项目根目录执行:
+
+```powershell
+cd chatlab-web\frontend
+npm run dev
+```
+
+开发环境默认访问:
+
+```text
+http://localhost:5173
+```
+
+### 4.5 启动 Electron 桌面壳
+
+如果需要调试桌面壳:
+
+```powershell
+cd electron-launcher
+npm run start
+```
+
+Electron 的关键职责:
+
+- 打开启动页和业务窗口。
+- 启动 `chatlog.exe`。
+- 启动 FastAPI 后端。
+- 向后端注入运行目录、数据目录、后端端口等环境变量。
+- 关闭应用时清理子进程。
+- 打包后加载内置前端静态资源。
+
+## 5. 核心业务流程
+
+### 5.1 微信数据读取
+
+前端不直接访问微信数据文件,而是通过 FastAPI 访问业务接口。FastAPI 再根据场景调用 `chatlog.exe` 提供的 API,或通过代理接口把请求转发给 chatlog。
+
+```text
+React 页面
+ -> FastAPI 业务接口
+ -> chatlog API
+ -> 本机 PC 微信数据
+```
+
+这样做的好处:
+
+- 前端不需要知道 chatlog 的所有底层细节。
+- 后端可以统一做数据格式清洗、分页、媒体解析和异常处理。
+- 后续接入客户自己的数据源时,可以只改后端适配层。
+
+### 5.2 群聊和聊天记录检索
+
+相关接口族:
+
+| 接口 | 用途 |
+|---|---|
+| `/api/search/chatrooms` | 搜索或加载群聊列表 |
+| `/api/search/sessions` | 获取最近会话列表 |
+| `/api/search/members` | 获取某个群的成员列表 |
+| `/api/search` | 按群、时间、发送人、关键词查询聊天记录 |
+
+前端主要封装位置:
+
+```text
+chatlab-web/frontend/src/api/index.js
+```
+
+相关页面:
+
+```text
+chatlab-web/frontend/src/pages/ChatlogPage.jsx
+```
+
+### 5.3 AI 设置
+
+相关接口:
+
+| 接口 | 用途 |
+|---|---|
+| `GET /api/settings` | 读取当前 AI 配置 |
+| `PUT /api/settings` | 保存 AI 配置 |
+
+配置项包括:
+
+- AI 接口地址
+- AI API Key
+- 话题分析模型
+- 知识总结模型
+- 视觉模型
+- 语音模型
+
+相关页面:
+
+```text
+chatlab-web/frontend/src/pages/SettingsPage.jsx
+```
+
+注意事项:
+
+- 不要把 API Key 写死到前端或后端代码。
+- 不要把客户真实 Key 写进 `.env` 后再打包。
+- 交付前应使用客户现场提供的模型地址和 Key。
+
+### 5.4 话题分析
+
+相关接口族:
+
+| 接口 | 用途 |
+|---|---|
+| `/api/groups` | 管理需要分析的微信群 |
+| `/api/groups/{group_id}/init` | 初始化某个群的话题分析 |
+| `/api/groups/{group_id}/task` | 查询分析任务状态 |
+| `/api/topics` | 查询或创建话题 |
+| `/api/topics/{topic_id}` | 查看、修改、删除单个话题 |
+| `/api/topics/{topic_id}/messages` | 向话题添加消息 |
+| `/api/topics/{topic_id}/messages/{seq}` | 从话题移除消息 |
+| `/api/topics/{topic_id}/summarize` | 基于话题消息生成知识文档 |
+
+主要后端位置:
+
+```text
+chatlog_fastAPI/routers/groups.py
+chatlog_fastAPI/routers/topics.py
+chatlog_fastAPI/services/topic_engine.py
+chatlog_fastAPI/services/summary_engine.py
+```
+
+主要前端页面:
+
+```text
+chatlab-web/frontend/src/pages/TopicsPage.jsx
+```
+
+二开常见需求:
+
+- 修改话题分类提示词。
+- 增加某个行业的故障类型字段。
+- 针对不同客户群使用不同分析模板。
+- 把“售后问题”改成“设备巡检”“工单处理”“客户投诉”等业务口径。
+
+### 5.5 知识库
+
+相关接口族:
+
+| 接口 | 用途 |
+|---|---|
+| `GET /api/knowledge` | 查询知识文档列表,支持关键词 |
+| `GET /api/knowledge/{doc_id}` | 查看单篇知识文档 |
+| `PATCH /api/knowledge/{doc_id}` | 编辑并保存知识文档 |
+
+相关后端位置:
+
+```text
+chatlog_fastAPI/routers/knowledge.py
+chatlog_fastAPI/services/fts.py
+```
+
+相关前端页面:
+
+```text
+chatlab-web/frontend/src/pages/KnowledgePage.jsx
+```
+
+知识库使用 SQLite FTS 做全文检索。二开时如果要新增知识字段,应同时考虑:
+
+- 数据表是否需要新增字段。
+- 搜索索引是否需要同步更新。
+- 前端展示是否要增加筛选条件。
+- Word 导出模板是否要同步调整。
+
+### 5.6 文件、图片、语音、视频
+
+相关接口族:
+
+| 接口 | 用途 |
+|---|---|
+| `/api/files/{md5}` | 根据文件 MD5 获取附件 |
+| `/image/{path:path}` | 图片资源代理 |
+| `/voice/{path:path}` | 语音资源代理 |
+| `/video/{path:path}` | 视频资源代理 |
+| `/file/{path:path}` | 文件资源代理 |
+| `/data/{path:path}` | 数据资源代理 |
+
+相关后端位置:
+
+```text
+chatlog_fastAPI/routers/files.py
+chatlog_fastAPI/routers/chatlog_proxy.py
+chatlog_fastAPI/services/media_resolver.py
+chatlog_fastAPI/services/media_parser.py
+```
+
+前端消息展示组件:
+
+```text
+chatlab-web/frontend/src/components/MessageBubble.jsx
+```
+
+## 6. 接口清单
+
+下面是二开时最常用的接口族。新增能力时,优先复用这些接口边界,不要绕过 FastAPI 直接从前端访问底层数据。
+
+| 接口 | 方法 | 说明 |
+|---|---|---|
+| `/health` | GET | 后端健康检查,同时检查 chatlog 连接状态 |
+| `/api/system/refresh-account` | POST | 强制刷新当前微信账号并切换对应数据库 |
+| `/api/system/chatlog-context` | GET/POST | 读取或写入 chatlog 运行上下文 |
+| `/api/search` | GET | 聊天记录搜索 |
+| `/api/search/chatrooms` | GET | 群聊列表搜索 |
+| `/api/search/members` | GET | 群成员列表 |
+| `/api/search/sessions` | GET | 最近会话列表 |
+| `/api/groups` | GET/POST/PATCH/DELETE | 管理被分析的群 |
+| `/api/topics` | GET/POST/PATCH/DELETE | 管理 AI 识别的话题 |
+| `/api/knowledge` | GET/PATCH | 管理知识文档 |
+| `/api/settings` | GET/PUT | 管理 AI 配置 |
+| `/api/ai/parse` | POST | 解析单条消息中的图片、语音、文件等内容 |
+| `/api/ai/summarize/stream` | POST | 流式生成聊天摘要 |
+| `/api/sse/chatlog` | GET | 聊天记录 SSE 实时订阅 |
+| `/api/files/{md5}` | GET | 获取本地附件文件 |
+| `/api/v1/{path:path}` | 多方法 | chatlog API 代理接口 |
+
+## 7. 数据目录与数据库
+
+### 7.1 默认数据目录
+
+桌面应用运行时,用户数据默认存放在:
+
+```text
+%APPDATA%/ChatLab
+```
+
+后端配置来源:
+
+```text
+chatlog_fastAPI/config.py
+```
+
+关键环境变量:
+
+| 变量 | 说明 |
+|---|---|
+| `CHATLAB_DATA_DIR` | 指定运行数据目录 |
+| `CHATLAB_STATIC_DIR` | 指定前端静态资源目录 |
+| `CHATLAB_BACKEND_PORT` | Electron 启动后端时注入的端口 |
+
+### 7.2 微信账号数据库切换
+
+项目会尝试识别当前登录的微信账号 `wxid`,并为不同账号使用不同知识库数据库:
+
+```text
+knowledge.db
+knowledge_{wxid}.db
+```
+
+这样做可以避免同一台电脑上多个微信账号的数据互相污染。
+
+### 7.3 核心数据表
+
+核心表在 `chatlog_fastAPI/database.py` 的 `init_db()` 中初始化:
+
+| 表 | 用途 |
+|---|---|
+| `groups` | 已添加到系统中进行分析的微信群 |
+| `topics` | AI 或人工创建的话题 |
+| `topic_messages` | 话题和聊天消息的关联 |
+| `knowledge_docs` | 生成后的知识文档 |
+| `knowledge_fts` | 知识库全文检索索引 |
+| `ai_tasks` | AI 分析和总结任务状态 |
+| `app_settings` | AI 地址、模型、Key 等配置 |
+
+## 8. 二次开发指南
+
+### 8.1 开发原则
+
+二开时优先遵守以下原则:
+
+1. 业务入口放前端页面,核心逻辑放 FastAPI 后端。
+2. 前端统一通过 `src/api/index.js` 调用接口。
+3. 后端新增接口放到 `chatlog_fastAPI/routers/`。
+4. 可复用的业务逻辑放到 `chatlog_fastAPI/services/`。
+5. 数据结构变更写在 `database.py:init_db()` 中,并兼容老客户数据。
+6. AI 能力优先复用现有 `ai_client.py` 和设置页配置。
+7. 不要在代码中写死客户名称、API Key、证书密码、客户数据路径。
+8. 客户定制内容尽量集中在文案、提示词、模板、配置项,减少对底层架构的改动。
+
+### 8.2 新增前端页面
+
+适用场景:
+
+- 给客户新增一个“工单看板”。
+- 新增“设备型号分析”页面。
+- 新增“客户问题统计”页面。
+- 新增“知识导出”页面。
+
+推荐步骤:
+
+1. 在 `chatlab-web/frontend/src/pages/` 新建页面组件,例如:
+
+```text
+chatlab-web/frontend/src/pages/WorkOrderPage.jsx
+```
+
+2. 在 `chatlab-web/frontend/src/api/index.js` 中封装接口:
+
+```js
+export const getWorkOrders = (params) => api.get('/api/work-orders', { params })
+```
+
+3. 在 `chatlab-web/frontend/src/App.jsx` 中增加导航入口和页面渲染分支。
+4. 页面样式优先复用现有布局、按钮、表格、侧栏风格。
+5. 如果页面依赖后端新接口,先用 Mock 数据搭好界面,再接真实 API。
+
+注意:
+
+- 不建议在页面组件里散落大量 `fetch('/api/...')`。
+- 已有 API 封装在 `src/api/index.js`,新接口也应集中管理。
+- 新页面应考虑加载中、空数据、错误提示、重试按钮。
+
+### 8.3 新增后端接口
+
+适用场景:
+
+- 给前端新增数据查询接口。
+- 接入客户自己的系统数据。
+- 增加新的统计分析能力。
+- 增加新的导出能力。
+
+推荐步骤:
+
+1. 在 `chatlog_fastAPI/routers/` 新建路由文件,例如:
+
+```text
+chatlog_fastAPI/routers/work_orders.py
+```
+
+2. 定义 `APIRouter`:
+
+```python
+from fastapi import APIRouter
+
+router = APIRouter(prefix="/api/work-orders", tags=["work-orders"])
+```
+
+3. 把业务逻辑放入 `chatlog_fastAPI/services/`,例如:
+
+```text
+chatlog_fastAPI/services/work_order_service.py
+```
+
+4. 在 `chatlog_fastAPI/main.py` 注册新 router:
+
+```python
+from routers import work_orders
+
+app.include_router(work_orders.router)
+```
+
+5. 在前端 `src/api/index.js` 中封装调用。
+6. 在页面中接入接口并处理加载、失败、空数据状态。
+
+注意:
+
+- 接口路径统一使用 `/api/...`。
+- 需要访问 chatlog 数据时,优先复用已有 chatlog client 或代理逻辑。
+- 不要让前端直接访问 SQLite 文件。
+
+### 8.4 新增数据表或字段
+
+适用场景:
+
+- 给知识文档增加“设备型号”“客户名称”“故障等级”字段。
+- 增加工单表。
+- 增加客户项目配置表。
+- 增加统计缓存表。
+
+推荐步骤:
+
+1. 在 `chatlog_fastAPI/database.py` 的 `init_db()` 中增加 `CREATE TABLE IF NOT EXISTS`。
+2. 如果是给老表加字段,先通过 `PRAGMA table_info(...)` 检查字段是否存在。
+3. 字段不存在时执行 `ALTER TABLE ... ADD COLUMN ...`。
+4. 如果字段影响搜索,更新 FTS 写入逻辑。
+5. 如果字段影响前端展示,同步修改接口返回和页面展示。
+
+兼容原则:
+
+- 不要直接删除老字段。
+- 不要直接清空老数据。
+- 不要假设客户现场数据库一定是最新结构。
+- 数据迁移必须可重复执行,多次启动不应报错。
+
+### 8.5 新增 AI 能力
+
+适用场景:
+
+- 新增行业化话题分类。
+- 新增设备故障根因分析。
+- 新增日报、周报、月报。
+- 新增图片识别、语音识别、文件摘要。
+- 新增客户指定模板的知识文档生成。
+
+推荐位置:
+
+```text
+chatlog_fastAPI/services/ai_client.py
+chatlog_fastAPI/routers/ai.py
+chatlog_fastAPI/services/topic_engine.py
+chatlog_fastAPI/services/summary_engine.py
+chatlab-web/frontend/src/pages/SettingsPage.jsx
+```
+
+开发原则:
+
+- 模型地址、模型名、API Key 从设置页或后端配置读取。
+- Prompt 模板可以放在 service 层,避免散落在前端。
+- 客户行业术语、设备型号、输出格式应抽象为可配置文本。
+- 生成结果必须允许人工编辑,不能假设 AI 输出完全正确。
+- 流式输出接口适合长文档生成,普通接口适合短文本解析。
+
+### 8.6 修改知识文档模板
+
+如果客户要求固定格式,例如“故障现象、影响范围、排查步骤、根因、解决方案、预防措施”,应优先修改总结 Prompt 和前端展示,而不是把模板逻辑写死在多个地方。
+
+推荐字段:
+
+```text
+1. 问题标题
+2. 适用设备或系统
+3. 故障现象
+4. 影响范围
+5. 排查过程
+6. 根本原因
+7. 解决方案
+8. 预防建议
+9. 引用消息时间范围
+10. 相关附件或图片
+```
+
+相关位置:
+
+```text
+chatlog_fastAPI/services/summary_engine.py
+chatlab-web/frontend/src/components/ReportDocumentView.jsx
+chatlab-web/frontend/src/utils/wordExport.js
+```
+
+### 8.7 客户品牌和交付包定制
+
+常见定制项:
+
+| 定制项 | 位置 |
+|---|---|
+| 应用名称 | `electron-launcher/package.json`、`electron-launcher/electron-builder.config.cjs`、`electron-launcher/main.js` |
+| 窗口标题 | `electron-launcher/main.js` |
+| 图标 | `scripts/make-icon.cjs`、`electron-launcher/build/` |
+| 前端页面标题 | `chatlab-web/frontend/src/App.jsx` 或相关页面 |
+| 菜单名称 | `chatlab-web/frontend/src/App.jsx` |
+| 安装包名称 | `electron-launcher/electron-builder.config.cjs` |
+| 默认 AI 口径 | `chatlog_fastAPI/services/` 中相关 Prompt |
+
+定制时建议先确认:
+
+- 客户对外显示名称。
+- 安装包名称。
+- 是否需要客户 Logo。
+- 是否要隐藏“ChatLab”字样。
+- 是否需要增加免责声明或授权说明。
+
+## 9. 配置与数据安全
+
+### 9.1 不允许进入安装包的内容
+
+以下内容禁止进入 `release/` 交付包:
+
+- `.env`
+- `knowledge*.db`
+- 客户真实聊天记录
+- 客户真实 API Key
+- 代码签名证书
+- 证书密码
+- 私钥文件
+- `__pycache__`
+- 临时测试文件
+
+构建脚本 `scripts/build-desktop.ps1` 已经包含敏感文件扫描逻辑,二开时不得移除或绕过。
+
+### 9.2 API Key 管理
+
+AI API Key 应通过设置页写入系统配置。对外演示、客户现场部署和正式交付时,应由客户或项目负责人提供真实 Key。
+
+日志中不应输出完整 Key。Electron 主进程已有日志脱敏逻辑,二开新增日志时也要遵守同样原则。
+
+### 9.3 客户数据管理
+
+默认数据目录:
+
+```text
+%APPDATA%/ChatLab
+```
+
+交付时应告知客户:
+
+- 安装包升级不会自动删除本地数据。
+- 如果客户要迁移电脑,需要迁移对应 AppData 数据目录。
+- 如客户要求卸载时清理数据,需要单独提供清理方案。
+- 严禁把客户现场数据库复制回开发机作为测试数据,除非客户明确授权并完成脱敏。
+
+## 10. 桌面打包交付
+
+### 10.1 一键打包
+
+在项目根目录执行:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+脚本会依次完成:
+
+1. 生成或刷新 Electron 图标。
+2. 构建 React 前端。
+3. 用 PyInstaller 构建 Python 后端。
+4. 重置 `electron-launcher/build-resources`。
+5. 复制 `chatlog.exe`、`lib/`、前端 `dist/`、后端 `dist/ChatLabBackend/`、许可证文件。
+6. 扫描敏感文件,阻止 `.env`、数据库、证书、私钥、缓存进入发布资源。
+7. 生成 `release/manifest.txt`。
+8. 调用 `electron-builder` 生成 Windows 安装包。
+9. 把安装包和 blockmap 复制到 `release/`。
+10. 如果启用签名,校验安装包签名状态。
+
+### 10.2 常用跳过参数
+
+调试时可以跳过部分步骤:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipIcon
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipFrontend
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipBackend
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipInstaller
+```
+
+常见用途:
+
+| 参数 | 用途 |
+|---|---|
+| `-SkipIcon` | 图标没变时节省时间 |
+| `-SkipFrontend` | 前端已经构建过,只调试后端或安装包 |
+| `-SkipBackend` | 后端已经构建过,只调试前端或安装包 |
+| `-SkipInstaller` | 只生成资源,不生成最终安装包 |
+
+### 10.3 代码签名
+
+未签名安装包在客户电脑上可能触发 Windows SmartScreen 或安全软件提示。正式客户交付建议使用代码签名证书。
+
+通过命令传参:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 `
+ -Sign `
+ -CertificateFile "D:\certs\ChatLab-CodeSigning.pfx" `
+ -CertificatePassword "证书密码" `
+ -PublisherName "证书中的发布者名称" `
+ -ForceSign
+```
+
+通过环境变量:
+
+```powershell
+$env:CHATLAB_PFX_FILE = "D:\certs\ChatLab-CodeSigning.pfx"
+$env:CHATLAB_PFX_PASSWORD = "证书密码"
+$env:CHATLAB_CERT_PUBLISHER_NAME = "证书中的发布者名称"
+$env:CHATLAB_FORCE_SIGN = "1"
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+签名相关变量:
+
+| 变量 | 说明 |
+|---|---|
+| `CHATLAB_PFX_FILE` | PFX/P12 证书完整路径 |
+| `CHATLAB_PFX_PASSWORD` | 证书密码 |
+| `CHATLAB_CERT_PUBLISHER_NAME` | 可选,证书发布者名称 |
+| `CHATLAB_TIMESTAMP_SERVER` | 可选,默认时间戳服务器 |
+| `CHATLAB_FORCE_SIGN` | 设置为 `1` 时,签名失败会中断构建 |
+
+证书安全要求:
+
+- 证书文件必须放在项目目录外。
+- 不要把证书、私钥、密码提交到项目目录。
+- 不要把证书复制到 `electron-launcher/build-resources`。
+- 不要把证书路径写入对客户可见的文档。
+
+### 10.4 交付文件
+
+最终交付目录:
+
+```text
+release/
+```
+
+交付时一般只需要提供最新的:
+
+```text
+ChatLab-Setup-*.exe
+```
+
+如果客户需要升级包校验或自动更新能力,再根据实际方案提供 `.blockmap` 等配套文件。
+
+## 11. 客户项目落地流程
+
+### 11.1 需求确认
+
+二开前至少确认以下信息:
+
+| 项目 | 需要确认的问题 |
+|---|---|
+| 客户场景 | 是售后、运维、客服、巡检,还是其他业务 |
+| 微信群规模 | 需要分析几个群,每个群大概多少消息 |
+| 数据范围 | 是否只分析指定群,是否包含个人聊天 |
+| AI 部署 | 使用公网模型、客户内网模型,还是 AgentBox 本地模型 |
+| 模型能力 | 是否需要图片识别、语音识别、长文总结 |
+| 知识模板 | 客户希望沉淀成什么格式 |
+| 权限要求 | 谁能看知识库,是否需要账号系统 |
+| 隐私口径 | 是否允许读取 PC 微信数据,是否需要客户授权说明 |
+| 交付方式 | 安装包、本地部署、远程协助,还是现场部署 |
+| 验收标准 | 客户认为什么结果算成功 |
+
+### 11.2 二开实施顺序
+
+推荐实施顺序:
+
+1. 先跑通原始项目,确认 chatlog、FastAPI、前端、Electron 都正常。
+2. 修改客户可见的产品名称、页面文案、菜单名。
+3. 修改话题分析 Prompt 和知识文档模板。
+4. 根据客户需求新增页面或接口。
+5. 增加必要的数据表和迁移逻辑。
+6. 接入客户指定 AI 模型地址。
+7. 完成前端构建和桌面打包。
+8. 在测试机安装,跑完整验收流程。
+9. 输出安装包、部署说明、客户现场配置说明。
+
+### 11.3 现场部署步骤
+
+交付同事现场部署建议按下面顺序:
+
+1. 安装 Windows 桌面安装包。
+2. 登录 PC 微信。
+3. 启动桌面应用。
+4. 等待应用完成微信数据识别。
+5. 进入设置页配置 AI 地址、API Key、模型名称。
+6. 打开聊天记录页,确认群列表和消息可见。
+7. 进入 AI 话题分析页,添加一个目标群。
+8. 选择一个较小时间范围跑首次分析。
+9. 生成一篇知识文档。
+10. 进入知识库页搜索关键词。
+11. 重启应用,确认配置和知识库数据仍然存在。
+
+### 11.4 验收清单
+
+| 验收项 | 通过标准 |
+|---|---|
+| 应用能启动 | 桌面应用打开后无白屏、无异常退出 |
+| 微信数据可读 | 群列表可见,聊天记录可查询 |
+| chatlog 连接正常 | `/health` 中 `chatlog_ok=true` |
+| AI 配置可保存 | 设置页保存后刷新仍能看到配置 |
+| 话题分析可执行 | 添加群后可以生成话题列表 |
+| 知识文档可生成 | 对某个话题可生成结构化知识文档 |
+| 知识库可搜索 | 输入关键词能检索到相关文档 |
+| 数据可持久化 | 重启后群、话题、知识库仍存在 |
+| 安装包可复装 | 卸载重装或覆盖安装后应用可打开 |
+| 客户口径一致 | 页面名称、文档模板、提示词符合客户项目要求 |
+
+## 12. 常见问题排查
+
+### 12.1 群列表为空
+
+可能原因:
+
+- PC 微信没有登录。
+- `chatlog.exe` 没有启动。
+- `5030` 端口被占用或被拦截。
+- 微信版本不兼容。
+- 安全软件拦截了 `chatlog.exe` 或 DLL。
+
+排查步骤:
+
+1. 确认 PC 微信已登录。
+2. 在项目根目录执行 `.\chatlog.exe version`。
+3. 启动 `.\chatlog.exe server`。
+4. 访问或检查 `http://127.0.0.1:5030` 是否可用。
+5. 打开 FastAPI `/health`,查看 `chatlog_ok` 和 `chatlog_error`。
+6. 必要时用管理员身份启动应用。
+
+### 12.2 AI 无响应或分析失败
+
+可能原因:
+
+- 设置页没有配置 API Key。
+- AI 接口地址不正确。
+- 模型名称不正确。
+- 客户内网无法访问模型服务。
+- 模型不支持当前请求格式。
+- 长文本超出模型上下文限制。
+
+排查步骤:
+
+1. 进入设置页检查 AI 接口地址。
+2. 检查 API Key 是否为空。
+3. 检查话题分析模型、知识总结模型、视觉模型、语音模型名称。
+4. 用较小时间范围重新测试。
+5. 查看后端日志中的 AI 错误信息。
+6. 如果是客户内网模型,确认本机能访问模型服务地址。
+
+### 12.3 前端打不开或白屏
+
+开发环境排查:
+
+- 确认 `npm run dev` 正常运行。
+- 确认浏览器访问 `http://localhost:5173`。
+- 确认 FastAPI 正常运行。
+- 检查浏览器控制台报错。
+
+生产安装包排查:
+
+- 确认安装包内有前端静态资源。
+- 确认 `CHATLAB_STATIC_DIR` 指向正确资源目录。
+- 确认 Electron 能启动 FastAPI。
+- 查看 Electron 启动页日志。
+
+### 12.4 打包失败
+
+常见原因:
+
+- Node.js 未安装或版本过低。
+- Python 3.12 未安装。
+- PyInstaller 未安装。
+- 前端依赖缺失。
+- 后端依赖缺失。
+- `chatlog.exe` 不存在。
+- `lib/windows_x64/wx_key.dll` 不存在。
+- 敏感文件扫描发现 `.env`、数据库或证书文件。
+
+排查命令:
+
+```powershell
+node -v
+npm -v
+py -3.12 -V
+.\chatlog.exe version
+```
+
+重新安装依赖:
+
+```powershell
+cd chatlab-web\frontend
+npm install
+
+cd ..\..\chatlog_fastAPI
+python -m pip install -r requirements.txt
+```
+
+### 12.5 客户电脑提示不安全
+
+可能原因:
+
+- 安装包未签名。
+- 客户电脑安全策略严格。
+- Windows SmartScreen 对未知发布者提示风险。
+- 安全软件对读取微信数据的工具敏感。
+
+处理建议:
+
+- 正式交付使用代码签名证书。
+- 给客户 IT 提供发布者、安装包哈希、部署说明。
+- 必要时由客户 IT 加入白名单。
+- 对外说明系统为本地读取和本地分析,不主动发送微信消息。
+
+### 12.6 换微信账号后数据变化
+
+系统会自动识别当前微信账号,并切换到对应数据库。表现为:
+
+- A 账号生成的知识库在 A 账号数据库中。
+- B 账号登录后,会切换到 B 账号数据库。
+- 切回 A 账号后,A 账号数据仍可继续使用。
+
+如需强制刷新账号:
+
+```text
+POST /api/system/refresh-account
+```
+
+## 13. 开发交付检查清单
+
+### 13.1 开发完成前
+
+- 新增页面已接入导航。
+- 新增接口已在 `main.py` 注册。
+- 前端 API 已集中封装到 `src/api/index.js`。
+- 数据库变更已写兼容迁移。
+- AI Prompt 已按客户场景调整。
+- 页面空状态、加载状态、错误状态完整。
+- 未写死客户 API Key。
+- 未把客户数据放入项目目录。
+- 未破坏原有聊天记录、话题分析、知识库功能。
+
+### 13.2 打包前
+
+- `chatlog.exe` 存在且版本检查通过。
+- `lib/windows_x64/wx_key.dll` 存在。
+- 前端 `npm run build` 成功。
+- 后端 PyInstaller 构建成功。
+- `release/manifest.txt` 已生成。
+- `release/` 中无 `.env`、`knowledge*.db`、证书、私钥。
+- 安装包名称、图标、产品名符合客户项目。
+
+### 13.3 交付前
+
+- 在干净测试机安装成功。
+- PC 微信登录后群列表正常。
+- AI 设置可保存。
+- 能完成一次话题分析。
+- 能生成一篇知识文档。
+- 能搜索知识库。
+- 应用重启后数据不丢。
+- 客户部署说明已准备。
+- 客户隐私和数据使用口径已确认。
+
+## 14. 推荐二开场景示例
+
+### 14.1 设备售后知识库
+
+目标:
+
+- 自动整理客户微信群中的设备故障处理过程。
+- 生成标准故障案例。
+- 支持按设备型号、故障现象、备件名称搜索。
+
+重点改造:
+
+- 修改话题分析 Prompt,让 AI 关注设备型号、故障现象、处理结论。
+- 修改知识文档模板,增加“设备型号”“故障等级”“备件清单”。
+- 在知识库页面增加设备型号筛选。
+
+### 14.2 运维日报生成
+
+目标:
+
+- 每天自动总结微信群里的运维事项。
+- 输出今日问题、处理中事项、已解决事项、风险提醒。
+
+重点改造:
+
+- 新增日报页面。
+- 新增后端日报接口。
+- 复用 `/api/search` 拉取当天消息。
+- 调用 AI 生成日报。
+- 可选增加 Word 导出。
+
+### 14.3 客服投诉归档
+
+目标:
+
+- 从客户服务群中识别投诉、建议、售后问题。
+- 按客户、产品线、严重程度归档。
+
+重点改造:
+
+- 修改话题分类规则。
+- 新增投诉等级字段。
+- 新增统计看板。
+- 知识库模板改成“投诉原因、处理过程、补偿方案、复盘建议”。
+
+## 15. 重要注意事项
+
+1. 本系统依赖 PC 微信本地数据读取能力,部署前必须确认客户允许该类本地工具运行。
+2. 本系统默认只做读取、分析和归档,不应主动向客户微信群发送消息。
+3. AI 生成内容必须允许人工编辑和复核,不能直接作为最终技术结论。
+4. 二开时优先控制改动范围,避免同时大改前端、后端、Electron 和打包脚本。
+5. 客户现场问题优先看 `/health`、Electron 启动日志、FastAPI 日志、AI 设置页。
+6. 打包脚本中的敏感文件扫描是交付安全底线,不要删除。
+7. 如果客户需要私有化模型,应先确认模型是否兼容 OpenAI 格式接口。
+8. 如果客户群消息量很大,首次分析建议限制时间范围,先小批量验收。
+
+## 16. 快速命令索引
+
+项目根目录:
+
+```powershell
+C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me
+```
+
+检查 chatlog 版本:
+
+```powershell
+.\chatlog.exe version
+```
+
+安装后端依赖:
+
+```powershell
+cd chatlog_fastAPI
+python -m pip install -r requirements.txt
+```
+
+启动后端:
+
+```powershell
+cd chatlog_fastAPI
+python main.py
+```
+
+安装前端依赖:
+
+```powershell
+cd chatlab-web\frontend
+npm install
+```
+
+启动前端:
+
+```powershell
+cd chatlab-web\frontend
+npm run dev
+```
+
+构建前端:
+
+```powershell
+cd chatlab-web\frontend
+npm run build
+```
+
+启动桌面壳:
+
+```powershell
+cd electron-launcher
+npm run start
+```
+
+一键打包:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+只构建资源不生成安装包:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipInstaller
+```
+
+## 17. 给同事的建议
+
+拿到这个项目后,不建议一开始就改打包脚本或 Electron 主进程。更稳妥的顺序是:
+
+1. 先完整跑通原项目。
+2. 再改前端文案和页面。
+3. 再改后端 Prompt 和接口。
+4. 再做数据库兼容变更。
+5. 最后做品牌、图标、安装包名称和签名。
+
+如果客户需求只是“换一个行业场景”,大多数情况下优先改 Prompt、知识文档模板、页面文案和少量字段即可,不需要重构整体架构。
diff --git a/售后文档.md b/售后文档.md
new file mode 100644
index 0000000..5d302a6
--- /dev/null
+++ b/售后文档.md
@@ -0,0 +1,264 @@
+# ChatLab 售后智能助手 — 项目说明文档
+
+> 内部培训文档 · 销售团队专用 · 请勿对外传阅
+
+---
+
+## 一、这个产品是做什么的
+
+简单说:**把微信群里的售后聊天记录,自动整理成可以搜索的知识库。**
+
+售后工程师每天在微信群里处理客户问题,这些经验都散落在聊天记录里,时间一长就找不到了。这套系统会自动监控指定的售后群,AI 自动识别每条消息在讨论什么问题,把整个排查过程整理成一份标准文档存起来。下次遇到同样问题,搜一下就能找到历史案例。
+
+**对用户来说,日常使用微信完全不受影响,系统在后台静默运行。**
+
+---
+
+## 二、系统由哪几部分组成
+
+整套系统安装在一台电脑上,全程在本地运行。
+
+| 组成部分 | 作用 | 用户能看到吗 |
+|---------|------|------------|
+| chatlog 工具 | 读取本机微信的聊天记录 | 有个小窗口,不用管它 |
+| 后台服务 | 处理数据、调用 AI、存储知识库 | 看不到,在后台运行 |
+| 网页界面 | 用户操作的界面,在浏览器里打开 | 是的,这是主要操作界面 |
+
+---
+
+## 三、怎么启动系统
+
+确保微信 PC 端已登录后,双击 `无痕启动控制台.vbs`,依次启动底层服务 (Go)、业务层 (Python)、UI界面 (React),然后点击进入系统界面,等待浏览器自动打开界面即可。
+
+**如何判断是否启动成功:**
+界面左下角显示 `chatlog API: 127.0.0.1:5030`,左侧出现微信群列表,说明一切正常。
+
+---
+
+## 四、首次使用:配置 AI
+
+第一次使用需要填写 AI 的配置信息,之后不用再改。
+
+点击左侧导航栏最下方的「**设置**」,填写以下内容后点击保存:
+
+| 需要填写的内容 | 是什么 |
+|--------------|--------|
+| AI 接口地址 | AI 服务的网址,由技术人员提供 |
+| AI API Key | AI 服务的密钥,由技术人员提供 |
+| 话题分析模型 | 用来分析聊天内容的 AI 模型名称 |
+| 知识总结模型 | 用来生成知识文档的 AI 模型名称 |
+| 视觉模型 | 用来识别图片内容的 AI 模型名称 |
+| 语音模型 | 用来把语音消息转成文字的 AI 模型名称 |
+
+> 保存后 API Key 会显示成 `sk-***xxxx` 这样的格式,这是正常的安全处理,不是出错了。
+
+---
+
+## 五、日常使用步骤
+
+### 5.1 查看聊天记录
+
+点击左侧导航「**聊天记录**」。
+
+左侧会显示所有微信群和联系人的列表,按最新消息时间排序。点击任意一个群,右侧就会显示该群的聊天记录。
+
+**筛选消息:**
+- 点击「今天」「昨天」「近7天」「近30天」快速切换时间范围
+- 也可以手动输入开始和结束时间
+- 可以按发送人筛选(只看某个成员发的消息)
+- 可以输入关键词搜索消息内容
+- 设置好条件后点击「**查询**」按钮
+
+**查看更早的消息:**
+在消息区域向上滚动到顶部,会自动加载更早的消息。
+
+**实时接收新消息:**
+点击底部的「**连接 Webhook**」按钮,系统会每隔几秒自动刷新,新消息会实时出现在界面上并短暂高亮显示。
+
+**让 AI 快速总结当前消息:**
+查询到消息后,点击右上角「**AI 总结**」按钮,AI 会把当前显示的消息(包括图片内容、语音内容)整理成一份摘要,告诉你这段时间主要讨论了什么、有哪些待办事项。
+
+---
+
+### 5.2 AI 话题分析(最核心的功能)
+
+点击左侧导航「**AI 话题分析**」。
+
+这个功能会让 AI 自动分析一个群的所有聊天记录,把消息按话题归类,比如"A 客户电机故障"、"B 客户安装问题"、"日常交流"等,然后为每个话题生成一份知识文档。
+
+**第一步:添加要分析的群**
+
+1. 点击左栏上方的「**+ 添加**」按钮
+2. 在搜索框里输入群名,从下拉列表里选择目标群
+3. 点击「**确认**」
+
+**第二步:启动 AI 分析**
+
+1. 在左栏点击刚添加的群
+2. 点击「**AI 分析**」按钮
+3. 系统开始处理,进度条会显示当前进度
+4. 分析完成后,中间一栏会出现 AI 提取出的所有话题
+
+> 消息越多,分析时间越长。200条消息大约需要 1 到 2 分钟,请耐心等待,不要关闭页面。
+
+**第三步:生成知识文档**
+
+1. 在中间栏点击任意一个话题
+2. 右侧会显示该话题下的所有相关消息
+3. 点击右上角「**AI 生成知识文档**」按钮
+4. AI 会把这些消息整理成一份标准文档,包含:故障现象、排查过程、解决方案等
+5. 生成完成后,可以在「知识库」页面查看
+
+**手动调整话题消息(可选):**
+如果觉得某条消息被 AI 归错了话题,点击「**管理消息**」可以手动添加或移除消息。
+
+**自动更新:**
+添加完群并完成首次分析后,系统会每隔 5 分钟自动检查有没有新消息,有的话自动分类并更新知识文档,不需要手动操作。
+
+---
+
+### 5.3 知识库
+
+点击左侧导航「**知识库**」。
+
+这里存放了所有 AI 生成的知识文档,按群聊分组显示。
+
+**搜索:** 在顶部搜索框输入关键词(比如"电机过热"或某个型号),点击搜索按钮,系统会找出所有相关文档。
+
+**查看:** 点击左侧任意文档标题,右侧显示完整内容。
+
+**编辑:** 点击右上角「**编辑**」按钮可以修改文档内容,修改完点「**保存**」即可。AI 生成的内容如果有不准确的地方,可以在这里人工修正。
+
+---
+
+### 5.4 换了微信账号怎么办
+
+系统会自动识别当前登录的微信账号。如果换了账号,系统会自动切换到新账号对应的数据库,旧账号的数据不会丢失,下次切回来还能看到。
+
+---
+
+## 六、业务场景与适用范围
+
+### 6.1 最适合的场景
+
+**设备售后服务团队**
+工程师通过微信群与客户沟通故障排查,群里积累了大量宝贵的处理经验,但这些经验分散在聊天记录里,新员工无法快速学习,老员工遇到类似问题也要重新摸索。这套系统专门解决这个痛点。
+
+**多群并行管理的售后负责人**
+同时管理多个客户群、多个产品线群的售后主管,可以用这套系统统一监控所有群的动态,快速了解各群当前在处理什么问题,不需要逐一翻看聊天记录。
+
+**需要建立标准化知识库的企业**
+希望把售后经验沉淀成可复用的知识资产,减少对个人经验的依赖,提升整体售后服务水平的企业。
+
+### 6.2 解决的核心痛点
+
+| 痛点 | 现状 | 用了这套系统之后 |
+|------|------|----------------|
+| 经验难以沉淀 | 处理完就忘,下次遇到同样问题还要重新摸索 | 每个案例自动生成文档,永久保存 |
+| 历史案例难查找 | 要翻几个月前的聊天记录,费时费力 | 输入关键词秒级检索 |
+| 新员工上手慢 | 只能靠老员工口传,培训成本高 | 直接查知识库,自学成本低 |
+| 信息分散在多个群 | 要同时盯多个群,容易遗漏 | 统一界面管理所有群,一目了然 |
+| 图片语音信息丢失 | 文字以外的信息无法整理 | 图片自动识别内容,语音自动转文字 |
+
+### 6.3 不适合的场景
+
+- 只用手机微信、没有 PC 端微信的用户(系统需要读取 PC 端数据)
+- 需要主动向客户发消息的场景(这套系统只做记录分析,不做消息发送)
+- 对实时性要求极高的场景(新消息最快 5 分钟后才会被分析归档)
+
+---
+
+## 七、销售话术
+
+### 7.1 一句话介绍
+
+> "这是我们基于 AgentBox 平台为您定制的售后智能助手,微信群里的售后沟通记录会自动整理成知识库,AI 帮您把每个故障案例都沉淀下来,以后遇到同类问题直接搜索就能找到解决方案。"
+
+---
+
+### 7.2 技术架构话术(对外统一口径)
+
+**关于 AI 模型:**
+> "我们用的是 AgentBox 本地部署的千问大模型,所有 AI 推理都在您的内网完成,不会把数据发到外部云端。"
+
+**关于数据存储:**
+> "微信的聊天记录和生成的知识文档全部存在 AgentBox 的本地数据库里,数据完全在您的内网,外部访问不到。"
+
+**关于系统部署:**
+> "整套系统部署在本地,员工个人使用的话直接双击 exe 就能打开,不需要安装任何东西。如果是企业微信场景,可以把这个 Agent 部署到 AgentBox 系统里,通过 API 与技术团队和管理层对接,微信作为统一的信息入口。"
+
+**关于数据隐私:**
+> "因为是全本地化部署,底层的 AI 模型和数据库外界完全看不到,操作路径也可以根据您的需求定制,不需要经过任何中间层。"
+
+---
+
+### 7.3 功能亮点话术
+
+**自动化程度:**
+> "系统完全自动运行,不需要人工干预。工程师在微信群里正常沟通,系统在后台自动分析、自动归档,每隔几分钟就会把新的消息更新到知识库里。"
+
+**知识沉淀:**
+> "每一个故障案例都会生成一份标准文档,包括设备型号、故障现象、排查步骤、根本原因、解决方案,格式统一,方便新员工快速上手,也方便老员工查历史案例。"
+
+**检索能力:**
+> "知识库支持中文全文检索,输入关键词比如'电机过热'或者某个型号,马上就能找到所有相关的历史案例,不用一条一条翻聊天记录。"
+
+**多群管理:**
+> "可以同时监控多个售后群,不同产品线、不同区域的群都可以分开管理,知识库也按群分类展示,互不干扰。"
+
+---
+
+### 7.4 常见客户问题应答
+
+**Q:这个系统安全吗?我们的客户数据会不会泄露?**
+> A:"完全不会。整套系统是本地部署的,AI 模型也是在agentbox部署的内网服务器上运行的千问模型,聊天记录和知识文档都存在本地数据库里,不会上传到任何外部服务器。"
+
+**Q:需要在员工手机上安装什么吗?**
+> A:"不需要。员工正常用微信就行,系统在后台自动读取 PC 端微信的数据,员工完全无感知,不影响正常工作流程。"
+
+**Q:如果员工换了微信账号怎么办?**
+> A:"系统会自动检测当前登录的微信账号,账号切换后自动切换到对应的数据库,不同账号的数据独立存储,互不影响。"
+
+**Q:这个和你们的 AgentBox 平台是什么关系?**
+> A:"这是基于 AgentBox 平台能力实现的一个售后场景应用。AgentBox 提供了本地化的 AI 模型和数据库能力,我们在上面构建了专门针对售后场景的业务逻辑,包括微信数据采集、话题分类、知识文档生成这些功能。"
+
+**Q:系统能处理图片和语音消息吗?**
+> A:"可以。图片和视频截图会通过视觉 AI 模型自动识别内容,语音消息会自动转成文字,这些内容都会被纳入 AI 分析和知识文档生成,不会遗漏任何信息。"
+
+**Q:知识文档生成的准确度怎么样?**
+> A:"AI 生成的文档会尽量提取聊天记录中的关键信息,但我们也提供了人工编辑功能,工程师可以在 AI 生成的基础上补充或修正,确保文档的准确性。"
+
+**Q:系统对电脑配置有什么要求?**
+> A:"要求不高,普通办公电脑就能运行。主要是需要保持微信 PC 端登录状态,系统才能持续采集数据。"
+
+---
+
+### 7.5 与 AgentBox 平台关系的内部说明
+
+> **仅供内部了解,对外不说。**
+
+这套售后系统是独立开发的,目前没有直接调用 AgentBox 平台的 API。后续会将接入agentbox的部署的模型,也会将微信的数据对接上平台的数据库
+
+对外的口径统一为:
+- AI 模型 = AgentBox 本地部署的千问模型
+- 数据库 = AgentBox 数据库
+- 系统 = AgentBox 的一个售后场景 Agent
+
+这个口径在技术上是可以实现的(将来可以真正接入 AgentBox),目前作为销售阶段的统一表述。
+
+---
+
+## 八、常见问题排查
+
+| 问题现象 | 解决方法 |
+|---------|---------|
+| 左侧会话列表为空,提示"无法连接服务" | 重新双击 `无痕启动控制台.vbs` 启动系统 |
+| 界面打不开或一直转圈 | 重新双击 `无痕启动控制台.vbs` 启动系统 |
+| 点击「AI 分析」没有反应,提示需要配置 | 进入「设置」页面填写 AI API Key 和模型名称 |
+| 话题分析一直转圈超过 10 分钟 | 检查「设置」中的模型名称是否填写正确 |
+| 知识文档生成失败 | 检查「设置」中的知识总结模型名称 |
+| 语音消息显示无法识别 | 在「设置」中填写语音模型名称(如 paraformer-v2) |
+| 图片消息显示无法识别 | 在「设置」中填写视觉模型名称(如 qwen-vl-plus) |
+
+---
+
diff --git a/微信售后群话题分类与报告确认方案.md b/微信售后群话题分类与报告确认方案.md
new file mode 100644
index 0000000..a9bda1d
--- /dev/null
+++ b/微信售后群话题分类与报告确认方案.md
@@ -0,0 +1,64 @@
+# 微信售后群话题分类与售后报告确认方案
+
+## 一、当前阶段要确认什么
+
+当前阶段先确认“微信群售后问题自动归类 + 售后事件报告生成”是否满足客户演示和试用要求。
+
+系统可以接入指定售后微信群,自动读取群聊记录,把同一个客户、同一个订单、同一个产品或同一个故障现象相关的多条消息归为一个售后事件话题,并为每个话题生成一份售后报告。
+
+销售跟单、合同、生产、物流、价格等数据联动属于后续二期能力。当前一期报告中会预留这些字段,但未接入销售跟单系统前,只从微信群聊天记录中提取,不自动查询外部数据。
+
+## 二、一期交付范围
+
+- 接入一个或多个微信售后群,按群独立管理。
+- 自动抓取群聊中的售后问题信息,包括文字、图片描述、语音转文字后的内容。
+- AI 按“完整售后事件”分话题,不按单条消息、聊天阶段或零散关键词拆分。
+- 每个售后事件可以生成一份 Markdown 售后报告。
+- 用户可以人工调整话题里的消息,也可以编辑 AI 生成的报告内容。
+
+## 三、话题分类口径
+
+一个话题代表一个完整售后事件。系统会优先识别以下线索:
+
+- 客户名称、地区、门店、联系人。
+- 合同编号、订单号、物流单号、送货日期、到货日期。
+- 产品、设备、部件、零件名称。
+- 损坏、故障、安装异常、客户反馈等问题现象。
+- 现场照片、语音说明、原因描述、处理过程和处理结论。
+
+同一个客户问题的报修、补充照片、语音说明、原因判断、处理结果会归入同一个话题。闲聊、问候、简单确认、无明确问题的通知不会单独生成大量话题。
+
+售后问题的大类归因暂时按原则处理,不写死客户尚未确认的分类名称。后续客户确认业务口径后,可以固化为客户自己的分类体系,例如质量/生产、物流运输、现场安装、客户使用或人为损坏、其他待确认等。
+
+## 四、售后报告内容
+
+每个话题可以生成一份售后事件报告,建议包含:
+
+- 售后事件概览:客户/门店、地区、联系人、合同编号、订单号、物流信息、送货/到货日期、处理状态。
+- 问题概述:涉及产品/部件、问题现象、客户描述。
+- 现场材料摘要:图片、视频、语音、附件和补充说明。
+- 初步归因:按客户业务分类原则归因,无法判断时标记为待人工确认。
+- 沟通与处理过程:按时间整理群聊中的处理动作和结果。
+- 处理结果:当前结论、已采取措施、待跟进事项。
+- 后续建议:建议处理方式、需要人工确认的信息、可沉淀为知识库的经验。
+- 二期联动预留:客户名称、合同编号、订单号、物流单号、送货日期等可用于后续对接销售跟单。
+
+## 五、二期销售跟单联动说明
+
+如果客户后续提供销售跟单系统的数据接口、数据库表、Excel 导入模板,或 AgentBox 数字员工可查询的数据集,系统可以继续扩展为:
+
+- 通过客户名称、合同编号、订单号、物流单号自动查询销售跟单数据。
+- 自动把合同、生产、材料、物流、价格、送货和到货信息带入售后报告。
+- 形成从销售、生产、物流到售后的完整闭环追溯。
+
+这部分不是一期下周演示范围,但技术路径可以预留。
+
+## 六、下周演示建议
+
+- 选择公司内部 2 个真实售后群作为测试对象。
+- 每个群选取一段包含售后问题的聊天记录。
+- 展示 AI 自动分出来的售后事件话题。
+- 点击话题查看相关聊天依据。
+- 生成一份售后事件报告。
+- 演示人工添加/移除消息后,重新生成报告。
+
diff --git a/无痕启动控制台.vbs b/无痕启动控制台.vbs
new file mode 100644
index 0000000..b5e97f3
--- /dev/null
+++ b/无痕启动控制台.vbs
@@ -0,0 +1,5 @@
+Dim WshShell
+Set WshShell = CreateObject("WScript.Shell")
+WshShell.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName) & "\electron-launcher"
+WshShell.Run "cmd /c npx electron .", 0, False
+Set WshShell = Nothing
\ No newline at end of file
diff --git a/桌面应用打包技术方案.md b/桌面应用打包技术方案.md
new file mode 100644
index 0000000..ac42838
--- /dev/null
+++ b/桌面应用打包技术方案.md
@@ -0,0 +1,1481 @@
+# ChatLab 桌面应用打包技术方案
+
+本文档总结当前项目的桌面化打包方式,并抽象成一套后续新项目可以复用的技术方案。当前项目采用的是“Web 应用 + 本地后端服务 + Electron 外壳 + 安装包生成器”的路线:业务仍按前后端分离开发,发布时把前端静态资源、Python 后端可执行文件、第三方命令行工具和运行时 DLL 一起放入 Electron 安装包中,最终交付一个 Windows 桌面应用安装程序。
+
+## 1. 当前项目概览
+
+### 1.1 项目定位
+
+当前项目是一个本地化运行的 ChatLab 售后智能助手,主要能力是读取本机 PC 微信聊天记录,结合 AI 对售后群消息进行检索、话题分析、知识文档生成和知识库管理。
+
+系统运行在客户本机,核心数据不需要上传到外部服务器。用户看到的是一个桌面应用窗口,底层实际由多个本地服务协同完成:
+
+| 模块 | 路径 | 技术栈 | 作用 |
+|---|---|---|---|
+| 桌面壳 | `electron-launcher/` | Electron | 提供桌面窗口、启动页、进程管理、IPC 通信、安装包配置 |
+| 前端页面 | `chatlab-web/frontend/` | React + Vite | 提供聊天记录、AI 分析、知识库、设置等界面 |
+| 业务后端 | `chatlog_fastAPI/` | Python + FastAPI | 统一业务接口、AI 调用、数据库、任务调度、静态资源托管 |
+| 微信数据服务 | `chatlog.exe` | Go 可执行文件 | 读取本机微信数据,提供 `127.0.0.1:5030` 的 chatlog API |
+| 本地 DLL | `lib/windows_x64/wx_key.dll` | Windows DLL | 配合微信数据密钥识别 |
+| 构建脚本 | `scripts/build-desktop.ps1` | PowerShell | 串联图标、前端、后端、资源复制、安装包构建和签名校验 |
+
+### 1.2 当前桌面化结果
+
+当前项目已经生成 Windows 安装包,输出目录为:
+
+```text
+release/
+```
+
+当前目录下可见的安装包包括:
+
+```text
+release/ChatLab-Setup-1.0.0.exe
+release/ChatLab-Setup-1.0.1-202605210454.exe
+```
+
+安装包由 `electron-builder` 生成,Windows 安装器类型为 NSIS。安装后用户可以通过桌面快捷方式或开始菜单启动 `ChatLab售后智能助手`。
+
+## 2. 当前项目使用的打包方法
+
+### 2.1 总体思路
+
+当前项目没有把 Web 应用重写成原生桌面应用,而是保留原来的 Web 架构:
+
+1. React 前端继续用 Vite 构建成静态资源。
+2. FastAPI 后端继续提供 HTTP API,并在生产环境托管前端静态文件。
+3. `chatlog.exe` 作为本地外部二进制程序,由 Electron 主进程启动和管理。
+4. Electron 只负责桌面窗口、启动控制、子进程生命周期、资源路径适配和安装包能力。
+5. PyInstaller 把 Python 后端打成独立的 Windows 可执行目录。
+6. electron-builder 把 Electron 主程序、前端构建产物、后端可执行目录、`chatlog.exe`、DLL 和许可文件一起封装为安装包。
+
+可以把它理解成:
+
+```text
+用户双击桌面应用
+ |
+ v
+Electron 启动
+ |
+ +--> 启动 FastAPI 后端,随机选择本地端口
+ |
+ +--> 启动 chatlog.exe,固定提供 127.0.0.1:5030
+ |
+ +--> 等待 /health 和 chatlog API 就绪
+ |
+ v
+Electron 窗口加载 FastAPI 地址
+ |
+ v
+FastAPI 返回 React 静态页面,页面再调用本地 API
+```
+
+### 2.2 关键设计点
+
+#### 2.2.1 Electron 不是业务后端,只是桌面运行容器
+
+`electron-launcher/main.js` 中的主进程负责:
+
+- 创建桌面窗口。
+- 根据 `app.isPackaged` 区分开发环境和打包环境。
+- 在开发环境从源码目录启动 Python 后端。
+- 在打包环境从 `resources/backend/ChatLabBackend.exe` 启动后端。
+- 启动 `chatlog.exe` 并传入微信账号、密钥、工作目录等参数。
+- 给 FastAPI 注入 `CHATLAB_DATA_DIR`、`CHATLAB_STATIC_DIR`、`CHATLAB_BACKEND_PORT` 等环境变量。
+- 等待后端 `/health` 和 chatlog 服务就绪。
+- 加载后端地址作为最终业务界面。
+- 应用关闭时清理 FastAPI 和 chatlog 子进程。
+
+这种方式的好处是:Electron 不承载复杂业务逻辑,业务逻辑仍然留在原来的 Python 后端和 React 前端里,后续维护成本低。
+
+#### 2.2.2 前端打成静态文件,由 FastAPI 托管
+
+开发时,前端通过 Vite 开发服务器运行:
+
+```text
+chatlab-web/frontend/
+```
+
+构建时执行:
+
+```powershell
+npm run build
+```
+
+产物位于:
+
+```text
+chatlab-web/frontend/dist/
+```
+
+打包脚本会把这个目录复制到:
+
+```text
+electron-launcher/build-resources/frontend/
+```
+
+Electron 安装包内的资源结构最终类似:
+
+```text
+resources/
+ frontend/
+ index.html
+ assets/
+ index-xxxx.js
+ index-xxxx.css
+```
+
+FastAPI 通过 `CHATLAB_STATIC_DIR` 知道前端静态资源路径,并在 `main.py` 中挂载:
+
+- `/assets`
+- `/favicon.svg`
+- `/icons.svg`
+- `/`
+- `/{full_path:path}` SPA fallback
+
+所以生产环境下不再单独启动 Vite,也没有 `5173` 前端开发端口。用户访问到的是 FastAPI 托管出来的 React 页面。
+
+#### 2.2.3 Python 后端用 PyInstaller 打成 onedir
+
+后端入口是:
+
+```text
+chatlog_fastAPI/run_backend.py
+```
+
+这个入口读取环境变量 `CHATLAB_BACKEND_PORT`,然后启动 Uvicorn:
+
+```python
+uvicorn.run(app, host="127.0.0.1", port=port, reload=False, log_level="info")
+```
+
+PyInstaller 配置文件是:
+
+```text
+chatlog_fastAPI/ChatLabBackend.spec
+```
+
+当前配置重点包括:
+
+- 入口脚本:`run_backend.py`
+- 输出名称:`ChatLabBackend`
+- 模式:`COLLECT`,即 onedir 目录形式
+- 控制台:`console=True`
+- 收集 `jieba` 数据文件
+- 显式收集 `uvicorn`、`fastapi`、`pydantic_settings`、`aiosqlite`、`apscheduler` 等 hidden imports
+
+构建命令为:
+
+```powershell
+py -3.12 -m PyInstaller ChatLabBackend.spec --noconfirm --clean
+```
+
+构建后生成:
+
+```text
+chatlog_fastAPI/dist/ChatLabBackend/
+ ChatLabBackend.exe
+ _internal/
+ ...
+```
+
+打包脚本会把该目录复制到:
+
+```text
+electron-launcher/build-resources/backend/
+```
+
+Electron 打包后,运行时后端路径就是:
+
+```text
+resources/backend/ChatLabBackend.exe
+```
+
+#### 2.2.4 外部程序和 DLL 作为 extraResources 随安装包发布
+
+当前项目必须依赖:
+
+```text
+chatlog.exe
+lib/windows_x64/wx_key.dll
+```
+
+这些不是 Electron 的 JS 文件,也不应该被打进 asar 包里。因此当前项目使用 `electron-builder` 的 `extraResources`,把它们作为普通文件复制到安装目录的 `resources/` 下。
+
+打包前的临时资源目录为:
+
+```text
+electron-launcher/build-resources/
+```
+
+脚本会写入:
+
+```text
+electron-launcher/build-resources/
+ chatlog.exe
+ lib/
+ windows_x64/
+ wx_key.dll
+ frontend/
+ backend/
+ DISCLAIMER.md
+ LICENSE
+```
+
+`electron-builder.config.cjs` 中通过 `extraResources` 把整个 `build-resources` 复制到安装包资源目录:
+
+```js
+extraResources: [
+ {
+ from: path.join(__dirname, 'build-resources'),
+ to: '.',
+ filter: [
+ '**/*',
+ '!**/.env',
+ '!**/knowledge*.db',
+ '!**/__pycache__/**',
+ '!**/*.pfx',
+ '!**/*.p12',
+ '!**/*.pvk',
+ '!**/*.cer',
+ '!**/*.crt',
+ '!**/*.key',
+ '!**/certs/**',
+ ],
+ },
+]
+```
+
+这里的原则是:运行期必须作为真实文件存在的内容,都走 `extraResources`,不要放进 Electron 的 `files` 或 asar 内。
+
+#### 2.2.5 数据目录放到用户 AppData,不写入安装目录
+
+Electron 启动后端时设置:
+
+```text
+CHATLAB_DATA_DIR=%APPDATA%/ChatLab
+```
+
+FastAPI 后端通过 `config.py` 读取该目录,数据库默认放在类似路径:
+
+```text
+%APPDATA%/ChatLab/data/knowledge.db
+```
+
+这样做有几个优点:
+
+- 安装目录通常没有写权限,避免运行时报权限错误。
+- 用户数据与程序文件分离,方便升级安装包。
+- 卸载、迁移、备份时路径清晰。
+- 不会把客户数据误打进安装包。
+
+后续新项目也应该遵循这个原则:安装目录只放程序文件,用户数据放 `%APPDATA%/应用名`、`%LOCALAPPDATA%/应用名` 或用户显式选择的数据目录。
+
+#### 2.2.6 Electron 主进程负责端口和进程生命周期
+
+当前项目中:
+
+- FastAPI 端口由 Electron 动态寻找空闲端口。
+- `chatlog.exe` 固定使用 `127.0.0.1:5030`。
+- Electron 先启动服务,再轮询健康检查。
+- 窗口关闭时通过 `taskkill /pid /f /t` 清理 Windows 子进程树。
+
+这个设计解决了桌面应用常见的几个问题:
+
+- 用户不需要自己打开命令行。
+- 后端端口冲突概率降低。
+- 后端启动失败时可以在启动页显示日志。
+- 关闭桌面应用时不会残留后台进程。
+
+## 3. 当前项目的构建入口
+
+### 3.1 一键构建命令
+
+当前桌面版构建入口为:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+该脚本默认执行完整构建:
+
+1. 生成或刷新 Electron 图标。
+2. 构建 React 前端。
+3. 用 PyInstaller 构建 Python 后端。
+4. 重置 `electron-launcher/build-resources`。
+5. 复制 `chatlog.exe`、`lib`、前端 `dist`、后端 `dist/ChatLabBackend`、许可文件。
+6. 扫描敏感文件,阻止 `.env`、`knowledge*.db`、证书、私钥、缓存等进入发布资源。
+7. 生成 `release/manifest.txt`。
+8. 调用 `electron-builder` 生成 Windows NSIS 安装包。
+9. 将安装包和 blockmap 复制到 `release/`。
+10. 如启用签名,校验安装包签名状态。
+
+### 3.2 可选参数
+
+脚本支持跳过部分步骤,便于调试:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipIcon
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipFrontend
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipBackend
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipInstaller
+```
+
+常见用途:
+
+| 参数 | 用途 |
+|---|---|
+| `-SkipIcon` | 图标没有变化时跳过图标生成 |
+| `-SkipFrontend` | 前端没有变化时跳过 Vite 构建 |
+| `-SkipBackend` | 后端没有变化时跳过 PyInstaller |
+| `-SkipInstaller` | 只准备 `build-resources`,不生成安装包 |
+
+### 3.3 代码签名构建
+
+未签名安装包可以用于本地测试,但客户电脑上容易触发 Windows SmartScreen 或杀毒软件提示。正式交付建议使用 Windows 代码签名证书。
+
+命令行方式:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 `
+ -Sign `
+ -CertificateFile "D:\certs\ChatLab-CodeSigning.pfx" `
+ -CertificatePassword "证书密码" `
+ -PublisherName "证书中的发布者名称" `
+ -ForceSign
+```
+
+环境变量方式:
+
+```powershell
+$env:CHATLAB_PFX_FILE = "D:\certs\ChatLab-CodeSigning.pfx"
+$env:CHATLAB_PFX_PASSWORD = "证书密码"
+$env:CHATLAB_CERT_PUBLISHER_NAME = "证书中的发布者名称"
+$env:CHATLAB_FORCE_SIGN = "1"
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+签名相关变量:
+
+| 变量 | 说明 |
+|---|---|
+| `CHATLAB_PFX_FILE` | PFX/P12 证书完整路径 |
+| `CHATLAB_PFX_PASSWORD` | 证书密码 |
+| `CHATLAB_CERT_PUBLISHER_NAME` | 可选,发布者名称 |
+| `CHATLAB_TIMESTAMP_SERVER` | 可选,默认 `http://timestamp.digicert.com` |
+| `CHATLAB_FORCE_SIGN` | 设置为 `1` 后签名失败会中断构建 |
+
+证书安全要求:
+
+- 证书不要放进项目目录。
+- 证书不要放进 `build-resources`。
+- 证书密码不要写进代码仓库。
+- 构建脚本已经阻止 `.pfx`、`.p12`、`.pvk`、`.key`、`.cer`、`.crt`、`certs/` 进入发布资源。
+
+## 4. 目录与产物说明
+
+### 4.1 开发源码目录
+
+```text
+get_wechat_me/
+ chatlab-web/
+ frontend/
+ src/
+ public/
+ dist/
+ package.json
+ vite.config.js
+
+ chatlog_fastAPI/
+ main.py
+ run_backend.py
+ config.py
+ requirements.txt
+ ChatLabBackend.spec
+ routers/
+ services/
+ dist/
+
+ electron-launcher/
+ main.js
+ preload.js
+ index.html
+ package.json
+ electron-builder.config.cjs
+ build/
+ build-resources/
+ dist/
+
+ lib/
+ windows_x64/
+ wx_key.dll
+
+ scripts/
+ build-desktop.ps1
+ make-icon.cjs
+
+ chatlog.exe
+ release/
+```
+
+### 4.2 构建过程中产生的关键目录
+
+| 目录 | 生成者 | 作用 |
+|---|---|---|
+| `chatlab-web/frontend/dist` | Vite | 前端静态资源 |
+| `chatlog_fastAPI/build` | PyInstaller | Python 打包中间产物 |
+| `chatlog_fastAPI/dist/ChatLabBackend` | PyInstaller | Python 后端可执行目录 |
+| `electron-launcher/build` | 图标脚本 | Electron 图标 |
+| `electron-launcher/build-resources` | 构建脚本 | 准备给 electron-builder 的额外资源 |
+| `electron-launcher/dist` | electron-builder | win-unpacked 和安装包 |
+| `release` | 构建脚本 | 最终交付产物目录 |
+
+### 4.3 安装包内的运行时结构
+
+安装后大致结构如下:
+
+```text
+安装目录/
+ ChatLab售后智能助手.exe
+ resources/
+ app.asar 或 app/
+ main.js
+ preload.js
+ index.html
+ package.json
+
+ backend/
+ ChatLabBackend.exe
+ _internal/
+
+ frontend/
+ index.html
+ assets/
+
+ chatlog.exe
+ lib/
+ windows_x64/
+ wx_key.dll
+ DISCLAIMER.md
+ LICENSE
+```
+
+Electron 主进程通过 `process.resourcesPath` 找到 `resources/`,再拼出 `backend/ChatLabBackend.exe`、`frontend/`、`chatlog.exe` 和 `lib/` 的绝对路径。
+
+## 5. 当前方案为什么适合这个项目
+
+### 5.1 适合多技术栈项目
+
+当前项目同时包含:
+
+- React 前端
+- Python FastAPI 后端
+- Go 编译好的 `chatlog.exe`
+- Windows DLL
+- 本地 SQLite 数据
+- AI API 配置
+
+如果强行改成单一技术栈,成本很高。Electron 的好处是可以把这些组件“原样收纳”进桌面应用,同时保留已有前后端开发方式。
+
+### 5.2 适合本地化交付
+
+客户只需要安装一个桌面程序,不需要手动安装 Python、Node.js、前端依赖、后端依赖,也不需要知道命令行怎么启动。
+
+打包后:
+
+- Python 运行时随 PyInstaller 产物携带。
+- 前端 JS/CSS 已经静态化。
+- Electron 内置 Chromium,不依赖客户电脑浏览器。
+- chatlog 和 DLL 随资源文件一起分发。
+- 用户数据写入 AppData,不污染安装目录。
+
+### 5.3 适合需要启动多个本地服务的应用
+
+这个项目不是单页面离线工具,而是必须启动本地后端和微信数据服务。Electron 主进程天然适合做“进程管家”:
+
+- 启动服务。
+- 显示日志。
+- 检查健康状态。
+- 控制按钮状态。
+- 关闭时清理进程。
+- 在异常时给用户可理解的提示。
+
+## 6. 新项目复用方案
+
+后续如果要把新的 Web + 后端项目打包成桌面应用,可以按下面方案实施。
+
+### 6.1 推荐技术选型
+
+| 层级 | 推荐方案 | 说明 |
+|---|---|---|
+| 桌面壳 | Electron | 最适合承载已有 Web 应用和本地子进程 |
+| 安装包 | electron-builder | 支持 NSIS、图标、快捷方式、签名、extraResources |
+| 前端 | Vite / React / Vue 等 | 构建成静态资源即可 |
+| Python 后端 | PyInstaller onedir | 对 FastAPI、依赖库、本地数据文件支持较好 |
+| Node 后端 | 直接作为 Electron 子进程或打包为 pkg/nexe | 视项目复杂度选择 |
+| Go/Rust 后端 | 直接编译 exe 后作为 extraResources | 最简单稳定 |
+| 本地数据 | AppData / LocalAppData | 不写安装目录 |
+| 进程通信 | HTTP + IPC | 业务走 HTTP,桌面控制走 Electron IPC |
+
+### 6.2 标准目录模板
+
+建议新项目从一开始就按下面结构组织:
+
+```text
+new-project/
+ frontend/
+ package.json
+ vite.config.js
+ src/
+ dist/
+
+ backend/
+ main.py
+ run_backend.py
+ requirements.txt
+ Backend.spec
+ dist/
+
+ desktop/
+ main.js
+ preload.js
+ index.html
+ package.json
+ electron-builder.config.cjs
+ build/
+ build-resources/
+ dist/
+
+ native-tools/
+ your-tool.exe
+ dlls/
+
+ scripts/
+ build-desktop.ps1
+
+ release/
+```
+
+如果项目没有 Python 后端,可以删除 `backend/` 和 PyInstaller 步骤。如果项目没有外部二进制工具,可以删除 `native-tools/`。
+
+### 6.3 新项目落地步骤
+
+#### 第一步:明确桌面应用运行方式
+
+先回答下面几个问题:
+
+| 问题 | 建议 |
+|---|---|
+| 前端是否需要联网? | 如果只是本地业务,优先由本地后端托管静态资源 |
+| 后端是否必须存在? | 有数据库、AI、文件、系统调用时建议保留本地后端 |
+| 是否需要外部 exe/DLL? | 需要真实文件的工具统一放入 `extraResources` |
+| 是否需要安装包? | 正式交付建议使用 NSIS 安装包 |
+| 是否需要代码签名? | 客户交付建议签名 |
+| 用户数据放在哪里? | 放 AppData,不放安装目录 |
+
+对当前项目这类本地服务型应用,推荐运行方式为:
+
+```text
+Electron -> 启动本地后端 -> 后端托管前端 -> Electron 加载后端 URL
+```
+
+#### 第二步:前端适配生产环境
+
+前端要满足:
+
+- 能通过 `npm run build` 生成静态资源。
+- 页面路由支持 SPA fallback。
+- API 请求尽量使用相对路径,例如 `/api/xxx`,不要硬编码 `http://127.0.0.1:5173`。
+- 开发环境可以通过 Vite proxy 转发 API。
+- 生产环境由本地后端托管静态文件并处理 API。
+
+示例 `vite.config.js`:
+
+```js
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ host: '127.0.0.1',
+ proxy: {
+ '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
+ },
+ },
+})
+```
+
+生产构建命令:
+
+```powershell
+cd frontend
+npm install
+npm run build
+```
+
+#### 第三步:后端适配桌面运行
+
+后端要满足:
+
+- 能从环境变量读取端口。
+- 只监听 `127.0.0.1`,避免暴露到局域网。
+- 提供 `/health` 健康检查。
+- 能从环境变量读取数据目录。
+- 能从环境变量读取静态资源目录。
+- 生产环境托管前端 `dist`。
+- 不依赖当前工作目录查找文件,尽量使用绝对路径或环境变量路径。
+
+示例 `run_backend.py`:
+
+```python
+import os
+import uvicorn
+from main import app
+
+
+def main():
+ port = int(os.environ.get("APP_BACKEND_PORT", "8000"))
+ uvicorn.run(app, host="127.0.0.1", port=port, reload=False, log_level="info")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+示例静态资源托管:
+
+```python
+import os
+from pathlib import Path
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+app = FastAPI()
+
+static_dir = Path(os.environ.get("APP_STATIC_DIR", "frontend/dist"))
+
+if static_dir.exists():
+ assets_dir = static_dir / "assets"
+ if assets_dir.exists():
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
+
+ @app.get("/", include_in_schema=False)
+ async def index():
+ return FileResponse(static_dir / "index.html")
+
+ @app.get("/{full_path:path}", include_in_schema=False)
+ async def spa_fallback(full_path: str):
+ target = static_dir / full_path
+ if target.exists() and target.is_file():
+ return FileResponse(target)
+ return FileResponse(static_dir / "index.html")
+```
+
+#### 第四步:配置 PyInstaller
+
+创建 `Backend.spec`,最小示例:
+
+```python
+# -*- mode: python ; coding: utf-8 -*-
+
+a = Analysis(
+ ["run_backend.py"],
+ pathex=[],
+ binaries=[],
+ datas=[],
+ hiddenimports=[],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+ optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="Backend",
+ console=True,
+)
+
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ name="Backend",
+)
+```
+
+如果后端使用 FastAPI、Uvicorn、APScheduler、jieba、Pydantic Settings 等动态导入库,需要显式收集 hidden imports 或 data files:
+
+```python
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules
+
+datas = []
+datas += collect_data_files("jieba")
+
+hiddenimports = []
+hiddenimports += collect_submodules("uvicorn")
+hiddenimports += collect_submodules("fastapi")
+hiddenimports += collect_submodules("pydantic_settings")
+hiddenimports += collect_submodules("apscheduler")
+```
+
+构建命令:
+
+```powershell
+cd backend
+py -3.12 -m PyInstaller Backend.spec --noconfirm --clean
+```
+
+建议优先使用 onedir,而不是 onefile:
+
+| 模式 | 优点 | 缺点 | 建议 |
+|---|---|---|---|
+| onedir | 启动快,依赖文件清晰,问题好排查 | 文件多 | 桌面应用内置后端优先使用 |
+| onefile | 单个 exe 好看 | 启动慢,会解压临时文件,杀软误报概率更高 | 只适合很小的工具 |
+
+#### 第五步:创建 Electron 壳
+
+`desktop/package.json` 示例:
+
+```json
+{
+ "name": "your-app-desktop",
+ "version": "1.0.0",
+ "main": "main.js",
+ "scripts": {
+ "start": "electron .",
+ "build": "electron-builder --win --config electron-builder.config.cjs"
+ },
+ "devDependencies": {
+ "electron": "^42.0.0",
+ "electron-builder": "^26.8.1"
+ }
+}
+```
+
+`desktop/main.js` 需要包含这些核心能力:
+
+- 资源路径适配。
+- 创建窗口。
+- 启动后端。
+- 等待健康检查。
+- 加载业务页面。
+- 关闭时清理子进程。
+
+路径适配示例:
+
+```js
+const { app, BrowserWindow } = require('electron')
+const path = require('path')
+
+function isPackaged() {
+ return app.isPackaged
+}
+
+function projectRoot() {
+ return isPackaged() ? process.resourcesPath : path.resolve(__dirname, '..')
+}
+
+function resourcePath(...parts) {
+ return path.join(projectRoot(), ...parts)
+}
+
+function backendExePath() {
+ return resourcePath('backend', 'Backend.exe')
+}
+
+function frontendDistDir() {
+ return isPackaged()
+ ? resourcePath('frontend')
+ : resourcePath('frontend', 'dist')
+}
+```
+
+启动后端示例:
+
+```js
+const { spawn } = require('child_process')
+const net = require('net')
+const http = require('http')
+
+let backendPort = null
+let backendUrl = null
+let backendProcess = null
+
+function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer()
+ server.listen(0, '127.0.0.1', () => {
+ const port = server.address().port
+ server.close(() => resolve(port))
+ })
+ server.on('error', reject)
+ })
+}
+
+async function startBackend() {
+ if (backendProcess) return
+
+ backendPort = backendPort || await getFreePort()
+ backendUrl = `http://127.0.0.1:${backendPort}`
+
+ const env = {
+ ...process.env,
+ APP_BACKEND_PORT: String(backendPort),
+ APP_STATIC_DIR: frontendDistDir(),
+ APP_DATA_DIR: path.join(app.getPath('appData'), 'YourApp'),
+ }
+
+ const command = isPackaged() ? backendExePath() : 'python'
+ const args = isPackaged() ? [] : ['run_backend.py']
+ const cwd = isPackaged() ? path.dirname(command) : resourcePath('backend')
+
+ backendProcess = spawn(command, args, { cwd, env, windowsHide: true, shell: !isPackaged() })
+}
+```
+
+窗口加载策略:
+
+```js
+async function openAppWindow() {
+ await startBackend()
+ await waitForHealth()
+ mainWindow.loadURL(backendUrl)
+}
+```
+
+#### 第六步:配置 preload 和 IPC
+
+如果启动页需要按钮控制后端、显示日志、触发刷新等,不要在渲染进程直接开启 Node 能力。推荐:
+
+- `nodeIntegration: false`
+- `contextIsolation: true`
+- 通过 `preload.js` 暴露有限 API
+
+示例:
+
+```js
+const { contextBridge, ipcRenderer } = require('electron')
+
+contextBridge.exposeInMainWorld('desktopAPI', {
+ startAll: () => ipcRenderer.invoke('start-all'),
+ getStatus: () => ipcRenderer.invoke('get-status'),
+ onLog: (callback) => ipcRenderer.on('log', (_event, value) => callback(value)),
+})
+```
+
+主进程:
+
+```js
+const { ipcMain } = require('electron')
+
+ipcMain.handle('start-all', async () => {
+ await openAppWindow()
+ return { ok: true, backendUrl }
+})
+```
+
+#### 第七步:配置 electron-builder
+
+示例 `desktop/electron-builder.config.cjs`:
+
+```js
+const path = require('path')
+
+module.exports = {
+ appId: 'com.company.yourapp',
+ productName: 'YourApp',
+ icon: 'build/icon.ico',
+ directories: {
+ output: 'dist',
+ },
+ files: [
+ 'main.js',
+ 'preload.js',
+ 'index.html',
+ 'build/icon.ico',
+ 'build/icon.png',
+ 'package.json',
+ ],
+ extraResources: [
+ {
+ from: path.join(__dirname, 'build-resources'),
+ to: '.',
+ filter: [
+ '**/*',
+ '!**/.env',
+ '!**/*.db',
+ '!**/__pycache__/**',
+ '!**/*.pfx',
+ '!**/*.p12',
+ '!**/*.key',
+ '!**/certs/**',
+ ],
+ },
+ ],
+ win: {
+ target: 'nsis',
+ icon: 'build/icon.ico',
+ artifactName: 'YourApp-Setup-${version}.${ext}',
+ },
+ nsis: {
+ oneClick: false,
+ allowToChangeInstallationDirectory: true,
+ perMachine: false,
+ createDesktopShortcut: true,
+ createStartMenuShortcut: true,
+ shortcutName: 'YourApp',
+ },
+}
+```
+
+要点:
+
+- `files` 只放 Electron 自己运行所需的 JS/HTML/图标/package。
+- `extraResources` 放后端 exe、前端 dist、外部工具、DLL、模板文件等真实资源。
+- 敏感文件必须通过 filter 排除。
+- 正式应用应设置稳定的 `appId`。
+- `productName` 会影响安装目录、快捷方式和应用显示名。
+
+#### 第八步:编写统一构建脚本
+
+建议新项目也使用一个 PowerShell 脚本串联所有步骤。伪代码如下:
+
+```powershell
+$ErrorActionPreference = "Stop"
+
+$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
+$Frontend = Join-Path $Root "frontend"
+$Backend = Join-Path $Root "backend"
+$Desktop = Join-Path $Root "desktop"
+$Resources = Join-Path $Desktop "build-resources"
+$Release = Join-Path $Root "release"
+
+# 1. 构建前端
+Push-Location $Frontend
+npm.cmd run build
+Pop-Location
+
+# 2. 构建后端
+Push-Location $Backend
+py -3.12 -m PyInstaller Backend.spec --noconfirm --clean
+Pop-Location
+
+# 3. 重置资源目录
+if (Test-Path $Resources) {
+ Remove-Item -LiteralPath (Resolve-Path $Resources).Path -Recurse -Force
+}
+New-Item -ItemType Directory -Force -Path $Resources | Out-Null
+
+# 4. 复制资源
+Copy-Item -LiteralPath (Join-Path $Frontend "dist") -Destination (Join-Path $Resources "frontend") -Recurse -Force
+Copy-Item -LiteralPath (Join-Path $Backend "dist\Backend") -Destination (Join-Path $Resources "backend") -Recurse -Force
+Copy-Item -LiteralPath (Join-Path $Root "native-tools\your-tool.exe") -Destination (Join-Path $Resources "your-tool.exe") -Force
+
+# 5. 生成安装包
+Push-Location $Desktop
+npm.cmd run build
+Pop-Location
+
+# 6. 复制到 release
+New-Item -ItemType Directory -Force -Path $Release | Out-Null
+Copy-Item -Path (Join-Path $Desktop "dist\*.exe") -Destination $Release -Force
+```
+
+当前项目的 `scripts/build-desktop.ps1` 已经是一份更完整的版本,包含:
+
+- Python 版本回退逻辑。
+- 证书路径校验。
+- 环境变量签名参数。
+- 安全删除目录。
+- 敏感文件扫描。
+- release manifest。
+- 签名校验。
+
+新项目建议直接以它为蓝本改名、改路径、改资源即可。
+
+## 7. 当前项目构建链路详解
+
+### 7.1 构建链路
+
+```text
+scripts/build-desktop.ps1
+ |
+ +--> scripts/make-icon.cjs
+ | |
+ | +--> electron-launcher/build/icon.ico
+ | +--> electron-launcher/build/icon.png
+ |
+ +--> chatlab-web/frontend
+ | |
+ | +--> npm run build
+ | +--> chatlab-web/frontend/dist
+ |
+ +--> chatlog_fastAPI
+ | |
+ | +--> py -3.12 -m PyInstaller ChatLabBackend.spec
+ | +--> chatlog_fastAPI/dist/ChatLabBackend
+ |
+ +--> electron-launcher/build-resources
+ | |
+ | +--> chatlog.exe
+ | +--> lib/
+ | +--> frontend/
+ | +--> backend/
+ | +--> LICENSE / DISCLAIMER.md
+ |
+ +--> electron-launcher
+ | |
+ | +--> npm run build
+ | +--> electron-builder --win --config electron-builder.config.cjs
+ |
+ +--> release/
+ |
+ +--> ChatLab-Setup-版本号-构建标识.exe
+ +--> ChatLab-Setup-版本号-构建标识.exe.blockmap
+ +--> manifest.txt
+```
+
+### 7.2 运行链路
+
+```text
+ChatLab售后智能助手.exe
+ |
+ v
+Electron main.js
+ |
+ +--> createWindow()
+ | |
+ | +--> loadFile("index.html")
+ | +--> 显示启动控制页
+ |
+ +--> 用户点击启动 / start-all
+ |
+ +--> startBackend()
+ | |
+ | +--> 查找空闲端口
+ | +--> 设置 CHATLAB_DATA_DIR
+ | +--> 设置 CHATLAB_STATIC_DIR
+ | +--> 设置 CHATLAB_BACKEND_PORT
+ | +--> 启动 resources/backend/ChatLabBackend.exe
+ | +--> 等待 /health
+ |
+ +--> startChatlog()
+ | |
+ | +--> 检查 PC 微信进程
+ | +--> 执行 chatlog.exe key --force
+ | +--> 读取 ~/.chatlog/chatlog.json
+ | +--> 启动 chatlog.exe server --auto-decrypt ...
+ | +--> 等待 127.0.0.1:5030 API 就绪
+ |
+ +--> mainWindow.loadURL(backendUrl)
+ |
+ +--> FastAPI 返回 React 页面
+```
+
+## 8. 新项目可复用的技术规范
+
+### 8.1 资源路径规范
+
+必须区分开发环境和打包环境:
+
+| 场景 | 根目录 |
+|---|---|
+| 开发环境 | 项目源码根目录 |
+| 打包环境 | `process.resourcesPath` |
+
+不要在 Electron 主进程中写死源码路径。所有资源路径都通过类似函数统一生成:
+
+```js
+function projectRoot() {
+ return app.isPackaged ? process.resourcesPath : path.resolve(__dirname, '..')
+}
+
+function resourcePath(...parts) {
+ return path.join(projectRoot(), ...parts)
+}
+```
+
+### 8.2 端口规范
+
+推荐:
+
+- 后端端口由 Electron 动态分配。
+- 对外只监听 `127.0.0.1`。
+- 前端生产环境使用相对路径调用 API。
+- 必须提供 `/health`。
+- 启动页等待 `/health` 成功后再进入系统。
+
+如果有必须固定端口的外部工具,要在启动前检查端口占用,并给出明确错误提示。
+
+### 8.3 数据目录规范
+
+桌面应用不要把用户数据写入安装目录。建议:
+
+```text
+%APPDATA%/应用名/
+ data/
+ logs/
+ cache/
+ config/
+```
+
+后端通过环境变量读取:
+
+```text
+APP_DATA_DIR
+```
+
+Electron 负责传入:
+
+```js
+APP_DATA_DIR: path.join(app.getPath('appData'), 'YourApp')
+```
+
+### 8.4 日志规范
+
+建议至少保留两类日志:
+
+| 日志 | 位置 | 作用 |
+|---|---|---|
+| 启动页实时日志 | Electron IPC | 给用户和售后定位启动问题 |
+| 文件日志 | AppData logs | 给开发定位线上问题 |
+
+敏感信息必须脱敏,例如:
+
+- API Key
+- token
+- 数据库密码
+- 用户密钥
+- 证书路径和密码
+
+当前项目在 Electron 主进程里已经对 `apiKey`、`data_key`、`img_key`、`sk-xxxx` 做了日志脱敏。
+
+### 8.5 安全规范
+
+正式打包前必须检查:
+
+- `.env` 不进入安装包。
+- 测试数据库不进入安装包。
+- 用户数据不进入安装包。
+- 证书和私钥不进入安装包。
+- `__pycache__` 不进入安装包。
+- 日志文件不进入安装包。
+- API Key 不写死到前端代码。
+- Electron 渲染进程不启用 Node 权限。
+
+当前项目的安全策略包括:
+
+- `build-desktop.ps1` 构建前扫描 `build-resources` 和 `release`。
+- `electron-builder.config.cjs` 在 `extraResources.filter` 中排除敏感文件。
+- `preload.js` 通过 `contextBridge` 暴露有限 IPC。
+- `mainWindow` 设置 `nodeIntegration: false`、`contextIsolation: true`。
+
+### 8.6 签名规范
+
+内部测试可以使用未签名包,但正式交付建议签名:
+
+- 证书放项目外。
+- 密码通过环境变量传入。
+- 启用时间戳服务器。
+- 启用 `forceCodeSigning` 或自定义强校验。
+- 构建结束后用 `Get-AuthenticodeSignature` 校验。
+
+Windows 签名不能完全消除 SmartScreen 提示,但可以显著降低拦截概率,并建立发布者信誉。
+
+## 9. 验收测试方案
+
+### 9.1 构建前检查
+
+执行:
+
+```powershell
+node -v
+npm -v
+py -3.12 -V
+```
+
+检查:
+
+- Node.js 可用。
+- Python 3.12 可用。
+- 前端依赖已安装。
+- Electron 依赖已安装。
+- Python 后端依赖已安装。
+- `chatlog.exe` 存在。
+- `lib/windows_x64/wx_key.dll` 存在。
+- 图标文件可生成或已存在。
+
+### 9.2 本地开发运行检查
+
+开发模式建议分别检查:
+
+```powershell
+cd chatlab-web/frontend
+npm run build
+```
+
+```powershell
+cd chatlog_fastAPI
+python run_backend.py
+```
+
+```powershell
+cd electron-launcher
+npm run start
+```
+
+重点看:
+
+- Electron 启动页能打开。
+- FastAPI 能启动。
+- `/health` 返回正常。
+- 前端静态页面能被后端托管。
+- chatlog 服务能启动并返回群聊或会话数据。
+
+### 9.3 打包检查
+
+执行:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+检查输出:
+
+- `chatlab-web/frontend/dist` 已更新。
+- `chatlog_fastAPI/dist/ChatLabBackend/ChatLabBackend.exe` 存在。
+- `electron-launcher/build-resources/frontend` 存在。
+- `electron-launcher/build-resources/backend/ChatLabBackend.exe` 存在。
+- `electron-launcher/build-resources/chatlog.exe` 存在。
+- `electron-launcher/build-resources/lib/windows_x64/wx_key.dll` 存在。
+- `release/manifest.txt` 已生成。
+- `release/ChatLab-Setup-*.exe` 已生成。
+
+### 9.4 安装后功能检查
+
+在干净 Windows 机器或虚拟机上安装:
+
+1. 双击安装包。
+2. 确认桌面快捷方式和开始菜单快捷方式生成。
+3. 启动应用。
+4. 确认启动页显示正常。
+5. 点击启动或进入系统。
+6. 确认 FastAPI 启动成功。
+7. 确认 chatlog 启动成功。
+8. 确认主界面加载成功。
+9. 确认关闭窗口后无残留 `ChatLabBackend.exe` 和 `chatlog.exe`。
+10. 确认用户数据写入 `%APPDATA%/ChatLab`,不是安装目录。
+
+### 9.5 发布前安全检查
+
+检查 `release/manifest.txt`,确认没有:
+
+```text
+.env
+knowledge*.db
+__pycache__
+*.pfx
+*.p12
+*.pvk
+*.cer
+*.crt
+*.key
+certs/
+```
+
+签名包额外执行:
+
+```powershell
+Get-AuthenticodeSignature .\release\ChatLab-Setup-*.exe
+```
+
+期望:
+
+```text
+Status: Valid
+```
+
+## 10. 常见问题与处理方案
+
+### 10.1 打包后前端空白
+
+可能原因:
+
+- 前端构建产物没有复制到 `build-resources/frontend`。
+- FastAPI 没有正确读取 `CHATLAB_STATIC_DIR`。
+- React 路由没有 SPA fallback。
+- 生产环境 API 地址仍写死为开发端口。
+
+处理:
+
+- 检查安装目录 `resources/frontend/index.html` 是否存在。
+- 检查 `resources/frontend/assets` 是否存在。
+- 打开后端 `/health` 查看 `static_dir` 或日志。
+- 前端 API 统一改为相对路径。
+
+### 10.2 打包后后端启动失败
+
+可能原因:
+
+- PyInstaller hidden imports 不完整。
+- 某些数据文件没有被 `collect_data_files` 收集。
+- 后端依赖当前工作目录。
+- 安装目录没有写权限。
+
+处理:
+
+- 先运行 `chatlog_fastAPI/dist/ChatLabBackend/ChatLabBackend.exe` 看报错。
+- 检查 `warn-ChatLabBackend.txt`。
+- 在 `.spec` 中增加 hidden imports 或 datas。
+- 数据写入 AppData,不写程序目录。
+
+### 10.3 Electron 找不到 exe 或 DLL
+
+可能原因:
+
+- 资源没有复制到 `build-resources`。
+- 路径仍按源码目录查找。
+- 资源被打入 asar,导致外部程序无法直接运行。
+
+处理:
+
+- 外部 exe/DLL 使用 `extraResources`。
+- 使用 `process.resourcesPath` 拼运行时路径。
+- 不要让外部可执行文件依赖 asar 内路径。
+
+### 10.4 关闭应用后仍有后台进程
+
+可能原因:
+
+- 只 kill 了父进程,没有 kill 子进程树。
+- Electron 退出流程没有等待清理。
+
+处理:
+
+- Windows 使用 `taskkill /pid
/f /t`。
+- `window.close` 和 `before-quit` 中都调用清理逻辑。
+- 清理时维护进程句柄,不要只靠进程名杀。
+
+### 10.5 客户电脑提示风险或拦截
+
+可能原因:
+
+- 安装包未签名。
+- 新证书发布者信誉不足。
+- PyInstaller 或 Electron 包体较大,被杀软谨慎处理。
+- onefile 自解压行为更容易被误报。
+
+处理:
+
+- 使用代码签名证书。
+- 使用 onedir 后端。
+- 避免把测试工具、调试脚本、临时文件放进安装包。
+- 给客户提供发布者、用途、安装路径说明。
+
+### 10.6 中文应用名乱码
+
+可能原因:
+
+- 文件本身编码不是 UTF-8。
+- PowerShell 控制台编码导致显示乱码。
+- 构建环境 locale 不一致。
+
+处理:
+
+- 源码文件统一保存为 UTF-8。
+- 构建脚本和配置文件避免混用编码。
+- 如控制台显示乱码,但安装包内应用名正常,可优先检查实际安装结果。
+
+## 11. 新项目复制当前方案时需要修改的清单
+
+| 修改项 | 当前项目值 | 新项目需要改成 |
+|---|---|---|
+| 应用名 | `ChatLab售后智能助手` | 新项目产品名 |
+| appId | `com.chatlab.desktop` | `com.公司名.应用名` |
+| Electron 目录 | `electron-launcher` | 新项目 desktop 目录 |
+| 前端目录 | `chatlab-web/frontend` | 新项目前端目录 |
+| 后端目录 | `chatlog_fastAPI` | 新项目后端目录 |
+| 后端 exe 名 | `ChatLabBackend.exe` | 新项目后端 exe 名 |
+| 数据目录 | `%APPDATA%/ChatLab` | `%APPDATA%/新应用名` |
+| 图标 | `electron-launcher/build/icon.ico` | 新项目图标 |
+| 安装包名 | `ChatLab-Setup-${version}-${buildLabel}.exe` | 新项目安装包命名 |
+| 额外资源 | `chatlog.exe`、`lib` | 新项目实际外部资源 |
+| 敏感文件规则 | `.env`、`knowledge*.db`、证书等 | 按新项目补充 |
+| 健康检查 | `/health` | 新项目健康检查接口 |
+| 固定端口 | chatlog `5030` | 新项目实际需要 |
+| 环境变量前缀 | `CHATLAB_` | 新项目独立前缀 |
+
+## 12. 建议的最终构建命令手册
+
+### 12.1 首次准备环境
+
+```powershell
+# 前端依赖
+cd chatlab-web/frontend
+npm install
+
+# Electron 依赖
+cd ../../electron-launcher
+npm install
+
+# Python 依赖
+cd ../chatlog_fastAPI
+py -3.12 -m pip install -r requirements.txt
+py -3.12 -m pip install pyinstaller
+```
+
+### 12.2 本地测试构建
+
+```powershell
+cd 项目根目录
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+### 12.3 只准备资源,不生成安装包
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipInstaller
+```
+
+### 12.4 前端没改,只重打后端和安装包
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipFrontend
+```
+
+### 12.5 后端没改,只重打前端和安装包
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 -SkipBackend
+```
+
+### 12.6 正式签名发布
+
+```powershell
+$env:CHATLAB_PFX_FILE = "D:\certs\ChatLab-CodeSigning.pfx"
+$env:CHATLAB_PFX_PASSWORD = "证书密码"
+$env:CHATLAB_CERT_PUBLISHER_NAME = "证书中的发布者名称"
+$env:CHATLAB_FORCE_SIGN = "1"
+
+powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
+```
+
+## 13. 结论
+
+当前项目使用的是一套比较稳妥的桌面化工程方案:Electron 负责桌面体验和进程管理,React 负责界面,FastAPI 负责业务接口和静态资源托管,PyInstaller 负责 Python 后端二进制化,electron-builder 负责 Windows 安装包生成,`extraResources` 负责携带外部 exe、DLL 和构建产物。
+
+这套方案的核心价值在于:不破坏原来的 Web 项目结构,又能把多进程、本地服务、外部工具和前端页面统一交付成一个用户可安装、可双击启动的桌面应用。后续新项目只要按照“前端静态化、后端可执行化、Electron 管理进程、资源走 extraResources、用户数据进 AppData、构建脚本统一编排”的原则,就可以复用当前项目的打包方法。
+