Skip to content
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
55 changes: 55 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,61 @@ 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 configured_spec(config, model=None):
"""从任务配置解析模型,确保查询和索引发布使用相同的远端参数。"""
return remote_spec(model or config['model'], config.get('remote_endpoint'), config.get('remote_model'),
config.get('remote_api_key'), config.get('remote_allow_self_signed'),
config.get('remote_dimension'))


def index_model_metadata(spec):
"""索引保存实际模型快照;重建期间旧向量只能由原模型查询。"""
metadata = {'identity': remote_identity(spec)}
if spec.get('backend') == 'remote':
metadata['remote_spec'] = dict(spec)
return metadata

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
Loading
Loading