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
2 changes: 1 addition & 1 deletion frontend/components/AiSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<header class="ais-heading">
<div class="ais-heading-icon"><WandSparkles :size="16" :stroke-width="1.8" aria-hidden="true" /></div>
<div><h3>AI 服务</h3><p>管理 AI 模型、本地搜索与对话体验。</p></div>
<span class="ais-local"><Lock :size="16" :stroke-width="1.8" aria-hidden="true" /> {{ activeTab==='local' ? '搜索在本机运行' : '密钥本机保存' }}</span>
<span class="ais-local"><Lock :size="16" :stroke-width="1.8" aria-hidden="true" /> {{ activeTab==='local' ? '本机或你指定的服务' : '密钥本机保存' }}</span>
</header>

<div class="ais-tabs" role="tablist" aria-label="AI 服务页面">
Expand Down
57 changes: 50 additions & 7 deletions frontend/components/LocalSearchSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -119,26 +119,36 @@
<details v-if="state.audit?.length" class="lss-section"><summary>本地处理记录</summary><p v-for="a in state.audit" :key="a.id" class="lss-note">{{ a.kind==='search' ? '检索' : '整理聊天' }} · {{ a.model }} · {{ a.actual_device==='cuda' ? 'NVIDIA GPU' : 'CPU' }} · {{ Number(a.seconds).toFixed(1) }} 秒</p></details>
</div>
</details>
<div class="lss-bottom-note"><Laptop :size="16" :stroke-width="1.8" aria-hidden="true" /><span>本地搜索不上传聊天。AI 助手回答时,引用的内容仍会发送至你配置的模型服务。</span></div>
<div class="lss-bottom-note"><Laptop :size="16" :stroke-width="1.8" aria-hidden="true" /><span>{{ selectedModel?.backend==='remote' ? '检索由你配置的远端服务执行:建立索引和搜索时,聊天文本会发送到该服务。AI 助手回答时,引用的内容仍会发送至你配置的模型服务。' : '本地搜索不上传聊天。AI 助手回答时,引用的内容仍会发送至你配置的模型服务。' }}</span></div>
</div>

