feat: add LAN access configuration for desktop app, allowing users to enable public API access with corresponding UI updates and translations

This commit is contained in:
wizardchen
2026-04-11 16:34:40 +08:00
committed by lyingbug
parent d99e2cbac3
commit dfaaa62f14
14 changed files with 421 additions and 558 deletions
+25 -3
View File
@@ -7,9 +7,11 @@ import (
// App holds Wails-bound state for the desktop shell.
type App struct {
ctx context.Context
backendURL string
shutdownCh chan struct{}
ctx context.Context
backendURL string
apiLanBaseURL string
listenPublic bool
shutdownCh chan struct{}
}
// NewApp creates a new App application struct.
@@ -46,3 +48,23 @@ func (a *App) GetDesktopHTTPPortSetting() int {
func (a *App) SetDesktopHTTPPortSetting(port int) error {
return SaveDesktopHTTPPortPreference(port)
}
// GetDesktopHTTPBindPublicSetting returns whether API listens on all interfaces (0.0.0.0).
func (a *App) GetDesktopHTTPBindPublicSetting() bool {
return LoadDesktopHTTPBindPublic()
}
// SetDesktopHTTPBindPublicSetting saves LAN/public listen preference. Restart the app for it to take effect.
func (a *App) SetDesktopHTTPBindPublicSetting(v bool) error {
return SaveDesktopHTTPBindPublicPreference(v)
}
// GetAPILanBaseURL returns a suggested base URL for other devices on the LAN (…/api/v1), or empty if not in bind-public mode or IP detection failed.
func (a *App) GetAPILanBaseURL() string {
return a.apiLanBaseURL
}
// GetDesktopListenPublicActive is true when this session’s API server is listening on all interfaces (runtime), not the saved preference.
func (a *App) GetDesktopListenPublicActive() bool {
return a.listenPublic
}
+80 -7
View File
@@ -222,10 +222,17 @@ func main() {
return fmt.Errorf("failed to start server: %v", err)
}
actualAddr := listener.Addr().(*net.TCPAddr).String()
// Store the backend URL so Wails can load it
app.backendURL = fmt.Sprintf("http://%s", actualAddr)
tcpAddr := listener.Addr().(*net.TCPAddr)
port := tcpAddr.Port
app.listenPublic = LoadDesktopHTTPBindPublic()
// Reverse proxy and webview API calls always use loopback; avoid 0.0.0.0 / [::] as dial target.
app.backendURL = fmt.Sprintf("http://127.0.0.1:%d", port)
app.apiLanBaseURL = ""
if app.listenPublic {
if ip := desktopPreferredLANIPv4(); ip != nil {
app.apiLanBaseURL = fmt.Sprintf("http://%s:%d/api/v1", ip.String(), port)
}
}
// Handle graceful shutdown from Wails OnShutdown hook
go func() {
@@ -254,7 +261,7 @@ func main() {
app.shutdownCh <- struct{}{} // trigger shutdown
}()
logger.Infof(context.Background(), "Server is running at %s", actualAddr)
logger.Infof(context.Background(), "Server is running at %s (proxy -> %s)", tcpAddr.String(), app.backendURL)
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server error: %v", err)
}
@@ -334,6 +341,10 @@ func main() {
inject := fmt.Sprintf(`try{window.__WEKNORA_API_BASE__=%s}catch(e){}`, strconv.Quote(apiRoot))
wailsruntime.WindowExecJS(ctx, inject)
}
if lan := strings.TrimSpace(app.apiLanBaseURL); lan != "" {
injectLan := fmt.Sprintf(`try{window.__WEKNORA_API_LAN_BASE__=%s}catch(e){}`, strconv.Quote(lan))
wailsruntime.WindowExecJS(ctx, injectLan)
}
},
OnShutdown: app.shutdown,
Bind: []interface{}{
@@ -411,9 +422,12 @@ func resolveDesktopDataPath(rawPath, defaultRelativePath, appSupportDir string)
}
// desktopBackendListenAddr returns the TCP address for the embedded Gin server (Wails desktop).
// Always binds loopback 127.0.0.1. Uses saved UI preference (desktop-prefs.json) when set, else ephemeral port :0.
// Binds 127.0.0.1 by default, or 0.0.0.0 when http_bind_public is set in desktop-prefs.json.
func desktopBackendListenAddr() string {
const host = "127.0.0.1"
host := "127.0.0.1"
if LoadDesktopHTTPBindPublic() {
host = "0.0.0.0"
}
pref := LoadDesktopPrefsHTTPPort()
if pref >= 1 && pref <= 65535 {
return net.JoinHostPort(host, strconv.Itoa(pref))
@@ -421,6 +435,65 @@ func desktopBackendListenAddr() string {
return net.JoinHostPort(host, "0")
}
// desktopPreferredLANIPv4 picks a non-loopback IPv4 for “other devices on this network” URL hints.
func desktopPreferredLANIPv4() net.IP {
ifaces, err := net.Interfaces()
if err == nil {
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP == nil {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
if ip4.IsPrivate() {
return ip4
}
}
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP == nil {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() || ip4.IsUnspecified() {
continue
}
return ip4
}
}
}
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return nil
}
defer conn.Close()
la, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || la.IP == nil {
return nil
}
return la.IP.To4()
}
func migrateLegacyDesktopData(resourcesDir, targetDataDir string) {
legacyDataDir := filepath.Join(resourcesDir, "data")
if info, err := os.Stat(legacyDataDir); err != nil || !info.IsDir() {
+40 -18
View File
@@ -10,7 +10,8 @@ import (
const desktopPrefsFileName = "desktop-prefs.json"
type desktopPrefs struct {
HTTPPort int `json:"http_port"`
HTTPPort int `json:"http_port"`
HTTPBindPublic bool `json:"http_bind_public"`
}
func desktopPrefsDir() (string, error) {
@@ -33,24 +34,45 @@ func desktopPrefsFilePath() (string, error) {
return filepath.Join(dir, desktopPrefsFileName), nil
}
// LoadDesktopPrefsHTTPPort returns http_port from prefs file, or 0 if unset / invalid (ephemeral port on each launch).
func LoadDesktopPrefsHTTPPort() int {
func loadDesktopPrefs() desktopPrefs {
path, err := desktopPrefsFilePath()
if err != nil {
return 0
return desktopPrefs{}
}
data, err := os.ReadFile(path)
if err != nil {
return 0
return desktopPrefs{}
}
var p desktopPrefs
if json.Unmarshal(data, &p) != nil {
return 0
return desktopPrefs{}
}
if p.HTTPPort < 0 || p.HTTPPort > 65535 {
return 0
p.HTTPPort = 0
}
return p.HTTPPort
return p
}
func saveDesktopPrefs(p desktopPrefs) error {
path, err := desktopPrefsFilePath()
if err != nil {
return err
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
// LoadDesktopPrefsHTTPPort returns http_port from prefs file, or 0 if unset / invalid (ephemeral port on each launch).
func LoadDesktopPrefsHTTPPort() int {
return loadDesktopPrefs().HTTPPort
}
// LoadDesktopHTTPBindPublic returns whether the embedded API server should listen on all interfaces (0.0.0.0).
func LoadDesktopHTTPBindPublic() bool {
return loadDesktopPrefs().HTTPBindPublic
}
// SaveDesktopHTTPPortPreference persists listen port preference. port 0 means use a random free port on each launch.
@@ -58,14 +80,14 @@ func SaveDesktopHTTPPortPreference(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("invalid port")
}
path, err := desktopPrefsFilePath()
if err != nil {
return err
}
p := desktopPrefs{HTTPPort: port}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
cur := loadDesktopPrefs()
cur.HTTPPort = port
return saveDesktopPrefs(cur)
}
// SaveDesktopHTTPBindPublicPreference persists whether to listen on 0.0.0.0 for LAN/public access.
func SaveDesktopHTTPBindPublicPreference(v bool) error {
cur := loadDesktopPrefs()
cur.HTTPBindPublic = v
return saveDesktopPrefs(cur)
}
+11 -173
View File
@@ -1,178 +1,16 @@
# WeKnora Lite
# WeKnora Lite 与标准版区别
零外部依赖的单二进制部署模式。无需 Docker、PostgreSQL、Redis,适合快速体验和小规模私有部署。
Lite 面向希望快速在本地使用、部署尽量简单的场景;标准版面向多租户协作与完整企业能力。主要差异如下。
## 架构
| 组件 | 标准版 | Lite 版 |
|------|--------|---------|
| 数据库 | PostgreSQL | SQLite (WAL) |
| 向量检索 | pgvector / Qdrant / ES | sqlite-vec (vec0) |
| 关键词检索 | ParadeDB BM25 / ES | SQLite FTS5 |
| 消息队列 | Redis + Asynq | 内存 SyncTaskExecutor |
| 会话存储 | Redis | 内存 |
| 流管理 | Redis / 内存 | 内存 |
| 文件存储 | MinIO / COS / 本地 | 本地 |
| 文档解析 | DocReader (gRPC) | 不可用(文本/段落导入可用)|
| 前端 | Nginx 容器 | Go 内置静态文件服务 |
| 维度 | Lite | 标准版 |
| --------- | ----------------------------------------------- | --------------------------------- |
| **共享空间** | 不提供共享空间(成员邀请、跨成员共享知识库与智能体等) | 提供共享空间及租户隔离检索等协作能力 |
| **租户与账号** | 单租户;开箱即用,**无需注册** | 多租户;通常需要注册、登录与组织管理 |
| **文档解析** | 内置仅 **Simple** 类型解析引擎;可通过 **Cloud** 等方式接入其他解析能力 | 可配置多种解析引擎(含高精度等),与完整文档处理链路集成 |
| **部署形态** | **单应用、零依赖**(不依赖独立的数据库、消息队列等外部服务栈) | 典型为 Docker Compose 等多服务部署,依赖与组件更多 |
| **数据归属** | 数据**完全在本地**存储与处理 | 私有化部署时数据也可在本地;具体取决于你的部署方式 |
| **网络暴露** | **默认仅本机访问**;可按需配置,**选择是否放行到公网** | 按部署与安全策略自行绑定地址与网关 |
## 快速开始
### 方式一:Homebrew 安装(macOS / Linux,推荐)
```bash
brew tap Tencent/weknora https://github.com/Tencent/WeKnora
brew install weknora-lite
```
安装完成后,推荐使用 **brew services** 以后台服务方式运行:
```bash
brew services start weknora-lite # 启动服务(开机自动启动)
brew services info weknora-lite # 查看运行状态
# 首次启动自动创建配置文件 ~/.config/weknora/.env.lite
# 数据存储在 ~/.local/share/weknora/
# 访问 http://localhost:8080
```
常用服务管理命令:
```bash
brew services stop weknora-lite # 停止服务
brew services restart weknora-lite # 重启服务(修改配置后需重启)
brew services info weknora-lite # 查看状态
```
日志位于 `$(brew --prefix)/var/log/weknora-lite.log`。
也可以前台直接运行:
```bash
weknora-lite
```
如需修改配置(LLM 服务地址、安全密钥等):
```bash
$EDITOR ~/.config/weknora/.env.lite
brew services restart weknora-lite # 修改配置后重启生效
```
> **LLM 服务**:WeKnora Lite 需要一个 OpenAI 兼容的 LLM 服务来提供对话和 Embedding 能力。
> 可以使用 [Ollama](https://ollama.com/)(本地)、通义千问、OpenAI 等任何兼容服务,
> 在配置文件中设置对应的地址和 API Key 即可。
### 方式二:下载预编译包
从 [GitHub Releases](https://github.com/Tencent/WeKnora/releases) 下载对应平台的 tarball:
| 文件 | 平台 |
|------|------|
| `WeKnora-lite_*_linux_amd64.tar.gz` | Linux x86_64 |
| `WeKnora-lite_*_linux_arm64.tar.gz` | Linux ARM64 |
| `WeKnora-lite_*_darwin_amd64.tar.gz` | macOS Intel |
| `WeKnora-lite_*_darwin_arm64.tar.gz` | macOS Apple Silicon |
```bash
# 1. 解压
tar xzf WeKnora-lite_v0.2.0_darwin_arm64.tar.gz
cd WeKnora-lite_v0.2.0_darwin_arm64
# 2. 配置
cp .env.lite.example .env.lite
# 编辑 .env.lite,配置 LLM 服务地址和安全密钥
# 3. 运行
set -a && source .env.lite && set +a
./WeKnora-lite
# 访问 http://localhost:8080
```
### 方式三:从源码构建
前置条件:Go 1.22+(需要 CGO)、C 编译器 (gcc/clang)、Node.js 22+(前端构建)。
```bash
make run-lite
```
## 配置
Lite 模式通过 `.env.lite` 文件配置(模板见 `.env.lite.example`)。关键环境变量:
```bash
DB_DRIVER=sqlite # 使用 SQLite
DB_PATH=./data/weknora.db # 数据库文件路径
RETRIEVE_DRIVER=sqlite # SQLite 检索引擎 (FTS5 + sqlite-vec)
STORAGE_TYPE=local # 本地文件存储
LOCAL_STORAGE_BASE_DIR=./data/files
STREAM_MANAGER_TYPE=memory # 内存流管理
# REDIS_ADDR= # 留空 = 不使用 Redis
OLLAMA_BASE_URL=http://127.0.0.1:11434
```
完整配置参见 [.env.lite.example](../.env.lite.example)。
## 后台运行
### Homebrew 用户(macOS / Linux)
Homebrew 安装后直接使用 `brew services` 管理,详见上方「快速开始 → 方式一」。
### Linux systemd(tarball 安装)
tarball 中附带 `weknora-lite.service` 模板,按需修改路径后安装:
```bash
# 创建用户和目录
sudo useradd -r -s /sbin/nologin weknora
sudo mkdir -p /opt/weknora/data
sudo cp WeKnora-lite web/ .env.lite /opt/weknora/
sudo chown -R weknora:weknora /opt/weknora
# 安装并启动服务
sudo cp weknora-lite.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now weknora-lite
# 管理
sudo systemctl status weknora-lite # 查看状态
sudo journalctl -u weknora-lite -f # 查看日志
```
## 功能限制
与标准版相比,Lite 版有以下限制:
- **文档解析**:不支持文件上传和 URL 导入的自动解析(PDF/Word/Excel 等)。可使用文本和段落方式手动导入。
- **向量检索**:sqlite-vec 使用精确 KNN(非近似),适合 10 万条以下的小规模数据集。
- **并发**:SQLite 单写者模型,高并发写入场景下性能不如 PostgreSQL。
- **任务队列**:无持久化队列,进程重启后未完成的异步任务会丢失。
- **知识图谱**:默认禁用 (`NEO4J_ENABLE=false`)。
- **Agent Skills 沙箱**:默认禁用 (`WEKNORA_SANDBOX_MODE=disabled`)。
## 数据目录
默认所有数据存储在 `./data/` 目录下:
```
data/
├── weknora.db # SQLite 数据库
├── weknora.db-wal # WAL 日志
└── files/ # 上传文件
```
备份只需复制整个 `data/` 目录。
## 桌面版(Wails)与 Chrome 扩展
桌面 **WeKnora Lite** 内置的 HTTP API 默认监听 **`127.0.0.1` 上的随机端口**(避免每次启动占用固定端口、并减少系统防火墙提示)。浏览器里的 **Chrome 扩展**与网页不同,需要你在扩展里填写明确的 API 地址(例如 `http://127.0.0.1:<端口>/api/v1`),因此会遇到两类问题:
1. **端口每次变**:重启桌面应用后端口可能变化,扩展里保存的地址会失效。
2. **扩展权限**:上架的扩展须在 `manifest.json` 的 `host_permissions`(或同类字段)中声明对 **`http://127.0.0.1/*`** 和/或 **`http://localhost/*`** 的访问,否则无法请求本机 Lite服务。
**推荐做法**
- 在桌面应用内打开 **设置 → API 信息**,在 **「本地 API 端口(桌面版)」** 中填写固定端口(如 `37841`)并保存,**重启应用** 后端口即固定;配置会写入用户配置目录下的 `desktop-prefs.json`。
- 启动桌面应用后,在同一页复制 **API 根路径**(与 `http://127.0.0.1:<端口>/api/v1` 一致),粘贴到扩展配置;扩展侧 API Key 与网页版租户 API Key 相同。
- 若扩展仍无法连接:确认扩展已声明上述 `host_permissions`;可尝试把地址里的主机从 `localhost` 改成 **`127.0.0.1`**(或反之)以排除解析差异。
若你不需要多团队协作、复杂解析流水线与多服务架构,Lite 更适合个人或小团队在本机零依赖试用;需要共享空间、多租户与完整解析引擎矩阵时,请使用标准版。
+18
View File
@@ -160,6 +160,24 @@ docker compose restart app
如果重启后短时间恢复正常,但再次访问又出现相同现象,仍应优先检查浏览器代理、缓存和多环境串连问题,而不是直接判断为后端配置丢失。
## 8. SSRF 校验白名单(`SSRF_WHITELIST`)
可选配置。在 `.env` 中设置 `SSRF_WHITELIST`,用于在 URL 校验等环节将指定目标加入白名单,从而绕过常规 SSRF 限制。值为逗号分隔的多条规则,每条可以是:
- **精确域名**:如 `api.internal`
- **通配域名**:如 `*.example.com`
- **IPv4**:如 `203.0.113.5`
- **IPv6**:如 `2001:db8::1`(不要带方括号)
- **CIDR**:如 `10.0.0.0/8`、`2001:db8::/32`
列入白名单的地址会在 URL 校验等处绕过常规 SSRF 规则,**生产环境请谨慎配置**,仅加入确实需要且可信的目标。
示例(与 `.env.example` 一致,可按需取消注释并修改):
```bash
# SSRF_WHITELIST=internal.service,*.corp.example,172.16.0.0/12,2001:db8::1,fd00::/8
```
## P.S.
如果以上方式未解决问题,请在issue中描述您的问题,并提供必要的日志信息辅助我们进行问题排查
-330
View File
@@ -1,330 +0,0 @@
## 介绍
WeKora 是一个可立即在生产环境投入的企业级RAG框架,实现智能文档理解和检索功能。该系统采用模块化设计,将文档理解、向量存储、推理文件等功能分离。
![arc](./images/arc.png)
---
## PipeLine
WeKnora 处理文档需要多个步骤:插入-》知识提取-》索引-》检索-》生成,整个流程支持多种检索方法,
![](./images/pipeline2.jpeg)
以用户上传的一张住宿流水单pdf文件为例,详细介绍下其数据流:
### 1. 接收请求与初始化
+ **请求识别**: 系统收到一个请求,并为其分配了唯一的 `request_id=Lkq0OGLYu2fV`,用于追踪整个处理流程。
+ **租户与会话验证**:
- 系统首先验证了租户信息(ID: 1, Name: Default Tenant)。
- 接着开始处理一个知识库问答(Knowledge QA)请求,该请求属于会话 `1f241340-ae75-40a5-8731-9a3a82e34fdd`。
+ **用户问题**: 用户的原始问题是:“**入住的房型是什么**”。
+ **消息创建**: 系统为用户的提问和即将生成的回答分别创建了消息记录,ID 分别为 `703ddf09-...` 和 `6f057649-...`。
### 2. 知识库问答流程启动
系统正式调用知识库问答服务,并定义了将要按顺序执行的完整处理管道(Pipeline),包含以下9个事件:
`[rewrite_query, preprocess_query, chunk_search, chunk_rerank, chunk_merge, filter_top_k, into_chat_message, chat_completion_stream, stream_filter]`
---
### 3. 事件执行详情
#### 事件 1: `rewrite_query` - 问题改写
+ **目的**: 为了让检索更精确,系统需要结合上下文来理解用户的真实意图。
+ **操作**:
1. 系统检索了当前会话最近的20条历史消息(实际检索到8条)作为上下文。
2. 调用了一个名为 `deepseek-r1:7b` 的本地大语言模型。
3. 模型根据聊天历史分析出提问者是“Liwx”,并将原问题“入住的房型是什么”改写得更具体。
+ **结果**: 问题被成功改写为:“**Liwx本次入住的房型是什么**”。
#### 事件 2: `preprocess_query` - 问题预处理
+ **目的**: 将改写后的问题进行分词,转换为适合搜索引擎处理的关键词序列。
+ **操作**: 对改写后的问题进行了分词处理。
+ **结果**: 生成了一串关键词:“`需要 改写 用户 问题 入住 房型 根据 提供 信息 入住 人 Liwx 选择 房型 双床 房 因此 改写 后 完整 问题 为 Liwx 本次 入住 房型`”。
#### 事件 3: `chunk_search` - 知识区块检索
这是最核心的**检索(Retrieval)**步骤,系统执行了两次混合搜索(Hybrid Search)。
+ **第一次搜索 (使用改写后的完整问句)**:
- **向量检索**:
1. 加载嵌入模型 `bge-m3:latest` 将问句转换为一个1024维的向量。
2. 在PostgreSQL数据库中进行向量相似度搜索,找到了2个相关的知识区块(chunk),ID 分别为 `e3bf6599-...` 和 `3989c6ce-...`。
- **关键词检索**:
1. 同时,系统也进行了关键词搜索。
2. 同样找到了上述2个知识区块。
- **结果合并**: 两种方法找到的4个结果(实际是2个重复的)被去重,最终得到2个唯一的知识区块。
+ **第二次搜索 (使用预处理后的关键词序列)**:
- 系统使用分词后的关键词重复了上述的**向量检索**和**关键词检索**过程。
- 最终也得到了相同的2个知识区块。
+ **最终结果**: 经过两次搜索和结果合并,系统锁定了2个最相关的知识区块,并将它们的内容提取出来,准备用于生成答案。
#### 事件 4: `chunk_rerank` - 结果重排序
+ **目的**: 使用一个更强大的模型对初步检索出的结果进行更精细的排序,以提高最终答案的质量。
+ **操作**: 日志显示 `Rerank model ID is empty, skipping reranking`。这意味着系统配置了重排序步骤,但没有指定具体的重排序模型,因此**跳过了此步骤**。
#### 事件 5: `chunk_merge` - 区块合并
+ **目的**: 将内容上相邻或相关的知识区块进行合并,形成更完整的上下文。
+ **操作**: 系统分析了检索到的2个区块,并尝试进行合并。根据日志,最终处理后仍然是2个独立的区块,但已按相关性分数排好序。
#### 事件 6: `filter_top_k` - Top-K 过滤
+ **目的**: 仅保留最相关的K个结果,防止过多无关信息干扰语言模型。
+ **操作**: 系统配置保留前5个(Top-K = 5)最相关的区块。由于当前只有2个区块,它们全部通过了此过滤器。
#### 事件 7 & 8: `into_chat_message` & `chat_completion_stream` - 生成回答
这是**生成(Generation)**步骤。
+ **目的**: 基于检索到的信息,生成自然流畅的回答。
+ **操作**:
1. 系统将检索到的2个知识区块的内容、用户的原始问题以及聊天历史整合在一起,形成一个完整的提示(Prompt)。
2. 再次调用 `deepseek-r1:7b` 大语言模型,并以**流式(Stream)**的方式请求生成答案。流式输出可以实现打字机效果,提升用户体验。
#### 事件 9: `stream_filter` - 流式输出过滤
+ **目的**: 对模型生成的实时文本流进行后处理,过滤掉不需要的特殊标记或内容。
+ **操作**:
- 系统设置了一个过滤器,用于移除模型在思考过程中可能产生的内部标记,如 `<think>` 和 `</think>`。
- 日志显示,模型输出的第一个词块是 `<think> 根据`,过滤器成功拦截并移除了 `<think>` 标记,只将“根据”及之后的内容传递下去。
### 4. 完成与响应
+ **发送引用**: 在生成答案的同时,系统将作为依据的2个知识区块作为“参考内容”发送给前端,以便用户查证来源。
+ **更新消息**: 当模型生成完所有内容后,系统将完整的回答更新到之前创建的消息记录(ID: `6f057649-...`)中。
+ **请求结束**: 服务器返回 `200` 成功状态码,标志着本次从提问到回答的完整流程结束。
### 总结
这个日志完整地记录了一次典型的RAG流程:系统通过**问题改写**和**预处理**来精确理解用户意图,接着利用**向量与关键词混合检索**从知识库中找到相关信息,虽然跳过了**重排序**,但依然执行了**合并**与**过滤**,最后将检索到的知识作为上下文,交由大语言模型**生成**流畅、准确的回答,并通过**流式过滤**保证了输出的纯净性。
## 文档解析切分
代码实现了一个独立的、通过gRPC通信的微服务,专门负责文档内容的深度解析、分块和多模态信息提取。它正是“异步处理”阶段的核心执行者。
### **整体架构**
这是一个基于Python的gRPC服务,其核心职责是接收文件(或URL),并将其解析成结构化的、可供后续处理(如向量化)的文本块(Chunks)。
+ `server.py`: 服务的入口和网络层。它负责启动一个多进程、多线程的gRPC服务器,接收来自Go后端的请求,并将解析结果返回。
+ `parser.py`: 设计模式中的**外观(Facade)模式**。它提供了一个统一的`Parser`类,屏蔽了内部多种具体解析器(如PDF、DOCX、Markdown等)的复杂性。外部调用者(`server.py`)只需与这个`Parser`类交互。
+ `base_parser.py`: 解析器的基类,定义了所有具体解析器共享的核心逻辑和抽象方法。这是整个解析流程的“大脑”,包含了最复杂的文本分块、图片处理、OCR和图像描述生成等功能。
---
### **详细工作流程**
当Go后端启动异步任务时,它会携带文件内容和配置信息,向这个Python服务发起一次gRPC调用。以下是完整的处理流程:
#### **第一步:请求接收与分发 (**`server.py`** & **`parser.py`**)
1. **gRPC服务入口 (**`server.py: serve`**)**:
- 服务通过`serve()`函数启动。它会根据环境变量(`GRPC_WORKER_PROCESSES`, `GRPC_MAX_WORKERS`)启动一个**多进程、多线程**的服务器,以充分利用CPU资源,提高并发处理能力。
- 每个工作进程都监听在指定的端口(如50051),准备接收请求。
2. **请求处理 (**`server.py: ReadFromFile`**)**:
- 当Go后端发起`ReadFromFile`请求时,其中一个工作进程会接收到该请求。
- 该方法首先会解析请求中的参数,包括:
* `file_name`, `file_type`, `file_content`:文件的基本信息和二进制内容。
* `read_config`: 一个包含所有解析配置的复杂对象,如`chunk_size`(分块大小)、`chunk_overlap`(重叠大小)、`enable_multimodal`(是否启用多模态处理)、`storage_config`(对象存储配置)、`vlm_config`(视觉语言模型配置)等。
- 它将这些配置整合成一个`ChunkingConfig`数据对象。
- 最关键的一步是调用 `self.parser.parse_file(...)`,将解析任务交给`Parser`外观类处理。
3. **解析器选择 (**`parser.py: Parser.parse_file`**)**:
- `Parser`类接收到任务后,首先调用`get_parser(file_type)`方法。
- 该方法会根据文件类型(例如 `'pdf'`)在一个字典 `self.parsers` 中查找对应的具体解析器类(例如 `PDFParser`)。
- 找到后,它会**实例化**这个`PDFParser`类,并将`ChunkingConfig`等所有配置信息传递给构造函数。
#### **第二步:核心解析与分块 (**`base_parser.py`**)**
它触及了整个流程的核心:**如何保证信息的上下文完整性和原始顺序**。
根据 `base_parser.py` 代码,**最终切分出的 Chunk 中的文本、表格和图像是按照它们在原始文档中的出现顺序来保存的**。
这个顺序得以保证,主要归功于 `BaseParser` 中几个设计精巧的方法相互协作。我们来详细追踪一下这个流程。
整个顺序的保证可以分为三个阶段:
1. **阶段一:统一的文本流创建 (**`pdf_parser.py`**)**:
- 在 `parse_into_text` 方法中,您的代码会**逐页**处理PDF。
- 在每一页内部,它会按照一定的逻辑(先提取非表格文本,再附加表格,最后附加图像占位符)将所有内容**拼接成一个长字符串** (`page_content_parts`)。
- **关键点**: 虽然在这个阶段,文本、表格和图像占位符的拼接顺序可能不是100%精确到字符级别,但它保证了**同一页的内容会在一起**,并且大致遵循了从上到下的阅读顺序。
- 最后,所有页面的内容被 `"\n\n--- Page Break ---\n\n"` 连接起来,形成一个**包含了所有信息(文本、Markdown表格、图像占位符)的、单一的、有序的文本流 (**`final_text`**)**。
2. **阶段二:原子化与保护 (**`_split_into_units`**)**:
- 这个单一的 `final_text` 被传递给 `_split_into_units` 方法。
- 这个方法是**保证结构完整性的关键**。它使用正则表达式,将**整个Markdown表格**和**整个Markdown图像占位符**识别为**不可分割的原子单元 (atomic units)**。
- 它会将这些原子单元(表格、图片)和它们之间的普通文本块,按照它们在 `final_text` 中出现的**原始顺序**,切分成一个列表 (`units`)。
- **结果**: 我们现在有了一个列表,例如 `['一些文本', '![...](...)', '另一些文本', '|...|...|\n|---|---|\n...', '更多文本']`。这个列表中的元素顺序**完全等同于它们在原始文档中的顺序**。
3. **阶段三:顺序分块 (**`chunk_text`**)**:
- `chunk_text` 方法接收到这个**有序的 **`units`** 列表**。
- 它的工作机制非常简单直接:它会**按顺序**遍历这个列表中的每一个单元(`unit`)。
- 它将这些单元**依次添加**到一个临时的 `current_chunk` 列表中,直到这个块的长度接近 `chunk_size` 的上限。
- 当一个块满了之后,它就被保存下来,然后开始一个新的块(可能会带有上一个块的重叠部分)。
- **关键点**: 因为 `chunk_text` **严格按照 **`units`** 列表的顺序进行处理**,所以它永远不会打乱表格、文本和图像之间的相对顺序。一个在文档中先出现的表格,也必然会出现在一个序号更靠前的 Chunk 中。
4. **阶段四:图像信息附加 (**`process_chunks_images`**)**:
- 在文本块被切分好之后,`process_chunks_images` 方法会被调用。
- 它会处理**每一个**已经生成好的 Chunk。
- 在每个 Chunk 内部,它会找到图像占位符,然后进行AI处理。
- 最后,它会将处理好的图像信息(包含永久URL、OCR文本、图像描述等)附加到**该 Chunk 自己**的 `.images` 属性中。
- **关键点**: 这个过程**不会改变 Chunk 的顺序或其 **`.content`** 的内容**。它只是为已经存在的、顺序正确的 Chunk 附加额外的信息。
#### **第三步:多模态处理(如果启用) (**`base_parser.py`**)**
如果 `enable_multimodal` 为 `True`,在文本分块完成后,会进入最复杂的多模态处理阶段。
1. **并发任务启动 (**`BaseParser.process_chunks_images`**)**:
- 该方法使用`asyncio`(Python的异步I/O框架)来**并发处理所有文本块中的图片**,以极大地提升效率。
- 它为每个`Chunk`创建一个异步任务`process_chunk_images_async`。
2. **处理单个块中的图片 (**`BaseParser.process_chunk_images_async`**)**:
- **提取图片引用**: 首先,使用正则表达式 `extract_images_from_chunk` 从当前块的文本中找到所有的图片引用(例如,`![alt text](image.png)`)。
- **图片持久化**: 对于找到的每个图片,并发地调用 `download_and_upload_image`。这个函数负责:
* 从其原始位置(可能是PDF内部、本地路径或远程URL)获取图片数据。
* 将图片**上传到配置好的对象存储(COS/MinIO)**。这一步至关重要,它将临时的、不稳定的图片引用转换成一个持久化、可通过URL公开访问的地址。
* 返回持久化的URL和图片对象(PIL Image)。
- **并发AI处理**: 将所有成功上传的图片收集起来,调用`process_multiple_images`。
* 该方法内部使用`asyncio.Semaphore`来限制并发数量(例如最多同时处理5张图片),防止瞬间消耗过多内存或触发模型API的速率限制。
* 对于每张图片,它会调用`process_image_async`。
3. **处理单张图片 (**`BaseParser.process_image_async`**)**:
- **OCR**: 调用`perform_ocr`,它会使用一个OCR引擎(如`PaddleOCR`)来识别图片中的所有文字。
- **图像描述 (Caption)**: 调用`get_image_caption`,它会将图片数据(转为Base64)发送给配置的视觉语言模型(VLM),生成对图片内容的自然语言描述。
- 该方法返回 `(ocr_text, caption, 持久化URL)`。
4. **结果聚合**:
- 所有图片处理完成后,包含持久化URL、OCR文本和图像描述的结构化信息,会被附加到对应`Chunk`对象的 `.images` 字段上。
#### **第四步:返回结果 (**`server.py`**)**
1. **数据转换 (**`server.py: _convert_chunk_to_proto`**)**:
- 当`parser.parse_file`执行完毕后,它返回一个包含所有处理过的`Chunk`对象的列表(`ParseResult`)。
- `ReadFromFile`方法接收到这个结果,并调用`_convert_chunk_to_proto`,将Python的`Chunk`对象(包括其内部的图片信息)转换成gRPC定义的Protobuf消息格式。
2. **响应返回**:
- 最后,gRPC服务器将这个包含所有分块和多模态信息的`ReadResponse`消息发送回给调用方——Go后端服务。
至此,Go后端就拿到了结构化、信息丰富的文档数据,可以进行下一步的向量化和索引存储了。
## 部署
支持Docker 镜像本地部署,并通过API端口提供接口服务
## 性能和监控
Weknora包含丰富的监控和测试组件:
+ 分布式跟踪:集成Jaeger用于跟踪请求在服务架构中的完整执行路。本质上,Jaeger是一种帮助用户“看见”请求在分布式系统中完整生命周期的技术。
+ 健康监控:监控服务处在健康状态
+ 可扩展性:通过容器化部署,可通过多个服务满足大规模并发请求
## QA
### 问题1: 在检索过程的执行了两次混合搜索的目的是什么?以及第一次和第二次搜索有什么不同?
这是一个非常好的观察。系统执行两次混合搜索是为了**最大化检索的准确性和召回率**,本质上是一种**查询扩展(Query Expansion)和多策略检索**的组合方法。
#### 目的
通过两种不同形式的查询(原始改写句 vs. 分词后的关键词序列)去搜索,系统可以结合两种查询方式的优点:
+ **语义检索的深度**: 使用完整的句子进行搜索,能更好地利用向量模型(如`bge-m3`)对句子整体含义的理解能力,找到语义上最接近的知识区块。
+ **关键词检索的广度**: 使用分词后的关键词进行搜索,能确保即使知识区块的表述方式与原问题不同,但只要包含了核心关键词,就有机会被命中。这对于传统的关键词匹配算法(如BM25)尤其有效。
简单来说,就是**用两种不同的“问法”去问同一个问题**,然后将两边的结果汇总起来,确保最相关的知识不会被遗漏。
#### 两次搜索的不同点
它们最核心的不同在于**输入的查询文本(Query Text)**:
1. **第一次混合搜索**
- **输入**: 使用的是经过`rewrite_query`事件后生成的、**语法完整的自然语言问句**。
- **日志证据**:
```plain
INFO [2025-08-29 09:46:36.896] [request_id=Lkq0OGLYu2fV] knowledgebase.go:266[HybridSearch] | Hybrid search parameters, knowledge base ID: kb-00000001, query text: 需要改写的用户问题是:“入住的房型是什么”。根据提供的信息,入住人Liwx选择的房型是双床房。因此,改写后的完整问题为: “Liwx本次入住的房型是什么”
```
2. **第二次混合搜索**
- **输入**: 使用的是经过`preprocess_query`事件处理后生成的、**由空格隔开的关键词序列**。
- **日志证据**:
```plain
INFO [2025-08-29 09:46:37.257] [request_id=Lkq0OGLYu2fV] knowledgebase.go:266[HybridSearch] | Hybrid search parameters, knowledge base ID: kb-00000001, query text: 需要 改写 用户 问题 入住 房型 根据 提供 信息 入住 人 Liwx 选择 房型 双床 房 因此 改写 后 完整 问题 为 Liwx 本次 入住 房型
```
最终,系统将这两次搜索的结果进行去重和合并(日志中显示每次都找到2个结果,去重后总共还是2个),从而得到一个更可靠的知识集合,用于后续的答案生成。
### 问题2:重排序模型分析
Reranker(重排器)是目前RAG领域中非常先进的技术,它们在工作原理和适用场景上有着显著的区别。
简单来说,它们代表了从“**专门的判别模型**”到“**利用大语言模型(LLM)进行判别**”再到“**深度挖掘LLM内部信息进行判别**”的演进。
以下是它们的详细区别:
#### 1. Normal Reranker (常规重排器 / 交叉编码器)
这是最经典也是最主流的重排方法。
+ **模型类型**: **序列分类模型 (Sequence Classification Model)**。本质上是一个**交叉编码器 (Cross-Encoder)**,通常基于BERT、RoBERTa等双向编码器架构。`BAAI/bge-reranker-base/large/v2-m3` 都属于这一类。
+ **工作原理**:
1. 它将**查询(Query)**和**待排序的文档(Passage)**拼接成一个单一的输入序列,例如:`[CLS] what is panda? [SEP] The giant panda is a bear species endemic to China. [SEP]`。
2. 这个拼接后的序列被完整地送入模型中。模型内部的自注意力机制(Self-Attention)可以同时分析查询和文档中的每一个词,并计算它们之间**细粒度的交互关系**。
3. 模型最终输出一个**单一的分数(Logit)**,这个分数直接代表了查询和文档的相关性。分数越高,相关性越强。
+ **关键特性**:
- **优点**: 由于查询和文档在模型内部进行了充分的、深度的交互,其**准确度通常非常高**,是衡量Reranker性能的黄金标准。
- **缺点**: **速度较慢**。因为它必须为**每一个“查询-文档”对**都独立执行一次完整的、代价高昂的计算。如果初步检索返回了100个文档,它就需要运行100次。
#### 2. LLM-based Reranker (基于LLM的重排器)
这种方法创造性地利用了通用大语言模型(LLM)的能力来进行重排。
+ **模型类型**: **因果语言模型 (Causal Language Model)**,即我们常说的GPT、Llama、Gemma这类用于生成文本的LLM。`BAAI/bge-reranker-v2-gemma` 就是一个典型的例子。
+ **工作原理**:
1. 它**不是直接输出一个分数**,而是将重排任务**转化为一个问答或文本生成任务**。
2. 它通过一个精心设计的**提示(Prompt)**来组织输入,例如:`"Given a query A and a passage B, determine whether the passage contains an answer to the query by providing a prediction of either 'Yes' or 'No'. A: {query} B: {passage}"`。
3. 它将这个完整的Prompt喂给LLM,然后**观察LLM在最后生成“Yes”这个词的概率**。
4. 这个**生成“Yes”的概率(或其Logit值)就被当作是相关性分数**。如果模型非常确信答案是“Yes”,说明它认为文档B包含了查询A的答案,即相关性高。
+ **关键特性**:
- **优点**: 能够利用LLM强大的**语义理解、推理和世界知识**,对于需要深度理解和推理才能判断相关性的复杂查询,效果可能更好。
- **缺点**: 计算开销可能非常大(取决于LLM的大小),并且性能**高度依赖于Prompt的设计**。
#### 3. LLM-based Layerwise Reranker (基于LLM分层信息的重排器)
这是第二种方法的“威力加强版”,是一种更前沿、更复杂的探究性技术。
+ **模型类型**: 同样是**因果语言模型 (Causal Language Model)**,例如`BAAI/bge-reranker-v2-minicpm-layerwise`。
+ **工作原理**:
1. 输入部分与第二种方法完全相同,也是使用“Yes/No”的Prompt。
2. 核心区别在于**分数的提取方式**。它不再仅仅依赖LLM**最后一层**的输出(即最终的预测结果)。
3. 它认为LLM在逐层处理信息的过程中,不同深度的网络层(Layer)可能捕获了不同层次的语义相关性信息。因此,它会从**模型的多个中间层**提取出关于“Yes”这个词的预测Logit。
4. 代码中的 `cutoff_layers=[28]` 参数就是告诉模型:“请把第28层的输出给我”。最终,你会得到一个或多个来自不同网络层的分数,这些分数可以被平均或以其他方式组合,形成一个更鲁棒的最终相关性判断。
+ **关键特性**:
- **优点**: 理论上可以获得**更丰富、更全面的相关性信号**,可能达到比只看最后一层更高的精度,是目前探索性能极限的一种方法。
- **缺点**: **复杂度最高**,需要对模型进行特定的修改才能提取中间层信息(代码中的`trust_remote_code=True`就是一个信号),计算开销也很大。
#### 总结对比
| 特性 | 1. Normal Reranker (常规) | 2. LLM-based Reranker (基于LLM) | 3. LLM-based Layerwise Reranker (基于LLM分层) |
| :--- | :--- | :--- | :--- |
| **底层模型** | 交叉编码器 (如BERT) | 因果语言模型 (如Gemma) | 因果语言模型 (如MiniCPM) |
| **工作原理** | 计算Query和Passage的深度交互,直接输出相关分 | 将排序任务转为"Yes/No"预测,用"Yes"的概率作为分数 | 与2类似,但从LLM的多个中间层提取"Yes"的概率 |
| **输出** | 单一的相关性分数 | 单一的相关性分数(来自最后一层) | 多个相关性分数(来自不同层) |
| **优点** | **速度与精度的最佳平衡点**,成熟稳定 | 利用LLM的推理能力,处理复杂问题 | 理论上精度最高,信号更丰富 |
| **缺点** | 相比向量检索慢 | 计算开销大,依赖Prompt设计 | **复杂度最高**,计算开销最大 |
| **推荐场景** | **大多数生产环境的首选**,效果好,易于部署 | 对答案质量有极致要求,且计算资源充足的场景 | 学术研究或追求SOTA(State-of-the-art)性能的场景 |
#### 使用建议
1. **开始阶段**: 强烈建议您**从 **`Normal Reranker`** 开始**,例如 `BAAI/bge-reranker-v2-m3`。它是目前综合表现最好的模型之一,能显著提升您的RAG系统性能,并且相对容易集成和部署。
2. **进阶探索**: 如果您发现常规Reranker在处理某些非常微妙或需要复杂推理的查询时表现不佳,并且您拥有充足的GPU资源,可以尝试 `LLM-based Reranker`。
3. **前沿研究**: `Layerwise Reranker` 更适合研究人员或希望在特定任务上压榨出最后一点性能的专家。
### 问题3:粗过滤或细过滤后的知识(带重排)如何组装发送给大模型的?
这一块主要是设计提示词,典型的指令细节,其核心任务是根据上下文回答用户问题。组装上下文时需要指定
关键约束:必须严格按照所提供文档回答,禁止使用你自己的知识回答
未知情况处理: 如果文档中没有足够的信息来回答问题,请告知“根据所掌握的资料,无法回答这个问题”
引用要求:在回答时,如果引用了某个文档内容,请在句子末尾加上文档编号
---
## 手工知识在线编辑
平台的知识库页面新增“上传文档 / 在线编辑”双入口,支持直接在浏览器中撰写并维护 Markdown 知识:
- 草稿模式用于暂存内容,草稿不会参与检索。
- 发布操作会自动触发向量化与索引构建。
- 已发布的 Markdown 知识可再次打开编辑并重新发布。
- 在对话页面的助手回答末尾提供“添加到知识库”工具,可一键带入当前问答到编辑器中确认后保存。
BIN
View File
Binary file not shown.
+12
View File
@@ -1987,6 +1987,18 @@ export default {
desktopPortSaved: 'Saved. Restart the app for it to take effect.',
desktopPortSaveFailed: 'Save failed',
desktopPortInvalid: 'Enter a port between 0 and 65535',
desktopBindPublicLabel: 'Allow LAN access to the API',
desktopBindPublicDescription:
'When enabled, the embedded server listens on 0.0.0.0 so other devices on your LAN can reach the API via the address below. This increases exposure—use host firewalls and routing rules as needed. Restart the app after changing this.',
desktopBindPublicSaved: 'Saved. Restart the app for it to take effect.',
desktopBindPublicSaveFailed: 'Save failed',
lanUrlLabel: 'LAN API base URL',
lanUrlDescription:
'Shown after “Allow LAN access” is on and the app has restarted. If the IP is wrong, replace the host with your machine’s IPv4 from system network settings.',
lanUrlCopyTitle: 'Copy LAN API URL',
lanUrlCopySuccess: 'LAN API URL copied',
lanUrlUnavailable:
'The API is listening on all interfaces, but no LAN IPv4 could be detected automatically. Find your IPv4 in network settings and build http://YOUR_IP:PORT/api/v1 manually.',
}
},
system: {
+13
View File
@@ -1435,8 +1435,21 @@ export default {
desktopPortSaved: "저장되었습니다. 적용하려면 앱을 다시 시작하세요.",
desktopPortSaveFailed: "저장 실패",
desktopPortInvalid: "0~65535 사이의 포트를 입력하세요",
desktopBindPublicLabel: "Allow LAN access to the API",
desktopBindPublicDescription:
"When enabled, the embedded server listens on 0.0.0.0 so other devices on your LAN can reach the API via the address below. This increases exposure—use host firewalls and routing rules as needed. Restart the app after changing this.",
desktopBindPublicSaved: "Saved. Restart the app for it to take effect.",
desktopBindPublicSaveFailed: "Save failed",
lanUrlLabel: "LAN API base URL",
lanUrlDescription:
"Shown after \"Allow LAN access\" is on and the app has restarted. If the IP is wrong, replace the host with your machine IPv4 from system network settings.",
lanUrlCopyTitle: "Copy LAN API URL",
lanUrlCopySuccess: "LAN API URL copied",
lanUrlUnavailable:
"The API is listening on all interfaces, but no LAN IPv4 could be detected automatically. Find your IPv4 in network settings and build http://YOUR_IP:PORT/api/v1 manually.",
},
},
system: {
title: "시스템 정보",
sectionDescription: "시스템 버전 정보 및 사용자 계정 설정 보기",
+12
View File
@@ -1282,6 +1282,18 @@ export default {
desktopPortSaved: 'Сохранено. Перезапустите приложение, чтобы применить.',
desktopPortSaveFailed: 'Не удалось сохранить',
desktopPortInvalid: 'Введите порт от 0 до 65535',
desktopBindPublicLabel: 'Разрешить доступ к API в LAN',
desktopBindPublicDescription:
'Встроенный сервер слушает 0.0.0.0 — другие устройства в локальной сети смогут обращаться к API по адресу ниже. Риски доступа выше; настройте брандмауэр и маршрутизацию. После изменения перезапустите приложение.',
desktopBindPublicSaved: 'Сохранено. Перезапустите приложение, чтобы применить.',
desktopBindPublicSaveFailed: 'Не удалось сохранить',
lanUrlLabel: 'Базовый URL API для LAN',
lanUrlDescription:
'Отображается после включения доступа в LAN и перезапуска. Если IP неверный, подставьте IPv4 из сетевых настроек системы.',
lanUrlCopyTitle: 'Копировать LAN URL API',
lanUrlCopySuccess: 'LAN URL API скопирован',
lanUrlUnavailable:
'API слушает все интерфейсы, но LAN IPv4 не удалось определить автоматически. Узнайте IPv4 в настройках сети и соберите http://ВАШ_IP:ПОРТ/api/v1 вручную.',
}
},
system: {
+12
View File
@@ -1429,6 +1429,18 @@ export default {
desktopPortSaved: "已保存,请重启应用后生效",
desktopPortSaveFailed: "保存失败",
desktopPortInvalid: "请输入 0–65535 之间的端口",
desktopBindPublicLabel: "允许局域网访问 API",
desktopBindPublicDescription:
"开启后内嵌服务监听 0.0.0.0,同一局域网内其他设备可通过下方地址调用 API。存在数据暴露风险,请在防火墙与路由侧自行做好访问控制;修改后需重启应用。",
desktopBindPublicSaved: "已保存,请重启应用后生效",
desktopBindPublicSaveFailed: "保存失败",
lanUrlLabel: "局域网 API 地址",
lanUrlDescription:
"在已开启「允许局域网访问」且应用已重启后显示;若 IP 不准确,请在本机网络设置中查看实际 IPv4 后替换主机部分。",
lanUrlCopyTitle: "复制局域网 API 地址",
lanUrlCopySuccess: "局域网 API 地址已复制",
lanUrlUnavailable:
"当前已监听所有网卡,但未能自动识别本机 IPv4。请在本机网络设置中查看局域网地址,并手动拼接为 http://你的IP:端口/api/v1。",
},
},
system: {
+174 -27
View File
@@ -81,28 +81,69 @@
</div>
</div>
<!-- Desktop (Wails): fixed local API port for Chrome extension etc. -->
<div v-if="showDesktopPortSetting" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.desktopPortLabel') }}</label>
<p class="desc">{{ $t('tenant.api.desktopPortDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<div class="desktop-port-input-wrap">
<t-input-number
v-model="desktopPortInput"
:min="0"
:max="65535"
theme="normal"
/>
<!-- Desktop (Wails): fixed local API port + optional LAN/public listen -->
<template v-if="showDesktopPortSetting || showDesktopBindPublicSetting">
<div v-if="showDesktopPortSetting" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.desktopPortLabel') }}</label>
<p class="desc">{{ $t('tenant.api.desktopPortDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<div class="desktop-port-input-wrap">
<t-input-number
v-model="desktopPortInput"
:min="0"
:max="65535"
theme="normal"
/>
</div>
<t-button size="small" variant="text" @click="saveDesktopPort">
{{ $t('tenant.api.desktopPortSave') }}
</t-button>
</div>
<t-button size="small" variant="text" @click="saveDesktopPort">
{{ $t('tenant.api.desktopPortSave') }}
</t-button>
</div>
</div>
</div>
<div v-if="showDesktopBindPublicSetting" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.desktopBindPublicLabel') }}</label>
<p class="desc">{{ $t('tenant.api.desktopBindPublicDescription') }}</p>
</div>
<div class="setting-control desktop-bind-public-control">
<t-switch v-model="desktopBindPublicInput" @change="onDesktopBindPublicChange" />
</div>
</div>
<div v-if="wailsApiLanBaseURL" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.lanUrlLabel') }}</label>
<p class="desc">{{ $t('tenant.api.lanUrlDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<t-input
:model-value="wailsApiLanBaseURL"
readonly
type="text"
style="width: 100%; font-family: monospace; font-size: 12px;"
/>
<t-button
size="small"
variant="text"
@click="copyLanApiUrl"
:title="$t('tenant.api.lanUrlCopyTitle')"
>
<t-icon name="file-copy" />
</t-button>
</div>
</div>
</div>
<div v-if="showLanUrlUnavailableHint" class="setting-row lan-url-hint-row">
<t-alert theme="warning" :message="$t('tenant.api.lanUrlUnavailable')" />
</div>
</template>
<!-- API docs -->
<div class="setting-row">
@@ -187,7 +228,11 @@ const showApiKey = ref(false)
/** WeKnora Lite (Wails): real API origin is loopback + dynamic port, not window.location.origin */
const wailsApiBaseURL = ref<string | null>(null)
const showDesktopPortSetting = ref(false)
const showDesktopBindPublicSetting = ref(false)
const desktopPortInput = ref<number | undefined>(0)
const desktopBindPublicInput = ref(false)
const wailsApiLanBaseURL = ref<string | null>(null)
const desktopListenPublicActive = ref(false)
// Computed
const displayApiKey = computed(() => {
@@ -202,6 +247,13 @@ const displayApiKey = computed(() => {
return masked
})
const showLanUrlUnavailableHint = computed(
() =>
showDesktopBindPublicSetting.value &&
desktopListenPublicActive.value &&
!wailsApiLanBaseURL.value
)
const apiBaseUrlDisplay = computed(() => {
if (wailsApiBaseURL.value) {
return wailsApiBaseURL.value
@@ -217,12 +269,17 @@ const apiBaseUrlDisplay = computed(() => {
type WeKnoraDesktopWindow = Window & {
__WEKNORA_API_BASE__?: string
__WEKNORA_API_LAN_BASE__?: string
go?: {
main?: {
App?: {
GetAPIBaseURL?: () => Promise<string> | string
GetAPILanBaseURL?: () => Promise<string> | string
GetDesktopHTTPPortSetting?: () => Promise<number> | number
GetDesktopHTTPBindPublicSetting?: () => Promise<boolean> | boolean
GetDesktopListenPublicActive?: () => Promise<boolean> | boolean
SetDesktopHTTPPortSetting?: (port: number) => Promise<void> | void
SetDesktopHTTPBindPublicSetting?: (v: boolean) => Promise<void> | void
}
}
}
@@ -234,6 +291,7 @@ async function tryLoadWailsApiBaseURL() {
const injected = win.__WEKNORA_API_BASE__
if (typeof injected === 'string' && injected.trim()) {
wailsApiBaseURL.value = injected.trim().replace(/\/$/, '')
await tryLoadWailsLanHints(win)
return
}
const fn = win.go?.main?.App?.GetAPIBaseURL
@@ -246,10 +304,38 @@ async function tryLoadWailsApiBaseURL() {
} catch {
/* binding error */
}
await tryLoadWailsLanHints(win)
return
}
await new Promise((r) => setTimeout(r, 50))
}
await tryLoadWailsLanHints(win)
}
async function tryLoadWailsLanHints(win: WeKnoraDesktopWindow) {
const injectedLan = win.__WEKNORA_API_LAN_BASE__
if (typeof injectedLan === 'string' && injectedLan.trim()) {
wailsApiLanBaseURL.value = injectedLan.trim().replace(/\/$/, '')
}
const fnLan = win.go?.main?.App?.GetAPILanBaseURL
if (typeof fnLan === 'function' && !wailsApiLanBaseURL.value) {
try {
const raw = await Promise.resolve(fnLan())
if (typeof raw === 'string' && raw.trim()) {
wailsApiLanBaseURL.value = raw.trim().replace(/\/$/, '')
}
} catch {
/* binding error */
}
}
const fnAct = win.go?.main?.App?.GetDesktopListenPublicActive
if (typeof fnAct === 'function') {
try {
desktopListenPublicActive.value = !!(await Promise.resolve(fnAct()))
} catch {
desktopListenPublicActive.value = false
}
}
}
function desktopPortBindingsAvailable(win: WeKnoraDesktopWindow) {
@@ -257,15 +343,47 @@ function desktopPortBindingsAvailable(win: WeKnoraDesktopWindow) {
return typeof app?.GetDesktopHTTPPortSetting === 'function' && typeof app?.SetDesktopHTTPPortSetting === 'function'
}
async function loadDesktopPortPrefs() {
function desktopBindPublicBindingsAvailable(win: WeKnoraDesktopWindow) {
const app = win.go?.main?.App
return (
typeof app?.GetDesktopHTTPBindPublicSetting === 'function' &&
typeof app?.SetDesktopHTTPBindPublicSetting === 'function'
)
}
async function loadDesktopApiPrefs() {
const win = window as WeKnoraDesktopWindow
if (!desktopPortBindingsAvailable(win)) return
showDesktopPortSetting.value = true
if (desktopPortBindingsAvailable(win)) {
showDesktopPortSetting.value = true
try {
const p = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPPortSetting!())
desktopPortInput.value = typeof p === 'number' ? p : 0
} catch {
desktopPortInput.value = 0
}
}
if (desktopBindPublicBindingsAvailable(win)) {
showDesktopBindPublicSetting.value = true
try {
const b = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPBindPublicSetting!())
desktopBindPublicInput.value = !!b
} catch {
desktopBindPublicInput.value = false
}
}
}
const onDesktopBindPublicChange = async (value: boolean) => {
const v = value === true
const win = window as WeKnoraDesktopWindow
const fn = win.go?.main?.App?.SetDesktopHTTPBindPublicSetting
if (typeof fn !== 'function') return
try {
const p = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPPortSetting!())
desktopPortInput.value = typeof p === 'number' ? p : 0
} catch {
desktopPortInput.value = 0
await Promise.resolve(fn(v))
MessagePlugin.success(t('tenant.api.desktopBindPublicSaved'))
} catch (err: unknown) {
MessagePlugin.error(err instanceof Error ? err.message : t('tenant.api.desktopBindPublicSaveFailed'))
desktopBindPublicInput.value = !v
}
}
@@ -342,6 +460,22 @@ const copyApiKey = async () => {
}
}
const copyLanApiUrl = async () => {
const text = wailsApiLanBaseURL.value
if (!text) return
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
fallbackCopyText(text)
}
MessagePlugin.success(t('tenant.api.lanUrlCopySuccess'))
} catch {
fallbackCopyText(text)
MessagePlugin.success(t('tenant.api.lanUrlCopySuccess'))
}
}
const copyApiUrl = async () => {
const text = apiBaseUrlDisplay.value
try {
@@ -378,7 +512,7 @@ const formatDate = (dateStr: string | undefined) => {
// Lifecycle
onMounted(async () => {
await tryLoadWailsApiBaseURL()
await loadDesktopPortPrefs()
await loadDesktopApiPrefs()
loadInfo()
})
</script>
@@ -531,5 +665,18 @@ onMounted(async () => {
font-size: 12px;
}
}
.desktop-bind-public-control {
padding-top: 4px;
}
.lan-url-hint-row {
padding-top: 0;
border-bottom: 1px solid var(--td-component-stroke);
:deep(.t-alert) {
width: 100%;
}
}
</style>
+8
View File
@@ -3,6 +3,14 @@
export function GetAPIBaseURL():Promise<string>;
export function GetAPILanBaseURL():Promise<string>;
export function GetDesktopHTTPBindPublicSetting():Promise<boolean>;
export function GetDesktopHTTPPortSetting():Promise<number>;
export function GetDesktopListenPublicActive():Promise<boolean>;
export function SetDesktopHTTPBindPublicSetting(arg1:boolean):Promise<void>;
export function SetDesktopHTTPPortSetting(arg1:number):Promise<void>;
+16
View File
@@ -6,10 +6,26 @@ export function GetAPIBaseURL() {
return window['go']['main']['App']['GetAPIBaseURL']();
}
export function GetAPILanBaseURL() {
return window['go']['main']['App']['GetAPILanBaseURL']();
}
export function GetDesktopHTTPBindPublicSetting() {
return window['go']['main']['App']['GetDesktopHTTPBindPublicSetting']();
}
export function GetDesktopHTTPPortSetting() {
return window['go']['main']['App']['GetDesktopHTTPPortSetting']();
}
export function GetDesktopListenPublicActive() {
return window['go']['main']['App']['GetDesktopListenPublicActive']();
}
export function SetDesktopHTTPBindPublicSetting(arg1) {
return window['go']['main']['App']['SetDesktopHTTPBindPublicSetting'](arg1);
}
export function SetDesktopHTTPPortSetting(arg1) {
return window['go']['main']['App']['SetDesktopHTTPPortSetting'](arg1);
}