feat: initialize LineUp Tauri reference host

This commit is contained in:
2026-08-03 10:26:27 +08:00
commit 32a425034b
79 changed files with 11460 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/dist/
/node_modules/
/src-tauri/target/
+82
View File
@@ -0,0 +1,82 @@
# LineUp Tauri Reference Host
这是 LineUp Mini Runtime 的第一版 Tauri 2 Reference Host。它独立于当前 `upstream/tangsengdaodao-android/` 快速实验工程,不替换或修改现有 Android 验证路径。
## 已实现的最小闭环
- 可信 Host 前端:AppServer 地址、手机号/验证码登录、退出;
- AppServer 交互:`/login``/messages/send``/messages/sync`
- 用户消息本地即时回显与送达状态;
- HTTP 同步游标:首次 `0`、后续最后序号 `+ 1`
- `lineup.v1.text``agent.status``agent.progress``error` 的基础 Renderer
- `marked + DOMPurify` 的 GFM Markdown 安全渲染;
- Tauri 2 Rust Host、capability 文件与桌面窗口配置。
AppServer 已配置只允许 `tauri://localhost``https://tauri.localhost``http://tauri.localhost` 三个 Tauri Host origin 跨源调用。若产品使用新的 Tauri origin 或独立 Web 客户端,应先在 `lineup-app-server/configs/lineup.yaml``client.allowedOrigins` 显式加入,不能放开任意 Origin。
macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配置了 `NSAllowsArbitraryLoadsInWebContent`。该例外仅作用于 Host 的 WKWebView 内容请求,使其能访问类似 `http://100.x.y.z:8090` 的地址,不放宽 Rust 原生网络层;待所有 AppServer 都迁移到 HTTPS 后应移除。
`ui.open / patch / close``app.call` 已被协议层识别,但目前安全降级为可见提示;它们是下一阶段 Mini Runtime Surface Host 与 Capability Registry 的实现范围,不能在当前 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。
## 本地命令
```bash
cd lineup-app/tauri
npm install
# 日常开发:启动 Vite 热更新 + 未打包的 Tauri 桌面壳
npm run desktop:dev
# 仅检查并构建前端静态文件
npm run build
# Linux:仅构建 Debian 包,避免为不需要的 AppImage 下载额外运行器
npm run tauri -- build --debug --bundles deb
# macOSApple Silicon / Intel):生成 .app 与 .dmg
npm run tauri build -- --debug
```
`npm run desktop:dev` 不生成 `.app``.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`
本项目的 `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 安装在其他位置,可临时覆盖:
```bash
RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug
```
## 本机构建状态(2026-08-03
- `npm run build`:已通过;
- Cargo/Tauri Rust crates:已下载并开始编译;
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化。
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
```bash
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
```
本机已生成的产物:
```text
src-tauri/target/debug/lineup-tauri
src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb
```
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`,其余 PNG/ICO/ICNS 与 Android/iOS 图标由 `npm run tauri icon src-tauri/icons/icon.svg` 生成。
## 目录边界
```text
tauri/
├── src/ # 可信 LineUp Host 前端
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度
│ └── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码
├── src-tauri/ # Tauri Rust Host 与 capability 配置
└── package.json # Vite、Tauri CLI 与前端依赖
```
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16211e" />
<title>LineUp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1455
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "lineup-tauri-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 1420",
"desktop:dev": "./scripts/tauri.sh dev",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"tauri": "./scripts/tauri.sh",
"check:rust": "./scripts/tauri.sh cargo-metadata"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"dompurify": "^3.2.6",
"marked": "^15.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
set -eu
# Tauri CLI launches Cargo itself. Choose a direct Rust toolchain bin first so
# the project works even if a shell has not sourced ~/.cargo/env. The override
# is useful for custom or CI toolchain locations.
if [ -z "${RUST_TOOLCHAIN_BIN:-}" ]; then
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin" ;;
Darwin-arm64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin" ;;
Darwin-x86_64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-x86_64-apple-darwin/bin" ;;
*) RUST_TOOLCHAIN_BIN="" ;;
esac
fi
if [ -n "$RUST_TOOLCHAIN_BIN" ] && [ -d "$RUST_TOOLCHAIN_BIN" ]; then
export PATH="$RUST_TOOLCHAIN_BIN:$PATH"
fi
if [ "${1:-}" = "cargo-metadata" ]; then
exec cargo metadata --manifest-path src-tauri/Cargo.toml --no-deps --format-version 1
fi
exec ./node_modules/.bin/tauri "$@"
+4392
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "lineup-tauri"
version = "0.1.0"
description = "LineUp Tauri Reference Host"
authors = ["LineUp"]
edition = "2021"
[lib]
name = "lineup_tauri_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
The LineUp Reference Host currently connects to a user-configured AppServer
on a private network/Tailscale over HTTP. Restrict this exception to the
WKWebView content process; native Rust networking remains subject to ATS.
Remove it when every AppServer endpoint is served over HTTPS.
-->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the trusted LineUp host frontend.",
"windows": ["main"],
"permissions": ["core:default"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capabilities for the trusted LineUp host frontend.","local":true,"windows":["main"],"permissions":["core:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="LineUp">
<defs>
<linearGradient id="background" x1="44" y1="34" x2="470" y2="488" gradientUnits="userSpaceOnUse">
<stop stop-color="#294c3d"/>
<stop offset="1" stop-color="#101b16"/>
</linearGradient>
<linearGradient id="mark" x1="164" y1="132" x2="351" y2="374" gradientUnits="userSpaceOnUse">
<stop stop-color="#d8fae8"/>
<stop offset="1" stop-color="#86d7b0"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="118" fill="url(#background)"/>
<path d="M256 106c10 72 38 108 108 120-70 12-98 48-108 120-10-72-38-108-108-120 70-12 98-48 108-120Z" fill="url(#mark)"/>
<path d="M354 294c0 58-44 104-98 104-17 0-33-4-47-12l-58 30 16-62c-7-17-11-37-11-60 0-58 45-104 100-104s98 46 98 104Z" fill="#143125" stroke="#9be2bf" stroke-width="14" stroke-linejoin="round"/>
<circle cx="215" cy="294" r="12" fill="#d8fae8"/>
<circle cx="256" cy="294" r="12" fill="#d8fae8"/>
<circle cx="297" cy="294" r="12" fill="#d8fae8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+6
View File
@@ -0,0 +1,6 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running LineUp Tauri host");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
lineup_tauri_lib::run();
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LineUp",
"version": "0.1.0",
"identifier": "com.lineup.client",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "label": "main", "title": "LineUp", "width": 1060, "height": 760, "minWidth": 360, "minHeight": 600 }],
"security": { "csp": "default-src 'self'; connect-src 'self' http: https: ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
},
"bundle": {
"active": true,
"targets": "all",
"macOS": {
"infoPlist": "Info.plist"
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import "./style.css";
import { decodeConversationItem, type ConversationItem, type Envelope } from "./protocol";
type Session = {
api: string;
uid: string;
agentUID: string;
channelID: string;
channelType: number;
lastSeq: number;
active: boolean;
};
type LoginResponse = {
uid: string;
agent_uid?: string;
channel_id?: string;
channel_type?: number;
};
type SyncedMessage = { message_seq: number; from_uid: string; payload: string };
const DEFAULT_API = "http://127.0.0.1:8090";
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
let polling = false;
let thinking: HTMLElement | undefined;
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing");
app.innerHTML = `
<main class="shell">
<section id="login-view" class="login-view">
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
<form id="login-form" class="login-card" novalidate>
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
<p id="login-error" class="error" role="alert"></p>
<button id="login-submit" type="submit">进入 LineUp</button>
</form>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><button id="logout" class="quiet">退出</button></header>
<section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section>
</main>`;
const $ = <T extends Element>(selector: string): T => {
const found = document.querySelector<T>(selector);
if (!found) throw new Error(`Missing element: ${selector}`);
return found;
};
const messages = $<HTMLElement>("#messages");
const loginView = $<HTMLElement>("#login-view");
const chatView = $<HTMLElement>("#chat-view");
const presence = $<HTMLElement>("#presence");
$<HTMLInputElement>("#api").value = session.api;
function api(path: string): string { return `${session.api.replace(/\/$/, "")}${path}`; }
function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; }
function setPresence(value: string): void { presence.textContent = value; }
function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); }
function normalizeAPIAddress(value: string): string {
const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。");
const withProtocol = /^https?:\/\//i.test(candidate) ? candidate : `http://${candidate}`;
let parsed: URL;
try { parsed = new URL(withProtocol); } catch { throw new Error("AppServer 地址格式不正确,例如 http://100.121.118.116:8090。"); }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("AppServer 地址仅支持 http:// 或 https://。");
if (!parsed.hostname) throw new Error("AppServer 地址缺少主机名或 IP 地址。");
return parsed.toString().replace(/\/$/, "");
}
function loginFailureMessage(reason: unknown): string {
if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。";
const message = reason instanceof Error ? reason.message : "";
if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。";
return message || "登录失败,请稍后重试。";
}
function appendBubble(role: "human" | "agent", content: string, markdown = false): HTMLElement {
const row = document.createElement("article"); row.className = `message-row ${role}`;
if (role === "agent") { const avatar = document.createElement("span"); avatar.className = "message-avatar"; avatar.textContent = "✦"; row.append(avatar); }
const bubble = document.createElement("div"); bubble.className = "bubble";
if (markdown) {
bubble.classList.add("markdown");
bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, { FORBID_TAGS: ["style", "form", "input", "button"] });
bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; });
} else bubble.textContent = content;
const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
bubble.append(meta); row.append(bubble); messages.append(row); scrollLatest(); return row;
}
function showThinking(detail = "正在思考…"): void {
thinking?.remove();
thinking = appendBubble("agent", detail);
thinking.classList.add("thinking");
setPresence("正在思考");
}
function hideThinking(): void { thinking?.remove(); thinking = undefined; }
function render(item: ConversationItem): void {
switch (item.kind) {
case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break;
case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break;
case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}${percent}%${item.status ? ` · ${item.status}` : ""}`); break; }
case "error": hideThinking(); addSystem(`Agent 错误:${item.message}`); break;
case "surface": addSystem(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); break;
case "app-call": addSystem("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); break;
case "fallback": addSystem(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); break;
}
}
function decodeBase64(value: string): string {
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
}
async function sendPayload(payload: string): Promise<number | undefined> {
const response = await fetch(api("/messages/send"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload }) });
const body = await response.json().catch(() => ({})) as { error?: string; message_seq?: number };
if (!response.ok) throw new Error(body.error || "消息发送失败");
return body.message_seq;
}
async function syncLoop(): Promise<void> {
if (polling) return;
polling = true;
while (session.active) {
try {
const response = await fetch(api("/messages/sync"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: session.channelID, channel_type: session.channelType, login_uid: session.uid, start_message_seq: session.lastSeq === 0 ? 0 : session.lastSeq + 1, limit: 50 }) });
const body = await response.json().catch(() => ({})) as { error?: string; messages?: SyncedMessage[] };
if (!response.ok) throw new Error(body.error || "同步失败");
for (const message of body.messages ?? []) {
session.lastSeq = Math.max(session.lastSeq, message.message_seq);
if (message.from_uid !== session.uid) render(decodeConversationItem(decodeBase64(message.payload)));
}
} catch {
if (session.active) setPresence("同步中断,正在重试…");
}
await new Promise(resolve => window.setTimeout(resolve, 1500));
}
polling = false;
}
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
event.preventDefault();
const apiInput = $<HTMLInputElement>("#api");
const phone = $<HTMLInputElement>("#phone").value.trim();
const code = $<HTMLInputElement>("#code").value.trim();
const error = $<HTMLElement>("#login-error");
const submit = $<HTMLButtonElement>("#login-submit");
let endpoint: string;
try { endpoint = normalizeAPIAddress(apiInput.value); }
catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; }
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
session.api = endpoint;
localStorage.setItem("lineup.api", session.api);
error.textContent = "正在连接 AppServer…";
submit.disabled = true;
submit.textContent = "正在进入…";
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(api("/login"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, code }), signal: controller.signal });
const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string };
if (!response.ok) throw new Error(body.error || "登录失败");
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
loginView.hidden = true; chatView.hidden = false; addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason); }
finally { window.clearTimeout(timeout); submit.disabled = false; submit.textContent = "进入 LineUp"; }
});
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text || !session.uid) return;
input.value = ""; send.disabled = true;
const row = appendBubble("human", text); const meta = row.querySelector<HTMLElement>(".meta");
try { const sequence = await sendPayload(text); if (sequence) session.lastSeq = Math.max(session.lastSeq, sequence); if (meta) meta.textContent += " · 已发送"; }
catch (reason) { if (meta) meta.textContent += " · 发送失败"; addSystem(reason instanceof Error ? reason.message : "发送失败"); }
finally { send.disabled = false; input.focus(); }
});
$<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 {
return { v: 1, id: `msg_${crypto.randomUUID()}`, type, conversation_id: conversationID, payload };
}
+60
View File
@@ -0,0 +1,60 @@
export const LINEUP_PROTOCOL_VERSION = 1;
export type Envelope = {
v: number;
id?: string;
type: string;
conversation_id?: string;
payload?: unknown;
};
export type ConversationItem =
| { kind: "markdown"; markdown: string; envelope?: Envelope }
| { kind: "agent-status"; status: string; detail: string; envelope?: Envelope }
| { kind: "progress"; title: string; percent: number; status: string; envelope?: Envelope }
| { kind: "error"; message: string; envelope?: Envelope }
| { kind: "surface"; envelope: Envelope }
| { kind: "app-call"; envelope: Envelope }
| { kind: "fallback"; reason: string; envelope?: Envelope };
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function decodeConversationItem(raw: string): ConversationItem {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { kind: "markdown", markdown: raw };
}
const envelope = object(parsed) as Envelope | null;
if (!envelope || envelope.v !== LINEUP_PROTOCOL_VERSION || !envelope.type?.startsWith("lineup.v1.")) {
return { kind: "fallback", reason: "invalid_envelope" };
}
const payload = object(envelope.payload) ?? {};
switch (envelope.type) {
case "lineup.v1.text":
return { kind: "markdown", markdown: text(payload.markdown) || text(payload.text), envelope };
case "lineup.v1.agent.status":
return { kind: "agent-status", status: text(payload.status), detail: text(payload.detail), envelope };
case "lineup.v1.agent.progress":
return { kind: "progress", title: text(payload.title), percent: Number(payload.percent) || 0, status: text(payload.status), envelope };
case "lineup.v1.error":
return { kind: "error", message: text(payload.message) || "Agent 执行失败", envelope };
case "lineup.v1.ui.open":
case "lineup.v1.ui.patch":
case "lineup.v1.ui.close":
return { kind: "surface", envelope };
case "lineup.v1.app.call":
return { kind: "app-call", envelope };
default:
return { kind: "fallback", reason: "unsupported_type", envelope };
}
}
+1
View File
@@ -0,0 +1 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled{opacity:.5}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true
},
"include": ["src"]
}