diff --git a/examples/apps/agentos/README.md b/examples/apps/agentos/README.md new file mode 100644 index 0000000..32502a0 --- /dev/null +++ b/examples/apps/agentos/README.md @@ -0,0 +1,77 @@ +# AgentOS 桌面包(阶段一:HTTP 轮询版) + +把 AgentOS 运行时呈现为 EmeraldOS 桌面的一组窗口。设计文档: +`agentos/docs/emerald-desktop-ui-design-2026-09-15.md`(桌面就是 World—— +Registry 里每个 Endpoint 一扇窗,Human 也是系统居民)。 + +## 组件 + +| 文件 | 组件 | 说明 | +|---|---|---| +| `src/link.rb` | `AgentOSDesk::LinkCore` | 纯 CRuby 数据内核:Observer JSON → 归一状态、POST body 生成。零 UI/零 IO,完整单测 | +| `src/link.rb` | `AgentOSDesk::LinkService < Emerald::Service` | 桌面侧唯一碰网络的组件:`activation on_startup`、运行期单例 `.instance`、9 个只读 `Citrine::Signal`。Opal 下 fetch 每 2s 轮询 + `Beryl::Timer.after` 递归重排;POST 走 `text/plain` 规避 CORS 预检(Observer 不处理 OPTIONS)。CRuby 下轮询/POST 全部 no-op | +| `src/agent_window.rb` | `AgentOSDesk::AgentWindow`(`:agentos_agent`,多实例) | 每 Agent 一扇窗:状态徽标 + mailbox 会话流(thread 分组)+ 披露层级切换 + 互动条 | +| `src/service_window.rb` | `AgentOSDesk::ServiceWindow`(`:agentos_service`,多实例) | 每 Service 一扇窗:running 状态点 + state 键值面板 + pending/processing 双栏队列 + 互动条 | +| `src/inbox_window.rb` | `AgentOSDesk::InboxWindow`(`:agentos_inbox`,单例) | Human 收件箱:卡片流 + 待回答质询橙色高亮 + 内联回答框 | +| `src/world_window.rb` | `AgentOSDesk::WorldWindow`(`:agentos_world`,单例) | World 总览(桌面自检口):观测端在线横幅、总览/配置键值、Registry 名单 + 「开窗」按钮 | + +窗口全部零 IO:只读 `LinkService.instance` 的 signal(view 内 `.get` 即订阅); +未启动时渲染「AgentOS 连接未启动」占位,任何情况下不崩溃。 + +## 数据契约(Observer HTTP,锁定于 agentos `test/observer_test.rb`) + +- 轮询:`/api/overview`、`/api/config`、`/api/agents`(`{'agents'=>[...]}`)、 + `/api/services`(`{'services'=>[...]}`)、`/api/human`(`pending_questions` + 是 question 的 message_id 字符串数组,需与 `cards` 按 message_id 关联归一)、 + 每 endpoint `/api/mailbox?agent=X`(`{'endpoint','pending','processing'}`)。 +- 写:`POST /api/messages`,body `{to, content, kind?, priority?, thread_id?, + task_id?, in_reply_to?}`,必须 `text/plain`。 +- **kind 翻译**:窗口互动条统一传 `:ask`(人话动作),而 AgentOS + `Message::KINDS` 只有 task/question/answer/result/alert/progress/stop—— + `LinkCore#build_post_body` 把 `:ask` 翻译为 `'task'`,其余 kind 原样字符串化。 + 直发 `'ask'` 会被 Observer 判 400。 + +## 运行(开发期路径 A:require + register) + +接线在 `examples/desktop.rb`:require 包 entry → `registry.register` 四个 App → +`hub.register(LinkService)` + 幂等 `activate_startup`(桌面启动即开轮询)→ +注入 launcher 桥接(World 窗口「开窗」按钮经它走 `DesktopShell#launch_app` +真正开窗;裸 `AppRegistry#launch` 只建实例不开窗,D3)。 + +前置:AgentOS 运行时(`ruby/bin/agentos`)已在 `127.0.0.1:4470` 监听 +(`LinkService.base_url=` 可改写,须在 activate 之前)。桌面图标双击 +「AgentOS 桌面」开 World 窗口;Agent/Service 窗口经 World 名单行内 +「开窗」按钮或 `launch(:agentos_agent, endpoint: "agent-1")` 打开。 + +## 测试 + +```sh +# emerald/ 目录内 +bundle exec ruby -Ilib examples/apps/agentos/test/agentos_desk_test.rb +``` + +纯 CRuby(beryl F5):内联 fixture 直喂内核/服务,零网络零 Observer 依赖。 + +## 已知限制 + +- **.emz 懒加载路径下 entry 的 `require_relative` 不可用**。AppHost + (`lib/emerald/pkg/apphost.rb`)装载 entry 时是「VFS 读出源码文本 → + `OpalParser.compile`(= `Opal.compile(src)`,无 load path 上下文)→ + `run_module` eval 产物」,Opal 编译器把字面量 `require`/`require_relative` + 当静态依赖解析——包内兄弟文件不在 load path,`require_relative` 无法 + 解析(calculator 参照示例的单文件 entry 即此约束的产物)。因此本包 + 现仅支持开发期 require 接线;打 .emz 前须把五个组件合并为单文件 + entry,或扩展 AppHost 支持包内多文件装载。 +- **多 App 包与 AppHost 的 entry 校验冲突**:`AppHost#evaluate!` 要求 entry + 声明的 `app_id` 与包 id 一致,而本包含 4 个 App(`agentos_agent` / + `agentos_service` / `agentos_inbox` / `agentos_world`),manifest 包 id + `agentos` 是组织名。`.emz` 安装前需放宽该校验(按 entry 定义的全部 + App 子类注册)或拆包。 +- **launcher 桥接是 `examples/desktop.rb` 的开发期接线**(替换 + `services[:launcher]` 为桥接对象):`.emz` 路径下 ctx[:launcher] 仍是 + 裸 AppRegistry,World 窗口「开窗」按钮降级为「(未接线)」纯文本 + (组件内建降级,不崩溃);`agentos.open` 命令的贡献点接线(v1 归约为 + 「打开该应用」)在打包后由 shell 的 `register_contributions` 统一处理。 +- 阶段一覆盖「看 + 说」(展示状态、发消息、答质询);停止 Agent、 + 启停 endpoint、改/撤回队列、快照检查点等「管」的能力待阶段二 + WebSocket 协议(见设计文档 §5)。 diff --git a/examples/apps/agentos/manifest.json b/examples/apps/agentos/manifest.json new file mode 100644 index 0000000..c9cab5d --- /dev/null +++ b/examples/apps/agentos/manifest.json @@ -0,0 +1,21 @@ +{ + "spec": 1, + "kind": "app", + "id": "agentos", + "name": "AgentOS 桌面", + "version": "0.1.0", + "description": "把 AgentOS 运行时呈现为 EmeraldOS 桌面的一组窗口(阶段一:HTTP 轮询版)——World 总览 + 每 Agent/Service 一扇窗 + Human 收件箱", + "authors": ["AgentOSDesk"], + "min_runtime": { "emerald": ">=0.1.0" }, + "permissions": [], + "entry": "src/main.rb", + "contributes": { + "commands": [{ "id": "agentos.open", "title": "打开 AgentOS" }], + "menus": [{ "path": "应用", "command": "agentos.open" }] + }, + "activation": { "on_command": ["agentos.open"] }, + "window": { + "singleton": true, + "default_geometry": { "x": 60, "y": 40, "w": 560, "h": 620 } + } +} diff --git a/examples/apps/agentos/src/agent_window.rb b/examples/apps/agentos/src/agent_window.rb new file mode 100644 index 0000000..5568f71 --- /dev/null +++ b/examples/apps/agentos/src/agent_window.rb @@ -0,0 +1,471 @@ +# frozen_string_literal: true + +# Agent 窗口(examples/apps/agentos 包,阶段一 HTTP 轮询版): +# 单个 Agent(endpoint)的状态条 + 会话流 + 互动条。结构照 calculator 模板: +# AgentWindow::Presenter —— 纯渲染内核(CRuby 可测、零 UI/网络依赖):把 +# LinkService 的 agents/mailboxes 原始数据整理成视图模型(状态徽标、 +# mailbox 计数、thread 分组、内容截断) +# AgentWindow —— App 薄壳(< Emerald::App):只负责订阅 +# LinkService 的 signal 并渲染;窗口自身零 IO,数据一律经 +# AgentOSDesk::LinkService.instance(nil 时整体渲染占位文案) +# +# entry 由包接线(src/main.rb)在运行时加载,本文件自身不 require; +# 纯 CRuby 可测,无任何 Opal/JS 代码。 +module AgentOSDesk + class AgentWindow < Emerald::App + # 纯渲染内核:输入是 LinkService 两个 signal 的原始片段 + #(agent 摘要 Hash + mailbox 两段消息数组),输出视图模型。 + # 所有渲染防御(缺失字段、未知 status/kind/priority、非 Hash + # 元素)集中在此,App 壳只搬字。 + class Presenter + # last_status 徽标配色(契约:Symbol 或 nil): + # completed 绿 / cancelled 灰 / failed 红 / step_limit 橙 / + # restart_requested 紫;nil(待命)与未知值一律蓝灰兜底。 + STATUS_BADGES = { + completed: { text: 'completed', color: '#10b981' }, + cancelled: { text: 'cancelled', color: '#9ca3af' }, + failed: { text: 'failed', color: '#ef4444' }, + step_limit: { text: 'step_limit', color: '#f59e0b' }, + restart_requested: { text: 'restart_requested', color: '#a855f7' } + }.freeze + IDLE_BADGE = { text: '待命', color: '#64748b' }.freeze + FALLBACK_BADGE_COLOR = '#64748b' + + # kind/priority 徽标配色(契约:string 键;未知值兜底灰) + KIND_COLORS = { + 'task' => '#60a5fa', 'question' => '#f59e0b', 'answer' => '#34d399', + 'result' => '#10b981', 'alert' => '#ef4444', 'progress' => '#94a3b8', + 'stop' => '#f87171' + }.freeze + FALLBACK_KIND_COLOR = '#94a3b8' + PRIORITY_COLORS = { + 'urgent' => '#f97316', 'control' => '#a855f7', 'normal' => '#64748b' + }.freeze + FALLBACK_PRIORITY_COLOR = '#64748b' + + CONTENT_LIMIT = 120 # 消息内容截断长度(展开前) + TIME_CHARS = 19 # ISO8601 截到秒:2026-09-15T08:30:00 + THREAD_FALLBACK_LABEL = '(未分组)' + + attr_reader :pending_count, :processing_count + + def initialize(agent, mailbox, fallback_name: nil) + @agent = agent.is_a?(Hash) ? agent : {} + @pending = bucket(mailbox, :pending) + @processing = bucket(mailbox, :processing) + @fallback_name = fallback_name + @pending_count = @pending.size + @processing_count = @processing.size + end + + # agents 列表缺该 endpoint 时回落到启动参数名 + def name + field(@agent, :name) || @fallback_name.to_s + end + + # 非空段拼接(如 "scripted · gpt-5"),全缺时视图显示兜底文案 + def driver_model + [field(@agent, :driver), field(@agent, :model)] + .map { |v| v.to_s.strip }.reject(&:empty?) + end + + # => { text:, color: };无 agent 数据(视作待命)也给徽标,头部不塌 + def status_badge + status = field(@agent, :last_status) + return IDLE_BADGE if status.nil? || status.to_s.empty? + + STATUS_BADGES.fetch(status.to_sym) do + { text: status.to_s, color: FALLBACK_BADGE_COLOR } + end + end + + # processing 段:视图置顶高亮渲染,组内按时间升序 + def processing_messages + sort_by_time(@processing) + end + + # 会话流主体:pending 消息按 thread_id 分组的可折叠卡片列表, + # 组间按最新活动倒序、组内按时间升序 + # => [{ id:, label:, latest:, messages: [...] }] + def threads + @pending + .group_by { |msg| thread_label(msg) } + .map do |label, msgs| + { id: label, label: label, latest: latest_time(msgs), + messages: sort_by_time(msgs) } + end + .sort_by { |t| t[:latest] }.reverse + end + + def kind_badge(msg) + kind = field(msg, :kind).to_s + { text: kind.empty? ? '?' : kind, + color: KIND_COLORS.fetch(kind, FALLBACK_KIND_COLOR) } + end + + def priority_badge(msg) + priority = field(msg, :priority).to_s + { text: priority.empty? ? 'normal' : priority, + color: PRIORITY_COLORS.fetch(priority, FALLBACK_PRIORITY_COLOR) } + end + + def route_text(msg) + "#{field(msg, :from)} → #{field(msg, :to)}" + end + + def message_key(msg) + field(msg, :message_id).to_s + end + + def time_text(msg) + field(msg, :created_at).to_s[0, TIME_CHARS] + end + + # 内容截断可展开 => [展示文本, 截断中?, 展开中?] + def content_view(msg, expanded) + text = field(msg, :content).to_s + return [text, false, false] if text.length <= CONTENT_LIMIT + return [text, false, true] if expanded + + ["#{text[0, CONTENT_LIMIT]}…", true, false] + end + + private + + # 键约定:agent/mailbox 外层为 symbol 键、消息为 string 键; + # 渲染防御——两种读取都容忍 + def field(hash, key) + hash[key] || hash[key.to_s] + end + + def bucket(mailbox, key) + Array(field(mailbox || {}, key)) + end + + def thread_label(msg) + label = field(msg, :thread_id).to_s + label.empty? ? THREAD_FALLBACK_LABEL : label + end + + def sort_by_time(msgs) + msgs.sort_by { |m| field(m, :created_at).to_s } + end + + def latest_time(msgs) + msgs.map { |m| field(m, :created_at).to_s }.max.to_s + end + end + + # ── App 薄壳 ──────────────────────────────────────── + + app_id :agentos_agent + app_title 'Agent' + app_icon '🤖' + singleton false + default_geometry { { x: 80, y: 60, w: 540, h: 580 } } + + state :draft, default: '' # 输入框内容(受控 text_input) + state :priority, default: :normal # 发送优先级::normal / :urgent + state :level, default: :result # 披露层级::result / :process / :trace + state(:collapsed_threads) { [] } # 折叠中的 thread_id 列表(块初始化防共享) + state(:expanded_msgs) { [] } # 展开长内容的消息 id 列表 + + LEVELS = { result: '结果', process: '过程', trace: '完整轨迹' }.freeze + PRIORITIES = { normal: '常规', urgent: '加急' }.freeze + + NO_LINK_TEXT = 'AgentOS 连接未启动' + NO_ENDPOINT_TEXT = '未指定目标 Agent:请经启动参数 endpoint 打开' \ + '(launch(:agentos_agent, endpoint: "agent-1"))' + TRACE_PLACEHOLDER = '执行轨迹(阶段二经 WS 推送)' + + PAGE = { width: '100%', height: '100%', background: '#1e1e2e', + color: '#e2e8f0', padding: '12px', gap: 10 }.freeze + PLACEHOLDER = { padding: 14, color: '#94a3b8', font_size: 13 }.freeze + HEADER = { align_items: 'center', gap: 8, padding_bottom: 8, + border_bottom: '1px solid #313244' }.freeze + HEADER_META = { align_items: 'center', gap: 6, font_size: 12, + color: '#64748b' }.freeze + CONVERSATION = { flex: 1, min_height: 0, overflow_y: 'auto', gap: 10 }.freeze + SECTION_TITLE = { font_size: 12, color: '#94a3b8', font_weight: 'bold' }.freeze + EMPTY_TEXT = { font_size: 12, color: '#64748b', padding: 6 }.freeze + CARD = { gap: 4, border_radius: 6, padding: 8 }.freeze + CARD_ROW = { align_items: 'center', gap: 6 }.freeze + ROUTE = { font_size: 11, color: '#94a3b8' }.freeze + CONTENT = { font_size: 12, line_height: 1.5, white_space: 'pre-wrap', + word_break: 'break-all', color: '#cbd5e1' }.freeze + TIME = { font_size: 11, color: '#64748b', margin_left: 'auto', + white_space: 'nowrap' }.freeze + LINK_BTN = { border: 'none', background: 'transparent', color: '#60a5fa', + font_size: 11, cursor: 'pointer', padding: 0, + align_self: 'flex-start' }.freeze + THREAD = { gap: 4, border_radius: 8, background: '#262636', + border: '1px solid #313244' }.freeze + THREAD_HEAD = { display: 'flex', align_items: 'center', gap: 6, + width: '100%', background: 'transparent', border: 'none', + cursor: 'pointer', padding: '7px 9px', + text_align: 'left', color: '#e2e8f0' }.freeze + PROCESSING_BOX = { gap: 6, border_radius: 8, padding: 8, + background: '#2a2140', border: '1px solid #a855f7' }.freeze + LEVELS_ROW = { align_items: 'center', gap: 6 }.freeze + TRACE_BOX = { gap: 4, border_radius: 8, padding: 8, + background: '#242438', border: '1px dashed #334155' }.freeze + INPUT_ROW = { align_items: 'center', gap: 8, padding_top: 8, + border_top: '1px solid #313244' }.freeze + INPUT = { flex: 1, background: '#262636', color: '#e2e8f0', + border: '1px solid #334155', border_radius: 6, + padding: '7px 9px', font_size: 13, outline: 'none' }.freeze + SEND_BTN = { padding: '7px 14px', font_size: 13, border: 'none', + border_radius: 6, background: '#10b981', color: '#ffffff', + cursor: 'pointer' }.freeze + + def view + stack(css_class: 'aos-agent', style: PAGE) do + link = current_link + if link.nil? + label(style: PLACEHOLDER) { NO_LINK_TEXT } + elsif endpoint.empty? + endpoint_prompt(link) + else + render_body(link) + end + nil + end + end + + # 发送:空输入忽略;交给 LinkService 构造信封(人永远不和协议打交道) + def send_current + content = draft.to_s.strip + return if content.empty? + + link = current_link + return unless link + + link.send_message(to: endpoint, content: content, kind: :ask, priority: priority) + self.draft = '' + end + + def toggle_thread(id) + self.collapsed_threads = if collapsed_threads.include?(id) + collapsed_threads - [id] + else + collapsed_threads + [id] + end + end + + def toggle_message(id) + self.expanded_msgs = if expanded_msgs.include?(id) + expanded_msgs - [id] + else + expanded_msgs + [id] + end + end + + private + + # LinkService 未加载(包接线外的裸渲染/单测)与 instance 为 nil 同等待遇 + def current_link + defined?(AgentOSDesk::LinkService) ? AgentOSDesk::LinkService.instance : nil + end + + def endpoint + return '' unless argv + + (argv[:endpoint] || argv['endpoint']).to_s + end + + def read_signal(service, name) + value = service.public_send(name) + value.respond_to?(:get) ? value.get : value + end + + # 无 endpoint:提示选择,并列出当前在线 Agent 帮忙挑(只读渲染, + # 开窗动作归 AppRegistry,视图内不得 launch——F6 守卫) + def endpoint_prompt(link) + stack(css_class: 'aos-no-endpoint', gap: 6) do + label(style: PLACEHOLDER) { NO_ENDPOINT_TEXT } + names = Array(read_signal(link, :agents)).filter_map do |a| + a.is_a?(Hash) ? (a[:name] || a['name']).to_s : nil + end + label(style: EMPTY_TEXT) do + names.any? ? "在线 Agent:#{names.join('、')}" : '当前无在线 Agent' + end + nil + end + end + + def render_body(link) + presenter = Presenter.new(find_agent(link), find_mailbox(link), + fallback_name: endpoint) + header(presenter, read_signal(link, :offline), read_signal(link, :last_error)) + conversation(presenter) + level_switch + trajectory_area + input_bar + end + + def find_agent(link) + Array(read_signal(link, :agents)).find do |a| + a.is_a?(Hash) && (a[:name] || a['name']).to_s == endpoint + end + end + + def find_mailbox(link) + boxes = read_signal(link, :mailboxes) + boxes.is_a?(Hash) ? boxes[endpoint] : nil + end + + # ① 头部:agent 名 + last_status 徽标 +(离线提示)+ mailbox 计数 + driver/model + def header(presenter, offline, last_error) + row(css_class: 'aos-header', style: HEADER) do + label(style: { font_size: 16, font_weight: 'bold', color: '#f8fafc', + white_space: 'nowrap' }) { "🤖 #{presenter.name}" } + badge(**presenter.status_badge) + badge(text: '观测端离线', color: '#ef4444') if offline + box(style: { flex: 1 }) { nil } + label(style: { font_size: 12, color: '#94a3b8', white_space: 'nowrap' }) do + "待处理 #{presenter.pending_count} · 处理中 #{presenter.processing_count}" + end + nil + end + row(css_class: 'aos-header-meta', style: HEADER_META) do + label do + presenter.driver_model.any? ? presenter.driver_model.join(' · ') : 'driver/model 未知' + end + label(style: { color: '#ef4444' }) { "(#{last_error.to_s[0, 60]})" } \ + if offline && !last_error.to_s.empty? + nil + end + end + + # ② 会话流:processing 置顶高亮 + pending 按 thread 分组的折叠卡片列表 + def conversation(presenter) + stack(css_class: 'aos-flow', style: CONVERSATION) do + processing = presenter.processing_messages + processing_block(processing, presenter) if processing.any? + threads = presenter.threads + if threads.empty? + label(style: EMPTY_TEXT) { processing.any? ? '队列中暂无其他消息' : '暂无消息' } + else + threads.each { |thread| thread_card(thread, presenter) } + end + nil + end + end + + def processing_block(messages, presenter) + stack(css_class: 'aos-processing', style: PROCESSING_BOX) do + label(style: { font_size: 12, color: '#c084fc', font_weight: 'bold' }) do + "🔄 处理中(#{messages.size})" + end + messages.each { |msg| message_card(presenter, msg, highlight: true) } + nil + end + end + + def thread_card(thread, presenter) + stack(css_class: 'aos-thread', style: THREAD) do + collapsed = collapsed_threads.include?(thread[:id]) + button(on_click: -> { toggle_thread(thread[:id]) }, style: THREAD_HEAD) do + label(style: { white_space: 'pre', color: '#c084fc' }) { collapsed ? '▸' : '▾' } + label(style: { font_size: 12, color: '#e2e8f0' }) { thread[:label] } + box(style: { flex: 1 }) { nil } + label(style: ROUTE) { "#{thread[:messages].size} 条" } + label(style: TIME.merge(margin_left: 0)) { thread[:latest][0, Presenter::TIME_CHARS] } + nil + end + thread[:messages].each { |msg| message_card(presenter, msg) } unless collapsed + nil + end + end + + def message_card(presenter, msg, highlight: false) + stack(css_class: 'aos-msg', style: card_style(highlight)) do + row(style: CARD_ROW) do + badge(**presenter.kind_badge(msg)) + badge(**presenter.priority_badge(msg)) + label(style: TIME) { presenter.time_text(msg) } + nil + end + label(style: ROUTE) { presenter.route_text(msg) } + text, truncated, expanded = + presenter.content_view(msg, expanded_msgs.include?(presenter.message_key(msg))) + label(style: CONTENT) { text } + if truncated + button(on_click: -> { toggle_message(presenter.message_key(msg)) }, + style: LINK_BTN) { '展开' } + elsif expanded + button(on_click: -> { toggle_message(presenter.message_key(msg)) }, + style: LINK_BTN) { '收起' } + end + nil + end + end + + def badge(text:, color:) + label(style: { display: 'inline-block', padding: '1px 7px', border_radius: 8, + font_size: 11, line_height: 1.6, color: '#ffffff', + background: color, white_space: 'nowrap' }) { text } + end + + def card_style(highlight) + CARD.merge( + background: highlight ? '#31273f' : '#242438', + border: highlight ? '1px solid #a855f7' : '1px solid #334155' + ) + end + + def choice_btn_style(active) + { padding: '3px 10px', font_size: 12, border: '1px solid #334155', + border_radius: 6, cursor: 'pointer', + background: active ? '#334155' : 'transparent', + color: active ? '#f8fafc' : '#94a3b8' } + end + + # ③ 披露层级切换(三档 state)+ 轨迹占位区(结果档收起) + def level_switch + row(css_class: 'aos-levels', style: LEVELS_ROW) do + label(style: SECTION_TITLE) { '披露层级' } + LEVELS.each do |key, text| + button(on_click: -> { self.level = key }, + style: choice_btn_style(level == key)) { text } + end + nil + end + end + + def trajectory_area + return if level == :result + + stack(css_class: 'aos-trace', style: TRACE_BOX) do + label(style: SECTION_TITLE) { level == :trace ? '完整轨迹' : '执行轨迹' } + label(style: { font_size: 12, color: '#64748b' }) { TRACE_PLACEHOLDER } + nil + end + end + + # ④ 底部互动条:受控输入框 + 优先级切换 + 发送 + def input_bar + row(css_class: 'aos-input', style: INPUT_ROW) do + text_input(value: signal(:draft), autofocus: true, + placeholder: "发消息给 #{endpoint},Enter 发送", + on_enter: -> { send_current }, style: INPUT) + priority_switch + button(on_click: -> { send_current }, style: SEND_BTN) { '发送' } + nil + end + end + + def priority_switch + row(css_class: 'aos-priority', + style: { gap: 0, border: '1px solid #334155', border_radius: 6, + overflow: 'hidden' }) do + PRIORITIES.each do |key, text| + button(on_click: -> { self.priority = key }, + style: choice_btn_style(priority == key) + .merge(border: 'none', border_radius: 0)) { text } + end + nil + end + end + end +end diff --git a/examples/apps/agentos/src/inbox_window.rb b/examples/apps/agentos/src/inbox_window.rb new file mode 100644 index 0000000..80580b0 --- /dev/null +++ b/examples/apps/agentos/src/inbox_window.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module AgentOSDesk + # 收件箱窗口(设计文档 §3.3):AgentOS Human Endpoint 的窗口化—— + # 人话卡片流 + 待回答质询橙色高亮 + 内联回答框。 + # 结构照 Calculator 模板:Cards 纯逻辑内核(不透明 Hash → 投影结构, + # CRuby 可测、不触 UI)+ App 薄壳(只渲染与派发事件)。 + # 数据一律经 LinkService.instance 的只读 signal(见 src/link.rb 契约), + # 本文件零 IO、零 Opal/JS。被包 entry(src/main.rb)require,自身不 require; + # LinkService 未加载/未启动时渲染占位文案,裸 Citrine.render(无 boot)也不崩。 + class InboxWindow < Emerald::App + app_id :agentos_inbox + app_title '收件箱' + app_icon '📥' + singleton true + default_geometry { { x: 200, y: 120, w: 500, h: 540 } } + + # ── 纯逻辑内核:不透明 Hash → 投影结构,全字段防御 ────────── + + # 投影结构:字段已归一为 String(缺失即 ''),view 直接渲染、 + # 不再触碰原始 Hash;显示不了的字段由 view 跳过。 + Card = Struct.new(:icon, :title, :body, :from, :time) + Question = Struct.new(:id, :from, :body, :time) + + class Cards + TIME_KEYS = %w[at created_at time timestamp].freeze + BODY_KEYS = %w[body content text message].freeze + TITLE_KEYS = %w[title subject].freeze + QUESTION_BODY_KEYS = %w[content body text title].freeze + # kind → 图标兜底(对齐 AgentOS Human::CARD_ICONS;'icon' 字段优先) + KIND_ICONS = { + 'question' => '❓', 'result' => '📊', + 'alert' => '🔔', 'progress' => '⏳' + }.freeze + DEFAULT_ICON = '✉️' + + class << self + # 单卡投影:cards 元素是不透明 Hash(Observer 侧已 string 键化), + # 逐字段挑最可能承载文本的键;非 Hash 元素或字段缺失一律回落。 + def project(card) + return Card.new(DEFAULT_ICON, '', '', '', '') unless card.is_a?(Hash) + + kind = card['kind'].to_s + icon = pick(card, ['icon']) + icon = KIND_ICONS.fetch(kind, DEFAULT_ICON) if icon.empty? + Card.new(icon, pick(card, TITLE_KEYS), pick(card, BODY_KEYS), + pick(card, ['from']), time_key(card)) + end + + # 质询投影:LinkCore 保证 'id'/'from' 存在,其余键仍防御提取; + # 缺 'id' 的元素是脏数据,返回 nil 由 view 跳过。 + def project_question(q) + return nil unless q.is_a?(Hash) + + id = q['id'] + return nil if id.nil? || id.to_s.empty? + + Question.new(id.to_s, pick(q, ['from']), pick(q, QUESTION_BODY_KEYS), time_key(q)) + end + + # 按时间倒序:主键是时间字符串(ISO 8601 字典序即时间序), + # 缺失归一为 '' 恒排最后;平手以原下标决胜(Ruby sort 不稳定)。 + def sort_desc(cards) + Array(cards).each_with_index.sort do |(a, ai), (b, bi)| + cmp = time_key(b) <=> time_key(a) + cmp.zero? ? ai <=> bi : cmp + end.map(&:first) + end + + # 时间排序键:候选键取第一个非空值;非 String 值 to_s 后比较 + def time_key(card) + return '' unless card.is_a?(Hash) + + pick(card, TIME_KEYS) + end + + private + + # 候选键依次取第一个非空值(nil 或纯空白都算缺失),全部缺失给 '' + def pick(hash, keys) + keys.each do |k| + v = hash[k] + next if v.nil? + + s = v.to_s + return s unless s.strip.empty? + end + '' + end + end + end + + # ── 样式(内联 snake_case,渲染边界各自归一)──────────────── + + ROOT_STYLE = { + width: '100%', height: '100%', padding: '12px', + background: '#ffffff', color: '#1f2937', overflow_y: 'auto', + font_family: 'ui-sans-serif, system-ui, sans-serif', font_size: 13 + }.freeze + BADGE_BASE = { + border_radius: 999, padding: '1px 9px', color: '#ffffff', + font_size: 12, font_weight: 700, min_width: '18px', text_align: 'center' + }.freeze + BADGE_IDLE = BADGE_BASE.merge(background: '#9ca3af').freeze # 0 条:灰 + BADGE_HOT = BADGE_BASE.merge(background: '#ea580c').freeze # 有质询:橙红 + TITLE_STYLE = { font_size: 16, font_weight: 700 }.freeze + SECTION_TITLE_STYLE = { font_size: 12, font_weight: 600, color: '#6b7280' }.freeze + PLACEHOLDER_STYLE = { padding: '24px 12px', color: '#9ca3af', text_align: 'center' }.freeze + EMPTY_STYLE = { color: '#9ca3af', font_size: 12 }.freeze + + QUESTION_STYLE = { + background: '#fff7ed', border: '1px solid #fb923c', + border_radius: 8, padding: '8px 10px', gap: '6px' + }.freeze + QUESTION_BODY_STYLE = { white_space: 'pre-wrap', word_break: 'break-word' }.freeze + QUESTION_META_STYLE = { font_size: 11, color: '#b45309' }.freeze + ANSWER_INPUT_STYLE = { + flex: 1, min_width: 0, border: '1px solid #fdba74', border_radius: 6, + padding: '4px 8px', font_size: 12, outline: 'none', background: '#ffffff' + }.freeze + ANSWER_BUTTON_STYLE = { + border: 'none', border_radius: 6, padding: '5px 12px', + background: '#ea580c', color: '#ffffff', cursor: 'pointer', font_size: 12 + }.freeze + + CARD_STYLE = { + background: '#f8fafc', border: '1px solid #e2e8f0', + border_radius: 8, padding: '8px 10px', gap: '8px', align_items: 'flex-start' + }.freeze + CARD_ICON_STYLE = { font_size: 16, line_height: '20px' }.freeze + CARD_TITLE_STYLE = { font_weight: 600 }.freeze + CARD_BODY_STYLE = { white_space: 'pre-wrap', word_break: 'break-word' }.freeze + CARD_META_STYLE = { font_size: 11, color: '#6b7280' }.freeze + + # ── view ──────────────────────────────────────────── + + def view + stack(css_class: 'agentos-inbox', gap: 12, style: ROOT_STYLE) do + if link + header + question_section + card_stream + else + label(css_class: 'agentos-inbox-placeholder', style: PLACEHOLDER_STYLE) { 'AgentOS 连接未启动' } + end + nil + end + end + + private + + # 数据中枢访问点。契约:窗口一律经 LinkService.instance 读数据; + # 实例为 nil(Service 未启动)或常量尚未加载(裸渲染 / 单测只 require + # 本文件)时一律视为未连接,view 渲染占位而不是崩溃。 + def link + defined?(AgentOSDesk::LinkService) ? AgentOSDesk::LinkService.instance : nil + end + + # 顶部:未回答质询计数徽标(0 灰 / >0 橙红)+ 标题 + def header + count = link&.pending_questions&.get&.size || 0 + row(css_class: 'agentos-inbox-header', gap: 8, style: { align_items: 'center' }) do + label(css_class: 'agentos-inbox-badge', style: count > 0 ? BADGE_HOT : BADGE_IDLE) { count.to_s } + label(css_class: 'agentos-inbox-title', style: TITLE_STYLE) { '收件箱' } + nil + end + end + + # 待回答质询区:每条渲染橙色高亮卡片(正文/来自/时间,字段缺失防御跳过) + def question_section + svc = link + questions = Array(svc && svc.pending_questions.get).map { |q| Cards.project_question(q) }.compact + stack(css_class: 'agentos-inbox-questions', gap: 6) do + label(css_class: 'agentos-inbox-section-title', style: SECTION_TITLE_STYLE) { "待回答质询(#{questions.size})" } + if questions.empty? + label(css_class: 'agentos-inbox-empty', style: EMPTY_STYLE) { '暂无待回答的质询' } + else + questions.each { |q| question_card(q) } + end + nil + end + end + + def question_card(q) + stack(css_class: 'agentos-inbox-question', key: "q-#{q.id}", gap: 6, style: QUESTION_STYLE) do + unless q.body.empty? + label(css_class: 'agentos-inbox-question-body', style: QUESTION_BODY_STYLE) { q.body } + end + row(css_class: 'agentos-inbox-question-meta', gap: 10, style: QUESTION_META_STYLE) do + label { "来自 #{q.from}" } unless q.from.empty? + label { q.time } unless q.time.empty? + nil + end + answer_row(q.id) + nil + end + end + + # 回答输入行:每个质询一把独立输入(keyed_signal 按 id 懒建), + # 回车或点提交均可;提交即 answer_question 并清空输入, + # 质询卡片消失等下一轮轮询刷新 signal 后自然撤下。 + def answer_row(id) + row(css_class: 'agentos-inbox-answer', gap: 6, style: { align_items: 'center' }) do + text_input(css_class: 'agentos-inbox-answer-input', + value: keyed_signal(:answer, id), placeholder: '输入回答…', + on_enter: -> { submit_answer(id) }, style: ANSWER_INPUT_STYLE) + button(css_class: 'agentos-inbox-answer-submit', on_click: -> { submit_answer(id) }, + style: ANSWER_BUTTON_STYLE) { '提交' } + nil + end + end + + def submit_answer(id) + svc = link + return unless svc + + input = keyed_signal(:answer, id) + text = input.peek.to_s.strip # 事件回调里取快照即可,不建立订阅 + return if text.empty? + + svc.answer_question(id, text) + input.set('') + end + + # 卡片流:cards 按时间倒序投影渲染(元素不透明,显示不了的字段跳过) + def card_stream + svc = link + cards = Cards.sort_desc(svc ? svc.cards.get : []) + stack(css_class: 'agentos-inbox-cards', gap: 6) do + label(css_class: 'agentos-inbox-section-title', style: SECTION_TITLE_STYLE) { "卡片流(#{cards.size})" } + if cards.empty? + label(css_class: 'agentos-inbox-empty', style: EMPTY_STYLE) { '暂无卡片' } + else + cards.each_with_index { |card, i| card_row(card, i) } + end + nil + end + end + + def card_row(card, index) + view = Cards.project(card) + key = card.is_a?(Hash) ? (card['message_id'] || card['id'] || "card-#{index}") : "card-#{index}" + row(css_class: 'agentos-inbox-card', key: key.to_s, gap: 8, style: CARD_STYLE) do + label(css_class: 'agentos-inbox-card-icon', style: CARD_ICON_STYLE) { view.icon } + stack(css_class: 'agentos-inbox-card-main', gap: 2, style: { flex: 1, min_width: 0 }) do + unless view.title.empty? + label(css_class: 'agentos-inbox-card-title', style: CARD_TITLE_STYLE) { view.title } + end + unless view.body.empty? + label(css_class: 'agentos-inbox-card-body', style: CARD_BODY_STYLE) { view.body } + end + row(css_class: 'agentos-inbox-card-meta', gap: 10, style: CARD_META_STYLE) do + label { "来自 #{view.from}" } unless view.from.empty? + label { view.time } unless view.time.empty? + nil + end + nil + end + nil + end + end + end +end diff --git a/examples/apps/agentos/src/link.rb b/examples/apps/agentos/src/link.rb new file mode 100644 index 0000000..8f6e1b2 --- /dev/null +++ b/examples/apps/agentos/src/link.rb @@ -0,0 +1,531 @@ +# backtick_javascript: true +# frozen_string_literal: true + +# AgentOS 桌面连线(阶段一:HTTP 轮询版)—— AgentOSDesk::LinkService < Emerald::Service。 +# 桌面侧唯一碰网络的组件:Opal fetch 每 2s 轮询 AgentOS Observer,结果经 +# AgentOSDesk::LinkCore(纯 CRuby 内核)归一化后写入 Citrine::Signal; +# 四个窗口 App 只读 signal(`LinkService.instance.agents.get`,view 块内 .get 即订阅)。 +# entry 在运行时已加载(require 'emerald' 完成)的前提下求值,自身不 require +# (opal -c 会把字面量 require 当静态依赖)。 +# +# 已确认的数据源形状(来源:agentos ruby/lib/agentos/observer.rb + +# test/observer_test.rb @ 2026-09-15): +# GET /api/overview → { 'version','data_dir','checkpoint','git_commit', +# 'history_events','mailbox_recovered', ... }(后附 agents/services 名单,忽略) +# GET /api/config → { 'driver','model','base_url','api_key_env','api_key_set', +# 'max_steps','version','ruby','data_dir' } +# GET /api/agents → { 'agents' => [{ 'name','driver','model','last_status', +# 'mailbox_pending','mailbox_processing' }] }(设计文档写裸数组,实测包一层,两种都收) +# GET /api/services → { 'services' => [{ 'name','running','state','mailbox_pending', +# 'mailbox_processing' }] }(state 值已 inspect 截断为 String) +# GET /api/mailbox?agent=X → { 'endpoint','pending' => [msg], 'processing' => [msg] } +# GET /api/human → { 'name','cards','pending_questions' } +# cards 元素(observer.rb 对值全体 to_s):{ 'message_id','kind','icon','title', +# 'body','from','thread_id','task_id','at' };pending_questions 是 question 的 +# message_id 字符串数组——'from' 需与 cards 按 message_id 关联取得(本内核的归一职责)。 +# POST /api/messages → body { 'to'(必填非空),'content','kind?','thread_id?','task_id?', +# 'priority?','in_reply_to?' };必须以 text/plain 发送以规避 CORS 预检 +# (Observer 不处理 OPTIONS)。 +# kind='answer' 时 Observer 交给 Human#answer:实际回复给 question.from、沿用原 +# thread_id/task_id,in_reply_to = 被回答问题的 message_id(to 仅校验非空、不用于路由)。 +# +# 信号合同(与并行开发共用,键型严格一致): +# agents/services/overview/config —— symbol 键 Hash;mailboxes —— String endpoint 键, +# 内层 :pending/:processing 为 symbol 键;mailbox msg 与 pending_questions 元素 —— string 键。 +module AgentOSDesk + # 纯 CRuby 数据内核:Observer JSON Hash → 内核状态,及 POST body 生成。 + # 零 UI、零 IO、零 Opal,可完整单测。所有 ingest_* 接受 string/symbol 键的 + # 原始 Hash(JSON.parse / 测试夹具均可直喂),缺字段一律防御默认值。 + class LinkCore + # /api/mailbox 消息的确切键集(AgentOS::Message#to_h,observer_test 锁定) + MESSAGE_KEYS = %w[message_id from to thread_id task_id in_reply_to kind content + priority created_at].freeze + + attr_reader :human_name + # 对接规格读口(test/agentos_desk_test.rb 头部锁定):直查内核归一状态, + # 与 snapshot 同源;signal 同步仍只走 LinkService#sync_signals。 + attr_reader :agents, :services, :mailboxes, :cards, :pending_questions, + :overview, :config, :offline, :last_error + + def initialize + clear + end + + # ── 归一入口 ───────────────────────────────────────── + + def ingest_overview(data) + data = pick_hash(data) + @overview = { + version: pick(data, 'version'), + data_dir: pick(data, 'data_dir'), + checkpoint: pick(data, 'checkpoint'), + git_commit: pick(data, 'git_commit'), + history_events: to_int(pick(data, 'history_events')), + mailbox_recovered: pick(data, 'mailbox_recovered') == true + } + self + end + + def ingest_config(data) + data = pick_hash(data) + @config = { + driver: pick(data, 'driver'), + model: pick(data, 'model'), + base_url: pick(data, 'base_url'), + api_key_env: pick(data, 'api_key_env'), + api_key_set: pick(data, 'api_key_set') == true, + max_steps: to_int(pick(data, 'max_steps')) + } + self + end + + def ingest_agents(payload) + @agents = unwrap(payload, 'agents').map { |a| normalize_agent(a) } + prune_mailboxes! + self + end + + def ingest_services(payload) + @services = unwrap(payload, 'services').map { |s| normalize_service(s) } + prune_mailboxes! + self + end + + # /api/human(调用即 drain):cards 透传存储(元素为不透明 string 键 Hash, + # 渲染方防御未知/缺失字段);pending_questions 归一为保证含 'id'/'from' 的 + # string 键 Hash('from' 与 cards 按 message_id 关联;卡片缺失时尽力而为)。 + def ingest_human(data) + data = pick_hash(data) + @human_name = pick(data, 'name') + cards = pick(data, 'cards') + @cards = cards.is_a?(Array) ? cards.dup : [] + raw = pick(data, 'pending_questions') + @pending_questions = normalize_pending_questions(raw.is_a?(Array) ? raw : []) + self + end + + # /api/mailbox?agent=:按响应 'endpoint' 落键(缺省退回参数 name)。 + # 单参调用(name 缺省)时第一参数即信封本身——endpoint 取自信封; + # msg 逐个归一为 MESSAGE_KEYS 的 string 键 Hash(值原样保留)。 + def ingest_mailbox(data, name = nil) + data = pick_hash(data) + endpoint = pick(data, 'endpoint').to_s + endpoint = name.to_s if endpoint.empty? + pending = pick(data, 'pending') + processing = pick(data, 'processing') + @mailboxes[endpoint] = { + pending: (pending.is_a?(Array) ? pending : []).map { |m| normalize_message(m) }, + processing: (processing.is_a?(Array) ? processing : []).map { |m| normalize_message(m) } + } + self + end + + # ── 连接状态(由 Service 侧轮询结果驱动)────────────── + + def mark_online + @offline = false + @last_error = nil + self + end + + def mark_offline(error) + @offline = true + @last_error = error.to_s + self + end + + # ── 查询 ──────────────────────────────────────────── + + # 已知 endpoint 名单(agents + services 的 name)——轮询 mailbox 的名单。 + def endpoint_names + (@agents + @services).map { |e| e[:name].to_s }.uniq + end + + # 按 id 查待回答质询(answer_question 用来解析发问人);=> Hash 或 nil。 + def pending_question(id) + @pending_questions.find { |q| q['id'] == id.to_s } + end + + # POST /api/messages 的 JSON body:to/content 恒在,其余仅在非 nil 时写入。 + # kind/priority 缺省即人话动作 :ask/:normal;:ask 在 post_kind 翻译为线 + # 格式 'task'(Observer 的 Message::KINDS 不认 :ask,直发会被判 400)。 + def build_post_body(to:, content:, kind: :ask, priority: :normal, thread_id: nil, + task_id: nil, in_reply_to: nil) + body = { 'to' => to.to_s, 'content' => content.to_s } + body['kind'] = post_kind(kind) unless kind.nil? + body['priority'] = priority.to_s unless priority.nil? + body['thread_id'] = thread_id.to_s unless thread_id.nil? + body['task_id'] = task_id.to_s unless task_id.nil? + body['in_reply_to'] = in_reply_to.to_s unless in_reply_to.nil? + Emerald::Pkg::Json.generate(body) + end + + # 内核状态只读快照:{ agents:, services:, mailboxes:, cards:, pending_questions:, + # overview:, config:, offline:, last_error: }。顶层 mailboxes 为浅拷贝(防外部 + # 改穿容器);内层结构与 signal 共享——消费方(窗口 view)只读。 + def snapshot + { + agents: @agents, services: @services, mailboxes: @mailboxes.dup, + cards: @cards, pending_questions: @pending_questions, + overview: @overview, config: @config, + offline: @offline, last_error: @last_error + } + end + + private + + def clear + @agents = [] + @services = [] + @mailboxes = {} + @cards = [] + @pending_questions = [] + @overview = {} + @config = {} + @human_name = nil + @offline = true # 尚未成功建立过连接即视为离线 + @last_error = nil + end + + # symbol / string 键兼容取值(JSON.parse 产 string 键,测试夹具常用 symbol 键) + def pick(hash, key) + hash[key] || hash[key.to_sym] + end + + def pick_hash(data) + data.is_a?(Hash) ? data : {} + end + + # 实测 API 把列表包在 key 里({ 'agents' => [...] });合同文档另有裸数组写法,两种都收。 + def unwrap(payload, key) + list = pick(payload, key) if payload.is_a?(Hash) + list = payload if list.nil? && payload.is_a?(Array) + list.is_a?(Array) ? list : [] + end + + def normalize_agent(raw) + raw = pick_hash(raw) + status = pick(raw, 'last_status') + { + name: pick(raw, 'name').to_s, + driver: pick(raw, 'driver'), + model: pick(raw, 'model'), + last_status: status.nil? || status.to_s.empty? ? nil : status.to_s.to_sym, + mailbox_pending: to_int(pick(raw, 'mailbox_pending')), + mailbox_processing: to_int(pick(raw, 'mailbox_processing')) + } + end + + def normalize_service(raw) + raw = pick_hash(raw) + state = pick(raw, 'state') + { + name: pick(raw, 'name').to_s, + running: pick(raw, 'running') == true, + state: state.is_a?(Hash) ? state : {}, + mailbox_pending: to_int(pick(raw, 'mailbox_pending')), + mailbox_processing: to_int(pick(raw, 'mailbox_processing')) + } + end + + def normalize_message(raw) + raw = pick_hash(raw) + MESSAGE_KEYS.to_h { |k| [k, pick(raw, k)] } + end + + # pending_questions 归一:API 给的是 question message_id 数组(observer_test 锁定), + # 防御 Hash 元素('id'/'message_id')。保证每个元素含 string 键 'id' 与 'from'; + # 其余字段(content/thread_id/task_id/at)仅在对应卡片存在时带上,渲染方防御缺失。 + def normalize_pending_questions(list) + list.map do |item| + id = item.is_a?(Hash) ? (pick(item, 'id') || pick(item, 'message_id')).to_s : item.to_s + card = @cards.find { |c| c.is_a?(Hash) && (pick(c, 'message_id') || '').to_s == id } + from = card_value(card, 'from') || (item.is_a?(Hash) ? pick(item, 'from') : nil) + # 'from' 键恒在、值可 nil(卡片缺失的质询无发问人可查;消费方 to_s 兜底) + q = { 'id' => id, 'from' => from } + %w[thread_id task_id].each do |k| + v = card_value(card, k) + q[k] = v unless v.nil? + end + body = card_value(card, 'body') || card_value(card, 'content') + q['content'] = body unless body.nil? + # 时间键两种形状都收:Observer 人话卡片用 'at',原始 Message 用 'created_at' + at = card_value(card, 'at') || card_value(card, 'created_at') + q['at'] = at unless at.nil? + q + end + end + + def card_value(card, key) + card.is_a?(Hash) ? pick(card, key) : nil + end + + # 人话动作 → Observer 线格式:窗口互动条统一传 :ask,而 AgentOS + # Message::KINDS 只有 task/question/answer/result/alert/progress/stop—— + # :ask 翻译为 'task',其余 kind 原样字符串化。 + def post_kind(kind) + kind.respond_to?(:to_sym) && kind.to_sym == :ask ? 'task' : kind.to_s + end + + # 消失的 endpoint 其 mailbox 一并清掉(名单取当前 agents+services); + # 轮询顺序 agents → services → mailboxes,同轮内先清后拉,无空窗。 + def prune_mailboxes! + known = endpoint_names + @mailboxes.delete_if { |endpoint, _| !known.include?(endpoint) } + end + + def to_int(v) + v.respond_to?(:to_i) ? v.to_i : 0 # nil.to_i = 0;"3"→3;防御非数值 + end + end + + # AgentOS 连线服务:activation on_startup;运行期单例(activate 登记、 + # deactivate 清除——窗口一律经 LinkService.instance 读,instance 为 nil 时 + # 由窗口渲染"AgentOS 连接未启动"占位,与本服务无耦合)。 + class LinkService < Emerald::Service + activation on_startup: true + + # 数据源:AgentOS Observer 默认地址(PLAN:可配置常量);集成阶段可在 + # activate 前改写 LinkService.base_url(activate 时快照进实例)。 + DEFAULT_BASE_URL = 'http://127.0.0.1:4470' + # 轮询间隔(毫秒) + POLL_INTERVAL_MS = 2000 + + class << self + # 运行期单例:窗口 App 的数据入口。 + attr_accessor :instance + + attr_writer :base_url + + def base_url + @base_url ||= DEFAULT_BASE_URL + end + end + + # 只读 signal(Citrine::Signal):窗口经 LinkService.instance..get 读, + # view 块内 .get 即建立订阅、数据更新自动重渲染;写入只归本服务。 + attr_reader :agents, :services, :mailboxes, :cards, :pending_questions, + :overview, :config, :offline, :last_error, :core + + def initialize(core: LinkCore.new) + @core = core + @agents = Citrine::Signal.new([]) + @services = Citrine::Signal.new([]) + @mailboxes = Citrine::Signal.new({}) + @cards = Citrine::Signal.new([]) + @pending_questions = Citrine::Signal.new([]) + @overview = Citrine::Signal.new({}) + @config = Citrine::Signal.new({}) + @offline = Citrine::Signal.new(true) + @last_error = Citrine::Signal.new(nil) + @polling = false + @timer = nil + end + + def activate(ctx) + super + @base_url = self.class.base_url + self.class.instance = self + start_polling if defined?(Opal) + self + end + + def deactivate + stop_polling if defined?(Opal) + self.class.instance = nil if self.class.instance.equal?(self) + super + end + + # 发消息:POST /api/messages(to 为目标 endpoint 名;kind/priority 缺省 + # :ask/:normal——:ask 在 build_post_body 翻译为线格式 'task',窗口按需 + # 显式传 question/stop 等其余 kind)。 + def send_message(to:, content:, kind: :ask, priority: :normal, + thread_id: nil, task_id: nil, in_reply_to: nil) + post_body @core.build_post_body(to: to, content: content, kind: kind, + priority: priority, thread_id: thread_id, + task_id: task_id, in_reply_to: in_reply_to) + end + + # 回答质询:沿 Observer 的 answer 通路——in_reply_to 指向问题 id,to 取其 + # 发问人(Human#answer 按 in_reply_to 找回原问题,to 仅校验非空、不用于路由; + # 本内核已查不到该问题时退化为 'user',由 Observer 侧再判 400)。 + def answer_question(question_id, content) + from = @core.pending_question(question_id)&.dig('from').to_s + send_message(to: from.empty? ? 'user' : from, content: content, + kind: :answer, in_reply_to: question_id) + end + + # ── 同名 ingest 薄委托(对接规格:宿主/测试可直接喂 Observer envelope + # 驱动 signal,与轮询生产路径同一条归一链路)。每个委托同步一次 signal; + # 生产轮询不走这里(ingest_response 直喂内核、poll_finished 统一 sync)。 + + def ingest_overview(data) + @core.ingest_overview(data) + sync_signals + end + + def ingest_config(data) + @core.ingest_config(data) + sync_signals + end + + def ingest_agents(payload) + @core.ingest_agents(payload) + sync_signals + end + + def ingest_services(payload) + @core.ingest_services(payload) + sync_signals + end + + def ingest_human(data) + @core.ingest_human(data) + sync_signals + end + + def ingest_mailbox(data, name = nil) + @core.ingest_mailbox(data, name) + sync_signals + end + + # 内核 → signal 全量同步。轮询每轮收尾调用;公开以便 CRuby 测试在直接喂内核后 + # 驱动 signal(生产写入路径仍只在本服务内)。 + def sync_signals + snap = @core.snapshot + Citrine::Scheduler.batch do + @agents.set(snap[:agents]) + @services.set(snap[:services]) + @mailboxes.set(snap[:mailboxes]) + @cards.set(snap[:cards]) + @pending_questions.set(snap[:pending_questions]) + @overview.set(snap[:overview]) + @config.set(snap[:config]) + @offline.set(snap[:offline]) + @last_error.set(snap[:last_error]) + end + nil + end + + private + + # ── Opal 适配层:全部 JS 集中在以下方法内,defined?(Opal) 守卫, + # CRuby 下为安全 no-op(测试零网络)。───────────────── + + # POST JSON body;必须 text/plain——Observer 不处理 OPTIONS,默认 + # application/json 会触发 CORS 预检导致整个 POST 失败。 + def post_body(body) + return nil unless defined?(Opal) + + url = "#{@base_url}/api/messages" + %x{ + fetch(#{url}, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: #{body} + }).then(function(r) { + if (!r.ok) { + return r.text().then(function(t) { #{post_failed(`t`)}; }); + } + return null; + }).catch(function(e) { + #{post_failed(`(e && e.message) ? '' + e.message : String(e)`)}; + }) + } + nil + end + + def post_failed(message) + @core.mark_offline("消息发送失败:#{message}") + sync_signals + nil + end + + # 轮询:activate 起、deactivate 停。递归重排——每轮的收尾(poll_finished) + # 里排下一轮,请求天然不重叠;任一请求失败即整轮中止(fetch 对 HTTP 错误 + # 状态不 reject,须查 r.ok 手动抛错),offline=true、记 last_error、 + # 旧数据保留,下一轮重试。 + def start_polling + @polling = true + schedule_tick(0) # 激活立即首轮,之后按 POLL_INTERVAL_MS 循环 + nil + end + + def stop_polling + @polling = false + Beryl::Timer.cancel(@timer) + @timer = nil + nil + end + + def schedule_tick(delay = POLL_INTERVAL_MS) + return nil unless @polling + + @timer = Beryl::Timer.after(delay) { poll_cycle } + nil + end + + # 一轮轮询:依次 overview → config → agents → services → human → 各 endpoint + # mailbox(名单取本轮刚归一的 agents+services)。顺序 await,全部成功才 + # mark_online;catch 到任一出错即 mark_offline。两种结局都同步 signal 并排下一轮。 + def poll_cycle + return nil unless @polling + + base = @base_url.to_s + %x{ + (async function() { + var jf = function(url) { + return fetch(url).then(function(r) { + if (!r.ok) throw new Error('HTTP ' + r.status + ' @ ' + url); + return r.json(); + }); + }; + try { + var ov = await jf(#{base} + '/api/overview'); + #{ingest_response(:overview, `ov`)}; + var cf = await jf(#{base} + '/api/config'); + #{ingest_response(:config, `cf`)}; + var ag = await jf(#{base} + '/api/agents'); + #{ingest_response(:agents, `ag`)}; + var sv = await jf(#{base} + '/api/services'); + #{ingest_response(:services, `sv`)}; + var hu = await jf(#{base} + '/api/human'); + #{ingest_response(:human, `hu`)}; + var names = #{@core.endpoint_names}; + for (var i = 0; i < names.length; i++) { + var mb = await jf(#{base} + '/api/mailbox?agent=' + encodeURIComponent(names[i])); + #{ingest_mailbox_response(`names[i]`, `mb`)}; + } + #{poll_finished(nil)}; + } catch (e) { + #{poll_finished(`(e && e.message) ? ('' + e.message) : String(e)`)}; + } + })(); + } + nil + end + + # JS 原生 JSON → Ruby(Opal 下 from_native 递归转换,CRuby 原样返回)后喂内核。 + def ingest_response(kind, data) + data = Emerald::Storage.from_native(data) + case kind + when :overview then @core.ingest_overview(data) + when :config then @core.ingest_config(data) + when :agents then @core.ingest_agents(data) + when :services then @core.ingest_services(data) + when :human then @core.ingest_human(data) + end + end + + def ingest_mailbox_response(name, data) + @core.ingest_mailbox(Emerald::Storage.from_native(data), name.to_s) + end + + def poll_finished(error) + error.nil? ? @core.mark_online : @core.mark_offline(error.to_s) + sync_signals + schedule_tick + nil + end + end +end diff --git a/examples/apps/agentos/src/main.rb b/examples/apps/agentos/src/main.rb new file mode 100644 index 0000000..bc4eab3 --- /dev/null +++ b/examples/apps/agentos/src/main.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# AgentOS 桌面包 entry(docs/SPEC-package-format.md §4.1)—— +# AgentOSDesk 命名空间的开发期装载点:1 个连线服务 + 4 个窗口 App。 +# +# 装载方式(README「已知限制」): +# - 开发期(examples/desktop.rb / CRuby 单测):require 'emerald' 已完成, +# 本文件经 require_relative 串起五个组件文件; +# - .emz / AppHost 懒加载求值路径下 require_relative 不可用(entry 被 +# Opal.compile 单独编译,包内文件不在 load path)——打包前须合并为 +# 单文件 entry 或扩展 AppHost,见包 README。 +require 'emerald' +require_relative 'link' +require_relative 'agent_window' +require_relative 'service_window' +require_relative 'inbox_window' +require_relative 'world_window' diff --git a/examples/apps/agentos/src/service_window.rb b/examples/apps/agentos/src/service_window.rb new file mode 100644 index 0000000..e1d1c75 --- /dev/null +++ b/examples/apps/agentos/src/service_window.rb @@ -0,0 +1,404 @@ +# frozen_string_literal: true + +# AgentOS Service 窗口(阶段一 · HTTP 轮询版)——每个 Service endpoint 一扇窗。 +# 窗口内容四段(设计文档 emerald-desktop-ui-design-2026-09-15.md §3.2): +# ①头部:service 名 + running 状态圆点(绿/灰)+ mailbox 待处理/处理中计数 +# ②state 面板:服务端 state Hash 渲染为键值表,值超 120 字符截断、点击展开全文 +# ③消息队列:pending / processing 双栏消息卡片(kind 徽标、from→to、内容、时间) +# ④互动条:输入框 + 发送(Service 支持同步应答,kind 固定 :ask)+ normal/urgent 切换 +# 结构照 Calculator 模板:Presenter 纯展示内核(零 UI/信号依赖,CRuby 可测) +# + App 薄壳(只读 LinkService signal、渲染、把动作派回 send_message)。 +# 防御纪律:LinkService 未启动(instance 为 nil)或 endpoint 未上报时渲染占位文案, +# 绝不崩溃——Citrine.render(App.new) 无 boot 也必须可渲染。 +# entry 在运行时已加载(Emerald::App 可用)的前提下求值,自身不 require。 +module AgentOSDesk + class ServiceWindow < Emerald::App + # 纯展示内核:把 LinkService 的原始数据(service 条目 / mailboxes 快照) + # 归一成视图直接可用的结构(状态行、消息卡片、徽标、截断串)。 + # 不碰 Citrine 信号与元素 DSL,输入输出全是纯 Ruby 值,可脱离 UI 完整单测。 + class Presenter + STATE_VALUE_LIMIT = 120 # state 值截断上限(字符),点击展开全文 + + # 状态圆点:running 绿 / 其余(含条目缺失)灰 + DOT_RUNNING = '#10b981' + DOT_OTHER = '#9ca3af' + + # kind → [徽标文字, 底色];未知 kind 兜底原样展示(防御 Observer 新增类型) + KIND_BADGES = { + 'ask' => ['询问', '#0ea5e9'], + 'task' => ['任务', '#7c3aed'], + 'answer' => ['回答', '#10b981'], + 'question' => ['质询', '#f59e0b'], + 'event' => ['事件', '#64748b'], + 'control' => ['控制', '#ef4444'] + }.freeze + FALLBACK_BADGE_COLOR = '#475569' + + attr_reader :endpoint + + def initialize(endpoint) + @endpoint = endpoint.to_s + end + + # services 数组里定位本窗口的 endpoint;元素类型不符 / 缺 name 键一律跳过 + def service_entry(services) + return nil if @endpoint.empty? + + Array(services).find do |svc| + svc.is_a?(Hash) && pick(svc, :name).to_s == @endpoint + end + end + + def running?(service) + service.is_a?(Hash) && pick(service, :running) == true + end + + def dot_color(service) + running?(service) ? DOT_RUNNING : DOT_OTHER + end + + # 状态文字用线格式词(设计文档 §3.2 标题栏 `⚙️ bash · ● running`, + # 与 AgentWindow 渲染 last_status 原词同口径);条目缺失时诚实显示"未知" + def status_text(service) + return '未知' unless service.is_a?(Hash) + + running?(service) ? 'running' : 'stopped' + end + + # mailbox 计数 [pending, processing](缺键 / nil 一律 0) + def mailbox_counts(service) + return [0, 0] unless service.is_a?(Hash) + + [pick(service, :mailbox_pending).to_i, pick(service, :mailbox_processing).to_i] + end + + # service 条目 → [[键, 值字符串], ...];state 为 nil / 非 Hash / 空 → [] + def state_rows(service) + return [] unless service.is_a?(Hash) + + state = pick(service, :state) + return [] unless state.is_a?(Hash) && state.any? + + state.map { |key, value| [key.to_s, value.nil? ? '—' : value.to_s] } + end + + def long_value?(text) + text.to_s.length > STATE_VALUE_LIMIT + end + + # 截断展示:expanded 给全文;未展开截到 LIMIT 并加省略号。 + # 返回 [显示串, 是否被截断] + def state_value(text, expanded) + return [text, false] if expanded || !long_value?(text) + + ["#{text[0, STATE_VALUE_LIMIT]}…", true] + end + + # mailboxes → [pending 卡片数组, processing 卡片数组](msg 防御缺字段) + def queue_cards(mailboxes) + box = (mailboxes || {})[@endpoint] + box = {} unless box.is_a?(Hash) + [pick(box, :pending), pick(box, :processing)].map do |msgs| + Array(msgs).select { |m| m.is_a?(Hash) }.map { |m| message_card(m) } + end + end + + # string 键 msg(契约:message_id/from/to/thread_id/task_id/in_reply_to/ + # kind/content/priority/created_at)→ 视图卡片 Hash + def message_card(msg) + kind = msg['kind'].to_s + badge = KIND_BADGES[kind] || [kind.empty? ? '消息' : kind, FALLBACK_BADGE_COLOR] + { + kind_label: badge[0], + kind_color: badge[1], + from: presence(msg['from']), + to: presence(msg['to']), + content: msg['content'].to_s, + time: format_time(msg['created_at']), + urgent: msg['priority'].to_s == 'urgent' + } + end + + # created_at 可能是 epoch 数 / ISO 字符串 / nil,统一成可展示串 + def format_time(value) + case value + when Numeric then Time.at(value).strftime('%Y-%m-%d %H:%M:%S') + when String then value.empty? ? '—' : value + else '—' + end + rescue RangeError, ArgumentError + value.to_s + end + + private + + # 契约内层键为 symbol(如 :running / :pending),防御 string 键混入 + def pick(hash, key) + hash[key].nil? ? hash[key.to_s] : hash[key] + end + + def presence(value) + text = value.to_s + text.empty? ? '—' : text + end + end + + app_id :agentos_service + app_title 'Service' + app_icon '⚙️' + singleton false + default_geometry { { x: 140, y: 100, w: 480, h: 460 } } + + state :input, default: '' # 互动条草稿 + state :priority, default: :normal # :normal / :urgent 切换 + + MONO = 'ui-monospace, Menlo, monospace' + + ROOT_STYLE = { width: '100%', height: '100%', padding: '12px', + background: '#ffffff', color: '#111827', font_size: 13, + overflow: 'auto' }.freeze + HEADER_STYLE = { align_items: 'center', padding_bottom: 8, + border_bottom: '1px solid #e5e7eb' }.freeze + DOT_STYLE = { width: 10, height: 10, border_radius: '50%', flex_shrink: 0 }.freeze + TITLE_STYLE = { font_size: 16, font_weight: 700 }.freeze + STATUS_STYLE = { color: '#6b7280', font_size: 12 }.freeze + SPACER_STYLE = { flex: 1 }.freeze + COUNT_STYLE = { color: '#6b7280', font_size: 12, white_space: 'nowrap' }.freeze + OFFLINE_STYLE = { background: '#fef2f2', color: '#b91c1c', font_size: 12, + border: '1px solid #fecaca', border_radius: 6, + padding: '6px 8px' }.freeze + SECTION_CAPTION_STYLE = { color: '#6b7280', font_size: 12, font_weight: 700 }.freeze + PANEL_STYLE = { border: '1px solid #e5e7eb', border_radius: 8, padding: 8 }.freeze + EMPTY_STYLE = { color: '#9ca3af' }.freeze + STATE_KEY_STYLE = { width: 140, flex_shrink: 0, color: '#6b7280', + font_family: MONO, font_size: 12, word_break: 'break-all' }.freeze + STATE_VALUE_STYLE = { flex: 1, min_width: 0, font_family: MONO, font_size: 12, + white_space: 'pre-wrap', word_break: 'break-all' }.freeze + # 超长值渲染成 button(点击展开/收起),视觉与纯文本一致 + STATE_VALUE_BUTTON_STYLE = STATE_VALUE_STYLE.merge( + background: 'transparent', border: 'none', padding: 0, + text_align: 'left', color: '#111827', cursor: 'pointer' + ).freeze + QUEUE_STYLE = { flex: 1, min_height: 0 }.freeze + QUEUE_COLUMNS_STYLE = { flex: 1, min_height: 0, align_items: 'flex-start' }.freeze + QUEUE_LIST_STYLE = { overflow: 'auto', max_height: 260 }.freeze + COLUMN_TITLE_STYLE = { color: '#374151', font_size: 12, font_weight: 700 }.freeze + CARD_STYLE = { border: '1px solid #e5e7eb', border_radius: 6, + padding: '6px 8px', background: '#f9fafb' }.freeze + CARD_HEAD_STYLE = { align_items: 'center' }.freeze + CARD_META_STYLE = { color: '#6b7280', font_size: 11 }.freeze + CARD_CONTENT_STYLE = { font_size: 12, white_space: 'pre-wrap', + word_break: 'break-all' }.freeze + TIME_STYLE = { color: '#9ca3af', font_size: 11, white_space: 'nowrap' }.freeze + URGENT_BADGE_STYLE = { background: '#fee2e2', color: '#b91c1c', font_size: 11, + padding: '1px 6px', border_radius: 4 }.freeze + BADGE_BASE = { color: '#ffffff', font_size: 11, padding: '1px 6px', + border_radius: 4, white_space: 'nowrap' }.freeze + COMPOSE_STYLE = { align_items: 'center', padding_top: 8, + border_top: '1px solid #e5e7eb' }.freeze + INPUT_STYLE = { flex: 1, min_width: 0, padding: '6px 8px', font_size: 13, + border: '1px solid #d1d5db', border_radius: 6 }.freeze + SEND_STYLE = { padding: '6px 14px', border: 'none', border_radius: 6, font_size: 13, + background: '#10b981', color: '#ffffff', cursor: 'pointer' }.freeze + PRIORITY_NORMAL_STYLE = { padding: '6px 10px', border: '1px solid #d1d5db', + border_radius: 6, background: '#ffffff', color: '#374151', + font_size: 13, cursor: 'pointer' }.freeze + PRIORITY_URGENT_STYLE = { padding: '6px 10px', border: '1px solid #b91c1c', + border_radius: 6, background: '#ef4444', color: '#ffffff', + font_size: 13, cursor: 'pointer' }.freeze + PLACEHOLDER_STYLE = { flex: 1, align_items: 'center', + justify_content: 'center' }.freeze + PLACEHOLDER_TITLE_STYLE = { color: '#6b7280', font_size: 14, font_weight: 700 }.freeze + PLACEHOLDER_HINT_STYLE = { color: '#9ca3af', font_size: 12 }.freeze + + def view + stack(style: ROOT_STYLE, gap: 10) do + link = link_service + if link.nil? + placeholder('AgentOS 连接未启动', 'AgentOSDesk Link Service 未启动') + else + service = presenter.service_entry(read(link.services)) + header_row(service) + offline_banner(read(link.last_error)) if read(link.offline) + if service + state_panel(service) + queue_section(read(link.mailboxes)) + else + placeholder("未找到 Service endpoint:#{display_endpoint}", + '等待 Observer 上报服务列表…') + end + compose_bar + end + end + end + + # ── 互动动作(按钮 / 回车绑定;测试可直接驱动,对齐 calculator #press 惯例)── + + # 优先级切换:normal ↔ urgent + def toggle_priority + self.priority = priority == :urgent ? :normal : :urgent + end + + # 发送草稿:Service 支持同步应答,kind 固定 :ask;发完清空输入。 + # 连接未启动 / 空草稿 / endpoint 缺失时静默忽略,绝不崩溃 + def submit_draft + link = link_service + text = input.to_s.strip + return if link.nil? || text.empty? || endpoint.empty? + + link.send_message(to: endpoint, content: text, kind: :ask, priority: priority) + self.input = '' + end + + private + + # LinkService 契约入口:类未加载(integration 前的单文件渲染)或 instance + # 为 nil(Service 未激活)一律视为未启动,view 走占位文案 + def link_service + AgentOSDesk::LinkService.instance if defined?(AgentOSDesk::LinkService) + end + + # endpoint 名来自 launch 的 argv;无 boot / 未传参时容忍为 '' + def endpoint + args = argv + (args && (args[:endpoint] || args['endpoint'])).to_s + end + + def display_endpoint + endpoint.empty? ? '(未指定)' : endpoint + end + + # endpoint 随窗口生命周期不变(launch 时注入),可安全 memoize + def presenter + @presenter ||= Presenter.new(endpoint) + end + + # LinkService 读口是 Citrine::Signal(view 内 .get 即订阅);实现侧若直接 + # 返回值也兼容(对齐 WorldWindow#read 的防御口径) + def read(source) + source.respond_to?(:get) ? source.get : source + end + + # ── ①头部:状态圆点 + 名 + 状态文字 + mailbox 计数 ────────── + def header_row(service) + pending, processing = presenter.mailbox_counts(service) + row(style: HEADER_STYLE, gap: 8) do + box(style: DOT_STYLE.merge(background: presenter.dot_color(service))) + label(style: TITLE_STYLE) { display_endpoint } + label(style: STATUS_STYLE) { presenter.status_text(service) } + box(style: SPACER_STYLE) + label(style: COUNT_STYLE) { "待处理 #{pending} · 处理中 #{processing}" } + end + end + + # 离线降级(设计 §4):offline 置位时窗口内提示并保留最后数据 + def offline_banner(last_error) + detail = last_error.to_s + box(style: OFFLINE_STYLE) do + label { detail.empty? ? '观测端离线' : "观测端离线:#{detail}" } + end + end + + # ── ②state 面板:键值表,超长值点击展开 ───────────────────── + def state_panel(service) + stack(style: PANEL_STYLE, gap: 6) do + label(style: SECTION_CAPTION_STYLE) { 'state' } + rows = presenter.state_rows(service) + if rows.empty? + label(style: EMPTY_STYLE) { '(空)' } + else + rows.each { |key, value| state_row(key, value) } + end + end + end + + def state_row(key, value) + expanded = keyed_signal(:state_expanded, key) { false }.get + row(gap: 8) do + label(style: STATE_KEY_STYLE) { key } + if presenter.long_value?(value) + text, = presenter.state_value(value, expanded) + button(on_click: -> { toggle_state_key(key) }, style: STATE_VALUE_BUTTON_STYLE) do + expanded ? "#{text}(收起)" : "#{text}(展开)" + end + else + label(style: STATE_VALUE_STYLE) { value } + end + end + end + + def toggle_state_key(key) + sig = keyed_signal(:state_expanded, key) { false } + sig.set(!sig.peek) + end + + # ── ③消息队列:pending / processing 双栏卡片 ─────────────── + def queue_section(mailboxes) + pending, processing = presenter.queue_cards(mailboxes) + stack(style: QUEUE_STYLE, gap: 6) do + label(style: SECTION_CAPTION_STYLE) { '消息队列' } + row(style: QUEUE_COLUMNS_STYLE, gap: 8) do + queue_column('待处理', pending) + queue_column('处理中', processing) + end + end + end + + def queue_column(title, cards) + stack(gap: 6, style: { flex: 1, min_height: 0 }) do + label(style: COLUMN_TITLE_STYLE) { "#{title}(#{cards.size})" } + stack(style: QUEUE_LIST_STYLE, gap: 6) do + if cards.empty? + label(style: EMPTY_STYLE) { '(空)' } + else + cards.each { |card| message_card_node(card) } + end + end + end + end + + def message_card_node(card) + box(style: CARD_STYLE, gap: 4) do + row(style: CARD_HEAD_STYLE, gap: 6) do + span(style: badge_style(card[:kind_color])) { card[:kind_label] } + span(style: URGENT_BADGE_STYLE) { '紧急' } if card[:urgent] + box(style: SPACER_STYLE) + span(style: TIME_STYLE) { card[:time] } + end + label(style: CARD_META_STYLE) { "#{card[:from]} → #{card[:to]}" } + label(style: CARD_CONTENT_STYLE) { card[:content] } + end + end + + def badge_style(color) + BADGE_BASE.merge(background: color) + end + + # ── ④互动条:受控输入 + 优先级切换 + 发送(kind 固定 :ask)── + def compose_bar + row(style: COMPOSE_STYLE, gap: 8) do + text_input(value: signal(:input), on_enter: -> { submit_draft }, + placeholder: "给 #{display_endpoint} 发消息…", style: INPUT_STYLE) + button(on_click: -> { toggle_priority }, style: priority_style) { priority_text } + button(on_click: -> { submit_draft }, style: SEND_STYLE) { '发送' } + end + end + + def priority_text + priority == :urgent ? '紧急' : '普通' + end + + def priority_style + priority == :urgent ? PRIORITY_URGENT_STYLE : PRIORITY_NORMAL_STYLE + end + + def placeholder(title, hint) + box(style: PLACEHOLDER_STYLE) do + stack(gap: 4, style: { align_items: 'center' }) do + label(style: PLACEHOLDER_TITLE_STYLE) { title } + label(style: PLACEHOLDER_HINT_STYLE) { hint } + end + end + end + end +end diff --git a/examples/apps/agentos/src/world_window.rb b/examples/apps/agentos/src/world_window.rb new file mode 100644 index 0000000..993898d --- /dev/null +++ b/examples/apps/agentos/src/world_window.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +module AgentOSDesk + # World 总览窗口(设计文档 §3.4:桌面自检口):AgentOS 运行时的整体面貌—— + # 观测端在线状态横幅、总览/配置键值区、Registry 名单(每个 Agent/Service + # 一行,行内「开窗」按钮经 ctx[:launcher] 拉起对应 endpoint 的专属窗口)。 + # + # 窗口是状态的投影(emerald D10):全部数据经 LinkService.instance 的只读 + # signal 读取,本类零 IO、无 Opal 依赖(纯 CRuby 可渲染可测);LinkService + # 未激活(instance 为 nil,如未启动直接渲染)时输出占位文案,任何情况下 + # 不得崩溃。 + class WorldWindow < Emerald::App + app_id :agentos_world + app_title 'AgentOS' + app_icon '🌐' + singleton true + default_geometry { { x: 60, y: 40, w: 560, h: 620 } } + + # 观测端地址展示串(与 LinkCore 默认地址一致;可配置常量在 LinkCore 侧) + OBSERVER_ADDRESS = '127.0.0.1:4470' + + # 缺失值展示兜底 + MISSING = '—' + + # AgentOS TaskResult 状态词表 → 状态点颜色(agent.rb;未知/nil 兜底灰) + AGENT_STATUS_COLORS = { + completed: '#10b981', # 完成 → 绿 + failed: '#ef4444', # 失败 → 红 + step_limit: '#f59e0b', # 达到最大步数中止 → 橙 + restart_requested: '#f59e0b', # 请求重启 → 橙 + cancelled: '#9ca3af' # 取消 → 灰 + }.freeze + SERVICE_RUNNING_COLOR = '#10b981' # Service running 状态点(绿) + DOT_FALLBACK_COLOR = '#9ca3af' # 未知/缺失状态点(灰) + + ROOT_STYLE = { + width: '100%', height: '100%', padding: '12px', overflow: 'auto', + font_size: '13px', color: '#1f2937' + }.freeze + PLACEHOLDER_STYLE = { padding: '16px', color: '#6b7280' }.freeze + OFFLINE_BANNER_STYLE = { + padding: '8px 12px', border_radius: '8px', font_weight: '600', + background: '#fef2f2', color: '#b91c1c', border: '1px solid #fecaca' + }.freeze + SECTION_TITLE_STYLE = { + font_weight: '600', font_size: '12px', color: '#6b7280', margin_top: '6px' + }.freeze + KV_ROW_STYLE = { align_items: 'center', gap: '8px' }.freeze + KV_KEY_STYLE = { width: '88px', flex_shrink: '0', color: '#6b7280' }.freeze + KV_VALUE_STYLE = { + flex: 1, font_family: 'ui-monospace, Menlo, monospace', word_break: 'break-all' + }.freeze + BADGE_BASE = { + padding: '1px 8px', border_radius: '10px', font_size: '11px', font_weight: '600' + }.freeze + BADGE_OK_STYLE = BADGE_BASE.merge(background: '#dcfce7', color: '#15803d').freeze + BADGE_ERR_STYLE = BADGE_BASE.merge(background: '#fee2e2', color: '#b91c1c').freeze + ENDPOINT_ROW_STYLE = { + align_items: 'center', gap: '8px', padding: '6px 8px', + border_radius: '8px', background: '#f9fafb', border: '1px solid #e5e7eb' + }.freeze + DOT_STYLE = { width: '8px', height: '8px', border_radius: '50%', flex_shrink: '0' }.freeze + ENDPOINT_NAME_STYLE = { + flex: 1, overflow: 'hidden', text_overflow: 'ellipsis', + white_space: 'nowrap', font_weight: '500' + }.freeze + MAILBOX_STYLE = { color: '#6b7280', font_size: '12px', white_space: 'nowrap' }.freeze + LAUNCH_BUTTON_STYLE = { + padding: '3px 10px', font_size: '12px', cursor: 'pointer', flex_shrink: '0', + border: '1px solid #d1d5db', border_radius: '6px', background: '#ffffff' + }.freeze + UNWIRED_STYLE = { color: '#9ca3af', font_size: '12px', flex_shrink: '0' }.freeze + EMPTY_STYLE = { color: '#9ca3af', font_size: '12px' }.freeze + + def view + stack(css_class: 'aos-world', gap: 10, style: ROOT_STYLE) do + if link_service + offline_banner if offline? + overview_section + registry_section + else + label(css_class: 'aos-world-placeholder', style: PLACEHOLDER_STYLE) { 'AgentOS 连接未启动' } + end + end + end + + private + + # LinkService 单例;未激活(未启动/已停用)时为 nil + def link_service + AgentOSDesk::LinkService.instance + end + + # 读 LinkService 暴露的只读 signal(view 内 .get 建立订阅);实现侧若直接 + # 返回值或尚未就绪(nil)也兼容 + def read(source) + source.respond_to?(:get) ? source.get : source + end + + def offline? + read(link_service.offline) ? true : false + end + + # ── ① 离线横幅(顶部醒目提示,数据保留最后值照常展示)────────── + + def offline_banner + box(css_class: 'aos-world-offline', style: OFFLINE_BANNER_STYLE) do + label { "⚠ 观测端离线(#{OBSERVER_ADDRESS})" } + end + end + + # ── ② 总览/配置键值区 ────────────────────────────────────── + + def overview_section + overview = read(link_service.overview) || {} + config = read(link_service.config) || {} + stack(css_class: 'aos-world-overview', gap: 4) do + section_title('总览') + kv_row('版本', display(overview[:version])) + kv_row('数据目录', display(overview[:data_dir])) + kv_row('Git 提交', short_text(overview[:git_commit])) + kv_row('检查点', display(overview[:checkpoint])) + kv_row('历史事件', display(overview[:history_events])) + kv_row('Mailbox 恢复', bool_text(overview[:mailbox_recovered])) + section_title('配置') + kv_row('驱动', display(config[:driver])) + kv_row('模型', display(config[:model])) + api_key_row(config) + end + end + + # api_key_set 徽标:已设置 → 绿,未设置 → 红 + def api_key_row(config) + set = config[:api_key_set] == true + row(key: 'kv:api_key', css_class: 'aos-world-kv', style: KV_ROW_STYLE) do + label(style: KV_KEY_STYLE) { 'API Key' } + box(css_class: set ? 'aos-badge is-set' : 'aos-badge is-unset', + style: set ? BADGE_OK_STYLE : BADGE_ERR_STYLE) { set ? '已设置' : '未设置' } + end + end + + # ── ③ Registry 名单区(主体)──────────────────────────────── + + def registry_section + agents = read(link_service.agents) || [] + services = read(link_service.services) || [] + stack(css_class: 'aos-world-registry', gap: 4, style: { flex: 1, min_height: '0px' }) do + section_title("Agents(#{agents.size})") + if agents.empty? + label(style: EMPTY_STYLE) { '(无 Agent)' } + else + agents.each { |agent| agent_row(agent) } + end + section_title("Services(#{services.size})") + if services.empty? + label(style: EMPTY_STYLE) { '(无 Service)' } + else + services.each { |service| service_row(service) } + end + end + end + + def agent_row(agent) + return unless agent.is_a?(Hash) + + name = agent[:name].to_s + name = '(未命名)' if name.empty? + endpoint_row('agent', :agentos_agent, '🤖', name, + agent_status_color(agent[:last_status]), mailbox_text(agent)) + end + + def service_row(service) + return unless service.is_a?(Hash) + + name = service[:name].to_s + name = '(未命名)' if name.empty? + dot_color = service[:running] ? SERVICE_RUNNING_COLOR : DOT_FALLBACK_COLOR + endpoint_row('service', :agentos_service, '⚙️', name, dot_color, mailbox_text(service)) + end + + # 名单行:图标 + 名 + 状态点 + mailbox 计数 + 开窗按钮 + def endpoint_row(kind, app_id, icon, name, dot_color, mailbox) + row(key: "#{kind}:#{name}", css_class: 'aos-world-endpoint', style: ENDPOINT_ROW_STYLE) do + label { icon } + label(style: ENDPOINT_NAME_STYLE) { name } + status_dot(dot_color) + label(style: MAILBOX_STYLE) { mailbox } + launch_button(app_id, name) + end + end + + def status_dot(color) + box(css_class: 'aos-status-dot', style: DOT_STYLE.merge(background: color)) + end + + # 「开窗」按钮:launch 只能在事件回调里调用(beryl F6——渲染期调用会同步 + # 重入渲染),渲染期只探 launcher 可用性;不可用则降级为「(未接线)」 + def launch_button(app_id, endpoint) + launcher = current_launcher + if launcher + button(on_click: -> { launcher.launch(app_id, endpoint: endpoint) }, + css_class: 'aos-world-launch', style: LAUNCH_BUTTON_STYLE) { '开窗' } + else + # TODO(集成阶段):ctx[:launcher] 未注入(独立宿主/无 boot 渲染)时降级为 + # 纯文本占位;桌面壳注入 AppRegistry 后本按钮拉起对应 endpoint 专属窗口。 + label(css_class: 'aos-world-unwired', style: UNWIRED_STYLE) { '(未接线)' } + end + end + + # ctx[:launcher] 即 AppRegistry(shell 注入的服务表;ctx['launcher'] 兜底) + def current_launcher + ctx && (ctx[:launcher] || ctx['launcher']) + end + + # ── 展示格式化(纯函数)─────────────────────────────────── + + def agent_status_color(status) + key = status.respond_to?(:to_sym) ? status.to_sym : nil + AGENT_STATUS_COLORS.fetch(key, DOT_FALLBACK_COLOR) + end + + def section_title(text) + label(css_class: 'aos-world-section-title', style: SECTION_TITLE_STYLE) { text } + end + + def kv_row(key, value) + row(key: "kv:#{key}", css_class: 'aos-world-kv', style: KV_ROW_STYLE) do + label(style: KV_KEY_STYLE) { key } + label(style: KV_VALUE_STYLE) { value } + end + end + + def mailbox_text(item) + "待办 #{to_count(item[:mailbox_pending])} · 处理中 #{to_count(item[:mailbox_processing])}" + end + + # 缺失/空值 → '—',其余 to_s + def display(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) ? MISSING : value.to_s + end + + # 长值截断(git commit 等),保留可辨识前缀 + def short_text(value, max = 12) + text = display(value) + text.length > max ? "#{text[0, max]}…" : text + end + + def bool_text(value) + value.nil? ? MISSING : (value ? '是' : '否') + end + + def to_count(value) + value.to_i + rescue StandardError + 0 + end + end +end diff --git a/examples/apps/agentos/test/agentos_desk_test.rb b/examples/apps/agentos/test/agentos_desk_test.rb new file mode 100644 index 0000000..c362611 --- /dev/null +++ b/examples/apps/agentos/test/agentos_desk_test.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +# AgentOS 桌面包单测(阶段一 · HTTP 轮询版):LinkCore 数据归一 + build_post_body、 +# LinkService 生命周期/只读 signal/CRuby 零轮询,四个窗口 App 的 manifest 与渲染冒烟。 +# 纯 CRuby(beryl F5):不启动 Observer、不发起任何真实 HTTP,数据全部内联 fixture。 +# 运行(emerald/ 目录内,集成阶段统一跑,勿单独提前跑——其余 src 文件由并行开发补齐): +# bundle exec ruby -Ilib examples/apps/agentos/test/agentos_desk_test.rb +# +# 本文件同时是并行开发的对接规格(src/link.rb 与四个窗口文件须遵守的约定): +# - AgentOSDesk::LinkCore.new 无参;读口:agents/services/mailboxes/cards/ +# pending_questions/overview/config/offline/last_error;ingest_* 方法吃 Observer +# 原始 envelope(string 键:'agents'/'services'/'endpoint'/'pending'/'processing'/ +# 'cards'/'pending_questions',HTTP 契约锁定于 agentos ruby/test/observer_test.rb)。 +# - LinkCore#build_post_body(to:, content:, kind: :ask, priority: :normal, +# thread_id: nil, task_id: nil, in_reply_to: nil) → JSON 字符串。 +# - AgentOSDesk::LinkService < Emerald::Service:activation on_startup: true; +# activate(ctx) 设 LinkService.instance(deactivate 清 nil);同名 ingest_* 薄委托 +# LinkCore 并同步对应 Citrine::Signal——读口是只读 signal,经 #get 取值; +# CRuby 下轮询与 POST 一律不启动(no-op,IO 仅在 defined?(Opal) 的适配层)。 +# - 窗口 App:AgentWindow/ServiceWindow 经 argv = { endpoint: '' } 指定实例 +# (attr_accessor 直写);LinkService.instance 为 nil 时渲染固定占位文案 +# 「AgentOS 连接未启动」,不得崩溃、不得要求先 boot。 +require 'minitest/autorun' +require 'emerald' +require 'json' +require_relative '../src/link' +require_relative '../src/agent_window' +require_relative '../src/service_window' +require_relative '../src/inbox_window' +require_relative '../src/world_window' + +# 内联 fixture:模拟 Observer 各端点的真实返回形状(string 键 envelope)。 +# pending_questions 在真实 API 里是 message_id 字符串数组,卡片里才有完整消息。 +module AgentOSDeskFixture + AGENTS = { + 'agents' => [ + { 'name' => 'agent-1', 'driver' => 'anthropic', 'model' => 'claude-sonnet-4', + 'last_status' => 'completed', 'mailbox_pending' => 2, 'mailbox_processing' => 1 }, + { 'name' => 'agent-2', 'driver' => 'scripted', 'model' => nil, + 'last_status' => nil, 'mailbox_pending' => 0, 'mailbox_processing' => 0 }, + # 字符串计数:归一须转 Integer(类型转换锁) + { 'name' => 'agent-3', 'driver' => 'scripted', 'model' => nil, + 'last_status' => 'running', 'mailbox_pending' => '3', 'mailbox_processing' => '0' } + ] + }.freeze + + SERVICES = { + 'services' => [ + { 'name' => 'bash', 'running' => true, + 'state' => { 'commands_run' => 3, 'last_error' => nil }, + 'mailbox_pending' => 1, 'mailbox_processing' => 0 }, + # 'false' 是 truthy 字符串:归一须转成 false(running 类型转换锁) + { 'name' => 'cron', 'running' => 'false', 'state' => {}, + 'mailbox_pending' => '0', 'mailbox_processing' => 2 } + ] + }.freeze + + MSG_TASK = { + 'message_id' => 'msg-1', 'from' => 'user', 'to' => 'agent-1', + 'thread_id' => 't-1', 'task_id' => 'task-1', 'in_reply_to' => nil, + 'kind' => 'task', 'content' => '待办任务', 'priority' => 'normal', + 'created_at' => '2026-09-15T10:00:00Z' + }.freeze + + MSG_QUESTION = { + 'message_id' => 'msg-q1', 'from' => 'agent-1', 'to' => 'user', + 'thread_id' => 'ui', 'task_id' => 'task-1', 'in_reply_to' => nil, + 'kind' => 'question', 'content' => '选 A 还是 B?', 'priority' => 'urgent', + 'created_at' => '2026-09-15T10:05:00Z' + }.freeze + + MAILBOX_AGENT = { + 'endpoint' => 'agent-1', 'pending' => [MSG_TASK], 'processing' => [] + }.freeze + + MAILBOX_SERVICE = { + 'endpoint' => 'bash', 'pending' => [], 'processing' => [MSG_QUESTION] + }.freeze + + HUMAN = { + 'name' => 'user', + 'cards' => [ + MSG_QUESTION.merge('icon' => '❓'), + { 'message_id' => 'msg-i1', 'from' => 'agent-1', 'to' => 'user', + 'thread_id' => 'ui', 'task_id' => 'task-1', 'in_reply_to' => nil, + 'kind' => 'info', 'content' => '任务已完成', 'priority' => 'normal', + 'created_at' => '2026-09-15T10:06:00Z', 'icon' => '✅' } + ], + # 'msg-ghost' 无对应卡片:归一后仍须保留该质询,from 给 nil(契约保证 'id'/'from' 键) + 'pending_questions' => %w[msg-q1 msg-ghost] + }.freeze + + OVERVIEW = { + 'version' => '1.2.3', 'data_dir' => '/tmp/agentos-data', + 'checkpoint' => 'cp-2026', 'git_commit' => 'abc1234', + 'history_events' => 42, 'mailbox_recovered' => false, + 'agents' => %w[agent-1 agent-2 agent-3], 'services' => %w[bash cron] + }.freeze + + CONFIG = { + 'driver' => 'anthropic', 'model' => 'claude-sonnet-4', + 'base_url' => 'https://api.anthropic.com', 'api_key_env' => 'ANTHROPIC_API_KEY', + 'api_key_set' => true, 'max_steps' => 40, + 'version' => '1.2.3', 'ruby' => '3.4.0', 'data_dir' => '/tmp/agentos-data' + }.freeze + + # 把全套 fixture 喂给 LinkCore 或 LinkService(同名 ingest 薄委托走同一条路径) + def feed_all(link) + link.ingest_agents(AGENTS) + link.ingest_services(SERVICES) + link.ingest_mailbox(MAILBOX_AGENT) + link.ingest_mailbox(MAILBOX_SERVICE) + link.ingest_human(HUMAN) + link.ingest_overview(OVERVIEW) + link.ingest_config(CONFIG) + end +end + +# ── LinkCore:纯逻辑内核的归一化 ───────────────────────────────────── +class LinkCoreTest < Minitest::Test + include AgentOSDeskFixture + + def setup + @core = AgentOSDesk::LinkCore.new + end + + def test_default_state + assert_equal [], @core.agents + assert_equal [], @core.services + assert_equal({}, @core.mailboxes) + assert_equal [], @core.cards + assert_equal [], @core.pending_questions + assert_nil @core.last_error + assert_includes [true, false], @core.offline + end + + def test_ingest_agents_normalizes_symbol_keys_and_types + @core.ingest_agents(AGENTS) + a1, a2, a3 = @core.agents + [a1, a2, a3].each do |a| + assert_equal %i[driver last_status mailbox_pending mailbox_processing model name], + a.keys.sort + end + assert_equal 'agent-1', a1[:name] + assert_equal 'anthropic', a1[:driver] + assert_equal 'claude-sonnet-4', a1[:model] + assert_equal :completed, a1[:last_status] # 字符串 → Symbol + assert_equal 2, a1[:mailbox_pending] + assert_equal 1, a1[:mailbox_processing] + assert_nil a2[:last_status] # nil 容忍 + assert_equal :running, a3[:last_status] + assert_kind_of Integer, a3[:mailbox_pending] # '3' → 3 + assert_equal 3, a3[:mailbox_pending] + assert_equal 0, a3[:mailbox_processing] + end + + def test_ingest_services_normalizes_running_and_counts + @core.ingest_services(SERVICES) + bash, cron = @core.services + [bash, cron].each do |s| + assert_equal %i[mailbox_pending mailbox_processing name running state], s.keys.sort + end + assert_equal true, bash[:running] + assert_equal({ 'commands_run' => 3, 'last_error' => nil }, bash[:state]) + assert_equal false, cron[:running] # 'false' 字符串 → false + assert_equal 1, bash[:mailbox_pending] + assert_kind_of Integer, cron[:mailbox_pending] # '0' → 0 + assert_equal 2, cron[:mailbox_processing] + end + + def test_ingest_mailbox_buckets_by_endpoint + @core.ingest_mailbox(MAILBOX_AGENT) + @core.ingest_mailbox(MAILBOX_SERVICE) + mbs = @core.mailboxes + assert_equal %w[agent-1 bash], mbs.keys.sort # 外层 endpoint 字符串键 + assert_equal [MSG_TASK], mbs['agent-1'][:pending] # 内层 symbol 键 + assert_equal [], mbs['agent-1'][:processing] + assert_equal [], mbs['bash'][:pending] + assert_equal [MSG_QUESTION], mbs['bash'][:processing] + # msg 保持 string 键原样透传 + assert_equal '待办任务', mbs['agent-1'][:pending].first['content'] + end + + def test_ingest_human_passthrough_cards_and_normalizes_questions + @core.ingest_human(HUMAN) + assert_equal AgentOSDeskFixture::HUMAN['cards'], @core.cards # 透传 + by_id = @core.pending_questions.to_h { |q| [q['id'], q] } + assert_equal %w[msg-ghost msg-q1], by_id.keys.sort + assert_equal 'agent-1', by_id['msg-q1']['from'] # 从卡片查到 from + assert_nil by_id['msg-ghost']['from'] # 卡片缺失也不丢质询 + end + + def test_ingest_overview_and_config_pick_contract_keys + @core.ingest_overview(OVERVIEW) + ov = @core.overview + %i[version data_dir checkpoint git_commit history_events mailbox_recovered] + .each { |k| assert ov.key?(k), "overview 缺键 #{k}" } + assert_equal '1.2.3', ov[:version] + assert_equal 'abc1234', ov[:git_commit] + assert_kind_of Integer, ov[:history_events] + assert_equal false, ov[:mailbox_recovered] + + @core.ingest_config(CONFIG) + cfg = @core.config + %i[driver model base_url api_key_env api_key_set max_steps] + .each { |k| assert cfg.key?(k), "config 缺键 #{k}" } + assert_equal 'anthropic', cfg[:driver] + assert_equal 'ANTHROPIC_API_KEY', cfg[:api_key_env] + assert_equal true, cfg[:api_key_set] + assert_kind_of Integer, cfg[:max_steps] + assert_equal 40, cfg[:max_steps] + end + + def test_build_post_body_roundtrips_with_defaults + parsed = JSON.parse(@core.build_post_body(to: 'agent-1', content: '帮我看看日志')) + assert_equal 'agent-1', parsed['to'] + assert_equal '帮我看看日志', parsed['content'] + # 人话动作 :ask 翻译为 Observer 线格式 'task'(Message::KINDS 无 :ask) + assert_equal 'task', parsed['kind'] + assert_equal 'normal', parsed['priority'] + end + + def test_build_post_body_carries_optional_envelope_fields + parsed = JSON.parse(@core.build_post_body(to: 'agent-1', content: '选 A', + kind: :answer, priority: :urgent, + thread_id: 'ui', task_id: 'task-1', + in_reply_to: 'msg-q1')) + assert_equal 'answer', parsed['kind'] + assert_equal 'urgent', parsed['priority'] + assert_equal 'ui', parsed['thread_id'] + assert_equal 'task-1', parsed['task_id'] + assert_equal 'msg-q1', parsed['in_reply_to'] + end +end + +# ── LinkService:生命周期、单例、只读 signal、CRuby 零轮询 ────────── +class LinkServiceTest < Minitest::Test + include AgentOSDeskFixture + + def teardown + AgentOSDesk::LinkService.instance&.deactivate + end + + def test_activation_declared_on_startup + assert_operator AgentOSDesk::LinkService, :<, Emerald::Service + assert_equal true, AgentOSDesk::LinkService.activation_events[:startup] + end + + def test_activate_sets_instance_and_deactivate_clears + assert_nil AgentOSDesk::LinkService.instance + service = AgentOSDesk::LinkService.new + assert_same service, service.activate({}) + assert_same service, AgentOSDesk::LinkService.instance + service.deactivate + assert_nil AgentOSDesk::LinkService.instance + refute service.activated? + end + + def test_signals_reflect_ingested_fixtures + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + + agents = service.agents.get + assert_equal 3, agents.size + assert_equal :completed, agents.first[:last_status] + + bash, = service.services.get + assert_equal 'bash', bash[:name] + assert_equal true, bash[:running] + + mailboxes = service.mailboxes.get + assert_equal '待办任务', mailboxes['agent-1'][:pending].first['content'] + + assert_equal AgentOSDeskFixture::HUMAN['cards'], service.cards.get + q, = service.pending_questions.get + assert_equal 'msg-q1', q['id'] + assert_equal 'agent-1', q['from'] + + assert_equal 42, service.overview.get[:history_events] + assert_equal false, service.overview.get[:mailbox_recovered] + assert_equal 40, service.config.get[:max_steps] + assert_equal true, service.config.get[:api_key_set] + assert_includes [true, false], service.offline.get + assert_nil service.last_error.get + end + + def test_signals_are_read_only + service = AgentOSDesk::LinkService.new + service.activate({}) + %i[agents services mailboxes cards pending_questions overview config offline last_error] + .each do |name| + assert_kind_of Citrine::Signal, service.public_send(name) + refute service.respond_to?(:"#{name}="), "#{name} 必须是只读 signal" + end + end + + def test_cruby_never_starts_polling_or_post + threads_before = Thread.list + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + # 动作方法在 CRuby 下可安全调用(no-op,不发 IO) + service.send_message(to: 'agent-1', content: 'hi') + service.answer_question('msg-q1', '选 A') + service.deactivate + assert_equal threads_before, Thread.list, 'CRuby 下不得启动轮询线程/定时器' + end +end + +# ── 四个窗口 App:manifest 宏 + 渲染冒烟 ──────────────────────────── +class AgentOSDeskWindowsTest < Minitest::Test + include AgentOSDeskFixture + + WINDOW_CLASSES = [ + AgentOSDesk::AgentWindow, AgentOSDesk::ServiceWindow, + AgentOSDesk::InboxWindow, AgentOSDesk::WorldWindow + ].freeze + + PLACEHOLDER = 'AgentOS 连接未启动' + + def teardown + AgentOSDesk::LinkService.instance&.deactivate + end + + def test_agent_window_manifest + k = AgentOSDesk::AgentWindow + assert_equal :agentos_agent, k.app_id + assert_equal 'Agent', k.app_title + assert_equal '🤖', k.app_icon + assert_equal false, k.singleton + assert_equal({ x: 80, y: 60, w: 540, h: 580 }, k.default_geometry.call) + end + + def test_service_window_manifest + k = AgentOSDesk::ServiceWindow + assert_equal :agentos_service, k.app_id + assert_equal 'Service', k.app_title + assert_equal '⚙️', k.app_icon + assert_equal false, k.singleton + assert_equal({ x: 140, y: 100, w: 480, h: 460 }, k.default_geometry.call) + end + + def test_inbox_window_manifest + k = AgentOSDesk::InboxWindow + assert_equal :agentos_inbox, k.app_id + assert_equal '收件箱', k.app_title + assert_equal '📥', k.app_icon + assert_equal true, k.singleton + assert_equal({ x: 200, y: 120, w: 500, h: 540 }, k.default_geometry.call) + end + + def test_world_window_manifest + k = AgentOSDesk::WorldWindow + assert_equal :agentos_world, k.app_id + assert_equal 'AgentOS', k.app_title + assert_equal '🌐', k.app_icon + assert_equal true, k.singleton + assert_equal({ x: 60, y: 40, w: 560, h: 620 }, k.default_geometry.call) + end + + def test_placeholder_rendering_without_link + assert_nil AgentOSDesk::LinkService.instance + WINDOW_CLASSES.each do |klass| + html = Citrine.render(klass.new) + assert_includes html, PLACEHOLDER, "#{klass} 未渲染占位文案" + end + end + + def test_agent_window_renders_endpoint_and_status + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + inst = AgentOSDesk::AgentWindow.new + inst.argv = { endpoint: 'agent-1' } # attr_accessor 直写启动参数 + html = Citrine.render(inst) + assert_includes html, 'agent-1' + assert_includes html, 'completed' + refute_includes html, PLACEHOLDER + end + + def test_service_window_renders_endpoint_and_status + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + inst = AgentOSDesk::ServiceWindow.new + inst.argv = { endpoint: 'bash' } + html = Citrine.render(inst) + assert_includes html, 'bash' + assert_includes html, 'running' + refute_includes html, PLACEHOLDER + end + + def test_inbox_window_renders_cards_and_questions + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + html = Citrine.render(AgentOSDesk::InboxWindow.new) + assert_includes html, '待回答质询(2)' # 质询区(含无卡片兜底项) + assert_includes html, '选 A 还是 B?' # 质询正文(与 cards 关联归一取得) + assert_includes html, '卡片流(2)' + end + + def test_world_window_renders_overview + service = AgentOSDesk::LinkService.new + service.activate({}) + feed_all(service) + html = Citrine.render(AgentOSDesk::WorldWindow.new) + assert_includes html, '1.2.3' # version + assert_includes html, 'abc1234' # git_commit + end +end diff --git a/examples/desktop.rb b/examples/desktop.rb index d2e0a08..a25bb49 100644 --- a/examples/desktop.rb +++ b/examples/desktop.rb @@ -7,6 +7,31 @@ require 'emerald' require_relative 'apps/calculator/src/main' require_relative 'apps/stickynote/src/main' +require_relative 'apps/agentos/src/main' + +# AgentOS 包 World 窗口「开窗」按钮调 ctx[:launcher].launch:裸 AppRegistry#launch +# 只建实例不开窗(D3,开窗归 DesktopShell#launch_app)。开发期桥接——launch 导向 +# shell.launch_app(建实例 + wm.open + 单例聚焦),其余消息原样委托 registry +# (About 等按 ctx[:launcher].apps 计数的消费者行为不变)。 +class ShellLauncherBridge + def initialize(shell) + @shell = shell + end + + def launch(id, **argv) + @shell.launch_app(id, **argv) + end + + def method_missing(name, *args, **kwargs, &block) + return super unless @shell.registry.respond_to?(name) + + @shell.registry.public_send(name, *args, **kwargs, &block) + end + + def respond_to_missing?(name, include_private = false) + @shell.registry.respond_to?(name, include_private) || super + end +end # 整机入口:DesktopShell 组装壁纸 / 图标网格 / 菜单栏 / 任务栏 / 多窗口与全部系统服务 shell = Emerald::DesktopShell.new @@ -14,6 +39,16 @@ # 落地后改由 /Applications 扫描自动完成 shell.registry.register(Calculator) shell.registry.register(StickyNote) +# AgentOS 桌面包(设计文档 agentos/docs/emerald-desktop-ui-design-2026-09-15.md): +# 四个窗口 App 进注册表;LinkService 进 ServiceHub,并幂等补跑 activate_startup +# (shell 构造内的那次早于本注册)——桌面启动即开轮询,窗口只读其 signal +shell.registry.register(AgentOSDesk::AgentWindow) +shell.registry.register(AgentOSDesk::ServiceWindow) +shell.registry.register(AgentOSDesk::InboxWindow) +shell.registry.register(AgentOSDesk::WorldWindow) +shell.hub.register(AgentOSDesk::LinkService) +shell.hub.activate_startup(shell.services) +shell.services[:launcher] = ShellLauncherBridge.new(shell) Beryl::Renderer.mount_at('app', shell) # 浏览器验收便利(E7):console 里可经 window.EmeraldShell 调安装流