上架内容
实时读取 /store.json,支持按标签筛选;检测到本机代理时,卡片上会多出「安装」按钮。
已装插件
来自本机 lvx plugins 的原始输出,通过代理读取。
本机代理 lvx-agent
只监听 127.0.0.1 的小服务:网页通过它调用真正的 lvx CLI,装 / 卸逻辑不重复实现。
启动
lvx-agent # 默认 127.0.0.1:8765(装在 ~/.local/bin,已在 PATH 里)
lvx-agent --port 8765 --token "$(cat ~/.lvx-agent-token)" \
--allow-origin https://lvx-office.com
usage: lvx_agent [--port N] [--bin PATH] [--dir DIR] [--token TOK] [--allow-origin ORIGIN]
# 对应环境变量:LVX_AGENT_PORT / LVX_BIN / LVX_AGENT_TOKEN / LVX_AGENT_ORIGIN
用哪个 lvx 干活:先看 --bin / $LVX_BIN,再看 ~/.lvx/bin(一个文本文件,里面一行写着 lvx CLI 路径,本机为 /home/linux/lvx-office/lvx-run/build/lvx),然后才是代理旁边的 lvx / ../build/lvx / ../lvx-run/build/lvx 与 PATH。/health 返回的 bin 字段就是最终选中的那个。
本机副本:~/.local/bin/lvx-agent(在 PATH 里)、lvx-office/bin/lvx-agent、lvx-office/lvx-run/build/lvx-agent。
接口
| 方法 | 路径 | 作用 |
|---|---|---|
GET | /health | 免令牌,探测「本机是否装了 LVX」,返回 {ok,service,api,bin}。 |
GET | /plugins | 已装插件清单(lvx plugins 原文)。 |
POST | /install | body {"url":…} 或 {"path":…} 或 {"id":…}。 |
POST | /uninstall | body {"id":"momotalk-x"}。 |
统一返回 {"ok":bool,"code":<子进程退出码>,"output":"<stdout+stderr>"};失败 500 / 参数错 400 / 令牌错 401 / 未知路由 404。响应带 CORS(OPTIONS 预检 200,Access-Control-Allow-Origin 可收紧)。装了令牌时,请求加 X-LVX-Token 头即可(本页右上角的输入框会记住它,只存在本机浏览器里)。
curl 验证(本机实测输出)
curl -s http://127.0.0.1:8765/health
# {"ok":true,"service":"lvx-agent","api":1,"bin":"/home/linux/lvx-office/lvx-run/build/lvx"}
curl -s http://127.0.0.1:8765/plugins # 本机 lvx plugins 原文:58 个插件
# {"ok":true,"code":0,"output":"LVX plugins (58 loaded):\n backend-vulkan-fake v1 ABI 1 caps=0x0 …\n"}
curl -s -X POST http://127.0.0.1:8765/install \
-H 'Content-Type: application/json' \
-d '{"id":"no-such-plugin-xyz"}'
# {"ok":false,"code":1,"output":"lvx: package not found: no-such-plugin-xyz\n…"}
curl -s -X POST http://127.0.0.1:8765/uninstall \
-H 'Content-Type: application/json' -d '{"id":"no-such-plugin-xyz"}'
# {"ok":false,"code":1,"output":"lvx: package 'no-such-plugin-xyz' not installed (plugin ls)\n"}
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8765/install \
-H 'Content-Type: application/json' -d '{}' # 400(参数错)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8765/nope # 404(未知路由)
失败时 output 就是 lvx CLI 的原话,本页会原样显示在日志区。
网页侧调用(本页实际使用的写法)
const AGENT = 'http://127.0.0.1:8765'; // 只在本机可达
const hasAgent = async () => { try { return (await fetch(AGENT+'/health')).ok } catch { return false } };
async function installPlugin(app) { // app.downloadUrl / app.id 来自 store.json
const r = await fetch(AGENT + '/install', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url: app.downloadUrl, id: app.id}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.output || d.error);
return d.output;
}
async function uninstallPlugin(app) {
const r = await fetch(AGENT + '/uninstall', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: app.id}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.output || d.error);
return d.output;
}
// 装了令牌时给所有请求加 X-LVX-Token 头即可。
边界(刻意做小)
- 只 bind
127.0.0.1,外网不可达;能触发的只有lvx install <参数>与lvx remove <参数>,参数经execvp数组传递(不过 shell,无注入面)。 - 代理不自己实现安装逻辑,全部落到真 CLI → 下载、校验、容器解包、注册的行为与命令行永不漂移。
- 网页是 https 时调
http://127.0.0.1属混合内容,但浏览器把 loopback 视为可信;只要 CORS 通过就没问题(已在响应里带上)。
环境小提醒:/usr/local/bin/lvx 目前是 root 建的断链(→ 已搬走的 /home/linux/LVX-RUN/build/lvx),代理会自动跳过它、改用 ~/.lvx/bin 里的路径。要彻底修好,执行一条即可:
sudo ln -sf /home/linux/lvx-office/lvx-run/build/lvx /usr/local/bin/lvx
源与开关
官方源 + 逗号分隔的 GitHub 仓库;两个源并行抓取,互不拖累。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
LVX_STORE_URL | https://lvx-office.com/repo | 官方静态源,读取 <URL>/store.json。 |
LVX_STORE_REPOS | 空 | 逗号分隔的 owner/repo,读取 /contents/store.json。空串 = 不扫 GitHub,不是“用默认”。 |
LVX_GITHUB_API | https://api.github.com | GitHub 接口前缀,便于走镜像或私有部署。 |
LVX_GITHUB_TOKEN | 空 | 可选令牌,只发给 GitHub,不会发给官方静态站。 |
本页所在域同样提供一份索引:https://lvx-office.com/store.json(同源,可直接作为官方源使用)。
store.json 格式
一个 JSON 文件,apps 数组里每项一个插件;字段名兼容两种写法。
{
"apps": [
{
"id": "openai-api-server",
"title": "OpenAI API Server",
"summary": "在本机起一个 OpenAI 兼容接口,供其它程序调用",
"kind": "plugin",
"version": "1.1.1",
"tags": ["工具"],
"package": "plugins/openai-api-server-1.1.1.lvx",
"size": 1048576
}
]
}
| 字段 | 说明 |
|---|---|
id | 插件标识。去重键是 source + id(用标题去重会让已安装的条目“消失”)。 |
title / name | 显示名,两种写法都认。 |
summary / description | 一句话说明。 |
kind | 类型(如 plugin / model / app),仅用于展示。 |
tags | 字符串数组,用于商店标签筛选(如 模型 / 推理 / 工具);仅展示与筛选,不影响安装。 |
version | 点分版本号,按数值比较(见文末)。 |
package / packageUrl | 相对路径或完整 URL,缺省时按下面的规则解析。 |
size | 字节数,可省略。 |
下载地址解析
三级回退:显式地址 → 以 http(s) 开头的 package → 按来源拼接。本页「安装」按钮用的就是这个解析结果。
packageUrl非空 → 直接使用。package以http://或https://开头 → 直接使用。- 来自 GitHub 源 →
https://raw.githubusercontent.com/<repo>/main/<相对路径>。 - 来自官方源 →
<官方源>/<相对路径>(开头的/会被去掉)。
宿主侧实现(resolveUrl)
std::string resolveUrl(const std::string &explicitUrl, const std::string &package,
const std::string &base, const std::string &repo) {
if (!explicitUrl.empty()) return explicitUrl;
if (package.empty()) return {};
if (package.rfind("http://", 0) == 0 || package.rfind("https://", 0) == 0)
return package;
std::string rel = package;
while (!rel.empty() && rel[0] == '/') rel.erase(rel.begin());
if (!repo.empty())
return "https://raw.githubusercontent.com/" + repo + "/main/" + rel;
return join(base, rel);
}
.lvx 扫包
安装器不信任目录结构,一切以容器内部索引为准。
- 读 LVX2 文件头,按 section id 扫描:
13 = AppManifest、14 = AppPayload、15 = AppIndex。 - 从清单里取
entry(正则匹配入口字段)。 - 解析索引行:
mode offset size path;路径含..一律拒绝,防止越出包内目录。
实现细节
Dart 侧(商店小应用)与宿主侧(C++)各有一套,行为对齐。
两个源并行,谁挂了都不拖垮商店
- Dart:
Future.wait并行拉取,每个源单独 try/catch,失败只返回空列表。 - 宿主:一个 Job 里最多起两个抓取任务,每帧
pump()推进一步,全部完成后finish()合并。 - 只要有一个源成功,商店即为 就绪;空商店也是合法结果,不会当成错误。
- 合并后用
source:id去重,并在界面上标注条目来源。
宿主侧:加载 → 推进 → 合并
// 起任务:两个源各自抓取
int Catalog::load(const std::string &officialUrlIn) {
if (fetcher_ == nullptr) fetcher_.reset(new fetch::Fetcher());
std::unique_ptr<Job> j(new Job());
const std::string official = officialUrlIn.empty() ? officialUrl() : officialUrlIn;
if (!official.empty())
j->fetchers[j->fetcherCount++] =
fetcher_->startText(join(official, "store.json"), "the official index");
const std::vector<std::string> repos = Catalog::repos();
const std::string token = githubToken();
const std::string auth = token.empty() ? std::string() : "Authorization: Bearer " + token;
for (size_t i = 0; i < repos.size() && j->fetcherCount < 2; ++i)
j->fetchers[j->fetcherCount++] = fetcher_->startText(
githubApiBase() + "/repos/" + repos[i] + "/contents/store.json",
"the GitHub index", auth);
jobs_.push_back(std::move(j));
return static_cast<int>(jobs_.size());
}
// 每帧推进一步:全部抓取完成 → finish()
void Catalog::pump() {
if (fetcher_ == nullptr) return;
fetcher_->pump();
for (auto &jp : jobs_) {
if (jp == nullptr || jp->state != 0) continue;
bool allDone = true;
for (int i = 0; i < jp->fetcherCount; ++i)
if (fetcher_->state(jp->fetchers[i]) == fetch::kRunning) allDone = false;
if (allDone) finish(*jp);
}
}
// 合并:任一源成功即 ready(0 加载中 / 1 就绪 / 2 失败)
void Catalog::finish(Job &j) {
int ok = 0;
for (int i = 0; i < j.fetcherCount; ++i) {
if (fetcher_->state(j.fetchers[i]) != fetch::kDone) { /* 记错误,继续下一个源 */ continue; }
const size_t before = j.entries.size();
if (i == 0) parseStore(fetcher_->body(id), "official", official, "", &j.entries);
else parseStore(fetcher_->body(id), "github:" + repos[i-1], "", repos[i-1], &j.entries);
if (j.entries.size() > before) ++ok;
}
j.state = j.fetcherCount == 0 ? 2 : (ok > 0 ? 1 : 2);
}
脚本接口
商店小应用由 LVX 脚本驱动,通过 syscall 桥与宿主通信。
| LVX 脚本 | syscall | 说明 |
|---|---|---|
storeload(url) | SYS_STORE_LOAD | 发起一次商店加载,返回 job id。 |
storestate(job) | SYS_STORE_STATE | 0 加载中 / 1 就绪 / 2 失败。 |
storecount(job) | SYS_STORE_COUNT | 条目数量。 |
storetext(job,i,field) | SYS_STORE_TEXT | 取标题 / 摘要 / 版本 / 下载地址 / 来源 / id,写入 guest 内存并补 \0(截断到 cap-1)。 |
storenum(job,i,field) | SYS_STORE_NUM | 整数化版本号 / 体积。 |
storereport(job) | SYS_STORE_REPORT | 形如 12 entry(s); official: 3 app(s); github:… 的来源统计。 |
版本比较
按数值逐段比较:0.10 > 0.9,不是按字典序。
Dart 侧用 cmpVersion 逐段解析比较;宿主侧因为 LVX 脚本没有字符串比较,把 1.2.3 整数化成 1002003(parts[0]*1e6 + parts[1]*1e3 + parts[2])后再比。
Dart 侧实现(cmpVersion / isNewerVersion)
/// 简单点分版本比较:0.10 > 0.9(按数值,不按字典序)
int cmpVersion(String a, String b) {
final pa = a.split('.').map((x) => int.tryParse(x)).toList();
final pb = b.split('.').map((x) => int.tryParse(x)).toList();
final n = pa.length > pb.length ? pa.length : pb.length;
for (var i = 0; i < n; i++) {
final x = i < pa.length ? (pa[i] ?? 0) : 0;
final y = i < pb.length ? (pb[i] ?? 0) : 0;
if (x != y) return x.compareTo(y);
}
return 0;
}
bool isNewerVersion(String remote, String local) => cmpVersion(remote, local) > 0;