feat: establish typed runtime protocol model
This commit is contained in:
@@ -4,21 +4,22 @@
|
|||||||
|
|
||||||
当前 Kotlin Android 客户端是 IM、协议、设备接入与交互闭环的快速实验基线;长期目标是在系统 WebView / WebKit 之上构建 LineUp Mini Runtime,为内置 Renderer 与受限第三方小程序 Surface 提供统一的运行环境。
|
当前 Kotlin Android 客户端是 IM、协议、设备接入与交互闭环的快速实验基线;长期目标是在系统 WebView / WebKit 之上构建 LineUp Mini Runtime,为内置 Renderer 与受限第三方小程序 Surface 提供统一的运行环境。
|
||||||
|
|
||||||
## Git 同步约定
|
## Git 与开发验证约定
|
||||||
|
|
||||||
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`,`main` 是 Linux 与 macOS 唯一的共享同步分支。每个可验证的客户端改动都应提交并推送到 `origin/main`;不要再通过复制 `node_modules`、`target` 或打包产物在开发机间同步。
|
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。Git 用于源码版本管理、备份和协作;提交应对应完整、可验证的改动,但**不再作为 Mac 浏览器验证前端改动的传输手段**。
|
||||||
|
|
||||||
macOS 首次获取:
|
日常 Web 联调由 Linux 工作机直接提供可信 Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd lineup-app/tauri
|
||||||
|
npm run web:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app
|
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app
|
||||||
cd ~/Workspace/lineup-app/tauri
|
cd ~/Workspace/lineup-app
|
||||||
npm ci
|
|
||||||
```
|
|
||||||
|
|
||||||
日常同步(工作区无未提交源码改动时):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull --ff-only origin main
|
git pull --ff-only origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -35,7 +35,7 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配
|
|||||||
|
|
||||||
### M0 回归场景
|
### M0 回归场景
|
||||||
|
|
||||||
每次迁移或拆分模块后,至少执行以下场景;其中 `R01` 为构建级检查,其余为 Mac 浏览器开发态人工验收,直至 M0 建立自动化测试。
|
每次迁移或拆分模块后,至少执行以下场景;其中 `R01` 为构建级检查,其余为 Mac 浏览器访问 Linux 开发 Host 的人工验收,直至 M0 建立自动化测试。
|
||||||
|
|
||||||
- **M0-R01:构建。** `npm run build` 通过 TypeScript 检查与 Vite 打包。
|
- **M0-R01:构建。** `npm run build` 通过 TypeScript 检查与 Vite 打包。
|
||||||
- **M0-R02:登录。** 使用 Tailscale AppServer 登录;成功切换到聊天页,错误地址/超时给出可理解提示。
|
- **M0-R02:登录。** 使用 Tailscale AppServer 登录;成功切换到聊天页,错误地址/超时给出可理解提示。
|
||||||
@@ -49,6 +49,16 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配
|
|||||||
|
|
||||||
`src/main.ts` 目前同时包含 App Shell、页面 DOM、登录、直接 HTTP `fetch`、会话 cursor、同步循环、协议到展示的映射和 renderer。M0 的目的不是改变上述用户行为,而是将它们迁入 `app-shell`、`transport`、`conversation-store`、`interaction-kernel` 和 `renderer-registry` 等独立边界;在 M0 准入前不新增 Surface 或 Capability 功能。
|
`src/main.ts` 目前同时包含 App Shell、页面 DOM、登录、直接 HTTP `fetch`、会话 cursor、同步循环、协议到展示的映射和 renderer。M0 的目的不是改变上述用户行为,而是将它们迁入 `app-shell`、`transport`、`conversation-store`、`interaction-kernel` 和 `renderer-registry` 等独立边界;在 M0 准入前不新增 Surface 或 Capability 功能。
|
||||||
|
|
||||||
|
### M0-02 协议模型(已完成)
|
||||||
|
|
||||||
|
`src/protocol.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供:
|
||||||
|
|
||||||
|
- `parseEnvelope(value)`:校验版本、消息类型、`id`、`conversation_id`、sender/target、timestamp 和 JSON payload;
|
||||||
|
- `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`;
|
||||||
|
- `decodeConversationItem(raw)`:保留历史纯文本作为 Markdown 的兼容行为;对于非法 LineUp envelope、未知类型和各类非法 payload,返回受控 fallback,绝不触发执行。
|
||||||
|
|
||||||
|
该文件不依赖 DOM、`fetch`、Tauri 或本地存储。Surface 与 Capability 在本阶段只校验其基础合约并降级显示;不加载 bundle,也不调用能力。下一项 M0-03 才会将此模型接入 Interaction Kernel 和 Renderer Registry。
|
||||||
|
|
||||||
## 本地命令
|
## 本地命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -58,9 +68,12 @@ npm install
|
|||||||
# 日常开发:启动 Vite 热更新 + 未打包的 Tauri 桌面壳
|
# 日常开发:启动 Vite 热更新 + 未打包的 Tauri 桌面壳
|
||||||
npm run desktop:dev
|
npm run desktop:dev
|
||||||
|
|
||||||
# 纯 Web 开发:仅启动 Vite,在浏览器打开 http://127.0.0.1:1420
|
# 纯 Web 开发:Linux 开发机监听全部网卡,供 Mac 浏览器访问
|
||||||
npm run web:dev
|
npm run web:dev
|
||||||
|
|
||||||
|
# Mac 浏览器访问(经 Tailscale)
|
||||||
|
# http://100.121.118.116:1420
|
||||||
|
|
||||||
# 仅检查并构建前端静态文件
|
# 仅检查并构建前端静态文件
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
@@ -73,7 +86,7 @@ npm run tauri build -- --debug
|
|||||||
|
|
||||||
`npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。
|
`npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。
|
||||||
|
|
||||||
`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 已对固定开发 Origin `http://127.0.0.1:1420` 配置 CORS;若改动 Vite 端口或 host,必须同步收紧更新 AppServer 的 `client.allowedOrigins`。
|
`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它监听 `0.0.0.0:1420`,Mac 通过 Tailscale 打开 `http://100.121.118.116:1420`,直接验证 Linux 工作区的 Vite 热更新;不需要为每次验证将源码同步到 Mac。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 仅对固定开发 Origin `http://127.0.0.1:1420` 和 `http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步收紧更新 AppServer 的 `client.allowedOrigins`,不得改为 wildcard。
|
||||||
|
|
||||||
本项目的 `tauri` 与 `check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖:
|
本项目的 `tauri` 与 `check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1 --port 1420",
|
"dev": "vite --host 0.0.0.0 --port 1420",
|
||||||
"web:dev": "npm run dev",
|
"web:dev": "npm run dev",
|
||||||
"desktop:dev": "./scripts/tauri.sh dev",
|
"desktop:dev": "./scripts/tauri.sh dev",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
|||||||
+24
-3
@@ -1,7 +1,13 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import { decodeConversationItem, type ConversationItem, type Envelope } from "./protocol";
|
import {
|
||||||
|
decodeConversationItem,
|
||||||
|
type Actor,
|
||||||
|
type ConversationItem,
|
||||||
|
type Envelope,
|
||||||
|
type JsonObject,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
api: string;
|
api: string;
|
||||||
@@ -232,6 +238,21 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
|||||||
|
|
||||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
||||||
|
|
||||||
export function makeProtocolEvent(type: string, payload: Record<string, unknown>, conversationID: string): Envelope {
|
export function makeProtocolEvent(
|
||||||
return { v: 1, id: `msg_${crypto.randomUUID()}`, type, conversation_id: conversationID, payload };
|
type: string,
|
||||||
|
payload: JsonObject,
|
||||||
|
conversationID: string,
|
||||||
|
sender: Actor,
|
||||||
|
target?: Actor,
|
||||||
|
): Envelope {
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
id: `msg_${crypto.randomUUID()}`,
|
||||||
|
type,
|
||||||
|
conversation_id: conversationID,
|
||||||
|
sender,
|
||||||
|
...(target ? { target } : {}),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+273
-28
@@ -1,32 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* Platform-neutral LineUp v1 protocol model.
|
||||||
|
*
|
||||||
|
* This module deliberately contains no DOM, network, Tauri, or storage code.
|
||||||
|
* It is the boundary between untrusted transport payloads and the trusted
|
||||||
|
* Interaction Kernel introduced in M0-03.
|
||||||
|
*/
|
||||||
export const LINEUP_PROTOCOL_VERSION = 1;
|
export const LINEUP_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
||||||
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type ActorKind = "human" | "agent" | "app" | "system";
|
||||||
|
|
||||||
|
export type Actor = {
|
||||||
|
kind: ActorKind;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A validated LineUp v1 wire envelope. */
|
||||||
export type Envelope = {
|
export type Envelope = {
|
||||||
v: number;
|
v: typeof LINEUP_PROTOCOL_VERSION;
|
||||||
id?: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
conversation_id?: string;
|
conversation_id: string;
|
||||||
payload?: unknown;
|
sender: Actor;
|
||||||
|
target?: Actor;
|
||||||
|
/** ISO-8601 timestamp supplied by the producing adapter or client. */
|
||||||
|
timestamp?: string;
|
||||||
|
payload: JsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeliveryState =
|
||||||
|
| { status: "local_pending"; updated_at: string }
|
||||||
|
| { status: "submitted"; updated_at: string; transport_id?: string }
|
||||||
|
| { status: "delivered"; updated_at: string; transport_id?: string }
|
||||||
|
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-originated action awaiting delivery through the Transport Adapter.
|
||||||
|
* It is data only: the Store owns retries and the Kernel owns state changes.
|
||||||
|
*/
|
||||||
|
export type UserAction =
|
||||||
|
| {
|
||||||
|
kind: "send-text";
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
text: string;
|
||||||
|
created_at: string;
|
||||||
|
delivery: DeliveryState;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "tool-result" | "tool-cancel";
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
call_id: string;
|
||||||
|
payload: JsonObject;
|
||||||
|
created_at: string;
|
||||||
|
delivery: DeliveryState;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "surface-event";
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
event: string;
|
||||||
|
data: JsonObject;
|
||||||
|
created_at: string;
|
||||||
|
delivery: DeliveryState;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "app-result";
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
call_id: string;
|
||||||
|
payload: JsonObject;
|
||||||
|
created_at: string;
|
||||||
|
delivery: DeliveryState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolFallbackCode =
|
||||||
|
| "invalid_json"
|
||||||
|
| "invalid_envelope"
|
||||||
|
| "unsupported_version"
|
||||||
|
| "unsupported_type"
|
||||||
|
| "invalid_payload";
|
||||||
|
|
||||||
|
/** Safe, displayable representation of content that must never be executed. */
|
||||||
|
export type ProtocolFallback = {
|
||||||
|
code: ProtocolFallbackCode;
|
||||||
|
/** A stable, non-sensitive diagnostic intended for logs and the fallback renderer. */
|
||||||
|
detail: string;
|
||||||
|
envelope?: Envelope;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationItemBase = {
|
||||||
|
id?: string;
|
||||||
|
envelope?: Envelope;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationItem =
|
export type ConversationItem =
|
||||||
| { kind: "markdown"; markdown: string; envelope?: Envelope }
|
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
|
||||||
| { kind: "agent-status"; status: string; detail: string; envelope?: Envelope }
|
| (ConversationItemBase & { kind: "markdown"; markdown: string })
|
||||||
| { kind: "progress"; title: string; percent: number; status: string; envelope?: Envelope }
|
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
|
||||||
| { kind: "error"; message: string; envelope?: Envelope }
|
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
|
||||||
| { kind: "surface"; envelope: Envelope }
|
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
||||||
| { kind: "app-call"; envelope: Envelope }
|
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
||||||
| { kind: "fallback"; reason: string; envelope?: Envelope };
|
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
|
||||||
|
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
|
||||||
|
|
||||||
function object(value: unknown): Record<string, unknown> | null {
|
export type EnvelopeParseResult =
|
||||||
|
| { ok: true; envelope: Envelope }
|
||||||
|
| { ok: false; fallback: ProtocolFallback };
|
||||||
|
|
||||||
|
const ACTOR_KINDS = new Set<ActorKind>(["human", "agent", "app", "system"]);
|
||||||
|
const SUPPORTED_TYPES = new Set([
|
||||||
|
"lineup.v1.text",
|
||||||
|
"lineup.v1.agent.status",
|
||||||
|
"lineup.v1.agent.progress",
|
||||||
|
"lineup.v1.error",
|
||||||
|
"lineup.v1.ui.open",
|
||||||
|
"lineup.v1.ui.patch",
|
||||||
|
"lineup.v1.ui.close",
|
||||||
|
"lineup.v1.app.call",
|
||||||
|
]);
|
||||||
|
const ID_MAX_LENGTH = 256;
|
||||||
|
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||||
|
const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/;
|
||||||
|
|
||||||
|
function object(value: unknown): JsonObject | null {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as JsonObject)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isJsonValue(value: unknown, depth = 0): value is JsonValue {
|
||||||
|
if (depth > 32 || value === null || typeof value === "string" || typeof value === "boolean") return depth <= 32;
|
||||||
|
if (typeof value === "number") return Number.isFinite(value);
|
||||||
|
if (Array.isArray(value)) return value.every(entry => isJsonValue(entry, depth + 1));
|
||||||
|
if (value === null || typeof value !== "object") return false;
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return (prototype === Object.prototype || prototype === null)
|
||||||
|
&& Object.values(value).every(entry => isJsonValue(entry, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
function text(value: unknown): string {
|
function text(value: unknown): string {
|
||||||
return typeof value === "string" ? value : "";
|
return typeof value === "string" ? value : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identifier(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 && value.length <= ID_MAX_LENGTH
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActor(value: unknown): Actor | null {
|
||||||
|
const candidate = object(value);
|
||||||
|
const id = candidate && identifier(candidate.id);
|
||||||
|
const kind = candidate?.kind;
|
||||||
|
return id && typeof kind === "string" && ACTOR_KINDS.has(kind as ActorKind)
|
||||||
|
? { kind: kind as ActorKind, id }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallback(code: ProtocolFallbackCode, detail: string, envelope?: Envelope): EnvelopeParseResult {
|
||||||
|
return { ok: false, fallback: { code, detail, envelope } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the shared envelope shape. It intentionally does not dispatch a
|
||||||
|
* message to a renderer or execute any request; that belongs to the Kernel.
|
||||||
|
*/
|
||||||
|
export function parseEnvelope(value: unknown): EnvelopeParseResult {
|
||||||
|
const candidate = object(value);
|
||||||
|
if (!candidate) return fallback("invalid_envelope", "Envelope must be a JSON object.");
|
||||||
|
if (candidate.v !== LINEUP_PROTOCOL_VERSION) return fallback("unsupported_version", "Unsupported LineUp protocol version.");
|
||||||
|
|
||||||
|
const id = identifier(candidate.id);
|
||||||
|
const type = text(candidate.type);
|
||||||
|
const conversationID = identifier(candidate.conversation_id);
|
||||||
|
const sender = parseActor(candidate.sender);
|
||||||
|
const payload = object(candidate.payload);
|
||||||
|
if (!id || !MESSAGE_TYPE.test(type) || !conversationID || !sender || !payload || !isJsonValue(payload)) {
|
||||||
|
return fallback("invalid_envelope", "Missing or invalid required LineUp envelope fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: Actor | undefined;
|
||||||
|
if (candidate.target !== undefined) {
|
||||||
|
target = parseActor(candidate.target) ?? undefined;
|
||||||
|
if (!target) return fallback("invalid_envelope", "Envelope target must be a valid actor.");
|
||||||
|
}
|
||||||
|
if (candidate.timestamp !== undefined && (typeof candidate.timestamp !== "string" || Number.isNaN(Date.parse(candidate.timestamp)))) {
|
||||||
|
return fallback("invalid_envelope", "Envelope timestamp must be an ISO-8601 string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
envelope: {
|
||||||
|
v: LINEUP_PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
conversation_id: conversationID,
|
||||||
|
sender,
|
||||||
|
...(target ? { target } : {}),
|
||||||
|
...(candidate.timestamp !== undefined ? { timestamp: candidate.timestamp as string } : {}),
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a JSON wire payload when callers do not support legacy plain text. */
|
||||||
|
export function parseEnvelopeJSON(raw: string): EnvelopeParseResult {
|
||||||
|
try {
|
||||||
|
return parseEnvelope(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return fallback("invalid_json", "Transport payload is not valid JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSurfacePayload(type: string, payload: JsonObject): boolean {
|
||||||
|
const instanceID = text(payload.instance_id);
|
||||||
|
if (!INSTANCE_ID.test(instanceID)) return false;
|
||||||
|
if (type === "lineup.v1.ui.open") {
|
||||||
|
if (!object(payload.app)) return false;
|
||||||
|
return payload.state === undefined || object(payload.state) !== null;
|
||||||
|
}
|
||||||
|
if (type === "lineup.v1.ui.patch") return object(payload.state) !== null;
|
||||||
|
return type === "lineup.v1.ui.close";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAppCallPayload(payload: JsonObject): boolean {
|
||||||
|
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackItem(value: ProtocolFallback): ConversationItem {
|
||||||
|
return {
|
||||||
|
kind: "fallback",
|
||||||
|
reason: value.code,
|
||||||
|
detail: value.detail,
|
||||||
|
...(value.envelope ? { id: value.envelope.id, envelope: value.envelope } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an untrusted transport payload into a trusted renderer model.
|
||||||
|
* Plain legacy text remains a Markdown item for backwards compatibility;
|
||||||
|
* malformed JSON and invalid LineUp envelopes are always safe fallbacks.
|
||||||
|
*/
|
||||||
export function decodeConversationItem(raw: string): ConversationItem {
|
export function decodeConversationItem(raw: string): ConversationItem {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -34,27 +254,52 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
|||||||
} catch {
|
} catch {
|
||||||
return { kind: "markdown", markdown: raw };
|
return { kind: "markdown", markdown: raw };
|
||||||
}
|
}
|
||||||
const envelope = object(parsed) as Envelope | null;
|
|
||||||
if (!envelope || envelope.v !== LINEUP_PROTOCOL_VERSION || !envelope.type?.startsWith("lineup.v1.")) {
|
const parsedEnvelope = parseEnvelope(parsed);
|
||||||
return { kind: "fallback", reason: "invalid_envelope" };
|
if (!parsedEnvelope.ok) return fallbackItem(parsedEnvelope.fallback);
|
||||||
|
const envelope = parsedEnvelope.envelope;
|
||||||
|
const payload = envelope.payload;
|
||||||
|
const base = { id: envelope.id, envelope };
|
||||||
|
|
||||||
|
if (!SUPPORTED_TYPES.has(envelope.type)) {
|
||||||
|
return fallbackItem({ code: "unsupported_type", detail: `Unsupported LineUp message type: ${envelope.type}`, envelope });
|
||||||
}
|
}
|
||||||
const payload = object(envelope.payload) ?? {};
|
|
||||||
switch (envelope.type) {
|
switch (envelope.type) {
|
||||||
case "lineup.v1.text":
|
case "lineup.v1.text": {
|
||||||
return { kind: "markdown", markdown: text(payload.markdown) || text(payload.text), envelope };
|
const markdown = text(payload.markdown) || text(payload.text);
|
||||||
case "lineup.v1.agent.status":
|
return markdown ? { kind: "markdown", markdown, ...base } : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope });
|
||||||
return { kind: "agent-status", status: text(payload.status), detail: text(payload.detail), envelope };
|
}
|
||||||
case "lineup.v1.agent.progress":
|
case "lineup.v1.agent.status": {
|
||||||
return { kind: "progress", title: text(payload.title), percent: Number(payload.percent) || 0, status: text(payload.status), envelope };
|
const status = text(payload.status);
|
||||||
case "lineup.v1.error":
|
return status
|
||||||
return { kind: "error", message: text(payload.message) || "Agent 执行失败", envelope };
|
? { kind: "agent-status", status, detail: text(payload.detail), ...base }
|
||||||
|
: fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", envelope });
|
||||||
|
}
|
||||||
|
case "lineup.v1.agent.progress": {
|
||||||
|
const percent = payload.percent;
|
||||||
|
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
||||||
|
return fallbackItem({ code: "invalid_payload", detail: "Agent progress payload requires percent between 0 and 100.", envelope });
|
||||||
|
}
|
||||||
|
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...base };
|
||||||
|
}
|
||||||
|
case "lineup.v1.error": {
|
||||||
|
const message = text(payload.message);
|
||||||
|
return message
|
||||||
|
? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base }
|
||||||
|
: fallbackItem({ code: "invalid_payload", detail: "Error payload requires message.", envelope });
|
||||||
|
}
|
||||||
case "lineup.v1.ui.open":
|
case "lineup.v1.ui.open":
|
||||||
case "lineup.v1.ui.patch":
|
case "lineup.v1.ui.patch":
|
||||||
case "lineup.v1.ui.close":
|
case "lineup.v1.ui.close":
|
||||||
return { kind: "surface", envelope };
|
return validSurfacePayload(envelope.type, payload)
|
||||||
|
? { kind: "surface", ...base }
|
||||||
|
: fallbackItem({ code: "invalid_payload", detail: "Surface payload does not match its lifecycle contract.", envelope });
|
||||||
case "lineup.v1.app.call":
|
case "lineup.v1.app.call":
|
||||||
return { kind: "app-call", envelope };
|
return validAppCallPayload(payload)
|
||||||
|
? { kind: "app-call", ...base }
|
||||||
|
: fallbackItem({ code: "invalid_payload", detail: "App call payload requires call_id, capability, and arguments.", envelope });
|
||||||
default:
|
default:
|
||||||
return { kind: "fallback", reason: "unsupported_type", envelope };
|
return fallbackItem({ code: "unsupported_type", detail: `Unsupported LineUp message type: ${envelope.type}`, envelope });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user