# alayaface **Repository Path**: wallacegibbon/alayaface ## Basic Information - **Project Name**: alayaface - **Description**: The AlayaFace - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-25 - **Last Updated**: 2026-10-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # AlayaFace [![CI](https://github.com/wallacegibbon/alayaface/actions/workflows/ci.yml/badge.svg)](https://github.com/wallacegibbon/alayaface/actions/workflows/ci.yml) [English](README.md) · **中文** [AlayaCore](https://github.com/alayacore/alayacore) 的 Tauri 图形界面前端。 前端使用 **Elm**,后端使用 **Rust**(Tauri)。 ## 架构 代码库由三棵并行的源码树组成:**src-elm/** —— 纯 Elm 前端(无 npm、无打包器),按领域组织模块(App、Plan、Session、Overlay),通过一个轻量 JS 桥(`transport.js`)抽象后端传输方式(Tauri IPC 或 HTTP/WebSocket);**src-tauri/** —— Rust Tauri 后端(IPC 命令、会话生命周期、帧协议);**src-go/** —— Go 后端(HTTP + WebSocket),托管同一套 Elm 客户端,是 Rust 后端的移植版,因此两个后端都可以运行本应用。两个后端都通过 stdin/stdout 上的帧协议与 AlayaCore 子进程通信;Elm 核心不依赖 Tauri,与平台无关。 ### 数据流 ``` AlayaCore (subprocess) │ stdout: frames ▼ backend reader (reader.rs / internal/session/reader.go) │ app.emit("tlv-frame") 或 hub.Broadcast("tlv-frame") ▼ transport.js → listen("tlv-frame") ← Tauri event 或 WS /ws 推送 │ ▼ Elm port Main.elm → subscriptions → Ports.onFrame → FrameEvent raw │ ▼ Decode + handle Handlers.elm → handleFrameEvent → update SessionState │ ▼ Render Main.elm → view → HTML ``` ## 环境要求 - [Elm](https://elm-lang.org/) 0.19.2(`elm` 需在 PATH 中) - 运行 Elm 测试套件需要 [elm-test](https://elm-test.readthedocs.io/) - [Rust](https://www.rust-lang.org/)(含 Cargo) - [AlayaCore](https://github.com/alayacore/alayacore) 二进制(`alayacore` 在 PATH 中,或通过 `ALAYACORE_BIN` 环境变量指定) - Linux:`libwebkit2gtk-4.1-dev`、`libgtk-3-dev` 等(参见 [Tauri 前置条件](https://v2.tauri.app/start/prerequisites/)) - [Go](https://go.dev/) 1.22+(仅 Go 后端需要) ## 快速开始 ```bash make run # Tauri 桌面应用 make run-go # Go 后端(浏览器:http://127.0.0.1:8765,绑定 0.0.0.0) ``` ### 常用命令 ```bash make elm # 只编译 Elm 前端(src-elm/ → elm.js) make run # 编译 Elm + 启动 Tauri make dev # run 的别名 make build # 发布构建 make test # 运行 Rust 单元测试 + Elm 测试 make clean # 清理构建产物 make run-go # Go 后端:托管 Elm 客户端 + RPC/WS API make build-go # 构建 Go 后端二进制 make test-go # 运行 Go 单元测试 make clean-go # 清理 Go 构建产物 ``` ### 手动构建 ```bash cd src-elm && elm make src/Main.elm --output=elm.js cd ../src-tauri && cargo run ``` ## Go 后端(浏览器 / HTTP) 同一套 Elm 客户端也可以运行在 Go 后端(`src-go/`)上:它托管前端,并以 HTTP API 的形式实现了与 Tauri 命令等价的功能。`transport.js` 会自动检测运行环境:存在 `window.__TAURI__` 时使用 Tauri IPC,否则使用 `fetch` + WebSocket。 ```bash make run-go # 打开 http://127.0.0.1:8765/(本机)或 http://:8765/(局域网) ``` 服务端绑定 `0.0.0.0:8765`(所有网卡),因此可以通过 SSH 端口转发访问(`ssh -L 8765:localhost:8765 ` 然后打开 `http://127.0.0.1:8765/`),或直接在局域网内访问——方便开发/调试。 参数: ``` alayaface-server --addr 0.0.0.0:8765 --static ../src-elm [--token ] [--config-path ] [--allow-host ] ``` `--config-path ` 覆盖配置目录(通常为 `$HOME/.alayaface`);预设、会话、全局/ASR 配置以及内容对象存储都位于 `` 下。便于在同一台机器上保留多个互相隔离的安装(例如每个客户端/服务端部署一份)。开头的 `~` 会按 `$HOME` 展开(即 `--config-path ~/.alayaface-test` 是绝对路径的简写形式)。两个后端均支持——Tauri 桌面应用接受同样的参数(`alayaface --config-path `)。 > ⚠️ 使用 `0.0.0.0` 且不带 `--token` 时,任何能访问该端口的人都可以通过 API 创建会话、读写文件。当端口暴露在 localhost/SSH 之外时,请加上 `--token `。令牌会被注入到所托管的页面中(``),`transport.js` 会自动附带它(RPC 用 `Authorization: Bearer`,WS 用 `?token=`),因此浏览器客户端无需改动;但任何能获取页面本身的人也会拿到令牌(页面本身就是凭证)。 > > 来自浏览器的请求还会额外接受同源策略校验(`internal/server/authz.go`):跨源或带 `Sec-Fetch-Site: cross-site` 的 `/rpc`、`/ws` 调用会以 403 拒绝,且浏览器发起的 RPC 必须以 `application/json` 提交。若不做这层校验,用户访问的任意网页都能以 CORS “简单请求” 驱动 API(浏览器会在查询 CORS 之前就把这类 POST 发出去,CORS 只会隐藏*响应*——命令仍然执行,而若干命令会写入或删除文件)。非浏览器客户端(curl、脚本)不发送 `Origin`,不受影响,仅由 `--token` 管控。 > > 同源校验比较的是两个请求头,而 DNS 重绑定攻击页面(攻击者域名解析到 `127.0.0.1`)可以同时控制二者。`--allow-host` 会锁定 `Host` 头,这才是真正的防御:填入真实客户端使用的名称,例如 `--allow-host 192.168.1.20:8765`(可重复;只写主机名则匹配任意端口;`*` 表示不限制)。当服务端绑定到非回环地址且未设置 `--token` 时,启动日志会给出告警。 - 命令:`POST /rpc/{command}`,JSON 参数(对应 Tauri 的 `invoke`)。 - 事件:WebSocket `GET /ws` 推送 `{type, payload}` 消息(`tlv-delta`、`tlv-frame`、`core-status`)。 - 完整的 API 映射与设计见 `docs/go-backend.md`(历史阶段记录:`docs/archive/TODO.md`)。 ## Plan 模式(DAG 任务规划与执行) Plan 模式把一个大任务变成依赖图,由 AlayaFace 替你执行: 1. **规划(Plan)**:在任意会话中,让模型把任务拆解并输出一个围栏 ```json 代码块(schema 见 `docs/plan-mode.md` §5)。只有当代码块顶层显式带有 `"type": "alayaface-plan"` 标记时计划才会自动创建,因此普通的 ```json 代码示例永远不会触发它。 2. **保存(Save)**:计划会经过校验(id 唯一、依赖存在、无环)、规范化,并写入 `~/.alayaface/sessions//plans//.json`(每个计划都保存在创建它的会话内部)。 3. **运行(Run)**:每个计划都在自己的**窗口**中打开(与会话窗口一样——可拖拽 / 调整大小 / 关闭,⚙ 菜单会列出所有已打开的计划)。窗口中显示 DAG;点击 **Run** 会为每个任务启动独立的会话窗口——**顶层计划等待你点击 Run;子计划自动运行**。**点击节点会打开它的会话**(聚焦它;如果已关闭或重启后,会自动从磁盘恢复——绑定关系持久化在 `.run.json` 中),并遵循依赖与并行度(`concurrency`,默认 8)。任务使用节点级的 `preset` / `tools` 运行(见下文),工具确认自动放行。 4. **重试(Retry)**:失败的任务会记录失败原因和尝试次数(显示在节点和详情面板上),自动重试至多 `max_attempts` 次(默认 3 次,2s 退避),之后把依赖它的节点标记为 **Blocked**(阻塞)。失败/取消的节点可以手动重试。运行状态持久化到 `.run.json`——**Load run**(加载运行)可在重启后恢复未完成的任务。 ### 预设与工具集 - `~/.alayaface/presets//` 打包了模型列表(`model.conf`)、MCP 服务器(`mcp.conf`)和 AlayaFace 自有的 `settings.conf`(`tool_confirm`、`builtin_tools`、`reasoning_level` 0|1|2、`system_prompt`)——通过 **预设管理器(Preset Manager)** → 编辑 → 设置修改。 - **模型编辑器**呈现 AlayaCore 认识的每一个 `model.conf` 字段:`name`、`protocol_type`、`base_url`、`api_key`、`model_name`、`context_limit`、`max_tokens`、`reasoning_field`、按推理档位划分的 `reasoning_0/1/2` provider JSON,以及 `serial_tool_calls`。原因是 `model_sync` 会*整体替换*模型列表:编辑器没有携带的字段,在下一次保存时就会从 `model.conf` 中被删除。比当前构建更新的键会被原样保留而不是丢弃(见 `src-elm/src/Session/ModelConfig.elm`,由 `make check-schema` 校验)。 - `~/.alayaface/preset_order.conf` 记录预设列表的**显示顺序**(JSON 名称数组)——在 **预设管理器** 中按住 ⠿ 手柄拖动即可排序;文件里没有的预设会按字母序追加,因此永远不会被隐藏。 - `~/.alayaface/global.conf` 是**跨预设的全局配置覆盖层**(对每个预设生效):目前只有 `recursion_limit`(默认 8)——通过 ⚙ → **全局配置(Global config)** 编辑。Plan 模式的递归受它约束:计划节点的会话深度(顶层 = 1,每层子计划 +1,持久化在计划的 `meta.json` 中)超过该限制时,节点会话**不再获得 plan 系统提示**,模型因此停止创建子计划。 - 内置工具通过 `--builtin-tools=id1,id2,...` 在会话启动时传给 AlayaCore(空 = 全部工具)。可按预设(设置编辑器)或按计划节点(`tools` 字段)配置。 - 首次运行会创建种子预设:`Default`、`Fast`、`Deep`、`Data` 和 `Safe`(无 `execute_command`)。可在预设管理器中复制/重命名;计划节点通过 `"preset": "Name"` 选择预设。 **计划递归是模型自治的**:只有**顶层计划**需要你点击 Run;每个**子计划**(节点模型输出的另一份计划 JSON)都会自动创建**并自动运行**——节点会话以工具自动放行的方式执行,因此顶层 Run 点击是整个委派子树的唯一人工闸门。 **AlayaCore 永远不会被修改**——所有能力差异都通过启动参数和预设配置文件来表达。 **计划节点会话相互隔离**:计划中每个节点都以 `sessions//plans//work/` 作为工作目录运行(后端在启动时创建),任务在计划内交换文件,同时计划之间、计划与后端目录之间互不干扰。**任务没有超时**(R 系列重构中移除):挂起的节点会一直保持 Running,直到你停止它或会话断开。 **输出注入(Output injection)**:下游节点提示词可以用 `{{.output}}`(例如 `{{t1.output}}`)引用上游任务的结果;下游节点启动时,模板会被替换为上游任务的最终回答(成功时记录,持久化在 `.run.json` 中,重启后仍然有效)。节点详情面板显示每个节点记录的输出。 **关闭会话以取消优先(cancel-first)**:`close_session` 现在先发送 CI `cancel`(AlayaCore 中止正在运行的任务并自动保存到取消点的对话),再 `save`,然后关闭 stdin——子进程立即退出,而不是把一个长任务耗尽。这正是 **Stop** 能真正停止每个运行中节点的原因:运行中的任务被中止(保留到取消点的历史),而不是被允许跑完。只有过了 5 秒宽限期后才会 SIGKILL。(AlayaCore 本身未被改动;`cancel` 和 `save` 是它自己的命令。) ## 窗口与 Solo 视图 每个会话、每个计划都是同一张无限画布上的一个窗口:拖动标题栏移动窗口,拖动边缘 调整大小,在空白背景上右键打开全局菜单,在背景上滚轮缩放(整块画布一起缩放,窗口 随之重排)。 窗口里的内容永远撑满窗口:消息、消息块和输入框都随你把它拉多宽就变多宽,没有一条 "阅读栏宽度"的上限会让文字停在旧宽度、只剩外框在变宽——要多宽由你缩放决定。消息块 只有上下两条分隔线,左右不再有边框;左右内边距也随着边框一起去掉了——文字就贴着那条 线的端点排布,只保留窗口自身与边框之间的那点间距。 ### 窗口叫什么名字 会话窗口的标题是它的**名字**:`<名字> — <模型>`。这个名字归你。你在一个会话里输入的 第一句话就成了它;想改成别的,在会话管理器里点 **✎ 重命名** 即可。名字属于会话本身, 不属于某个窗口、也不属于它的 fork,所以重跑一次、重启一次都还在——而只要你确认过一次 编辑器,之后就绝不会再有自动名字盖掉它,哪怕你填回的就是它自己猜出来的那几个字。 一个你既没命名、也没发过消息的会话,显示的还是从前那样:标题栏里 `Session `,列表里 8 位 id。那个编号只是当前这一页给它排的位置,这正是列表宁可用它前缀 id 的原因,也是给 会话起个名字值得你敲一次键的原因。计划里的节点会话不命名——它们的 `[Plan · planId/nodeId]` 标记已经说明了身份。 会话管理器按名字过滤,命中的结果按名字排序;它同时也匹配 id,所以没命名的会话你照样找得 回来。名字在存储上限 120 个字符,在屏幕上按宽度截断,完整名字和 id 都在 tooltip 里:一个 你读不全的名字,不等于一个你弄丢了的名字。 **Solo 视图**是"一次只看一个窗口"的展示模式:该窗口铺满整个视口,其余窗口不再绘制。 | | | |---|---| | 进入 | 窗口标题栏的 `⤢`(会话与计划都有)、全局菜单的 **Solo 窗口(Solo window)**,或 `Ctrl+Shift+F`(作用于你当前正在看的窗口) | | 退出 | **只能用鼠标**:同一个按钮(`⤡`),或全局菜单的 **退出 Solo(Exit solo)** | | 会停下的操作 | 平移、拖动窗口、调整大小、滚轮缩放、计划信息窗——面板就是视口,这些手势已无对象可作用 | | **不会**停下的操作 | 其他一切。流式输出、运行中的任务、工具确认、MCP 授权、计划执行、版本冻结全部照常继续:Solo 只是隐藏窗口,从不关闭窗口 | 窗口处于 Solo 时,标题栏会显示身后发生了什么——`画布 · 2 运行中 · 1 等待你`。这些数字 统计的是*被隐藏*的窗口:`等待你` 指某个窗口正弹着需要回答的界面(工具确认、关闭或取消 确认、文件选择器、模型选择器、MCP 初始化/授权、媒体预览),并会高亮显示——一个卡在 看不见的对话框上的会话,否则就会无声地停住;`运行中` 统计仍有任务在跑的隐藏窗口。点击 该控件回到画布,那个待处理的界面就在那里。 **任何键盘组合键都不会退出 Solo** —— 这是刻意的。键盘是打字和条件反射发生的地方, 而以前会退出 Solo 的两个键都是迟早会出事的手势:`Ctrl+W`(从浏览器借来的习惯)以前会 关闭最上层窗口(活动计划优先,否则是活动会话),所以它能一下把一个会话从画布上拿走; `Esc` 则是万能的「先出去再说」。现在 `Ctrl+W` 完全不起作用,`Esc` 只会关掉当前打开的 浮层,然后就停下。`Ctrl+Shift+F` 仍然可以**进入** Solo,再按一次不会做任何事 —— 进入是一个请求,离开是一个按钮。 所以关闭窗口只属于每个窗口的 `✕`(可见、逐个窗口、并且永远先问一句:关闭会把对话留在 磁盘上、可随时恢复;关闭并删除才会删文件),离开 Solo 只属于 `⤡` 按钮或菜单项。而 Solo 下根本没有 `✕`:标题栏只剩这两个控件——在整块画布都看不见的时候关掉眼前这个会话, 正是这个视图唯一不能提供的误操作。退出 Solo 后,`✕` 就回到原来的位置。窗口仍然可能在 Solo 下消失(在会话管理器里删除、或按归属关系级联关闭),此时视图会自己退出,所以它 不可能指向一个已经不存在的窗口。 Solo 本身不保存任何东西:它返回的布局就是离开时的布局,因为进入 Solo 不改动任何 rect。 它被记住靠的是那块「画布存档」——`ui.conf` 里存了哪个窗口处于 Solo;由于会话不会自动重开, 这个 key 是以一条**待生效意图**落地的,等那个窗口再次被创建时才附着上去(见 `docs/solo-view.md`)。所以重启后先看到画布,等你重新打开那个会话,它才按你离开时的样子 全屏 Solo。退出 Solo 会清掉这条意图,于是三次重启前进入的 Solo 不会劫持一个用户从没要求 独处的会话。 ## 自动化 E2E(无头浏览器,无真实模型) `make e2e` 运行完整的 Plan 模式浏览器测试——无需 GUI,无需真实模型: - 构建 `fakecore`(可脚本化的 alayacore 替身)+ Go 后端,通过 `puppeteer-core` 启动系统 Chrome 无头模式(安装一次:`cd e2e && npm install`) - 走真实 UI 流程:新建会话 → 输入提示词 → fakecore 返回围栏 plan JSON → Plan 窗口**自动创建**(R2,无需按钮)→ Plan 窗口 DAG → 修改并发度 → **Run** → 节点成功(t2 通过标记失败一次,然后自动重试)→ 运行完成 → 状态栏显示 Completed → 点击节点激活它的会话并显示助手回复;Stop / resume / 浏览导入 / 标记拒绝路径也都覆盖了 - 通过 DOM 选择器断言,截图保存到临时产物目录(退出时删除;`ALAYAFACE_KEEP_ARTIFACTS=1` 可保留用于调试),并清理服务/Chrome/临时目录——Ctrl-C 也会杀掉后端子进程,不会留下孤儿服务 它已经抓到过单元测试漏掉的真实 bug(见 `docs/archive/TODO.md` P15)。 它**不**覆盖真实模型行为——那仍然需要 OpenAI 兼容的 API key(或本地 `.gguf`),见 `manual-acceptance.md`。 ## CI(GitHub Actions) `.github/workflows/ci.yml` 在推送到 `main` 和 pull request 时运行:**Go**(`go vet` + `go test -race`)、**Elm**(`elm make` + `elm-test`)、**Tauri/Rust**(`cargo test` + `cargo build`——完整应用编译),以及完整的 **E2E** 任务(Go 后端 + fakecore + 无头 Chrome,同时运行 `plan-e2e.mjs` 和 `restart-e2e.mjs`)。状态徽章在本文件顶部。 ## 帧协议 AlayaFace 通过 stdin/stdout 上的帧与 AlayaCore 通信。一帧就是**一行**:两个字节的 tag 后跟一个 JSON 值(`[tag][value]\n`);控制帧 `UE` 与 `CE` 不带值。完整协议规范见[适配器指南](../alayacore/adapter-guide/README.md)。 关键标签(协议 v17——每个内容/函数帧的值都是 JSON,具体类型作为 content item 的 `"type"` 位于其中): | Tag | 方向 | 说明 | |-----|------|------| | UX | 双向 | 用户内容——JSON content item(`{"type":"text","text":...}`、`{"type":"image","uri":...}` 等);在 stdout 上是用户消息的回显 | | UE | stdin | 用户消息结束(flush) | | CI | stdin | 命令输入(JSON:command_id、name、args) | | CE | stdin | 输入结束——不再有新 prompt,但命令仍可继续(协议 v12;AlayaFace 用不到,见 plan-mode.md §8.8) | | Ax | stdout | 助手内容流式增量(`"type"` 为 `text` 或 `reasoning`) | | AX | stdout | 助手内容完成/权威(增量先行时为空) | | Af | stdout | 工具参数流式增量 | | Uf | stdout | 工具结果预览快照(临时、仅显示;UF 会覆盖) | | AF | stdout | 工具调用生命周期(JSON:call_id、name、input) | | UF | stdout | 工具结果(JSON:call_id、output、is_error) | | CO | stdout | 命令输出(JSON:command_id、result、is_error;失败时 `result` 为 `{code,message}`) | | SM | stdout | 系统消息(JSON:`{"type":...,"data":...}`) | 输出的内容/函数帧把历史 id 放在 JSON 顶层的 `"id"`(一个普通字段——不再有 NUL 分隔前缀),工具调用 id 是 `"call_id"`;命令面板的 id 是 `"command_id"`——两者是不同面板中的不同值,永不比较。 ## 调试 帧日志会以 `[wire]` 前缀打印到 stderr: ``` [wire] << abc-123 AX 0b [wire] >> abc-123 CI 44b {"command_id":"a1","name":"cancel","args":""} [wire] << abc-123 SM 80b {"type":"task",...} ``` 运行 `cargo run` 时这些日志会出现在终端中。 ## 移植到 Web / VS Code Elm 核心与平台无关(`Session/` 模块没有 Tauri 依赖),`transport.js` 已经抽象了后端传输: - 有 `window.__TAURI__` → Tauri IPC(`tauriTransport`) - 没有 → `fetch POST /rpc/{command}` + WebSocket `/ws`(`httpTransport`,Go 后端使用) 要移植到其他目标(例如 VS Code postMessage),只需在 `transport.js` 顶部增加一个传输实现;Elm ports 和 `Session/` 模块保持不变。