LVX · STORE

插件商店

插件即 .lvx —— 与模型、应用同一种容器格式。装了 LVX 的机器上,本页会通过本机代理直接安装 / 卸载;没有代理时,本页照常浏览与下载。

本机 LVX 代理 检测中…

上架内容

实时读取 /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/installbody {"url":…} 或 {"path":…} 或 {"id":…}。
POST/uninstallbody {"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_URLhttps://lvx-office.com/repo官方静态源,读取 <URL>/store.json。
LVX_STORE_REPOS空逗号分隔的 owner/repo,读取 /contents/store.json。空串 = 不扫 GitHub,不是“用默认”。
LVX_GITHUB_APIhttps://api.github.comGitHub 接口前缀,便于走镜像或私有部署。
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_STATE0 加载中 / 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;