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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Deep links for your notes (`vixel://open?…`).** Right-click any item (or use the
inspector) → Copy Link / Copy Markdown Link, paste into Obsidian / Notes / anywhere;
clicking the link opens Vixel at that item, and video/audio jumps to the timestamp
(`[clip.mp4 @ 0:42](vixel://…)`). Links are addressed by content hash, so they keep
working after re-indexing or moving files. The `vixel` scheme is registered with the
OS (macOS `open-url` incl. cold start; Windows/Linux single-instance argv hand-off).
- **Local API (Settings → General, off by default).** Runs the server edition's HTTP
stack inside the desktop app against the same library: web UI + JSON API on
`127.0.0.1:41721` (port configurable), optional LAN access (binds 0.0.0.0, password
required — browse your desktop library from a phone), and a read-only MCP endpoint
at `/mcp` (Streamable HTTP, bearer token with copy/regenerate in Settings).
- **MCP for AI clients.** Read-only tools `search_media` / `get_media` / `find_similar`
(results carry scores, matched video time ranges, `vixel://` deep links and http media
links). Available three ways: desktop Local API `/mcp`, server edition `/mcp`
(enable with `VIXEL_MCP_TOKEN`), and `vixel mcp` on stdio for Claude Desktop /
Cursor / Claude Code without any network service (setup snippet in the README).

## [0.5.1] - 2026-10-15

### Fixed
Expand Down
29 changes: 29 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Local AI search for photos, videos and audio. Fully offline, no account, nothing
- **Clean up similar photos.** Finds burst shots, re-exports and lightly edited copies, suggests which one to keep, and hides the rest in Vixel or moves them to the Trash after you confirm.
- **Offline map, similar photos, de-duplication, HEIC / RAW, quick look, dark mode.**
- **CLI for scripts and agents:** `vixel search "sunset at the beach" --json`.
- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link gives a `vixel://` deep link to paste into Obsidian, Notes or any editor; clicking it opens Vixel at that item (video/audio jumps to the timestamp). Links are addressed by content hash, so they survive re-indexing.
- **Local API (off by default).** Settings → General → Local API serves a web UI and HTTP API for your desktop library — enable LAN access (password required) to browse it from your phone — plus a read-only MCP endpoint at `/mcp` for AI clients (see below).

<img src="docs/media/video.jpg" width="860" alt="Video segment search" />

Expand Down Expand Up @@ -113,6 +115,33 @@ means full support (ARM: `grep -o asimddp /proc/cpuinfo`).
Full deployment guide (compose, Synology walkthrough, permissions, reverse proxy / HTTPS,
backup, limitations): [docs/server/README.md](docs/server/README.md).

## MCP (let AI clients query your library)

`vixel mcp` runs a **stdio MCP server** with read-only tools (`search_media` / `get_media` /
`find_similar`), so Claude Desktop, Cursor or Claude Code can search your library **without
any network service**. Results include `vixel://` deep links that open the item in the app
(videos jump to the matched timestamp). It needs a source checkout (`npm install &&
npm run build:cli`; models inside an installed Vixel.app are found automatically):

```json
{
"mcpServers": {
"vixel": {
"command": "/absolute/path/Vixel/node_modules/.bin/electron",
"args": ["/absolute/path/Vixel/dist/cli/cli/index.js", "mcp"],
"env": { "ELECTRON_RUN_AS_NODE": "1" }
}
}
}
```

> Why Electron as the runtime: the repo's better-sqlite3 is compiled for Electron's ABI;
> with `ELECTRON_RUN_AS_NODE=1` Electron behaves as plain Node and opens no window.

No source checkout? The desktop app's Settings → General → **Local API** exposes the same
tools over Streamable HTTP at `http://127.0.0.1:41721/mcp` (bearer token shown in Settings);
the server edition enables it with `VIXEL_MCP_TOKEN` (see [docs/server/README.md](docs/server/README.md)).

## Build from source

