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

## [Unreleased]

## [0.5.3] - 2026-10-18

### Changed
- **Copy Link now gives an http link that every app recognizes.** Right-click / inspector →
Copy Link, Copy Markdown Link and Copy Markdown with Thumbnail all produce
`http://localhost:41721/open?hash=…&t=…` instead of `vixel://…`: note, chat and document apps
highlight it as a link. Opening it on this machine jumps Vixel to that item (video at the
timestamp) and leaves a small "Opened in Vixel" page in the browser; on other devices (LAN
access on) it opens the web view. To make this work without turning on the Local API, the
desktop app now keeps a tiny link service on 127.0.0.1 while it runs: it answers only `/open`
and hash-addressed thumbnails, only to this machine, and only for top-level navigations — a
web page can't make Vixel jump by fetching the URL in the background. Links need Vixel to be
running. `vixel://` links already pasted keep working; the menu no longer copies them.

## [0.5.2] - 2026-10-18

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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. With the Local API on you can also Copy Web Link / Copy Markdown with Thumbnail: an `http://…/open?…` link that opens on phones and in web-based notes, with an inline preview in Markdown (the NAS web UI offers the same).
- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link / Copy Markdown with Thumbnail gives an `http://localhost:…/open?…` link that every note, chat and document app recognizes as a link; opening it on this machine jumps Vixel to that item (video/audio at the timestamp), and the Markdown form can show a thumbnail. With the Local API's LAN access on, links use the LAN address and open a web view on phones; the NAS web UI offers the same. Links are addressed by content hash, so they survive re-indexing (Vixel needs to be running).
- **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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ 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 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。打开了「本地接口」时还能「拷贝网页链接 / 拷贝 Markdown(带缩略图)」:`http://…/open?…` 在手机、网页笔记等装不了 Vixel 的地方也能点开,Markdown 里直接显示缩略图;NAS 版网页里同样能拷贝。
- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接 / 拷贝 Markdown(带缩略图)」得到 `http://localhost:…/open?…` 链接,任何笔记、聊天、文档软件都能识别成可点的链接;在本机点开回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点),Markdown 还能直接显示缩略图。开了「本地接口」的局域网访问后,链接换成局域网地址,手机上点开是网页查看页;NAS 版网页里同样能拷贝。链接按内容哈希定位,清库重建、移动文件后仍然有效(需要 Vixel 在运行)。
- **本地接口(默认关闭)** —— 设置 → 通用 → 本地接口:在本机开出网页界面和 HTTP API,手机浏览器也能访问桌面的资料库(局域网访问必须设口令);同一服务的 `/mcp` 是给 AI 客户端的只读 MCP 接口(见下文)。
- **中英文界面** —— 跟随系统语言,也可在 设置 → 通用 里切换。
- **诊断** —— `vixel doctor` / `vixel bench` 体检和测速,`VIXEL_PROFILE=1` 打印慢操作,见 [docs/debugging.md](docs/debugging.md)。
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vixel",
"version": "0.5.2",
"version": "0.5.3",
"description": "Private, offline AI search for your photos, videos and audio",
"main": "./out/main/index.js",
"author": "Vixel Team",
Expand Down
12 changes: 11 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,8 +756,18 @@ app.whenReady().then(async () => {
dataDir: app.getPath('userData'),
webDir: existsSync(prodWebDir) ? prodWebDir : join(process.cwd(), 'dist', 'web'),
version: app.getVersion(),
// 本机点开的网页链接:和 vixel:// 深链接同一条路 —— 定位后交给主窗口打开(带时间点)
openInApp: async (target) => {
const result = await services.api.resolveLink(target)
if (!result) return false
quickPanel!.openResult(result)
// 从浏览器点进来:vixel:// 由系统激活应用,http 不会 —— 主动把 Vixel 拉到前台
if (process.platform === 'darwin') app.focus({ steal: true })
return true
},
})
localApi.startIfEnabled()
// 常驻:没开本地接口时也起一个只响应 /open 的本机服务,"拷贝链接"出来的 http 链接才点得开
localApi.startOnLaunch()
// PDF / AI 的 pdf.js 栅格化服务(隐藏窗口按需创建;Windows/Linux 主路径,macOS 兜底)
registerPdfRenderer()
registerIpcHandlers()
Expand Down
39 changes: 30 additions & 9 deletions src/main/localApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createToolContext } from '../core/mcp/tools'
import type { VixelServices } from '../services/api'
import type { createServiceEvents } from '../services/events'
import { DEFAULT_LOCALAPI_PORT, type LocalApiPatch, type LocalApiStatus } from '../shared/localapi'
import type { DeepLinkTarget } from '../shared/deeplink'

