Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
+102
View File
@@ -0,0 +1,102 @@
# Assistants
An **assistant** is a reusable persona package for an agent: display metadata,
default agent backend, optional model preferences, system prompt, and skill
selection.
Open the current page at **`/assistants`**. The legacy
`/settings/assistants` route redirects to `/assistants?tab=assistants`.
![Assistants list and drawer](../images/assistants-01-list.png)
## Sources
Assistants are merged from three sources:
| Source | Where it comes from | Editable? |
| --- | --- | --- |
| Builtin | Embedded manifest under `crates/backend/nomifun-app/assets/builtin-assistants/`, loaded by `BuiltinAssistantRegistry`. | Content is read-only; enable/sort/last-used state and builtin `preset_agent_type` override are stored separately. |
| Custom | User-created rows in the `assistants` table plus files in the data dir. | Fully editable and deletable. |
| Extension | Installed extensions via `resolvers::assistant`. | Read-only from this page; manage the extension lifecycle instead. |
The merged list is returned by `GET /api/assistants`.
## What an Assistant Owns
Key fields:
- `id`, `source`, `name`, `description`, `avatar`
- `preset_agent_type`: default backend such as `nomi`, `claude`, `codex`, `gemini`
- `models`: optional preferred model ids
- `prompts` / `prompts_i18n`: assistant instructions
- `enabled_skills`: skills attached when starting a session
- `enabled`, `sort_order`, `last_used_at`
- tag metadata used by the picker and filters
Custom assistant rule files live under the data dir:
- `assistant-rules/`
- `assistant-skills/`
- `assistant-avatars/`
Deleting a custom assistant removes its associated files.
## Editing Rules
| Field / action | Builtin | Extension | Custom |
| --- | --- | --- | --- |
| Enable / disable | yes | yes | yes |
| Sort / last-used state | yes | yes | yes |
| Change default agent backend | builtin override only | no | yes |
| Edit name / description / avatar | no | no | yes |
| Edit prompt / skill text | no | no | yes |
| Delete | no | no | yes |
Builtin mutations are stored in `assistant_overrides`. Extension assistants are
owned by their extension and intentionally read-only here.
![Assistant editor drawer](../images/assistants-02-editor.png)
## Skills
The Skills tab is also under `/assistants`:
- `/assistants?tab=assistants`
- `/assistants?tab=skills`
Assistant `enabled_skills` are merged with auto-injected builtin skills when a
session starts. The materialization rules are implemented by the skill routes
and backend-specific agent adapters; users do not need to copy skill folders
manually for normal use.
For MCP servers, use `/mcp`; skills and MCP servers are related but managed on
separate pages now.
## API
| Operation | Endpoint |
| --- | --- |
| List / create | `GET`, `POST /api/assistants` |
| Update / delete | `PUT`, `DELETE /api/assistants/{id}` |
| State override | `PATCH /api/assistants/{id}/state` |
| Avatar | `GET /api/assistants/{id}/avatar` |
| Bulk import | `POST /api/assistants/import` |
| Tags | `GET`, `POST /api/assistant-tags`; `PUT`, `DELETE /api/assistant-tags/{key}` |
Rule and assistant-skill file reads/writes go through `/api/skills/assistant-*`
routes so builtin, extension, and user sources can be dispatched correctly.
## Notes
- Creating an assistant without `preset_agent_type` requires at least one
configured provider; the service defaults to `nomi` when possible.
- CLI-backed agents still require their CLI to be installed on the host. Picking
`claude`, `codex`, or `gemini` as an assistant backend does not install those
tools.
- Import from legacy JSON is insert-only and idempotent: existing ids are
skipped, and invalid rows are reported per assistant.
## Related
- [MCP & Skills](./mcp-and-skills.md)
- [Model Failover Queue](./model-routing.md)
@@ -0,0 +1,95 @@
# 助手
**助手**是一套可复用的 agent persona:展示信息、默认 agent 后端、可选模型偏好、
system prompt 和技能选择。
当前入口是 **`/assistants`**。旧 `/settings/assistants` 会重定向到
`/assistants?tab=assistants`
![助手列表和抽屉](../images/assistants-01-list.png)
## 来源
助手由三类来源合并:
| 来源 | 来自哪里 | 是否可编辑 |
| --- | --- | --- |
| Builtin | 嵌入在 `crates/backend/nomifun-app/assets/builtin-assistants/` 的 manifest,由 `BuiltinAssistantRegistry` 加载。 | 内容只读;启用、排序、最近使用和 builtin `preset_agent_type` 覆盖单独存储。 |
| Custom | 用户创建的 `assistants` 表记录和数据目录中的文件。 | 可完整编辑和删除。 |
| Extension | 已安装扩展通过 `resolvers::assistant` 提供。 | 此页只读;生命周期由扩展管理。 |
合并后的列表由 `GET /api/assistants` 返回。
## 助手包含什么
关键字段:
- `id``source``name``description``avatar`
- `preset_agent_type`:默认后端,例如 `nomi``claude``codex``gemini`
- `models`:可选模型偏好
- `prompts` / `prompts_i18n`:助手指令
- `enabled_skills`:启动会话时附加的技能
- `enabled``sort_order``last_used_at`
- picker/filter 使用的标签元数据
自定义助手文件位于数据目录:
- `assistant-rules/`
- `assistant-skills/`
- `assistant-avatars/`
删除自定义助手会清理关联文件。
## 编辑规则
| 字段 / 操作 | Builtin | Extension | Custom |
| --- | --- | --- | --- |
| 启用 / 禁用 | yes | yes | yes |
| 排序 / 最近使用 | yes | yes | yes |
| 修改默认 agent 后端 | 仅 builtin override | no | yes |
| 编辑名称 / 描述 / 头像 | no | no | yes |
| 编辑 prompt / skill 文本 | no | no | yes |
| 删除 | no | no | yes |
Builtin 的可变状态写入 `assistant_overrides`。Extension 助手由扩展拥有,因此在
这里只读。
![助手编辑抽屉](../images/assistants-02-editor.png)
## 技能
技能 tab 也在 `/assistants` 下:
- `/assistants?tab=assistants`
- `/assistants?tab=skills`
助手的 `enabled_skills` 会在 session start 时与自动注入的 builtin 技能合并。
后端会按不同 agent 后端的规则 materialize 技能;正常使用时不需要手动复制技能目录。
MCP server 已独立到 `/mcp`。技能和 MCP 都能扩展 agent 能力,但当前是分开管理。
## API
| 操作 | Endpoint |
| --- | --- |
| 列表 / 创建 | `GET`, `POST /api/assistants` |
| 更新 / 删除 | `PUT`, `DELETE /api/assistants/{id}` |
| 状态覆盖 | `PATCH /api/assistants/{id}/state` |
| 头像 | `GET /api/assistants/{id}/avatar` |
| 批量导入 | `POST /api/assistants/import` |
| 标签 | `GET`, `POST /api/assistant-tags`; `PUT`, `DELETE /api/assistant-tags/{key}` |
助手规则和助手技能文件通过 `/api/skills/assistant-*` 路由读写,以便正确分发到
builtin、extension 或 user source。
## 注意
- 创建助手但未指定 `preset_agent_type` 时,需要至少有一个已配置 provider;能推断时默认使用 `nomi`
- CLI 型 agent 仍需要宿主机安装对应 CLI。选择 `claude``codex``gemini`
不会自动安装这些工具。
- 旧 JSON 导入是 insert-only 且幂等的:已有 id 会跳过,错误按行报告。
## 相关
- [MCP 与技能](./mcp-and-skills.zh.md)
- [模型故障转移队列](./model-routing.zh.md)
@@ -0,0 +1,307 @@
# AutoWork & Requirements
AutoWork is Nomi's flagship automation: a **requirements board** plus an
**orchestrator** that drives an AI agent (or an agent CLI in a terminal) to
work through those requirements one at a time, without you holding its hand.
You file requirements, group them by tag, bind a tag to a session
(conversation or terminal), and the orchestrator claims, executes, and
finalises them in order. When a requirement reaches a terminal state it can
fire a **completion notifier** (Lark/飞书 webhook) so your team hears about
it the moment it lands.
Everything described here is **backend-authoritative**: AutoWork resumes on
boot and runs whether or not you have the UI open.
![AutoWork tag-sessions overview](../images/autowork-01-tag-sessions.png)
## Concepts
| Term | What it means |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Requirement** | A unit of work: title, content (the actual instructions), tag, an `order_key` (string compared lexicographically), and a status. Stored in SQLite. |
| **Tag** | A free-form string used to group requirements into a queue. Bindings, kanban columns, and webhook routing all key off the tag. |
| **Status** | `pending``in_progress``done` (or `failed` / `cancelled`). The kanban view has one column per status. |
| **Claim & lease** | The orchestrator atomically transitions the lowest-`order_key` `pending` requirement in a tag to `in_progress` and writes a lease that expires. |
| **Lease sweeper** | A background task (every 60 s) that re-pends `in_progress` rows whose lease expired and whose owning session is no longer live — so a crash never orphans work. |
| **Orchestrator** | The per-target loop that claims → injects → waits → finalises → repeats. One loop per bound session. Persistent: it idles when the queue drains, it does not exit. |
| **Target** | The thing executing the work. Two kinds: a **conversation** (an AI agent), or a **terminal** (a real CLI agent over a PTY). |
| **Turn completion** | How a turn signals "done." For agent targets, the agent ends its turn (or calls a Nomi-only tool); for terminal targets, the terminal simply goes quiescent — a clean end-of-turn. |
| **Completion notifier** | A Lark/飞书 webhook fired when a requirement reaches `done`/`failed`/`cancelled`. Bound per tag. |
| **IDMM** | Intelligent Decision-Making Mode — a session supervisor that keeps targets alive through provider faults and decision stalls. Stacks on AutoWork. |
## Lifecycle of one requirement
```
pending ──claim_next()──▶ in_progress (lease) ──injection──▶ agent / CLI runs
│ │
▼ ▼
sweeper re-pends if lease Finish event / quiescence
expires & loop is gone │
done | failed | cancelled
CompletionNotifier fires (best-effort)
```
The orchestrator does **not** exit when the tag is empty. It awaits a wake
notification (with a 10 s safety-net poll) and keeps claiming forever, so a
new requirement filed against a bound tag is picked up almost instantly.
It exits only when:
- you disable AutoWork on that target,
- the binding hits its `max_requirements` cap (which is then persisted as
disabled, so the cap survives a restart), or
- a terminal target's row is deleted (a terminal whose PTY merely exited
idles and waits for re-launch — it does not stop).
## Three views
AutoWork's data is the same in every view; the views are different lenses.
### Requirements list — `/requirements`
The flat table. Filter by tag, status, or free-text search. Bulk-delete
selected rows. Open a row to see its detail drawer; **Edit** lives at
`/requirements/:id/edit`. **New requirement** opens the list with
`/requirements?new=1`; the old `/requirements/new` route redirects there.
![Requirements list](../images/autowork-02-list.png)
### Board — `/requirements?view=board`
One column per status for a chosen tag. Drag-and-drop is intentionally not
the way to change status here; use the detail drawer. The board re-fetches
on every `requirements.*` realtime event so it tracks the orchestrator
live.
![Requirements kanban](../images/autowork-03-kanban.png)
### Tag sessions — `需求平台 → 扩展能力 → 自动执行`
The AutoWork admin (`/requirements/extensions?tab=autowork`). Lists every
tag, every binding (which conversations and terminals are bound to which
tag), and the live run-state for each binding (`Idle`, `Active` while a
turn is in flight). The per-tag completion webhook now lives one tab over,
in **通知** (see [Completion notifications](#completion-notifications--lark--http--slack)).
This is where you watch the fleet. To **start** AutoWork on a binding, open
the session itself and toggle AutoWork there — that is the canonical place
to bind a tag, set `max_requirements`, and persist the configuration.
![Tag sessions admin](../images/autowork-04-tag-sessions.png)
## Filing a requirement
Press **New requirement** from the list page (or navigate to
`/requirements?new=1`). The form has:
- **Title** — short label.
- **Tag** — pick an existing tag or type a new one. Tags are created on
first use.
- **Content** — the actual instructions the agent / CLI will be handed.
Write it like you would write a ticket: enough context that the agent can
start without asking back, plus a clear definition of done.
- **Order key** — a string used for queue order. Lexicographic, so common
patterns are `1.0`, `1.1`, `1.2.0` etc. Lower is earlier.
- **Status** — defaults to `pending`. You can manually mark a row `done` or
`cancelled` from here too.
Submit and the row is queued. If a session is already bound to that tag, it
is woken up immediately and starts on this requirement (assuming nothing
else is in flight ahead of it).
## Binding a session: agent vs terminal
A binding is `(target_kind, target_id, tag, max_requirements?)`. There are
exactly two target kinds.
### Agent target (a conversation)
Open any conversation. The header has an **AutoWork** control. Pick a tag,
optionally set a completion cap, and enable.
What happens per turn:
1. The orchestrator claims the next `pending` requirement in that tag.
2. It builds an injection prompt that names the requirement and tells the
agent how to signal completion. The exact contract is **engine-aware**:
- On Nomi-engine sessions only, the agent has the
`requirement_complete` / `requirement_update_status` tools registered
and the prompt asks the model to call them.
- On every other engine (ACP / Codex / Gemini / Openclaw / Nanobot /
Remote), the agent has no requirement tools registered, so the prompt
uses the **tool-free contract**: do the work, end the turn with a
plain-text completion note, and the platform records `done`
automatically when the turn finishes cleanly. Failures are surfaced in
plain text (the prompt asks the model to start the final line with
`Requirement failed:` followed by the reason).
3. The injected message is hidden from the user-visible transcript.
4. The orchestrator subscribes to the agent's stream and waits for a
`Finish` (clean) or `Error`/timeout (re-pend or fail). It also captures
the agent's prose into a tail-bounded **completion note** that is stored
on the requirement and, on tool-free engines, becomes the report sent
downstream.
5. When the turn ends cleanly, `finalize_if_needed` records the row as
`done` and fires the notifier.
### Terminal target (an agent CLI over a PTY)
Open a terminal whose preset is `claude` or `codex` (a plain shell is not
eligible). Gemini terminals can be run manually, but the backend does not
accept them as terminal AutoWork targets yet because the turn lifecycle and
completion contract are not wired into the orchestrator. The header has the
same **AutoWork** control for eligible terminals. Bind a tag and enable.
What happens per turn:
1. The orchestrator subscribes to the terminal's live output stream
**before** injecting (so nothing is missed).
2. It writes the requirement prompt into the PTY wrapped in bracketed-paste
markers (`ESC [200~ … ESC [201~`) followed by `CR`, so the multi-line
text lands as a single paste in the CLI's editor and Enter actually
submits.
3. The prompt just asks the agent to do the work and **end its turn** — there
is no marker to print. Scraping a protocol string out of an interactive TUI
proved unreliable (cursor-painted output, no clean newlines, the model
mis-copying a code), so completion is detected from the turn itself.
4. When the output goes **quiescent** (silent for ≥ 10 s after a 3 s minimum,
with the PTY still alive) the agent has finished and gone idle — the turn is
recorded as `done`, the same clean-finish contract a tool-free chat agent
uses.
5. If the agent cannot complete the requirement it is asked to say so in plain
text (e.g. a final `Requirement failed:` line); such turns still finish as
`done` at the platform level, so review the conversation when in doubt.
6. PTY death mid-turn → re-pend. Hard turn timeout is 1 hour.
> **Full Auto recommended.** A turn that hits an interactive approval
> prompt will block until the timeout. Each agent CLI has a non-interactive
> flag the terminal's "Full Auto" mode adds for you (see
> [Terminals → Creating a terminal](./terminal.md#creating-a-terminal)).
A terminal that has been bound but whose PTY has exited keeps its loop
alive in idle: the moment you re-launch the terminal, AutoWork resumes
where it left off — no need to toggle the bind off and on.
## Boot resume — it runs without you
The orchestrator's running set is in-memory, but every binding's `enabled`,
`tag`, and `max_requirements` are persisted (in conversation `extra.autowork`
or the terminal's `autowork` column). On process start the backend lists
every user, walks every tag binding, and **spawns the loops itself**. You do
not need to open the session page for AutoWork to work; the UI just shows
you what is already running.
This is why "AutoWork only worked while I had the tab open" is a bug, not a
feature. If you observe it, check the orchestrator logs for resume failures
on that user / target.
## Completion notifications (Lark / HTTP / Slack)
When a requirement transitions to a terminal state, the
`CompletionNotifier` is invoked. Today it does this:
1. Look up the **per-tag setting** for the requirement's tag — if the tag
has no setting or no bound webhook, the notifier silently no-ops. If
the tag's event filter (**完成 / 失败 / 待复核**) excludes this
transition, it also no-ops.
2. Look up the bound webhook by id; if it is disabled, no-op.
3. Build a payload for the webhook's platform — a **Lark/飞书** interactive
card, a **通用 HTTP** JSON body, or a **Slack** message — carrying these
fields:
`需求id` · `需求名` · `需求内容` (truncated to 500 chars) ·
`完成状态` (`done`/`failed`/`cancelled`) ·
`完成记录(报告)` (the completion note captured during the turn,
truncated to 500 chars).
4. POST to the webhook URL. If the webhook has a secret configured, the
request is signed with the standard Lark custom-bot scheme
(`HMAC-SHA256(key="{ts}\n{secret}", msg="")`, base64).
5. Failure is logged at `warn` and swallowed — a flaky webhook never
affects requirement state.
### Setting it up
Notification setup now lives entirely inside the platform at
**需求平台 → 扩展能力 → 通知** (`/requirements/extensions?tab=notify`) —
channel and routing sit side by side on the one sub-tab.
1. In the **通知** sub-tab, **Create webhook**: give it a name, pick the
platform (**Lark/飞书**, **通用 HTTP**, or **Slack**), paste the URL,
and (optionally) the matching secret. Use **Test** to send a card and
verify the bot is reachable.
2. Under **触发规则** in the same sub-tab, find the tag and pick the
webhook from the per-tag dropdown. You can also filter which events
fire — **完成 / 失败 / 待复核** — so a tag only notifies on the states
you care about. The setting is saved per tag.
You can change which webhook a tag points to at any time, including
clearing the binding to mute notifications for that tag.
![Per-tag webhook routing](../images/autowork-05-webhook-binding.png)
## IDMM — keeping turns alive through stalls
IDMM is a separate, optional supervisor (`nomifun-idmm`). It watches a
session and intervenes when a stall is detected:
- **Rule tier (no LLM)** — provider error, repeated retries, model spinning
on a tool call, etc. — handled with a deterministic policy.
- **Sidecar tier** — a lightweight backup model is asked to make the next
decision so the session does not hang.
When AutoWork starts a turn, it asks IDMM (if wired) to **ensure
supervision** of the target for the duration of that turn. The two
features compose: AutoWork drives forward progress, IDMM keeps each turn
from getting stuck so it actually reaches a terminal state instead of
timing out. Toggle IDMM from the same place as AutoWork (the session
header).
See `crates/backend/nomifun-idmm/` for the per-tier policy detail and the
intervention log API.
> For the full picture — the rule tier, the sidecar model, session keep-alive
> and when to turn it on — see the dedicated
> [Intelligent Decision (IDMM)](intelligent-decision.md) guide.
## Routes & API
| What | Where |
| --------------------------------- | ---------------------------------------------------------------- |
| Requirements list | `/requirements` |
| Board (per tag) | `/requirements?view=board` |
| Tag sessions admin (自动执行) | `/requirements/extensions?tab=autowork` |
| Notification config (通知) | `/requirements/extensions?tab=notify` |
| New / edit | `/requirements?new=1`, `/requirements/:id/edit` |
| Legacy `/requirements/new`, `/requirements/kanban` | redirect to the current query-param routes |
| Legacy `/autowork`, `/requirements/tag-sessions` | redirect to `/requirements/extensions?tab=autowork` |
| Legacy `/settings/webhook`, `/other` | redirect to `/requirements/extensions?tab=notify` |
| List / create requirement | `GET /api/requirements`, `POST /api/requirements` |
| Tags | `GET /api/requirements/tags` |
| Tag bindings (admin) | `GET /api/requirements/tag-bindings` |
| Per-tag board | `GET /api/requirements/board?tag=…` |
| Get / update / delete | `GET|PUT|DELETE /api/requirements/:id` |
| Status / complete / claim | `POST /api/requirements/:id/status`, `…/complete`, `…/claim` |
| AutoWork toggle / state | `POST /api/requirements/autowork`, `GET …/autowork/:kind/:tid` |
| Webhooks | `GET|POST /api/webhooks`, `…/{id}`, `…/{id}/test` |
| Per-tag webhook | `GET|PUT /api/tags/:tag/settings` |
## Implementation notes (for the curious)
- `requirements.conversation_id` intentionally has **no foreign key** to the
conversations table. A requirement is created and rotates through
conversations as it gets re-pended; tying it to a single conversation
with referential integrity made cleanups awkward and added no real
safety. Treat the column as advisory.
- The orchestrator's `wake` Notify is shared with `RequirementService`;
every state transition that re-pends or creates work fires it, and the
loop is armed-then-awaited around each `claim_next()` call so a wake
arriving between "claim returned None" and "await" is never lost.
- The terminal injection wraps the prompt in bracketed-paste markers so the
multi-line text lands as one paste, and submits with a separate `CR` written
a beat later (a CR in the same write would be swallowed by the paste-burst
detection modern agent TUIs use).
- The completion note for tool-free engines is bounded (`MAX_NOTE_CHARS =
4000`) and **tail-biased** — agents tend to summarise at the end, so the
tail is what we keep when truncation is needed.
@@ -0,0 +1,191 @@
# AutoWork 与 Requirements
AutoWork 是 NomiFun 的旗舰自动化能力:一块 **需求看板**requirements board)加上一个 **编排器**orchestrator),由它驱动 AI 智能体(或运行在终端中的 agent CLI)逐条处理这些需求,无需你全程盯着。
你登记需求,按 tag 分组,把 tag 绑定到一个会话(对话或终端),编排器就会按顺序认领、执行并完结它们。当某条需求进入终态时,可以触发 **完成通知**Lark/飞书 webhook),让你的团队第一时间知道结果。
这里描述的所有内容都是 **后端权威** 的:AutoWork 在进程启动时自动恢复,无论你是否打开 UI 都会运行。
![AutoWork tag-sessions 总览](../images/autowork-01-tag-sessions.png)
## 概念
| 术语 | 含义 |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Requirement** | 一个工作单元:标题、内容(实际指令)、tag、`order_key`(按字典序比较的字符串)以及状态。存储在 SQLite 中。 |
| **Tag** | 任意字符串,用来把需求归入一个队列。绑定关系、看板列以及 webhook 路由都以 tag 为键。 |
| **Status** | `pending``in_progress``done`(或 `failed` / `cancelled`)。看板视图每个状态对应一列。 |
| **Claim & lease** | 编排器原子地把某 tag 中 `order_key` 最小的 `pending` 需求转为 `in_progress`,并写入一份带过期时间的租约(lease)。 |
| **Lease sweeper** | 一个后台任务(每 60 秒一次),会把租约已过期、且持有它的会话已不在的 `in_progress` 行重置回 `pending`——这样崩溃永远不会让任务孤立。 |
| **Orchestrator** | 每个目标对应一个的循环:认领 → 注入 → 等待 → 完结 → 重复。每个绑定的会话有一个循环。它是常驻的:队列空了就空闲等待,不会退出。 |
| **Target** | 实际执行工作的对象。两种:**会话**(一个 AI 智能体),或者 **终端**(通过 PTY 运行的真实 CLI 智能体)。 |
| **回合完成** | 一轮如何宣告"完成"。对智能体目标来说,是该智能体结束本轮回复(或调用 Nomi 专属工具);对终端目标来说,是终端输出静默下来(干净收尾)。 |
| **Completion notifier** | 当需求进入 `done`/`failed`/`cancelled` 时触发的 Lark/飞书 webhook。按 tag 绑定。 |
| **IDMM** | 智能决策模式(Intelligent Decision-Making Mode)——一个会话级监督器,能在 provider 故障和决策卡顿时让目标继续存活。可与 AutoWork 叠加使用。 |
## 单条需求的生命周期
```
pending ──claim_next()──▶ in_progress (lease) ──injection──▶ agent / CLI runs
│ │
▼ ▼
sweeper re-pends if lease Finish event / quiescence
expires & loop is gone │
done | failed | cancelled
CompletionNotifier fires (best-effort)
```
当 tag 为空时编排器 **不会** 退出。它会等待唤醒通知(外加一个 10 秒兜底轮询),并永久持续认领,因此向已绑定的 tag 新提交的需求几乎是即时被拾取。
它仅在以下情况退出:
- 你对该目标关闭了 AutoWork;
- 绑定触达了 `max_requirements` 上限(此时配置会被持久化为已禁用,使该上限在重启后依然生效);或
- 某个终端目标对应的行被删除(仅仅是 PTY 退出的终端会进入空闲并等待重新启动——它并不会停止循环)。
## 三种视图
AutoWork 在每个视图中的数据完全相同,视图只是不同的"镜头"。
### 需求列表 — `/requirements`
扁平表格。可按 tag、状态或全文搜索过滤。可批量删除选中行。点击行可以打开详情抽屉;**编辑** 路径是 `/requirements/:id/edit`**新建需求** 通过 `/requirements?new=1` 打开,旧的 `/requirements/new` 会重定向到这里。
![需求列表](../images/autowork-02-list.png)
### 看板 — `/requirements?view=board`
针对所选 tag,每个状态一列。这里有意 **不** 通过拖拽来改状态;请使用详情抽屉。看板会在每次 `requirements.*` 实时事件触发时重取数据,因此能跟随编排器实时变化。
![需求看板](../images/autowork-03-kanban.png)
### Tag sessions — `需求平台 → 扩展能力 → 自动执行`
AutoWork 的管理面板(`/requirements/extensions?tab=autowork`)。列出所有 tag、所有绑定(哪些会话和终端绑定到哪个 tag)、每条绑定的实时运行状态(`Idle`,或正在执行某轮时为 `Active`)。每个 tag 的完成 webhook 现在在旁边的 **通知** tab`/requirements/extensions?tab=notify`)。
这里是你"巡视舰队"的地方。要在某条绑定上 **启动** AutoWork,请打开会话本身并在那里切换 AutoWork 开关——那才是绑定 tag、设置 `max_requirements` 和持久化配置的标准位置。
![Tag sessions 管理面板](../images/autowork-04-tag-sessions.png)
## 提交一条需求
在列表页点击 **新建需求**(或访问 `/requirements?new=1`)。表单包含:
- **标题**:简短的标签。
- **Tag**:选择已有 tag 或键入一个新值。tag 在首次使用时会被创建。
- **内容**:交给智能体 / CLI 的实际指令。当作 ticket 来写:上下文足够让智能体不必反问就能开始,并附上清晰的"完成定义"。
- **Order key**:用于队列排序的字符串。按字典序排列,因此常见模式如 `1.0``1.1``1.2.0` 等等。值越小越早。
- **状态**:默认是 `pending`。你也可以在这里手动把某行标记为 `done``cancelled`
提交后该行进入队列。如果已有会话绑定到该 tag,它会立刻被唤醒并开始处理这条需求(前提是没有别的需求排在它前面)。
## 绑定会话:智能体 vs 终端
一条绑定形如 `(target_kind, target_id, tag, max_requirements?)`。target kind 只有两种。
### 智能体目标(一个会话)
打开任意会话。头部有一个 **AutoWork** 控件。选择 tag,可选地设置完成上限,然后启用。
每一轮中发生的事:
1. 编排器认领该 tag 中下一条 `pending` 需求。
2. 它构造一段注入 prompt,点名该需求并告知智能体如何上报完成状态。具体协议是 **engine-aware** 的:
- 仅在 Nomi-engine 会话上,智能体会注册 `requirement_complete` / `requirement_update_status` 工具,并由 prompt 要求模型调用它们。
- 在所有其他 engineACP / Codex / Gemini / Openclaw / Nanobot / Remote)上,智能体不会注册任何 requirement 工具,因此 prompt 使用 **无工具协议**:把工作做完,以一段纯文本完成说明结束本轮,平台会在本轮干净结束后自动记为 `done`。失败通过纯文本上报(prompt 要求模型把最后一行以 `Requirement failed:` 起头,紧跟原因)。
3. 注入消息会从用户可见的对话记录中隐藏。
4. 编排器订阅该智能体的流,等待 `Finish`(干净)或 `Error`/超时(重置回 pending 或 fail)。同时它会把智能体的文本输出捕获到一份 tail-bounded 的 **completion note**,存到该需求上;在无工具协议的 engine 上,这份 note 就是发到下游的报告。
5. 当本轮干净结束时,`finalize_if_needed` 把该行记为 `done` 并触发通知器。
### 终端目标(运行在 PTY 中的 agent CLI
打开预设为 `claude``codex` 的终端(普通 shell 不符合条件)。
Gemini 终端可以手动运行,但后端目前不会接受它作为终端 AutoWork 目标:
它的回合生命周期和完成契约还没有接入编排器。符合条件的终端头部会显示同一个
**AutoWork** 控件;绑定一个 tag 并启用即可。
每一轮中发生的事:
1. 编排器在注入 **之前** 订阅终端的实时输出流(这样不会漏任何字节)。
2. 它向 PTY 写入需求 prompt,外面包了一对 bracketed-paste 标记(`ESC [200~ … ESC [201~`),后跟 `CR`,使多行文本作为单次粘贴落入 CLI 的编辑器,并由 Enter 实际提交。
3. prompt 只要求 agent 把活干完、**结束本轮回复**——不需要打印任何标记。从交互式 TUI 里抓协议字符串被证明不可靠(光标重绘输出、没有干净的换行、模型抄错 code),所以完成判定改为基于回合本身。
4. 当输出 **静默**(在最少 3 秒后≥10 秒无输出,且 PTY 还活着),说明 agent 已干完并回到空闲——本轮记为 `done`,与无工具的对话 agent 用的是同一套「干净收尾即完成」契约。
5. 如果 agent 无法完成,会被要求用纯文本明确说明(例如最后一行以 `Requirement failed:` 开头);这类回合在平台层面仍记为 `done`,拿不准时请回看对话。
6. 中途 PTY 死亡 → 重置回 pending。整轮硬超时是 1 小时。
> **强烈推荐 Full Auto。** 一旦本轮撞到交互式批准提示,会一直阻塞到超时。每个 agent CLI 都有一个非交互式开关,终端的 "Full Auto" 模式会替你加上(参见 [Terminals → Creating a terminal](./terminal.zh.md#creating-a-terminal))。
被绑定但 PTY 已退出的终端会让循环以空闲方式存活:当你重新启动该终端时,AutoWork 会从中断处继续——无需先关闭再开启绑定。
## 启动恢复——它在你不在场时也会运行
编排器的运行集合存放在内存中,但每条绑定的 `enabled``tag``max_requirements` 都已持久化(在会话的 `extra.autowork` 或终端的 `autowork` 列中)。进程启动时后端会列出每个用户、遍历每条 tag 绑定,并 **自行启动** 这些循环。要让 AutoWork 工作你不必去打开会话页面;UI 只是把已经在跑的状态展示给你看。
这就是为什么"AutoWork 只在我开着标签页时才工作"是一个 bug 而不是 feature。如果你观察到这种现象,去检查编排器日志中是否有该用户/目标的 resume 失败记录。
## 完成通知(Lark / 飞书)
当需求进入终态时,会调用 `CompletionNotifier`。今天它做的事:
1. 查找该需求 tag 的 **per-tag 设置**——如果该 tag 没有设置或没有绑定 webhook,通知器静默 no-op。
2. 按 id 查找绑定的 webhook;如果它处于禁用状态,no-op。
3. 构造一张 Lark 互动卡片,字段如下:
`需求id` · `需求名` · `需求内容`(截断到 500 字符) ·
`完成状态``done`/`failed`/`cancelled` ·
`完成记录(报告)`(本轮中捕获的 completion note,截断到 500 字符)。
4. POST 到 webhook URL。如果该 webhook 配置了 secret,请求会按 Lark 自定义机器人的标准方案签名(`HMAC-SHA256(key="{ts}\n{secret}", msg="")`base64)。
5. 失败会以 `warn` 记录并吞掉——一个不稳定的 webhook 永远不会影响需求状态。
### 配置步骤
1. 进入 **需求平台 → 扩展能力 → 通知**`/requirements/extensions?tab=notify`)并 **Create webhook**:填写名称、Lark 自定义机器人 URL,以及(可选的)匹配 secret。点 **Test** 发一张卡片,验证机器人可达。
2. 在同一个 **通知** tab 里找到该 tag,从 per-tag 下拉框中挑选 webhook。设置按 tag 保存。
你可以随时改变某个 tag 指向哪个 webhook,包括清空绑定以静音该 tag 的通知。
![Per-tag webhook 路由](../images/autowork-05-webhook-binding.png)
## IDMM——让卡顿中的本轮继续存活
IDMM 是一个独立、可选的监督器(`nomifun-idmm`)。它监视会话,并在检测到卡顿时介入:
- **规则层(无 LLM**:provider 报错、反复重试、模型在工具调用上转圈等等——以确定性策略处理。
- **Sidecar 层**:调用一个轻量备用模型来下达下一步决策,避免会话挂死。
当 AutoWork 启动一轮时,它会请求 IDMM(如果已对接)在本轮持续期间 **保证监督** 该目标。两个特性可以组合:AutoWork 推动前进,IDMM 让每一轮不至于卡死,从而真正进入终态而不是超时。IDMM 与 AutoWork 在同一处切换(会话头部)。
每层策略的细节和介入日志 API 见 `crates/backend/nomifun-idmm/`
> 想看完整全貌 —— 规则层、旁路模型、会话保活,以及何时开启 —— 参见专门的
> [智能决策(IDMM](intelligent-decision.zh.md)指南。
## 路由与 API
| 用途 | 位置 |
| --------------------------------- | ----------------------------------------------------------------- |
| 需求列表 | `/requirements` |
| 看板(按 tag) | `/requirements?view=board` |
| Tag sessions 管理 | `/requirements/extensions?tab=autowork` |
| 通知配置 | `/requirements/extensions?tab=notify` |
| 新建 / 编辑 | `/requirements?new=1``/requirements/:id/edit` |
| 旧版 `/autowork``/requirements/tag-sessions` | 重定向到 `/requirements/extensions?tab=autowork` |
| 旧版 `/requirements/new``/requirements/kanban` | 重定向到当前 query-param 路由 |
| 列出 / 创建需求 | `GET /api/requirements``POST /api/requirements` |
| Tags | `GET /api/requirements/tags` |
| Tag 绑定(管理) | `GET /api/requirements/tag-bindings` |
| Per-tag 看板 | `GET /api/requirements/board?tag=…` |
| 获取 / 更新 / 删除 | `GET|PUT|DELETE /api/requirements/:id` |
| 状态 / 完成 / 认领 | `POST /api/requirements/:id/status``…/complete``…/claim` |
| AutoWork 开关 / 状态 | `POST /api/requirements/autowork``GET …/autowork/:kind/:tid` |
| Webhooks | `GET|POST /api/webhooks``…/{id}``…/{id}/test` |
| Per-tag webhook | `GET|PUT /api/tags/:tag/settings` |
## 实现注记(写给好奇的你)
- `requirements.conversation_id` 有意 **不带外键** 指向 conversations 表。一条需求一旦创建,会随着重置回 pending 在多个会话之间轮转;用引用完整性把它绑死到单个 conversation 会让清理逻辑变得别扭,而且并不会带来真正的安全保障。请把该列视为参考性字段。
- 编排器的 `wake` Notify 与 `RequirementService` 共用;任何会重置回 pending 或创建工作的状态变更都会触发它,循环也会在每次 `claim_next()` 调用前后用 armed-then-await 的方式包起来,因此在"claim 返回 None"和"await"之间到达的唤醒永远不会丢。
- 终端注入仍用 bracketed-paste 标记把多行 prompt 作为单次粘贴落入,再在一拍之后单独写一个 `CR` 提交(与 paste 同批写入的 CR 会被现代 agent TUI 的 paste-burst 检测吞掉)。
- 无工具 engine 的 completion note 有上限(`MAX_NOTE_CHARS = 4000`)且 **偏向尾部**——智能体倾向于在末尾做总结,因此当需要截断时我们保留尾部。
+306
View File
@@ -0,0 +1,306 @@
# Channels
A **channel** lets you operate a NomiFun agent from an external chat app —
Telegram, Lark / 飞书, DingTalk, WeChat — instead of sitting in front of
the desktop window. You enable a plugin, paste in its credentials,
authorize a chat user with a one-time code, and from then on messages
to your bot are dispatched to the agent and its replies come back into
the same thread.
Channels are useful when:
- you want to brief an agent from your phone or a group chat;
- you want a workspace-aware agent reachable from a team's existing IM;
- you want long-running tasks ([AutoWork](./autowork-requirements.md))
to be kickable from outside the desktop without spinning up the WebUI.
> Each platform plugin is a Cargo feature on `nomifun-channel`
> (`telegram`, `lark`, `dingtalk`, `weixin`). The default NomiFun build
> ships with all of them on; if you build the backend yourself with a
> non-default feature set, the corresponding tab simply disappears.
![Channels settings overview](../images/channels-01-overview.png)
## Where to find it
Open the Nomi page (`/nomi`), select a companion, and switch to the
**Remote** tab (`/nomi?companion=<id>&tab=remote`). That tab lists the
remote connectors for the selected companion — built-in (Telegram,
Lark, DingTalk, WeChat, WeCom, Slack, Discord, extensions). For each
plugin you'll see:
- a status pill (`stopped` / `connected`),
- the bot username once connected,
- the number of currently authorised users,
- a per-channel **default agent** + **default model** selector.
Slack / Discord / WeCom appear as built-in placeholders today — the
backend wiring is feature-gated and still being built out for those
two; Telegram / Lark / DingTalk / WeChat are the ones you can run
today.
## How a channel works
```
external IM ──▶ plugin (long-poll / WebSocket)
ChannelManager ◀─▶ PairingService
SessionManager ──▶ agent / conversation
```
- **Plugin** owns the platform-specific connection (Telegram long-poll
with exponential backoff, Lark / DingTalk WebSocket, WeChat QR-code
login over SSE).
- **PairingService** turns "I'm John on Telegram, let me in" into a
6-digit code that you approve from the desktop UI.
- **SessionManager** maps `(platform_user, chat_id)` to an agent
conversation, so each external chat is a stable session and follow-up
messages land in the same agent.
- **Orchestrator** plumbs incoming messages into the agent stream and
the agent's replies back out as edits to the same IM message
(everything except WeChat supports message editing — WeChat falls
back to sending follow-up replies).
## Setting up each platform
### Telegram
1. Talk to [`@BotFather`](https://t.me/BotFather) and create a bot.
Save the token (looks like `123456:ABC-DEF…`).
2. In **Nomi → Remote → Telegram**, paste the token.
3. Click **Test** — the backend calls `getMe` and shows the bot
username on success.
4. Click **Enable**. The plugin starts long-polling
(25 s timeout, exponential backoff up to 10 reconnects).
To pair a Telegram user with the desktop, the user messages your bot;
the bot replies with a 6-digit code (10-minute TTL). Paste / type the
code into **Nomi → Remote → Pending pairings** on the desktop
and click **Approve**. From then on that Telegram user can chat with
the agent.
### Lark / Feishu
1. Create a custom app in the Lark developer console with the events
you need (text message, card action, bot menu).
2. Copy the **App ID**, **App Secret**, and (optional) **Encrypt key /
Verification token**.
3. Paste them into the Lark form in the Channels tab and click
**Enable**.
The Lark plugin connects via Lark's WebSocket long-connection (no
public webhook needed), with a 60-second event-dedup cleanup loop and
fragment reassembly. Replies are sent as **interactive cards** because
Lark's API only supports editing card messages.
### DingTalk
1. Create an internal app in DingTalk Developer Backstage with **Stream
Mode** enabled.
2. Copy the **Client ID** and **Client Secret** into the DingTalk form
and enable.
The DingTalk plugin opens a WebSocket using the standard DingTalk
stream-mode handshake; pairing flow is identical to Telegram.
### WeChat
1. WeChat is QR-code login. Click **Enable** on the WeChat plugin —
the backend opens an SSE stream (`POST /api/channel/weixin/login/start`)
that pushes QR-code refresh events.
2. Scan the QR with the WeChat app, confirm the login, and the plugin
transitions to `connected`.
WeChat does **not** support message editing — replies are delivered as
new messages in the same chat instead of in-place edits.
## Pairing and authorising users
A pairing request comes in two ways:
1. The platform user messages the bot for the first time (Telegram
/Lark / DingTalk). The plugin auto-creates a pending request and
replies to the user with the code.
2. You can approve / reject the pending request from
**Nomi → Remote → Pending pairings** or programmatically
via `POST /api/channel/pairings/approve` and
`POST /api/channel/pairings/reject`.
Approved users are listed in **Authorised users**, with `last active`.
You can revoke at any time (`POST /api/channel/users/revoke`); the
service also cleans up that user's open sessions so the next message
re-pairs from scratch.
![Pairing approval](../images/channels-02-pairing.png)
## Master Agent mode
By default, every channel conversation runs in **Master Agent mode**:
the remote message is greeted by the Nomi companion itself. The conversation
inherits the companion's personality and memories, and the agent is wired to
the **Desktop Gateway** tools, so from your phone you're not talking
to an isolated chat bot — you're talking to the agent that runs your
desktop.
What the gateway tools (all prefixed `nomi_*`, 32 of them today) let the
remote agent do on your behalf:
- **Conversations** — list every conversation with its runtime state,
inspect one (status plus the latest messages, including an in-flight
streaming reply), send a message or task prompt into any
conversation, create new ones, update or delete old ones
(`nomi_list_conversations`, `nomi_conversation_status`,
`nomi_send_to_conversation`, `nomi_create_conversation`,
`nomi_update_conversation`, `nomi_delete_conversation`).
- **Scheduled tasks** — list / create / update / delete cron jobs
(`nomi_cron_list`, `nomi_cron_create`, `nomi_cron_update`,
`nomi_cron_delete`).
- **Long-term memory** — read and write the companion's global memory bank
(`nomi_memory_list`, `nomi_memory_save`, `nomi_memory_update`,
`nomi_memory_delete`).
- **Requirements** — browse and manage the requirements platform
(`nomi_requirement_list`, `nomi_requirement_create`,
`nomi_requirement_update`, `nomi_requirement_delete`).
- **Terminals & supervision** — list terminal sessions, create new ones
(optionally binding knowledge bases via `knowledge_base_ids`), and
read / toggle a terminal's AutoWork binding and IDMM supervision
(`nomi_list_terminals`, `nomi_create_terminal`, `nomi_get_autowork`,
`nomi_set_autowork`, `nomi_get_idmm`, `nomi_set_idmm`).
- **Knowledge bases** — browse bases and bindings, rebind a
conversation / terminal / companion, create a new base, write markdown
files into one, trigger the AI digest, or fetch a URL as markdown —
so the companion can deposit knowledge on its own
(`nomi_knowledge_list_bases`, `nomi_knowledge_get_binding`,
`nomi_knowledge_set_binding`, `nomi_knowledge_create_base`,
`nomi_knowledge_write_file`, `nomi_knowledge_autogen`,
`nomi_knowledge_fetch_url`). `nomi_knowledge_create_base` with
`urls` fetches in the background — the call returns immediately, so
don't create the base a second time while waiting; the base's
description appearing means the fetch + digest pipeline is done.
- **Providers** — list the configured LLM providers
(`nomi_list_providers`).
So *"move my daily-report cron to 9 am and tell me what's running
right now"* is a single Lark message.
**Turning it off.** Each platform panel has a **Master Agent mode**
switch next to the default-model selector. It's on by default; the
preference is stored per platform as `assistant.<platform>.masterAgent`
in the client preferences (missing value = on). Switching it off
reverts that platform to the legacy behavior — each remote chat gets a
plain standalone conversation, with no companion persona and no gateway
tools. Like the model selector, toggling the switch calls
`POST /api/channel/settings/sync` and clears the platform's active
sessions, so the next inbound message starts a conversation in the new
mode.
**Choosing which companion greets the channel.** With [multiple companions](./companions.md),
bots are bound to companions **per channel row**: each row of
`assistant_plugins` is one bot (the same platform can host several —
e.g. one Feishu in-house app per companion), its `companion_id` decides which companion
answers, and the `UNIQUE(type, bot_key)` constraint structurally
guarantees **one bot is never bound to two companions** (bot identity: Feishu
`app_id`, the Telegram bot id, DingTalk `client_id`, …). Binding or
unbinding calls `POST /api/channel/settings/companion` with a `plugin_id`,
which persists the row and resets **that channel's** active sessions in
one step — the next inbound message is greeted by the new companion's persona,
model, and knowledge mounts (the conversation carries `extra.companionId`).
Connecting a bot from a companion's **Remote** tab creates the channel row and
binds it to that companion in one go. A row without a companion binding falls back
to the legacy per-platform preference `assistant.<platform>.companionId`, then
to the **default companion**; if the bound companion is later deleted, the channel
falls back to the default companion and the sessions are likewise reset.
Memory is shared across the whole companion family: no matter how many bots
and channels you connect, their conversations flow into the same single
memory pipeline, so switching companions never loses memories.
**How it relates to the agent / model pickers.** The per-platform
**Default agent** still decides which engine answers; the gateway
tools are injected for any agent type, while the companion persona and
memory ride on the Nomi engine. Model resolution in master mode:
the platform's **Default model** (if set) wins, otherwise the
conversation falls back to the bound companion's own model.
## Picking the agent and model
Each platform has a **Default agent** and **Default model** selector
in its config form. The platform stores them as
`assistant.<platform>.defaultModel` in the client config, so:
- a message from Telegram routes to whatever agent / model you picked
for Telegram;
- a message from Lark can route to a different agent;
- changing the selector calls `POST /api/channel/settings/sync`, which
clears any active sessions for that platform — the next inbound
message re-creates them with the new defaults.
The model selector is the same Gemini-flavoured component the desktop
uses, so any provider you've configured (Anthropic, OpenAI-compatible
custom URL, Gemini-with-Google-auth, Bedrock, …) is available here.
## What works from the IM side
The platform-agnostic abstraction (`UnifiedIncomingMessage`,
`UnifiedOutgoingMessage`, `UnifiedAction`) covers:
- **Plain text** — both directions.
- **Edited streaming responses** — incremental updates from the agent
are edited into the in-flight bot message (not on WeChat).
- **Action buttons** — confirmation prompts, retry actions, etc.,
rendered as inline keyboards (Telegram), interactive-card buttons
(Lark), or platform equivalents.
- **Bot mention / require-mention** — group chats can be configured
to only respond when the bot is `@`-mentioned.
What you don't get from the IM side (yet):
- spawning teams (use the desktop / web UI for that);
- file uploads beyond what the platform plugin natively understands;
- per-user workspace selection — the agent's workspace is the one set
on the conversation it routed to.
## Routes & API
| What | Where |
| ------------------------------- | ------------------------------------------------------- |
| Channels UI | `/nomi?companion=<id>&tab=remote` |
| List plugins / status | `GET /api/channel/plugins` |
| Enable / disable | `POST /api/channel/plugins/enable`, `…/disable` |
| Test credentials | `POST /api/channel/plugins/test` |
| Pending pairings | `GET /api/channel/pairings` |
| Approve / reject pairing | `POST /api/channel/pairings/approve`, `…/reject` |
| Authorised users | `GET /api/channel/users`, `POST .../users/revoke` |
| Active sessions | `GET /api/channel/sessions` |
| Sync (clear sessions on change) | `POST /api/channel/settings/sync` |
| Bind master-agent companion | `POST /api/channel/settings/companion` |
| WeChat QR login SSE | `POST /api/channel/weixin/login/start` |
## Notes
- Plugin lifecycle is a state machine —
`Created → Initializing → Ready → Starting → Running → Stopping → Stopped`,
with any step able to transition to `Error`. The status pill in the
UI is this enum.
- A revoked user's session is torn down before the user row is
deleted. The next message from that platform user will trigger a new
pairing code.
- Pairing codes are 6 digits, generated with `getrandom`, with a
10-minute TTL. The pairing service runs a periodic sweep that
expires pending codes whose TTL has passed.
- WeChat is feature-gated separately because its dependency tree is
heavier (QR / login / auth flow). If you build with
`--no-default-features`, you'll see the placeholder card but no
enable button.
## Related
- [Companions](./companions.md) — multi-companion management, shared memory, and the
per-companion knowledge bindings that ride on channel conversations.
- [AutoWork & Requirements](./autowork-requirements.md) — file a
requirement from a chat, get notified when it lands via a webhook to
Lark / HTTP / Slack (configured at **需求平台 → 扩展能力 → 通知**).
- [Web Server Deployment](./web-server-deployment.md) — exposes the
same channels when you self-host the backend on a server.
+208
View File
@@ -0,0 +1,208 @@
# Channels
通过 **channel**,你可以从外部聊天应用——Telegram、Lark / 飞书、钉钉、微信——操作 NomiFun 的智能体,而不必坐在桌面客户端前面。你启用一个插件,粘贴它的凭证,用一次性验证码授权一个聊天用户,从此发到你机器人的消息就会被分发到智能体,智能体的回复也会回到同一个会话。
Channel 适用于以下场景:
- 你想从手机或群聊里给智能体下达指令;
- 你希望让一个工作区感知的智能体能从团队现有 IM 中触达;
- 你希望长时任务([AutoWork](./autowork-requirements.zh.md))能从桌面之外被发起,而不必启动 WebUI。
> 每个平台插件都是 `nomifun-channel` 上的一个 Cargo feature`telegram`、`lark`、`dingtalk`、`weixin`)。NomiFun 的默认构建把它们全部打开;如果你用非默认 feature 集合自行构建后端,对应的 tab 就直接消失。
![Channels 设置总览](../images/channels-01-overview.png)
## 在哪里找
打开 Nomi 页面(`/nomi`),选择一只伙伴,然后进入 **Remote** tab`/nomi?companion=<id>&tab=remote`)。这个 tab 会列出该伙伴可用的远程连接器——内置的(Telegram、Lark、DingTalk、WeChat、WeCom、Slack、Discord、扩展)。每个插件你能看到:
- 一个状态药丸(`stopped` / `connected`);
- 连接成功后的 bot 用户名;
- 当前已授权用户数;
- 一个 per-channel 的 **默认 agent** + **默认模型** 选择器。
Slack / Discord / WeCom 目前作为内置占位符出现——这两者的后端接线被 feature gate 覆盖且仍在搭建中;今天可用的是 Telegram / Lark / DingTalk / WeChat。
## channel 是怎么工作的
```
external IM ──▶ plugin (long-poll / WebSocket)
ChannelManager ◀─▶ PairingService
SessionManager ──▶ agent / conversation
```
- **Plugin** 持有平台特定连接(Telegram 长轮询带指数退避,Lark / 钉钉 WebSocket,微信通过 SSE 上的 QR-code 登录)。
- **PairingService** 把"我是 Telegram 上的 John,让我进来"变成一个由你在桌面 UI 上批准的 6 位验证码。
- **SessionManager** 把 `(platform_user, chat_id)` 映射到一个智能体会话,因此每个外部聊天都是一个稳定 session,后续消息落到同一个智能体。
- **Orchestrator** 把进入的消息接到智能体流,并把智能体的回复以"对同一条 IM 消息编辑"的形式送回(除微信外都支持消息编辑——微信会回退为发送追加回复)。
## 各平台配置步骤
### Telegram
1. 找 [`@BotFather`](https://t.me/BotFather) 创建一个 bot,保存 token(形如 `123456:ABC-DEF…`)。
2.**Nomi → Remote → Telegram** 粘入 token。
3.**Test**——后端会调 `getMe`,成功后显示 bot 用户名。
4.**Enable**。插件开始长轮询(25 s 超时,指数退避,最多 10 次重连)。
为了把 Telegram 用户与桌面端配对:用户给你的 bot 发消息;bot 用一个 6 位验证码(10 分钟 TTL)回复。在桌面端的 **Nomi → Remote → Pending pairings** 中粘入或键入该验证码并点 **Approve**。从此该 Telegram 用户即可与智能体对话。
### Lark / 飞书
1. 在飞书开发者控制台创建一个自定义 app,开启你需要的事件(文本消息、卡片动作、bot 菜单)。
2. 复制 **App ID**、**App Secret**,以及(可选)**Encrypt key / Verification token**。
3. 把它们填入 Channels tab 中的 Lark 表单,点 **Enable**
Lark 插件通过飞书的 WebSocket 长连接接入(无需公网 webhook),带一个 60 秒的事件去重清理循环和分片重组。回复以 **互动卡片** 形式发送,因为飞书 API 只支持编辑卡片消息。
### 钉钉
1. 在钉钉开发者后台创建一个内部 app,启用 **Stream Mode**
2.**Client ID****Client Secret** 填入 DingTalk 表单并启用。
钉钉插件通过标准 stream-mode 握手打开 WebSocket;配对流程与 Telegram 一致。
### 微信
1. 微信用 QR-code 登录。在 WeChat 插件上点 **Enable**——后端会打开一个 SSE 流(`POST /api/channel/weixin/login/start`)推送 QR-code 刷新事件。
2. 用微信 app 扫码确认登录,插件转为 `connected`
微信 **不支持** 消息编辑——回复以新消息形式投递到同一聊天,而不是就地编辑。
## 配对与授权用户
配对请求有两种来源:
1. 平台用户首次给 bot 发消息(Telegram / Lark / 钉钉)。插件自动创建一份待处理请求,并把验证码回复给用户。
2. 你可以在 **Nomi → Remote → Pending pairings** 中批准 / 拒绝待处理请求,或以编程方式调用 `POST /api/channel/pairings/approve``POST /api/channel/pairings/reject`
已批准用户会出现在 **Authorised users** 中,并显示 `last active`。你可以随时撤销(`POST /api/channel/users/revoke`);服务也会清理该用户的活跃 session,使下一条消息从头开始重新配对。
![配对批准](../images/channels-02-pairing.png)
## 主 Agent 模式 (Master Agent)
默认情况下,每个 channel 会话都运行在 **主 Agent 模式**:远程消息由
Nomi 伙伴本尊接待。会话继承伙伴的人格与记忆,并且 agent 接上了
**Desktop Gateway** 工具——所以你在手机上对话的不是一个孤立的聊天
bot,而是那个掌管你整个桌面的 agent。
网关工具(统一前缀 `nomi_*`,目前共 32 个)能替你做的事:
- **会话**——列出所有会话及其运行态,查看单个会话(状态 + 最近消息,
含进行中的流式回复),向任意会话注入消息或任务 prompt,新建会话,
修改与删除旧会话(`nomi_list_conversations``nomi_conversation_status`
`nomi_send_to_conversation``nomi_create_conversation`
`nomi_update_conversation``nomi_delete_conversation`)。
- **定时任务**——列出 / 创建 / 修改 / 删除 cron 任务
`nomi_cron_list``nomi_cron_create``nomi_cron_update`
`nomi_cron_delete`)。
- **长期记忆**——读写伙伴的全局记忆库(`nomi_memory_list`
`nomi_memory_save``nomi_memory_update``nomi_memory_delete`)。
- **需求平台**——浏览与管理需求平台(`nomi_requirement_list`
`nomi_requirement_create``nomi_requirement_update`
`nomi_requirement_delete`)。
- **终端与监督**——列出终端会话、创建新终端(可经 `knowledge_base_ids`
顺带绑定知识库),以及读取 / 切换某个终端的 AutoWork 绑定与 IDMM
监督(`nomi_list_terminals``nomi_create_terminal`
`nomi_get_autowork``nomi_set_autowork``nomi_get_idmm`
`nomi_set_idmm`)。
- **知识库**——浏览知识库与绑定关系,改绑会话 / 终端 / 伙伴,新建
知识库,向库内写 markdown 文件,触发 AI 梗概生成,或把一个 URL
抓取为 markdown——伙伴可以自主沉淀知识
`nomi_knowledge_list_bases``nomi_knowledge_get_binding`
`nomi_knowledge_set_binding``nomi_knowledge_create_base`
`nomi_knowledge_write_file``nomi_knowledge_autogen`
`nomi_knowledge_fetch_url`)。`nomi_knowledge_create_base`
`urls` 时抓取为后台异步——工具立即返回,等待期间勿重复建库;
库描述(description)出现即代表抓取与梗概流水线已完成。
- **Provider**——列出已配置的 LLM provider`nomi_list_providers`)。
于是"把我的日报 cron 改到早上 9 点,再说说现在桌面上有什么在跑"
只需要一条飞书消息。
**如何关闭。** 每个平台面板里,默认模型选择器旁边有一个
**主 Agent 模式** 开关。默认开启;偏好按平台存为客户端配置中的
`assistant.<platform>.masterAgent`(无值 = 开启)。关闭后该平台回退
为旧行为——每个远程聊天只得到一个普通独立会话,没有伙伴人格也没有
网关工具。与模型选择器一样,切换开关会调
`POST /api/channel/settings/sync` 并清掉该平台的活跃 session,下一条
进来的消息会以新模式重新创建会话。
**选择由哪只伙伴接待。** 有了[多伙伴](./companions.zh.md)之后,机器人按
**渠道行**绑定伙伴:`assistant_plugins` 每行代表一个机器人(同一平台
可以接入多个机器人,比如飞书上为每只伙伴各开一个企业自建应用),行上
`companion_id` 决定由哪只伙伴接待,`UNIQUE(type, bot_key)` 唯一约束从结构
上保证**同一个机器人永远不会被绑到第二只伙伴**(bot 身份:飞书
`app_id`、Telegram bot id、钉钉 `client_id`……)。绑定 / 解绑走
`POST /api/channel/settings/companion`(带 `plugin_id`),一步完成持久化与
**该渠道** session 的重置——下一条进来的消息由新宠的人格、模型与知识
库挂载接待(会话带 `extra.companionId`)。在伙伴面板的 **远程连接** tab 里
为某只伙伴连接机器人,就是「新建渠道行 + 绑定该宠」一步完成。未绑定
伙伴的渠道行回退到旧的平台级偏好 `assistant.<platform>.companionId`,再回退
**默认伙伴**;被绑定的伙伴若之后被删除,自动回退默认宠并同样重置
session。记忆是全家共享的:不管多少个机器人、多少个渠道,会话数据都
汇入同一套记忆体系,换宠不会丢失任何记忆。
**与 agent / 模型配置的关系。** 平台级 **默认 agent** 仍然决定由哪个
引擎应答;网关工具对任意 agent 类型都会注入,而伙伴人格与记忆搭载在
Nomi 引擎上。主 Agent 模式下的模型解析顺序:平台 **默认模型**(若已
设置)优先,否则回退到所绑定伙伴自己的模型。
## 选择 agent 和模型
每个平台都在它的配置表单里有 **默认 agent****默认模型** 选择器。平台把它们存为客户端配置中的 `assistant.<platform>.defaultModel`,因此:
- 来自 Telegram 的消息路由到你为 Telegram 选的智能体 / 模型;
- 来自飞书的消息可以路由到一个不同的智能体;
- 改动选择器会调 `POST /api/channel/settings/sync`,它会清掉该平台的活跃 session——下一条进来的消息会用新的默认值重新创建。
模型选择器与桌面端使用的是同一个 Gemini 风味组件,所以你配置过的任何 providerAnthropic、OpenAI 兼容自定义 URL、带 Google 认证的 Gemini、Bedrock,……)这里都可用。
## 从 IM 端能做什么
平台无关抽象(`UnifiedIncomingMessage``UnifiedOutgoingMessage``UnifiedAction`)覆盖:
- **纯文本**——双向。
- **流式编辑回复**——智能体的增量更新会被编辑进正在飞行的 bot 消息(微信除外)。
- **动作按钮**——确认 prompt、重试动作等等,渲染为 inline keyboardTelegram)、互动卡片按钮(Lark)或对应平台的等价物。
- **Bot mention / require-mention**——群聊可配置为只在 bot 被 `@` 时才回应。
从 IM 端目前还做不到:
- 创建 team(请用桌面 / web UI);
- 超出平台插件原生能力的文件上传;
- per-user 工作区选择——智能体的工作区就是它路由到的会话上设的那个。
## 路由与 API
| 用途 | 位置 |
| ------------------------------- | ---------------------------------------------------------- |
| Channels UI | `/nomi?companion=<id>&tab=remote` |
| 列出插件 / 状态 | `GET /api/channel/plugins` |
| 启用 / 禁用 | `POST /api/channel/plugins/enable``…/disable` |
| 测试凭证 | `POST /api/channel/plugins/test` |
| 待处理配对 | `GET /api/channel/pairings` |
| 批准 / 拒绝配对 | `POST /api/channel/pairings/approve``…/reject` |
| 已授权用户 | `GET /api/channel/users``POST .../users/revoke` |
| 活跃 session | `GET /api/channel/sessions` |
| 同步(变更时清掉 session) | `POST /api/channel/settings/sync` |
| 绑定主 Agent 伙伴 | `POST /api/channel/settings/companion` |
| 微信 QR 登录 SSE | `POST /api/channel/weixin/login/start` |
## 注记
- 插件生命周期是一个状态机——`Created → Initializing → Ready → Starting → Running → Stopping → Stopped`,每一步都可能转到 `Error`。UI 上的状态药丸就是这个枚举。
- 撤销用户时,session 会先于该 user row 被拆掉。来自该平台用户的下一条消息会触发新的配对码。
- 配对码 6 位,由 `getrandom` 生成,TTL 10 分钟。配对服务运行一个周期清扫,把 TTL 已过的待处理码过期掉。
- 微信单独被 feature gate 控制,因为它的依赖树更重(QR / 登录 / 鉴权流)。如果你用 `--no-default-features` 构建,会看到占位卡片但没有启用按钮。
## 相关
- [伙伴(Companions](./companions.zh.md)——多伙伴管理、共享记忆,以及搭载在渠道会话上的每宠知识库绑定。
- [AutoWork & Requirements](./autowork-requirements.zh.md)——从聊天里登记一条需求,再用 webhook 卡片把通知打回飞书。
- [Web Server Deployment](./web-server-deployment.zh.md)——当你在服务器上自托管后端时同样能暴露这些 channel。
+253
View File
@@ -0,0 +1,253 @@
# Companions
Nomi's virtual companion has grown from "a single nomi" into a **multi-companion
family**: you can create several companions, use them side by side, raise
them separately, and give each its own name, character, persona, and
chat model. Each companion can also be bound to its own **dedicated knowledge
bases** (turning it into a finance companion, a literature companion, a coding companion,
…), while every companion **shares one memory hub** — collection and learning
run as a single global pipeline, so whatever one companion learns, the whole
family remembers. Memories, companions, and knowledge bases can each be
packed into a `.zip` bundle for export/import, making machine-to-machine
migration painless.
> The entry point is the **Desktop Companion** page in the sidebar (the `/nomi`
> route); the right-click menu of any desktop companion window ("Open chat")
> deep-links there too.
## Page layout: companion switcher + two tab domains
The top of the Desktop Companion page is the **companion switcher bar**: one card per companion
(character thumbnail + name + level) plus a **New companion** button. The
selected companion drives the **companion-domain** tabs below; everything that is
global lives in the **shared-domain** tabs:
| Domain | Tab | Contents |
| --- | --- | --- |
| Companion domain (follows the switcher) | Overview | **Desktop-companion toggle** + that companion's level / XP / mood + shared stats |
| | Chat | That companion's own companion threads |
| | Model & Knowledge | Chat model picker / **knowledge bindings** |
| | Remote | That companion's IM bots (bound per companion — see the [channels guide](./channels.md)) |
| | Settings | Name / character / persona / quiet hours / delete companion |
| Shared domain (one per install) | Memories · Collect · Learn · Suggestions | The shared memory hub (one copy for all companions) |
| | Migrate | Export / import migration bundles (see below) |
## Creating and managing companions
1. Click **New companion** on the switcher bar, pick a name and one of the
six characters (mochi / ink / roux / pixel / bolt / boo).
2. **The first companion automatically becomes the default companion** (its card
carries a "default" badge). The default companion is the fallback whenever
a channel has no explicit binding (see the channels section below).
3. In a companion's **Settings** tab you can rename it at any time (takes
effect immediately), swap the character, tune the persona (preset or
custom), **pick a chat model just for this companion**, and toggle the
desktop companion plus its quiet hours.
4. **Deleting a companion** cascades: its companion conversations, runtime
state (XP, …), and `('companion', companionId)` knowledge bindings are removed
together; if you delete the default companion, the default role moves on
to the next one. Deleting down to zero companions is allowed (the shared
memory hub exists independently of companions — collection and learning
keep running).
On disk each companion is a directory — `{data_dir}/companion/companions/{companion_id}/config.json`,
**the directory is the source of truth** — which is also the unit the
companion bundle exports and imports.
### Multiple desktop companions on screen
Every companion with the desktop-companion switch enabled gets its own desktop
window (transparent, always-on-top, draggable; window label
`companion-{companionId}`). Several can share the screen; keeping it to 5 or fewer
is recommended (each window is an independent WebView instance — the
UI warns about performance beyond that but does not enforce a limit).
Right-click any desktop companion to jump straight to its chat.
## The shared memory hub
All companions share one set of memory facilities under
`{data_dir}/companion/shared/`:
- **Collection** — a single pipeline subscribes to the global event
bus, gathers your working data according to the collect switches,
and writes `shared/events/YYYYMMDD.jsonl`.
- **Learning** — a single learner incrementally distills events into
long-term memories on the configured interval, stored in
`shared/memory.db`. The learning pipeline uses the **learn model
from the shared config** (independent of each companion's chat model — one
pipeline, one budget).
- Memories saved during any companion's chat, and memories produced by
learning, are **visible to every companion** — switch companions mid-stream and
the new one remembers everything that happened before.
### XP and mood attribution
| Source | Credited to |
| --- | --- |
| Learning-run output (scored by events processed + new memories) | **All companions** (the family grows together) |
| Suggestion adopted (+20) | **All companions** |
| Companion chat turn (+2) | Only the companion in that conversation |
| Memory saved during chat (+5) | Only that companion |
**Mood is global**: it is produced by learning runs and stored in
shared state, so all companions share one mood (per-companion mood/personality
divergence is reserved for a later version).
## Binding knowledge bases to a companion
In a companion's **Model & Knowledge tab → Knowledge** section, use the binding
control to mount one or more knowledge bases on that companion (the binding
is `('companion', companionId)`). Scope of effect:
- The companion's **companion chats** and the **channel conversations** it
greets (conversations carrying `extra.companionId`) mount that companion's bound
knowledge bases — searchable during the conversation. Regular
conversations without a companionId keep their conversation-level bindings;
the two are **not merged**.
- **What the agent sees**: bases are mounted at
`{workspace}/.nomi/knowledge/`, and the injected context carries, per
base, the description + an AI digest + "when to consult" hints + a
budgeted table of contents (20 entries per base / 60 global,
directories aggregated beyond that), plus an explicit retrieval
protocol — the agent is told to look things up rather than answer
from memory.
- **Write-back** comes in two modes, briefly:
- **staged** — knowledge produced during a conversation first lands
in the base's `_inbox/` (isolated per conversation) for you to
review on the knowledge page before it is committed;
- **direct** — skips staging and writes straight into the base.
- **AI bootstrap**: the **AI generate** button on the knowledge page
(list edit modal and detail page) calls
`POST /api/knowledge/bases/{id}/autogen` to produce the base's
description and `README.md`; a `.zip` import auto-fills an empty
description. Requires a configured AI provider (`409` otherwise).
- **URL sources**: a base can be created from up to 16 URLs.
*snapshot* mode fetches them at creation, converts each page to
markdown under the base's `snapshots/` (pages over 32 KB are
AI-compressed) and auto-generates the digest — refreshable from the
detail page; *live* mode lets the agent fetch at runtime (engines
without a web tool can call the gateway tool
`nomi_knowledge_fetch_url`). Only public `http/https` URLs are
accepted (SSRF guard).
- The companion can also **grow its own libraries**: the Desktop Gateway
ships seven knowledge tools (list / bindings / create / write /
autogen / fetch-url), and knowledge-deposit tips are built into the
companion's system prompt — a companion or channel chat can create a base
and distill notes into it unprompted. When
`nomi_knowledge_create_base` is called with `urls`, the fetching runs
as a background job — the tool returns immediately, so the agent must
not create the base again just because the snapshots haven't appeared
yet; once the base's description shows up, the fetch + digest
pipeline has finished.
Bind different bases to different companions and you get a "finance companion", a
"literature companion", a "coding companion" — persona, model, and knowledge are
all per-companion, while memory stays shared.
## Binding a companion to a channel
Each IM platform (Telegram / Lark / DingTalk / WeChat) can bind its own
greeter companion for remote messages: open the companion's **Remote**
tab (`/nomi?companion=<id>&tab=remote`) and connect or rebind the bot
there. The binding is still persisted as `assistant.{platform}.companionId`
for legacy platform-level preferences when a channel row has no direct
companion binding. With no binding the **default companion** takes over;
switching the binding resets that channel's active sessions (the next
message is greeted by the new companion); if a bound companion is deleted,
the platform falls back to the default companion and the sessions are
likewise reset. See the "Master Agent mode" section of the
[Channels guide](./channels.md).
> A companionId grants no permissions (memory is shared anyway): it only
> selects persona / model / knowledge mounts — unlike the
> `desktopGateway` marker, which grants gateway tools.
## Export / import: migrating between machines
The shared-domain **Migrate** tab offers three kinds of `.zip` bundles
(the migration UI is desktop-only; paths are picked with the system
dialog):
| Bundle | Contents | Import semantics |
| --- | --- | --- |
| **Memory bundle** | All long-term memories + learning history + mood; **optionally** the raw event data (checkbox) | **Merged with dedup** into local memories (original timestamps and sources preserved) |
| **Companion bundle** | One companion's persona / character / settings / XP + the **name list** of its bound knowledge bases (`knowledge_refs`) | Creates a new companion under a fresh id, name conflicts get a "(2)" suffix; knowledge refs are matched **by name** against local bases to rebuild bindings — unmatched names are listed so you can import those knowledge bundles first and bind manually |
| **Knowledge-base bundle** | Base metadata + the md file tree verbatim | Lands as a new knowledge base, name conflicts get "(2)" |
Migration steps:
1. Old machine: export the **memory bundle** (tick events only if you
want them) → export a **companion bundle** per companion → export a
**knowledge-base bundle** per base.
2. New machine: import the **knowledge-base bundles** first (so companion
bundles can rebuild bindings by name) → then the **companion bundles**
then the **memory bundle**.
3. Check each companion's model setting: model config travels verbatim in
the bundle, but if the new machine has no matching provider it shows
as unconfigured — re-select in settings.
### Privacy boundaries
- `events/*.jsonl` is **raw collected data containing your working
content verbatim** — it is **not** exported by default; it only
enters the memory bundle when you explicitly tick "include raw event
data".
- **Chat history does not travel with the companion bundle**: companion
conversation logs live in the main database; the companion bundle carries
only persona and settings. Chat logs stay on the original machine.
## Automatic migration of legacy data
After upgrading from the single-companion version, the first boot detects the
legacy layout `{data_dir}/companion/nomi/`: if it exists and `companion/shared/`
does not, it is automatically migrated into the shared memory hub plus
a first companion (default name **"Nomi"**, inheriting the existing XP /
persona / character / model / desktop-companion position / companion
threads). The migration is idempotent and re-entrant; on completion a
`.migrated` marker is written into the legacy directory, which is kept
around (to be cleaned up after one release cycle). No manual action is
needed.
## Manual walkthrough checklist
To verify a multi-companion setup end to end, walk through in order:
1. **Create two companions**: create companions A and B, rename them, change
characters; confirm the first one carries the "default" badge.
2. **Bind one base each**: bind knowledge base X to A and Y to B (companion
Model & Knowledge tab → Knowledge).
3. **Retrieval isolation**: in A's and B's chats, ask about content
that only exists in X / Y respectively; confirm A only hits X and B
only hits Y.
4. **Shared memory round-trip**: in A's chat, have it remember
something (save a memory); switch to B's chat and ask — confirm B
knows it.
5. **Export/import roundtrip**: export the memory bundle + A's companion
bundle + base X's bundle; (on a new machine or after a wipe) import
in the order knowledge base → companion → memory; confirm the rebuilt A
has its binding restored automatically and memories merge without
duplicates.
6. **Channel companion switch**: on some channel platform, switch the greeter
companion from A to B; confirm the active sessions are reset and the next
remote message is greeted with B's persona and B's knowledge mounts.
## Routes & API
| What | Where |
| --- | --- |
| List / create companions | `GET/POST /api/companion/companions` |
| Companion detail / update / delete | `GET/PATCH/DELETE /api/companion/companions/{companionId}` |
| Shared config (collect / learn / default companion) | `GET/PATCH /api/companion/config` |
| Per-companion companion threads | `GET /api/companion/companions/{companionId}/companion/threads`, `…/companion/active` |
| Export memory bundle | `POST /api/companion/export/memory` (`{dest_path, include_events}`) |
| Export companion bundle | `POST /api/companion/export/companions/{companionId}` |
| Import memory / companion bundle | `POST /api/companion/import` (dispatched by manifest.kind) |
| Export / import knowledge-base bundle | `POST /api/knowledge/bases/{id}/export`, `POST /api/knowledge/bases/import` |
| Bind a companion to a channel | `POST /api/channel/settings/companion` |
## Related
- [Channels](./channels.md) — channel Master Agent mode and per-platform
companion binding.
- [Data and Storage](../architecture/data-and-storage.md) — the `companion/`
data directory layout.
+127
View File
@@ -0,0 +1,127 @@
# 伙伴(Companions
NomiFun 的数字伙伴从「单个 nomi」升级为**多伙伴家庭**:你可以创建多个伙伴并同时使用、分别培养、自定义名称/形象/人格,每个伙伴可以使用自己的聊天模型、绑定自己的**专属知识库**(演进出金融/文学/coding/情感等专业伙伴);而所有伙伴**共享同一个记忆中枢**——采集与学习是一条全局链路,任何一个伙伴学到的东西全家都记得。记忆、伙伴、知识库都可以打包成 `.zip` 导出/导入,换机平滑迁移。
> 入口是侧边栏的 **桌面伙伴** 页(即 `/nomi` 路由);任意桌面伙伴窗口右键菜单的「打开聊天」也会深链到这里。
## 页面结构:伙伴切换条 + 双域 Tab
「桌面伙伴」页顶部是**伙伴切换条**:每个伙伴一张卡片(形象缩略图 + 名字 + 等级),加一个「新建伙伴」按钮。当前选中的伙伴驱动下面的**伙伴域** Tab;与伙伴无关的全局数据归**共享域** Tab:
| 域 | Tab | 内容 |
| --- | --- | --- |
| 伙伴域(随切换条变化) | 总览 | **桌面伙伴开关** + 该伙伴的等级 / XP / mood + 共享统计 |
| | 聊天 | 该伙伴自己的陪伴会话线程 |
| | 模型&知识 | 聊天模型选择 / **知识库绑定** |
| | 远程连接 | 该伙伴的 IM 机器人(按伙伴绑定,详见[渠道指南](./channels.zh.md) |
| | 设置 | 名称 / 形象 / 人格 / 勿扰 / 删除伙伴 |
| 共享域(全局唯一) | 记忆 · 数据采集 · 学习 · 建议 | 共享记忆中枢(所有伙伴同一份) |
| | 迁移 | 导出 / 导入迁移包(见下文) |
## 创建与管理多伙伴
1. 点伙伴条上的**新建伙伴**,起个名字、挑一个形象(mochi / ink / roux / pixel / bolt / boo 六款)即可。
2. **第一个伙伴自动成为默认伙伴**(卡片带「默认」徽标)。默认伙伴是渠道未显式绑定时的回退对象(见下文渠道一节)。
3. 在该伙伴的**设置** Tab 里可以随时改名(即时生效)、换形象、调人格(预设或自定义)、**为这个伙伴单独选聊天模型**、开关桌面伙伴窗口与勿扰时段。
4. **删除伙伴**会级联清理:它的陪伴会话、运行时状态(XP 等)、`('companion', companionId)` 知识库绑定一并移除;若删的是默认伙伴,默认资格自动顺延给下一个。删除允许删到零个(共享记忆中枢独立于伙伴存在,采集/学习照常运行)。
每个伙伴在磁盘上是一个目录:`{data_dir}/companion/companions/{companion_id}/config.json`,**目录即真相**——这也是伙伴包导出/导入的单位。
### 多个桌面伙伴同屏
每个开启了桌面伙伴开关的伙伴拥有自己的桌面窗口(透明、置顶、可拖动,窗口 label 为 `companion-{companionId}`)。多个可以同屏共处;建议同屏不超过 5 个(每个窗口是独立的 WebView 实例,开太多影响性能,UI 会提示但不硬限)。右键任意桌面伙伴窗口可直达它的聊天页。
## 共享记忆中枢
所有伙伴共用 `{data_dir}/companion/shared/` 下的同一套记忆设施:
- **采集**:单条链路订阅全局事件总线,按开关采集你的工作数据,写入 `shared/events/YYYYMMDD.jsonl`
- **学习**:单个学习器按设定间隔增量蒸馏事件为长期记忆,存入 `shared/memory.db`。学习链路使用**共享配置里的学习模型**(与每个伙伴的聊天模型相互独立,单链路单预算)。
- 任何一个伙伴聊天时保存的记忆、学习产出的记忆,**对全体伙伴可见**——换一个伙伴继续聊,它记得之前发生的一切。
### XP 与 mood 的归属规则
| 来源 | 归属 |
| --- | --- |
| 学习 run 产出(按处理事件数 + 新记忆数计分) | **所有伙伴**(家庭共同成长) |
| 建议被采纳(+20 | **所有伙伴** |
| 陪伴聊天轮次(+2) | 仅参与对话的那个伙伴 |
| 聊天中保存记忆(+5) | 仅该伙伴 |
**mood 是全局的**:由学习 run 产出、存共享状态,所有伙伴同一 mood(按伙伴分化的人格化 mood 留待后续版本)。
## 给伙伴绑定知识库
在伙伴的**模型&知识 Tab → 知识库**区域,用绑定控件为这个伙伴挂载一个或多个知识库(绑定关系为 `('companion', companionId)`)。生效范围:
- 该伙伴的**陪伴聊天**与它接待的**渠道会话**(会话上带 `extra.companionId`)都会挂载这个伙伴绑定的知识库——对话时可检索;不带 companionId 的普通会话维持原有的会话级绑定,二者**不合并**。
- **agent 看到什么**:库挂载到 `{workspace}/.nomi/knowledge/`,注入的上下文按库携带 描述 + AI 梗概 +「何时查阅」提示 + 按预算的目录(每库 20 条 / 全局 60 条,超出按目录聚合),外加一份显式检索协议——要求 agent 先查再答,而不是凭记忆作答。
- **回写(回血)**两种模式,简述如下:
- **staged(暂存)**——对话中产生的知识回写先落入知识库的 `_inbox/`(按会话隔离),由你在知识库页面审阅后入库;
- **direct(直写)**——跳过暂存直接写入知识库正文。
- **AI 自动生成**:知识库页面的「AI 生成」按钮(列表编辑 Modal 与详情页都有)调 `POST /api/knowledge/bases/{id}/autogen`,生成库的描述与 `README.md``.zip` 导入会自动补全空描述。需要已配置 AI Provider(否则返回 `409`)。
- **URL 知识源**:创建知识库时可给出最多 16 条 URL。*snapshot* 模式在创建时抓取并把每页转为 markdown 落入库的 `snapshots/`(超过 32 KB 的页面由 AI 压缩),并自动生成梗概——详情页可刷新快照;*live* 模式留给 agent 运行期实时抓取(无网络工具的引擎可调网关工具 `nomi_knowledge_fetch_url`)。仅接受公网 `http/https` URLSSRF 防护)。
- 伙伴也能**自己养库**Desktop Gateway 提供 7 个知识工具(列表 / 绑定 / 建库 / 写文件 / AI 生成 / 抓取 URL),且伙伴系统提示里内置了「知识沉淀技巧」——陪伴或渠道聊天中它可以不经吩咐就建库并把心得沉淀进去。注意 `nomi_knowledge_create_base``urls` 建库时,URL 抓取在**后台异步**执行——工具立即返回,agent 勿因快照尚未出现而重复建库;库描述(description)生成出来即代表抓取与梗概流水线已完成。
给不同的伙伴绑不同的库,就得到了「金融伙伴」「文学伙伴」「coding 伙伴」——人格、模型、知识三件套都按伙伴独立,记忆共享。
## 渠道绑定伙伴
每个 IM 平台(Telegram / Lark / 钉钉 / 微信)可以各绑一个伙伴来接待远程消息:打开该伙伴的 **Remote** tab`/nomi?companion=<id>&tab=remote`),在那里连接或改绑 bot。未绑定渠道行时仍会读取旧的平台级偏好 `assistant.{platform}.companionId` 作为兼容回退。未绑定时回退**默认伙伴**;切换绑定会重置该渠道的活跃会话(下一条消息由新伙伴接待);被绑定的伙伴若被删除,自动回退默认伙伴并同样重置会话。详见 [Channels 指南](./channels.zh.md)的「主 Agent 模式」一节。
> companionId 不授予任何权限(记忆本就共享):它只决定 persona / 模型 / 知识库挂载,与授予网关工具的 `desktopGateway` 标记性质不同。
## 导出 / 导入:换机迁移
共享域的**迁移** Tab 提供三种 `.zip` 迁移包(仅桌面版提供迁移 UI;路径用系统对话框选取):
| 包 | 内容 | 导入语义 |
| --- | --- | --- |
| **记忆包** | 全部长期记忆 + 学习历史 + mood;**可选**勾选包含原始事件数据 | 与本机记忆**合并去重**(保留原时间戳与来源) |
| **伙伴包** | 单个伙伴的人格 / 形象 / 设置 / XP + 它绑定的知识库**名称清单**(`knowledge_refs`) | 以新 id 创建新伙伴,名称冲突自动缀 "(2)";知识库引用**按名称**匹配本机已有库自动重建绑定,匹配不到的列出来提示你先导入对应知识库包再手动绑定 |
| **知识库包** | 知识库元数据 + md 文件树原样 | 新建知识库落地,名称冲突缀 "(2)" |
换机迁移步骤:
1. 旧机:导出**记忆包**(按需勾选事件数据)→ 逐个导出**伙伴包** → 逐库导出**知识库包**。
2. 新机:先导入**知识库包**(让伙伴包的绑定重建能按名匹配上)→ 导入**伙伴包** → 导入**记忆包**。
3. 检查每个伙伴的模型设置:模型配置原样随包带走,但若新机没有配置对应 provider,会显示未配置,需在设置里重选。
### 隐私边界
- `events/*.jsonl` 是**原始采集数据,包含你的工作内容原文**——默认**不**导出,只有显式勾选「包含原始事件数据」才会进记忆包。
- **聊天历史不随伙伴包迁移**:陪伴会话记录存在主数据库里,伙伴包只带人格与设置。聊天记录留在原机。
## 旧版数据自动迁移
从单伙伴版本升级后,首次启动会自动检测旧布局 `{data_dir}/companion/nomi/`:若存在且尚无 `companion/shared/`,自动迁移为共享记忆中枢 + 第一个伙伴(默认名 **"Nomi"**,继承原有 XP / 人格 / 形象 / 模型 / 桌面伙伴位置 / 陪伴会话线程)。迁移幂等可重入,完成后在旧目录写入 `.migrated` 标记并保留原目录(一个版本周期后清理)。无需任何手工操作。
## 手工走查清单
验证一套多伙伴部署是否健康,按序走一遍:
1. **建两个伙伴**:新建 A、B 两个伙伴,分别改名、换形象;确认第一个带「默认」徽标。
2. **各绑一库**:给 A 绑知识库 X、给 B 绑知识库 Y(伙伴模型&知识 Tab → 知识库)。
3. **各自检索**:分别在 A、B 的聊天里提问只在 X / Y 中存在的内容,确认 A 只命中 X、B 只命中 Y。
4. **共享记忆互通**:在 A 的聊天里让它记住一件事(保存记忆),切到 B 的聊天提问,确认 B 知道。
5. **导出导入 roundtrip**:导出记忆包 + A 的伙伴包 + 知识库 X 的包;(换机或清空后)按「知识库 → 伙伴 → 记忆」顺序导入,确认 A 重建后绑定自动恢复、记忆合并无重复。
6. **渠道切换伙伴**:在某个渠道平台把接待伙伴从 A 切到 B,确认活跃会话被重置、下一条远程消息由 B 的人格接待并挂 B 的知识库。
## 路由与 API
| 用途 | 位置 |
| --- | --- |
| 伙伴列表 / 创建 | `GET/POST /api/companion/companions` |
| 伙伴详情 / 修改 / 删除 | `GET/PATCH/DELETE /api/companion/companions/{companionId}` |
| 共享配置(采集 / 学习 / 默认伙伴) | `GET/PATCH /api/companion/config` |
| 每个伙伴的陪伴线程 | `GET /api/companion/companions/{companionId}/companion/threads``…/companion/active` |
| 导出记忆包 | `POST /api/companion/export/memory``{dest_path, include_events}` |
| 导出伙伴包 | `POST /api/companion/export/companions/{companionId}` |
| 导入记忆包 / 伙伴包 | `POST /api/companion/import`(按 manifest.kind 分发) |
| 导出 / 导入知识库包 | `POST /api/knowledge/bases/{id}/export``POST /api/knowledge/bases/import` |
| 渠道绑定伙伴 | `POST /api/channel/settings/companion` |
## 相关
- [Channels](./channels.zh.md) —— 渠道主 Agent 模式与每平台伙伴绑定。
- [数据与存储](../architecture/data-and-storage.zh.md) —— `companion/` 数据目录布局。
@@ -0,0 +1,157 @@
# Computer Use And Browser Use
NomiFun exposes two optional automation capability families to agents:
- **Computer use**: screenshots, mouse/keyboard input, window enumeration, and
focus control through the in-process Rust implementation (`nomi-computer`,
with accessibility helpers in `nomi-a11y`).
- **Browser use**: Chrome automation through the in-process Rust CDP engine
(`nomi-browser-engine`) and the tool facade (`nomi-browser`).
Both are high-privilege capabilities. In the desktop product UI they are
compiled in and enabled by default so a user can opt out from Settings. In
headless/server hosts they are omitted or disabled unless the host explicitly
enables the relevant build feature and runtime flag.
## Current Architecture
The old external `@playwright/mcp` sidecar path and its boot-time Node/npm/
Chromium provisioning have been removed. Browser use now runs through the
native CDP engine. ACP/Codex-style sessions can reach the same engine through
the `mcp-browser-stdio` bridge.
Computer use is desktop-oriented. It can observe the screen and synthesize
input, so it is compiled into desktop/Nomi CLI builds but omitted from the
headless web/server build.
## Enabling And Disabling Capabilities
### Desktop Settings
The desktop app exposes both toggles under System Settings:
- **Browser Use** (`/settings/browser-use`)
- **Computer Use** (`/settings/computer-use`)
Current desktop builds default both toggles to **on** when the corresponding
feature is compiled. Turning either toggle off persists a user preference and
prevents new sessions from receiving that capability.
### Per Session
Create or update a session with capability flags in `extra`:
```json
{ "computerUse": true, "browserUse": true }
```
Both camelCase and snake_case keys are accepted by compatibility paths.
### Host Environment
```bash
NOMIFUN_COMPUTER_USE=1
NOMIFUN_BROWSER_USE=1
```
These set default availability for Nomi-engine sessions in the host where they
are read. They do not bypass build-time feature gates.
### Nomi Engine Config
`~/.nomi/config.toml` or project `.nomi/config.toml`:
```toml
[tools]
max_recent_images = 3
[tools.computer]
enabled = true
max_screenshot_edge = 1568
[tools.browser]
enabled = true
headless = false
allowed_origins = []
```
`browser_path` and `idle_timeout_secs` are legacy compatibility fields; the
native engine manages browser acquisition and lifecycle itself. On first use,
the engine can acquire Chrome for Testing into its own user-data area without
requiring Node, npm, or Playwright.
## Build Matrix
| Host | Computer use | Browser use |
| --- | --- | --- |
| `nomifun-desktop` | Compiled by the `computer-use` feature | Compiled by the `browser-use` feature |
| `nomi` CLI | Enabled in the current `nomi-cli` build | Not enabled in the current `nomi-cli` manifest |
| `nomifun-web` / Docker | Not compiled | Not compiled in the current headless web host |
Web/server builds should not promise desktop or managed-browser control. If a
config enables these tools in a host that was built without the relevant
features, the backend should warn rather than expose a non-working tool.
## macOS Permissions
Computer use needs OS permissions the first time it is used:
- **Accessibility**: required for mouse/keyboard input and accessibility tree
operations.
- **Screen Recording**: required for screenshots. A black screenshot usually
means this permission is missing.
These run **in-process inside the desktop app**, so the permission must be
granted to **NomiFun itself** (the entry named "NomiFun" in System Settings),
not to the terminal/editor — and a freshly-granted permission only takes effect
after the app is **completely quit and reopened** (macOS does not hot-load TCC
grants into a running process). Permission-failure messages name "NomiFun"
explicitly so the guidance is unambiguous.
Settings → Computer Use surfaces a live status panel (macOS): it shows whether
Accessibility / Screen Recording are *in effect for the running process*
which is authoritative, since a System Settings toggle bound to a stale
code-signing identity reads "Not in effect" even while it looks on — with
buttons that deep-link to the exact Privacy pane and trigger the OS prompt.
Backed by `GET/POST /api/computer/permissions[/request|/open-settings]`
(`nomi_computer::permissions``AXIsProcessTrusted` /
`CG*ScreenCaptureAccess`).
> **Stale grant.** If a toggle is clearly on yet computer use still fails, the
> grant is bound to an older build's identity. Quit NomiFun, run
> `tccutil reset Accessibility com.nomifun.desktop` and
> `tccutil reset ScreenCapture com.nomifun.desktop`, relaunch, re-grant, and
> fully restart once more.
## Approval Semantics
- Read-only computer actions such as `screenshot`, `cursor_position`,
`list_windows`, and `wait` are treated as info-level operations.
- Mutating computer actions such as click, type, scroll, drag, and
`focus_window` are execution-level operations and require approval in default
modes.
- Plan mode hides the whole computer-use tool.
- Browser actions derive approval from behavior: observation is info-level;
navigation, clicking, typing, and other page mutations are execution-level.
Recommended loop: observe with a screenshot or browser snapshot, perform one
small operation, then observe again.
## Image And Token Hygiene
- Screenshots are downsampled to a maximum long edge of
`max_screenshot_edge` pixels, with coordinates mapped back to real screen
coordinates.
- The conversation keeps only the most recent `max_recent_images` image-bearing
tool results to avoid unbounded token growth.
- OpenAI-compatible tool messages cannot carry images directly; image data is
sent as a following user message with a source call id. Anthropic, Bedrock,
and Vertex use native image blocks where supported.
- External MCP image results pass through the same image pipeline with a
per-image size cap.
## Related Docs
- [Agent Engine](../architecture/agent-engine.md)
- [MCP And Skills](mcp-and-skills.md)
- [Remote Capability API](remote-capability-api.md)
@@ -0,0 +1,101 @@
# Computer Use 与 Browser Use(计算机控制与浏览器自动化)
NomiFun agent 内置/接入两项可选的系统级能力:
- **Computer**computer use,进程内 Rust):截屏、鼠标键盘合成输入、窗口枚举/聚焦——让 agent 看到并操作本机桌面。crate:`nomi-computer`xcap + enigo)。
- **Browser**browser use,进程内自研 CDP 引擎):通过内置浏览器引擎驱动 Chrome 完成导航、读取、点击、填表等,以单工具 `Browser` 暴露。crate`nomi-browser-engine`(自研 Rust CDP+ `nomi-browser`(facade)。首次启用时引擎按需自动获取 Chrome(`acquire.rs` 内置 CfT 下载/解压),无需 Node/npm。由 `nomi-agent::bootstrap` 在启用且 `browser-use` feature 开启时注册 `BrowserTool`
> 注:早期的外接 `@playwright/mcp` sidecar 与其 boot-time provisioning(装 node/npm/Chromium**已移除**browser use 现统一走进程内自研 CDP 引擎,是唯一浏览器路径。ACP/codex 经 `mcp-browser-stdio`native facade)接入同一引擎。
>
> 当前文档只描述已落地路径:桌面端的系统设置开关、进程内
> browser/computer 工具,以及对应的 build feature 门控。
两者都是高权限能力。当前桌面产品构建在对应 feature 存在时默认开启,
用户可在系统设置中关闭;无头 Web/服务器构建则不承诺桌面控制或托管
浏览器能力。
## 启用与关闭方式
### 1. 桌面端系统设置(推荐)
桌面应用在系统设置中提供两个页面:
- **Browser Use**`/settings/browser-use`
- **Computer Use**`/settings/computer-use`
当前桌面构建默认把两个开关设为开启;关闭任一开关会持久化到用户偏好,
后续新会话不会获得对应能力。
### 2. 会话级
创建会话时在 `extra` 中传开关(camelCase 与 snake_case 均可):
```json
{ "computerUse": true, "browserUse": true }
```
### 3. 宿主级环境变量
```bash
NOMIFUN_COMPUTER_USE=1 # 所有 nomi 会话默认启用 Computer
NOMIFUN_BROWSER_USE=1 # 所有 nomi 会话默认启用 Browser(进程内 native CDP 引擎)
```
### 4. nomi CLI / 配置文件
`~/.nomi/config.toml` 或项目 `.nomi/config.toml`
```toml
[tools]
max_recent_images = 3 # 历史中保留图片的工具结果条数(旧图自动剥离省 token)
[tools.computer]
enabled = true
max_screenshot_edge = 1568 # 截图长边像素上限
[tools.browser]
enabled = true
headless = false # 服务器部署建议 true
allowed_origins = [] # 可选 origin 白名单;空=全放行,仅纵深防御
# 注:browser_path / idle_timeout_secs 已弃用(native 引擎自管浏览器与生命周期),保留 #[serde(default)] 仅为旧配置兼容。
```
启用 Browser 后,native 引擎首次使用时自动获取 Chrome(CfT 下载到引擎专属 user-data-dir,不污染用户浏览器),无需预装 Node/npm/Playwright。
## 构建形态(feature 门控)
| 宿主 | Computer(进程内) | Browser(进程内 native CDP |
|---|---|---|
| 桌面应用(nomifun-desktop | ✅ 默认编译(`computer-use` feature | ✅(`browser-use` feature;首次自动获取 Chrome |
| nomi CLI | ✅ 当前 `nomi-cli` manifest 启用 | ❌ 当前 `nomi-cli` manifest 未启用 |
| Web/服务器(nomifun-web、Docker | ❌ 不编译(无显示器;xcap/enigo 不进二进制) | ❌ 当前 headless web host 未启用 `browser-use` feature |
`computer-use` feature 链:`apps/desktop``nomifun-app``nomifun-ai-agent``nomi-agent``nomi-computer`。Web 构建若配置中误开 computer,仅记录 warning,不报错。Browser 由 `browser-use` feature 门控(`nomi-browser` / `nomi-browser-engine`)。
## macOS 权限
Computer 能力首次使用需在「系统设置 → 隐私与安全性」中授权宿主应用:
- **辅助功能(Accessibility)**:鼠标键盘合成输入需要此项(未来 a11y 树读取/动作亦只需此项)。
- **屏幕录制(Screen Recording)**:截图需要此项(截图全黑或失败时检查)。
当前为反应式诊断:权限缺失时,工具结果会给出授权指引。
## 工具语义与审批
- Computer 为单工具 + `action` 参数形态。
- 只读 action`screenshot``cursor_position``list_windows``wait`)按 **Info** 类审批——AutoEdit/Default 模式自动放行;操作类 action(点击、输入、滚动、拖拽、`focus_window` 等)按 **Exec** 类——Default 模式需用户确认。
- Plan mode 下 Computer 整工具不可见(只读规划阶段不操作桌面)。
- Browsernative CDP)工具按动作语义派生审批类别:只读观察(如 `observe`/快照)→ Info,写操作(导航、点击、输入等)→ Exec。
- 推荐工作流:`screenshot` 观察 → 操作 → 再次 `screenshot` 验证。
## 截图与 token 治理
- 截图自动降采样到长边 ≤ `max_screenshot_edge`(默认 1568pxAnthropic 视觉推荐区间),文本中标注缩放后尺寸;模型给的坐标自动映射回真实屏幕(含 Retina 缩放)。
- 历史消息中只保留最近 `max_recent_images`(默认 3)个带图结果的图片,更早的图片在轮次结束时剥离(文本保留),避免会话文件与请求 token 膨胀。
- OpenAI 协议的 tool 消息不支持图片:图片以紧随其后的 user 消息(`image_url` data URI)传递,并标注来源 call id。Anthropic/Bedrock/Vertex 走原生 `tool_result` 图片块。
- 外接 MCP 工具回传的图片同样经 `McpToolProxy` 映射进图片管道(单图 ≤ 5 MiB 上限)。
## 替代路径:其他外接 MCP
除内置 Computer 与 native Browser 外,仍可外接任意社区 MCP server(在 MCP 设置中添加),与上述能力互不冲突(工具名不同)。
+152
View File
@@ -0,0 +1,152 @@
# Running NomiFun as a Desktop App
The desktop app (`nomifun-desktop`) is a [Tauri](https://tauri.app/) shell that links the Rust backend (`nomifun-app`) **into the same process**. There is no spawned backend binary, no Electron, no bundled `nomicore`. The shell starts the backend as an async task on a free `127.0.0.1` port, then loads the bundled SPA (`ui/dist`) into a WebView and points it at `http://127.0.0.1:<port>/api`.
The desktop WebView does not show a login screen. Instead, the embedded backend
runs under `AuthPolicy::TrustLocalToken`: the shell injects a per-boot local
trust secret into its own WebView, and only requests carrying that secret are
treated as the desktop user. If you want login + remote browser/phone access,
see [WebUI Remote Access](./webui-remote-access.md) for the in-app feature, or
[Self-Host the Web Server](./web-server-deployment.md) for the standalone
server.
![NomiFun desktop main window](../images/desktop-01-main-window.png)
## Quick start
### Prerequisites
The desktop app requires:
- A platform Tauri supports (Windows 10+, macOS 11+, mainstream Linux distros).
- A WebView runtime: **WebView2** on Windows (preinstalled on Win 11; on Win 10 install the [Evergreen Bootstrapper](https://developer.microsoft.com/microsoft-edge/webview2/)), **WKWebView** on macOS (built-in), **WebKitGTK** on Linux (`libwebkit2gtk-4.1-0`).
- For development: Rust toolchain, [Bun](https://bun.sh) ≥ 1.3.13, and the platform Tauri build deps (see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)).
### Run from source (development)
From the repo root:
```bash
bun install
bun run dev
```
This runs `tauri dev --config apps/desktop/tauri.conf.json`. It starts the Vite dev server (`http://localhost:5173`) for the SPA, builds and launches `nomifun-desktop`, and the embedded backend is started on a fresh free localhost port at every boot.
### Build a release bundle
```bash
bun run build
```
Output bundles land under `target/release/bundle/` per platform (NSIS installer + MSI on Windows, `.app` + `.dmg` on macOS, `.deb` + `.AppImage` on Linux). To produce signed updater artifacts (extra `.sig` files), use `bun run build:updater` after configuring signing keys (see [Updater status](#updater-status) below).
A successful build prints the bundle locations, for example on macOS:
```text
$ bun run build
Compiling nomifun-app v0.1.0
Finished `release` profile [optimized] target(s)
Bundling NomiFun.app (macos)
Bundling NomiFun_0.1.0_aarch64.dmg (macos)
Finished 2 bundles at:
target/release/bundle/macos/NomiFun.app
target/release/bundle/dmg/NomiFun_0.1.0_aarch64.dmg
```
## Window and titlebar
The main window is **frameless** on Windows and Linux: the React titlebar component draws min/maximize/close on the same row as the in-app navigation. On macOS the native traffic-light buttons are kept via Tauri's `Overlay` title-bar style, with content extending under the bar.
- Default size: `1280 × 832`, minimum `880 × 600`.
- Resizable everywhere (edge-resize and Snap still work on Windows even without OS-drawn decorations).
- Title bar: `NomiFun`.
> The exact chrome differs per OS: a frameless titlebar with in-app controls on
> Windows and Linux, and the native traffic-light buttons (content under an
> `Overlay` bar) on macOS.
## Single instance
`tauri-plugin-single-instance` enforces a single running copy of the app on Windows and Linux. Trying to launch a second `nomifun-desktop` will silently focus the existing window instead of starting another backend on a different port.
## Deep links
The app registers the `nomifun://` URL scheme (configured in `apps/desktop/tauri.conf.json` under `plugins.deep-link.desktop.schemes`). When the OS launches Nomi via a `nomifun://...` URL, the shell forwards the URLs to the renderer over the Tauri event `deep-link://received`. The renderer can subscribe with `listen('deep-link://received', ...)` from `@tauri-apps/api/event` to handle the payload.
`register_all()` is called at startup to install the scheme; on platforms that need an out-of-band registration step (some Linux desktops, dev contexts) the call is best-effort and a failure is ignored.
## Autostart
The shell ships `tauri-plugin-autostart` so the renderer can opt the app into "launch at login" via the plugin's invoke API. On macOS this uses a `LaunchAgent`; on Windows the registry's `Run` key; on Linux a `.desktop` file in the autostart folder. The user-facing toggle lives in app settings.
## Notifications
`tauri-plugin-notification` is enabled. The renderer can show OS-level notifications (e.g. when an agent finishes a long task or AutoWork has results). On macOS the user is asked for permission the first time; on Windows, notifications use the modern Action Center; on Linux they go through `libnotify`.
## Where data is stored
The desktop app persists the SQLite database, agent state, logs, and the Bun runtime cache under the per-user application-data directory — **`%LOCALAPPDATA%\NomiFun\Nomi`** on Windows, **`~/Library/Application Support/NomiFun/Nomi`** on macOS, **`$XDG_DATA_HOME/NomiFun/Nomi`** on Linux (resolved by the shared `nomifun_app::cli::default_data_dir()`). This is the same default the `nomifun-web` host and the dev scripts use, so a provider or companion configured in one host is visible in the others.
Set `NOMIFUN_DATA_DIR=<absolute path>` before launching the app and the data dir becomes `$NOMIFUN_DATA_DIR/Nomi`. The backend takes an exclusive `server.lock` on the data dir at startup; if it fails to start — for example because another instance already holds the directory — the desktop shell shows a native error dialog and exits.
> Older builds defaulted to `<system temp>/nomifun-data/Nomi`. An install found there is relocated to the per-user location automatically on launch (one-shot): data is copied, absolute paths stored in the database are rewritten, and the legacy directory is kept as a backup. Regenerable caches (the extracted Bun runtime, logs, browser profile, …) are not carried over — they rebuild on first use.
To start fresh, **quit the app** and delete that directory. To migrate, copy the directory to a new machine.
```text
~/Library/Application Support/NomiFun/Nomi/ # macOS (see paths above for Windows/Linux)
├── nomifun-backend.db # SQLite state (conversations, settings, sessions, …)
├── logs/ # nomicore.log
├── companion/ # companions + the shared memory hub
├── knowledge/ # managed knowledge bases
├── runtime/ # extracted Bun runtime cache (regenerable)
└── server.lock # exclusive lock held while a backend is running
```
## Authentication and local trust
The desktop shell does not expose the old blanket no-auth backend to every
localhost caller. It starts the embedded backend with `TrustLocalToken`, injects
`window.__nomiLocalTrust` into the WebView, and the renderer presents that secret
on HTTP and WebSocket calls. A process that only knows
`127.0.0.1:<port>/api` is not automatically trusted.
The desktop app is still a single-user tool: the OS account that starts it owns
everything the agent can do, including shell and file access.
If you want to access the same install from another device, do **not** expose the embedded port. Use one of:
- **WebUI remote access** (a per-instance feature, see [WebUI Remote Access](./webui-remote-access.md)) — turns on a separate authenticated server and gives you a QR-code login.
- **Self-hosted web server** ([Web Server Deployment](./web-server-deployment.md)) — runs the same backend headlessly under `nomifun-web` with auth required.
## Updater status
The Tauri updater plugin (`tauri-plugin-updater`) is wired in and the renderer exposes `invoke('check_for_updates')` (returns the new version string or `null` if up to date). However:
- The endpoint configured in `apps/desktop/tauri.conf.json` (`plugins.updater.endpoints`) is a **placeholder** (`https://REPLACE-WITH-YOUR-HOST/...`). Until you replace it with a real HTTPS URL serving a signed `latest.json`, the updater check will fail.
- The included `pubkey` is a **development key** generated for local testing. **Replace it before any public release** and store your private key in a CI secret.
- `bun run build:updater` produces signed update artifacts (extra `.sig` files next to each installer).
The full updater flow (signing env vars, `latest.json` schema, supported platform
keys) is documented in `apps/desktop/updater/README.md`. OS-level code signing /
notarization is separate. macOS Developer ID signing and notarization are wired
through `bun run build:signed` and documented in
`apps/desktop/signing/README.md`; Windows signing still requires an external
code-signing certificate.
## Troubleshooting
**The window opens to a blank white area.**
Make sure the WebView runtime is installed (WebView2 on Windows 10 needs the Evergreen Bootstrapper). On Linux, `libwebkit2gtk-4.1-0` is required.
**"Failed to bind backend port".**
Another process is holding `127.0.0.1` ephemeral ports. The backend tries `pick_free_port()` and falls back to `8799` if that fails — quit any other NomiFun instance and try again.
**Agent commands fail with `bun: command not found`.**
The agent engine spawns Bun as a child process for tool execution. Install Bun (`curl -fsSL https://bun.sh/install | bash`) and make sure it is on the system `PATH`, or build the desktop bundle with `NOMIFUN_EMBED_BUN=1` to embed it.
## See also
- [Web Server Deployment](./web-server-deployment.md) — run the same backend headlessly under `nomifun-web`.
- [WebUI Remote Access](./webui-remote-access.md) — expose your desktop instance for remote browser/phone use.
+146
View File
@@ -0,0 +1,146 @@
# 以桌面应用方式运行 NomiFun
桌面应用 (`nomifun-desktop`) 是一个 [Tauri](https://tauri.app/) 外壳,**在同一进程内**链接 Rust 后端 (`nomifun-app`)。这里没有派生的后端二进制,没有 Electron,也没有捆绑的 `nomicore`。外壳在一个空闲的 `127.0.0.1` 端口上将后端启动为异步任务,然后将打包好的 SPA (`ui/dist`) 加载进 WebView,并使其指向 `http://127.0.0.1:<port>/api`
桌面 WebView 不显示登录页。嵌入式后端使用 `AuthPolicy::TrustLocalToken`
外壳把每次启动生成的本地信任 secret 注入自己的 WebView,只有携带该 secret
的请求会被视为桌面用户。如果你想要登录 + 远程浏览器/手机访问,请参阅
[WebUI 远程访问](./webui-remote-access.zh.md)(应用内功能),或
[自托管 Web 服务器](./web-server-deployment.zh.md)(独立服务器)。
![NomiFun 桌面主窗口](../images/desktop-01-main-window.png)
## 快速开始
### 前置条件
桌面应用需要:
- Tauri 支持的平台 (Windows 10+、macOS 11+、主流 Linux 发行版)。
- WebView 运行时:Windows 上的 **WebView2** (Win 11 预装;Win 10 上请安装 [Evergreen Bootstrapper](https://developer.microsoft.com/microsoft-edge/webview2/))macOS 上的 **WKWebView** (内置)Linux 上的 **WebKitGTK** (`libwebkit2gtk-4.1-0`)。
- 用于开发:Rust 工具链、[Bun](https://bun.sh) ≥ 1.3.13,以及对应平台的 Tauri 构建依赖 (参见 [Tauri 前置条件](https://v2.tauri.app/start/prerequisites/))。
### 从源码运行 (开发模式)
在仓库根目录:
```bash
bun install
bun run dev
```
这会执行 `tauri dev --config apps/desktop/tauri.conf.json`。它启动 Vite 开发服务器 (`http://localhost:5173`) 来托管 SPA,构建并启动 `nomifun-desktop`,并在每次启动时在一个全新的空闲 localhost 端口上启动嵌入的后端。
### 构建发布包
```bash
bun run build
```
输出包按平台落到 `target/release/bundle/` 下 (Windows 上是 NSIS 安装器 + MSImacOS 上是 `.app` + `.dmg`Linux 上是 `.deb` + `.AppImage`)。要生成签名的更新器构件 (额外的 `.sig` 文件),请在配置好签名密钥后使用 `bun run build:updater` (参见下方[更新器状态](#更新器状态))。
构建成功后会打印包的位置,例如在 macOS 上:
```text
$ bun run build
Compiling nomifun-app v0.1.0
Finished `release` profile [optimized] target(s)
Bundling NomiFun.app (macos)
Bundling NomiFun_0.1.0_aarch64.dmg (macos)
Finished 2 bundles at:
target/release/bundle/macos/NomiFun.app
target/release/bundle/dmg/NomiFun_0.1.0_aarch64.dmg
```
## 窗口与标题栏
主窗口在 Windows 和 Linux 上是**无边框**的:React 标题栏组件在与应用内导航同一行绘制最小化/最大化/关闭按钮。在 macOS 上,原生的红绿灯按钮通过 Tauri 的 `Overlay` 标题栏样式得以保留,内容延伸至栏底之下。
- 默认尺寸:`1280 × 832`,最小 `880 × 600`
- 各处都可调整大小 (即使没有 OS 绘制的装饰,Windows 上的边缘调整和 Snap 仍然可用)。
- 标题栏:`NomiFun`
> 窗口边框因系统而异:Windows / Linux 上是带应用内控件的无边框标题栏,macOS
> 上保留原生红绿灯按钮(内容延伸至 `Overlay` 栏下)。
## 单实例
`tauri-plugin-single-instance` 在 Windows 和 Linux 上强制应用只运行一个副本。试图启动第二个 `nomifun-desktop` 不会在另一个端口上启动新的后端,而是会静默地聚焦到已有的窗口。
## 深度链接
应用注册了 `nomifun://` URL 协议 (在 `apps/desktop/tauri.conf.json``plugins.deep-link.desktop.schemes` 下配置)。当操作系统通过 `nomifun://...` URL 启动 Nomi 时,外壳会通过 Tauri 事件 `deep-link://received` 将 URL 转发给渲染进程。渲染进程可以使用 `@tauri-apps/api/event` 中的 `listen('deep-link://received', ...)` 订阅以处理负载。
启动时会调用 `register_all()` 来安装该协议;在需要带外注册步骤的平台上 (某些 Linux 桌面、开发环境),该调用是尽力而为的,失败会被忽略。
## 自启动
外壳附带 `tauri-plugin-autostart`,使得渲染进程可以通过插件的 invoke API 让应用加入 "登录时启动"。在 macOS 上这使用 `LaunchAgent`;在 Windows 上使用注册表的 `Run` 键;在 Linux 上则使用 autostart 文件夹中的 `.desktop` 文件。面向用户的开关位于应用设置中。
## 通知
`tauri-plugin-notification` 已启用。渲染进程可以显示 OS 级别的通知 (例如,当 agent 完成一个长任务或 AutoWork 有结果时)。在 macOS 上,第一次会请求用户授权;在 Windows 上,通知使用现代的操作中心;在 Linux 上则通过 `libnotify`
## 数据存储位置
桌面应用将 SQLite 数据库、agent 状态、日志和 Bun 运行时缓存持久化到按用户的应用数据目录下 —— Windows 上是 **`%LOCALAPPDATA%\NomiFun\Nomi`**macOS 上是 **`~/Library/Application Support/NomiFun/Nomi`**Linux 上是 **`$XDG_DATA_HOME/NomiFun/Nomi`** (由共享的 `nomifun_app::cli::default_data_dir()` 解析)。这与 `nomifun-web` 宿主和开发脚本使用的是同一个默认目录,因此在一个宿主里配置的 provider 或伙伴在其他宿主里同样可见。
在启动应用前设置 `NOMIFUN_DATA_DIR=<absolute path>`,数据目录就会变为 `$NOMIFUN_DATA_DIR/Nomi`。后端启动时会对数据目录取排他的 `server.lock`;若启动失败 (例如该目录已被另一个实例占用),桌面外壳会弹出原生错误对话框并退出。
> 旧版本默认使用 `<system temp>/nomifun-data/Nomi`。在那里发现的安装会在启动时自动迁移到按用户位置 (一次性):数据被复制,数据库中存储的绝对路径会被改写,旧目录保留作为备份。可再生的缓存 (解压出的 Bun 运行时、日志、浏览器配置 …) 不会带过去 —— 它们会在首次使用时重建。
要重新开始,**退出应用**并删除该目录。要迁移,将该目录复制到新机器上即可。
```text
~/Library/Application Support/NomiFun/Nomi/ # macOSWindows/Linux 路径见上文)
├── nomifun-backend.db # SQLite 状态(会话、设置、session 等)
├── logs/ # nomicore.log
├── companion/ # 伙伴 + 共享记忆中枢
├── knowledge/ # 受管理的知识库
├── runtime/ # 解压出的 Bun 运行时缓存(可再生)
└── server.lock # 后端运行期间持有的排他锁
```
## 认证与本地信任
桌面外壳不会把旧式完全无鉴权后端暴露给所有 localhost 调用者。它以
`TrustLocalToken` 启动嵌入式后端,向 WebView 注入 `window.__nomiLocalTrust`
渲染端在 HTTP 与 WebSocket 请求中呈递该 secret。只知道
`127.0.0.1:<port>/api` 的其他进程不会自动被信任。
桌面应用仍是单用户工具:启动它的 OS 账户拥有 agent 能做的一切,包括 shell
和文件访问。
如果你想从另一台设备访问同一个安装,**不要**直接暴露嵌入的端口。请使用以下之一:
- **WebUI 远程访问** (一个按实例启用的功能,参见 [WebUI 远程访问](./webui-remote-access.zh.md)) —— 启动一个独立的认证服务器并提供二维码登录。
- **自托管 Web 服务器** ([Web 服务器部署](./web-server-deployment.zh.md)) —— 在 `nomifun-web` 下以无头方式运行同一个后端,并要求认证。
## 更新器状态
Tauri 更新器插件 (`tauri-plugin-updater`) 已接入,渲染进程暴露了 `invoke('check_for_updates')` (返回新版本字符串,若已是最新则返回 `null`)。然而:
-`apps/desktop/tauri.conf.json` 中配置的端点 (`plugins.updater.endpoints`) 是一个**占位符** (`https://REPLACE-WITH-YOUR-HOST/...`)。在你将其替换为一个提供已签名的 `latest.json` 的真实 HTTPS URL 之前,更新器检查会失败。
- 包含的 `pubkey` 是一个为本地测试生成的**开发密钥**。**在任何公开发布前请替换它**,并将私钥存储在 CI 密钥中。
- `bun run build:updater` 会生成已签名的更新构件 (在每个安装器旁边附带 `.sig` 文件)。
完整 updater 流程(签名环境变量、`latest.json` schema、支持的平台键)在
`apps/desktop/updater/README.md` 中。OS 级别代码签名/公证是另一层:macOS
Developer ID 签名与公证已通过 `bun run build:signed`
`apps/desktop/signing/README.md` 接好;Windows 签名仍需要外部代码签名证书。
## 故障排查
**窗口打开后是空白白屏。**
确保已安装 WebView 运行时 (Windows 10 上的 WebView2 需要 Evergreen Bootstrapper)。在 Linux 上需要 `libwebkit2gtk-4.1-0`
**"Failed to bind backend port"。**
另一个进程占用了 `127.0.0.1` 临时端口。后端会尝试 `pick_free_port()`,失败时回退到 `8799` —— 退出任何其他 NomiFun 实例后再试。
**Agent 命令失败并报 `bun: command not found`。**
Agent 引擎会派生 Bun 作为子进程来执行工具。请安装 Bun (`curl -fsSL https://bun.sh/install | bash`) 并确保它在系统 `PATH` 上,或者使用 `NOMIFUN_EMBED_BUN=1` 构建桌面包以将其嵌入。
## 另请参阅
- [Web 服务器部署](./web-server-deployment.zh.md) —— 在 `nomifun-web` 下以无头方式运行同一个后端。
- [WebUI 远程访问](./webui-remote-access.zh.md) —— 暴露你的桌面实例供远程浏览器/手机使用。
@@ -0,0 +1,115 @@
# Intelligent Decision (IDMM)
**IDMM** — Intelligent Decision-Making Mode — is Nomi's reliability layer for
unattended work. It is a **session supervisor** that watches each turn and
intervenes the moment it stalls, so a long, automated run reaches a terminal
state instead of hanging on a provider hiccup or a model that has stopped
making progress.
If [AutoWork](autowork-requirements.md) is the engine that drives work
*forward*, IDMM is the guard that keeps each turn *moving*. The two are designed
to compose: AutoWork claims and executes requirements; IDMM makes sure every
turn it starts actually finishes.
> IDMM is an **optional** supervisor (the `nomifun-idmm` crate). You turn it on
> per session, from the same place you toggle AutoWork — the session header.
## Why it exists
Agent turns fail in boring, recoverable ways far more often than they fail in
interesting ones:
- a provider returns a transient `429` / `5xx` and the turn would otherwise give
up;
- the model retries the same failing call in a loop;
- the model spins on a tool call and never decides what to do next;
- the turn simply goes quiet and would eventually hit a hard timeout.
For an interactive session you would just nudge it yourself. For an *unattended*
session — an AutoWork queue running overnight, a scheduled job, a multi-agent
teammate — there is nobody watching. IDMM is that watcher.
## The two tiers
When IDMM detects a stall it resolves it with the cheapest mechanism that can,
escalating only when it must.
### Rule tier (no LLM)
A deterministic policy handles the common, mechanical stalls **without calling a
model at all** — so it is fast and free:
- **Provider faults** — transient errors and rate limits are absorbed and the
turn is retried under a sane backoff instead of failing outright.
- **Retry loops** — repeated identical retries are detected and broken.
- **Tool-spin** — a model that keeps re-issuing the same tool call without
progress is steered back on track.
Most interventions never get past this tier.
### Sidecar tier (a backup model)
When a stall is genuinely a *decision* problem — the main model is stuck and a
rule cannot resolve it — IDMM asks a **lightweight sidecar model** to make the
next decision so the session does not deadlock. The sidecar is a small, cheap
"second opinion" model: its only job is to unstick the turn, not to take over
the work.
This is the **bypass model** in product terms: a model that sits beside the main
agent and steps in only when needed.
## Session guard & keep-alive
Together, the rule tier and the sidecar form the **session guard**: IDMM keeps
the target alive through faults and decision stalls and shepherds the turn to a
terminal state. This is what "session keep-alive" means in Nomi — not a dumb
heartbeat, but an active supervisor that resolves the thing that would otherwise
have stalled the turn.
## How it composes with AutoWork
IDMM and AutoWork are independent but complementary:
- **AutoWork** claims the next requirement, injects it, waits for the turn to
finish, and finalises it.
- When AutoWork starts a turn, it asks IDMM (if wired) to **ensure supervision**
of that target for the duration of the turn.
- **IDMM** keeps that turn from getting stuck, so it reaches `done` / `failed`
cleanly instead of timing out.
The net effect: AutoWork provides forward progress; IDMM provides liveness. A
queue can run for hours, unattended, and individual transient failures no longer
abort the run.
```
AutoWork: claim ─▶ inject ─▶ [ turn runs ] ─▶ finalize (done/failed)
│ ensure supervision
IDMM guard ──▶ rule tier ──▶ (escalate) ──▶ sidecar model
```
See `crates/backend/nomifun-idmm/` for the per-tier policy detail and the
intervention log API.
## Enabling it
IDMM is toggled from the **session header**, the same control surface as
AutoWork. Turn it on for a conversation or a terminal target that you intend to
leave running unattended. There is nothing to configure for the rule tier; the
sidecar tier uses a lightweight model from your configured providers.
## When to use it
- **Use it** for any unattended run: AutoWork queues, scheduled
([cron](scheduled-tasks.md)) jobs, overnight batches, or long terminal-driven
agent sessions.
- **You may not need it** for short, interactive sessions where you are watching
the turn and can intervene yourself.
## See also
- [AutoWork & Requirements](autowork-requirements.md) — the engine IDMM most
often guards.
- [Scheduled Tasks](scheduled-tasks.md) — unattended jobs that benefit from
supervision.
- [Terminal](terminal.md) — IDMM can supervise long-running terminal targets.
@@ -0,0 +1,97 @@
# 智能决策(IDMM
**IDMM**——Intelligent Decision-Making Mode,智能决策模式——是 Nomi 面向
无人值守任务的稳定性层。它是一个**会话监督器**,盯守每一轮对话,一旦停滞
立即介入,让长时间自动化任务跑到终态,而不是卡在一次提供商抖动、或一个
不再向前推进的模型上。
如果说 [AutoWork](autowork-requirements.zh.md) 是推动工作**向前**的引擎,
IDMM 就是让每一轮持续**运转**的守卫。两者天生互补:AutoWork 负责认领并执行
需求,IDMM 则确保它启动的每一轮都真的能跑完。
> IDMM 是一个**可选**的监督器(`nomifun-idmm` crate)。在会话头部——与开启
> AutoWork 相同的位置——按会话开启。
## 为什么需要它
智能体对话失败,绝大多数是无聊、可恢复的方式,而不是什么有趣的原因:
- 提供商返回一次瞬时的 `429` / `5xx`,对话本会就此放弃;
- 模型反复重试同一个失败调用,陷入循环;
- 模型在某个工具调用上空转,迟迟不决定下一步;
- 对话干脆陷入沉默,最终撞上硬性超时。
交互式会话里,你自己推一把就好。但**无人值守**的会话——通宵跑的 AutoWork
队列、定时任务、多智能协同里的某个队友——没有人盯着。IDMM 就是那个盯守者。
## 两个层级
检测到停滞时,IDMM 会用能解决问题的、最省的手段去化解,必要时才逐级升级。
### 规则层(无需 LLM
一套确定性策略**完全不调用模型**就能处理常见的机械性停滞——又快又省:
- **提供商故障**——瞬时错误与限流被吸收,对话在合理退避下重试,而不是直接
失败。
- **重试循环**——识别并打断反复出现的相同重试。
- **工具空转**——对于不断重复发起同一工具调用却毫无进展的模型,把它拉回
正轨。
大多数介入都到此为止,不会进入下一层。
### 旁路模型层(备用模型)
当停滞确实是一个*决策*问题——主模型卡住了、规则无法化解——IDMM 会请一个
**轻量旁路模型**做出下一步决策,让会话不至于死锁。旁路模型是一个小而便宜的
「第二意见」模型:它唯一的职责是把这一轮解开,而不是接管整个工作。
这就是产品语境里的**旁路模型(Sidecar)**:一个待在主智能体旁边、仅在需要
时才介入的模型。
## 会话守卫与会话保活
规则层与旁路模型合在一起,构成了**会话守卫(Session guard**IDMM 在故障与
决策停滞中保活目标,并把这一轮护送到终态。这正是 Nomi 所说的「会话保活」
——不是一个傻乎乎的心跳,而是一个主动的监督器,去解决那个本会让对话卡死的
根因。
## 它如何与 AutoWork 协同
IDMM 与 AutoWork 相互独立、彼此互补:
- **AutoWork** 认领下一条需求、注入、等待这一轮跑完、并完成它。
- 当 AutoWork 启动一轮时,会请求 IDMM(若已接入)在这一轮期间**确保对该目标
的监督**。
- **IDMM** 让这一轮不至于卡住,从而干净地抵达 `done` / `failed`,而不是超时。
最终效果是:AutoWork 提供向前的推进力,IDMM 提供存活性。一个队列可以无人值守
地跑上数小时,单次的瞬时失败不再让整轮任务中止。
```
AutoWork: 认领 ─▶ 注入 ─▶ [ 对话运行 ] ─▶ 完成(done/failed
│ 确保监督
IDMM 守卫 ──▶ 规则层 ──▶ (升级)──▶ 旁路模型
```
每层策略的细节和介入日志 API 见 `crates/backend/nomifun-idmm/`
## 开启方式
IDMM 在**会话头部**开启,与 AutoWork 是同一处控制入口。对你打算无人值守长跑
的会话或终端目标打开它即可。规则层无需任何配置;旁路模型层会从你配置好的
提供商里使用一个轻量模型。
## 何时使用
- **建议开启**:任何无人值守的长跑——AutoWork 队列、定时
[cron](scheduled-tasks.zh.md))任务、通宵批处理,或长时间运行的终端型
agent 会话。
- **可以不开**:你自己盯着、随时能介入的短交互会话。
## 另请参阅
- [AutoWork 与需求](autowork-requirements.zh.md)——IDMM 最常守护的引擎。
- [定时任务](scheduled-tasks.zh.md)——受益于监督的无人值守任务。
- [终端](terminal.zh.md)——IDMM 可以监督长时间运行的终端目标。
+112
View File
@@ -0,0 +1,112 @@
# MCP & Skills
NomiFun has two extension mechanisms that are easy to confuse:
- **MCP servers** are external tool servers. They expose callable tools over
stdio, HTTP, or SSE.
- **Skills** are markdown/folder knowledge bundles. They tell an agent how to do
a workflow; they are not long-running tool servers.
Current pages:
| Capability | Page |
| --- | --- |
| MCP servers | `/mcp` |
| Skills | `/assistants?tab=skills` |
| Assistants | `/assistants?tab=assistants` |
| Public/remote capability exposure | `/open-capabilities` |
Legacy settings URLs redirect to these pages.
## MCP Servers
Open `/mcp` to add, import, test, enable, disable, and sync MCP servers.
![MCP page](../images/mcp-01-capabilities.png)
Each server row owns:
- name;
- transport: `stdio`, `http`, or `sse`;
- command / args / env for stdio, or URL for HTTP/SSE;
- raw imported JSON, when imported from another agent config;
- enabled state;
- last connection-test result.
Connection test uses a temporary MCP client, performs the handshake, lists tools,
and persists the result. Failure codes include command-not-found, permission,
timeout, HTTP, RPC, and protocol errors.
OAuth-backed HTTP/SSE servers use the `/api/mcp/oauth/*` flow.
## Importing and Syncing Agent Configs
`GET /api/mcp/agent-configs` detects MCP config files from supported local agent
CLIs. The UI lets you import detected servers into NomiFun and push the NomiFun
list back to selected agent configs when an adapter supports writing.
This sync is config management only. A conversation still decides which MCP
servers are visible for that session.
## Per-Conversation Selection
Enabling an MCP server globally makes it available. It does not inject it into
every agent automatically. Conversation/session setup builds the final MCP list
from:
- globally enabled servers;
- the servers selected for that conversation;
- built-in bridge servers required by the active capability set.
The resulting list is passed to the agent session start payload.
## MCP API
| Operation | Endpoint |
| --- | --- |
| List / create | `GET`, `POST /api/mcp/servers` |
| Import batch | `POST /api/mcp/servers/import` |
| Get / update / delete | `GET`, `PUT`, `DELETE /api/mcp/servers/{id}` |
| Toggle | `POST /api/mcp/servers/{id}/toggle` |
| Test connection | `POST /api/mcp/test-connection` |
| Detect agent configs | `GET /api/mcp/agent-configs` |
| OAuth | `POST /api/mcp/oauth/check-status`, `/login`, `/logout`; `GET /api/mcp/oauth/authenticated` |
## Skills
Open `/assistants?tab=skills`.
![Skills tab](../images/mcp-03-skills.png)
A skill is either a single markdown file or a directory containing `SKILL.md`.
Sources:
| Source | Meaning |
| --- | --- |
| Builtin | Shipped with the app. Some are auto-injected. |
| Custom | Imported by the user or placed in a configured skill directory. |
| Extension | Provided by an installed extension. |
Skills can be tagged, imported, exported/symlinked, scanned from external paths,
or materialized for a specific agent backend.
## Skill API
| Operation | Endpoint |
| --- | --- |
| List | `GET /api/skills` |
| Builtin auto-injected list | `GET /api/skills/builtin-auto` |
| Tags | `PUT /api/skills/{name}/tags` |
| Info / paths | `POST /api/skills/info`, `GET /api/skills/paths` |
| Import / export / delete | `POST /api/skills/import`, `POST /api/skills/import-symlink`, `POST /api/skills/export-symlink`, `DELETE /api/skills/{name}` |
| Scan / detect paths | `POST /api/skills/scan`, `GET /api/skills/detect-paths`, `GET /api/skills/detect-external` |
| Materialize for agent | `POST /api/skills/materialize-for-agent` |
| Assistant rule/skill files | `/api/skills/assistant-rule/*`, `/api/skills/assistant-skill/*` |
| External paths | `GET`, `POST`, `DELETE /api/skills/external-paths` |
| Skills market | `POST /api/skills/market/enable`, `POST /api/skills/market/disable` |
## Related
- [Assistants](./assistants.md)
- [Remote Capability API](./remote-capability-api.md)
- [Terminal](./terminal.md)
@@ -0,0 +1,105 @@
# MCP 与技能
NomiFun 有两种容易混淆的扩展机制:
- **MCP server** 是外部工具服务器,通过 stdio、HTTP 或 SSE 暴露可调用工具。
- **技能** 是 markdown/文件夹知识包,告诉 agent 如何完成某个工作流;它不是常驻工具服务器。
当前页面:
| 能力 | 页面 |
| --- | --- |
| MCP server | `/mcp` |
| 技能 | `/assistants?tab=skills` |
| 助手 | `/assistants?tab=assistants` |
| 对外能力暴露 | `/open-capabilities` |
旧 Settings URL 会重定向到这些页面。
## MCP Server
打开 `/mcp` 可以新增、导入、测试、启用/禁用和同步 MCP server。
![MCP 页面](../images/mcp-01-capabilities.png)
每条 server 记录包含:
- 名称;
- transport`stdio``http``sse`
- stdio 的 command / args / env,或 HTTP/SSE 的 URL
- 从其他 agent 配置导入时保留的 raw JSON
- enabled 状态;
- 最近一次连接测试结果。
连接测试会启动临时 MCP client,完成握手、列出工具并持久化结果。失败码覆盖命令
不存在、权限、超时、HTTP、RPC 和协议错误。
需要 OAuth 的 HTTP/SSE server 走 `/api/mcp/oauth/*` 流程。
## 导入和同步 Agent 配置
`GET /api/mcp/agent-configs` 会探测已支持本地 agent CLI 的 MCP 配置。UI 可把探测到
的 server 导入 NomiFun,也可在 adapter 支持写入时把 NomiFun 的 MCP 列表同步回选中的
agent 配置。
这只是配置管理。某次会话最终能看到哪些 MCP server,仍由该会话的选择决定。
## 每会话选择
全局启用 MCP server 只是让它可用,不会自动注入每个 agent。会话启动时最终 MCP 列表来自:
- 全局 enabled server
- 该会话选择的 server
- 当前能力集需要的 builtin bridge server。
最终列表会进入 agent session start payload。
## MCP API
| 操作 | Endpoint |
| --- | --- |
| 列表 / 创建 | `GET`, `POST /api/mcp/servers` |
| 批量导入 | `POST /api/mcp/servers/import` |
| 获取 / 更新 / 删除 | `GET`, `PUT`, `DELETE /api/mcp/servers/{id}` |
| 启用切换 | `POST /api/mcp/servers/{id}/toggle` |
| 连接测试 | `POST /api/mcp/test-connection` |
| 探测 agent 配置 | `GET /api/mcp/agent-configs` |
| OAuth | `POST /api/mcp/oauth/check-status`, `/login`, `/logout`; `GET /api/mcp/oauth/authenticated` |
## 技能
打开 `/assistants?tab=skills`
![技能页](../images/mcp-03-skills.png)
技能可以是单个 markdown 文件,也可以是包含 `SKILL.md` 的目录。
| 来源 | 含义 |
| --- | --- |
| Builtin | 随应用发布;部分会自动注入。 |
| Custom | 用户导入或放入配置目录。 |
| Extension | 已安装扩展提供。 |
技能可打标签、导入、导出/符号链接、扫描外部目录,也可按某个 agent 后端进行
materialize。
## 技能 API
| 操作 | Endpoint |
| --- | --- |
| 列表 | `GET /api/skills` |
| 自动注入 builtin 列表 | `GET /api/skills/builtin-auto` |
| 标签 | `PUT /api/skills/{name}/tags` |
| 信息 / 路径 | `POST /api/skills/info`, `GET /api/skills/paths` |
| 导入 / 导出 / 删除 | `POST /api/skills/import`, `POST /api/skills/import-symlink`, `POST /api/skills/export-symlink`, `DELETE /api/skills/{name}` |
| 扫描 / 探测路径 | `POST /api/skills/scan`, `GET /api/skills/detect-paths`, `GET /api/skills/detect-external` |
| 为 agent materialize | `POST /api/skills/materialize-for-agent` |
| 助手规则/技能文件 | `/api/skills/assistant-rule/*`, `/api/skills/assistant-skill/*` |
| 外部路径 | `GET`, `POST`, `DELETE /api/skills/external-paths` |
| 技能市场 | `POST /api/skills/market/enable`, `POST /api/skills/market/disable` |
## 相关
- [助手](./assistants.zh.md)
- [远程能力 API](./remote-capability-api.zh.md)
- [终端](./terminal.zh.md)
@@ -0,0 +1,52 @@
# Model Failover Queue
The current feature behind model-routing settings is a **model failover queue**,
not a credential round-robin pool.
It lets Nomi-engine conversations try a configured sequence of backup models
when a provider fault is detected. ACP/CLI agents are not included in this
feature because their provider calls happen inside external runtimes.
## What It Does
- Stores a global default queue under `agent.model_failover`.
- Allows per-conversation overrides under `extra.model_failover`.
- Applies only to Nomi-engine conversations.
- Can be used by IDMM fault-watch flows when that session has failover enabled.
- Does not distribute load across API keys.
- Does not make all CLI agents share a common pool.
## When To Use It
Use model failover when a Nomi-engine session should recover from transient
provider/model faults without requiring manual model switching.
Typical queue:
```text
primary model -> cheaper backup -> stronger backup -> manual review
```
The queue is about reliability, not quota aggregation. If every configured
provider is down or the prompt/tool state is invalid, failover cannot make the
turn succeed.
## How It Relates To IDMM
IDMM has separate fault and decision watches. Model failover belongs to the
fault side: when a provider fault is classified as recoverable and failover is
enabled, IDMM can ask the conversation runtime to retry through the configured
queue.
AutoWork then sits one layer above both features: it keeps a tagged work queue
moving, while IDMM/model failover try to keep each claimed turn alive.
## Source Of Truth
- `crates/backend/nomifun-conversation/src/model_failover.rs`
- `crates/backend/nomifun-conversation/src/failover_seam.rs`
- `crates/backend/nomifun-app/src/router/model_failover.rs`
- `crates/backend/nomifun-idmm/src/policy.rs`
Older copies of this page described multi-credential round-robin routing. That
was not the current implementation and should not be used as operator guidance.
@@ -0,0 +1,48 @@
# 模型故障转移队列
当前模型路由设置背后的实现是**模型故障转移队列**,不是多凭据轮询池。
它允许 Nomi 引擎会话在检测到提供商故障时,按你配置的顺序尝试备用模型。
ACP/CLI 智能体不包含在这个功能里,因为它们的提供商调用发生在外部运行时内部。
## 它做什么
- 全局默认队列存储在 `agent.model_failover`
- 单个会话可以通过 `extra.model_failover` 覆盖。
- 只作用于 Nomi 引擎会话。
- 可被 IDMM 的故障监视流程使用。
- 不会在 API Key 之间分摊负载。
- 不会让所有 CLI 智能体共享同一个模型池。
## 什么时候使用
当一个 Nomi 引擎会话需要在临时提供商/模型故障后自动换用备用模型时,使用模型
故障转移。
常见队列:
```text
主模型 -> 便宜备用模型 -> 更强备用模型 -> 人工检查
```
这个队列解决的是可靠性,不是额度聚合。如果所有配置的提供商都不可用,或者
prompt / tool 状态本身无效,故障转移也无法让这一轮成功。
## 与 IDMM 的关系
IDMM 有独立的故障监视和决策停滞监视。模型故障转移属于故障侧:当某个提供商
故障被判定为可恢复,且该会话启用了故障转移时,IDMM 可以让会话运行时按配置
队列重试。
AutoWork 位于更上一层:它负责让标签队列继续认领和推进需求,而 IDMM / 模型
故障转移负责尽量让每个已认领的回合活下来。
## 真相来源
- `crates/backend/nomifun-conversation/src/model_failover.rs`
- `crates/backend/nomifun-conversation/src/failover_seam.rs`
- `crates/backend/nomifun-app/src/router/model_failover.rs`
- `crates/backend/nomifun-idmm/src/policy.rs`
本页旧版本曾把该功能描述成多凭据 round-robin 路由。那不是当前实现,不应作为
运维或用户指南使用。
@@ -0,0 +1,168 @@
# Remote Capability API Examples
These examples use one companion access token bound to one companion. Replace
`$HOST` with your NomiFun host and `$TOKEN` with the token shown when it was
created.
```bash
export HOST=127.0.0.1:25808
export TOKEN=<companion-access-token>
```
## MCP Client
For Claude Code, Cursor, or any MCP client that supports Streamable HTTP:
```json
{
"mcpServers": {
"nomifun": {
"type": "streamable-http",
"url": "http://$HOST/mcp-agent",
"headers": {
"Authorization": "Bearer $TOKEN"
}
}
}
}
```
Use `/mcp-agent` for the curated worker surface. Use `/mcp` only when you need
the broader platform-control surface.
## curl
List curated tools:
```bash
curl -s "http://$HOST/v1/tools?profile=agent" \
-H "Authorization: Bearer $TOKEN"
```
Delegate a task to an autonomous NomiFun agent:
```bash
curl -s -X POST "http://$HOST/v1/tools/nomi_agent_run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal":"Research competitor pricing and write notes.md","timeout_secs":600}'
```
Poll a long-running delegated task:
```bash
curl -s -X POST "http://$HOST/v1/tools/nomi_agent_result" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"conversation_id":123}'
```
Call any discovered tool:
```bash
curl -s -X POST "http://$HOST/v1/tools/<tool_name>" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"argument":"value"}'
```
For a confirmation-required destructive action, first show the returned
challenge to the user. Retry only after explicit approval:
```bash
curl -s -X POST "http://$HOST/v1/tools/<tool_name>" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"argument":"value","confirm":true}'
```
## SSE Streaming
```bash
curl -N -X POST "http://$HOST/v1/tools/nomi_agent_run/stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal":"Summarize this repository"}'
```
The final event is:
```json
{"type":"__result__","data":{"result":{}}}
```
## Python REST
```python
import requests
base = f"http://{HOST}"
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
}
response = requests.post(
f"{base}/v1/tools/nomi_agent_run",
headers=headers,
json={"goal": "Research competitor pricing and write notes.md"},
)
print(response.json())
```
## Python Streamable HTTP MCP
```python
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
async def main():
headers = {"Authorization": "Bearer " + TOKEN}
async with streamablehttp_client(
"http://%s/mcp-agent" % HOST,
headers=headers,
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print([tool.name for tool in tools.tools])
result = await session.call_tool(
"nomi_agent_run",
{"goal": "Research competitor pricing and write notes.md"},
)
print(result)
```
## Headless Server Token Seed
For a local headless server:
```bash
export NOMIFUN_COMPANION_TOKEN="$(openssl rand -hex 32)"
nomifun-web --host 127.0.0.1 --port 8787
```
For LAN or public access, finish admin setup first, bind intentionally, and
place the server behind TLS:
```bash
nomifun-web --host 0.0.0.0 --port 8787
```
## OpenAPI
Generate a typed client from:
```bash
curl -s "http://$HOST/v1/openapi.json?profile=agent" \
-H "Authorization: Bearer $TOKEN" > nomifun-openapi.json
```
## Notes
- MCP clients should prefer `/mcp-agent`.
- Scripts and automation systems can use `/v1/tools/{name}` directly.
- Use `/v1/tools/{name}/stream` when live progress matters.
- Tokens can be revoked with
`DELETE /api/webui/companions/{id}/access-token` from a trusted local
desktop context.
@@ -0,0 +1,149 @@
# Remote 能力 API · 对接示例 cookbook
> 配套 `remote-capability-api.zh.md`。所有示例用同一枚**伙伴访问令牌**(绑定到某个具体伙伴,调用即以该伙伴身份运行);端点 = WebUI/LAN 端口(默认 `25808`)或 `nomifun-web` 的服务端口。下文用 `$HOST`/`$TOKEN` 占位。
## 0. 先决:拿到端点 + 令牌
- **端点**`http://<你的实例IP>:25808`(开启 WebUI 远程访问后),或本机 `http://127.0.0.1:<port>`
- **令牌(运维侧一次性发放,绑定到一个伙伴)**:
- 桌面应用:WebUI/远程访问面板为某个伙伴点「生成访问令牌」(明文只显示一次)。
- 无头服务器:启动时 `NOMIFUN_COMPANION_TOKEN=$(openssl rand -hex 32) nomifun-web --host 127.0.0.1 --port 8787`,绑定到默认伙伴,把这串 hex 当令牌。
- 本机可信上下文(桌面 webview / dev NoAuth)可 `curl -X POST http://127.0.0.1:<port>/api/webui/companions/<companion-id>/access-token`(远程/普通 curl 会 403——铸造刻意只限本地可信)。
- 拿到后:`export TOKEN=<令牌>`;所有请求带 `Authorization: Bearer $TOKEN`
- **以伙伴身份运行**:调用继承所绑定伙伴的模型/人格/知识库;`nomi_agent_run` 不带 `model` 时用该伙伴的 profile 模型,**所以该伙伴要先配置好可用模型**(否则铸造响应里会带 `warning`)。
- **能力发现**`GET /v1/tools`(或 `/v1/tools?profile=agent` 精瘦集)列出所有工具名 + 描述 + JSON Schema;下文工具名以此为准(`nomi_agent_run` 一定有)。
---
## 1. MCP 客户端(Claude Code / Cursor / 任意 MCP Agent)—— 旗舰
最省事:把 NomiFun 作为一个 Streamable-HTTP MCP server 配进去。Claude Code / Cursor 的 `mcpServers`
```json
{
"mcpServers": {
"nomifun": {
"type": "streamable-http",
"url": "http://$HOST:25808/mcp-agent",
"headers": { "Authorization": "Bearer $TOKEN" }
}
}
}
```
- `/mcp-agent` = curated「干活」工具集(agent/browser/computer/knowledge/files);要全平台控制面用 `/mcp`
- 连上后 `tools/list` 即见 `nomi_*` 工具,`tools/call` 驱动。委派整件事就调 `nomi_agent_run`
Python 通用 MCP SDK
```python
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
async def main():
headers = {"Authorization": "Bearer " + TOKEN}
async with streamablehttp_client("http://%s:25808/mcp" % HOST, headers=headers) as (r, w, _):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
res = await s.call_tool("nomi_agent_run", {"goal": "调研 X 并写 notes.md"})
print(res)
```
---
## 2. curlHTTP/REST,最通用)
```bash
# 列能力(精瘦 agent 档)
curl -s "http://$HOST:25808/v1/tools?profile=agent" -H "Authorization: Bearer $TOKEN"
# 委派一个目标(一句话把活交给一个自治 nomi agent)
curl -s -X POST "http://$HOST:25808/v1/tools/nomi_agent_run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"goal":"调研竞品定价并写入 notes.md","timeout_secs":600}'
# => 200 {"result":{"conversation_id":123,"status":"completed","text":"..."}}
# 长任务 => {"result":{"conversation_id":123,"status":"running",...}},之后轮询:
curl -s -X POST "http://$HOST:25808/v1/tools/nomi_agent_result" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"conversation_id":123}'
# 调任意能力(名字来自 /v1/tools)
curl -s -X POST "http://$HOST:25808/v1/tools/<tool_name>" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{...args...}'
# 危险操作:先返回 {"needs_confirmation":true,...}(HTTP 409) → 向用户复述后带 confirm 重试
curl -s -X POST "http://$HOST:25808/v1/tools/<tool>" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{...args..., "confirm": true}'
```
**结果信封**:成功 `200 {"result": <payload>}`;工具报错 `422 {"error":..}`;需确认 `409 {"needs_confirmation":true,..}`;未知工具 `404`;无/错令牌 `401`
### 流式(SSE
```bash
curl -N -X POST "http://$HOST:25808/v1/tools/nomi_agent_run/stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"goal":"..."}'
# 每行一个 data: JSON 事件(agent 的 text/tool_call delta),
# 末帧 data: {"type":"__result__","data":{"result":{...终值...}}}
```
---
## 3. PythonREST + SSE
```python
import requests, json
BASE = "http://%s:25808" % HOST
H = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
# 调用
r = requests.post(f"{BASE}/v1/tools/nomi_agent_run", headers=H, json={"goal": "..."})
print(r.json()) # {"result": {...}} / {"error":...} / {"needs_confirmation":...}
# 流式
with requests.post(f"{BASE}/v1/tools/nomi_agent_run/stream", headers=H,
json={"goal": "..."}, stream=True) as resp:
for line in resp.iter_lines():
if line and line.startswith(b"data: "):
ev = json.loads(line[6:])
if ev.get("type") == "__result__":
print("FINAL:", ev["data"]); break
print("delta:", ev)
```
---
## 4. nomicore CLI(人/脚本)
```bash
export NOMIFUN_URL=http://$HOST:25808
export NOMIFUN_COMPANION_TOKEN=$TOKEN
nomicore tools # 离线列出 Remote 能力(无需运行实例)
nomicore call nomi_agent_run '{"goal":"..."}'
nomicore agent "调研竞品定价并总结" # nomi_agent_run 的便捷包装
```
---
## 5. 任意 HTTP 自动化(n8n / Zapier / Make / shell 脚本)
把一个 HTTP 节点指向 `POST http://$HOST:25808/v1/tools/{name}`Header `Authorization: Bearer $TOKEN`Body = 该工具的 JSON 参数。零 SDK。
## 6. 从 OpenAPI 生成客户端
`GET http://$HOST:25808/v1/openapi.json[?profile=agent]` 是 OpenAPI 3.1 契约 —— 喂给 `openapi-generator` 生成任意语言的 typed client,或导入 Postman/Insomnia/Bruno。
## 7. 接进别的 LLM agent 框架(LangChain / OpenAI tool-calling / 自研)
`GET /v1/tools` 每个工具自带 `name` + `description` + `input_schema`(标准 JSON Schema)。把它们直接注册成你框架的工具列表;模型决定调用某工具时,转一发 `POST /v1/tools/{name}`(带 `confirm` 处理 409)。等于让 NomiFun 全平台能力即插即用地成为你 agent 的工具集。
---
## 备注
- **安全**:持令牌即全权(≈授予 RCE 等价能力);只发给可信客户端,公网前置 TLS 反代,令牌可吊销(`DELETE /api/webui/companions/{id}/access-token`,只影响对应伙伴)。
- **MCP vs REST 选择**agent/MCP 客户端用 `/mcp`(-agent);脚本/自动化/其它语言用 `/v1`;要实时进度用 `/v1/tools/{name}/stream`(SSE) 或(MCP 端长任务)`nomi_agent_run``{status:running}` 句柄 + `nomi_agent_result` 轮询。
@@ -0,0 +1,185 @@
# Remote Capability API
NomiFun can expose its platform capabilities through a network-reachable,
token-authenticated MCP and REST front door. A trusted external agent or MCP
client can connect with a URL plus a companion access token and then call the
same capability registry used by the desktop app.
Each token is bound to one companion. Calls made with that token run as that
companion and inherit its profile model, persona, and knowledge context.
For copy-ready integrations, see
[Remote Capability API Examples](./remote-capability-api-examples.md).
## Security Model
A companion access token is high privilege. It can drive agents, read and
write files through exposed tools, and in desktop builds may operate browser or
computer-use capabilities. Treat it like remote code execution authority:
- Give tokens only to clients and agents you trust.
- Prefer loopback, VPN, or a private network.
- Put TLS, firewall rules, and rate limits in front of any public exposure.
- Rotate or revoke tokens immediately if they leave your control.
- Sensitive tools such as secrets and factory reset are not exposed on the
remote surface by default.
- Destructive tools require a confirmation retry: the first call returns a
confirmation challenge; the caller must show the action to the user and retry
with `confirm: true`.
## Endpoints
The network front door is mounted by the same backend process as the Web UI.
| Endpoint | Purpose |
| --- | --- |
| `/mcp` | Full Streamable-HTTP MCP server. |
| `/mcp-agent` | Curated MCP profile for external working agents. |
| `/v1/tools` | REST tool discovery. Add `?profile=agent` for the curated set. |
| `/v1/tools/{name}` | REST tool call. |
| `/v1/tools/{name}/stream` | SSE streaming wrapper for tools that emit progress. |
| `/v1/openapi.json` | OpenAPI 3.1 description for the REST tool surface. |
Authenticate every request with:
```http
Authorization: Bearer <companion-access-token>
```
Common base URLs:
- Desktop remote access: `http://<LAN-IP>:25808`
- Standalone server: `http://<host>:8787` unless you changed the port
- Local development or embedded desktop backend: `http://127.0.0.1:<port>`
## Creating A Companion Token
Tokens are stored hashed. The plaintext token is shown only once.
### Desktop App
Use the Open Capabilities / remote access UI, or call the trusted local API
from the desktop WebView context:
```bash
curl -X POST \
http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
```
The response returns the plaintext token once:
```json
{
"success": true,
"data": {
"token": "<64-character-hex-token>",
"companion_id": "<companion-id>"
}
}
```
Status and revoke use the same path:
```bash
curl http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
curl -X DELETE \
http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
```
These token-management endpoints require local trust. A remote browser or plain
curl client cannot mint tokens.
### Headless `nomifun-web`
Seed a token at startup with `NOMIFUN_COMPANION_TOKEN`. The value binds to the
default companion when no token is already configured:
```bash
NOMIFUN_COMPANION_TOKEN="$(openssl rand -hex 32)" \
nomifun-web --host 127.0.0.1 --port 8787
```
Use the generated hex string as the Bearer token. For non-local exposure,
finish admin setup first and put the server behind TLS.
## MCP Client Configuration
Example Streamable-HTTP MCP configuration:
```json
{
"mcpServers": {
"nomifun": {
"type": "streamable-http",
"url": "http://127.0.0.1:25808/mcp-agent",
"headers": {
"Authorization": "Bearer <companion-access-token>"
}
}
}
}
```
Use `/mcp-agent` when an external agent mostly needs work tools
(agent/browser/computer/knowledge/files). Use `/mcp` when you intentionally
want the broader platform control surface.
## REST Tool Calls
Discover tools:
```bash
curl -s "http://127.0.0.1:25808/v1/tools?profile=agent" \
-H "Authorization: Bearer $TOKEN"
```
Run a delegated NomiFun agent task:
```bash
curl -s -X POST "http://127.0.0.1:25808/v1/tools/nomi_agent_run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal":"Research competitors and write notes.md","timeout_secs":600}'
```
Poll a long-running task:
```bash
curl -s -X POST "http://127.0.0.1:25808/v1/tools/nomi_agent_result" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"conversation_id":123}'
```
Successful REST calls return `200 {"result": ...}`. Tool validation failures
return `422`, unknown tools return `404`, invalid tokens return `401`, and
confirmation-required calls return `409`.
## Streaming
SSE streaming is available for tools that report progress:
```bash
curl -N -X POST "http://127.0.0.1:25808/v1/tools/nomi_agent_run/stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal":"Summarize the current repository"}'
```
Each event is a `data: <json>` line. The final event uses
`{"type":"__result__","data":{"result":...}}`.
## Companion Context
Because the caller runs as the bound companion, `nomi_agent_run` can use that
companion's configured model when no `model` argument is supplied. Configure a
usable provider/model for the companion before relying on model-backed tools;
token creation may warn if the companion has no usable model.
## Related Docs
- [Remote Capability API Examples](./remote-capability-api-examples.md)
- [WebUI Remote Access](./webui-remote-access.md)
- [Web Server Deployment](./web-server-deployment.md)
- [Computer Use And Browser Use](./computer-browser-use.md)
@@ -0,0 +1,106 @@
# Remote 能力 API(外部伙伴 / MCP 接入指南)
NomiFun 把整个平台的能力(agent / browser / computer / 知识库 / 文件 / 以及平台控制)通过一个**网络可达、伙伴访问令牌鉴权的 MCP 端点**暴露出来。任何 MCP 客户端(Claude Code、Cursor、自研 LLM agent)填一个 URL + 一枚访问令牌,就能像"桌面伙伴"一样驱动平台——这就是"**外部伙伴**"。每枚令牌**绑定到一个具体伙伴**:持令牌调用即以该伙伴的身份运行,继承它的 profile 模型 / 人格 / 知识库,互不串扰。
> 📋 **可复制的对接示例**MCP 客户端 / curl / Python / CLI / 自动化 / OpenAPI codegen / LLM 框架)见 **`remote-capability-api-examples.zh.md`**。
## ⚠️ 安全须知
持有伙伴访问令牌即可调用平台能力,**等价于授予远程代码执行(RCE)能力**(可驱动 agent、读写文件、操作 computer/browser)。因此:
- 只把令牌交给你信任的客户端/agent。
- 仅在可信网络暴露;公网暴露务必前置 TLS 反代 + 防火墙。
- 令牌可随时吊销/轮换(见下);吊销只影响对应伙伴,其它伙伴的令牌不受影响。
- 默认安全栏:危险能力(`secret.*``system.factory_reset` 等)在 Remote 面被拒;破坏性操作需二次确认(协议级握手,见「权限模型」)。
## 端点
`/mcp`MCP Streamable-HTTP)随后端进程内挂载,与 WebUI 共用监听器:
- **本机**`http://127.0.0.1:<port>/mcp`(桌面应用的回环端口,或 `nomifun-web` 的服务端口)
- **局域网/远程**:开启 WebUI 远程访问后 `http://<你的IP>:25808/mcp`
鉴权:HTTP 头 `Authorization: Bearer <伙伴访问令牌>`
## 一、获取伙伴访问令牌
令牌**只存哈希、明文只在铸造时返回一次**,且**绑定到一个具体伙伴**(`{id}` = 伙伴 id)。两种获取方式:
### 桌面应用(本机可信客户端)
桌面 webview 自带本地信任,可直接调本地端点(也会有 UI 入口)。下文 `<companion-id>` 为要绑定的伙伴 id
```bash
# 铸造(返回明文一次,并绑定到该伙伴)
curl -X POST http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
# => {"success":true,"data":{"token":"<64位hex令牌>","companion_id":"<companion-id>"}}
# 若该伙伴尚无可用模型,data 还会带 "warning":"…"(令牌照常铸造,但 nomi_agent_run 等
# 需要模型的能力会失败,先去「模型管理」配置)
# 查询是否已配置(不返回令牌)
curl http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
# => {"success":true,"data":{"configured":true}}
# 吊销
curl -X DELETE http://127.0.0.1:<loopback-port>/api/webui/companions/<companion-id>/access-token
# => {"success":true,"data":{"configured":false}}
```
> 这些 `/api/webui/companions/{id}/access-token` 端点仅本地可信客户端可达(`require_local_trust`),远程浏览器拿不到。每个伙伴各持一枚令牌(再次铸造会覆盖旧令牌)。
### 无头服务器(headless `nomifun-web`
无头部署用环境变量在启动时播种,**绑定到默认伙伴**(仅当该令牌尚未配置时生效,不覆盖已有;若实例中尚无任何伙伴会跳过并告警):
```bash
NOMIFUN_COMPANION_TOKEN="$(openssl rand -hex 32)" \
nomifun-web --host 127.0.0.1 --port 8787
```
把这串 hex 作为客户端的 Bearer 令牌。
## 二、连接 MCP 客户端
### Claude Code / 通用 MCP 客户端(Streamable-HTTP
```json
{
"mcpServers": {
"nomifun": {
"type": "streamable-http",
"url": "http://127.0.0.1:25808/mcp",
"headers": { "Authorization": "Bearer <伙伴访问令牌>" }
}
}
}
```
连上后 `tools/list` 即可看到平台在 Remote 面暴露的工具(`nomi_*`);`tools/call` 驱动。
## 三、权限模型(Remote 面)
外部调用方落在 `Surface::Remote`,权限矩阵:
| 能力危险级 | Remote 行为 |
|---|---|
| 读 / 写 | 允许 |
| 破坏性(删除等) | 需确认:先返回 `{"needs_confirmation":true,...}`,agent 复述动作征得用户同意后,带 `"confirm": true` 重试 |
| 敏感(`secret.*` / `factory_reset` | **拒绝**(默认不在 Remote 暴露) |
被拒的工具**不出现在 `tools/list`**(更好的 UX + 纵深防御)。
## 四、能力继承
平台能力通过同一条能力总线(`nomifun-gateway` 的 Capability Registry)暴露到 MCP/HTTP/CLI/Skill 等外部面。新增能力时,应同时评估它是否适合 Remote surface、是否需要确认,以及是否应进入 `/mcp-agent` 精简集。
调用方**以令牌所绑定的伙伴身份运行**:继承该伙伴的 profile 模型、人格与知识库,伙伴之间彼此隔离。因此 `nomi_agent_run` 在不显式指定 `model` 时,会解析所绑定伙伴的 profile 模型——**该伙伴必须配置好可用模型**(否则铸造时会返回 `warning`,且需要模型的能力会失败)。
## 当前可用面
-**MCP**`/mcp`(全量 ~140 工具)+ `/mcp-agent`curated 干活子集)。
-**委派目标**`nomi_agent_run(goal,workspace?,model?,timeout_secs?)` 一句话把任务交给一个自治 nomi agent,跑完返回终稿;长任务返回 `{status:running}` 句柄,用 `nomi_agent_result(conversation_id)` 轮询。
-**HTTP REST**`POST /v1/tools/{name}``GET /v1/tools[?profile=agent]``GET /v1/openapi.json[?profile=agent]`OpenAPI 3.1,同令牌)。
-**CLI**`nomicore tools`(离线列能力)、`nomicore call <name> [json]``nomicore agent "<目标>"`(读 `NOMIFUN_URL`/`NOMIFUN_COMPANION_TOKEN``--url`/`--token`)。
-**Skill**`docs/skills/drive-nomifun/SKILL.md` —— 教外部 agent 如何连上并驱动 NomiFun(可发布到技能市场)。
-**Computer**:桌面版(`computer-use` 构建)暴露 `nomi_computer_*`snapshot/click/type/key/scroll/launch/screenshot/…),外部调用方可驱动桌面(headless/web 构建不含)。
-**流式**`POST /v1/tools/{name}/stream`SSE)—— 流式工具(如 `nomi_agent_run`)实时吐 `{type:..}` delta,末帧 `{type:"__result__"}` 带终值;非流式工具仅末帧。`nomi_agent_run` 已流式(订阅 agent 广播逐条转发)。
@@ -0,0 +1,236 @@
# Scheduled Tasks (Cron)
A scheduled task in NomiFun is a recurring (or one-shot) job that fires at a
time you choose and drives an AI agent to do something. You can configure it
from the Scheduled Tasks page, run it on demand, attach a personalised
**skill** so the agent always behaves the right way for that job, and you
can ask any agent in chat to manage tasks for you using a built-in cron
skill.
> Looking for one-off async work that should run as soon as possible, not on
> a clock? See [AutoWork & Requirements](./autowork-requirements.md). Need a
> live shell instead? See [In-App Terminals](./terminal.md).
![Scheduled tasks list](../images/cron-01-list.png)
## What a job does
`nomifun-cron` is a backend scheduler + executor:
- The **scheduler** computes the next fire time for each enabled job using
a 5-field (Unix) or 6-field (seconds-prefixed) cron expression — both are
accepted; a 5-field expression is normalised to 6 fields by prepending
`0` for seconds. Schedules can also be a single absolute timestamp
(`At { at_ms }`) or a fixed interval (`Every { every_ms }`).
- A timezone (e.g. `Asia/Shanghai`, `America/Los_Angeles`) is honoured per
job, so `0 9 * * MON` means 09:00 in **that** zone, not UTC.
- The **executor** drives the job's agent when the timer fires. Two
execution modes:
- **`new_conversation`** — start a fresh conversation per fire. The job
carries the workspace, agent, model, and prompt; the executor creates
the conversation, broadcasts a `cron_trigger` artifact (so the chat
UI shows "this conversation was started by a scheduled task"), and
sends the prompt.
- **`existing`** — reuse the conversation that owns the job. Each fire
sends the prompt as a new message in that same thread. Good for
"remind me", "summarise the day", or any job where continuity matters.
- A **busy guard** prevents the same conversation from being entered
concurrently. If the previous run is still going when the next fire
lands, the new run is skipped (logged as `skipped`).
- A **missed-trigger handler** runs at boot and after the OS wakes from
sleep (`/api/cron/internal/system-resume`). It walks every enabled job
whose `next_run` is in the past and emits a system message so you can
see that a fire was missed (e.g. while your laptop was asleep), then
re-arms the timer for the next cron tick.
- Each fire is recorded with a status — `ok` / `error` / `skipped` /
`missed` — and (when applicable) a link to the conversation that
resulted, so the detail page can show you the run history.
## Creating a job
Open **Scheduled Tasks** from the sidebar (route: `/scheduled`) and press
**New task**. The dialog covers four areas.
### Frequency
Pick from a small set of presets — `Manual` (no automatic schedule, fire
only via Run now), `Hourly`, `Daily`, `Weekdays` (`MON-FRI`), `Weekly`, or
`Custom`. Presets render an editable cron expression in the builder; pick
**Custom** to type one directly. The builder validates as you type.
Cron syntax cheat-sheet (5-field — seconds field is added automatically):
```
* * * * *
│ │ │ │ └─ day of week (06 or SUNSAT, MON-FRI works)
│ │ │ └──── month (112 or JANDEC)
│ │ └─────── day of month (131)
│ └────────── hour (023)
└───────────── minute (059)
```
The job's timezone is set on creation (defaults to your browser's IANA
zone) and stored on the row; if a job's stored zone is invalid for any
reason, the detail page offers a one-click repair to your local zone.
### Agent
Pick the agent that runs each fire. Three flavours show up in the picker:
- **CLI agents** — `claude` / `codex` / `gemini` (whatever the backend
detected on `PATH`). The job records the backend label and uses ACP
end-to-end.
- **Nomi (built-in)** — uses Nomi's own engine with your selected
provider/model.
- **Preset assistants** — pre-configured agent personalities; the job
records the assistant id.
The **Advanced** section lets you override the workspace (working
directory the agent runs in), the model, and arbitrary `config_options`
key/value pairs that get forwarded to the agent factory. No directory name
in a workspace path may begin or end with whitespace — that is enforced
server-side; the form will surface the error. Interior spaces
(`My Project`) are fine.
### Execution mode
Choose `new_conversation` or `existing` (called "specified conversation"
in the UI when you also pick which one). The detail page later shows you
the resulting conversation(s).
### Prompt + name
The **prompt** is what gets sent to the agent each time. Write it as a
**self-contained instruction** — the agent will not get to see your
original "I want this" framing, only this prompt. Patterns like:
- `Reply with a short weekly meeting reminder that includes the current date and time.`
- `Search for the latest AI news from this week and produce a concise bullet-point summary report.`
- `Run the weekly database health check and post the results back here.`
…work better than restating the user's wish. **Name** is just a label.
![Create scheduled task dialog](../images/cron-02-create-dialog.png)
## Running, pausing, deleting
The list view (`/scheduled`) shows every job, its next fire, and an enable
toggle. From the detail page (`/scheduled/:job_id`) you can:
- **Run now** — fires the job immediately, regardless of schedule. The
busy guard still applies.
- **Pause / Resume** — stops further fires without deleting the row.
- **Edit** — same dialog as create, in edit mode.
- **Delete** — removes the job and its per-job skill directory. Conversations
created by previous runs remain in the conversation list and can be deleted
separately.
The detail page also lists the conversations created by this job, sorted
by activity — useful when the job runs in `new_conversation` mode and
fans out one thread per fire.
![Scheduled task detail](../images/cron-03-detail.png)
## Keep-Awake
Cron jobs only fire while the host process is running. The list page has a
**Keep system awake while NomiFun is running** toggle that asks the OS to
inhibit sleep (Windows: `SetThreadExecutionState`, macOS: `caffeinate`,
Linux: `systemd-inhibit` where available) so jobs you set up on a laptop
do not silently miss their fires the moment the lid closes.
If a fire is missed because the system slept anyway (or NomiFun was not
running), the missed-trigger handler at next boot/wake will record a
`missed` run and post a system message into the affected conversation,
then re-arm the timer for the next normal fire.
## Skills attached to a job
A **skill** is a `SKILL.md` file the agent reads when it joins a session
— same mechanism the rest of Nomi uses, but with a per-job scope. You can
write/edit the skill on the detail page; behind the scenes the file is
written to the data directory under `cron/skills/cron-<job_id>/SKILL.md`,
and the executor injects it into the agent's session each fire.
Use cases:
- A consistent **persona** for that job's output (style, tone, format).
- **Tool/MCP** preferences (which servers to enable, which to ignore).
- Workspace-specific conventions (commit message style, directory
layout, deployment quirks).
The job has its own skill directory (named with the job id, prefixed
`cron-`), so two jobs sharing the same workspace can carry different
behaviour without colliding. Deleting the job removes its skill
directory.
There is also an automatic **skill-suggest** detector that watches the
agent's output during a run; when it produces a clean candidate skill
(matching the expected format and not just a placeholder template), the
detector creates a `skill_suggest` artifact in the conversation so you
can review and save it as the job's skill in one click.
## Managing tasks from chat — the built-in `cron` skill
NomiFun ships a built-in auto-inject skill named `cron` that any agent can
load when you ask it to "set up a reminder", "schedule X every Monday",
etc. The conversation middleware then watches the agent's reply for the
following directive blocks and runs them through the cron service:
| Directive | Meaning |
| -------------------- | ------------------------------------------------------- |
| `[CRON_LIST]` | List the cron jobs scoped to the current conversation. |
| `[CRON_CREATE]…[/CRON_CREATE]` | Create a job (fields: `name`, `schedule`, `schedule_description`, `message`). |
| `[CRON_UPDATE: <id>]…[/CRON_UPDATE]` | Update an existing job in place. |
| `[CRON_DELETE: <id>]` | Delete a job by id. |
The middleware **strips** these blocks from what the user sees and posts
the system response (`Created cron job 'X'`, `No scheduled tasks`, etc.)
back into the conversation. So in chat it looks like a normal back-and-
forth; behind the scenes the agent emitted a directive and the platform
executed it.
The skill is constrained to **one task per conversation** by design —
this keeps the loop simple ("query, then act") and avoids duplicate jobs
piling up when you re-ask. To manage many jobs at once, use the
Scheduled Tasks page directly.
## Routes & API
| What | Where |
| ------------------------------- | ---------------------------------------------------------------- |
| List page | `/scheduled` |
| Detail page | `/scheduled/:job_id` |
| List / create job | `GET /api/cron/jobs`, `POST /api/cron/jobs` |
| Get / update / delete | `GET|PUT|DELETE /api/cron/jobs/:id` |
| Run now | `POST /api/cron/jobs/:id/run` |
| List conversations for a job | `GET /api/cron/jobs/:id/conversations` |
| Per-job skill | `GET|POST|DELETE /api/cron/jobs/:id/skill` |
| System resume (internal) | `POST /api/cron/internal/system-resume` (requires internal hdr) |
Realtime events the UI subscribes to: `cron.job-created`,
`cron.job-updated`, `cron.job-removed`, and `cron.job-executed`. A missed
fire is represented as a `cron.job-executed` payload whose status is
`missed`.
## Troubleshooting
- **The job did not fire on time.** Was the host running and awake at
that moment? If you closed the laptop or the app, look at the next
conversation entry after wake — the missed-trigger handler will have
posted a `missed` notice and re-armed the timer.
- **My cron expression is rejected.** Both 5-field (`m h dom mon dow`)
and 6-field (`s m h dom mon dow`) forms are valid. Validate it locally
with [crontab.guru](https://crontab.guru/) or the in-dialog builder.
- **Jobs run but the agent does the wrong thing.** Re-read the prompt as
if you had no other context. It must tell the agent exactly what to
produce. Then consider attaching a skill to lock in the behaviour.
- **Two scheduled fires collide.** The busy guard skips overlapping
runs in `existing` mode (the run is recorded as `skipped`). If you
expect long-running fires, switch the job to `new_conversation` so
each fire gets its own thread.
- **A `cron` directive in chat did nothing.** The middleware no-ops if
the cron service is not wired (e.g. some test harnesses); in a normal
app build it is always wired. If a directive is malformed (missing
closing tag, missing `schedule`), it is silently dropped — re-prompt
the agent with cleaner input.
@@ -0,0 +1,221 @@
# 定时任务 (Cron)
NomiFun 中的一个定时任务是一个在你选择的时间触发的循环 (或一次性)
任务,它会驱动一个 AI agent 去做某件事。你可以从定时任务页面
配置它、按需运行它、给它附加一个个性化的**技能**让 agent 在
该任务下始终以正确的方式行事,并且你可以使用一个内置的 cron
技能在聊天中让任何 agent 帮你管理任务。
> 找的是应该尽快运行的一次性异步工作,而不是按时钟来的?参见
> [AutoWork & Requirements](./autowork-requirements.md)。需要一个
> 实时 shell?参见 [应用内终端](./terminal.zh.md)。
![定时任务列表](../images/cron-01-list.png)
## 一个任务做什么
`nomifun-cron` 是一个后端调度器 + 执行器:
- **调度器**用一个 5 字段 (Unix) 或 6 字段 (秒前缀) 的 cron 表达式
为每个已启用的任务计算下一次触发时间 —— 两者都接受;
5 字段表达式会通过在前面加 `0` 作为秒被规范化为 6 字段。
调度也可以是一个绝对时间戳 (`At { at_ms }`) 或一个固定间隔
(`Every { every_ms }`)。
- 每个任务的时区 (例如 `Asia/Shanghai``America/Los_Angeles`)
会被尊重,所以 `0 9 * * MON` 表示**那个**时区的 09:00,而不是 UTC。
- **执行器**在定时器触发时驱动该任务的 agent。两种执行模式:
- **`new_conversation`** —— 每次触发开启一个新会话。该任务
携带 workspace、agent、model 和 prompt;执行器会创建会话、
广播一个 `cron_trigger` 工件 (这样聊天 UI 会显示
"本会话由一个定时任务发起"),然后发送 prompt。
- **`existing`** —— 复用拥有该任务的会话。每次触发把 prompt
作为一条新消息发送到同一个线程中。适合 "提醒我"、
"总结今天" 或任何延续性重要的任务。
- 一个**忙碌守卫**防止同一个会话被并发进入。如果上一次运行
在下一次触发到来时还在进行中,新的运行会被跳过 (记录为 `skipped`)。
- 一个**漏触发处理器**会在启动时和操作系统从睡眠中醒来后运行
(`/api/cron/internal/system-resume`)。它会遍历每个 `next_run`
在过去的已启用任务并发出一条系统消息,让你能看到漏掉一次
触发 (例如笔记本休眠时),然后为下一个 cron 节拍重新装定定时器。
- 每次触发会以一个状态被记录 —— `ok` / `error` / `skipped` /
`missed` —— 并且 (在适用时) 附带一个指向所产生会话的链接,
这样详情页可以向你展示运行历史。
## 创建一个任务
从侧边栏打开 **定时任务** (路由:`/scheduled`) 并按
**New task**。对话框涵盖四个区域。
### 频率
从一组小的预设中选择 —— `Manual` (无自动调度,仅通过 Run now 触发)、
`Hourly``Daily``Weekdays` (`MON-FRI`)、`Weekly`
`Custom`。预设会在 builder 中渲染出可编辑的 cron 表达式;选
**Custom** 直接键入。Builder 在你键入时进行校验。
Cron 语法速查 (5 字段 —— 秒字段会自动添加):
```
* * * * *
│ │ │ │ └─ 星期几 (06 或 SUNSATMON-FRI 可用)
│ │ │ └──── 月 (112 或 JANDEC)
│ │ └─────── 月中第几天 (1–31)
│ └────────── 小时 (0–23)
└───────────── 分钟 (0–59)
```
任务的时区在创建时设置 (默认是你浏览器的 IANA 时区) 并存储在
该行中;如果一个任务存储的时区因故无效,详情页会提供一键
修复到你的本地时区。
### Agent
选择每次触发运行的 agent。选择器中会显示三种类型:
- **CLI agent** —— `claude` / `codex` / `gemini` (后端在 `PATH`
上检测到的任何一个)。该任务记录后端标签并端到端使用 ACP。
- **Nomi (内置)** —— 使用 Nomi 自有引擎以及你选择的
provider/model。
- **Preset assistant** —— 预先配置的 agent 人格;该任务记录
assistant id。
**Advanced** 部分让你覆盖 workspace (agent 的工作目录)、model
以及任意的 `config_options` 键值对,它们会被转发给 agent 工厂。
Workspace 路径不能包含空白片段 —— 这一点在服务端被强制;
表单会把错误显式呈现出来。
### 执行模式
选择 `new_conversation``existing` (在 UI 中当你同时选择具体
是哪一个时被称为 "specified conversation")。详情页之后会向你
展示得到的会话。
### Prompt + 名称
**Prompt** 是每次发送给 agent 的内容。请把它写成一个**自包含的
指令** —— agent 看不到你原本 "我想要这个" 的框架,只看到这个
prompt。诸如下面这些模式:
- `Reply with a short weekly meeting reminder that includes the current date and time.`
- `Search for the latest AI news from this week and produce a concise bullet-point summary report.`
- `Run the weekly database health check and post the results back here.`
…比重述用户愿望要好。**Name** 只是一个标签。
![创建定时任务对话框](../images/cron-02-create-dialog.png)
## 运行、暂停、删除
列表视图 (`/scheduled`) 显示每一个任务、它的下一次触发,以及
一个启用开关。在详情页 (`/scheduled/:job_id`) 你可以:
- **Run now** —— 立即触发该任务,无视调度。忙碌守卫仍然适用。
- **Pause / Resume** —— 停止后续触发但不删除该行。
- **Edit** —— 与创建相同的对话框,处于编辑模式。
- **Delete** —— 删除该任务及其按任务生成的技能目录。此前运行创建的
会话会保留在会话列表中,可按需单独删除。
详情页还会列出本任务创建的会话,按活跃度排序 —— 当任务
`new_conversation` 模式运行并为每次触发各分出一个线程时
非常有用。
![定时任务详情](../images/cron-03-detail.png)
## 保持唤醒
只有当宿主进程在运行时,cron 任务才会触发。列表页有一个
**NomiFun 运行时保持系统唤醒**开关,它会请求 OS 抑制睡眠
(Windows`SetThreadExecutionState`macOS`caffeinate`
Linux 上若可用为 `systemd-inhibit`),这样你在笔记本上设置的
任务不会在合上盖子那一刻悄悄漏触发。
如果一次触发还是因为系统进入睡眠 (或 NomiFun 没在运行) 而漏掉,
下一次启动/唤醒时的漏触发处理器会记录一次 `missed` 运行并
向受影响的会话中投送一条系统消息,然后为下一次正常触发
重新装定定时器。
## 附加到任务的技能
一个**技能**是一个 `SKILL.md` 文件,agent 会在加入会话时读取它
—— 与 NomiFun 其他地方使用的相同机制,但作用域是按任务的。
你可以在详情页编写/编辑该技能;在幕后该文件会被写入数据
目录下的 `cron/skills/cron-<job_id>/SKILL.md`,执行器会在每次
触发时把它注入到 agent 的会话中。
用例:
- 任务输出的一致**人格** (风格、语气、格式)。
- **工具/MCP** 偏好 (启用哪些服务器、忽略哪些)。
- 工作区特定的约定 (commit 信息风格、目录布局、部署细节)。
任务有自己的技能目录 (以 job id 命名,前缀 `cron-`),所以共享同一
workspace 的两个任务可以承载不同的行为而不冲突。删除任务会
移除其技能目录。
还有一个自动的**技能建议**检测器,它会在运行期间观察 agent 的
输出;当它产出一个干净的候选技能时 (符合预期格式且不只是占位
模板),检测器会在会话中创建一个 `skill_suggest` 工件,让你
可以审查并一键将其保存为该任务的技能。
## 在聊天中管理任务 —— 内置的 `cron` 技能
NomiFun 附带一个名为 `cron` 的内置自动注入技能,任何 agent 都可以
在你让它"设置一个提醒"、"每周一安排 X" 等时加载它。
然后会话中间件会观察 agent 的回复中以下的指令块,并通过 cron
服务运行它们:
| 指令 | 含义 |
| --------------------- | ------------------------------------------------------- |
| `[CRON_LIST]` | 列出当前会话作用域内的 cron 任务。 |
| `[CRON_CREATE]…[/CRON_CREATE]` | 创建一个任务 (字段:`name``schedule``schedule_description``message`)。 |
| `[CRON_UPDATE: <id>]…[/CRON_UPDATE]` | 原地更新一个已有任务。 |
| `[CRON_DELETE: <id>]` | 按 id 删除一个任务。 |
中间件会从用户看到的内容中**剥离**这些块,并把系统响应
(`Created cron job 'X'``No scheduled tasks` 等) 投回到会话中。
所以在聊天里看起来像是正常的来回;幕后是 agent 发出了一个
指令,平台执行了它。
该技能在设计上被限制为**每个会话一个任务** —— 这让循环保持
简单 ("查询,然后行动") 并避免了你重新询问时重复任务堆积。
要一次管理多个任务,请直接使用定时任务页面。
## 路由 & API
| 内容 | 位置 |
| ------------------------------- | ----------------------------------------------------------------- |
| 列表页面 | `/scheduled` |
| 详情页面 | `/scheduled/:job_id` |
| 列出 / 创建任务 | `GET /api/cron/jobs``POST /api/cron/jobs` |
| 获取 / 更新 / 删除 | `GET|PUT|DELETE /api/cron/jobs/:id` |
| 立即运行 | `POST /api/cron/jobs/:id/run` |
| 列出某任务的会话 | `GET /api/cron/jobs/:id/conversations` |
| 每任务技能 | `GET|POST|DELETE /api/cron/jobs/:id/skill` |
| 系统恢复 (内部) | `POST /api/cron/internal/system-resume` (需要内部 header) |
UI 订阅的实时事件:`cron.job-created``cron.job-updated`
`cron.job-removed``cron.job-executed`。漏触发会作为一次
`cron.job-executed` 事件上报,payload 中的状态是 `missed`
## 故障排查
- **任务没有按时触发。** 那时宿主在运行且处于唤醒状态吗?
如果你合上了笔记本或关闭了应用,请查看唤醒后的下一条会话
条目 —— 漏触发处理器会投送一条 `missed` 通知并重新装定
定时器。
- **我的 cron 表达式被拒绝了。** 5 字段 (`m h dom mon dow`)
和 6 字段 (`s m h dom mon dow`) 形式都是合法的。在本地用
[crontab.guru](https://crontab.guru/) 或对话框中的 builder
校验。
- **任务运行了但 agent 做错了事。** 重新阅读 prompt,假设你没有
其他上下文。它必须告诉 agent 准确要产出什么。然后考虑附加
一个技能以锁定行为。
- **两次定时触发碰撞了。** 忙碌守卫会在 `existing` 模式下跳过
重叠的运行 (该运行被记录为 `skipped`)。如果你预期触发会
长时间运行,请把任务切换到 `new_conversation` 模式,让每次
触发各得一个线程。
- **聊天中的 `cron` 指令什么也没做。** 如果 cron 服务没有接入
(例如某些测试 harness),中间件会 no-op;在正常 app build 中
它总是接入的。如果指令格式错误 (缺失闭合标签、缺失
`schedule`),它会被静默丢弃 —— 用更干净的输入重新提示
agent。
+183
View File
@@ -0,0 +1,183 @@
# In-App Terminals
Nomi ships a real terminal inside the app. Each terminal is a backend-managed
PTY session you can drive interactively from your browser/desktop window — and
that AutoWork can drive on your behalf when you bind it to a tag.
> Need the automation guide? See [AutoWork & Requirements](./autowork-requirements.md).
> Need to run an agent on a schedule? See [Scheduled Tasks](./scheduled-tasks.md).
![Nomi in-app terminal](../images/terminal-01-session.png)
## What an in-app terminal is
When you create a terminal, the backend (`nomifun-terminal`) spawns a child
process attached to a real pseudo-terminal via [`portable-pty`]. The session
has three pieces:
- **Persistent metadata** — id, name, working directory, command + args, env,
preset/backend, permission mode, current size (cols × rows), pinned flag,
exit status. Stored in SQLite so the session entry survives restarts.
- **A live PTY** (only while the child is running) — the OS pseudo-terminal,
its byte-stream output, and a scrollback buffer the backend keeps for late
joiners.
- **Realtime events on the WebSocket bus** — every chunk of PTY output is
base64-encoded and broadcast as `terminal.output`. Lifecycle events
(`terminal.created`, `terminal.updated`, `terminal.exit`, `terminal.removed`)
ride the same bus. The xterm.js view in the renderer subscribes and renders
the stream.
A PTY child cannot be paused or moved between processes: when the child exits,
the row stays but the live PTY is gone. Re-launching is in-place — the same
session id keeps a fresh process attached, so you do not get a new sidebar
entry every time you restart a CLI.
[`portable-pty`]: https://crates.io/crates/portable-pty
## Creating a terminal
Open the Terminal create page (the **+** button in the terminal sidebar
section, or navigate to `/terminal-new`). You pick five things:
1. **Workspace** — the working directory the child process will be spawned in.
Recent workspaces are remembered.
2. **Preset**`Shell`, `Claude Code`, `Codex`, or `Gemini`. The shell preset
resolves to your platform's login shell at launch time (Windows:
PowerShell/`cmd`, macOS/Linux: `$SHELL`); the agent presets launch the
matching CLI binary that must already be installed and on `PATH`.
3. **Permission mode** (agent presets only) — `Default` (interactive
approvals) or `Full Auto` (the CLI's own non-interactive flag is added):
| Preset | Full-auto flag |
| ------------ | ------------------------------------------- |
| `claude` | `--dangerously-skip-permissions` |
| `codex` | `--dangerously-bypass-approvals-and-sandbox`|
| `gemini` | `--yolo` |
These bypass the CLI's interactive approval prompt — needed for AutoWork to
drive a turn end-to-end without a human pressing Enter, but the same flags
give the CLI broad capability on your machine. Treat full-auto terminals
like a logged-in shell.
4. **Launch command** — the dialog renders the resolved `command + args` into
an editable field. Tweak it freely (extra flags, alternative entry point,
etc.) before pressing **Launch**.
5. **Knowledge bases** (optional) — multi-select one or more knowledge bases
to bind to this session. Bound bases are mounted at
`{workspace}/.nomi/knowledge/` before the child spawns, together with a
generated `README.md` (retrieval protocol + per-base digests + TOC +
write-back rules); the `claude` preset additionally gets an
`--append-system-prompt` pointer to that README. Rebinding takes effect on
the next re-launch. (The gateway tool `nomi_create_terminal` accepts the
same binding via `knowledge_base_ids`.)
![Terminal create page](../images/terminal-02-create-page.png)
The backend persists the row and spawns the child. The page navigates to
`/terminal/<id>` and you start receiving live output.
## Driving a terminal
The session page is xterm.js wired to the realtime stream:
- **Type** to send keystrokes to the PTY. The send box also accepts paste with
bracketed-paste markers, so multi-line text becomes one paste rather than a
flurry of Enters.
- **Resize** the panel and the backend resizes the PTY accordingly (`SIGWINCH`
is delivered to the child). The new dimensions are persisted.
- **Re-launch** when the child has exited: a single button kills any leftover
PTY for the same id, spawns a fresh process with the stored command + cwd
+ env, clears the view, and the same `terminal.<id>` subscription picks up
the new output. You keep the same sidebar entry.
- **Rename / pin** from the session header (renames broadcast as
`terminal.updated`; pinned terminals float to the top of the sidebar).
- **Kill** stops the child but keeps the row (it transitions to `exited` and
becomes re-launchable). **Delete** kills the child and removes the row
entirely.
![Driving a terminal session](../images/terminal-03-driving-session.png)
## Streaming model
Output flows over a single WebSocket. While you are looking at a session, your
client receives `terminal.output` events for that id and renders them. The
backend keeps a scrollback buffer in memory while the PTY is live: when you
open a terminal that is already running, the GET response includes a
base64-encoded `scrollback_b64` snapshot, so xterm replays history before live
events stream in.
Client-to-server input goes the other direction over a small REST endpoint
(base64-encoded bytes). The backend writes those bytes straight to the PTY's
stdin.
## Terminals as automation targets
The same in-memory PTY map that powers the UI is shared with the **AutoWork
orchestrator** in `nomifun-requirement` via the `TerminalDriver` trait. That
trait lets AutoWork:
- Subscribe to a copy of the terminal's live output (it watches for completion
markers and detects quiescence — see the AutoWork guide for the contract).
- Write input bytes to the PTY (it injects the requirement prompt wrapped in
bracketed-paste so a multi-line instruction lands as a single paste).
- Check liveness, read the row's metadata (user, backend, mode), and read or
write a per-terminal `autowork` config blob.
In other words: **a terminal you create here is automatable by AutoWork**.
Bind a tag from the AutoWork toolbar in the session header, and the
orchestrator will start claiming requirements and feeding them to the CLI
running in this terminal. Only agent-CLI terminals (`claude`, `codex`,
`gemini`) are eligible — a plain shell can be driven manually but is not an
AutoWork target. The orchestrator also recommends Full Auto mode, because a
turn that hits an interactive approval prompt will block until it times out.
If the workspace has knowledge bases mounted (`{cwd}/.nomi/knowledge/`
exists), AutoWork- and cron-driven prompts are automatically prefixed with a
one-line hint pointing the CLI at the mounted `README.md` before it starts
working.
If the PTY exits while AutoWork is still bound, the loop does not stop — it
idles and waits for you to re-launch the terminal, then resumes claiming
where it left off. If you delete the row, the loop stops for good.
## IDMM (decision-stall supervision)
Long-running CLI sessions sometimes stall: the provider drops, the model
spins on a tool call, the CLI prints a confirmation prompt nobody answers.
The IDMM (Intelligent Decision-Making Mode) supervisor watches a session and
intervenes — first with rule-based nudges (no LLM), then by calling a sidecar
backup model — so the turn reaches a terminal state instead of hanging until
the AutoWork timeout fires.
You can enable IDMM per-terminal from the same session header (the **IDMM**
control next to AutoWork). It works whether or not AutoWork is also bound;
when both are on, AutoWork ensures IDMM is supervising for the duration of
each turn.
## Routes & API
| What | Where |
| -------------------------- | ------------------------------------------- |
| Create page | `/terminal-new` |
| Session page | `/terminal/:id` |
| List / create | `GET /api/terminals`, `POST /api/terminals` |
| Get / update / delete | `GET|PATCH|DELETE /api/terminals/:id` |
| Send input | `POST /api/terminals/:id/input` |
| Resize | `POST /api/terminals/:id/resize` |
| Kill child | `POST /api/terminals/:id/kill` |
| Re-launch in place | `POST /api/terminals/:id/relaunch` |
| Live output / lifecycle | WebSocket events `terminal.*` |
## Troubleshooting
- **The CLI is not found.** The agent presets call `claude`, `codex`, or
`gemini` directly — they must be on the `PATH` of whatever account is
running the backend. Either install the CLI globally or edit the launch
command to use an absolute path before launching.
- **AutoWork bind is greyed out.** Only `claude`/`codex` terminals are
AutoWork targets today. A plain shell preset cannot be bound, and Gemini
terminal AutoWork is not wired into the backend completion contract yet.
- **Re-launch keeps reusing the same env / cwd.** That is intentional — the
session row stores them. To change them, create a new terminal with the
desired settings.
- **The output is garbled after resize.** Some TUIs need a redraw on
`SIGWINCH`. Press `Ctrl-L` (or your CLI's redraw shortcut).
+167
View File
@@ -0,0 +1,167 @@
# 应用内终端
Nomi 在应用内附带了一个真正的终端。每个终端都是一个由后端管理的
PTY 会话,你可以从浏览器/桌面窗口中以交互方式驱动它 —— 当你把它
绑定到一个 tag 上时,AutoWork 也可以代你来驱动它。
> 需要自动化指南?参见 [AutoWork & Requirements](./autowork-requirements.md)。
> 需要按计划运行 agent?参见 [定时任务](./scheduled-tasks.zh.md)。
![Nomi 应用内终端](../images/terminal-01-session.png)
## 应用内终端是什么
当你创建一个终端时,后端 (`nomifun-terminal`) 会通过
[`portable-pty`] 派生一个连接到真实伪终端的子进程。该会话由三部分组成:
- **持久化元数据** —— id、名称、工作目录、命令 + 参数、env、
preset/backend、权限模式、当前尺寸 (列 × 行)、pinned 标记、
退出状态。存储在 SQLite 中,所以会话条目在重启后仍然存在。
- **一个活跃的 PTY** (仅在子进程运行时存在) —— OS 伪终端、
其字节流输出,以及后端为后加入者保留的回滚缓冲区。
- **WebSocket 总线上的实时事件** —— PTY 输出的每一块都会被
base64 编码并以 `terminal.output` 广播。生命周期事件
(`terminal.created``terminal.updated``terminal.exit``terminal.removed`)
也走同一条总线。渲染进程中的 xterm.js 视图订阅并渲染这条流。
PTY 子进程不能被暂停或在进程间迁移:当子进程退出时,
列表行保留,但活跃的 PTY 没了。重新启动是原地进行的 —— 同一个会话 id
会附上一个全新的进程,所以你不会每次重启 CLI 都得到一个新的侧边栏
条目。
[`portable-pty`]: https://crates.io/crates/portable-pty
## 创建终端
打开终端创建页面 (终端侧边栏区段中的 **+** 按钮,或导航到
`/terminal-new`)。你需要选择五样东西:
1. **Workspace** —— 子进程将在其中派生的工作目录。
最近使用过的 workspace 会被记住。
2. **Preset** —— `Shell``Claude Code``Codex``Gemini`。shell
preset 会在启动时解析为你平台的 login shell (Windows
PowerShell/`cmd`macOS/Linux`$SHELL`)agent preset 会启动
对应的 CLI 二进制,该二进制必须已安装并在 `PATH` 上。
3. **权限模式** (仅 agent preset) —— `Default` (交互式审批)
`Full Auto` (会附加该 CLI 自身的非交互式 flag):
| Preset | Full-auto flag |
| ------------ | ------------------------------------------- |
| `claude` | `--dangerously-skip-permissions` |
| `codex` | `--dangerously-bypass-approvals-and-sandbox`|
| `gemini` | `--yolo` |
这些 flag 会绕过 CLI 的交互式审批提示 —— 这是 AutoWork 在没有
人按回车的情况下端到端驱动一轮所必需的,但同样的 flag 也赋予了
CLI 在你机器上的广泛能力。请把 full-auto 终端当作已登录的 shell 来对待。
4. **启动命令** —— 对话框会把解析后的 `command + args` 渲染到
一个可编辑字段中。在按下 **Launch** 之前可以自由调整 (额外
flag、替代入口点等)。
5. **知识库** (可选) —— 多选一个或多个知识库绑定到本会话。绑定的库
会在子进程派生前挂载到 `{workspace}/.nomi/knowledge/`,并生成一份
`README.md` (检索协议 + 各库梗概 + TOC + 回写规则);`claude`
preset 还会额外附加一条指向该 README 的 `--append-system-prompt`
指针。改绑在下次重新启动时生效。(网关工具 `nomi_create_terminal`
通过 `knowledge_base_ids` 支持同样的绑定。)
![终端创建页面](../images/terminal-02-create-page.png)
后端会持久化该行并派生子进程。页面会跳转到
`/terminal/<id>`,然后你开始接收实时输出。
## 驱动终端
会话页面是与实时流相连的 xterm.js:
- **键入** 把击键发送给 PTY。发送框也接受带 bracketed-paste 标记
的粘贴,所以多行文本会变成一次粘贴而不是一连串的回车。
- **调整大小** 调整面板大小,后端会相应调整 PTY 尺寸 (会向子进程
发送 `SIGWINCH`)。新的尺寸会被持久化。
- **重新启动** 在子进程退出后:单个按钮会杀掉同一 id 的任何残留
PTY,使用存储的命令 + cwd + env 派生一个新的进程,清空视图,
同样的 `terminal.<id>` 订阅会接管新的输出。你保留同一个侧边栏条目。
- **重命名 / 置顶** 从会话头进行 (重命名会作为
`terminal.updated` 广播;置顶的终端会浮到侧边栏顶部)。
- **Kill** 停止子进程但保留行 (它会转换为 `exited` 并可重新启动)。
**Delete** 杀掉子进程并完全移除该行。
![驱动一个终端会话](../images/terminal-03-driving-session.png)
## 流模型
输出走单个 WebSocket。当你正在查看一个会话时,你的客户端
会接收到该 id 的 `terminal.output` 事件并渲染它们。在 PTY 活跃期间,
后端在内存中保留一个回滚缓冲区:当你打开一个已经在运行的
终端时,GET 响应会包含一个 base64 编码的 `scrollback_b64` 快照,
所以 xterm 会先回放历史记录,然后实时事件再流入。
客户端到服务器的输入走另一个方向,通过一个小的 REST 端点
(base64 编码的字节)。后端会把这些字节直接写到 PTY 的 stdin。
## 终端作为自动化目标
驱动 UI 的同一个内存中的 PTY 映射通过 `TerminalDriver` trait
`nomifun-requirement` 中的 **AutoWork orchestrator** 共享。该
trait 让 AutoWork
- 订阅终端实时输出的副本 (它会监视完成标记并检测静默 ——
契约见 AutoWork 指南)。
- 向 PTY 写入输入字节 (它把 requirement prompt 包装在
bracketed-paste 中注入,使得多行指令会作为单次粘贴落地)。
- 检查存活性,读取该行的元数据 (user、backend、mode),并读取或
写入每个终端的 `autowork` 配置 blob。
换句话说:**你在这里创建的终端可被 AutoWork 自动化**。
在会话头的 AutoWork 工具栏上绑定一个 tagorchestrator
就会开始认领 requirement 并把它们喂给运行在该终端中的 CLI。
只有 agent-CLI 终端 (`claude``codex``gemini`) 才符合条件 ——
普通的 shell 可以手动驱动但不是 AutoWork 目标。orchestrator 也
推荐使用 Full Auto 模式,因为一轮如果撞上交互式审批提示
会一直阻塞直到超时。
如果工作区挂载了知识库 (存在 `{cwd}/.nomi/knowledge/`)AutoWork 与
cron 驱动注入的 prompt 会自动前置一行提示,让 CLI 先阅读挂载目录里的
`README.md` 再开工。
如果在 AutoWork 仍绑定时 PTY 退出,循环不会停止 —— 它会
空转并等待你重新启动该终端,然后从中断处继续认领。
如果你删除该行,循环会彻底停止。
## IDMM (决策停滞监督)
长时间运行的 CLI 会话有时会停滞:provider 掉线,模型在某个工具
调用上空转,CLI 打印了一个无人回答的确认提示。IDMM
(Intelligent Decision-Making Mode) supervisor 会监视会话并介入 ——
先用基于规则的轻推 (无 LLM),然后调用一个 sidecar 备用模型 ——
这样这一轮会到达一个终态,而不是挂起到 AutoWork 超时触发。
你可以在同一个会话头 (AutoWork 旁边的 **IDMM** 控件) 中按终端
启用 IDMM。无论 AutoWork 是否同时绑定它都能工作;当两者都开启
时,AutoWork 会确保 IDMM 在每一轮的全程都在监督。
## 路由 & API
| 内容 | 位置 |
| ------------------------ | --------------------------------------------- |
| 创建页面 | `/terminal-new` |
| 会话页面 | `/terminal/:id` |
| 列出 / 创建 | `GET /api/terminals``POST /api/terminals` |
| 获取 / 更新 / 删除 | `GET|PATCH|DELETE /api/terminals/:id` |
| 发送输入 | `POST /api/terminals/:id/input` |
| 调整大小 | `POST /api/terminals/:id/resize` |
| 杀掉子进程 | `POST /api/terminals/:id/kill` |
| 原地重新启动 | `POST /api/terminals/:id/relaunch` |
| 实时输出 / 生命周期 | WebSocket 事件 `terminal.*` |
## 故障排查
- **找不到 CLI。** Agent preset 直接调用 `claude``codex`
`gemini` —— 它们必须在运行后端的账户的 `PATH` 上。要么全局安装
CLI,要么在启动前编辑启动命令使用绝对路径。
- **AutoWork 绑定是灰色的。** 当前只有 `claude`/`codex` 终端才是
AutoWork 目标。普通 shell preset 不能被绑定;Gemini 终端 AutoWork
还没有接入后端的完成契约。
- **重新启动一直复用同一个 env / cwd。** 这是有意为之 —— 会话
行存储着它们。要修改它们,请用想要的设置创建一个新的终端。
- **调整大小后输出乱了。** 一些 TUI 在 `SIGWINCH` 时需要重绘。
`Ctrl-L` (或你 CLI 的重绘快捷键)。
@@ -0,0 +1,295 @@
# Web Server Deployment
`nomifun-web` is the **headless, self-host** way to run NomiFun. It is the same Rust backend that the [desktop app](./desktop-app.md) embeds, but built as a standalone binary that also serves the SPA (`ui/dist`) on the same port. There is no GUI, no WebView, no `DISPLAY` requirement — it runs anywhere a Linux/macOS/Windows server will run a static binary.
Unlike the desktop shell, **`nomifun-web` requires authentication by default**. The first browser visitor either creates the admin account interactively (first-run setup), or you pre-seed credentials with `NOMIFUN_ADMIN_PASSWORD`.
> If you want to expose an *existing* desktop install for remote access without setting up a server, see [WebUI Remote Access](./webui-remote-access.md). That is a per-instance feature; this guide is for a dedicated server.
```text
Browser / phone / LAN nomifun-web (one process, one port)
┌──────────────────┐ ┌───────────────────────────────────────┐
│ SPA + login │ HTTP / WS │ axum router │
│ (ui/dist) │ ────────────► │ ├─ / → SPA (ui/dist) │
└──────────────────┘ │ ├─ /api/* → REST handlers │
│ ├─ /ws → WebSocket events │
│ └─ /login … → auth (on by default)│
│ │
│ embedded backend (nomifun-app) │
│ └─ SQLite · agents · cron · channels │
└───────────────────────────────────────┘
```
## Quick start
### Run the binary directly
```bash
cargo build --release -p nomifun-web
./target/release/nomifun-web --host 127.0.0.1 --port 8787 \
--data-dir ./data --dist ./ui/dist
```
Then open `http://127.0.0.1:8787` and the first visit lets you create the admin account. After that, the setup endpoint returns `409 Conflict` and the only way in is via the login form (or `NOMIFUN_ADMIN_PASSWORD`).
![First-run admin setup screen](../images/webserver-02-first-run-setup.png)
### Or via Cargo, from the repo
```bash
bun install
bun run build:ui # produces ui/dist
cargo run -p nomifun-web # picks up the default --dist=../../ui/dist
```
## CLI flags and environment variables
All flags below are read by `apps/web/src/main.rs`. Each has an environment-variable counterpart for systemd / Docker / orchestrators.
| Flag | Env var | Default | Purpose |
|---|---|---|---|
| `--host` | `NOMIFUN_WEB_HOST` | `127.0.0.1` | IP to bind on. `0.0.0.0` accepts LAN/VPN/public traffic; pre-seed the admin or complete first-run setup before broad exposure. |
| `--port` | `NOMIFUN_WEB_PORT` | `8787` | TCP port. Serves the API, the WebSocket at `/ws`, and the SPA. |
| `--data-dir` | `NOMIFUN_DATA_DIR` | per-user dir | Backend data dir (SQLite database, agent state, logs, Bun cache). Defaults to the per-user location shared with the desktop app (`%LOCALAPPDATA%\NomiFun\Nomi`, `~/Library/Application Support/NomiFun/Nomi`, `$XDG_DATA_HOME/NomiFun/Nomi`). **Still set an explicit absolute path in production.** |
| `--dist` | `NOMIFUN_WEB_DIST` | `../../ui/dist` | Directory containing the built SPA. **Set this explicitly when deploying.** |
| `--admin-user` | `NOMIFUN_ADMIN_USERNAME` | `admin` | Username used when pre-seeding the first admin. Ignored once an admin exists. |
| `--admin-password` | `NOMIFUN_ADMIN_PASSWORD` | — | Pre-seed the first admin password at boot, skipping interactive setup. Ignored once an admin exists. |
| `--insecure-no-auth` | `NOMIFUN_WEB_INSECURE_NO_AUTH` | `false` | **DANGER.** Disables authentication entirely (desktop-style local mode). Only use on loopback or a fully trusted private network. |
| — | `NOMIFUN_HTTPS` | `false` | When `true`, session and CSRF cookies are flagged `Secure`. Set this whenever the app is reached over HTTPS (e.g. behind a TLS reverse proxy). |
| — | `SHELL` | platform default | Shell used by the agent engine when spawning processes. Set to `/bin/bash` on Linux servers if `$SHELL` is unset. |
Boolean envs accept `1`, `true`, `yes`, `on` (case-insensitive).
A bad `--host` (anything that does not parse as an IP) fails fast at startup with a clear error rather than a cryptic socket error.
At startup the backend takes an OS-level exclusive lock on `{data_dir}/server.lock`**one backend instance per data dir**. A second process pointed at the same directory fails fast with an error naming the current holder (pid + exe); to deploy multiple instances, give each its own `NOMIFUN_DATA_DIR` / `--data-dir`. The OS releases the lock on exit or crash, so a leftover `server.lock` file is harmless.
### Password and username rules
When the admin account is created (interactively or via pre-seed), values are validated server-side:
- **Username**: 332 chars, `[a-zA-Z0-9_-]`, must not start or end with `-` / `_`.
- **Password**: 8128 chars, rejected if it appears in a small common-passwords list (`password`, `12345678`, `qwertyui`, …).
A weak `NOMIFUN_ADMIN_PASSWORD` will refuse to boot. A weak interactively-typed password will return `400` with the validation message.
## First-run admin provisioning
There are two supported paths.
### Interactive (default)
Leave `NOMIFUN_ADMIN_PASSWORD` unset. On a fresh data dir the install is "uninitialised": `GET /api/auth/status` reports `needs_setup: true`, the SPA shows the first-run form, and the **first browser visitor's chosen username + password become the admin** via an atomic `POST /api/auth/setup`. The write is a conditional UPDATE — even two concurrent first-run requests cannot both win; the loser receives `409 Conflict`.
> **Security note — the first-run window.** Between the moment the server is reachable and the moment you complete setup, anyone who can reach the port can claim the admin account. On a non-loopback bind the server logs a loud warning. Mitigate by completing setup over a trusted tunnel/VPN first, or pre-seed (next section) so the install is initialised before it goes live.
### Pre-seeded (recommended for automation)
Provide `NOMIFUN_ADMIN_PASSWORD` (and optionally `NOMIFUN_ADMIN_USERNAME`, default `admin`) before first boot. The bootstrap routine hashes and stores the credentials atomically, the first-run setup endpoint returns `409` from the very first start, and there is no window for someone else to claim the account.
```bash
NOMIFUN_ADMIN_USERNAME=alice \
NOMIFUN_ADMIN_PASSWORD='change-me-to-something-strong' \
nomifun-web --host 0.0.0.0 --port 8787 \
--data-dir /var/lib/nomifun --dist /opt/nomifun/web
```
The pre-seed is **idempotent** — once an admin exists, the env vars are ignored on subsequent boots. To rotate credentials, use the in-app change-password / change-username flow rather than the env vars.
## Docker
The repo ships a multi-stage `Dockerfile` and a `docker-compose.yml`. The image:
1. Builds the SPA with Bun.
2. Compiles `nomifun-web` from the workspace.
3. Assembles a slim `debian:bookworm-slim` runtime that includes `bun`, `git`, and `ripgrep`.
It exposes port `8787` and uses `/data` as the data volume.
### Compose
```bash
docker compose up -d --build
# then open http://<server-ip>:8787 and create the first admin
```
`restart: unless-stopped` makes the service start on host boot — installing it *is* enabling it. The default ports block publishes `8787:8787` directly; pre-seed the admin or complete setup on a trusted network before exposing it broadly. Add TLS (next section) before exposing to the internet.
Verify readiness:
```bash
docker compose logs -f nomifun
# look for: "nomifun-web: embedded backend + SPA on one port"
```
The compose file mounts a named volume `nomifun-data:/data` which holds the SQLite DB, logs, the Bun runtime cache, and per-agent state. Back this up with the same care as any other database.
### Pre-seeding the admin in Compose
Uncomment the `environment:` block:
```yaml
environment:
NOMIFUN_ADMIN_USERNAME: admin
NOMIFUN_ADMIN_PASSWORD: "change-me-to-something-strong"
NOMIFUN_HTTPS: "true" # when fronted by Caddy / nginx with TLS
```
### Building behind a slow registry
The Rust stage accepts a `CARGO_REGISTRY_MIRROR` build arg for cargo registry mirroring (e.g. on a network where crates.io is slow):
```bash
docker build --build-arg CARGO_REGISTRY_MIRROR=https://rsproxy.cn/index/ -t nomifun-web:local .
```
```text
$ docker compose up -d
[+] Running 2/2
✔ Network nomifun_default Created
✔ Container nomifun-web Started
$ docker compose logs -f web
nomifun-web | listening on 0.0.0.0:8787 (auth: enabled)
```
## TLS via Caddy reverse proxy
A `Caddyfile` is included for Caddy 2. Caddy auto-provisions HTTPS certificates (Let's Encrypt or ZeroSSL by default) and proxies to the app. The WebSocket upgrade at `/ws` passes through automatically, no extra config required.
```caddy
your.domain.com {
encode zstd gzip
reverse_proxy nomifun:8787
}
```
To enable the Caddy service in `docker-compose.yml`:
1. Edit `Caddyfile` and replace `your.domain.com` with your real domain.
2. Set `NOMIFUN_HTTPS=true` in the `nomifun` service env (so cookies get the `Secure` flag).
3. Replace `ports: ["8787:8787"]` with `expose: ["8787"]` so only Caddy is published.
4. Uncomment the `caddy:` service and the `caddy-data` / `caddy-config` volumes.
5. `docker compose up -d`.
The app already provides its own login screen, so **do not configure HTTP basic auth in Caddy** — Caddy's job is purely TLS termination and proxying.
For a LAN-only host without a public domain you can use an internal name with `tls internal`, or just publish port `8787` directly without Caddy (the in-app login still protects it).
## systemd (Linux server, no Docker)
The repo includes `packaging/linux/nomifun-web.service` and a long-form Linux deployment guide at `packaging/linux/README.md`.
### Build artifacts
You need a Linux build host (cross-compiling the C dependencies from Windows is painful — the easiest workaround is to extract the binary from the Docker image with `docker cp`). On Linux:
```bash
bun install
bun run build:ui # → ui/dist (~21MB)
cargo build --release -p nomifun-web # → target/release/nomifun-web
```
### Layout
```
/opt/nomifun/nomifun-web # the binary
/opt/nomifun/web/ # contents of ui/dist
/var/lib/nomifun/ # data dir (created by systemd's StateDirectory)
```
```bash
sudo useradd --system --home /var/lib/nomifun --shell /usr/sbin/nologin nomifun
sudo mkdir -p /opt/nomifun/web
sudo cp target/release/nomifun-web /opt/nomifun/
sudo cp -r ui/dist/. /opt/nomifun/web/
```
### Bun must be on the system `PATH`
The agent engine requires **`bun ≥ 1.3.13`** as a runtime dependency. Because the service runs under a `nologin` system account, an install in someone's `~/.bun/bin/` is invisible to it. Pick one:
- **System install**: `curl -fsSL https://bun.sh/install | bash`, then `sudo install ~/.bun/bin/bun /usr/local/bin/bun`.
- **Embed in the binary**: build with `NOMIFUN_EMBED_BUN=1 cargo build --release -p nomifun-web`. Bun is bundled into the binary and self-extracts into the data dir on first run.
Verify: `sudo -u nomifun -s -- which bun` must return a path. Otherwise the first agent spawn will fail with an opaque error.
### Install the unit
```bash
sudo cp packaging/linux/nomifun-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nomifun-web
sudo systemctl status nomifun-web
```
The shipped unit:
- Binds `127.0.0.1:8787` by default. Change `NOMIFUN_WEB_HOST` to
`0.0.0.0` only after first-run setup is complete or
`NOMIFUN_ADMIN_PASSWORD` is configured.
- Sets `NOMIFUN_DATA_DIR=/var/lib/nomifun` to match the systemd-managed `StateDirectory=nomifun`. **Keep these two in sync** — if you drop the env line, the data dir silently falls back to the service user's per-user directory (`$XDG_DATA_HOME/NomiFun/Nomi`, typically `~nomifun/.local/share/NomiFun/Nomi`), decoupled from systemd state.
- Runs as a dedicated `nomifun` user (`User=nomifun`, `Group=nomifun`).
- Restarts on failure with a 3 s backoff.
- Applies moderate hardening (`NoNewPrivileges=yes`, `PrivateTmp=yes`). **Do not add** `ProtectHome=yes` or strict `ProtectSystem` — the agent engine reads/writes operator-directed files and over-sandboxing breaks core features.
To enable HTTPS cookies behind a TLS proxy, uncomment:
```ini
Environment=NOMIFUN_HTTPS=true
```
To pre-seed the admin instead of interactive setup:
```ini
Environment=NOMIFUN_ADMIN_USERNAME=admin
Environment=NOMIFUN_ADMIN_PASSWORD=change-me-to-something-strong
```
```text
$ sudo systemctl status nomifun-web
● nomifun-web.service - NomiFun web host
Loaded: loaded (/etc/systemd/system/nomifun-web.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-06-25 09:12:03 UTC
Main PID: 12345 (nomifun-web)
CGroup: /system.slice/nomifun-web.service
└─12345 /usr/local/bin/nomifun-web --host 127.0.0.1 --port 8787 …
nomifun-web[12345]: listening on 127.0.0.1:8787 (auth: enabled)
```
## Linux runtime dependencies
| Dependency | Required? | Notes |
|---|---|---|
| `glibc` + `ca-certificates` | Yes | sqlite is statically linked, TLS uses rustls — **no openssl, no libsqlite needed**. |
| `bun` ≥ 1.3.13 | **Yes** | Agent execution runtime. 1.1.38 has an stdin bug; do not use. Already inside the Docker image. |
| `node` / `npm` / `npx` | Recommended | Many user-configured MCP stdio servers launch via `npx -y …`. |
| `git` | Recommended | Skill discovery and a few built-in tools. |
| `ripgrep` (`rg`) | Recommended | Code-search backend. Falls back to `grep` if missing. |
| `DISPLAY` / X11 / WebView | **No** | `nomifun-web` is fully headless. |
## Security checklist
- **Use TLS for any public deployment.** Cookies and login credentials over plain HTTP can be sniffed. Behind a TLS proxy, set `NOMIFUN_HTTPS=true` so the session cookie is flagged `Secure`.
- **Strong admin password.** The validator rejects passwords below 8 chars and a few obvious dictionary entries, but it does not enforce a strength score — pick something long and random. Change it from the in-app flow whenever you suspect compromise; the change-password endpoint rotates the JWT secret, invalidating every existing session.
- **Close the first-run window** with `NOMIFUN_ADMIN_PASSWORD` for any host that becomes reachable before you are ready to interactively complete setup. Alternatively keep the service on `127.0.0.1` until setup is finished, then intentionally bind `0.0.0.0`.
- **`--insecure-no-auth` is hostile by default.** It disables authentication completely; *anyone* who can reach the port becomes a privileged user with shell, file, and agent access. Only use on a loopback bind or a fully trusted private network. The server logs a warning when it is enabled on a non-loopback address.
- The backend has terminal, filesystem, and agent execution capabilities — running it remotely is, by design, equivalent to giving yourself remote code execution on the host. Auth + TLS are the floor, not the ceiling. Treat the data dir and the admin password the same way you would treat root credentials.
## Troubleshooting
**`invalid --host '<value>'`.** Pass an IP literal (`127.0.0.1`, `0.0.0.0`, an explicit interface IP). Hostnames are not parsed.
**Cookies don't stick over HTTPS.** Set `NOMIFUN_HTTPS=true` so the `Secure` flag is added. Without it, browsers reject the cookie on HTTPS responses.
**Agent commands fail with `bun: command not found` under systemd.** Install bun system-wide (see the bun-on-PATH section above) or rebuild with `NOMIFUN_EMBED_BUN=1`.
**Healthcheck.** Use `GET /health` for process liveness. Use
`GET /api/auth/status` only when the caller also needs setup/auth state.
## See also
- [Running NomiFun as a Desktop App](./desktop-app.md)
- [WebUI Remote Access](./webui-remote-access.md) — turn an existing desktop install into a remotely-accessible server (without provisioning a separate machine).
- `packaging/linux/README.md` — deeper Linux notes (mostly Chinese; this guide subsumes the English content).
- `apps/web/src/main.rs` — the source of truth for flags, env vars, and bootstrapping order.
@@ -0,0 +1,294 @@
# Web 服务器部署
`nomifun-web` 是 NomiFun 的**无头、自托管**运行方式。它与 [桌面应用](./desktop-app.zh.md)嵌入的后端是同一个 Rust 后端,但被构建为一个独立二进制,并且会在同一个端口上同时提供 SPA (`ui/dist`)。它没有 GUI,没有 WebView,也不需要 `DISPLAY` —— 任何能运行静态二进制的 Linux/macOS/Windows 服务器上都能跑。
与桌面外壳不同,**`nomifun-web` 默认要求认证**。第一个浏览器访问者要么以交互方式创建管理员账户 (首次运行设置),要么你通过 `NOMIFUN_ADMIN_PASSWORD` 预置凭据。
> 如果你想暴露一个*已有的*桌面安装以便远程访问,而不需要搭建服务器,请参阅 [WebUI 远程访问](./webui-remote-access.zh.md)。那是一个按实例启用的功能;本指南面向的是专用服务器。
```text
浏览器 / 手机 / 局域网 nomifun-web(单进程、单端口)
┌──────────────────┐ ┌───────────────────────────────────────┐
│ SPA + 登录 │ HTTP / WS │ axum router │
│ (ui/dist) │ ────────────► │ ├─ / → SPA (ui/dist) │
└──────────────────┘ │ ├─ /api/* → REST handlers │
│ ├─ /ws → WebSocket 事件 │
│ └─ /login … → 鉴权(默认开启) │
│ │
│ 进程内后端 (nomifun-app) │
│ └─ SQLite · agents · cron · channels │
└───────────────────────────────────────┘
```
## 快速开始
### 直接运行二进制
```bash
cargo build --release -p nomifun-web
./target/release/nomifun-web --host 127.0.0.1 --port 8787 \
--data-dir ./data --dist ./ui/dist
```
然后打开 `http://127.0.0.1:8787`,首次访问时让你创建管理员账户。之后,setup 端点会返回 `409 Conflict`,唯一的进入方式就是通过登录表单 (或 `NOMIFUN_ADMIN_PASSWORD`)。
![首次运行管理员设置界面](../images/webserver-02-first-run-setup.png)
### 或者从仓库通过 Cargo 运行
```bash
bun install
bun run build:ui # 产出 ui/dist
cargo run -p nomifun-web # 会自动使用默认 --dist=../../ui/dist
```
## CLI 参数和环境变量
下方所有参数由 `apps/web/src/main.rs` 读取。每个都有对应的环境变量,方便用于 systemd / Docker / 编排器。
| 参数 | 环境变量 | 默认值 | 用途 |
|---|---|---|---|
| `--host` | `NOMIFUN_WEB_HOST` | `127.0.0.1` | 绑定的 IP。`0.0.0.0` 会接收 LAN/VPN/公网流量;大范围暴露前请先预置管理员或完成首次设置。 |
| `--port` | `NOMIFUN_WEB_PORT` | `8787` | TCP 端口。提供 API、`/ws` 处的 WebSocket,以及 SPA。 |
| `--data-dir` | `NOMIFUN_DATA_DIR` | 按用户目录 | 后端数据目录 (SQLite 数据库、agent 状态、日志、Bun 缓存)。默认是与桌面应用共享的按用户位置 (`%LOCALAPPDATA%\NomiFun\Nomi``~/Library/Application Support/NomiFun/Nomi``$XDG_DATA_HOME/NomiFun/Nomi`)。**生产环境请仍显式指定绝对路径。** |
| `--dist` | `NOMIFUN_WEB_DIST` | `../../ui/dist` | 构建好的 SPA 所在目录。**部署时请显式设置。** |
| `--admin-user` | `NOMIFUN_ADMIN_USERNAME` | `admin` | 预置首个管理员时使用的用户名。一旦管理员存在则被忽略。 |
| `--admin-password` | `NOMIFUN_ADMIN_PASSWORD` | — | 在启动时预置首个管理员密码,跳过交互式设置。一旦管理员存在则被忽略。 |
| `--insecure-no-auth` | `NOMIFUN_WEB_INSECURE_NO_AUTH` | `false` | **危险。** 完全禁用认证 (类似桌面的本地模式)。仅在 loopback 或完全可信的私有网络上使用。 |
| — | `NOMIFUN_HTTPS` | `false` | 当为 `true` 时,session 和 CSRF cookie 会带上 `Secure` 标记。每当应用通过 HTTPS 访问 (例如位于 TLS 反向代理之后) 时都应设置。 |
| — | `SHELL` | 平台默认 | Agent 引擎派生进程时使用的 shell。在 Linux 服务器上若 `$SHELL` 未设置,请设为 `/bin/bash`。 |
布尔类环境变量接受 `1``true``yes``on` (大小写不敏感)。
错误的 `--host` (任何无法解析为 IP 的内容) 会在启动时快速失败并给出清晰错误,而不是抛出晦涩的 socket 错误。
后端启动时会对 `{data_dir}/server.lock` 取 OS 级排他锁 —— **同一数据目录只允许一个后端实例**。第二个指向同一目录的进程会快速失败,错误信息会指出当前持有者 (pid + exe);要部署多个实例,请为每个实例指定各自独立的 `NOMIFUN_DATA_DIR` / `--data-dir`。锁在进程退出或崩溃时由 OS 自动释放,残留的 `server.lock` 文件是无害的。
### 密码与用户名规则
当管理员账户被创建时 (无论是交互式还是预置),值都会在服务端校验:
- **用户名**332 字符,`[a-zA-Z0-9_-]`,不能以 `-` / `_` 开头或结尾。
- **密码**:8–128 字符,若出现在一个小型常见密码列表中 (`password``12345678``qwertyui` …) 则被拒绝。
弱的 `NOMIFUN_ADMIN_PASSWORD` 会拒绝启动。交互式输入的弱密码会返回 `400` 并附带校验信息。
## 首次运行管理员配置
支持两种路径。
### 交互式 (默认)
不设置 `NOMIFUN_ADMIN_PASSWORD`。在新的数据目录上,安装处于 "未初始化" 状态:`GET /api/auth/status` 会报告 `needs_setup: true`,SPA 显示首次运行表单,**第一个浏览器访问者所选的用户名 + 密码会通过原子化的 `POST /api/auth/setup` 成为管理员**。该写入是一个条件性 UPDATE —— 即便两个并发的首次运行请求也无法同时获胜;输者会收到 `409 Conflict`
> **安全提示 —— 首次运行窗口期。** 在服务器可达的那一刻起,到你完成设置之间,任何能到达该端口的人都可以认领管理员账户。在非 loopback 绑定上,服务器会记录一条醒目的警告。可通过先在受信任的隧道/VPN 上完成设置来缓解,或预置 (见下一节) 让安装在上线前就已初始化。
### 预置 (推荐用于自动化)
在首次启动前提供 `NOMIFUN_ADMIN_PASSWORD` (以及可选的 `NOMIFUN_ADMIN_USERNAME`,默认 `admin`)。引导例程会原子地哈希并存储凭据,从首次启动开始首次运行 setup 端点就会返回 `409`,没有任何窗口让别人来认领账户。
```bash
NOMIFUN_ADMIN_USERNAME=alice \
NOMIFUN_ADMIN_PASSWORD='change-me-to-something-strong' \
nomifun-web --host 0.0.0.0 --port 8787 \
--data-dir /var/lib/nomifun --dist /opt/nomifun/web
```
预置是**幂等的** —— 一旦管理员存在,后续启动时这些环境变量会被忽略。要轮换凭据,请使用应用内的修改密码 / 修改用户名流程,而不是环境变量。
## Docker
仓库附带一个多阶段 `Dockerfile` 和一个 `docker-compose.yml`。镜像会:
1. 用 Bun 构建 SPA。
2. 从 workspace 编译 `nomifun-web`
3. 组装一个精简的 `debian:bookworm-slim` 运行时,包含 `bun``git``ripgrep`
它暴露端口 `8787`,并使用 `/data` 作为数据卷。
### Compose
```bash
docker compose up -d --build
# 然后打开 http://<server-ip>:8787 并创建首位管理员
```
`restart: unless-stopped` 让服务在主机启动时启动 —— 安装它*就是*启用它。默认的 ports 块直接发布 `8787:8787`;请先预置管理员或在受信网络完成首次设置,再大范围暴露。暴露到公网前请加上 TLS (下一节)。
验证就绪:
```bash
docker compose logs -f nomifun
# 查找:“nomifun-web: embedded backend + SPA on one port”
```
compose 文件挂载了一个名为 `nomifun-data:/data` 的具名卷,其中保存着 SQLite DB、日志、Bun 运行时缓存以及每个 agent 的状态。请像对待其他数据库一样仔细备份。
### 在 Compose 中预置管理员
取消 `environment:` 块的注释:
```yaml
environment:
NOMIFUN_ADMIN_USERNAME: admin
NOMIFUN_ADMIN_PASSWORD: "change-me-to-something-strong"
NOMIFUN_HTTPS: "true" # 在 Caddy / nginx 加 TLS 前置时启用
```
### 在缓慢的 registry 后构建
Rust 阶段接受一个 `CARGO_REGISTRY_MIRROR` 构建参数用于 cargo 注册表镜像 (例如在 crates.io 较慢的网络上)
```bash
docker build --build-arg CARGO_REGISTRY_MIRROR=https://rsproxy.cn/index/ -t nomifun-web:local .
```
```text
$ docker compose up -d
[+] Running 2/2
✔ Network nomifun_default Created
✔ Container nomifun-web Started
$ docker compose logs -f web
nomifun-web | listening on 0.0.0.0:8787 (auth: enabled)
```
## 通过 Caddy 反向代理实现 TLS
仓库附带一个用于 Caddy 2 的 `Caddyfile`。Caddy 会自动签发 HTTPS 证书 (默认 Let's Encrypt 或 ZeroSSL) 并代理到应用。`/ws` 处的 WebSocket 升级会自动透传,无需额外配置。
```caddy
your.domain.com {
encode zstd gzip
reverse_proxy nomifun:8787
}
```
要在 `docker-compose.yml` 中启用 Caddy 服务:
1. 编辑 `Caddyfile` 并把 `your.domain.com` 替换为你的真实域名。
2.`nomifun` 服务的环境变量中设置 `NOMIFUN_HTTPS=true` (这样 cookie 会带上 `Secure` 标记)。
3.`ports: ["8787:8787"]` 替换为 `expose: ["8787"]`,让只有 Caddy 对外发布。
4. 取消 `caddy:` 服务以及 `caddy-data` / `caddy-config` 卷的注释。
5. `docker compose up -d`
应用本身已经提供了登录界面,所以**不要在 Caddy 里配置 HTTP basic auth** —— Caddy 的职责只是 TLS 终结和代理。
对于没有公网域名的仅局域网主机,可以使用一个内部名加上 `tls internal`,或者干脆不加 Caddy 直接发布端口 `8787` (应用内登录依然提供保护)。
## systemd (Linux 服务器,无 Docker)
仓库包含 `packaging/linux/nomifun-web.service` 以及一份长篇 Linux 部署指南 `packaging/linux/README.md`
### 构建产物
你需要一台 Linux 构建主机 (从 Windows 交叉编译 C 依赖很痛苦 —— 最简单的变通是用 `docker cp` 从 Docker 镜像中提取二进制)。在 Linux 上:
```bash
bun install
bun run build:ui # → ui/dist (~21MB)
cargo build --release -p nomifun-web # → target/release/nomifun-web
```
### 布局
```
/opt/nomifun/nomifun-web # 二进制
/opt/nomifun/web/ # ui/dist 的内容
/var/lib/nomifun/ # 数据目录 (由 systemd 的 StateDirectory 创建)
```
```bash
sudo useradd --system --home /var/lib/nomifun --shell /usr/sbin/nologin nomifun
sudo mkdir -p /opt/nomifun/web
sudo cp target/release/nomifun-web /opt/nomifun/
sudo cp -r ui/dist/. /opt/nomifun/web/
```
### Bun 必须在系统 `PATH` 上
Agent 引擎需要 **`bun ≥ 1.3.13`** 作为运行时依赖。由于服务以一个 `nologin` 系统账户运行,安装在某个用户 `~/.bun/bin/` 下对它来说是不可见的。请二选一:
- **系统级安装**`curl -fsSL https://bun.sh/install | bash`,然后 `sudo install ~/.bun/bin/bun /usr/local/bin/bun`
- **嵌入二进制**:使用 `NOMIFUN_EMBED_BUN=1 cargo build --release -p nomifun-web` 构建。Bun 会被打包进二进制中,并在首次运行时自解压到数据目录。
验证:`sudo -u nomifun -s -- which bun` 必须返回一个路径。否则首次 agent 派生会以一个晦涩的错误失败。
### 安装 unit
```bash
sudo cp packaging/linux/nomifun-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nomifun-web
sudo systemctl status nomifun-web
```
附带的 unit
- 默认绑定 `127.0.0.1:8787`。只有在首次设置完成或已配置
`NOMIFUN_ADMIN_PASSWORD` 后,才应把 `NOMIFUN_WEB_HOST` 改成
`0.0.0.0`
- 设置 `NOMIFUN_DATA_DIR=/var/lib/nomifun` 以匹配 systemd 管理的 `StateDirectory=nomifun`。**保持这两者同步** —— 如果你删除该环境变量行,数据目录会静默回退到服务用户的按用户目录 (`$XDG_DATA_HOME/NomiFun/Nomi`,通常是 `~nomifun/.local/share/NomiFun/Nomi`),与 systemd state 解耦。
- 以专用的 `nomifun` 用户运行 (`User=nomifun``Group=nomifun`)。
- 失败时以 3 秒退避重启。
- 应用适度的硬化 (`NoNewPrivileges=yes``PrivateTmp=yes`)。**不要添加** `ProtectHome=yes` 或严格的 `ProtectSystem` —— agent 引擎需要读写操作员指定的文件,过度沙箱化会破坏核心功能。
要在 TLS 代理后启用 HTTPS cookie,请取消注释:
```ini
Environment=NOMIFUN_HTTPS=true
```
要预置管理员而不是交互式设置:
```ini
Environment=NOMIFUN_ADMIN_USERNAME=admin
Environment=NOMIFUN_ADMIN_PASSWORD=change-me-to-something-strong
```
```text
$ sudo systemctl status nomifun-web
● nomifun-web.service - NomiFun web host
Loaded: loaded (/etc/systemd/system/nomifun-web.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-06-25 09:12:03 UTC
Main PID: 12345 (nomifun-web)
CGroup: /system.slice/nomifun-web.service
└─12345 /usr/local/bin/nomifun-web --host 127.0.0.1 --port 8787 …
nomifun-web[12345]: listening on 127.0.0.1:8787 (auth: enabled)
```
## Linux 运行时依赖
| 依赖 | 是否必需 | 说明 |
|---|---|---|
| `glibc` + `ca-certificates` | 是 | sqlite 是静态链接的,TLS 使用 rustls —— **不需要 openssl,不需要 libsqlite**。 |
| `bun` ≥ 1.3.13 | **是** | Agent 执行运行时。1.1.38 有 stdin bug;不要使用。Docker 镜像里已包含。 |
| `node` / `npm` / `npx` | 推荐 | 许多用户配置的 MCP stdio 服务器通过 `npx -y …` 启动。 |
| `git` | 推荐 | 技能发现和一些内置工具会使用。 |
| `ripgrep` (`rg`) | 推荐 | 代码搜索后端。缺失时回退到 `grep`。 |
| `DISPLAY` / X11 / WebView | **否** | `nomifun-web` 是完全无头的。 |
## 安全检查清单
- **任何公网部署都要使用 TLS。** 通过明文 HTTP 传输的 cookie 和登录凭据可能被嗅探。在 TLS 代理后请设置 `NOMIFUN_HTTPS=true`,让 session cookie 带上 `Secure` 标记。
- **强管理员密码。** 校验器会拒绝长度低于 8 字符的密码以及一些显而易见的字典条目,但它并不强制执行强度评分 —— 请选择长且随机的内容。怀疑被泄露时,请通过应用内流程修改它;修改密码端点会轮换 JWT 密钥,使所有现有会话失效。
- 对于任何在你能进行交互式设置之前就已可达的主机,请用 `NOMIFUN_ADMIN_PASSWORD` **关闭首次运行窗口期**。另一种做法是先保持 `127.0.0.1`,完成设置后再显式绑定 `0.0.0.0`
- **`--insecure-no-auth` 默认是敌对的。** 它完全禁用认证;*任何*能到达该端口的人都会成为拥有 shell、文件和 agent 访问权限的特权用户。仅在 loopback 绑定或完全可信的私有网络上使用。当它在非 loopback 地址上启用时,服务器会记录警告。
- 后端拥有终端、文件系统和 agent 执行能力 —— 远程运行它,本设计上等同于给自己开通了对该主机的远程代码执行。Auth + TLS 是底线,不是上限。请像对待 root 凭据一样对待数据目录和管理员密码。
## 故障排查
**`invalid --host '<value>'`。** 请传入一个 IP 字面量 (`127.0.0.1``0.0.0.0`、显式接口 IP)。不解析主机名。
**HTTPS 下 cookie 无法保留。** 设置 `NOMIFUN_HTTPS=true` 以加上 `Secure` 标记。否则浏览器会在 HTTPS 响应中拒绝该 cookie。
**在 systemd 下 agent 命令失败并报 `bun: command not found`。** 请系统级安装 bun (参见上面的 bun-on-PATH 一节) 或使用 `NOMIFUN_EMBED_BUN=1` 重新构建。
**健康检查。** 使用 `GET /health` 作为进程存活探针;只有在调用方还需要设置 / 认证状态时,才使用 `GET /api/auth/status`
## 另请参阅
- [以桌面应用方式运行 NomiFun](./desktop-app.zh.md)
- [WebUI 远程访问](./webui-remote-access.zh.md) —— 把现有桌面安装变成一个可远程访问的服务器 (无需另置一台机器)。
- `packaging/linux/README.md` —— 更深入的 Linux 笔记 (主要是中文;本指南涵盖了其英文部分)。
- `apps/web/src/main.rs` —— 参数、环境变量和引导顺序的真相之源。
@@ -0,0 +1,111 @@
# WebUI Remote Access
The desktop app already runs a backend on a localhost port for its own webview — why not just expose it? Because exposing an unauthenticated backend on a LAN would hand every device on that network full shell, file, and agent access.
**WebUI remote access** solves that. The desktop backend runs under a *trust-local-token* policy: the desktop's own webview is trusted via a per-boot secret it presents on every request (so you never log in locally), while any other client must authenticate. With one switch, an additional listener is bound on a stable LAN port that serves the app to remote browsers behind a login (password + QR), so you can use Nomi from your phone or another browser without giving up local-mode convenience.
This is **per-instance** — it lives inside your already-running desktop app — and is distinct from the dedicated [Web Server Deployment](./web-server-deployment.md). Use this when you have an existing desktop install and just want to reach it from another device on the same network. Use the dedicated server when you want a long-lived headless deployment.
![Open Capabilities WebUI panel](../images/webui-01-settings-overview.png)
## Where to find it
Open **Open Capabilities** (route `/open-capabilities`) and use the WebUI
remote-access panel. The legacy `/settings/webui` route redirects there.
- **WebUI remote access** controls the desktop LAN listener described in this guide.
- Other cards on the page manage public/remote capability exposure and should be
reviewed separately before enabling them.
> The WebUI remote-access controls are meaningful inside the desktop shell. In a
> browser tab against `nomifun-web`, you are already using the dedicated Web host;
> use [Web Server Deployment](./web-server-deployment.md) settings instead.
## What enabling it does
Toggling **Enable WebUI** on starts an additional authenticated server inside the desktop process:
- **Default port `25808`** (`25809` in dev mode, `25810` when `NOMIFUN_MULTI_INSTANCE=1`).
- An admin user (default name `admin`) is provisioned with a freshly generated random password — shown in plaintext **once**, on this first start, so you can copy it.
- The server's lifetime is tracked by the desktop main process; the toggle reflects the *real* server state, not a remembered preference, so a silent failure (port conflict, etc.) leaves the switch off rather than misleading you into thinking it is up.
## Architecture: two listeners, one backend
The desktop process serves its backend on **two** sockets that share one in-process router (built once):
- A **permanent loopback listener** on an ephemeral port — the desktop's own webview, trusted via the per-boot secret. Always up; never disturbed by toggling remote access.
- An **on-demand LAN listener** on `0.0.0.0:25808` — bound only when you enable remote access, torn down when you disable it. Remote browsers reach this one and must log in. Trust is the secret (which only the desktop webview holds), *not* "arrived on loopback", so other OS accounts on a shared workstation and same-host reverse proxies are **not** auto-trusted. The LAN listener additionally enforces a Host/Origin allow-list (IP/localhost only, blocking DNS-rebinding) and rate-limits by real peer address.
Because of the exclusive data-dir lock, the desktop process is the only backend on its data directory — so the LAN listener lives *inside* the desktop app, it is not a co-running `nomifun-web`.
## Binding and the access URL
Enabling remote access binds **`0.0.0.0:25808`** (`25809` in dev; falls back to an ephemeral port if `25808` is taken) so other devices on your network can reach it. The displayed URL adapts:
- **The desktop's own machine**: `http://localhost:<port>`.
- **Remote (LAN/VPN)**: `http://<your-LAN-IP>:<port>` (e.g. `http://192.168.1.42:25808`). The candidate interface addresses are detected from the host's network interfaces; on a VPN host with multiple adapters, confirm the advertised address is the one your phone can actually reach.
A copy button copies the URL; clicking it opens it in your default external browser. The QR-code login is shown while the LAN listener is running.
## Login: username and password
The credentials panel shows:
- **Username** — defaults to `admin`. Editable via the pencil icon (server-side validation: 332 chars, `[a-zA-Z0-9_-]`, must not start or end with `-` / `_`).
- **Initial password** — shown in plaintext on the *very first* start, masked as `******` after that. The plaintext can be copied while it is visible. Once you copy it (or the first session ends), it switches to masked permanently.
- The plaintext is only shown once because the backend stores a bcrypt hash, not the plaintext. After the first display, even the desktop UI cannot recover the original.
To change the password later, click the pencil icon next to the masked field. The form requires the new password and a confirmation; on success the new value is hashed and persisted, and the cached plaintext is cleared. The password validator rejects values shorter than 8 characters and a small list of common passwords (`password`, `12345678`, …).
The "reset password" path (when you forget it) generates a fresh 16-character random password server-side; a one-time displayed value, like the initial one.
![Login screen on the remote browser](../images/webui-03-login-screen.png)
## QR-code login
While WebUI is enabled (the LAN listener is running), a QR code appears in the credentials card.
- Scanning it from your phone opens `http://<host>:<port>/qr-login?token=<one-time>` in the phone's default browser.
- That URL hits a static page that calls `POST /api/auth/qr-login` with the token. The token is single-use and validated atomically; the server returns a session cookie + JWT and the page redirects to `/`.
- Tokens **expire after 5 minutes**; the UI auto-refreshes the QR every 4 minutes so a panel left open does not invalidate.
- A copy button next to the QR copies the full login URL (useful if your phone cannot scan), and a refresh button regenerates the token on demand.
QR login always logs you in as the configured WebUI admin (the primary admin user), regardless of how many users exist in the database — it is the per-instance "skip the password form" shortcut, not a multi-user feature.
![QR code login on phone](../images/webui-04-qr-login-phone.png)
## How this differs from `nomifun-web`
| | WebUI remote access | `nomifun-web` (Web Server Deployment) |
|---|---|---|
| Where it runs | Inside your already-running desktop app | A separate, headless binary |
| GUI required to start | Yes (the Settings toggle) | No |
| Admin provisioning | Auto-generated password on first enable | Interactive first-run setup, or `NOMIFUN_ADMIN_PASSWORD` |
| Default port | `25808` (prod), `25809` (dev) | `8787` |
| Survives reboot | Only if your desktop app is running | Yes, with systemd / Docker `restart: unless-stopped` |
| TLS | None built in (LAN-oriented) | Caddy / nginx in front; `NOMIFUN_HTTPS=true` |
| Use case | Quick remote access from a phone on the same network | A real always-on server |
If you find yourself leaving the desktop app running on a server-like box just so the WebUI server stays up, that is the cue to switch to a dedicated [Web Server Deployment](./web-server-deployment.md).
## Security notes
- The server listens on plain HTTP. Use it on a **trusted local network** (your home Wi-Fi, a VPN, Tailscale, etc.). For exposure beyond that, deploy `nomifun-web` behind a TLS reverse proxy instead.
- The admin user has the same capabilities as the local desktop user: shell access, file access, agent execution. Treat the admin password and QR tokens accordingly.
- Changing the password (in-app or via reset) invalidates all existing sessions because the JWT signing secret rotates atomically with the password update.
- The QR token is one-shot — once scanned and consumed it cannot be reused. A leaked token is therefore self-limiting, but a leaked URL **before** scanning still grants login. Don't post screenshots of the QR.
## Troubleshooting
**Toggle flips back to off immediately.** Another process is bound to the WebUI port. Pick a different port if you can configure it from the UI; otherwise stop whatever is holding `25808`.
**The QR code shows but my phone gets a connection error.** Check the LAN IP shown in the access URL — if your machine has multiple interfaces (Wi-Fi + Ethernet, VPN adapters), the auto-detected address may not be the one your phone can reach. Confirm your phone is on the same network/subnet, and that the firewall allowed Nomi when prompted.
**`./qr-login?token=…` says "Login failed: …".** The token expired (5-minute TTL) or has already been consumed. Click the refresh button next to the QR to mint a new one.
**I forgot the admin password.** Use the reset button (the pencil + reset icon next to the masked password), then sign in with the freshly generated value — it is shown once.
## See also
- [Running NomiFun as a Desktop App](./desktop-app.md)
- [Web Server Deployment](./web-server-deployment.md) — when you want a real always-on server, not a desktop side-channel.
@@ -0,0 +1,110 @@
# WebUI 远程访问
桌面应用本来就在一个 localhost 端口上为自己的 webview 运行了一个后端 —— 为什么不直接把它暴露出去?因为把一个无认证的后端放到 LAN 上,等于把对该网络上每台设备完全的 shell、文件和 agent 访问权拱手相让。
**WebUI 远程访问**正是为此而生。桌面后端运行在 *trust-local-token* 策略下:桌面自己的 webview 通过一个每次启动生成的密钥被信任(每个请求都携带它,所以本机从不需要登录),而任何其他客户端都必须认证。一键即可在一个稳定的 LAN 端口上额外绑定一个监听器,把应用经登录(密码 + 二维码)服务给远程浏览器,让你在不放弃本地模式便利性的前提下,从手机或另一个浏览器使用 Nomi。
它是**按实例启用**的 —— 它存在于你已经运行着的桌面应用内 —— 与专用的 [Web 服务器部署](./web-server-deployment.zh.md)是不同的。当你已经有一个桌面安装并且只想从同一网络上的另一台设备访问它时,请使用本功能。当你想要一个长期运行的无头部署时,请使用专用服务器。
![开放能力 WebUI 面板](../images/webui-01-settings-overview.png)
## 在哪里找到它
打开 **开放能力**(路由 `/open-capabilities`)中的 WebUI 远程访问面板。旧
`/settings/webui` 路由会重定向到这里。
- **WebUI 远程访问** 控制本指南描述的桌面 LAN listener。
- 页面上的其他卡片管理 public/remote capability 暴露,启用前应单独审查。
> WebUI 远程访问控制主要用于桌面壳。若你正在浏览器里访问 `nomifun-web`
> 说明已经在使用专用 Web host;请按 [Web 服务部署](./web-server-deployment.zh.md)
> 的方式配置。
## 启用它做了什么
打开 **Enable WebUI** 会在桌面进程内启动一个额外的认证服务器:
- **默认端口 `25808`** (开发模式下为 `25809`,当 `NOMIFUN_MULTI_INSTANCE=1` 时为 `25810`)。
- 一个管理员用户 (默认名 `admin`) 会被开通,并带有一个新生成的随机密码 —— 在首次启动时**仅以明文显示一次**,以便你复制。
- 服务器的生命周期由桌面主进程跟踪;该开关反映服务器的*真实*状态,而不是某个被记住的偏好,所以一次静默失败 (端口冲突等) 会让开关保持关闭,而不是误导你以为它已开启。
## 架构:双监听器,一个后端
桌面进程在**两个** socket 上服务后端,二者共享同一个(只构建一次的)路由:
- 一个 **永久 loopback 监听器**(随机端口)—— 桌面自己的 webview,通过每启动密钥被信任。始终在线;切换远程访问从不打断它。
- 一个 **按需 LAN 监听器**`0.0.0.0:25808`)—— 仅在你开启远程访问时绑定,关闭时拆除。远程浏览器连这个,且必须登录。信任的依据是**密钥**(只有桌面 webview 持有),而非"来自 loopback",因此共享工作站上的其他 OS 账户、以及同机反向代理都**不会**被自动信任。LAN 监听器还强制 Host/Origin 白名单(仅 IP/localhost,阻断 DNS-rebinding),并按真实对端地址限流。
由于数据目录独占锁,桌面进程是其数据目录上唯一的后端 —— 所以 LAN 监听器活在桌面应用*内部*,它不是另跑的 `nomifun-web`
## 绑定与访问 URL
开启远程访问会绑定 **`0.0.0.0:25808`**(开发模式 `25809`;若 `25808` 被占用则回退到一个随机端口),使你网络上的其他设备可以访问。显示的 URL 会自适应:
- **桌面本机**`http://localhost:<port>`
- **远程 (LAN/VPN)**`http://<your-LAN-IP>:<port>`(例如 `http://192.168.1.42:25808`)。候选网卡地址从主机网络接口探测;在带多个网卡的 VPN 主机上,请确认广播出的地址是手机真正可达的那个。
复制按钮会复制 URL;点击它会在默认外部浏览器中打开。LAN 监听器运行时会显示二维码登录。
## 登录:用户名和密码
凭据面板显示:
- **用户名** —— 默认 `admin`。可通过铅笔图标编辑 (服务端校验:3–32 字符,`[a-zA-Z0-9_-]`,不能以 `-` / `_` 开头或结尾)。
- **初始密码** —— 在*仅*第一次启动时以明文显示,之后被遮蔽为 `******`。在它可见时可以复制明文。一旦你复制了它 (或第一次会话结束),它就永久切换为遮蔽状态。
- 明文只显示一次,因为后端存储的是 bcrypt 哈希,而不是明文。在第一次显示之后,连桌面 UI 也无法恢复原始值。
要稍后修改密码,点击被遮蔽字段旁的铅笔图标。表单需要新密码和一次确认;成功后新值会被哈希并持久化,缓存的明文会被清除。密码校验器会拒绝长度低于 8 字符的值以及一小列常见密码 (`password``12345678` …)。
"重置密码" 路径 (当你忘记时) 会在服务端生成一个新的 16 字符随机密码;像初始密码一样是一次性显示的值。
![远程浏览器上的登录界面](../images/webui-03-login-screen.png)
## 二维码登录
WebUI 启用时(局域网监听器运行中),凭据卡中会出现一个二维码。
- 用手机扫描会在手机的默认浏览器中打开 `http://<host>:<port>/qr-login?token=<one-time>`
- 该 URL 命中一个静态页面,该页面调用 `POST /api/auth/qr-login` 并带上 token。token 是一次性使用的,并被原子性地校验;服务器返回一个 session cookie + JWT,页面跳转到 `/`
- Token **5 分钟后过期**;UI 每 4 分钟自动刷新一次二维码,避免一个一直开着的面板失效。
- 二维码旁的复制按钮会复制完整的登录 URL (在你手机无法扫描时有用),刷新按钮则可以按需重新生成 token。
无论数据库中存在多少用户,二维码登录始终把你登入为已配置的 WebUI 管理员 (主管理员) —— 它是按实例的"跳过密码表单"的捷径,而不是一个多用户功能。
![手机上的二维码登录](../images/webui-04-qr-login-phone.png)
## 与 `nomifun-web` 的区别
| | WebUI 远程访问 | `nomifun-web` (Web 服务器部署) |
|---|---|---|
| 运行位置 | 在你已经运行的桌面应用内 | 一个独立的、无头的二进制 |
| 启动是否需要 GUI | 是 (设置开关) | 否 |
| 管理员配置 | 首次启用时自动生成密码 | 交互式首次运行设置,或 `NOMIFUN_ADMIN_PASSWORD` |
| 默认端口 | `25808` (生产)`25809` (开发) | `8787` |
| 是否在重启后保留 | 仅当桌面应用在运行时 | 是,配合 systemd / Docker 的 `restart: unless-stopped` |
| TLS | 没有内建 (面向 LAN) | 前置 Caddy / nginx`NOMIFUN_HTTPS=true` |
| 适用场景 | 从同一网络上的手机快速远程访问 | 真正的常开服务器 |
如果你发现自己只是为了让 WebUI 服务器保持开启而把桌面应用一直跑在某台类服务器机器上,那就是切换到专用 [Web 服务器部署](./web-server-deployment.zh.md) 的信号。
## 安全说明
- 服务器监听明文 HTTP。请在**可信本地网络** (家里 Wi-Fi、VPN、Tailscale 等) 上使用。要超出这个范围暴露,请改为在 TLS 反向代理后部署 `nomifun-web`
- 管理员用户拥有与本地桌面用户相同的能力:shell 访问、文件访问、agent 执行。请相应对待管理员密码和 QR token。
- 修改密码 (在应用内或通过重置) 会使所有现有会话失效,因为 JWT 签名密钥会随密码更新一同原子轮换。
- QR token 是一次性的 —— 一旦扫描并被消费就无法重用。因此被泄露的 token 自我限制有限,但**扫描之前**被泄露的 URL 仍能授予登录权。不要发布二维码的截图。
## 故障排查
**开关立即弹回 off。** 另一个进程绑定了 WebUI 端口。如果可以从 UI 配置就换一个端口;否则停掉占用 `25808` 的程序。
**二维码显示了但手机连不上。** 检查访问 URL 中显示的 LAN IP —— 如果你的机器有多个接口 (Wi-Fi + 以太网、VPN 适配器),自动检测到的地址可能不是手机能到达的那个。确认手机和电脑在同一网络/子网,且首次绑定时已在防火墙提示中允许 Nomi。
**`./qr-login?token=…` 提示 "Login failed: …"。** Token 已过期 (5 分钟 TTL) 或已被消费过。点击二维码旁的刷新按钮铸造一个新的。
**我忘了管理员密码。** 使用重置按钮 (被遮蔽密码旁的铅笔 + 重置图标),然后用新生成的值登录 —— 它只显示一次。
## 另请参阅
- [以桌面应用方式运行 NomiFun](./desktop-app.zh.md)
- [Web 服务器部署](./web-server-deployment.zh.md) —— 当你想要一个真正的常开服务器,而不是桌面侧通道时。