Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/chat-image-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ on:
paths:
- '.github/workflows/chat-image-quality.yml'
- 'src/wechat_decrypt_tool/media_helpers.py'
- 'src/wechat_decrypt_tool/key_store.py'
- 'src/wechat_decrypt_tool/chat_export_service.py'
- 'src/wechat_decrypt_tool/routers/media.py'
- 'src/wechat_decrypt_tool/routers/chat*.py'
- 'tests/test_chat_image_local_quality.py'
- 'tests/test_chat_media_image_cache_upgrade.py'
- 'tests/test_media_decrypt_stream_cancel.py'
- 'tests/test_media_xor_decode.py'
- 'tests/test_media_wxgf_ffmpeg_fallback.py'
- 'tests/test_key_file_permissions.py'
- 'frontend/lib/chat/chat-history.js'
- 'frontend/lib/chat/message-normalizer.js'
- 'frontend/tests/chat-image-quality.test.js'
Expand Down Expand Up @@ -54,6 +58,9 @@ jobs:
tests/test_image_key_resolver.py
tests/test_media_emoticon_catalog.py
tests/test_media_emoji_download_stream.py
tests/test_media_xor_decode.py
tests/test_media_wxgf_ffmpeg_fallback.py
tests/test_key_file_permissions.py
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
Expand Down
2 changes: 2 additions & 0 deletions docs/chat-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

选择 Ollama 或 LM Studio 前,请先启动其本地服务并准备好模型。默认地址分别为 `http://127.0.0.1:11434/v1` 和 `http://127.0.0.1:1234/v1`,未启用鉴权时密钥可留空;选择后会自动获取模型。启用鉴权时需填写密钥,端口不同则修改地址。连接失败时检查服务、地址、端口和鉴权设置,再点击「从上游获取」。也可勾选「手动输入(备用)」填写真实模型名称后保存;保存配置不代表连接已验证。

服务运行在局域网内的另一台机器上时,把地址改为 `http://<局域网 IP>:端口/v1`。明文 HTTP 仅支持本机(`localhost`、`127.0.0.1`、`[::1]`)和局域网 IP(`10.x`、`172.16-31.x`、`192.168.x`、IPv6 `fc`/`fd` 开头),`nas.local` 这类主机名及其他地址必须使用 HTTPS。明文 HTTP 不加密也不校验对方身份,同一网络上的其他设备可能看到聊天内容和密钥,或冒充该服务返回内容;保存后自动任务和关注提醒也会持续向该地址发送内容,请仅在可信的局域网中使用。

## 内容与运行边界

- 支持文本、引用、卡片标题、已有语音转写、JPEG/PNG/WebP/GIF(首帧)、PDF(含扫描页)、DOCX、XLSX、PPTX、TXT、MD、CSV。
Expand Down
12 changes: 9 additions & 3 deletions frontend/components/AiSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
</div>
<label class="ais-key-label"><span>API 密钥 <span v-if="editId && form.has_key && !key" class="ais-tag">已保存</span></span><input v-model="key" type="password" autocomplete="new-password" :placeholder="editId && !credentialsReset ? '留空保留已有密钥' : '输入 API 密钥,本地服务可留空'" @blur="autoFetchModels" /></label>
<p v-if="isLocalService" class="ais-muted">请先启动本地服务并准备好模型;未启用鉴权时,API 密钥可留空。</p>
<p v-if="isLanHttp" class="ais-muted">此地址使用明文 HTTP,聊天内容和密钥不加密传输,请仅在可信的局域网中使用。</p>