const KEY = {
enabled: 'localapi.enabled',
Expand All @@ -31,6 +32,8 @@ const KEY = {
} as const

export interface LocalApiDeps {
/** 本机点开的 /open?… 链接:在 Vixel 窗口里定位(返回是否找到) */
openInApp: (target: DeepLinkTarget) => Promise<boolean>
db: DatabaseInstance
services: VixelServices
events: ReturnType<typeof createServiceEvents>
Expand All @@ -45,6 +48,8 @@ export interface LocalApiDeps {
export class LocalApiServer {
private server: Server | null = null
private app: VixelServerApp | null = null
/** 当前跑的是完整本地接口,还是只响应链接的常驻服务 */
private linksOnly = false
private error: string | null = null

constructor(private deps: LocalApiDeps) {}
Expand All @@ -69,7 +74,7 @@ export class LocalApiServer {

/** 正在监听的可访问地址:回环 + (LAN 时)各网卡的 IPv4 */
private urls(): string[] {
if (!this.server?.listening) return []
if (!this.server?.listening || this.linksOnly) return []
const out = [`http://127.0.0.1:${this.port}`]
if (this.lan) {
for (const addrs of Object.values(networkInterfaces())) {
Expand All @@ -81,16 +86,25 @@ export class LocalApiServer {
return out
}

/** "拷贝链接"的基址:局域网开着 → 第一个局域网地址;否则 localhost(读着比 127.0.0.1 友好,Host 校验同样放行) */
private linkBase(): string | null {
if (!this.server?.listening) return null
const lan = this.urls().find((u) => !u.startsWith('http://127.0.0.1'))
return lan ?? `http://localhost:${this.port}`
}

status(): LocalApiStatus {
return {
enabled: this.enabled,
running: this.server?.listening ?? false,
running: (this.server?.listening ?? false) && !this.linksOnly,
port: this.port,
lan: this.lan,
hasPassword: this.password.length > 0,
mcpToken: this.mcpToken,
urls: this.urls(),
error: this.error,
linksRunning: this.server?.listening ?? false,
linkBase: this.linkBase(),
}
}

Expand Down Expand Up @@ -118,7 +132,7 @@ export class LocalApiServer {
}
if (typeof p.enabled === 'boolean') db.setMetaState(KEY.enabled, p.enabled ? '1' : '0')
await this.stop()
if (this.enabled) await this.start()
await this.start()
return this.status()
}

Expand All @@ -132,14 +146,16 @@ export class LocalApiServer {
return this.status()
}

startIfEnabled(): void {
if (this.enabled) void this.start()
/** 应用启动时调用:开了本地接口就起完整栈,否则起只响应链接的常驻服务 */
startOnLaunch(): void {
void this.start()
}

private start(): Promise<void> {
this.error = null
const { db, services, events, searchEngine, dataDir, webDir, version } = this.deps
const lan = this.lan
const { db, services, events, searchEngine, dataDir, webDir, version, openInApp } = this.deps
const linksOnly = !this.enabled
const lan = this.lan && !linksOnly
const port = this.port
// LAN 必须有口令(update 已拦;这里兜底防直接改库)
if (lan && !this.password) {
Expand All @@ -152,12 +168,14 @@ export class LocalApiServer {
events,
dataDir,
webDir,
openInApp,
linksOnly,
// 仅回环时不要求登录(与浏览器扩展收集服务一致);一开局域网就强制口令,连本机也要登录
password: lan ? this.password : null,
cookieSecure: false,
trustProxy: false,
allowedRoots: () => [dataDir, ...db.getFolders().map((f) => f.path)],
mcp: {
mcp: linksOnly ? null : {
server: createMcpServer(createToolContext(db, searchEngine), {
name: 'vixel',
version,
Expand All @@ -182,7 +200,10 @@ export class LocalApiServer {
server.listen(port, lan ? '0.0.0.0' : '127.0.0.1', () => {
this.server = server
this.app = app
console.log(`[localapi] serving library on ${lan ? '0.0.0.0' : '127.0.0.1'}:${port} (lan: ${lan}, auth: ${lan ? 'password' : 'none'}, /mcp: bearer token)`)
this.linksOnly = linksOnly
console.log(linksOnly
? `[localapi] link service on 127.0.0.1:${port} (/open only)`
: `[localapi] serving library on ${lan ? '0.0.0.0' : '127.0.0.1'}:${port} (lan: ${lan}, auth: ${lan ? 'password' : 'none'}, /mcp: bearer token)`)
resolve()
})
})
Expand Down
13 changes: 5 additions & 8 deletions src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,12 @@ export function popupItemMenu(
] as MenuItemConstructorOptions[]),
{ label: multi ? t.menu.revealMany(revealLabel(), count) : revealLabel(), accelerator: 'CmdOrCtrl+Shift+R', click: pick('reveal') },
{ label: multi ? t.menu.copyPaths(count) : t.common.copyPath, click: pick('copy-path') },
// 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记,点击回到 Vixel 定位这一项
// 拷贝链接(http://…/open?…,见 shared/deeplink.ts):贴进笔记,本机点开回到 Vixel 定位这一项;
// 链接服务没起来(端口被占)时置灰
...(multi ? [] : [
{ label: t.menu.copyLink, click: pick('copy-link') },
{ label: t.menu.copyMarkdownLink, click: pick('copy-md-link') },
// 网页链接(http://…/open?…):本地接口开着时才有 —— 手机、网页笔记等装不了 Vixel 的地方也能点开
...(webLinks ? [
{ label: t.menu.copyWebLink, click: pick('copy-web-link') },
{ label: t.menu.copyWebMarkdown, click: pick('copy-web-md') },
] : []),
{ label: t.menu.copyLink, enabled: !!webLinks, click: pick('copy-link') },
{ label: t.menu.copyMarkdownLink, enabled: !!webLinks, click: pick('copy-md-link') },
{ label: t.menu.copyWebMarkdown, enabled: !!webLinks, click: pick('copy-web-md') },
] as MenuItemConstructorOptions[]),
{ label: favorite ? t.organize.unfavorite : t.organize.favorite, accelerator: 'F', registerAccelerator: false, click: pick('toggle-favorite') },
...(multi ? [] : [
Expand Down
35 changes: 14 additions & 21 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { thumbUrl } from './lib/mediaUrl'
import { sourcePathOf } from './lib/resultActions'
import { useSearchHistory, normalizeQuery } from './lib/searchHistory'
import { t } from '../../shared/i18n'
import { deepLinkForItem, markdownLinkForItem, parseLinkParams, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../shared/deeplink'
import { markdownLinkForItem, parseLinkParams, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../shared/deeplink'
import { copyText, useWebLinkBase } from './lib/webLinks'
import { FilterBar, hasExtraFilters, countExtraFilters, describeExtraFilters, useTags, type ExtraFilters } from './components/shell/FilterBar'
import { useSmartAlbums, sameAlbumState, splitAlbumFilters } from './lib/smartAlbums'
Expand Down Expand Up @@ -870,25 +870,19 @@ function App(): JSX.Element {
try { await navigator.clipboard.writeText(paths.join('\n')) } catch (e) { console.error('Copy failed:', e) }
}, [])

// 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记后点击回到 Vixel 定位;
// 命中片段的起点作为时间点,Markdown 形态是 [文件名 @ 0:42](vixel://…)
const copyDeepLinks = useCallback(async (items: SearchResult[], markdown: boolean) => {
const lines = items.map((r) => {
const url = deepLinkForItem(r.photo, r.segment?.startMs)
return markdown ? markdownLinkForItem(r.photo.fileName, url, r.segment?.startMs) : url
})
try { await navigator.clipboard.writeText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) }
}, [])

// 网页链接(http://…/open?…):本地接口 / 网页版才有;Markdown 形态带按 hash 取的缩略图,笔记里直接显示预览
// 拷贝链接(http://…/open?…,见 shared/deeplink.ts):贴进笔记 / 聊天能被识别成链接,本机点开回到 Vixel 定位,
// 手机等其他设备打开网页查看页。命中片段的起点作为时间点;Markdown 形态 [文件名 @ 0:42](链接),
// 带缩略图的形态内嵌按 hash 取的预览图
const webBase = useWebLinkBase()
const copyWebLinks = useCallback(async (items: SearchResult[], markdown: boolean) => {
const copyLinks = useCallback(async (items: SearchResult[], form: 'url' | 'markdown' | 'thumb') => {
if (!webBase) return
const lines = items.map((r) => {
const url = httpLinkForItem(webBase, r.photo, r.segment?.startMs)
return markdown && r.photo.fileHash
? markdownThumbLinkForItem(r.photo.fileName, url, thumbUrlForHash(webBase, r.photo.fileHash), r.segment?.startMs)
: url
if (form === 'url') return url
if (form === 'thumb' && r.photo.fileHash) {
return markdownThumbLinkForItem(r.photo.fileName, url, thumbUrlForHash(webBase, r.photo.fileHash), r.segment?.startMs)
}
return markdownLinkForItem(r.photo.fileName, url, r.segment?.startMs)
})
try { await copyText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) }
}, [webBase])
Expand Down Expand Up @@ -924,15 +918,14 @@ function App(): JSX.Element {
case 'quick-look': setQuickLookId(first.photo.id); break
case 'reveal': revealItems(items); break
case 'copy-path': copyPaths(items); break
case 'copy-link': copyDeepLinks(items, false); break
case 'copy-md-link': copyDeepLinks(items, true); break
case 'copy-web-link': copyWebLinks(items, false); break
case 'copy-web-md': copyWebLinks(items, true); break
case 'copy-link': copyLinks(items, 'url'); break
case 'copy-md-link': copyLinks(items, 'markdown'); break
case 'copy-web-md': copyLinks(items, 'thumb'); break
case 'search-by-image': searchWithItem(first); break
case 'open-external': if (first.photo.videoId != null) window.api.openSourceVideo(first.photo.videoId); break
case 'toggle-favorite': toggleFavorite(items); break
}
}, [revealItems, copyPaths, copyDeepLinks, copyWebLinks, searchWithItem, toggleFavorite])
}, [revealItems, copyPaths, copyLinks, searchWithItem, toggleFavorite])

const handleItemContextMenu = useCallback(async (index: number, e: React.MouseEvent) => {
e.preventDefault()
Expand Down
Loading
Loading