<Teleport to="body"><div v-if="dialog" class="lss-overlay" @click.self="closeDialog" @keydown.esc.stop="closeDialog" @keydown.tab="trapFocus">
<section ref="dialogRef" class="lss-dialog local-search-settings" :class="{ 'lss-model-dialog':dialog==='models', 'lss-scope-dialog':dialog==='scope' }" role="dialog" aria-modal="true" :aria-label="dialogTitle" tabindex="-1">
<header class="lss-dialog-heading"><div><h4>{{ dialogTitle }}</h4><p>{{ dialog==='models' ? '按语言和电脑配置选择,下载完成后点击「使用此模型」。' : dialog==='scope' ? '按分类选择要检索的聊天' : '导入后会校验版本和文件完整性。' }}</p></div><button type="button" aria-label="关闭" :disabled="busy" @click="closeDialog"><X :size="16" :stroke-width="1.8" aria-hidden="true" /></button></header>
<header class="lss-dialog-heading"><div><h4>{{ dialogTitle }}</h4><p>{{ dialog==='models' ? '按语言和电脑配置选择,下载完成后点击「使用此模型」;远端模型无需下载,填好服务地址和模型名后直接使用。' : dialog==='scope' ? '按分类选择要检索的聊天' : '导入后会校验版本和文件完整性。' }}</p></div><button type="button" aria-label="关闭" :disabled="busy" @click="closeDialog"><X :size="16" :stroke-width="1.8" aria-hidden="true" /></button></header>
<p v-if="dialogError" class="lss-feedback lss-error" role="alert">{{ dialogError }}</p>
<template v-if="dialog==='models'"> <div class="lss-models" role="list" aria-label="可用检索模型">
<article v-for="m in state.models || []" :key="m.id" class="lss-card lss-model" :class="{selected: form.model===m.id}" role="listitem">
<div class="lss-row"><h5>{{ m.name }} <small v-if="m.recommended">推荐</small></h5><span class="lss-note">{{ m.downloaded ? '已下载' : stage(m.job) }}</span></div>
<p>{{ m.description }}</p><p class="lss-note">{{ bytes(m.size) }} · 本地运行 · {{ m.license }}</p>
<details class="lss-source"><summary>模型来源</summary><a :href="`https://huggingface.co/${m.repo}`" target="_blank" rel="noopener noreferrer">{{ m.repo }}</a><p>{{ m.id.startsWith('bge') ? '原作者 BAAI · ONNX 转换 Xenova' : '原作者 intfloat' }}</p><p>固定版本 {{ m.revision.slice(0, 12) }}</p></details>
<div class="lss-row"><h5>{{ m.name }} <small v-if="m.recommended">推荐</small></h5><span class="lss-note">{{ m.backend==='remote' ? '局域网服务' : m.downloaded ? '已下载' : stage(m.job) }}</span></div>
<p>{{ m.description }}</p><p class="lss-note">{{ m.backend==='remote' ? '远端运行' : bytes(m.size)+' · 本地运行' }}{{ m.license ? ' · '+m.license : '' }}</p>
<div v-if="m.backend==='remote'" class="lss-endpoint">
<div class="lss-presets"><span>快速预设</span><button v-for="preset in remotePresets" :key="preset.label" type="button" :disabled="busy" @click="applyPreset(preset)">{{ preset.label }}</button></div>
<span>服务地址</span><input v-model="form.remote_endpoint" type="text" inputmode="url" placeholder="https://192.168.1.10:11434/v1" aria-label="远端向量服务地址" />
<span>模型名</span><input v-model="form.remote_model" type="text" placeholder="tencent/WeMM-Embedding-2B" aria-label="远端服务模型名" />
<span>API Key(可选)</span><input v-model="form.remote_api_key" type="password" autocomplete="off" placeholder="服务不校验密钥时留空" aria-label="远端服务 API Key" />
<label class="lss-check"><input v-model="form.remote_allow_self_signed" type="checkbox" />允许自签名证书</label>
<div class="lss-actions"><button type="button" :disabled="busy || !form.remote_endpoint" @click="testRemote">测试连接</button></div>
<p v-if="remoteTestText" class="lss-note" :class="{ 'lss-error': remoteTest && remoteTest.status !== 'success' }">{{ remoteTestText }}</p>
<small>地址填到 /v1 即可(也接受完整的 /v1/embeddings);服务需提供 OpenAI 兼容的向量接口。建立索引和搜索时,文本会发送到该服务。</small>
</div>
<details class="lss-source"><summary>模型来源</summary><a v-if="m.repo" :href="`https://huggingface.co/${m.repo}`" target="_blank" rel="noopener noreferrer">{{ m.repo }}</a><p>{{ m.backend==='remote' ? '向量推理由你填写的服务提供,本工具只按 OpenAI 兼容协议调用' : m.id.startsWith('bge') ? '原作者 BAAI · ONNX 转换 Xenova' : '原作者 intfloat' }}</p><p v-if="m.revision">固定版本 {{ m.revision.slice(0, 12) }}</p></details>
<template v-if="m.job && !['done','error'].includes(m.job.status)"><progress :value="m.job.total ? m.job.bytes : undefined" :max="m.job.total || undefined" :aria-label="`${m.name} 下载进度`" /><p class="lss-note">{{ stage(m.job) }} · {{ bytes(m.job.bytes) }} / {{ bytes(m.job.total) }}<span v-if="m.job.speed"> · {{ bytes(m.job.speed) }}/s</span><span v-if="m.job.stage==='retry_wait'"> · {{ Math.max(0,Math.ceil(m.job.next_retry-now)) }} 秒后重试</span></p></template>
<p v-if="m.job?.error" class="lss-error">{{ m.job.error }}</p>
<div class="lss-actions lss-model-actions">
<button v-if="m.downloaded" type="button" :disabled="busy || running || !account || form.model===m.id" @click="selectModel(m)">{{ form.model===m.id ? '当前使用' : '使用此模型' }}</button>
<button v-else-if="!['running','queued'].includes(m.job?.status)" type="button" class="lss-primary" :disabled="busy" @click="act(()=>request(`/models/${m.id}/download`,{method:'POST'}))">{{ m.job ? '继续 / 重试' : '下载' }}</button>
<button v-else type="button" :disabled="busy" @click="act(()=>request(`/models/${m.id}/pause`,{method:'POST'}))">暂停</button>
<button v-if="!m.downloaded" type="button" :disabled="busy || modelActive(m.id)" @click="openImport(m.id)">离线导入</button>
<button v-if="m.downloaded || m.job" type="button" :disabled="busy || form.model===m.id" @click="removeModel(m)">{{ m.downloaded ? '删除' : '取消并清理' }}</button>
<button v-if="m.backend!=='remote' && (m.downloaded || m.job)" type="button" :disabled="busy || form.model===m.id" @click="removeModel(m)">{{ m.downloaded ? '删除' : '取消并清理' }}</button>
</div>
</article>
</div>
Expand Down Expand Up @@ -178,7 +188,7 @@ import UiSelect from './UiSelect.vue'
const props=defineProps({accountWide:{type:Boolean,default:false}})
const { selectedAccount: account }=storeToRefs(useChatAccountsStore())
const api=useAiApi(), route=useRoute()
const state=ref({}), form=reactive({enabled:false,model:null,usernames:[],days:90,start:null,end:null,device:'auto',device_id:0,auto_update:true,read_batch_size:0})
const state=ref({}), form=reactive({enabled:false,model:null,usernames:[],days:90,start:null,end:null,device:'auto',device_id:0,auto_update:true,read_batch_size:0,remote_endpoint:null,remote_model:null,remote_api_key:null,remote_allow_self_signed:false})
const statusRef=ref(null)
const advancedOpen=ref(false),busy=ref(false),dialogError=ref(''),error=ref(''),notice=ref(''),now=ref(Date.now()/1000),gpuDevices=ref([])
const dialog=ref(''),dialogRef=ref(null),scopeSearch=ref(''),scopeDraft=ref([]),chats=ref([]),importId=ref(''),importPath=ref('')
Expand Down Expand Up @@ -282,6 +292,33 @@ const date=n=>n ? new Date(n*1000).toLocaleString() : '—'
const elapsed=j=>`${Math.max(0,Math.floor((j.finished || (['paused','done','error'].includes(j.status) ? j.updated : now.value) || now.value)-j.started))} 秒`
const stage=j=>!j ? '未下载' : ({counting:'正在统计消息总量',importing:'导入中',pausing:'正在暂停',queued:'等待整理',connecting:'连接中',downloading:'下载中',retry_wait:'等待重试',paused:'已暂停',verifying:'校验中',verified:'校验完成',loading:'测试模型',done:'已完成',error:'处理失败',reading:'读取聊天记录',organizing:'整理消息片段',embedding:'正在理解聊天内容',saving:'正在保存搜索数据',installing:'安装加速组件'}[j.status==='error' || j.status==='paused' ? j.status : j.stage] || '处理中')
const request=(path,options={},scoped=false)=>api.request(`/local-search${path}${scoped && account.value ? `${path.includes('?')?'&':'?'}account=${encodeURIComponent(account.value)}`:''}`,options)
// 远端模型只按 OpenAI 兼容协议调用,常见服务用预设把地址和模型名一次填好。
const remotePresets=[
{label:'WeMM 2B',endpoint:'',model:'tencent/WeMM-Embedding-2B'},
{label:'Ollama',endpoint:'http://127.0.0.1:11434/v1',model:'bge-m3'},
{label:'LM Studio',endpoint:'http://127.0.0.1:1234/v1',model:'text-embedding-bge-m3'},
]
const remoteTest=ref(null)
function applyPreset(preset){
if(preset.endpoint)form.remote_endpoint=preset.endpoint
if(preset.model)form.remote_model=preset.model
remoteTest.value=null
}
const remoteTestText=computed(()=>{
const result=remoteTest.value
if(!result)return ''
if(result.status!=='success')return result.error || '连接失败'
const parts=[`连接成功,向量维度 ${result.dimension}`]
// 服务端不会校验模型名(写错也会返回向量),所以清单里没有就一定要提醒。
if(result.model_listed===false)parts.push(`但服务的模型清单里没有「${form.remote_model}」`)
else if(result.models?.length)parts.push(`服务提供 ${result.models.length} 个模型:${result.models.slice(0,5).join('、')}`)
return parts.join(';')
})
async function testRemote(){
await act(async()=>{
remoteTest.value=await request('/remote/test',{method:'POST',body:{endpoint:form.remote_endpoint || '',model:form.remote_model || '',api_key:form.remote_api_key || '',allow_self_signed:!!form.remote_allow_self_signed}})
})
}
let refreshDone=Promise.resolve()
let timer, loading=false, version=0, gpuVersion=0, needsReset=false, previousFocus, stopEvents
const defaults={...form,usernames:[]}
Expand Down Expand Up @@ -520,6 +557,12 @@ summary{cursor:pointer}
.lss-model small{color:#079b57;font-size:10px;font-weight:400}.lss-model p{font-size:11px}
.lss-model .lss-row{align-items:flex-start}.lss-model-actions{margin-top:auto;padding-top:12px}
.lss-source{font-size:10px;color:var(--app-text-secondary);margin-top:10px;overflow-wrap:anywhere}.lss-source a{color:#079b57}
.lss-endpoint{display:grid;gap:4px;margin-top:10px}
.lss-endpoint>span{font-size:11px;font-weight:500}
.lss-endpoint small{color:var(--app-text-secondary);font-size:10px;line-height:1.5}
.lss-presets{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:10px;color:var(--app-text-secondary)}
.lss-presets button{font-size:10px;padding:2px 8px;border-radius:999px;border:1px solid var(--app-border,#d8e1da);background:transparent;color:inherit;cursor:pointer}
.lss-presets button:disabled{opacity:.5;cursor:default}
@container (max-width:600px){.lss-heading{flex-wrap:wrap}.lss-start{align-items:stretch;flex-direction:column}.lss-start>button{width:100%}.lss-time-row{flex-wrap:wrap}.lss-time-row>.lss-note{width:100%}.lss-model-summary{flex-wrap:wrap}.lss-model-summary>.lss-grow{min-width:180px}.lss-step{padding:15px}.lss-advanced>summary>.lss-note{font-size:9px}.lss-advanced .lss-row{flex-wrap:wrap}}
@media(max-width:600px){.lss-models,.lss-grid{grid-template-columns:1fr}.lss-row{flex-wrap:wrap}.lss-dialog{padding:16px}.lss-model-dialog{width:100%}}
@media(prefers-reduced-motion:reduce){.agent-icon-spin{animation:none}}
Expand Down
17 changes: 17 additions & 0 deletions src/wechat_decrypt_tool/ai/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ def is_lan_address(host):
return any(address in network for network in LAN_NETWORKS)


# 代理绕过比「明文 HTTP 例外」宽:覆盖网络(Tailscale 等用 100.64.0.0/10)和链路本地
# 地址都只能直连。这些地址交给系统代理转发会被拦下,报错看不出真正原因,所以判定
# 独立于上面的明文策略,不改动 HTTP 明文允许范围。
PROXY_BYPASS_NETWORKS = (*LAN_NETWORKS, ipaddress.ip_network("100.64.0.0/10"), ipaddress.ip_network("169.254.0.0/16"))


def is_proxy_bypass_host(host):
"""必须绕过系统代理、直连的地址:私有网段、覆盖网络(CGNAT)、链路本地。只认 IP 字面量。"""
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 PROXY_BYPASS_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:
Expand Down
2 changes: 1 addition & 1 deletion src/wechat_decrypt_tool/ai/runtime_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def check_runtime(model_root=None):
output = io.BytesIO(); picture.save(output, 'PDF')
assert any(part.get('image') for part in parse_document(output.getvalue(), '.pdf'))
report['media'] = parsed + ['.pdf', 'JPEG', 'PNG', 'WEBP', 'GIF']
report['catalog'] = [model_spec(name)['id'] for name in ['bge-small-zh', 'bge-base-zh', 'e5-small']]
report['catalog'] = [model_spec(name)['id'] for name in ['bge-small-zh', 'bge-base-zh', 'e5-small', 'remote-openai']]
if model_root:
engine = LocalInference()
try:
Expand Down
40 changes: 40 additions & 0 deletions src/wechat_decrypt_tool/local_search/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,46 @@ def model_spec(id):
return model
raise ValueError('不支持的检索模型')

def remote_spec(id, endpoint=None, model=None, api_key=None, allow_self_signed=False, dimension=None):
"""远端模型的地址、模型名和密钥都由账号配置覆盖,目录里的值只是预设。

协议固定为 OpenAI 兼容的 /v1/embeddings,所以这里不再区分服务商。
"""
spec = model_spec(id)
if spec.get('backend') != 'remote':
return spec
spec = {**spec}
endpoint = str(endpoint or '').strip().rstrip('/')
if endpoint:
spec['endpoint'] = endpoint
model = str(model or '').strip()
if model:
spec['model'] = model
key = str(api_key or '').strip()
if key:
spec['api_key'] = key
if allow_self_signed:
spec['allow_self_signed'] = True
if dimension:
try:
spec['dimension'] = int(dimension)
except (TypeError, ValueError):
pass
return spec


def remote_identity(spec):
"""索引身份指纹。

同一个模型 id 下换了服务地址、模型名或维度,索引里的旧向量就和新查询不在同一个
向量空间,余弦距离算出来只是无意义的数字,所以这些字段必须参与「要不要重建」的判断。
"""
if spec.get('backend') != 'remote':
return None
fields = [str(spec.get('protocol') or 'openai'), str(spec.get('endpoint') or ''), str(spec.get('model') or ''),
str(spec.get('dimension') or '')]
return hashlib.sha256('\n'.join(fields).encode()).hexdigest()[:32]

def file_hash(path):
digest = hashlib.sha256()
with Path(path).open('rb') as stream:
Expand Down
4 changes: 4 additions & 0 deletions src/wechat_decrypt_tool/local_search/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ def update(self, job, **values):
def models(self):
values = []
for spec in CATALOG:
if spec.get('backend') == 'remote':
values.append({**spec, 'downloaded': True, 'size': 0,
'job': None})
continue
root = model_dir(self.root, spec['id'])
record = self.store.get('model', spec['id']) or {}
available = record.get('revision') == spec['revision'] and all((root / f['path']).is_file() and (root / f['path']).stat().st_size == f['size'] for f in spec['files'])
Expand Down
33 changes: 26 additions & 7 deletions src/wechat_decrypt_tool/local_search/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,22 +197,41 @@ def prune(self, generation, usernames, start, end):
db.execute('DELETE FROM chunks WHERE NOT EXISTS (SELECT 1 FROM members WHERE chunk=chunks.id)')


def make_chunks(messages, tokenizer, max_tokens=384, overlap=64):
def make_chunks(messages, tokenizer, max_tokens=384, overlap=64, max_chars=None):
"""把消息切成可嵌入的块。

有 tokenizer 时按 token 精确切;远端服务可以不提供 tokenizer,这时退化为按字符切:
字符数是 token 数的上界(中文 1 字≈1 token,英文更省),宁可多切几块,也不越过模型
上下文——溢出会变成服务端 400 或静默截断,比多几个块难排查得多。
"""
if tokenizer is None:
window = max(32, int(max_chars or max_tokens))
step = max(1, window - max(1, min(overlap, window // 4)))
else:
window = max_tokens
step = max(1, max_tokens - overlap)

def units(text):
return tokenizer.encode(text + '\n', add_special_tokens=False).ids if tokenizer is not None else list(text + '\n')

def text_of(part):
return tokenizer.decode(part) if tokenizer is not None else ''.join(part)

chunks, tokens, owners, username, last_time = [], [], [], '', 0
def flush():
nonlocal tokens, owners
for start in range(0,len(tokens),max_tokens-overlap):
part=tokens[start:start+max_tokens]
text=tokenizer.decode(part)
if text.strip(): chunks.append({'text':text,'sources':list(dict.fromkeys(owners[start:start+max_tokens])),'username':username})
if start+max_tokens>=len(tokens): break
for start in range(0,len(tokens),step):
part=tokens[start:start+window]
text=text_of(part)
if text.strip(): chunks.append({'text':text,'sources':list(dict.fromkeys(owners[start:start+window])),'username':username})
if start+window>=len(tokens): break
tokens,owners=[],[]
for message in messages:
text = '\n'.join(filter(None,[message.get('text',''),message.get('local_attachment_text','')])).strip()
if not text: continue
if username and (username != message['username'] or message['time'] - last_time > 600): flush()
username, last_time = message['username'], message['time']
part = tokenizer.encode(text+'\n', add_special_tokens=False).ids
part = units(text)
tokens.extend(part);owners.extend([message['source']]*len(part))
flush()
return chunks
Expand Down
Loading