</div>
<div class="ais-model-config">
Expand Down Expand Up @@ -247,10 +248,15 @@ const blank = () => ({ provider: 'deepseek', name: 'DeepSeek', protocol: 'openai
const form = reactive(blank())
// 切换预设后明确清空凭据,不让后端复用原配置的密钥。
const credentialsReset = ref(false)
const isLocalService = computed(() => {
if (!['ollama', 'lmstudio'].includes(form.provider)) return false
try { return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(form.base_url).hostname) } catch { return false }
// 后端只对本机和局域网 IP 放行明文 HTTP,这里按协议判断,不重复网段规则。
const serviceAddress = computed(() => {
try {
const url = new URL(form.base_url)
return { http: url.protocol === 'http:', loopback: ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) }
} catch { return {} }
})
const isLocalService = computed(() => ['ollama', 'lmstudio'].includes(form.provider) && Boolean(serviceAddress.value.http || serviceAddress.value.loopback))
const isLanHttp = computed(() => Boolean(serviceAddress.value.http && !serviceAddress.value.loopback))
const manualModel = ref(false), modelDetails = ref([]), modelError = ref(''), modelsLoading = ref(false)
const manualMetadata = ref(null)
const selectedMetadata = computed(() => {
Expand Down
5 changes: 1 addition & 4 deletions frontend/components/GlobalExportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -566,10 +566,7 @@ const startExport = async () => {
} else {
task.value.message = '正在保存到浏览器目录...'
task.value.progress = 98
const zipPath = String(finalJob.zipPath || '').trim()
const query = new URLSearchParams()
query.set('path', zipPath)
const downloadUrl = `${apiBase}/account/archive_export/download?${query.toString()}`
const downloadUrl = `${apiBase}/account/archive_export/${encodeURIComponent(currentExportId.value)}/download`
const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) {
throw new Error(`下载导出文件失败(${downloadResponse.status})。`)
Expand Down
12 changes: 11 additions & 1 deletion frontend/components/chat/ChatExportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@
</div>

<div
v-if="exportJob.repairCandidates?.length || exportJob.unresolvedMedia?.conversations?.length"
v-if="exportJob.repairCandidates?.length || exportJob.unresolvedMedia?.conversations?.length || exportJob.incremental?.locationTypeSkipped"
class="chat-export-folder-result__followups"
aria-label="差异与媒体状态"
>
Expand Down Expand Up @@ -497,6 +497,16 @@
重新探测缺失媒体
</button>
</div>

<div v-if="exportJob.incremental?.locationTypeSkipped" class="chat-export-followup">
<span class="chat-export-followup__icon" aria-hidden="true">
<i class="fa-solid fa-location-dot"></i>
</span>
<div class="chat-export-followup__copy">
<strong>本次未导出位置消息</strong>
<span>该目录的基线不含“位置”类型,已按基线的消息类型更新;需要时请重置增量基线或改用新目录。</span>
</div>
</div>
</div>

<details v-if="exportJob.warning" class="chat-export-folder-result__details">
Expand Down
1 change: 1 addition & 0 deletions frontend/composables/chat/useChatExport.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
{ value: 'emoji', label: '表情' },
{ value: 'video', label: '视频' },
{ value: 'voice', label: '语音' },
{ value: 'location', label: '位置' },
{ value: 'chatHistory', label: '聊天记录' },
{ value: 'transfer', label: '转账' },
{ value: 'redPacket', label: '红包' },
Expand Down
23 changes: 23 additions & 0 deletions frontend/tests/ai-presets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ describe('AI 服务预设', () => {
expect(request.mock.calls.find(([path]) => path === '/profiles')[1].body).toMatchObject({ provider, model: 'local-model', api_key: '' })
})

it.each(['ollama', 'lmstudio'])('%s 改填局域网 HTTP 地址后仍免密钥自动获取,并提示明文传输', async provider => {
await open(); await choose(provider)
const dialogText = () => wrapper.find('[role=dialog]').text()
const fetches = () => request.mock.calls.filter(([path]) => path === '/models')
const fillAddress = async value => {
await wrapper.find('input[type=url]').setValue(value)
await wrapper.find('input[type=url]').trigger('blur'); await flushPromises()
}
expect(fetches()).toHaveLength(1)
expect(dialogText()).not.toContain('明文 HTTP')
await fillAddress('http://192.168.1.5:11434/v1')
expect(fetches()).toHaveLength(2)
expect(fetches().at(-1)[1].body).toMatchObject({ base_url: 'http://192.168.1.5:11434/v1', api_key: '' })
expect(dialogText()).toContain('API 密钥可留空')
expect(dialogText()).toContain('明文 HTTP')
// 同一预设改填 HTTPS 远程地址时,仍需先填写密钥。
await fillAddress('https://ollama.example.com/v1')
expect(wrapper.find('input[type=url]').element.value).toBe('https://ollama.example.com/v1')
expect(fetches()).toHaveLength(2)
expect(dialogText()).not.toContain('API 密钥可留空')
expect(dialogText()).not.toContain('明文 HTTP')
})

it('已有云端配置切换到本地时明确清空保存的密钥', async () => {
const original = request.getMockImplementation()
const profile = { ...presets[0], id: 'saved', has_key: true, model: 'cloud-model', vision: false }
Expand Down
13 changes: 12 additions & 1 deletion frontend/tests/chat-export-model-options.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function setup({ types = ['link'], privacy = false, transcribe = false, availabl
selectedAccount: ref('test-account'), selectedContact: ref(null), privacyMode: ref(privacy) }))
state.exportSelectedUsernames.value = ['friend']
state.exportFolderHandle.value = {}
state.exportMessageTypes.value = types
if (types) state.exportMessageTypes.value = types
state.exportTranscribeVoice.value = transcribe
return { state, api }
}
Expand Down Expand Up @@ -59,4 +59,15 @@ describe('导出媒体与语音模型选项', () => {
expect(api.createChatExport).not.toHaveBeenCalled()
expect(state.exportError.value).toBe('请先下载模型')
})

it('位置消息默认勾选并随导出请求提交', async () => {
const { state, api } = setup({ types: null })
expect(state.exportMessageTypeOptions).toContainEqual({ value: 'location', label: '位置' })
expect(state.areAllExportMessageTypesSelected.value).toBe(true)
await state.startChatExport()
expect(api.createChatExport).toHaveBeenCalledWith(expect.objectContaining({
message_types: state.exportMessageTypeOptions.map(item => item.value),
}))
expect(api.createChatExport.mock.calls[0][0].message_types).toContain('location')
})
})
20 changes: 18 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,25 @@

import multiprocessing
import os
import sys
from pathlib import Path

# Keep standalone/frozen launches safe when scanner code uses multiprocessing.
if __name__ == "__main__":
multiprocessing.freeze_support()

# Source launches must run this checkout's src/, not the copy that
# `uv sync --no-editable` froze into site-packages. Keep this above every
# project import.
SRC_DIR = Path(__file__).resolve().parent / "src"
if SRC_DIR.is_dir():
if str(SRC_DIR) in sys.path:
sys.path.remove(str(SRC_DIR))
sys.path.insert(0, str(SRC_DIR))

import uvicorn

import wechat_decrypt_tool
from wechat_decrypt_tool.desktop_parent_watchdog import (
start_desktop_parent_watchdog_from_env,
)
Expand Down Expand Up @@ -55,14 +66,19 @@ def main():
else:
print("监听地址来源: 默认值")
print(f"监听地址: {host}")
code_source = str(Path(wechat_decrypt_tool.__file__).resolve())
try:
print(f"代码来源: {code_source}")
except UnicodeEncodeError:
# 标准输出的编码表示不了仓库路径时,退回转义形式,不让这行诊断信息中断启动。
print(f"代码来源: {ascii(code_source)}")
print(f"API文档: http://{access_host}:{port}/docs")
print(f"健康检查: http://{access_host}:{port}/api/health")
if lan_access_host != access_host:
print(f"局域网 MCP: http://{lan_access_host}:{port}/mcp")
print("按 Ctrl+C 停止服务")
print("=" * 60)

repo_root = Path(__file__).resolve().parent
enable_reload = os.environ.get("WECHAT_TOOL_RELOAD", "0") == "1"

# 启动API服务
Expand All @@ -71,7 +87,7 @@ def main():
host=host,
port=port,
reload=enable_reload,
reload_dirs=[str(repo_root / "src")] if enable_reload else None,
reload_dirs=[str(SRC_DIR)] if enable_reload else None,
reload_excludes=[
"output/*",
"output/**",
Expand Down
21 changes: 19 additions & 2 deletions src/wechat_decrypt_tool/ai/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging

import asyncio
import ipaddress
import json
import re
import time
Expand Down Expand Up @@ -74,12 +75,28 @@ def public_profile(profile):
return {k: v for k, v in profile.items() if k != "api_key"} | {"has_key": bool(profile.get("api_key"))}


# 明文 HTTP 的局域网例外只列 RFC 1918 私有网段和 IPv6 唯一本地地址。不用 is_private:
# 它还包含 0.0.0.0/8、链路本地(含 169.254.169.254)和文档保留网段。
LAN_NETWORKS = tuple(ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"))


def is_lan_address(host):
"""只认 IP 字面量:不解析域名,不展开 IPv4 映射等嵌入地址,也不接受带 zone id 的写法。"""
if not isinstance(host, str) or "%" in host:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
return any(address in network for network in LAN_NETWORKS)


def validate_url(value):
url = urlparse(value)
if url.scheme not in {"https", "http"} or not url.hostname or url.username or url.password or url.query or url.fragment:
raise ValueError("请输入有效的 HTTP(S) 服务地址,不要在地址中包含密钥")
if url.scheme == "http" and url.hostname not in {"localhost", "127.0.0.1", "::1"}:
raise ValueError("远程模型服务必须使用 HTTPS")
if url.scheme == "http" and url.hostname not in {"localhost", "127.0.0.1", "::1"} and not is_lan_address(url.hostname):
raise ValueError("明文 HTTP 仅支持本机(localhost、127.0.0.1、[::1])和局域网 IP(10.x、172.16-31.x、192.168.x、IPv6 fc/fd 开头),域名等其他地址必须使用 HTTPS")


def model_base_url(value):
Expand Down
11 changes: 11 additions & 0 deletions src/wechat_decrypt_tool/chat_export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2172,6 +2172,13 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
missing_files=list(opts.get("missingFiles") or []),
reset_baseline=bool(opts.get("resetBaseline")),
)
if folder_context.location_type_skipped:
# 探测、渲染都要和基线用同一份类型清单,否则已导出的历史会被误判为有差异。
want_types = set(folder_context.config.get("messageTypes") or [])
job.options["messageTypes"] = [
value for value in message_types_raw if _normalize_render_type_key(value) in want_types
]
_safe_trace(trace, "incremental_location_type_skipped", messageTypes=sorted(want_types))
preferred_missing_owner_keys = {
incremental_conversation_key(salt=folder_context.salt, username=username)
for username in target_usernames
Expand Down Expand Up @@ -3655,6 +3662,10 @@ def esc_attr(v: Any) -> str:
warning_parts: list[str] = []
if folder_context.reset_baseline:
warning_parts.append("已重置基线并完整重建本次选择的会话。")
if folder_context.location_type_skipped:
warning_parts.append(
"该增量目录的基线不含“位置”类型,本次仍按基线的消息类型更新,未导出位置消息;需要时请重置增量基线或改用新目录。"
)
recovered_files = int(job.incremental.get("filesRecovered") or 0)
if recovered_files:
warning_parts.append(f"已补回 {recovered_files} 个缺失或异常的受管理文件。")
Expand Down
33 changes: 28 additions & 5 deletions src/wechat_decrypt_tool/chat_incremental_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ def config_fingerprint(config: dict[str, Any]) -> str:
return hashlib.sha256(_canonical_json(config)).hexdigest()


def _config_without_location(config: dict[str, Any]) -> Optional[dict[str, Any]]:
"""返回去掉“位置”后的配置;本次没有勾选位置,或只勾选了位置时返回 None。"""

requested = list(config.get("messageTypes") or [])
kept = [value for value in requested if value != "location"]
# 空清单在基线里表示“不过滤、导出全部类型”,不能把“只勾选位置”当成它。
if not kept or len(kept) == len(requested):
return None
return {**config, "messageTypes": kept}


def conversation_key(*, salt: str, username: str) -> str:
payload = f"{str(salt or '')}\0{str(username or '')}".encode("utf-8", errors="replace")
return hashlib.sha256(payload).hexdigest()
Expand Down Expand Up @@ -275,6 +286,7 @@ class ChatFolderContext:
unresolved_media_conversations: list[dict[str, Any]] = field(default_factory=list)
unresolved_missing_owner_keys: set[str] = field(default_factory=set)
metadata_changed: bool = False
location_type_skipped: bool = False

@property
def export_runtime_id(self) -> str:
Expand Down Expand Up @@ -361,6 +373,7 @@ def prepare_folder_context(
_validate_baseline_paths(old_state)

desired_hash = config_fingerprint(config)
location_type_skipped = False
if owned:
baseline_account = str(old_state.get("account") or "")
baseline_account_fingerprint = str(old_state.get("accountFingerprint") or "")
Expand All @@ -371,11 +384,19 @@ def prepare_folder_context(
)
if not account_matches:
raise ChatIncrementalError("incremental_account_mismatch", "该增量目录属于其他微信账号,请选择新目录。")
if str(old_state.get("configFingerprint") or "") != desired_hash and not reset_baseline:
raise ChatIncrementalError(
"incremental_config_mismatch",
"导出格式或筛选配置与该增量目录不一致,请选择新目录或重置后完整重建。",
)
baseline_hash = str(old_state.get("configFingerprint") or "")
if baseline_hash != desired_hash and not reset_baseline:
# “位置”是导出面板后来补上的类型,而且默认勾选。基线只差这一项时沿用基线的类型清单,
# 已导出的历史与后续追加保持同一口径;需要位置消息时重置基线即可。
baseline_config = _config_without_location(config)
if baseline_config is None or config_fingerprint(baseline_config) != baseline_hash:
raise ChatIncrementalError(
"incremental_config_mismatch",
"导出格式或筛选配置与该增量目录不一致,请选择新目录或重置后完整重建。",
)
config = baseline_config
desired_hash = baseline_hash
location_type_skipped = True

if reset_baseline:
if old_state and not owned:
Expand Down Expand Up @@ -421,6 +442,7 @@ def prepare_folder_context(
salt=salt,
missing_files=missing,
reset_baseline=bool(reset_baseline),
location_type_skipped=location_type_skipped,
)


Expand Down Expand Up @@ -848,6 +870,7 @@ def materialize_folder_archive(
"filesReused": max(0, len(current_files) - len(staged_entries)),
"filesRemoved": len(stale),
"filesRecovered": recovered_count,
"locationTypeSkipped": bool(context.location_type_skipped),
}
job.repair_candidates = list(context.repair_candidates)
job.unresolved_media = {
Expand Down
Loading
Loading