Node.js 20+.
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ SCRFD 人脸检测 + MobileFaceNet 512 维特征 + sqlite-vec 近邻索引。
- **HEIC / RAW** —— macOS 上通过系统 `sips` 解码 CR2 / CR3 / NEF / ARW / DNG / RAF / ORF / RW2,保留 EXIF。
- **快速查看** —— 空格预览,⌘1–5 切换 全部 / 图片 / 视频 / 音频 / 地图。
- **命令行** —— `vixel search "海边" --json`,方便脚本和 Agent 调用(见下文);结果里的 `matchedBy` 说明是画面、图中文字还是文件名命中。
- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接」得到 `vixel://` 深链接,贴进 Obsidian、备忘录等任意笔记,点击回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。
- **本地接口(默认关闭)** —— 设置 → 通用 → 本地接口:在本机开出网页界面和 HTTP API,手机浏览器也能访问桌面的资料库(局域网访问必须设口令);同一服务的 `/mcp` 是给 AI 客户端的只读 MCP 接口(见下文)。
- **中英文界面** —— 跟随系统语言,也可在 设置 → 通用 里切换。
- **诊断** —— `vixel doctor` / `vixel bench` 体检和测速,`VIXEL_PROFILE=1` 打印慢操作,见 [docs/debugging.md](docs/debugging.md)。

Expand Down Expand Up @@ -252,9 +254,36 @@ npm run cli -- people
npm run cli -- stats
```

支持 `search` · `info` · `similar` · `people` · `stats` · `caption` · `folders`,
支持 `search` · `info` · `similar` · `people` · `stats` · `caption` · `folders` · `mcp`,
`--json` 输出便于脚本和 AI Agent 调用。需要先运行一次应用以创建照片库。

### MCP(让 AI 客户端查你的库)

`vixel mcp` 把同一套查询变成 **stdio 形态的 MCP 服务器**(工具全部只读:`search_media` /
`get_media` / `find_similar`),Claude Desktop、Cursor、Claude Code 等本地 AI 客户端
**不开任何网络服务**就能搜你的库;结果里的 `vixel://` 链接点开直接回到应用并定位(视频跳到时间点)。
需要源码仓库(`npm install && npm run build:cli`;已安装的 Vixel.app 里的语义模型会被自动找到),
客户端里这样配置(Claude Desktop 的 `claude_desktop_config.json`、Cursor 的 `mcp.json` 同形):

```json
{
"mcpServers": {
"vixel": {
"command": "/绝对路径/Vixel/node_modules/.bin/electron",
"args": ["/绝对路径/Vixel/dist/cli/cli/index.js", "mcp"],
"env": { "ELECTRON_RUN_AS_NODE": "1" }
}
}
}
```

> 为什么用 Electron 当运行时:仓库的 better-sqlite3 按 Electron 的 ABI 构建,普通 `node` 加载不了;
> `ELECTRON_RUN_AS_NODE=1` 下 Electron 就是一个纯 Node,不会弹窗口。

不想碰源码:桌面应用 设置 → 通用 → **本地接口** 打开后,同样的工具在 `http://127.0.0.1:41721/mcp`
(Streamable HTTP + Bearer 令牌,设置页里拷贝);服务器版设 `VIXEL_MCP_TOKEN` 同理,
见 [docs/server/README.md](docs/server/README.md)。

## 开发

```
Expand Down
51 changes: 51 additions & 0 deletions docs/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ volumes:
| `VIXEL_PORT` | `8080` | 容器内监听端口 |
| `VIXEL_COOKIE_SECURE` | 关 | `=1` 会话 cookie 永远带 `Secure`(全程 HTTPS 时用) |
| `VIXEL_TRUST_PROXY` | 关 | `=1` 信任反代头:`X-Forwarded-Proto` 为 https 时自动加 `Secure`,登录限速按 `X-Forwarded-For` 计 |
| `VIXEL_MCP_TOKEN` | (不设 = 关闭) | 开启 `/mcp` 端点(AI 客户端的只读 MCP 接口)的 Bearer 令牌,≥ 16 位。生成:`openssl rand -base64 24` |

## MCP(AI 客户端接口)

设置 `VIXEL_MCP_TOKEN` 后,服务器在 `/mcp` 提供一个 [MCP](https://modelcontextprotocol.io)
端点(Streamable HTTP),AI 客户端 / Agent 可以用自然语言查询媒体库。工具**全部只读**:
`search_media`(语义 + 图内文字 + 文件名搜索,命中视频时间段,带 vixel:// 深链接和 http 媒体链接)、
`get_media`(单项详情:OCR 文字、标签、人物等)、`find_similar`(找相似)。

```yaml
environment:
VIXEL_MCP_TOKEN: "用 openssl rand -base64 24 生成"
```

客户端配置(以 Claude Desktop / Cursor 的远程 MCP 为例):URL `http://<主机>:8080/mcp`,
HTTP 头 `Authorization: Bearer <令牌>`。验证:

```bash
curl -s http://<主机>:8080/mcp -H "Authorization: Bearer <令牌>" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

注意:搜索结果(文件名、路径、识别出的文字等)会发给你连接的那个 AI 客户端;令牌与登录口令
相互独立,泄露任何一个都应立即更换。

## 反向代理 / HTTPS

Expand Down Expand Up @@ -251,6 +276,32 @@ search instant. Lower `VIXEL_THREADS` (e.g. `2`) for a gentler, slower index.
| `VIXEL_PORT` | `8080` | Port inside the container |
| `VIXEL_COOKIE_SECURE` | off | `=1` always sets `Secure` on the session cookie (all-HTTPS setups) |
| `VIXEL_TRUST_PROXY` | off | `=1` trusts proxy headers: `Secure` cookie when `X-Forwarded-Proto` is https, login rate limit keyed by `X-Forwarded-For` |
| `VIXEL_MCP_TOKEN` | (unset = disabled) | Bearer token that enables the `/mcp` endpoint (read-only MCP interface for AI clients), min 16 chars. Generate: `openssl rand -base64 24` |

## MCP (AI client interface)

With `VIXEL_MCP_TOKEN` set, the server exposes an [MCP](https://modelcontextprotocol.io)
endpoint (Streamable HTTP) at `/mcp`, so AI clients and agents can query the library in
natural language. All tools are **read-only**: `search_media` (semantic + text-in-image +
filename search, with matched video time ranges, a vixel:// deep link and an http media link),
`get_media` (details of one item: OCR text, tags, people, …) and `find_similar`.

```yaml
environment:
VIXEL_MCP_TOKEN: "generate with: openssl rand -base64 24"
```

Client setup (remote MCP in Claude Desktop / Cursor): URL `http://<host>:8080/mcp` with the
HTTP header `Authorization: Bearer <token>`. To verify:

```bash
curl -s http://<host>:8080/mcp -H "Authorization: Bearer <token>" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Note: search results (file names, paths, recognized text, …) are sent to whichever AI client
you connect. The token is independent of the login password; rotate either immediately if leaked.

## Reverse proxy / HTTPS

Expand Down
9 changes: 9 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ files:
- '!node_modules/onnxruntime-node/bin/napi-v*/!(${platform})/**'
asar: true
afterPack: build/adhoc-sign.cjs
# 深链接(vixel://open?…,见 src/shared/deeplink.ts):注册成系统级 URL scheme,
# 笔记里的链接能直接拉起 / 聚焦 Vixel(macOS 写进 Info.plist 的 CFBundleURLTypes,Windows 写注册表)
protocols:
- name: Vixel Deep Link
schemes:
- vixel
# asarIntegrity: 防止打包后 asar 被替换;Electron 启动时校验摘要
asarUnpack:
- resources/**
Expand All @@ -47,6 +53,9 @@ extraResources:
# LiteRT-LM 原生库:只带目标平台那一份(npm run models:download 按平台解压到这里)
- from: resources/litert/${platform}-${arch}
to: litert
# 网页前端(本地接口的浏览器界面;prepackage 跑 npm run build:web 保证它存在)
- from: dist/web
to: web

mac:
icon: build/icon.icns
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"prepackage": "npm run build:web",
"package": "electron-builder",
"postinstall": "electron-builder install-app-deps",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
Expand Down
12 changes: 12 additions & 0 deletions scripts/dev-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ try {
changed = true
}

// 深链接(vixel://open?…)开发期也要能用:macOS 的 URL scheme 必须声明在 Info.plist 里,
// setAsDefaultProtocolClient 才会生效。打包版由 electron-builder.yml 的 protocols 写入,与此无关。
if (read('CFBundleURLTypes:0:CFBundleURLSchemes:0') !== 'vixel') {
rewrite(plist, readFileSync(plist))
if (read('CFBundleURLTypes') === null) execFileSync(pb, ['-c', 'Add :CFBundleURLTypes array', plist])
execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0 dict', plist])
execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLName string Vixel Deep Link', plist])
execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array', plist])
execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string vixel', plist])
changed = true
}

if (existsSync(ourIcns)) {
const want = readFileSync(ourIcns)
if (!readFileSync(icns).equals(want)) {
Expand Down
12 changes: 11 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { initDatabase, type DatabaseInstance } from '../core/db'
import { SearchEngine } from '../core/search'
import { initEmbeddingServicePath, getEmbeddingService } from '../core/embedding'
import { cmdDoctor, cmdBench } from './doctor'
import { cmdMcp } from './mcp'
import { litertPlatformDir } from '../core/embedding/providers/litert/native'

// 跨平台 userData 路径。打包版目录名取 productName(Vixel),开发版取 package.json name(vixel);
Expand Down Expand Up @@ -366,6 +367,7 @@ Usage:
vixel folders List watched folders
vixel doctor Health check: counts, queue errors, query plans
vixel bench Time hot queries and searches on this library
vixel mcp MCP server on stdio for AI clients (read-only; see README)

Flags:
--json JSON output (for scripts/agents)
Expand All @@ -387,6 +389,11 @@ Environment:

const { command, positional, flags } = parseArgs(args)

// MCP:stdout 是 JSON-RPC 协议通道,任何日志(迁移提示、模型加载)都改走 stderr
if (command === 'mcp') {
console.log = (...a: unknown[]) => console.error(...a)
}

// 检查数据库
const dbPath = getDbPath()
if (!existsSync(dbPath)) {
Expand All @@ -398,7 +405,7 @@ Environment:
// 初始化。只有改 caption 需要写库;其余只读打开,不跑迁移,应用开着也安全
const modelsDir = getModelsDir()
initEmbeddingServicePath(getUserDataPath(), modelsDir, getLiteRtDir())
if (!modelsDir && (command === 'search' || command === 'similar' || command === 'bench')) {
if (!modelsDir && (command === 'search' || command === 'similar' || command === 'bench' || command === 'mcp')) {
console.error('Note: models not found — semantic search disabled (set VIXEL_MODELS_DIR). Text and filename matching still work.')
}
const writes = command === 'caption' && flags.set !== undefined
Expand Down Expand Up @@ -440,6 +447,9 @@ Environment:
case 'bench':
await cmdBench(db, flags)
break
case 'mcp':
await cmdMcp(db)
break
default:
console.error(`Unknown command: ${command}`)
console.error('Run "vixel --help" for usage.')
Expand Down
60 changes: 60 additions & 0 deletions src/cli/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* `vixel mcp` —— stdio 传输的 MCP 服务器:本地 AI 客户端(Claude Desktop / Cursor / Claude Code)
* 不开任何网络服务就能查询媒体库。工具与桌面"本地接口"、服务器版 /mcp 完全相同
* (core/mcp:search_media / get_media / find_similar,全部只读),数据库按 CLI 惯例只读打开。
*
* 传输即 MCP 规范的 stdio 形态:stdin 每行一条 JSON-RPC 消息,响应单行 JSON 写 stdout;
* 日志一律走 stderr(cli/index.ts 在 mcp 命令下已把 console.log 重定向过去)。
* stdin 关闭(客户端退出)即结束。
*/
import { createInterface } from 'readline'
import { join } from 'path'
import type { DatabaseInstance } from '../core/db'
import { SearchEngine } from '../core/search'
import { getEmbeddingService } from '../core/embedding'
import { createMcpServer, errorResponse, JSONRPC_PARSE_ERROR } from '../core/mcp/server'
import { createToolContext } from '../core/mcp/tools'

/** serverInfo 用的应用版本(dist/cli/cli → 仓库根在三级之上) */
function appVersion(): string {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return (require(join(__dirname, '..', '..', '..', 'package.json')) as { version?: string }).version ?? '0.0.0'
} catch {
return '0.0.0'
}
}

export async function cmdMcp(db: DatabaseInstance): Promise<void> {
// 语义模型尽力加载(找不到模型时 OCR / 文件名 / 标签搜索照常);失败原因已在 index.ts 提示过
try {
await getEmbeddingService().init()
} catch { /* BM25-only */ }

const mcp = createMcpServer(createToolContext(db, new SearchEngine(db)), {
name: 'vixel',
version: appVersion(),
instructions:
'Read-only access to the user\'s local Vixel photo/video/audio library. ' +
'The "link" field in results is a vixel:// deep link that opens the item in the Vixel app ' +
'(for video/audio it jumps to the matched timestamp).',
})

console.error('[mcp] vixel stdio server ready (read-only); waiting for JSON-RPC on stdin')
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
const write = (msg: unknown): void => { process.stdout.write(JSON.stringify(msg) + '\n') }
for await (const line of rl) {
const s = line.trim()
if (!s) continue
let msg: unknown
try {
msg = JSON.parse(s)
} catch {
write(errorResponse(null, JSONRPC_PARSE_ERROR, 'Parse error'))
continue
}
// stdio 没有 http 基址:结果只带 vixel:// 深链接
const out = await mcp.handleMessage(msg)
if (out) write(out)
}
}
Loading
Loading