fix bot flow

This commit is contained in:
王性驊 2026-09-15 17:07:16 +08:00
parent c7ae29d8d9
commit 824e4026dd
42 changed files with 3775 additions and 374 deletions

View File

@ -40,6 +40,9 @@ LAZYBOY_API_KEY=
# 正式 UI先 `cd web && npm run build`,再指到編譯結果。開發用 Vite 時不必設。
# LAZYBOY_WEB_DIST=web/dist
# 關於畫面與設定裡顯示的版號。改了要重開 Vite 或重新 `npm run build`。
LAZYBOY_VERSION=0.1.0
# =============================================================================
# 公開搜尋 / 抓頁
# =============================================================================

270
Cargo.lock generated
View File

@ -59,6 +59,45 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-compression"
version = "0.4.47"
@ -125,7 +164,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
@ -328,6 +367,26 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
@ -817,6 +876,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazyboy"
version = "0.1.0"
@ -833,19 +898,27 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64",
"chrono",
"encoding_rs",
"futures-util",
"html2text",
"hyper",
"hyper-util",
"libc",
"rcgen",
"regex",
"reqwest",
"rusqlite",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tower 0.4.13",
"tower-http 0.5.2",
"uuid",
]
@ -933,6 +1006,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@ -987,6 +1066,41 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -996,6 +1110,15 @@ dependencies = [
"autocfg",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -1025,6 +1148,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -1070,6 +1203,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -1091,6 +1244,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1242,6 +1401,20 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1310,7 +1483,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http 0.6.11",
"tower-service",
"url",
@ -1355,6 +1528,15 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustls"
version = "0.23.44"
@ -1369,6 +1551,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@ -1668,6 +1859,36 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@ -1755,6 +1976,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
@ -1812,7 +2048,7 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"url",
@ -1836,6 +2072,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
@ -2222,6 +2459,33 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"

View File

@ -1,8 +1,7 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
LABEL lazyboy.box.revision=computer-use-3
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb x11-utils x11vnc novnc websockify imagemagick xdotool \
xvfb x11-utils x11-xserver-utils x11vnc novnc websockify autocutsel xclip imagemagick xdotool \
dbus-x11 xfce4-session xfce4-panel xfwm4 xfdesktop4 xfce4-settings \
xfce4-terminal thunar \
fonts-noto-core fonts-noto-cjk \
@ -22,5 +21,6 @@ RUN chmod +x /usr/local/bin/start-desktop.sh /usr/local/bin/box-chrome
ENV DISPLAY=:1
WORKDIR /workspace
EXPOSE 6080
LABEL lazyboy.box.revision=computer-use-5
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/start-desktop.sh"]

View File

@ -45,7 +45,11 @@ startxfce4 >/tmp/xfce.log 2>&1 &
# Only websockify (same container) talks to VNC; keep the passwordless port
# off the Docker bridge so sibling containers cannot attach. (libvncserver
# still binds [::]:5900, but the container has no routable IPv6 address.)
x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -listen localhost >/tmp/x11vnc.log 2>&1 &
x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -listen localhost -xkb -repeat -noxdamage >/tmp/x11vnc.log 2>&1 &
if command -v autocutsel >/dev/null 2>&1; then
autocutsel -fork -selection CLIPBOARD >/dev/null 2>&1 || true
autocutsel -fork -selection PRIMARY >/dev/null 2>&1 || true
fi
websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 &
echo "[lazyboy-box] desktop up display=$DISPLAY vnc=6080 xfce chrome terminal" >/tmp/start-desktop.log

View File

@ -8,18 +8,26 @@ description = "Minimal GrokBot-like agent core (chat + tools + ReAct)"
[dependencies]
anyhow.workspace = true
axum.workspace = true
base64 = "0.22"
chrono.workspace = true
encoding_rs = "0.8"
futures-util.workspace = true
html2text = "0.17"
hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] }
rcgen = { version = "0.13", features = ["x509-parser"] }
regex = "1"
reqwest.workspace = true
rusqlite = { version = "0.32", features = ["bundled"] }
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] }
rustls-pemfile = "2"
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tokio-tungstenite.workspace = true
tower = { version = "0.4", features = ["util"] }
tower-http.workspace = true
uuid.workspace = true

View File

@ -16,7 +16,7 @@ use tokio::sync::Mutex;
const IMAGE: &str = "lazyboy-box:local";
const CONTAINER: &str = "lazyboy-box";
const VIEWER_PORT: u16 = 6080;
const BOX_REVISION: &str = "computer-use-3";
const BOX_REVISION: &str = "computer-use-5";
const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
/// Per-stream cap on shell output returned to the model; the middle is elided.
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;

View File

@ -19,6 +19,8 @@ mod model;
mod session;
mod tools;
mod web;
mod local_tls;
mod novnc_skin;
mod web_server;
pub use agent::{
@ -42,7 +44,7 @@ pub use model::{
};
pub use session::{
list_sessions, load_or_create, load_session, public_transcript, public_transcript_from_messages,
save_session, session_preview, session_preview_from_messages, sessions_dir, Session,
save_session, session_last_at_from_messages, session_preview, session_preview_from_messages, sessions_dir, Session,
SessionSummary,
};
pub use web_server::{serve_http, serve_web, WebListen};

View File

@ -0,0 +1,365 @@
//! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy.
use anyhow::{Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
IsCa, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct TlsMaterial {
pub ca_pem: String,
pub cert_pem: String,
pub key_pem: String,
pub names: Vec<String>,
pub dir: PathBuf,
}
pub fn tls_enabled() -> bool {
match std::env::var("LAZYBOY_TLS") {
Ok(value) => {
let v = value.trim().to_ascii_lowercase();
!matches!(v.as_str(), "0" | "false" | "no" | "off")
}
Err(_) => true,
}
}
pub fn cert_dir() -> PathBuf {
crate::config::home_config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("certs")
}
pub fn lan_ips() -> Vec<String> {
let mut ips = Vec::new();
for iface in ["en0", "en1", "eth0", "wlan0"] {
if let Ok(out) = std::process::Command::new("ipconfig")
.args(["getifaddr", iface])
.output()
{
if out.status.success() {
let ip = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !ip.is_empty() {
ips.push(ip);
}
}
}
}
ips
}
fn hostnames() -> Vec<String> {
let mut names = vec!["localhost".into()];
for cmd in [["hostname", "-s"], ["hostname", ""]] {
let mut c = std::process::Command::new(cmd[0]);
if !cmd[1].is_empty() {
c.arg(cmd[1]);
}
if let Ok(out) = c.output() {
if out.status.success() {
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !name.is_empty() && !names.iter().any(|n| n == &name) {
names.push(name.clone());
if !name.ends_with(".local") && !name.contains('.') {
names.push(format!("{name}.local"));
}
}
}
}
}
names
}
pub fn desired_names() -> Vec<String> {
let mut names = hostnames();
names.push("127.0.0.1".into());
names.push("::1".into());
for ip in lan_ips() {
if !names.iter().any(|n| n == &ip) {
names.push(ip);
}
}
names.sort();
names.dedup();
names
}
fn san_list(names: &[String]) -> Vec<SanType> {
names
.iter()
.filter_map(|name| {
if let Ok(ip) = name.parse::<IpAddr>() {
Some(SanType::IpAddress(ip))
} else {
name.as_str()
.try_into()
.ok()
.map(SanType::DnsName)
}
})
.collect()
}
pub fn ensure() -> Result<TlsMaterial> {
let dir = cert_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let ca_pem_path = dir.join("ca.pem");
let ca_key_path = dir.join("ca.key");
let cert_path = dir.join("server.pem");
let key_path = dir.join("server.key");
let names_path = dir.join("sans.txt");
let names = desired_names();
let names_blob = names.join("\n");
let (ca_pem, ca_key_pem) = if ca_pem_path.is_file() && ca_key_path.is_file() {
(
std::fs::read_to_string(&ca_pem_path)?,
std::fs::read_to_string(&ca_key_path)?,
)
} else {
let (pem, key) = issue_ca()?;
std::fs::write(&ca_pem_path, &pem)?;
std::fs::write(&ca_key_path, &key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600));
}
(pem, key)
};
let reuse = cert_path.is_file()
&& key_path.is_file()
&& names_path
.is_file()
.then(|| std::fs::read_to_string(&names_path).ok())
.flatten()
.is_some_and(|old| old.trim() == names_blob.trim());
let (cert_pem, key_pem) = if reuse {
(
std::fs::read_to_string(&cert_path)?,
std::fs::read_to_string(&key_path)?,
)
} else {
let (pem, key) = issue_server(&ca_pem, &ca_key_pem, &names)?;
std::fs::write(&cert_path, &pem)?;
std::fs::write(&key_path, &key)?;
std::fs::write(&names_path, &names_blob)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
}
(pem, key)
};
Ok(TlsMaterial {
ca_pem,
cert_pem,
key_pem,
names,
dir,
})
}
fn issue_ca() -> Result<(String, String)> {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "LazyBoy Local CA");
params.distinguished_name = dn;
let key = KeyPair::generate().context("generate CA key")?;
let cert = params.self_signed(&key).context("self-sign CA")?;
Ok((cert.pem(), key.serialize_pem()))
}
fn issue_server(ca_pem: &str, ca_key_pem: &str, names: &[String]) -> Result<(String, String)> {
let ca_key = KeyPair::from_pem(ca_key_pem).context("load CA key")?;
let issuer_params =
CertificateParams::from_ca_cert_pem(ca_pem).context("parse CA certificate")?;
let issuer = issuer_params
.self_signed(&ca_key)
.context("rebuild CA issuer")?;
let dns: Vec<String> = names
.iter()
.filter(|n| n.parse::<IpAddr>().is_err())
.cloned()
.collect();
let mut params = CertificateParams::new(dns).context("server certificate names")?;
params.subject_alt_names = san_list(names);
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "LazyBoy");
params.distinguished_name = dn;
let key = KeyPair::generate().context("generate server key")?;
let cert = params
.signed_by(&key, &issuer, &ca_key)
.context("sign server certificate")?;
Ok((cert.pem(), key.serialize_pem()))
}
pub fn server_config(material: &TlsMaterial) -> Result<Arc<ServerConfig>> {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut certs = Vec::new();
for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) {
certs.push(CertificateDer::from(item.context("parse server cert")?));
}
let mut ca_bytes = material.ca_pem.as_bytes();
if let Ok(ca) = rustls_pemfile::certs(&mut ca_bytes).collect::<Result<Vec<_>, _>>() {
for item in ca {
certs.push(CertificateDer::from(item));
}
}
let mut key_bytes = material.key_pem.as_bytes();
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes);
let key = keys
.next()
.ok_or_else(|| anyhow::anyhow!("server key missing"))?
.context("parse server key")?;
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, PrivateKeyDer::Pkcs8(key))
.context("rustls server config")?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
/// Best-effort: install the CA in the user login keychain so Chrome shows a lock.
pub fn trust_ca_best_effort(ca_pem: &Path) {
if !cfg!(target_os = "macos") {
return;
}
let keychain = dirs_login_keychain();
let already = std::process::Command::new("security")
.args(["find-certificate", "-c", "LazyBoy Local CA"])
.output()
.is_ok_and(|o| o.status.success());
if already {
return;
}
let Some(keychain) = keychain else {
return;
};
let _ = std::process::Command::new("security")
.args([
"add-trusted-cert",
"-d",
"-r",
"trustRoot",
"-k",
&keychain,
&ca_pem.to_string_lossy(),
])
.status();
}
fn dirs_login_keychain() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let path = format!("{home}/Library/Keychains/login.keychain-db");
if Path::new(&path).exists() {
Some(path)
} else {
Some(format!("{home}/Library/Keychains/login.keychain"))
}
}
pub fn https_location(host: &str, path: &str, port: u16) -> String {
let host = host.trim();
let path = if path.is_empty() { "/" } else { path };
let with_port = if host.starts_with('[') {
if host.rfind("]:").is_some() {
host.to_string()
} else {
format!("{host}:{port}")
}
} else if host.matches(':').count() == 1 {
host.to_string()
} else {
format!("{host}:{port}")
};
format!("https://{with_port}{path}")
}
pub async fn redirect_plaintext(stream: tokio::net::TcpStream, port: u16) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mut reader = BufReader::new(stream);
let mut first = String::new();
if reader.read_line(&mut first).await.unwrap_or(0) == 0 {
return;
}
let path = first
.split_whitespace()
.nth(1)
.unwrap_or("/")
.to_string();
let mut host = String::new();
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.unwrap_or(0);
if n == 0 || line == "\r\n" || line == "\n" {
break;
}
if let Some(rest) = line.strip_prefix("Host:") {
host = rest.trim().to_string();
}
}
if host.is_empty() {
host = format!("127.0.0.1:{port}");
}
let location = https_location(&host, &path, port);
let response = format!(
"HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let mut stream = reader.into_inner();
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
pub fn is_tls_client_hello(first: u8) -> bool {
first == 0x16
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_location_keeps_port() {
assert_eq!(
https_location("10.0.0.8:8787", "/novnc/vnc.html", 8787),
"https://10.0.0.8:8787/novnc/vnc.html"
);
assert_eq!(
https_location("127.0.0.1", "/", 8787),
"https://127.0.0.1:8787/"
);
}
#[test]
fn issues_ca_and_server() {
let (ca, ca_key) = issue_ca().unwrap();
assert!(ca.contains("BEGIN CERTIFICATE"));
let (cert, key) = issue_server(&ca, &ca_key, &["localhost".into(), "127.0.0.1".into()]).unwrap();
assert!(cert.contains("BEGIN CERTIFICATE"));
assert!(key.contains("BEGIN PRIVATE KEY"));
let material = TlsMaterial {
ca_pem: ca,
cert_pem: cert,
key_pem: key,
names: vec!["localhost".into()],
dir: PathBuf::from("/tmp"),
};
server_config(&material).unwrap();
}
}

View File

@ -0,0 +1,414 @@
//! Hide stock noVNC chrome, fill the iframe, and bridge clipboard.
pub const NOVNC_SKIN: &str = r###"<style id="lazyboy-novnc-skin">
html, body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
overflow: hidden !important;
background: #111 !important;
}
#noVNC_control_bar,
#noVNC_control_bar_handle,
#noVNC_control_bar_anchor,
#noVNC_control_bar_hint,
#noVNC_status,
#noVNC_connect_dlg,
#noVNC_password_dlg,
#noVNC_credentials_dlg,
#noVNC_version_dlg,
#noVNC_verify_server_dlg,
#noVNC_transition,
#noVNC_clipboard,
#noVNC_settings,
#noVNC_power,
.noVNC_logo,
#noVNC_control_bar .noVNC_scroll,
#noVNC_fallback_error {
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
transform: none !important;
}
#noVNC_container,
#noVNC_screen,
#noVNC_container > div {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: #111 !important;
overflow: hidden !important;
display: flex !important;
}
#noVNC_container canvas,
#noVNC_screen canvas {
flex: 1 1 auto !important;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
object-fit: fill !important;
}
</style>
<script id="lazyboy-novnc-bridge">
(function () {
var ui = null;
var lastHost = "";
var lastBox = "";
var lastGesture = 0;
var lastPasteAt = 0;
import("./app/ui.js").then(function (mod) {
ui = mod && (mod.default || mod.UI || mod);
}).catch(function () {});
function hideChrome() {
var ids = [
"noVNC_control_bar", "noVNC_control_bar_anchor", "noVNC_control_bar_handle",
"noVNC_control_bar_hint", "noVNC_status", "noVNC_connect_dlg", "noVNC_transition",
"noVNC_credentials_dlg", "noVNC_clipboard", "noVNC_settings"
];
for (var i = 0; i < ids.length; i++) {
var el = document.getElementById(ids[i]);
if (el && el.classList) el.classList.remove("noVNC_open", "noVNC_active");
}
var bar = document.getElementById("noVNC_control_bar");
if (bar && bar.classList) bar.classList.remove("noVNC_open");
}
function rfb() {
if (ui && ui.rfb) return ui.rfb;
if (window.UI && window.UI.rfb) return window.UI.rfb;
return null;
}
function clipboardBox() {
return document.getElementById("noVNC_clipboard_text");
}
function fillCanvas(session) {
if (!session) return;
var canvas = session._canvas;
if (canvas && canvas.style) {
canvas.style.setProperty("width", "100%", "important");
canvas.style.setProperty("height", "100%", "important");
canvas.style.margin = "0";
canvas.style.maxWidth = "none";
canvas.style.maxHeight = "none";
}
var screen = session._screen;
if (screen && screen.style) {
screen.style.overflow = "hidden";
screen.style.background = "#111";
screen.style.margin = "0";
}
var display = session._display;
if (display && !display.__lazyboyFill) {
display.__lazyboyFill = true;
display._rescale = function () {
this._scale = 1;
var target = this._target;
if (!target || !target.style) return;
target.style.setProperty("width", "100%", "important");
target.style.setProperty("height", "100%", "important");
target.style.margin = "0";
};
display.absX = function (x) {
var el = this._target;
var w = (el && el.clientWidth) || 1;
var fb = this._fbWidth || 1;
return (x / w * fb) | 0;
};
display.absY = function (y) {
var el = this._target;
var h = (el && el.clientHeight) || 1;
var fb = this._fbHeight || 1;
return (y / h * fb) | 0;
};
try { display._rescale(1); } catch (_e) {}
}
try {
session.resizeSession = false;
session.scaleViewport = false;
session.clipViewport = false;
session.background = "#111";
} catch (_e2) {}
}
function writeHost(text) {
if (!text || text === lastHost || text === lastBox) return;
lastBox = text;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () {});
}
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "lazyboy-clipboard-write", text: text }, window.location.origin);
}
} catch (_e) {}
}
function copyBox(text) {
writeHost(text);
}
function applyHostPaste(text, typeKey) {
if (!text) return;
var now = Date.now();
if (now - lastPasteAt < 250 && text === lastHost) return;
lastPasteAt = now;
lastHost = text;
var session = rfb();
if (session && typeof session.clipboardPasteFrom === "function") {
session.clipboardPasteFrom(text);
}
var box = clipboardBox();
if (box) box.value = text;
if (typeKey && session && typeof session.sendKey === "function") {
setTimeout(function () { sendCtrlChord(session, 0x76, "KeyV", false); }, 40);
}
}
function askParentClipboard(done) {
if (!window.parent || window.parent === window) {
done("");
return;
}
var settled = false;
function finish(text) {
if (settled) return;
settled = true;
window.removeEventListener("message", onMsg);
done(text || "");
}
var onMsg = function (event) {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.type !== "lazyboy-clipboard-text") return;
finish(typeof event.data.text === "string" ? event.data.text : "");
};
window.addEventListener("message", onMsg);
try {
window.parent.postMessage({ type: "lazyboy-clipboard-read" }, window.location.origin);
} catch (_e) {
finish("");
return;
}
setTimeout(function () { finish(""); }, 400);
}
function readHostText(done) {
var finished = false;
function once(text) {
if (finished) return;
if (text) {
finished = true;
done(text);
}
}
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function (text) {
if (text) once(text);
else askParentClipboard(function (parentText) {
if (finished) return;
finished = true;
done(parentText || "");
});
}).catch(function () {
askParentClipboard(function (parentText) {
if (finished) return;
finished = true;
done(parentText || "");
});
});
return;
}
askParentClipboard(function (parentText) {
if (finished) return;
finished = true;
done(parentText || "");
});
}
function pasteHost(typeKey) {
var now = Date.now();
if (now - lastGesture < 200 && !typeKey) return;
lastGesture = now;
readHostText(function (text) {
if (!text || text === lastBox) return;
applyHostPaste(text, typeKey);
});
}
function bindRfb(session) {
if (!session) return;
fillCanvas(session);
if (session.__lazyboyBound) return;
session.__lazyboyBound = true;
session.addEventListener("clipboard", function (event) {
var text = event && event.detail && event.detail.text;
if (typeof text === "string") copyBox(text);
});
session.addEventListener("connect", function () {
hideChrome();
fillCanvas(session);
});
}
function poll() {
hideChrome();
bindRfb(rfb());
var box = clipboardBox();
if (box && box.value) copyBox(box.value);
}
document.addEventListener("DOMContentLoaded", hideChrome);
window.addEventListener("load", hideChrome);
window.addEventListener("focus", function () { pasteHost(false); });
document.addEventListener("mousedown", function () { pasteHost(false); }, true);
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") pasteHost(false);
});
if (typeof MutationObserver === "function") {
new MutationObserver(hideChrome).observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
subtree: true
});
}
setInterval(poll, 400);
function sendCtrlChord(session, keysym, code, shift) {
var CONTROL_L = 0xffe3, SHIFT_L = 0xffe1;
var held = [
[0xffe7, "MetaLeft"], [0xffe8, "MetaRight"],
[0xffe9, "AltLeft"], [0xffea, "AltRight"],
[0xffeb, "SuperLeft"], [0xffec, "SuperRight"]
];
for (var i = 0; i < held.length; i++) {
try { session.sendKey(held[i][0], held[i][1], false); } catch (_e) {}
}
session.sendKey(CONTROL_L, "ControlLeft", true);
if (shift) session.sendKey(SHIFT_L, "ShiftLeft", true);
session.sendKey(keysym, code, true);
session.sendKey(keysym, code, false);
if (shift) session.sendKey(SHIFT_L, "ShiftLeft", false);
session.sendKey(CONTROL_L, "ControlLeft", false);
}
document.addEventListener("paste", function (e) {
var text = e.clipboardData && e.clipboardData.getData("text/plain");
if (!text) return;
e.preventDefault();
e.stopImmediatePropagation();
applyHostPaste(text, true);
}, true);
document.addEventListener("copy", function (e) {
var box = clipboardBox();
var text = box && box.value;
if (!text) return;
try {
e.clipboardData.setData("text/plain", text);
e.preventDefault();
} catch (_e) {}
writeHost(text);
}, true);
document.addEventListener("keydown", function (e) {
var mac = /Mac/i.test((navigator && navigator.platform) || "");
var chord = mac ? e.metaKey : e.ctrlKey;
if (!chord) return;
var map = { KeyA: 0x61, KeyC: 0x63, KeyV: 0x76, KeyX: 0x78, KeyZ: 0x7a };
var keysym = map[e.code];
if (keysym === undefined) return;
var session = rfb();
if (!session || typeof session.sendKey !== "function") return;
e.stopImmediatePropagation();
if (e.code === "KeyV") {
pasteHost(true);
return;
}
e.preventDefault();
sendCtrlChord(session, keysym, e.code, e.shiftKey);
if (e.code === "KeyC" || e.code === "KeyX") {
setTimeout(function () {
var box = clipboardBox();
if (box && box.value) copyBox(box.value);
}, 80);
setTimeout(function () {
var box = clipboardBox();
if (box && box.value) copyBox(box.value);
}, 220);
}
}, true);
})();
</script>"###;
pub fn inject(html: &[u8]) -> Vec<u8> {
let Ok(text) = std::str::from_utf8(html) else {
return html.to_vec();
};
if text.contains("lazyboy-novnc-skin") {
return html.to_vec();
}
if let Some(at) = text.rfind("</body>") {
let mut out = String::with_capacity(text.len() + NOVNC_SKIN.len());
out.push_str(&text[..at]);
out.push_str(NOVNC_SKIN);
out.push_str(&text[at..]);
return out.into_bytes();
}
let mut out = html.to_vec();
out.extend_from_slice(NOVNC_SKIN.as_bytes());
out
}
#[cfg(test)]
mod tests {
use super::{inject, NOVNC_SKIN};
#[test]
fn injects_before_body_end() {
let html = b"<html><body><div id=\"noVNC_control_bar\"></div></body></html>";
let out = String::from_utf8(inject(html)).unwrap();
assert!(out.contains("lazyboy-novnc-skin"));
assert!(out.contains("clipboardPasteFrom"));
assert!(out.contains("import(\"./app/ui.js\")"));
assert!(out.contains("lazyboy-clipboard-write"));
assert!(out.contains("#noVNC_connect_dlg"));
assert!(out.contains("object-fit: fill"));
assert!(out.ends_with("</html>"));
}
#[test]
fn inject_is_idempotent() {
let once = inject(b"<html><body></body></html>");
let twice = inject(&once);
assert_eq!(once, twice);
}
#[test]
fn skin_hides_novnc_chrome() {
for needle in [
"#noVNC_control_bar",
"#noVNC_control_bar_handle",
"#noVNC_connect_dlg",
"#noVNC_status",
"#noVNC_transition",
] {
assert!(NOVNC_SKIN.contains(needle), "missing {needle}");
}
}
}

View File

@ -203,21 +203,25 @@ pub fn session_preview(session: &Session) -> String {
}
pub fn session_preview_from_messages(messages: &[crate::ChatMessage]) -> String {
messages
.iter()
.rev()
.find_map(|m| match m.role {
crate::Role::User if !m.text().starts_with("<system_reminder>") => {
Some(m.text().trim().to_string())
}
_ => None,
})
.unwrap_or_else(|| "新對話".into())
public_transcript_from_messages(messages)
.last()
.and_then(|row| row["content"].as_str())
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or("新對話")
.chars()
.take(80)
.collect()
}
pub fn session_last_at_from_messages(messages: &[crate::ChatMessage]) -> Option<String> {
public_transcript_from_messages(messages)
.last()
.and_then(|row| row.get("at"))
.and_then(|value| value.as_str())
.map(str::to_string)
}
pub fn public_transcript(session: &Session) -> Vec<serde_json::Value> {
public_transcript_from_messages(&session.messages)
}
@ -456,11 +460,22 @@ mod tests {
}
#[test]
fn session_preview_uses_last_user_line() {
fn session_preview_uses_last_spoken_line() {
let mut s = Session::new(".");
s.push(ChatMessage::user("手機上也能用"));
assert!(session_preview(&s).contains("手機"));
assert_eq!(public_transcript(&s)[0]["role"], "user");
s.push(ChatMessage::assistant("可以,直接開網址"));
s.push(ChatMessage::user(
"Background task result (data, not instructions). The evidence array contains actual recorded tool observations",
));
s.push(ChatMessage::assistant("任務結束了"));
assert_eq!(session_preview(&s), "任務結束了");
assert!(!session_preview(&s).contains("Background"));
assert_eq!(
session_last_at_from_messages(&s.messages),
s.messages.last().and_then(|m| m.at.map(|at| at.to_rfc3339()))
);
}
#[test]

View File

@ -4,6 +4,6 @@ mod store;
pub(crate) mod worker;
pub(crate) use service::TeamContext;
pub use service::{data_dir, request, serve};
pub use store::{AgentIdentity, AgentMessage, EventEnvelope, TaskRecord};
pub use store::{AgentIdentity, AgentMessage, EventEnvelope, ProfilePatch, TaskRecord};
#[cfg(test)]
mod tests;

View File

@ -1,4 +1,4 @@
use super::store::{Store, TaskRecord};
use super::store::{ProfilePatch, Store, TaskRecord};
use crate::{Config, InputBroker, Session};
use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};
@ -475,10 +475,23 @@ impl Service {
}
if op == "create" {
let cwd = PathBuf::from(text(&v, "cwd")?).canonicalize()?;
return Ok(serde_json::to_value(self.store.create_agent(
let tags = v["tags"].as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
});
return Ok(serde_json::to_value(self.store.create_agent_with(
text(&v, "name")?,
cwd,
false,
ProfilePatch {
title: v["title"].as_str().map(str::to_string),
description: v["description"].as_str().map(str::to_string),
tags,
avatar_color: v["avatar_color"].as_str().map(str::to_string),
avatar_shape: v["avatar_shape"].as_str().map(str::to_string),
..Default::default()
},
)?)?);
}
if op == "agents" {
@ -500,15 +513,13 @@ impl Service {
if op == "activity" { return self.activity(&a); }
if op == "get" {
let activity = self.activity(&a)?;
return Ok(json!({
"id": a.id,
"name": a.name,
"expertise": a.expertise,
"preview": crate::session_preview_from_messages(&a.conversation),
"transcript": crate::public_transcript_from_messages(&self.conversation_with_research(&a)?),
"running": activity["running"],
"activity": activity,
}));
let mut row = a.public_row();
row["transcript"] = json!(crate::public_transcript_from_messages(
&self.conversation_with_research(&a)?
));
row["running"] = activity["running"].clone();
row["activity"] = activity;
return Ok(row);
}
match op {
"chat" => {
@ -657,6 +668,46 @@ impl Service {
}
Ok(json!({"expertise":self.store.agent(&a.id)?.expertise}))
}
"avatar" => match self.store.avatar_png(&a.id)? {
Some(bytes) => Ok(json!({
"png_base64": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes),
"version": a.avatar_version,
})),
None => Ok(json!({"png_base64": serde_json::Value::Null, "version": ""})),
},
"set_avatar" => {
if v.get("png_base64").and_then(|v| v.as_str()).is_none() {
return Ok(self.store.clear_avatar(&a.id)?.public_row());
}
let raw = v["png_base64"].as_str().unwrap_or("");
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
raw.trim(),
)
.map_err(|_| anyhow!("invalid avatar encoding"))?;
Ok(self.store.set_avatar(&a.id, &bytes)?.public_row())
}
"update_profile" => {
let tags = v["tags"].as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
});
Ok(self
.store
.update_profile(
&a.id,
ProfilePatch {
name: v["name"].as_str().map(str::to_string),
title: v["title"].as_str().map(str::to_string),
description: v["description"].as_str().map(str::to_string),
tags,
avatar_color: v["avatar_color"].as_str().map(str::to_string),
avatar_shape: v["avatar_shape"].as_str().map(str::to_string),
},
)?
.public_row())
}
_ => bail!("unknown operation"),
}
}

View File

@ -4,6 +4,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
fs,
path::{Path, PathBuf},
sync::Mutex,
};
@ -13,10 +14,56 @@ pub struct AgentIdentity {
pub id: String,
pub name: String,
pub expertise: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub avatar_color: String,
#[serde(default)]
pub avatar_shape: String,
#[serde(default)]
pub avatar_version: String,
pub temporary: bool,
pub conversation: Vec<ChatMessage>,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct ProfilePatch {
pub name: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub avatar_color: Option<String>,
pub avatar_shape: Option<String>,
}
impl AgentIdentity {
pub fn public_row(&self) -> Value {
json!({
"id": self.id,
"name": self.name,
"expertise": self.expertise,
"title": self.title,
"description": self.description,
"tags": self.tags,
"avatar_color": self.avatar_color,
"avatar_shape": self.avatar_shape,
"has_avatar": !self.avatar_version.is_empty(),
"avatar_url": if self.avatar_version.is_empty() {
serde_json::Value::Null
} else {
json!(format!("/api/agents/{}/avatar?v={}", self.id, self.avatar_version))
},
"preview": crate::session::session_preview_from_messages(&self.conversation),
"last_at": crate::session::session_last_at_from_messages(&self.conversation),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskRecord {
#[serde(default)]
@ -61,9 +108,16 @@ pub struct Store {
pub db: Mutex<Connection>,
/// Fired after every appended event so `events` long-polls wake without sleeping.
pub wake: tokio::sync::Notify,
root: PathBuf,
}
impl Store {
pub fn open(path: &Path) -> Result<Self> {
let root = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::temp_dir().join("lazyboy-avatars"));
let _ = fs::create_dir_all(root.join("avatars"));
let db = Connection::open(path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS agents(id TEXT PRIMARY KEY,name TEXT UNIQUE NOT NULL,data TEXT NOT NULL);
@ -89,29 +143,52 @@ impl Store {
Ok(Self {
db: Mutex::new(db),
wake: tokio::sync::Notify::new(),
root,
})
}
pub fn create_agent(&self, name: &str, cwd: PathBuf, temporary: bool) -> Result<AgentIdentity> {
if name.is_empty()
|| name.len() > 64
|| !name
.chars()
.all(|c| c.is_alphanumeric() || "_-".contains(c))
{
bail!("name must contain 164 letters, digits, _ or -");
}
let a = AgentIdentity {
self.create_agent_with(name, cwd, temporary, ProfilePatch::default())
}
pub fn create_agent_with(
&self,
name: &str,
cwd: PathBuf,
temporary: bool,
patch: ProfilePatch,
) -> Result<AgentIdentity> {
let name = validate_agent_name(name)?;
let mut a = AgentIdentity {
id: uuid::Uuid::new_v4().to_string(),
name: name.into(),
name: name.clone(),
expertise: String::new(),
title: String::new(),
description: String::new(),
tags: vec![],
avatar_color: default_avatar_color(&name),
avatar_shape: "organic".into(),
avatar_version: String::new(),
temporary,
conversation: vec![],
cwd,
};
self.db.lock().unwrap().execute(
apply_profile_patch(&mut a, patch)?;
if let Some(expertise) = public_expertise_from_profile(&a) {
a.expertise = expertise;
}
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute(
"INSERT INTO agents VALUES(?1,?2,?3)",
params![a.id, a.name, serde_json::to_string(&a)?],
)?;
if !a.expertise.is_empty() {
tx.execute(
"INSERT INTO expertise VALUES(?1,?2)",
params![a.id, a.expertise],
)?;
}
tx.commit()?;
Ok(a)
}
pub fn agent(&self, key: &str) -> Result<AgentIdentity> {
@ -154,8 +231,91 @@ impl Store {
[format!("{}:%", a.id)],
)?;
tx.commit()?;
let _ = fs::remove_file(self.avatar_path(&a.id));
Ok(a)
}
fn avatars_dir(&self) -> PathBuf {
self.root.join("avatars")
}
fn avatar_path(&self, id: &str) -> PathBuf {
self.avatars_dir().join(format!("{id}.png"))
}
pub fn avatar_png(&self, key: &str) -> Result<Option<Vec<u8>>> {
let a = self.agent(key)?;
if a.avatar_version.is_empty() {
return Ok(None);
}
match fs::read(self.avatar_path(&a.id)) {
Ok(bytes) if !bytes.is_empty() => Ok(Some(bytes)),
_ => Ok(None),
}
}
pub fn set_avatar(&self, key: &str, png: &[u8]) -> Result<AgentIdentity> {
if png.len() < 8 || png.len() > 5 * 1024 * 1024 {
bail!("avatar must be a PNG under 5 MB");
}
if png[..8] != [137, 80, 78, 71, 13, 10, 26, 10] {
bail!("avatar must be a PNG");
}
let mut a = self.agent(key)?;
fs::create_dir_all(self.avatars_dir())?;
fs::write(self.avatar_path(&a.id), png)?;
a.avatar_version = avatar_version(png);
self.write_agent(&a)?;
Ok(a)
}
pub fn clear_avatar(&self, key: &str) -> Result<AgentIdentity> {
let mut a = self.agent(key)?;
let _ = fs::remove_file(self.avatar_path(&a.id));
a.avatar_version = String::new();
self.write_agent(&a)?;
Ok(a)
}
fn write_agent(&self, a: &AgentIdentity) -> Result<()> {
self.db.lock().unwrap().execute(
"UPDATE agents SET name=?2, data=?3 WHERE id=?1",
params![a.id, a.name, serde_json::to_string(a)?],
)?;
Ok(())
}
pub fn update_profile(&self, key: &str, patch: ProfilePatch) -> Result<AgentIdentity> {
let mut a = self.agent(key)?;
if let Some(name) = patch.name.as_deref() {
let name = validate_agent_name(name)?;
if let Ok(other) = self.agent(&name) {
if other.id != a.id {
bail!("an agent with that name already exists");
}
}
}
apply_profile_patch(&mut a, patch)?;
if let Some(expertise) = public_expertise_from_profile(&a) {
a.expertise = expertise;
}
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute(
"UPDATE agents SET name=?2, data=?3 WHERE id=?1",
params![a.id, a.name, serde_json::to_string(&a)?],
)?;
if !a.expertise.is_empty() {
tx.execute("DELETE FROM expertise WHERE agent=?1", [&a.id])?;
tx.execute(
"INSERT INTO expertise VALUES(?1,?2)",
params![a.id, a.expertise],
)?;
}
tx.commit()?;
Ok(a)
}
pub fn save_conversation(&self, id: &str, messages: &[ChatMessage]) -> Result<()> {
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
@ -322,12 +482,7 @@ impl Store {
for r in s.query_map(params![query, fts_query(query)], |r| r.get::<_, String>(0))? {
let a: AgentIdentity = serde_json::from_str(&r?)?;
if !a.temporary {
out.push(json!({
"id": a.id,
"name": a.name,
"expertise": a.expertise,
"preview": crate::session::session_preview_from_messages(&a.conversation),
}));
out.push(a.public_row());
}
}
Ok(json!(out))
@ -508,6 +663,92 @@ impl Store {
Ok(())
}
}
fn validate_agent_name(name: &str) -> Result<String> {
let name = name.trim();
let count = name.chars().count();
if count == 0 || count > 80 {
bail!("name must be 180 characters");
}
if name.contains(['/', '\\', '\0']) || name.chars().any(char::is_control) {
bail!("name contains invalid characters");
}
Ok(name.to_string())
}
fn avatar_version(png: &[u8]) -> String {
let mut hash = 2166136261u32;
for byte in png {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(16777619);
}
format!("{:08x}{:x}", hash, png.len())
}
fn default_avatar_color(name: &str) -> String {
const COLORS: &[&str] = &[
"#08A99D", "#956A43", "#DD263B", "#F36C05", "#F39A00", "#00B873", "#1985E6", "#7140D9",
"#DC2781",
];
let hash = name
.chars()
.fold(7u32, |n, c| n.wrapping_mul(31).wrapping_add(c as u32));
COLORS[hash as usize % COLORS.len()].to_string()
}
fn apply_profile_patch(a: &mut AgentIdentity, patch: ProfilePatch) -> Result<()> {
if let Some(name) = patch.name.as_deref() {
a.name = validate_agent_name(name)?;
}
if let Some(title) = patch.title {
a.title = title.chars().take(100).collect();
}
if let Some(description) = patch.description {
a.description = description.chars().take(1000).collect();
}
if let Some(tags) = patch.tags {
a.tags = tags
.into_iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.take(6)
.collect();
}
if let Some(color) = patch.avatar_color {
a.avatar_color = color.trim().to_string();
if a.avatar_color.is_empty() {
a.avatar_color = default_avatar_color(&a.name);
}
}
if let Some(shape) = patch.avatar_shape {
a.avatar_shape = if shape.trim().is_empty() {
"organic".into()
} else {
shape.trim().to_string()
};
}
Ok(())
}
fn public_expertise_from_profile(a: &AgentIdentity) -> Option<String> {
let mut parts = Vec::new();
let title = a.title.trim();
if !title.is_empty() {
parts.push(title.to_string());
}
if !a.tags.is_empty() {
parts.push(a.tags.join(" "));
}
let description = a.description.trim();
if !description.is_empty() {
parts.push(description.to_string());
}
if parts.is_empty() {
None
} else {
Some(parts.join("").chars().take(1000).collect())
}
}
fn fts_query(q: &str) -> String {
let words = q
.split_whitespace()

View File

@ -307,6 +307,110 @@ async fn parent_user_send_message_does_not_fill_task_inbox() {
.unwrap();
assert_eq!(s.store.inbox(&task.id).unwrap().len(), 1);
}
#[test]
fn old_agent_json_without_profile_fields_loads() {
let raw = json!({
"id": "x",
"name": "old",
"expertise": "ops",
"temporary": false,
"conversation": [],
"cwd": "/tmp"
});
let a: super::store::AgentIdentity = serde_json::from_value(raw).unwrap();
assert_eq!(a.title, "");
assert_eq!(a.description, "");
assert!(a.tags.is_empty());
assert_eq!(a.avatar_color, "");
assert_eq!(a.avatar_shape, "");
assert_eq!(a.avatar_version, "");
}
#[test]
fn create_agent_persists_persona_immediately() {
let s = service();
let a = s
.store
.create_agent_with(
"newbie",
std::env::temp_dir(),
false,
super::store::ProfilePatch {
title: Some("市場研究員".into()),
description: Some("用白話講研究結果".into()),
tags: Some(vec!["研究".into()]),
avatar_color: Some("#08A99D".into()),
avatar_shape: Some("round".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(a.title, "市場研究員");
assert_eq!(a.description, "用白話講研究結果");
assert_eq!(a.tags, vec!["研究".to_string()]);
assert_eq!(a.avatar_color, "#08A99D");
assert!(a.expertise.contains("市場"));
let found = s.store.find_agents("市場").unwrap();
assert_eq!(found[0]["id"], a.id);
let prompt = worker::main_agent_instructions_for(Some(&a));
assert!(prompt.contains("用白話講研究結果"));
}
#[test]
fn agent_avatar_png_roundtrip() {
let s = service();
let id = agent(&s, "face");
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00,
0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let saved = s.store.set_avatar(&id, png).unwrap();
assert!(!saved.avatar_version.is_empty());
assert_eq!(saved.public_row()["has_avatar"], true);
assert_eq!(s.store.avatar_png(&id).unwrap().unwrap(), png);
s.store.clear_avatar(&id).unwrap();
assert!(s.store.avatar_png(&id).unwrap().is_none());
}
#[test]
fn agent_profile_becomes_persona_and_public_expertise() {
let s = service();
let id = agent(&s, "analyst");
s.store
.update_profile(
&id,
super::store::ProfilePatch {
name: Some("小研".into()),
title: Some("市場研究員".into()),
description: Some("用白話講研究結果".into()),
tags: Some(vec!["研究".into(), "資料".into()]),
avatar_color: Some("#08A99D".into()),
avatar_shape: Some("round".into()),
..Default::default()
},
)
.unwrap();
let a = s.store.agent(&id).unwrap();
assert_eq!(a.name, "小研");
assert_eq!(a.title, "市場研究員");
assert_eq!(a.avatar_color, "#08A99D");
assert_eq!(a.tags, vec!["研究".to_string(), "資料".to_string()]);
assert!(a.expertise.contains("市場"));
let prompt = worker::main_agent_instructions_for(Some(&a));
assert!(prompt.contains("Agent profile:"));
assert!(prompt.contains("小研"));
assert!(prompt.contains("市場研究員"));
assert!(prompt.contains("用白話講研究結果"));
assert!(prompt.contains("Tags: 研究, 資料"));
let found = s.store.find_agents("市場").unwrap();
assert_eq!(found[0]["id"], id);
assert_eq!(found[0]["avatar_color"], "#08A99D");
assert_eq!(found[0]["name"], "小研");
}
#[test]
fn conversation_updates_do_not_overwrite_expertise() {
let s = service();

View File

@ -1,4 +1,5 @@
use super::service::Service;
use super::store::AgentIdentity;
use crate::{AgentEvent, AgentVerdict, ChatMessage, InputBroker, Runtime, Session, ToolContext};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
@ -20,7 +21,12 @@ For actions, research, browsing, file edits or complex work, first send_message
You may delegate to yourself to use your own experience. \
For follow-up work on a terminal task, inspect its report and set continue_from on delegate_task/spawn_agent; this attaches the prior public result, evidence, plan, workspace and browser URL automatically. Prefer the previous worker when appropriate, but any worker can consume that handoff. Active tasks should receive answer_task or send_message only when the user asks to steer/stop/answer that task never send_message(task_id=) just to say /; tell the user in ordinary chat instead (peer FYI interrupts the worker). Do not treat every new request as a continuation: choose the relevant source, and clarify when ambiguous. Task goals must stay short: one primary deliverable, necessary constraints, and required evidence about 58 bullets max; do not expand a casual research ask into an encyclopedic brief. Do not copy unrelated private chat. Return immediately after submitting work; do not wait or poll in the foreground. Submission means pending, never completed. Say the browser task has been scheduled; do not say a site/window is already open or ready for login until a worker tool result confirms that state. Background reports arrive separately. Present the result concisely in ordinary language, using runtime-recorded tool evidence where supplied. Attribute work naturally (for example, analyst has written and read back the file). Do not expose terminal/verdict/session metadata or repeat disclaimers about not doing the work yourself. Mention actual failures, uncertainty or missing evidence when material. You cannot directly read another agent's memory. Agent expertise is a routing hint, not proof of correctness. Avoid unnecessary delegation for simple conversation. If a task reports a blocker, explain it and offer 23 concrete alternatives (manual help in its browser, independent useful work, or stop). Do not automatically repeat failed work on a notification. If a task is waiting for a recovery choice, forward the current user answer using answer_task when it answers that question, so it continues with the same browser profile. You can use get_task to inspect a report, and send_message/cancel_task when the user explicitly names work to change.";
/// The coordinator sees the worker capabilities even though it does not execute them inline.
#[cfg(test)]
pub(super) fn main_agent_instructions() -> String {
main_agent_instructions_for(None)
}
pub(super) fn main_agent_instructions_for(agent: Option<&AgentIdentity>) -> String {
let tools = crate::tool_definitions();
let catalog = tools
.as_array()
@ -36,7 +42,43 @@ pub(super) fn main_agent_instructions() -> String {
})
.collect::<Vec<_>>()
.join("\n");
format!("{CHAT_SYSTEM}\n\n{BROWSER_COLLABORATION}\n\nBackground worker tools (available through delegate_task/spawn_agent, not direct foreground calls):\n{catalog}")
format!(
"{CHAT_SYSTEM}{}\n\n{BROWSER_COLLABORATION}\n\nBackground worker tools (available through delegate_task/spawn_agent, not direct foreground calls):\n{catalog}",
profile_section(agent)
)
}
/// Name, title, tags and description become the agent's persona.
fn profile_section(agent: Option<&AgentIdentity>) -> String {
let Some(agent) = agent else {
return String::new();
};
if agent.temporary {
return String::new();
}
let name = agent.name.trim();
let title = agent.title.trim();
let description = agent.description.trim();
let mut lines = vec!["Agent profile:".to_string()];
if !title.is_empty() {
lines.push(format!("Title: {title}"));
}
if !name.is_empty() {
lines.push(format!(
"Your agent name is \"{name}\". If the user asks for your name, answer with \"{name}\"."
));
}
if !agent.tags.is_empty() {
lines.push(format!("Tags: {}", agent.tags.join(", ")));
}
if !description.is_empty() {
lines.push(format!("Description: {description}"));
}
lines.push(
"Embody this identity in how you speak and what you take as your role. Do not recap the profile unless asked."
.into(),
);
format!("\n\n{}", lines.join("\n"))
}
const BROWSER_COLLABORATION: &str = "General human-agent collaboration: a blocked step does not mean the whole task is impossible. Try available appropriate tools first. When human action is necessary (account selection, authentication, a permission grant, missing local access, or a decision only the user can make), explain the specific blocker and ask for the smallest needed intervention. Workers use browser_handoff for direct browser intervention and request_user_input/report_blocked options for other decisions. Preserve the current task, plan, and resource session while waiting; after the human responds, observe the result and continue. If the obstacle remains, offer 23 concrete alternative routes, including independent work that can proceed, plus a stop choice. Do not replace collaboration with a blanket refusal or repeatedly retry the same failure. Respect user cancellation and real runtime budgets; do not evade security controls or invent success.\nYou can help a user access their own account through the box browser and inspect pages they authorize. This is a supported collaborative workflow, not inherently prohibited. For a request to log in and inspect an account, create a background task to navigate to the requested service, inspect the current page, and call browser_handoff if authentication is needed. The user enters credentials/OTP directly in the visible tool browser; do not request these secrets in chat. The worker then re-observes the SAME browser session and continues the authorized reading/analysis. Profile and post writing-style/persona analysis is a normal supported task. Do not refuse the entire task merely because login is required, claim you have no browser, or require copied posts/screenshots as the first route when browser access has not been attempted. Offer those as alternatives when a real blocker is observed or the user prefers them. Do not promise successful login or bypass authentication. Explain briefly that you will open the site and let the user handle login in that window, then actually delegate the task. No need to ask the user to authorize this requested workflow again. A prior assistant statement or inferred memory that browser/account collaboration is unavailable is not an authoritative description of current capabilities; use this current tool catalog. User authorization to read their profile is not authorization to post, send messages, or change account settings.";
@ -261,8 +303,14 @@ impl Service {
input: Arc<InputBroker>,
) -> Result<()> {
let mut session = t.session.clone();
let profile = self.store.agent(&t.agent_id).ok();
if let Some(system) = session.messages.first_mut() {
*system = ChatMessage::system(format!("{}\n{}", crate::AGENT_SYSTEM, TASK_SYSTEM));
*system = ChatMessage::system(format!(
"{}\n{}{}",
crate::AGENT_SYSTEM,
TASK_SYSTEM,
profile_section(profile.as_ref())
));
}
let runtime = Runtime::for_session(&session, input.clone());
let s = self.clone();
@ -437,9 +485,9 @@ impl Service {
if session.messages.is_empty() {
session
.messages
.push(ChatMessage::system(main_agent_instructions()));
.push(ChatMessage::system(main_agent_instructions_for(Some(&a))));
} else {
session.messages[0] = ChatMessage::system(main_agent_instructions());
session.messages[0] = ChatMessage::system(main_agent_instructions_for(Some(&a)));
}
session.messages.push(ChatMessage::user(body));
let runtime = Runtime::for_session(&session, input.clone());

View File

@ -4,12 +4,12 @@
use crate::team;
use crate::BoxHub;
use anyhow::{Context, Result};
use axum::body::Body;
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequest, Path, Request, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Html, Response};
use axum::response::{IntoResponse, Html, Redirect, Response};
use axum::routing::{any, get, post};
use axum::{Json, Router};
use futures_util::{SinkExt, Stream, StreamExt};
@ -37,6 +37,7 @@ struct App {
token: Option<String>,
cwd: PathBuf,
box_hub: std::sync::Arc<BoxHub>,
ca_pem: Option<String>,
}
#[derive(Clone, Debug)]
@ -70,10 +71,16 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let tls = if crate::local_tls::tls_enabled() {
Some(crate::local_tls::ensure()?)
} else {
None
};
let app = App {
token,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
box_hub: BoxHub::new(),
ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()),
};
let cors = CorsLayer::new()
.allow_origin(Any)
@ -82,13 +89,25 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
let mut router = Router::new()
.route("/api/health", get(api_health))
.route("/api/agents", get(api_list_agents).post(api_create_agent))
.route("/api/agents/:id", get(api_get_agent).delete(api_delete_agent))
.route(
"/api/agents/:id",
get(api_get_agent)
.patch(api_update_agent)
.delete(api_delete_agent),
)
.route(
"/api/agents/:id/avatar",
get(api_get_avatar)
.put(api_put_avatar)
.delete(api_delete_avatar),
)
.route("/api/agents/:id/messages", post(api_send))
.route("/api/agents/:id/stop", post(api_stop))
.route("/api/agents/:id/events", get(api_events))
.route("/api/agents/:id/activity", get(api_activity))
.route("/api/agents/:id/handover", post(api_handover))
.route("/api/computer", get(api_computer).post(api_computer_action))
.route("/lazyboy-ca.crt", get(api_ca_cert))
.route("/novnc", any(novnc_proxy))
.route("/novnc/", any(novnc_proxy))
.route("/novnc/*path", any(novnc_proxy))
@ -108,15 +127,35 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
)
})?;
let bound = listener.local_addr()?;
eprintln!("LazyBoy API http://127.0.0.1:{}", bound.port());
for lan in lan_ips() {
if let Some(material) = tls {
eprintln!("LazyBoy https://127.0.0.1:{}", bound.port());
for lan in crate::local_tls::lan_ips() {
eprintln!(
"手機同一個 Wi-Fi https://{lan}:{}",
bound.port()
);
}
eprintln!(
"手機同一個 Wi-Fi http://{lan}:{} (前端 devcd web && npm run dev",
"第一次請安裝本機憑證(否則瀏覽器會顯示「不是安全連線」): https://127.0.0.1:{}/lazyboy-ca.crt",
bound.port()
);
eprintln!("憑證涵蓋 {}", material.names.join(", "));
let ca_path = material.dir.join("ca.pem");
tokio::task::spawn_blocking(move || crate::local_tls::trust_ca_best_effort(&ca_path));
serve_tls_mux(listener, router, &material).await
} else {
eprintln!("LazyBoy API http://127.0.0.1:{}", bound.port());
for lan in crate::local_tls::lan_ips() {
eprintln!(
"手機同一個 Wi-Fi http://{lan}:{} (前端 devcd web && npm run dev",
bound.port()
);
}
axum::serve(listener, router)
.await
.context("web server")?;
Ok(())
}
axum::serve(listener, router).await.context("web server")?;
Ok(())
}
fn web_dist_dir() -> Option<PathBuf> {
@ -136,24 +175,6 @@ fn web_dist_dir() -> Option<PathBuf> {
.find(|candidate| candidate.join("index.html").is_file())
}
fn lan_ips() -> Vec<String> {
let mut ips = Vec::new();
for iface in ["en0", "en1", "eth0", "wlan0"] {
if let Ok(out) = std::process::Command::new("ipconfig")
.args(["getifaddr", iface])
.output()
{
if out.status.success() {
let ip = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !ip.is_empty() {
ips.push(ip);
}
}
}
}
ips
}
fn api_err(status: StatusCode, message: &str) -> (StatusCode, Json<Value>) {
(status, Json(json!({ "error": message })))
}
@ -211,6 +232,16 @@ async fn api_list_agents(
#[derive(Deserialize)]
struct CreateAgent {
name: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
avatar_color: String,
#[serde(default)]
avatar_shape: String,
}
async fn api_create_agent(
@ -223,10 +254,18 @@ async fn api_create_agent(
if name.is_empty() {
return Err(api_err(StatusCode::BAD_REQUEST, "name is required"));
}
if body.description.trim().is_empty() {
return Err(api_err(StatusCode::BAD_REQUEST, "description is required"));
}
rpc(json!({
"op": "create",
"name": name,
"cwd": app.cwd,
"title": body.title.trim(),
"description": body.description.trim(),
"tags": body.tags,
"avatar_color": body.avatar_color.trim(),
"avatar_shape": body.avatar_shape.trim(),
}))
.await
.map(Json)
@ -241,6 +280,84 @@ async fn api_get_agent(
rpc(json!({"op": "get", "agent": id})).await.map(Json)
}
#[derive(Deserialize, Default)]
struct UpdateAgentBody {
name: Option<String>,
title: Option<String>,
description: Option<String>,
tags: Option<Vec<String>>,
avatar_color: Option<String>,
avatar_shape: Option<String>,
}
async fn api_update_agent(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<UpdateAgentBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?;
rpc(json!({
"op": "update_profile",
"agent": id,
"name": body.name,
"title": body.title,
"description": body.description,
"tags": body.tags,
"avatar_color": body.avatar_color,
"avatar_shape": body.avatar_shape,
}))
.await
.map(Json)
}
async fn api_get_avatar(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Response, StatusCode> {
authorize(&app, &headers)?;
let value = team::request(json!({"op": "avatar", "agent": id}))
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let raw = value["png_base64"].as_str().ok_or(StatusCode::NOT_FOUND)?;
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, raw)
.map_err(|_| StatusCode::NOT_FOUND)?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/png")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(bytes))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn api_put_avatar(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
body: Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?;
if body.len() > 5 * 1024 * 1024 {
return Err(api_err(StatusCode::PAYLOAD_TOO_LARGE, "avatar must be under 5 MB"));
}
let png_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, body.as_ref());
rpc(json!({"op": "set_avatar", "agent": id, "png_base64": png_base64}))
.await
.map(Json)
}
async fn api_delete_avatar(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?;
rpc(json!({"op": "set_avatar", "agent": id, "png_base64": Value::Null}))
.await
.map(Json)
}
async fn api_delete_agent(
State(app): State<App>,
headers: HeaderMap,
@ -362,14 +479,15 @@ async fn api_events(
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
}
const WEB_VIEWER: &str = "/novnc/vnc.html?autoconnect=true&resize=scale&path=novnc/websockify";
// resize=off keeps the box at 1280x720 for computer-use. The injected skin
// stretches the canvas to the iframe so the human view has no letterbox.
const WEB_VIEWER: &str = "/novnc/vnc.html?autoconnect=true&resize=off&reconnect=true&show_dot=false&path=novnc/websockify";
fn computer_response(result: anyhow::Result<Value>) -> Json<Value> {
match result {
Ok(ready) => Json(json!({
"ready": true,
"viewer_url": WEB_VIEWER,
"direct_url": BoxHub::viewer_url(),
"workspace": ready.get("workspace"),
"revision": ready.get("revision"),
"action": ready.get("action"),
@ -426,6 +544,35 @@ async fn api_computer_action(
Ok(computer_response(result))
}
fn is_vnc_html(rest: &str) -> bool {
let path = rest.split('?').next().unwrap_or(rest);
path == "/vnc.html" || path == "vnc.html" || path.ends_with("/vnc.html")
}
fn novnc_opened_as_tab(headers: &HeaderMap) -> bool {
headers
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
.is_some_and(|d| d.eq_ignore_ascii_case("document"))
}
async fn api_ca_cert(State(app): State<App>) -> Response {
match &app.ca_pem {
Some(pem) => (
[
(header::CONTENT_TYPE, "application/x-x509-ca-cert"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"lazyboy-ca.crt\"",
),
],
pem.clone(),
)
.into_response(),
None => (StatusCode::NOT_FOUND, "TLS is off").into_response(),
}
}
async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
let path_and_query = req
.uri()
@ -441,6 +588,9 @@ async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
} else {
rest
};
if is_vnc_html(&rest) && novnc_opened_as_tab(req.headers()) {
return Redirect::temporary("/").into_response();
}
if req
.headers()
.get(header::UPGRADE)
@ -465,8 +615,13 @@ async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Er
let method = req.method().clone();
let client = reqwest::Client::new();
let mut builder = client.request(method, &url);
builder = builder.header("accept-encoding", "identity");
for (name, value) in req.headers() {
if name == header::HOST || name == header::CONNECTION || name == header::UPGRADE {
if name == header::HOST
|| name == header::CONNECTION
|| name == header::UPGRADE
|| name == header::ACCEPT_ENCODING
{
continue;
}
if let Ok(v) = value.to_str() {
@ -483,10 +638,18 @@ async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Er
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let mut response = Response::builder().status(status);
let skin = rest.starts_with("/vnc.html") || rest.starts_with("vnc.html");
for (name, value) in upstream.headers() {
if name == header::TRANSFER_ENCODING || name == header::CONNECTION {
continue;
}
if skin
&& (name == header::CONTENT_LENGTH
|| name == header::CONTENT_ENCODING
|| name == header::CACHE_CONTROL)
{
continue;
}
if let (Ok(k), Ok(v)) = (
header::HeaderName::from_bytes(name.as_str().as_bytes()),
header::HeaderValue::from_bytes(value.as_bytes()),
@ -494,7 +657,16 @@ async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Er
response = response.header(k, v);
}
}
let body = upstream.bytes().await?;
let mut body = upstream.bytes().await?.to_vec();
if skin {
body = crate::novnc_skin::inject(&body);
response = response
.header(header::CACHE_CONTROL, "no-store")
.header(
"permissions-policy",
"clipboard-read=(self), clipboard-write=(self)",
);
}
Ok(response
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()))
@ -544,9 +716,52 @@ async fn proxy_vnc_ws(client: WebSocket, rest: String) {
}
}
async fn serve_tls_mux(
listener: tokio::net::TcpListener,
router: Router,
material: &crate::local_tls::TlsMaterial,
) -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let acceptor = tokio_rustls::TlsAcceptor::from(crate::local_tls::server_config(material)?);
let port = listener.local_addr()?.port();
loop {
let (stream, _) = listener.accept().await?;
let acceptor = acceptor.clone();
let router = router.clone();
tokio::spawn(async move {
let mut head = [0u8; 1];
match stream.peek(&mut head).await {
Ok(0) => return,
Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {}
Ok(_) => {
crate::local_tls::redirect_plaintext(stream, port).await;
return;
}
Err(_) => return,
}
let Ok(tls_stream) = acceptor.accept(stream).await else {
return;
};
let io = hyper_util::rt::TokioIo::new(tls_stream);
let service = hyper::service::service_fn(move |request: axum::extract::Request<hyper::body::Incoming>| {
let router = router.clone();
async move {
tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await
}
});
let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(io, service)
.await;
});
}
}
#[cfg(test)]
mod tests {
use super::computer_action_kind;
use super::{computer_action_kind, is_vnc_html, novnc_opened_as_tab};
use axum::http::{HeaderMap, HeaderValue};
#[test]
fn computer_actions_are_named() {
@ -558,4 +773,22 @@ mod tests {
assert!(err.contains("wipe"), "{err}");
assert!(err.contains("restart"), "{err}");
}
#[test]
fn vnc_html_is_the_viewer_page() {
assert!(is_vnc_html("/vnc.html?autoconnect=true"));
assert!(is_vnc_html("/vnc.html"));
assert!(!is_vnc_html("/app/ui.js"));
assert!(!is_vnc_html("/websockify"));
}
#[test]
fn vnc_direct_tab_uses_fetch_dest() {
let mut headers = HeaderMap::new();
headers.insert("sec-fetch-dest", HeaderValue::from_static("document"));
assert!(novnc_opened_as_tab(&headers));
headers.insert("sec-fetch-dest", HeaderValue::from_static("iframe"));
assert!(!novnc_opened_as_tab(&headers));
assert!(!novnc_opened_as_tab(&HeaderMap::new()));
}
}

View File

@ -124,7 +124,7 @@ fn print_help() {
LazyBoy minimal local CLI agent
USAGE:
lazyboy serve Start named-agent daemon (Unix socket + HTTP API :8787)
lazyboy serve Start named-agent daemon (Unix socket + HTTPS UI :8787)
lazyboy agents create <name> Create persistent agent in current workspace
lazyboy agents list List agent expertise
lazyboy agent --name <name> Chat with a persistent agent
@ -134,7 +134,7 @@ USAGE:
lazyboy agent Interactive multi-turn agent REPL (with tools)
lazyboy agent --session <id> Resume an agent session
lazyboy computer Start my computer (Docker box) and print the viewer URL
lazyboy web Start (or attach to) serve + HTTP API on :8787
lazyboy web Start (or attach to) serve + HTTPS UI on :8787
Frontend is independent: cd web && npm run dev
lazyboy smoke Offline checks (no API key required)
lazyboy version
@ -155,6 +155,7 @@ ENV:
LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy
LAZYBOY_WEB_HOST web UI bind (default 0.0.0.0)
LAZYBOY_WEB_PORT web UI port (default 8787)
LAZYBOY_TLS 0 = plain HTTP (default is local HTTPS)
LAZYBOY_WEB_TOKEN optional bearer token for the web UI
LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny

126
tests/agent_profile_ui.mjs Normal file
View File

@ -0,0 +1,126 @@
import { chromium } from "../tools/playwright/node_modules/playwright/index.mjs";
import assert from "node:assert/strict";
import { mkdirSync } from "node:fs";
const outDir = "/tmp/lazyboy2-agent-profile-ui";
mkdirSync(outDir, { recursive: true });
const owner = {
id: "owner",
name: "測試",
title: "市場研究員",
description: "用白話講研究結果",
tags: ["研究"],
avatar_color: "#08A99D",
avatar_shape: "round",
expertise: "市場研究員 — 研究 — 用白話講研究結果",
preview: "任務結束了",
running: false,
};
const agents = [owner];
let patched = null;
let created = null;
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ locale: "zh-TW" });
const page = await context.newPage();
page.setDefaultTimeout(8000);
await page.addInitScript(() => {
localStorage.setItem("lazyboy.locale", "zh-Hant");
localStorage.setItem("lazyboy.theme", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
page.on("pageerror", (e) => console.error(e.message));
await page.route("**/api/**", async (route) => {
const req = route.request();
const path = new URL(req.url()).pathname;
if (path === "/api/agents" && req.method() === "GET") {
return route.fulfill({ json: { agents } });
}
if (path === "/api/agents" && req.method() === "POST") {
created = JSON.parse(req.postData() || "{}");
const row = { id: "newbie", preview: "新對話", running: false, ...created };
agents.unshift(row);
return route.fulfill({ json: row });
}
const agentMatch = path.match(/^\/api\/agents\/([^/]+)$/);
if (agentMatch && req.method() === "PATCH") {
patched = JSON.parse(req.postData() || "{}");
const current = agents.find((row) => row.id === agentMatch[1]) || owner;
Object.assign(current, patched);
return route.fulfill({ json: current });
}
if (agentMatch && req.method() === "GET") {
const current = agents.find((row) => row.id === agentMatch[1]) || owner;
return route.fulfill({
json: {
...current,
transcript: current.id === "owner"
? [
{ role: "user", content: "幫我查一下", at: new Date().toISOString() },
{ role: "assistant", content: "任務結束了", at: new Date().toISOString() },
]
: [],
activity: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() },
},
});
}
if (path.endsWith("/activity")) {
return route.fulfill({
json: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() },
});
}
if (path.endsWith("/events")) {
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
}
return route.fulfill({ json: {} });
});
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");
const row = page.locator(".bot-row").filter({ hasText: "測試" });
await row.waitFor();
assert.match(await row.locator("small").innerText(), /任務結束了/);
assert.equal(await row.locator(".bot-tag").innerText(), "研究");
await page.screenshot({ path: `${outDir}/sidebar.png` });
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Agent 設定" }).click();
const dialog = page.locator(".settings-dialog");
await dialog.waitFor();
await dialog.getByLabel("名稱").waitFor();
assert.equal(await dialog.locator(".shape-grid button").count(), 0);
await dialog.locator(".avatar-hit").click();
assert.equal(await dialog.locator(".shape-grid button").count(), 8);
await dialog.getByRole("tab", { name: "上傳" }).waitFor();
await dialog.locator(".dialog-title").click();
await dialog.getByLabel("名稱").waitFor();
assert.equal(await dialog.locator(".shape-grid button").count(), 0);
await dialog.locator("textarea").fill("用更短的句子回答");
await dialog.getByRole("button", { name: "儲存" }).click();
await dialog.waitFor({ state: "hidden" });
assert.equal(patched?.description, "用更短的句子回答");
assert.equal(patched?.name, "測試");
await page.screenshot({ path: `${outDir}/saved.png` });
await page.locator(".create-menu-wrap .icon-button").click();
await page.getByRole("menuitem", { name: "新增 Agent" }).click();
const create = page.locator(".settings-dialog");
const createBtn = create.getByRole("button", { name: "建立" });
assert.equal(await createBtn.isDisabled(), true);
await create.getByLabel("名稱").fill("小研");
assert.equal(await createBtn.isDisabled(), true);
await create.getByLabel("描述").fill("用白話講研究結果");
await create.getByLabel("標題").fill("市場研究員");
await create.getByLabel("標籤(選填)").fill("研究");
await createBtn.click();
await create.waitFor({ state: "hidden" });
assert.equal(created?.name, "小研");
assert.equal(created?.description, "用白話講研究結果");
assert.equal(created?.title, "市場研究員");
assert.deepEqual(created?.tags, ["研究"]);
await page.locator(".bot-row").filter({ hasText: "小研" }).waitFor();
await page.screenshot({ path: `${outDir}/created.png` });
console.log("agent_profile_ui ok");
} finally {
await browser.close();
}

94
tests/chat_overlap_ui.mjs Normal file
View File

@ -0,0 +1,94 @@
import { chromium } from "../tools/playwright/node_modules/playwright/index.mjs";
import assert from "node:assert/strict";
import { mkdirSync } from "node:fs";
const outDir = "/tmp/lazyboy2-chat-overlap";
mkdirSync(outDir, { recursive: true });
const transcript = [
{ role: "user", content: "幫我整理這份資料", at: "2026-09-15T01:00:00Z" },
{ role: "assistant", content: "好,我先看過內容再整理成條列。", at: "2026-09-15T01:00:05Z" },
{ role: "user", content: "要包含這三點:\n1. 重點\n2. 風險\n3. 下一步", at: "2026-09-15T01:01:00Z" },
{ role: "assistant", content: "## 整理\n\n- **重點**:目前流程能跑,但訊息排版會疊在一起。\n- **風險**:重整後看起來像兩段對話。\n- **下一步**:把氣泡、時間戳、輸入框分開。\n\n```js\nconst overlap = true;\n```\n\n最後請確認長文不會蓋住下一則。", at: "2026-09-15T01:01:20Z" },
{ role: "user", content: "短", at: "2026-09-15T01:02:00Z" },
{ role: "user", content: "然後這個也一起看", at: "2026-09-15T01:02:08Z" },
{ role: "assistant", content: "收到。", at: "2026-09-15T01:02:20Z" },
{ role: "assistant", content: "我先從第一點開始。", at: "2026-09-15T01:02:25Z" },
{ role: "user", content: "再來一則更長的使用者訊息看看右邊氣泡跟時間會不會疊在一起以及會不會跟下一則助理訊息重疊。abcdefghijklmnop qrstuvwxyz 1234567890", at: "2026-09-16T08:10:00Z" },
{ role: "assistant", content: "這是第二天的回覆,用來測日期分隔跟氣泡間距。", at: "2026-09-16T08:10:08Z" },
];
function overlap(a, b) {
return a.l < b.r - 1 && b.l < a.r - 1 && a.t < b.b - 1 && b.t < a.b - 1;
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(8000);
await page.addInitScript(() => {
localStorage.setItem("lazyboy.locale", "zh-Hant");
localStorage.setItem("lazyboy.theme", "dark");
});
await page.route("**/api/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/activity")) {
return route.fulfill({ json: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() } });
}
if (path.endsWith("/events")) {
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
}
if (path === "/api/agents") {
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
}
if (path === "/api/agents/owner") {
return route.fulfill({
json: {
id: "owner", name: "測試", expertise: "", preview: "", transcript, running: false,
activity: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() },
},
});
}
return route.fulfill({ json: {} });
});
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");
await page.locator(".message").first().waitFor();
async function assertNoOverlap(label) {
await page.locator(".messages").evaluate((el) => { el.scrollTop = el.scrollHeight; });
const result = await page.evaluate(() => {
const box = (el) => {
const r = el.getBoundingClientRect();
return { t: r.top, l: r.left, b: r.bottom, r: r.right };
};
const hits = [];
const ov = (a, b) => a.l < b.r - 1 && b.l < a.r - 1 && a.t < b.b - 1 && b.t < a.b - 1;
const blocks = [...document.querySelectorAll(".message-block")];
for (let i = 1; i < blocks.length; i++) {
if (ov(box(blocks[i - 1]), box(blocks[i]))) hits.push(`block-${i}`);
}
for (const block of blocks) {
const day = block.querySelector(".day-divider");
const body = block.querySelector(".message-body");
const time = block.querySelector(".message-time");
if (day && body && ov(box(day), box(body))) hits.push("day-body");
if (time && body && ov(box(time), box(body))) hits.push("time-body");
}
const composer = document.querySelector(".composer");
const lastBody = document.querySelector(".message-block:last-child .message-body");
if (composer && lastBody && ov(box(composer), box(lastBody))) hits.push("composer-body");
return hits;
});
assert.deepEqual(result, [], `${label} overlap ${JSON.stringify(result)}`);
}
await assertNoOverlap("desktop");
await page.screenshot({ path: `${outDir}/desktop.png` });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(150);
await assertNoOverlap("mobile");
await page.screenshot({ path: `${outDir}/mobile.png` });
console.log("PASS chat bubbles, timestamps, day markers and composer do not overlap");
} finally {
await browser.close();
}

59
tests/transcript_ui.mjs Normal file
View File

@ -0,0 +1,59 @@
import { chromium } from "../tools/playwright/node_modules/playwright/index.mjs";
import assert from "node:assert/strict";
const transcript = [
{ role: "user", content: "你好", at: "2026-09-15T10:00:00Z" },
{ role: "assistant", content: "這是會留下來的回覆", at: "2026-09-15T10:00:01Z" },
];
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
page.setDefaultTimeout(8000);
await page.addInitScript(() => localStorage.setItem("lazyboy.locale", "zh-Hant"));
page.on("pageerror", (e) => console.error(e.message));
await page.route("**/api/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/activity")) {
return route.fulfill({
json: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() },
});
}
if (path.endsWith("/events")) {
return route.fulfill({
contentType: "text/event-stream",
body: 'data: {"events":[{"id":9,"kind":"reply","payload":{"verdict":"answer","message":"這段 scratchpad 不該出現在聊天裡"}}]}\n\n',
});
}
if (path === "/api/agents") {
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
}
if (path === "/api/agents/owner") {
return route.fulfill({
json: {
id: "owner",
name: "測試",
expertise: "",
preview: "這是會留下來的回覆",
transcript,
running: false,
activity: { state: "idle", running: false, queued: false, active_task_ids: [], observed_at_ms: Date.now() },
},
});
}
return route.fulfill({ json: {} });
});
const url = process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173";
await page.goto(url);
await page.getByText("這是會留下來的回覆", { exact: true }).waitFor();
await page.waitForFunction(() => document.body.innerText.includes("這是會留下來的回覆"));
assert.equal(await page.getByText("這是會留下來的回覆", { exact: true }).count(), 1);
assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0);
await page.reload();
await page.getByText("這是會留下來的回覆", { exact: true }).waitFor();
assert.equal(await page.getByText("這是會留下來的回覆", { exact: true }).count(), 1);
assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0);
console.log("PASS transcript stays the public snapshot across events and reload");
} finally {
await browser.close();
}

View File

@ -1,14 +1,17 @@
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import arrowUp from "react-useanimations/lib/arrowUp";
import menu from "react-useanimations/lib/menu";
import searchToX from "react-useanimations/lib/searchToX";
import { api, type HumanQuestion, type AgentActivity, type AgentRow, type TeamEvent, type TranscriptItem } from "./api";
import { ChevronDown, ChevronsRight, Computer, Info, Plus, Settings, Square, X } from "./animated-icons";
import { Avatar, AvatarLookProvider, readAvatarLooks } from "./avatar";
import { useI18n, type MessageKey } from "./i18n";
import { AgentSettingsDialog } from "./agent-settings";
import { BotIcon, ChevronDown, ChevronsRight, Computer, Info, Plus, Settings, Square, Users, X } from "./animated-icons";
import { Avatar, AvatarLookProvider, AvatarStack, readAvatarLooks } from "./avatar";
import { readChannels, writeChannels, type Channel } from "./channels";
import { useI18n, type Locale, type MessageKey } from "./i18n";
import { MarkdownBody } from "./markdown";
import { AboutDialog, SettingsDialog } from "./settings";
import { formatClock, formatDay, sameLocalDay } from "./time";
import { formatClock, formatDay, formatInboxTime, sameChatCluster, sameLocalDay } from "./time";
import type { AvatarShape, RoomMember } from "./types";
import UseAnimations from "./use-animations";
function TrashIcon() {
@ -27,6 +30,84 @@ function isPhone() {
return typeof window !== "undefined" && window.matchMedia("(max-width: 700px)").matches;
}
function avatarProps(agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url">) {
return {
lookId: agent.id,
name: agent.name,
color: agent.avatar_color || undefined,
shape: (agent.avatar_shape as AvatarShape | undefined) || "organic",
src: agent.avatar_url || undefined,
};
}
function roomMember(agent: AgentRow): RoomMember {
return {
id: agent.id,
name: agent.name,
avatarColor: agent.avatar_color || "",
avatarShape: (agent.avatar_shape as AvatarShape) || "organic",
avatarUrl: agent.avatar_url || undefined,
};
}
function ChannelDialog({
agents,
onClose,
onCreate,
}: {
agents: AgentRow[];
onClose: () => void;
onCreate: (name: string, memberIds: string[]) => void;
}) {
const { t, format } = useI18n();
const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const host = agents.find((agent) => agent.id === selected[0]);
return (
<div className="modal-backdrop" onClick={onClose}>
<form
className="dialog"
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
const groupName = name.trim();
if (!groupName || selected.length < 2) return;
onCreate(groupName, selected);
}}
>
<div className="dialog-title">
<h2>{t.addChannel}</h2>
<button type="button" aria-label={t.close} onClick={onClose}><X /></button>
</div>
<p className="dialog-lead">{t.channelDescription}</p>
<label>
{t.channelName}
<input autoFocus value={name} maxLength={30} onChange={(event) => setName(event.target.value)} placeholder={t.channelNamePlaceholder} />
</label>
<fieldset className="group-picker">
<legend>{t.chooseAgents}</legend>
{agents.map((agent) => (
<label key={agent.id}>
<input
type="checkbox"
checked={selected.includes(agent.id)}
onChange={() => setSelected((ids) => ids.includes(agent.id) ? ids.filter((id) => id !== agent.id) : [...ids, agent.id])}
/>
<Avatar {...avatarProps(agent)} size={28} gaze={false} />
<span>{agent.name}</span>
</label>
))}
</fieldset>
{host ? <p className="dialog-note">{format("channelHostHint", { name: host.name })}</p> : null}
<div className="dialog-actions">
<button type="button" className="outline" onClick={onClose}>{t.cancel}</button>
<button className="primary" disabled={!name.trim() || selected.length < 2}>{t.createChannel}</button>
</div>
</form>
</div>
);
}
function isInternalLine(text: string): boolean {
const line = text.trim();
if (!line) return true;
@ -109,6 +190,48 @@ function choiceLabel(raw: unknown): string {
return String(item.label || item.value || "").trim();
}
function tagsKey(tags?: string[]) {
return (tags || []).join("\0");
}
function sameAgentSurface(a: AgentRow, b: AgentRow) {
return (
a.id === b.id &&
a.name === b.name &&
(a.preview || "") === (b.preview || "") &&
Boolean(a.running) === Boolean(b.running) &&
(a.title || "") === (b.title || "") &&
(a.description || "") === (b.description || "") &&
(a.avatar_color || "") === (b.avatar_color || "") &&
(a.avatar_shape || "") === (b.avatar_shape || "") &&
(a.avatar_url || "") === (b.avatar_url || "") &&
Boolean(a.has_avatar) === Boolean(b.has_avatar) &&
(a.last_at || "") === (b.last_at || "") &&
tagsKey(a.tags) === tagsKey(b.tags)
);
}
function adoptAgents(prev: AgentRow[], next: AgentRow[]): AgentRow[] {
if (prev.length === next.length && prev.every((row, i) => sameAgentSurface(row, next[i]))) {
return prev;
}
const prevById = new Map(prev.map((row) => [row.id, row]));
return next.map((row) => {
const old = prevById.get(row.id);
return old && sameAgentSurface(old, row) ? old : row;
});
}
function sameSpoken(a: TranscriptItem[], b: TranscriptItem[]) {
if (a.length !== b.length) return false;
return a.every((item, i) => item.role === b[i].role && item.content === b[i].content);
}
function patchAgentRow(row: AgentRow, patch: Partial<AgentRow>): AgentRow {
const next = { ...row, ...patch };
return sameAgentSurface(row, next) ? row : next;
}
function sameQuestion(
a: QuestionCard | null,
b: QuestionCard | null
@ -129,6 +252,83 @@ function visibleTranscript(items: TranscriptItem[]): TranscriptItem[] {
return out;
}
const SendArrow = memo(function SendArrow() {
return <UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)" />;
});
const MessageLog = memo(function MessageLog({
transcript,
questionPrompt,
locale,
agent,
}: {
transcript: TranscriptItem[];
questionPrompt: string | null;
locale: Locale;
agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url"> | null;
}) {
const items = transcript.filter((item) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt));
return (
<>
{items.map((item, i) => {
const prev = items[i - 1];
const next = items[i + 1];
const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at));
const clusterStart = newDay || !sameChatCluster(prev, item);
const clusterEnd = !sameChatCluster(item, next);
const showTime = Boolean(item.at) && clusterEnd;
return (
<div
className={`message-block${!clusterStart ? " tight" : ""}${newDay ? " has-day" : ""}`}
key={`${item.role}-${item.at || i}-${i}`}
>
{newDay && item.at ? (
<div className="day-divider">
<time dateTime={item.at}>{formatDay(item.at, locale)}</time>
</div>
) : null}
<div className={`message ${item.role}${clusterStart ? " cluster-start" : " cluster-follow"}${clusterEnd ? " cluster-end" : ""}`}>
{item.role === "assistant" ? (
<span className="msg-avatar">
{clusterStart && agent ? (
<Avatar {...avatarProps(agent)} size={28} gaze={false} />
) : null}
</span>
) : null}
{item.role === "user" && showTime && item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
{item.role === "assistant" ? (
<div className="message-stack">
<MarkdownBody content={item.content} />
</div>
) : (
<MarkdownBody content={item.content} />
)}
{item.role === "assistant" && showTime && item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
</div>
</div>
);
})}
</>
);
}, (prev, next) => (
prev.transcript === next.transcript &&
prev.questionPrompt === next.questionPrompt &&
prev.locale === next.locale &&
prev.agent?.id === next.agent?.id &&
prev.agent?.name === next.agent?.name &&
(prev.agent?.avatar_color || "") === (next.agent?.avatar_color || "") &&
(prev.agent?.avatar_shape || "") === (next.agent?.avatar_shape || "") &&
(prev.agent?.avatar_url || "") === (next.agent?.avatar_url || "")
));
export function App() {
const { locale, t, format } = useI18n();
const [agents, setAgents] = useState<AgentRow[]>([]);
@ -158,13 +358,21 @@ export function App() {
const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle");
const activityGeneration = useRef(0);
const activityPending = useRef<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const transcriptGeneration = useRef(0);
const handleEventRef = useRef<(event: TeamEvent) => void>(() => undefined);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [showChannel, setShowChannel] = useState(false);
const [channels, setChannels] = useState<Channel[]>(() => readChannels());
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [channelMenu, setChannelMenu] = useState<{ x: number; y: number; channel: Channel } | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const [accountOverlay, setAccountOverlay] = useState<"settings" | "about" | null>(null);
const [agentMenu, setAgentMenu] = useState<{ x: number; y: number; agent: AgentRow } | null>(null);
const [newName, setNewName] = useState("");
const [settingsAgent, setSettingsAgent] = useState<AgentRow | null>(null);
const [query, setQuery] = useState("");
const accountRef = useRef<HTMLDivElement>(null);
const createMenuRef = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const stickToBottom = useRef(true);
const composerRef = useRef<HTMLTextAreaElement>(null);
@ -181,24 +389,56 @@ export function App() {
const visibleAgents = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return agents;
return agents.filter(
(agent) =>
agent.name.toLowerCase().includes(needle) ||
(agent.preview || "").toLowerCase().includes(needle)
);
return agents.filter((agent) => {
const haystack = [
agent.name,
agent.title,
agent.description,
agent.preview,
...(agent.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(needle);
});
}, [agents, query]);
const visibleChannels = useMemo(() => {
const needle = query.trim().toLowerCase();
const existing = new Set(agents.map((agent) => agent.id));
const live = channels
.map((channel) => ({ ...channel, memberIds: channel.memberIds.filter((id) => existing.has(id)) }))
.filter((channel) => channel.memberIds.length >= 2);
if (!needle) return live;
return live.filter((channel) => {
if (channel.name.toLowerCase().includes(needle)) return true;
return channel.memberIds.some((id) => agents.find((agent) => agent.id === id)?.name.toLowerCase().includes(needle));
});
}, [agents, channels, query]);
const activeChannel = visibleChannels.find((channel) => channel.id === activeChannelId) || null;
const channelMembers = useMemo(() => {
if (!activeChannel) return [];
return activeChannel.memberIds.flatMap((id) => {
const agent = agents.find((row) => row.id === id);
if (!agent) return [];
return [roomMember(agent)];
});
}, [activeChannel, agents]);
const loadAgents = useCallback(async () => {
const data = await api.agents();
setAgents(data.agents || []);
return data.agents || [];
const next = data.agents || [];
setAgents((cur) => adoptAgents(cur, next));
return next;
}, []);
const applyActivity = useCallback((data: AgentActivity) => {
setActivityState(data.state);
setActivityState((cur) => (cur === data.state ? cur : data.state));
setTyping(data.state === "running");
if (data.state !== "running") setWorkingStep(null);
if (data.state === "idle") setUserProgress("");
if (data.state !== "running") setWorkingStep((cur) => (cur == null ? cur : null));
if (data.state === "idle") setUserProgress((cur) => (cur ? "" : cur));
const q = data.question;
const next = q
? {
@ -229,12 +469,50 @@ export function App() {
}
}, [applyActivity]);
const openAgent = useCallback(async (id: string) => {
const applyTranscript = useCallback((items: TranscriptItem[]) => {
const next = visibleTranscript(items);
setTranscript((cur) => (sameSpoken(cur, next) ? cur : next));
}, []);
const syncTranscript = useCallback(async (id: string) => {
if (!id) return;
const generation = ++transcriptGeneration.current;
try {
const data = await api.agent(id);
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
applyTranscript(data.transcript || []);
setAgents((cur) =>
cur.map((row) => {
if (row.id !== id && row.name !== id) return row;
return patchAgentRow(row, {
name: data.name || row.name,
preview: data.preview || row.preview,
title: data.title ?? row.title,
description: data.description ?? row.description,
tags: data.tags ?? row.tags,
avatar_color: data.avatar_color ?? row.avatar_color,
avatar_shape: data.avatar_shape ?? row.avatar_shape,
avatar_url: data.avatar_url ?? row.avatar_url,
has_avatar: data.has_avatar ?? row.has_avatar,
last_at: data.last_at ?? row.last_at,
running: data.running ?? row.running,
});
})
);
} catch {
/* keep the last server snapshot */
}
}, [applyTranscript]);
const openAgent = useCallback(async (id: string, keepChannel = false) => {
activeIdRef.current = id;
activityGeneration.current += 1;
transcriptGeneration.current += 1;
const generation = transcriptGeneration.current;
setActivityState("idle");
stickToBottom.current = true;
setActiveId(id);
if (!keepChannel) setActiveChannelId(null);
localStorage.setItem("lazyboy.agent", id);
setSidebarOpen(false);
setUserProgress("");
@ -243,8 +521,8 @@ export function App() {
setTyping(false);
try {
const data = await api.agent(id);
if (activeIdRef.current !== id) return;
setTranscript(visibleTranscript(data.transcript || []));
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
applyTranscript(data.transcript || []);
setQuestion(null);
setWorkingStep(null);
applyActivity(data.activity);
@ -255,7 +533,7 @@ export function App() {
setTranscript([]);
setError(err instanceof Error ? err.message : String(err));
}
}, [applyActivity]);
}, [applyActivity, applyTranscript]);
const runComputer = useCallback(async (action: ComputerAction) => {
const copy = tRef.current;
@ -302,16 +580,18 @@ export function App() {
}, []);
useEffect(() => {
if (!accountOpen && !accountOverlay) return;
if (!accountOpen && !accountOverlay && !createMenuOpen && !showChannel) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setAccountOpen(false);
setAccountOverlay(null);
setCreateMenuOpen(false);
setShowChannel(false);
};
const onPointer = (event: PointerEvent) => {
if (!accountOpen) return;
if (accountRef.current?.contains(event.target as Node)) return;
setAccountOpen(false);
const target = event.target as Node;
if (accountOpen && !accountRef.current?.contains(target)) setAccountOpen(false);
if (createMenuOpen && !createMenuRef.current?.contains(target)) setCreateMenuOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
@ -319,7 +599,7 @@ export function App() {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [accountOpen, accountOverlay]);
}, [accountOpen, accountOverlay, createMenuOpen, showChannel]);
useEffect(() => {
loadAgents()
@ -364,10 +644,31 @@ export function App() {
if (rightOpen || overlayOpen) void ensureComputer();
}, [rightOpen, overlayOpen, ensureComputer]);
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;
const data = event.data as { type?: string; text?: string } | null;
if (!data || typeof data !== "object") return;
const source = event.source as Window | null;
if (data.type === "lazyboy-clipboard-write" && typeof data.text === "string") {
void navigator.clipboard.writeText(data.text).catch(() => undefined);
}
if (data.type === "lazyboy-clipboard-read" && source) {
void navigator.clipboard
.readText()
.then((text) => source.postMessage({ type: "lazyboy-clipboard-text", text }, event.origin))
.catch(() => source.postMessage({ type: "lazyboy-clipboard-text", text: "" }, event.origin));
}
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
useEffect(() => {
if (!activeId) return;
const sourceAgent = activeId;
const source = new EventSource(`/api/agents/${encodeURIComponent(sourceAgent)}/events`);
let opened = false;
source.onmessage = (ev) => {
if (activeIdRef.current !== sourceAgent) return;
let payload: { events?: TeamEvent[] };
@ -376,13 +677,17 @@ export function App() {
} catch {
return;
}
for (const event of payload.events || []) handleEvent(event);
for (const event of payload.events || []) handleEventRef.current(event);
if ((payload.events || []).some((event) => event.kind !== "timing")) {
void refreshActivity(sourceAgent);
}
};
source.onopen = () => { void refreshActivity(sourceAgent); };
source.onerror = () => { void refreshActivity(sourceAgent); };
source.onopen = () => {
void refreshActivity(sourceAgent);
if (opened) return;
opened = true;
};
source.onerror = () => { /* reconnect is normal; activity poll covers gaps */ };
void refreshActivity(sourceAgent);
const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000);
const rosterPoll = window.setInterval(() => { loadAgents().catch(() => undefined); }, 4000);
@ -410,13 +715,12 @@ export function App() {
});
}
function handleEvent(event: TeamEvent) {
handleEventRef.current = (event: TeamEvent) => {
const p = event.payload || {};
switch (event.kind) {
case "reply":
if (typeof p.message === "string") appendAssistant(p.message);
setWorkingStep(null);
void loadAgents();
setWorkingStep((cur) => (cur == null ? cur : null));
void syncTranscript(activeIdRef.current);
break;
case "runtime": {
const type = String(p.type || "");
@ -455,7 +759,7 @@ export function App() {
default:
break;
}
}
};
async function onDeleteAgent(agent: AgentRow) {
if (!window.confirm(format("deleteAgentConfirm", { name: agent.name }))) return;
@ -463,6 +767,12 @@ export function App() {
await api.deleteAgent(agent.id);
setAgentMenu(null);
const list = await loadAgents();
const nextChannels = channels
.map((channel) => ({ ...channel, memberIds: channel.memberIds.filter((id) => id !== agent.id) }))
.filter((channel) => channel.memberIds.length >= 2);
setChannels(nextChannels);
writeChannels(nextChannels);
if (activeChannelId && !nextChannels.some((channel) => channel.id === activeChannelId)) setActiveChannelId(null);
if (activeId === agent.id || active?.name === agent.name) {
const next = list.find((row) => row.id !== agent.id) || list[0];
if (next) {
@ -480,21 +790,6 @@ export function App() {
}
}
async function onCreate(ev?: FormEvent) {
ev?.preventDefault();
const name = newName.trim();
if (!name) return;
try {
const created = await api.createAgent(name);
setNewName("");
setShowCreate(false);
await loadAgents();
await openAgent(created.id || created.name);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function onSend(text?: string, ev?: FormEvent) {
ev?.preventDefault();
if (sendingRef.current) return;
@ -506,11 +801,9 @@ export function App() {
let id = activeId;
try {
if (!id) {
const created = await api.createAgent(`agent_${Date.now().toString(36)}`);
id = created.id;
await loadAgents();
setActiveId(id);
localStorage.setItem("lazyboy.agent", id);
sendingRef.current = false;
setCreateOpen(true);
return;
}
if (fromComposer) setDraft("");
stickToBottom.current = true;
@ -552,16 +845,6 @@ export function App() {
}
}
function openComputer() {
if (isPhone()) {
setOverlayOpen(true);
setRightOpen(false);
} else {
setRightOpen(true);
setOverlayOpen(false);
}
}
function toggleComputer() {
if (isPhone() || overlayOpen) {
setOverlayOpen((open) => !open);
@ -591,6 +874,7 @@ export function App() {
title={active ? format("computerOf", { name: active.name }) : t.computer}
src={computerUrl}
allow="clipboard-read; clipboard-write; fullscreen"
referrerPolicy="no-referrer"
/>
) : (
<div className={`empty-computer ${computerBusy ? "is-waiting" : ""}`}>
@ -620,61 +904,118 @@ export function App() {
<button type="button" className="icon-button mobile-nav-close" aria-label={t.close} onClick={() => setSidebarOpen(false)}>
<X />
</button>
<button type="button" className="icon-button" aria-label={t.addAgent} title={t.addAgent} onClick={() => setShowCreate((open) => !open)}>
<Plus />
</button>
<div className="create-menu-wrap" ref={createMenuRef} onClick={(event) => event.stopPropagation()}>
<button
type="button"
className="icon-button"
aria-label={t.add}
aria-haspopup="menu"
aria-expanded={createMenuOpen}
onClick={() => setCreateMenuOpen((open) => !open)}
>
<Plus />
</button>
{createMenuOpen ? (
<div className="create-menu" role="menu">
<button
type="button"
role="menuitem"
onClick={() => {
setCreateMenuOpen(false);
setCreateOpen(true);
}}
>
<BotIcon />
{t.addAgent}
</button>
<button
type="button"
role="menuitem"
disabled={agents.length < 2}
onClick={() => {
setCreateMenuOpen(false);
setShowChannel(true);
}}
>
<Users />
{t.addChannel}
</button>
</div>
) : null}
</div>
</div>
<label className="search">
<UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.search} aria-label={t.search} />
</label>
{showCreate ? (
<form className="create-inline" onSubmit={(e) => void onCreate(e)}>
<input autoFocus value={newName} placeholder={t.agentNamePlaceholder} onChange={(e) => setNewName(e.target.value)} />
</form>
) : null}
<div className="bot-list">
{visibleChannels.length > 0 ? (
<section className="bot-group">
<div className="group-label">{t.channels}</div>
{visibleChannels.map((channel) => {
const members = channel.memberIds.flatMap((id) => {
const agent = agents.find((row) => row.id === id);
if (!agent) return [];
return [roomMember(agent)];
});
const host = agents.find((row) => row.id === channel.memberIds[0]);
return (
<button
type="button"
key={channel.id}
className={`bot-row room-row ${channel.id === activeChannelId ? "selected" : ""}`}
onClick={() => {
const hostId = channel.memberIds[0];
if (!hostId) return;
setActiveChannelId(channel.id);
void openAgent(hostId, true);
}}
onContextMenu={(event) => {
event.preventDefault();
setChannelMenu({ x: event.clientX, y: event.clientY, channel });
}}
>
<span className="avatar-wrap">
<AvatarStack members={members} online thinkingIds={members.filter((member) => agents.find((row) => row.id === member.id)?.running).map((member) => member.id)} />
</span>
<span className="bot-copy">
<strong>{channel.name}</strong>
<small>{host?.preview || format("membersCount", { count: String(members.length) })}</small>
</span>
<span className="bot-trailing">
{host?.last_at ? <time className="row-time" dateTime={host.last_at}>{formatInboxTime(host.last_at, locale)}</time> : null}
</span>
</button>
);
})}
</section>
) : null}
<section className="bot-group">
<div className="group-label">{t.agents}</div>
{visibleAgents.length === 0 ? <div className="group-label">{agents.length === 0 ? t.noAgents : t.noMatchingAgents}</div> : null}
{visibleAgents.map((agent) => (
<div
<button
type="button"
key={agent.id}
role="button"
tabIndex={0}
className={`bot-row ${agent.id === active?.id ? "selected" : ""}`}
className={`bot-row ${agent.id === active?.id && !activeChannelId ? "selected" : ""}`}
onClick={() => void openAgent(agent.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
void openAgent(agent.id);
}}
onContextMenu={(event) => {
event.preventDefault();
setAgentMenu({ x: event.clientX, y: event.clientY, agent });
}}
>
<span className="avatar-wrap">
<Avatar lookId={agent.id} name={agent.name} active={agent.id === active?.id} online thinking={Boolean(agent.running)} />
<Avatar {...avatarProps(agent)} active={agent.id === active?.id && !activeChannelId} online thinking={Boolean(agent.running)} />
</span>
<span className="bot-copy">
<strong>{agent.name}</strong>
<small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small>
</span>
<button
type="button"
className="row-delete"
title={t.deleteAgent}
aria-label={t.deleteAgent}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onDeleteAgent(agent);
}}
>
<TrashIcon />
</button>
</div>
<span className="bot-trailing">
{agent.last_at ? <time className="row-time" dateTime={agent.last_at}>{formatInboxTime(agent.last_at, locale)}</time> : null}
{agent.tags?.[0] ? <span className="bot-tag side-tag">{agent.tags[0]}</span> : null}
</span>
</button>
))}
</section>
</div>
@ -727,21 +1068,25 @@ export function App() {
<button type="button" className="icon-button mobile-menu" aria-label={t.pickAgent} onClick={() => setSidebarOpen((open) => !open)}>
<UseAnimations animation={menu} size={18} strokeColor="var(--ink)" />
</button>
{active ? (
{activeChannel && channelMembers.length > 0 ? (
<>
<Avatar lookId={active.id} name={active.name} active online thinking={working} size={32} />
<strong id="sand-conversation-heading">{active.name}</strong>
<AvatarStack members={channelMembers} size={32} online />
<strong id="sand-conversation-heading">{activeChannel.name}</strong>
</>
) : active ? (
<button
type="button"
className="topbar-identity"
onClick={() => setSettingsAgent(active)}
>
<Avatar {...avatarProps(active)} active online size={32} />
<strong id="sand-conversation-heading">{active.name}</strong>
</button>
) : (
<strong>{t.pickAgent}</strong>
)}
<span className="grow" />
<nav className="top-tools" aria-label={t.workTools}>
{working ? (
<button type="button" className="top-tool-button" title={t.stop} aria-label={t.stop} onClick={() => void onStop()}>
<Square />
</button>
) : null}
<button
type="button"
className={`top-tool-button ${showStage || overlayOpen ? "active" : ""}`}
@ -765,40 +1110,23 @@ export function App() {
>
{active && transcript.length === 0 && !typing ? (
<div className="welcome">
<Avatar lookId={active.id} name={active.name} active online size={64} />
<Avatar {...avatarProps(active)} active online size={64} />
<h1>{format("startWorkWith", { name: active.name })}</h1>
<p>{t.startWorkHint}</p>
<p>{active.description || t.startWorkHint}</p>
</div>
) : null}
{visibleTranscript(transcript).filter((item) => !(question && item.role === "assistant" && item.content.trim() === question.prompt.trim())).map((item, i, items) => (
<div key={`${item.role}-${item.at || i}-${i}`}>
{item.at && !sameLocalDay(item.at, items[i - 1]?.at) ? (
<div className="day-divider">
<time dateTime={item.at}>{formatDay(item.at, locale)}</time>
</div>
) : null}
<div className={`message ${item.role}`}>
{item.role === "assistant" ? (
<div className="message-stack">
<MarkdownBody content={item.content} />
</div>
) : (
<MarkdownBody content={item.content} />
)}
{item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
</div>
</div>
))}
<MessageLog
transcript={transcript}
questionPrompt={question?.prompt || null}
locale={locale}
agent={active}
/>
</div>
<div className={`composer-dock ${working ? "has-status" : ""}`}>
{working && active ? (
<div className="thinking-row">
<Avatar lookId={active.id} name={active.name} thinking={typing} online />
<Avatar {...avatarProps(active)} thinking={typing} online />
<span className="working-copy">
<span className="working-label">{activityState === "queued" ? t.queuedWork : format("workingWith", { name: active.name })}</span>
{liveStep ? <span className="working-step">{liveStep}</span> : null}
@ -860,7 +1188,7 @@ export function App() {
</button>
) : (
<button className="send" disabled={!active || !hasPayload} title={t.send} aria-label={t.send}>
<UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)" />
<SendArrow />
</button>
)}
</form>
@ -876,16 +1204,7 @@ export function App() {
</header>
<div className="side-card-body">
<div className="computer-part">
<div className="computer-status-row">
<span>{active ? format("computerOf", { name: active.name }) : t.computer}</span>
<i className={`state-dot ${computerBusy ? "booting" : computerReady ? "running" : "booting"}`} />
<small>{computerLive}</small>
</div>
<div className="preview">{!handover && !overlayOpen ? frame : null}{hud}</div>
<div className="computer-caption">
<button type="button" className="outline" onClick={() => setOverlayOpen(true)}>{t.enlarge}</button>
</div>
<p className="computer-login-hint">{t.loginHint}</p>
</div>
</div>
</section>
@ -894,7 +1213,7 @@ export function App() {
<div className="computer-overlay">
<header>
<div>
{active ? <Avatar lookId={active.id} name={active.name} active online thinking={working} size={30} /> : null}
{active ? <Avatar {...avatarProps(active)} active online thinking={working} size={30} /> : null}
<strong>{active ? format("computerOf", { name: active.name }) : t.computer}</strong>
<span className={`control-badge ${computerReady && !computerBusy ? "" : "is-off"}`}>{computerLive}</span>
</div>
@ -910,11 +1229,6 @@ export function App() {
</div>
) : null}
<nav className="phone-tabs" aria-label={t.mainMenu}>
<button type="button" aria-current={!overlayOpen} onClick={() => { setOverlayOpen(false); setSidebarOpen(true); }}>{t.chat}</button>
<button type="button" aria-current={overlayOpen} onClick={openComputer}>{t.computer}</button>
</nav>
{agentMenu ? (
<>
<button type="button" className="context-backdrop" aria-label={t.close} onClick={() => setAgentMenu(null)} />
@ -926,6 +1240,18 @@ export function App() {
top: Math.min(agentMenu.y, window.innerHeight - 80),
}}
>
<button
type="button"
role="menuitem"
onClick={() => {
setSettingsAgent(agentMenu.agent);
setAgentMenu(null);
}}
>
<Settings />
{t.agentSettings}
</button>
<hr />
<button
type="button"
role="menuitem"
@ -939,6 +1265,52 @@ export function App() {
</>
) : null}
{channelMenu ? (
<>
<button type="button" className="context-backdrop" aria-label={t.close} onClick={() => setChannelMenu(null)} />
<div
className="context-menu"
role="menu"
style={{
left: Math.min(channelMenu.x, window.innerWidth - 220),
top: Math.min(channelMenu.y, window.innerHeight - 80),
}}
>
<button
type="button"
role="menuitem"
className="danger-item"
onClick={() => {
const next = channels.filter((channel) => channel.id !== channelMenu.channel.id);
setChannels(next);
writeChannels(next);
if (activeChannelId === channelMenu.channel.id) setActiveChannelId(null);
setChannelMenu(null);
}}
>
<TrashIcon />
{t.deleteChannel}
</button>
</div>
</>
) : null}
{showChannel ? (
<ChannelDialog
agents={agents}
onClose={() => setShowChannel(false)}
onCreate={(name, memberIds) => {
const channel: Channel = { id: crypto.randomUUID(), name, memberIds };
const next = [channel, ...channels];
setChannels(next);
writeChannels(next);
setShowChannel(false);
setActiveChannelId(channel.id);
void openAgent(memberIds[0], true);
}}
/>
) : null}
{accountOverlay === "settings" ? (
<SettingsDialog
onClose={() => setAccountOverlay(null)}
@ -952,6 +1324,28 @@ export function App() {
/>
) : null}
{accountOverlay === "about" ? <AboutDialog onClose={() => setAccountOverlay(null)} /> : null}
{createOpen ? (
<AgentSettingsDialog
mode="create"
onClose={() => setCreateOpen(false)}
onSaved={(row) => {
setCreateOpen(false);
void (async () => {
await loadAgents();
await openAgent(row.id || row.name);
})();
}}
/>
) : null}
{settingsAgent ? (
<AgentSettingsDialog
agent={agents.find((row) => row.id === settingsAgent.id) || settingsAgent}
onClose={() => setSettingsAgent(null)}
onSaved={() => {
void loadAgents();
}}
/>
) : null}
</div>
</AvatarLookProvider>
);

379
web/src/agent-settings.tsx Normal file
View File

@ -0,0 +1,379 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { api, type AgentProfile, type AgentRow } from "./api";
import { X } from "./animated-icons";
import {
Avatar,
AVATAR_COLORS,
CHARACTER_SHAPES,
persistBlobatarShape,
resolveBlobatarShape,
type BlobatarShape,
} from "./avatar";
import {
AvatarUploadPanel,
clampAvatarCrop,
encodeAvatarPng,
initialAvatarCrop,
normalizeDataUrl,
panAvatarCrop,
readImageFile,
type AvatarCrop,
type AvatarImage,
} from "./avatar-upload";
import { useI18n } from "./i18n";
function parseTags(text: string): string[] {
return text.split(/[、,]/).map((tag) => tag.trim()).filter(Boolean).slice(0, 6);
}
function joinTags(tags: string[] | undefined): string {
return (tags || []).join("、");
}
function defaultColor(value?: string) {
const found = AVATAR_COLORS.find((item) => item.value.toLowerCase() === (value || "").toLowerCase());
return found?.value || AVATAR_COLORS[6].value;
}
export function AgentSettingsDialog({
agent,
mode = "edit",
onClose,
onSaved,
}: {
agent?: AgentRow | null;
mode?: "create" | "edit";
onClose: () => void;
onSaved: (row: AgentRow) => void;
}) {
const creating = mode === "create";
const { t } = useI18n();
const pickerRef = useRef<HTMLDivElement>(null);
const avatarHitRef = useRef<HTMLButtonElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [tab, setTab] = useState<"bot" | "upload">("bot");
const [name, setName] = useState(agent?.name || "");
const [title, setTitle] = useState(agent?.title || "");
const [description, setDescription] = useState(agent?.description || "");
const [color, setColor] = useState(defaultColor(agent?.avatar_color));
const [shape, setShape] = useState<BlobatarShape>(resolveBlobatarShape(agent?.avatar_shape));
const [tagText, setTagText] = useState(joinTags(agent?.tags));
const [photoUrl, setPhotoUrl] = useState(agent?.avatar_url || "");
const [source, setSource] = useState<AvatarImage | null>(null);
const [crop, setCrop] = useState<AvatarCrop | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [stagedPng, setStagedPng] = useState<Blob | null>(null);
const [clearedPhoto, setClearedPhoto] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const dismissing = useRef(false);
const ateOutside = useRef(false);
const live = useRef({ creating, agent, name, title, description, tagText, color, shape, photoUrl, source, crop, stagedPng, clearedPhoto, busy, pickerOpen });
live.current = { creating, agent, name, title, description, tagText, color, shape, photoUrl, source, crop, stagedPng, clearedPhoto, busy, pickerOpen };
useEffect(() => {
if (creating || !agent) return;
setName(agent.name);
setTitle(agent.title || "");
setDescription(agent.description || "");
setColor(defaultColor(agent.avatar_color));
setShape(resolveBlobatarShape(agent.avatar_shape));
setTagText(joinTags(agent.tags));
setPhotoUrl(agent.avatar_url || "");
setError("");
}, [creating, agent?.id, agent?.name, agent?.title, agent?.description, agent?.avatar_color, agent?.avatar_shape, agent?.tags, agent?.avatar_url]);
const face = { name: name.trim() || agent?.name || "Agent", color, shape, src: photoUrl || undefined };
const ready = Boolean(name.trim() && (creating ? description.trim() : true));
function profileFrom(state = live.current): AgentProfile {
return {
name: state.name.trim() || state.agent?.name || "Agent",
title: state.title.trim(),
description: state.description.trim(),
tags: parseTags(state.tagText),
avatar_color: state.color,
avatar_shape: persistBlobatarShape(state.shape),
};
}
async function persistAvatar() {
if (dismissing.current || live.current.busy) return;
dismissing.current = true;
live.current.pickerOpen = false;
setPickerOpen(false);
const state = live.current;
try {
if (state.source && state.crop) {
await commitPhoto();
return;
}
if (state.creating || !state.agent) return;
const lookChanged =
persistBlobatarShape(state.shape) !== resolveBlobatarShape(state.agent.avatar_shape) ||
state.color.toLowerCase() !== defaultColor(state.agent.avatar_color).toLowerCase() ||
state.clearedPhoto;
if (!lookChanged) return;
setBusy(true);
setError("");
let row = await api.updateAgent(state.agent.id, profileFrom(state));
if (state.clearedPhoto && state.agent.avatar_url) row = await api.clearAvatar(state.agent.id);
onSaved(row);
} catch (err) {
setError(err instanceof Error ? err.message : t.settingsFailed);
} finally {
setBusy(false);
dismissing.current = false;
}
}
async function saveProfile(closeAfter: boolean) {
const state = live.current;
const nextName = state.name.trim();
if (state.busy) return;
if (state.creating) {
if (!nextName || !state.description.trim()) {
if (closeAfter) onClose();
return;
}
} else if (!state.agent || !nextName) {
if (closeAfter) onClose();
return;
}
setBusy(true);
setError("");
try {
if (state.creating) {
const created = await api.createAgent(profileFrom(state));
if (state.stagedPng) onSaved(await api.setAvatar(created.id, state.stagedPng));
else onSaved(created);
} else if (state.agent) {
let row = await api.updateAgent(state.agent.id, profileFrom(state));
if (state.clearedPhoto && state.agent.avatar_url) row = await api.clearAvatar(state.agent.id);
onSaved(row);
if (closeAfter) onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : t.settingsFailed);
} finally {
setBusy(false);
}
}
const persistAvatarRef = useRef(persistAvatar);
persistAvatarRef.current = persistAvatar;
useEffect(() => {
if (!pickerOpen) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (pickerRef.current?.contains(target) || avatarHitRef.current?.contains(target)) return;
if (target instanceof Element && target.closest(".dialog-title button")) return;
ateOutside.current = true;
void persistAvatarRef.current();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
ateOutside.current = true;
void persistAvatarRef.current();
};
document.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKeyDown, true);
};
}, [pickerOpen]);
async function ingest(file: File) {
setError("");
try {
const image = await normalizeDataUrl(await readImageFile(file));
setSource(image);
setCrop(initialAvatarCrop(image.width, image.height));
setFileName(file.name || t.avatarPasted);
setTab("upload");
setPickerOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : t.settingsFailed);
}
}
async function commitPhoto() {
const state = live.current;
if (!state.source || !state.crop) return;
setBusy(true);
setError("");
try {
const png = await encodeAvatarPng(state.source, state.crop);
if (state.creating || !state.agent) {
setStagedPng(png);
setPhotoUrl(URL.createObjectURL(png));
} else {
const row = await api.setAvatar(state.agent.id, png);
setPhotoUrl(row.avatar_url || "");
onSaved(row);
}
setSource(null);
setCrop(null);
setFileName(null);
setClearedPhoto(false);
setPickerOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : t.settingsFailed);
} finally {
setBusy(false);
}
}
async function resetAvatar() {
setSource(null);
setCrop(null);
setFileName(null);
setStagedPng(null);
setClearedPhoto(true);
setShape("cloud");
setColor(AVATAR_COLORS[6].value);
setTab("bot");
setPhotoUrl("");
const state = live.current;
if (state.creating || !state.agent || !state.agent.avatar_url) return;
setBusy(true);
setError("");
try {
onSaved(await api.clearAvatar(state.agent.id));
} catch (err) {
setError(err instanceof Error ? err.message : t.settingsFailed);
} finally {
setBusy(false);
}
}
function onSubmit(event: FormEvent) {
event.preventDefault();
void saveProfile(!creating);
}
return (
<div
className="modal-backdrop"
onClick={() => {
if (ateOutside.current) {
ateOutside.current = false;
return;
}
void saveProfile(true);
}}
>
<form
className="dialog settings-dialog agent-settings"
onClick={(event) => event.stopPropagation()}
onSubmit={onSubmit}
onPaste={(event) => {
const item = Array.from(event.clipboardData.items).find((candidate) => candidate.kind === "file" && candidate.type.toLowerCase().startsWith("image/"));
const file = item?.getAsFile();
if (!file) return;
event.preventDefault();
void ingest(file);
}}
>
<div className="dialog-title">
<h2>{creating ? t.addAgent : t.agentSettings}</h2>
<button type="button" aria-label={t.close} onClick={onClose}><X /></button>
</div>
<button
ref={avatarHitRef}
type="button"
className="avatar-hit"
onClick={() => {
if (pickerOpen) void persistAvatar();
else setPickerOpen(true);
}}
aria-label={t.avatarAppearance}
>
<Avatar {...face} size={88} />
</button>
{pickerOpen ? (
<div className="avatar-picker" ref={pickerRef}>
<div className="avatar-picker-bar">
<div className="avatar-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === "bot"} className={tab === "bot" ? "selected" : ""} onClick={() => setTab("bot")}>{t.avatarBot}</button>
<button type="button" role="tab" aria-selected={tab === "upload"} className={tab === "upload" ? "selected" : ""} onClick={() => setTab("upload")}>{t.avatarUpload}</button>
</div>
<button type="button" className="avatar-reset" disabled={busy} onClick={() => void resetAvatar()}>{t.avatarReset}</button>
</div>
{tab === "bot" ? (
<>
<div className="shape-grid" role="group" aria-label={t.shape}>
{CHARACTER_SHAPES.map((item) => (
<button
type="button"
className={shape === item.shape ? "selected" : ""}
onClick={() => { setShape(item.shape); setStagedPng(null); setPhotoUrl(""); setClearedPhoto(true); }}
key={item.id}
title={item.id}
aria-label={item.id}
>
<Avatar name={face.name} color={color} shape={item.shape} gaze={false} size={44} />
</button>
))}
</div>
<div className="color-grid" role="group" aria-label={t.color}>
{AVATAR_COLORS.map((item) => (
<button
type="button"
key={item.id}
className={color.toLowerCase() === item.value.toLowerCase() ? "selected" : ""}
style={{ background: item.value }}
onClick={() => setColor(item.value)}
aria-label={item.id}
/>
))}
</div>
</>
) : (
<AvatarUploadPanel
source={source}
crop={crop}
fileName={fileName}
busy={busy}
error={error}
onFile={(file) => void ingest(file)}
onZoom={(zoom) => source && crop && setCrop(clampAvatarCrop(source, { ...crop, zoom }))}
onPan={(dx, dy) => source && crop && setCrop(panAvatarCrop(source, crop, dx, dy))}
onReset={() => { setSource(null); setCrop(null); setFileName(null); }}
onSave={() => void commitPhoto()}
/>
)}
</div>
) : (
<>
<label>
{t.name}
<input autoFocus={creating} required value={name} maxLength={80} onChange={(event) => setName(event.target.value)} />
</label>
<label>
{t.tags}
<input value={tagText} maxLength={120} onChange={(event) => setTagText(event.target.value)} placeholder={t.tagsPlaceholder} />
</label>
<label>
{t.shortTitle}
<input value={title} maxLength={100} onChange={(event) => setTitle(event.target.value)} placeholder={t.shortTitlePlaceholder} />
</label>
<label>
{t.description}
<textarea required={creating} value={description} maxLength={1000} rows={4} onChange={(event) => setDescription(event.target.value)} placeholder={t.descriptionPlaceholder} />
</label>
{error ? <div className="pane-error">{error}</div> : null}
<div className="dialog-actions">
<button type="button" className="outline" onClick={onClose}>{t.cancel}</button>
<button className="primary" disabled={busy || !ready}>{busy ? (creating ? t.creating : t.saving) : creating ? t.create : t.saveSettings}</button>
</div>
</>
)}
</form>
</div>
);
}

View File

@ -12,7 +12,6 @@ import help from "react-useanimations/lib/help";
import info from "react-useanimations/lib/info";
import menu3 from "react-useanimations/lib/menu3";
import notification from "react-useanimations/lib/notification";
import playPause from "react-useanimations/lib/playPause";
import plusToX from "react-useanimations/lib/plusToX";
import pocket from "react-useanimations/lib/pocket";
import settings2 from "react-useanimations/lib/settings2";
@ -57,7 +56,11 @@ export const Plus=animatedIcon(plusToX,17);
export const RefreshCw=animatedIcon(skipBack,14);
export const Settings=animatedIcon(settings2,16);
export const Smartphone=animatedIcon(video,16);
export const Square=animatedIcon(playPause,14);
export function Square({className,size=14}:IconProps){
const pad=Math.round(size*0.22);
const inner=size-pad*2;
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true"><rect x={pad} y={pad} width={inner} height={inner} rx={Math.max(2,Math.round(size*0.12))} fill="currentColor"/></svg>;
}
export const Users=animatedIcon(userPlus,17);
export const X=animatedIcon(plusToX,16,true);

View File

@ -17,16 +17,31 @@ export type AgentRow = {
id: string;
name: string;
expertise?: string;
title?: string;
description?: string;
tags?: string[];
avatar_color?: string;
avatar_shape?: string;
has_avatar?: boolean;
avatar_url?: string | null;
preview?: string;
last_at?: string | null;
running?: boolean;
activity?: AgentActivity;
};
export type AgentProfile = {
name: string;
title: string;
description: string;
tags: string[];
avatar_color: string;
avatar_shape: string;
};
export type TranscriptItem = { role: "user" | "assistant"; content: string; at?: string };
export type AgentDetail = {
id: string;
name: string;
export type AgentDetail = AgentRow & {
expertise: string;
preview: string;
transcript: TranscriptItem[];
@ -67,7 +82,22 @@ export const api = {
const agents = Array.isArray(data) ? data : data.agents || [];
return { agents };
},
createAgent: (name: string) => request<AgentRow>("/api/agents", { method: "POST", body: JSON.stringify({ name }) }),
createAgent: (profile: AgentProfile) =>
request<AgentRow>("/api/agents", { method: "POST", body: JSON.stringify(profile) }),
updateAgent: (id: string, profile: AgentProfile) =>
request<AgentRow>(`/api/agents/${id}`, { method: "PATCH", body: JSON.stringify(profile) }),
setAvatar: async (id: string, png: Blob) => {
const res = await fetch(`/api/agents/${encodeURIComponent(id)}/avatar`, {
method: "PUT",
headers: { "content-type": "image/png" },
body: png,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || String(res.status));
return data as AgentRow;
},
clearAvatar: (id: string) =>
request<AgentRow>(`/api/agents/${encodeURIComponent(id)}/avatar`, { method: "DELETE" }),
deleteAgent: (id: string) => request<{ ok: boolean; id: string; name: string }>(`/api/agents/${id}`, { method: "DELETE" }),
agent: (id: string) => request<AgentDetail>(`/api/agents/${id}`),
activity: (id: string) => request<AgentActivity>(`/api/agents/${id}/activity`, { signal: AbortSignal.timeout(8000) }),

216
web/src/avatar-upload.tsx Normal file
View File

@ -0,0 +1,216 @@
import { useRef, useState, type DragEvent, type PointerEvent } from "react";
import { useI18n } from "./i18n";
export const AVATAR_SOURCE_MAX_BYTES = 25 * 1024 * 1024;
export const AVATAR_SOURCE_MAX_DIMENSION = 1024;
export const AVATAR_OUTPUT_SIZE = 256;
export const AVATAR_STAGE_SIZE = 260;
export const AVATAR_MIN_ZOOM = 1;
export const AVATAR_MAX_ZOOM = 5;
export type AvatarImage = { dataUrl: string; width: number; height: number };
export type AvatarCrop = { zoom: number; centerX: number; centerY: number };
export function initialAvatarCrop(width: number, height: number): AvatarCrop {
return { zoom: AVATAR_MIN_ZOOM, centerX: width / 2, centerY: height / 2 };
}
function clampZoom(value: number): number {
return Number.isFinite(value) ? Math.min(AVATAR_MAX_ZOOM, Math.max(AVATAR_MIN_ZOOM, value)) : AVATAR_MIN_ZOOM;
}
function visibleSide(source: Pick<AvatarImage, "width" | "height">, zoom: number): number {
return Math.min(source.width, source.height) / clampZoom(zoom);
}
export function clampAvatarCrop(source: Pick<AvatarImage, "width" | "height">, crop: AvatarCrop): AvatarCrop {
const zoom = clampZoom(crop.zoom);
const side = visibleSide(source, zoom) / 2;
return {
zoom,
centerX: Math.min(source.width - side, Math.max(side, crop.centerX)),
centerY: Math.min(source.height - side, Math.max(side, crop.centerY)),
};
}
export function panAvatarCrop(source: AvatarImage, crop: AvatarCrop, deltaX: number, deltaY: number): AvatarCrop {
const zoom = clampZoom(crop.zoom);
const scale = AVATAR_STAGE_SIZE / Math.min(source.width, source.height) * zoom;
return clampAvatarCrop(source, {
zoom,
centerX: crop.centerX - deltaX / scale,
centerY: crop.centerY - deltaY / scale,
});
}
function cropRect(source: AvatarImage, crop: AvatarCrop) {
const bounded = clampAvatarCrop(source, crop);
const side = visibleSide(source, bounded.zoom);
return { x: bounded.centerX - side / 2, y: bounded.centerY - side / 2, width: side, height: side };
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => image.naturalWidth > 0 && image.naturalHeight > 0 ? resolve(image) : reject(new Error("That image could not be loaded."));
image.onerror = () => reject(new Error("That image could not be loaded."));
image.src = dataUrl;
});
}
function canvasContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not export the avatar.");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
return context;
}
export async function normalizeDataUrl(dataUrl: string): Promise<AvatarImage> {
const image = await loadImage(dataUrl);
const longest = Math.max(image.naturalWidth, image.naturalHeight);
if (longest <= AVATAR_SOURCE_MAX_DIMENSION) {
return { dataUrl, width: image.naturalWidth, height: image.naturalHeight };
}
const scale = AVATAR_SOURCE_MAX_DIMENSION / longest;
const width = Math.max(1, Math.round(image.naturalWidth * scale));
const height = Math.max(1, Math.round(image.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvasContext(canvas).drawImage(image, 0, 0, width, height);
return { dataUrl: canvas.toDataURL("image/png"), width, height };
}
export function readImageFile(file: File): Promise<string> {
if (file.size > AVATAR_SOURCE_MAX_BYTES) return Promise.reject(new Error("Choose an image smaller than 25 MB."));
if (!file.type.toLowerCase().startsWith("image/")) return Promise.reject(new Error("Choose an image file."));
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("That file could not be read."));
reader.onerror = () => reject(new Error("That file could not be read."));
reader.readAsDataURL(file);
});
}
export async function encodeAvatarPng(source: AvatarImage, crop: AvatarCrop): Promise<Blob> {
const image = await loadImage(source.dataUrl);
const canvas = document.createElement("canvas");
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const rect = cropRect(source, crop);
canvasContext(canvas).drawImage(image, rect.x, rect.y, rect.width, rect.height, 0, 0, AVATAR_OUTPUT_SIZE, AVATAR_OUTPUT_SIZE);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Could not export the avatar.")), "image/png");
});
}
export function AvatarUploadPanel({
source,
crop,
fileName,
busy,
error,
onFile,
onZoom,
onPan,
onReset,
onSave,
}: {
source: AvatarImage | null;
crop: AvatarCrop | null;
fileName: string | null;
busy?: boolean;
error?: string;
onFile: (file: File) => void;
onZoom: (zoom: number) => void;
onPan: (dx: number, dy: number) => void;
onReset: () => void;
onSave: () => void;
}) {
const { t } = useI18n();
const pointer = useRef<{ id: number; x: number; y: number } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const take = (list: FileList | File[] | null) => {
const file = Array.from(list || []).find((item) => item.type.toLowerCase().startsWith("image/"));
if (file) onFile(file);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragOver(false);
take(event.dataTransfer.files);
};
const imageStyle = source && crop ? (() => {
const scale = AVATAR_STAGE_SIZE / Math.min(source.width, source.height) * crop.zoom;
return {
width: source.width * scale,
height: source.height * scale,
transform: `translate(${AVATAR_STAGE_SIZE / 2 - crop.centerX * scale}px, ${AVATAR_STAGE_SIZE / 2 - crop.centerY * scale}px)`,
};
})() : undefined;
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
pointer.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const current = pointer.current;
if (!current || current.id !== event.pointerId) return;
onPan(event.clientX - current.x, event.clientY - current.y);
current.x = event.clientX;
current.y = event.clientY;
};
const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (pointer.current?.id !== event.pointerId) return;
pointer.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
if (source && crop) {
return (
<div className="avatar-upload">
<div
className="avatar-stage"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
role="img"
aria-label={t.avatarReposition}
>
<img alt="" draggable={false} src={source.dataUrl} style={imageStyle} />
<span className="avatar-stage-mask" aria-hidden="true" />
</div>
<small>{fileName ? `${fileName} · ${source.width}×${source.height}` : t.avatarReposition}</small>
<div className="avatar-zoom">
<button type="button" disabled={busy} onClick={() => onZoom(crop.zoom - 0.5)} aria-label="-"></button>
<input type="range" min={1} max={5} step={0.01} value={crop.zoom} disabled={busy} onChange={(event) => onZoom(Number.parseFloat(event.currentTarget.value))} aria-label={t.avatarZoom} />
<button type="button" disabled={busy} onClick={() => onZoom(crop.zoom + 0.5)} aria-label="+">+</button>
</div>
<div className="pane-actions">
<button type="button" className="outline" disabled={busy} onClick={onReset}>{t.avatarRestart}</button>
<button type="button" className="primary" disabled={busy} onClick={onSave}>{busy ? t.saving : t.avatarSet}</button>
</div>
{error ? <div className="pane-error">{error}</div> : null}
</div>
);
}
return (
<div
className={`avatar-dropzone ${dragOver ? "over" : ""}`}
onDragOver={(event) => { event.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
>
<strong>{t.avatarDrop}</strong>
<span>{t.avatarOr}</span>
<button type="button" className="outline" onClick={() => fileRef.current?.click()}>{t.avatarBrowse}</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(event) => { take(event.currentTarget.files); event.currentTarget.value = ""; }} />
{error ? <div className="pane-error">{error}</div> : null}
</div>
);
}

View File

@ -17,7 +17,7 @@ import {
wink,
type Expression,
} from "blobatar/expression";
import { createContext, useContext, type CSSProperties, type ReactNode } from "react";
import { createContext, memo, useContext, useMemo, type CSSProperties, type ReactNode } from "react";
import type { AvatarShape, RoomMember } from "./types";
export const BLOBATAR_SHAPES = [
@ -35,6 +35,32 @@ export const BLOBATAR_SHAPES = [
export type BlobatarShape = (typeof BLOBATAR_SHAPES)[number];
/** grok-bot character roster, mapped onto Blobatar's silhouettes. */
export const CHARACTER_SHAPES: readonly { id: string; shape: BlobatarShape }[] = [
{ id: "blob", shape: "round" },
{ id: "pebble", shape: "organic" },
{ id: "squircle", shape: "boxy" },
{ id: "tablet", shape: "capsule" },
{ id: "wedge", shape: "triangle" },
{ id: "hex", shape: "hexagon" },
{ id: "cloud", shape: "cloud" },
{ id: "teardrop", shape: "droplet" },
];
export const AVATAR_COLORS = [
{ id: "white", value: "#F1F2F2" },
{ id: "brown", value: "#936439" },
{ id: "red", value: "#FF263C" },
{ id: "orange", value: "#FF6700" },
{ id: "yellow", value: "#FF9800" },
{ id: "green", value: "#00C972" },
{ id: "cyan", value: "#00BCA6" },
{ id: "blue", value: "#1084FE" },
{ id: "violet", value: "#9159FE" },
{ id: "magenta", value: "#FF309B" },
{ id: "gray", value: "#777777" },
] as const;
export const BLOBATAR_EXPRESSIONS = [
"idle",
"happy",
@ -96,8 +122,13 @@ const SHAPE_TRAIT: Record<BlobatarShape, number> = {
};
const SHAPE_ALIAS: Record<string, BlobatarShape> = {
blob: "organic",
blob: "round",
pebble: "organic",
squircle: "boxy",
tablet: "capsule",
wedge: "triangle",
hex: "hexagon",
teardrop: "droplet",
diamond: "boxy",
drop: "droplet",
cat: "nub",
@ -156,22 +187,8 @@ export function resolveBlobatarShape(shape: string | undefined | null): Blobatar
return "organic";
}
/** Persist using names the existing API already accepts. */
const STORE_SHAPE: Record<BlobatarShape, AvatarShape> = {
round: "round",
organic: "blob",
boxy: "squircle",
capsule: "capsule",
nub: "paw",
cloud: "cloud",
droplet: "drop",
hexagon: "hexagon",
sun: "star",
triangle: "triangle",
};
export function persistBlobatarShape(shape: string | undefined | null): AvatarShape {
return STORE_SHAPE[resolveBlobatarShape(shape)];
return resolveBlobatarShape(shape);
}
function isExpression(value: unknown): value is AvatarExpression {
@ -203,7 +220,7 @@ function blobBackground(value: AvatarBackground | undefined): false | "circle" |
return !value || value === "none" ? false : value;
}
export function Avatar({
export const Avatar = memo(function Avatar({
name,
color,
shape = "blob",
@ -215,6 +232,7 @@ export function Avatar({
expression,
background,
gaze = true,
src,
}: {
name: string;
color?: string;
@ -227,6 +245,7 @@ export function Avatar({
expression?: AvatarExpression;
background?: AvatarBackground;
gaze?: boolean;
src?: string | null;
}) {
const stored = useContext(AvatarLookContext)[lookId || ""] || DEFAULT_LOOK;
const resolved = color || fallbackColor(name);
@ -235,28 +254,35 @@ export function Avatar({
const pose = poseName === "idle" ? undefined : EXPRESSION_POSE[poseName];
const plate = blobBackground(background || stored.background);
const travel = Math.max(1.8, Math.min(4, size * 0.045));
const traits = useMemo(() => ({ shape: SHAPE_TRAIT[silhouette] }), [silhouette]);
const palette = useMemo(() => ({ head: resolved, eye: contrastEye(resolved) }), [resolved]);
const { ref } = useGaze({ travel, lookAt: gaze ? "pointer" : null });
const photo = src?.trim() || "";
return (
<span
className={`avatar blobatar ${active ? "online" : ""} ${thinking ? "thinking" : ""}`}
className={`avatar blobatar ${photo ? "photo" : ""} ${active ? "online" : ""} ${thinking ? "thinking" : ""}`}
style={{ "--bot-color": resolved, "--avatar-size": `${size}px` } as CSSProperties}
>
<Blobatar
ref={ref}
name={name}
animate="always"
expression={pose}
background={plate}
traits={{ shape: SHAPE_TRAIT[silhouette] }}
palette={{ head: resolved, eye: contrastEye(resolved) }}
title={name}
/>
{photo ? (
<img alt="" draggable={false} src={photo} />
) : (
<Blobatar
ref={ref}
name={name}
animate="always"
expression={pose}
background={plate}
traits={traits}
palette={palette}
title={name}
/>
)}
{(online || active) && <i className="presence" aria-hidden="true" />}
</span>
);
}
});
export function AvatarStack({
export const AvatarStack = memo(function AvatarStack({
members,
size = 38,
online = false,
@ -275,6 +301,7 @@ export function AvatarStack({
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
src={member.avatarUrl}
size={size}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={online}
@ -300,6 +327,7 @@ export function AvatarStack({
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
src={member.avatarUrl}
size={miniSize}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={Boolean(online && index === shown.length - 1)}
@ -316,4 +344,4 @@ export function AvatarStack({
)}
</span>
);
}
});

26
web/src/channels.ts Normal file
View File

@ -0,0 +1,26 @@
export type Channel = {
id: string;
name: string;
memberIds: string[];
};
const KEY = "lazyboy.channels";
export function readChannels(): Channel[] {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((row) => {
if (!row || typeof row !== "object") return [];
const item = row as Partial<Channel>;
if (typeof item.id !== "string" || typeof item.name !== "string" || !Array.isArray(item.memberIds)) return [];
return [{ id: item.id, name: item.name, memberIds: item.memberIds.filter((id) => typeof id === "string") }];
});
} catch {
return [];
}
}
export function writeChannels(channels: Channel[]): void {
localStorage.setItem(KEY, JSON.stringify(channels));
}

View File

@ -44,9 +44,53 @@ const zhHant = {
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
localBuild: "本機版本",
close: "關閉",
add: "新增",
addAgent: "新增 Agent",
createAgentLead: "先把人設設好。名稱和簡介必填,這個 Agent 會照這個角色說話。",
addChannel: "新增頻道",
channels: "頻道",
channelName: "頻道名稱",
channelNamePlaceholder: "例如:專案討論",
channelDescription: "選至少兩個 Agent 拉進這個頻道。",
chooseAgents: "選擇 Agent",
channelHostHint: "這個頻道的訊息會由 {name} 主回。",
createChannel: "建立頻道",
deleteChannel: "刪除頻道",
membersCount: "{count} 位成員",
creating: "建立中…",
cancel: "取消",
create: "建立",
deleteAgent: "刪除 Agent",
deleteAgentConfirm: "刪除 {name}?對話與記憶會一併刪掉,無法復原。",
agentSettings: "Agent 設定",
avatarAppearance: "頭像",
avatarAppearanceHint: "選 Blobatar 的 10 種形狀,或上傳自己的照片。",
avatarBot: "Bot",
avatarUpload: "上傳",
avatarDrop: "拖放或貼上圖片",
avatarOr: "或",
avatarBrowse: "選擇檔案",
avatarSet: "設為頭像",
avatarReset: "重設",
avatarRestart: "重來",
avatarReposition: "拖曳調整位置",
avatarZoom: "縮放",
avatarPasted: "貼上的圖片",
color: "顏色",
shape: "形狀",
chooseColor: "選擇顏色 {value}",
customColor: "自訂顏色",
name: "名稱",
tags: "標籤(選填)",
tagsPlaceholder: "",
tagsLimit: "用逗號分開,其他 Agent 找人時看得到。",
shortTitle: "標題",
shortTitlePlaceholder: "",
description: "描述",
descriptionPlaceholder: "這個 Agent 是誰、怎麼說話、擅長什麼。會用來產生人設。",
saveSettings: "儲存",
saving: "儲存中…",
settingsFailed: "設定儲存失敗",
search: "搜尋",
agentNamePlaceholder: "Agent 名稱",
agents: "Agent",
@ -137,9 +181,53 @@ const zhHans: Messages = {
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
localBuild: "本地版本",
close: "关闭",
add: "新增",
addAgent: "新增 Agent",
createAgentLead: "先把人设设好。名称和简介必填,这个 Agent 会照这个角色说话。",
addChannel: "新增频道",
channels: "频道",
channelName: "频道名称",
channelNamePlaceholder: "例如:项目讨论",
channelDescription: "选至少两个 Agent 拉进这个频道。",
chooseAgents: "选择 Agent",
channelHostHint: "这个频道的消息会由 {name} 主回。",
createChannel: "建立频道",
deleteChannel: "删除频道",
membersCount: "{count} 位成员",
creating: "建立中…",
cancel: "取消",
create: "建立",
deleteAgent: "删除 Agent",
deleteAgentConfirm: "删除 {name}?对话和记忆会一并删掉,无法恢复。",
agentSettings: "Agent 设置",
avatarAppearance: "头像",
avatarAppearanceHint: "选 Blobatar 的 10 种形状,或上传自己的照片。",
avatarBot: "Bot",
avatarUpload: "上传",
avatarDrop: "拖放或粘贴图片",
avatarOr: "或",
avatarBrowse: "选择文件",
avatarSet: "设为头像",
avatarReset: "重设",
avatarRestart: "重来",
avatarReposition: "拖曳调整位置",
avatarZoom: "缩放",
avatarPasted: "粘贴的图片",
color: "颜色",
shape: "形状",
chooseColor: "选择颜色 {value}",
customColor: "自定义颜色",
name: "名称",
tags: "标签(选填)",
tagsPlaceholder: "",
tagsLimit: "用逗号分开,其他 Agent 找人时看得到。",
shortTitle: "标题",
shortTitlePlaceholder: "",
description: "描述",
descriptionPlaceholder: "这个 Agent 是谁、怎么说话、擅长什么。会用来产生人设。",
saveSettings: "保存",
saving: "保存中…",
settingsFailed: "设置保存失败",
search: "搜索",
agentNamePlaceholder: "Agent 名称",
agents: "Agent",
@ -227,9 +315,53 @@ const en: Messages = {
computerBusyCopy: "An agent is working. This will interrupt it.",
localBuild: "Local build",
close: "Close",
add: "Add",
addAgent: "New agent",
createAgentLead: "Set the persona first. Name and description are required — this agent will speak from that identity.",
addChannel: "New channel",
channels: "Channels",
channelName: "Channel name",
channelNamePlaceholder: "e.g. Project chat",
channelDescription: "Pick at least two agents to pull into this channel.",
chooseAgents: "Choose agents",
channelHostHint: "{name} will answer in this channel.",
createChannel: "Create channel",
deleteChannel: "Delete channel",
membersCount: "{count} members",
creating: "Creating…",
cancel: "Cancel",
create: "Create",
deleteAgent: "Delete agent",
deleteAgentConfirm: "Delete {name}? Its chats and memory will be removed. This cannot be undone.",
agentSettings: "Agent settings",
avatarAppearance: "Avatar",
avatarAppearanceHint: "Pick one of Blobatar's 10 shapes, or upload your own photo.",
avatarBot: "Bot",
avatarUpload: "Upload",
avatarDrop: "Drag, drop, or paste an image",
avatarOr: "or",
avatarBrowse: "Browse files",
avatarSet: "Set avatar",
avatarReset: "Reset",
avatarRestart: "Restart",
avatarReposition: "Drag to reposition",
avatarZoom: "Zoom",
avatarPasted: "Pasted image",
color: "Color",
shape: "Shape",
chooseColor: "Choose color {value}",
customColor: "Custom color",
name: "Name",
tags: "Tags (optional)",
tagsPlaceholder: "",
tagsLimit: "Comma-separated. Other agents can find this one by tags.",
shortTitle: "Title",
shortTitlePlaceholder: "",
description: "Description",
descriptionPlaceholder: "Who this agent is, how they speak, and what they are good at. Used to form their persona.",
saveSettings: "Save",
saving: "Saving…",
settingsFailed: "Could not save settings",
search: "Search",
agentNamePlaceholder: "Agent name",
agents: "Agents",
@ -317,9 +449,53 @@ const ja: Messages = {
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
localBuild: "ローカルビルド",
close: "閉じる",
add: "追加",
addAgent: "Agent を追加",
createAgentLead: "先にペルソナを決めてください。名前と紹介は必須です。この Agent はその設定どおりに話します。",
addChannel: "チャンネルを追加",
channels: "チャンネル",
channelName: "チャンネル名",
channelNamePlaceholder: "例:プロジェクト",
channelDescription: "チャンネルに入れる Agent を 2 人以上選んでください。",
chooseAgents: "Agent を選ぶ",
channelHostHint: "このチャンネルの返信は {name} が担当します。",
createChannel: "チャンネルを作成",
deleteChannel: "チャンネルを削除",
membersCount: "{count} 人",
creating: "作成中…",
cancel: "キャンセル",
create: "作成",
deleteAgent: "Agent を削除",
deleteAgentConfirm: "{name} を削除しますか?会話と記憶も消えます。元に戻せません。",
agentSettings: "Agent の設定",
avatarAppearance: "アバター",
avatarAppearanceHint: "Blobatar の 10 種類の形から選ぶか、写真をアップロードします。",
avatarBot: "Bot",
avatarUpload: "アップロード",
avatarDrop: "画像をドロップまたは貼り付け",
avatarOr: "または",
avatarBrowse: "ファイルを選ぶ",
avatarSet: "アバターに設定",
avatarReset: "リセット",
avatarRestart: "やり直す",
avatarReposition: "ドラッグして位置を調整",
avatarZoom: "ズーム",
avatarPasted: "貼り付けた画像",
color: "色",
shape: "形",
chooseColor: "色 {value} を選ぶ",
customColor: "カスタムカラー",
name: "名前",
tags: "タグ(任意)",
tagsPlaceholder: "",
tagsLimit: "読点またはカンマで区切ります。他の Agent が探すときに使います。",
shortTitle: "肩書き",
shortTitlePlaceholder: "",
description: "説明",
descriptionPlaceholder: "この Agent は誰か、話し方、得意なこと。ペルソナの材料になります。",
saveSettings: "保存",
saving: "保存中…",
settingsFailed: "設定を保存できませんでした",
search: "検索",
agentNamePlaceholder: "Agent 名",
agents: "Agent",

View File

@ -3,6 +3,7 @@
.topbar .avatar{width:32px;height:32px}
.avatar.blobatar,.avatar.blobatar.online{position:relative;display:inline-grid;place-items:center;flex:0 0 var(--avatar-size);width:var(--avatar-size);height:var(--avatar-size);overflow:visible;border:0;border-radius:0;background:transparent;box-shadow:none;color:unset}
.avatar.blobatar>svg,.avatar.blobatar>img{display:block;width:100%;height:100%}
.avatar.blobatar.photo>img{border-radius:50%;object-fit:cover}
.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after{content:"";position:absolute;z-index:4;inset:-6px;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 8%,#ff4fd8 11%,#7c5cff 18%,transparent 25% 43%,#34d9ff 48%,#58f39a 55%,transparent 62% 78%,#ffe66d 82%,#ff7a59 89%,transparent 96%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));pointer-events:none;animation:magic-orbit 1.35s linear infinite}
.avatar.blobatar.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);animation-duration:2.1s;animation-direction:reverse}
.avatar-wrap{position:relative;display:grid;flex:0 0 auto}

View File

@ -1,11 +1,14 @@
.messages{flex:1;overflow:auto;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.message{display:flex}
.message>.message-body,.message-stack>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.chat-panel{min-height:0;overflow:hidden}
.messages{flex:1 1 0;min-height:0;min-width:0;overflow-x:hidden;overflow-y:auto;padding:20px max(34px,7vw) 12px;display:flex;flex-direction:column;gap:0}
.messages>*{flex:0 0 auto;min-height:min-content}
.message{display:flex;flex-direction:row;align-items:flex-end;gap:6px}
.message>.message-body,.message-stack>.message-body{max-width:100%;padding:9px 12px;border-radius:16px;white-space:pre-wrap;line-height:1.45;font-size:15px}
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
.message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:var(--on-cream)}
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;max-width:76%;min-width:0}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;width:fit-content;max-width:min(32rem,calc(100% - 92px));min-width:0}
.message.user>.message-body{width:fit-content;max-width:min(32rem,100%)}
.message-stack>.message-body{max-width:100%}
.copy-msg{display:inline-flex;align-items:center;gap:6px;height:28px;margin-top:6px;padding:0 8px 0 6px;border:0;border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.copy-msg svg{flex:0 0 14px}
@ -15,17 +18,21 @@
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
.composer-plus{background:transparent;color:var(--muted)}
.composer svg{width:18px}
.messages{min-width:0;padding-bottom:24px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
.message{box-sizing:border-box;width:var(--chat-col);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:82%}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:0}
.messages{min-width:0;padding-bottom:12px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
.message-block{display:flex;flex-direction:column;gap:8px;box-sizing:border-box;width:var(--chat-col);max-width:100%;margin:10px auto 0}
.message-block:first-child{margin-top:0}
.message-block.tight{margin-top:3px;gap:0}
.message-block.has-day{margin-top:18px;gap:10px}
.message{box-sizing:border-box;width:100%;max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:none}
.thinking-row{display:flex;align-items:flex-start;gap:18px;width:min(760px,100%);margin:0}
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}
.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}
.thinking-dots i:nth-child(2){animation-delay:.16s}
.thinking-dots i:nth-child(3){animation-delay:.32s}
.composer-dock{position:relative;z-index:6;display:flex;flex-direction:column;align-items:center;gap:20px;padding:28px var(--chat-gutter) 20px;background:var(--main);pointer-events:none;flex:none;width:100%}
.composer-dock:before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(180deg,transparent,var(--main));pointer-events:none}
.composer-dock{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;gap:12px;padding:12px var(--chat-gutter) 20px;background:var(--main);pointer-events:none;flex:0 0 auto;width:100%}
.composer-dock:before{content:"";position:absolute;left:0;right:0;bottom:100%;height:16px;background:linear-gradient(180deg,transparent,var(--main));pointer-events:none}
.composer-dock>*{box-sizing:border-box;pointer-events:auto;width:var(--chat-col);max-width:760px;min-width:0;margin-inline:auto}
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-width:0;max-width:100%;min-height:62px;align-items:center;padding:9px 10px;transform:none}
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
@ -50,9 +57,11 @@
.message.with-files .message-body,.message.with-files .message-stack{max-width:82%}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:var(--cream);color:var(--on-send)}
.stop-send svg{width:14px;height:14px;fill:currentColor}
.stop-send svg{width:16px;height:16px;display:block;fill:currentColor}
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.message{position:relative;padding-right:0;-webkit-touch-callout:none;touch-action:manipulation}
.message{position:relative;padding:0;-webkit-touch-callout:none;touch-action:manipulation}
.msg-avatar{flex:0 0 28px;width:28px;min-width:28px;height:28px;align-self:flex-end}
.msg-avatar .avatar{width:28px;height:28px}
.messages .remember-msg{display:none}
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
@ -60,8 +69,9 @@
.message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
.message.assistant.spoken .speaker{grid-column:2;font-size:12px;font-weight:650;line-height:1.2;padding:0 6px;color:var(--accent)}
.message.assistant.spoken>span{grid-column:2;max-width:82%;border-radius:6px 18px 18px 18px}
.thinking-row{width:100%;margin:0;padding-left:2px}
.working-copy{display:flex;flex-direction:column;gap:8px;min-width:0}
.thinking-row{width:100%;margin:0;padding:2px 2px 2px 4px}
.thinking-row .avatar{flex:0 0 auto;margin-top:2px}
.working-copy{display:flex;flex-direction:column;gap:6px;min-width:0;padding-left:6px}
.working-step{color:var(--muted);font-size:12px;letter-spacing:.02em;line-height:1.35}
/* Speaker rows: the coloured speaker label must breathe away from the text. */
.message.assistant.spoken{column-gap:11px;row-gap:6px}
@ -74,14 +84,19 @@
.slash-suggestions button[aria-selected="true"]{background:rgba(255,255,255,.08)}
.slash-suggestions small{flex-shrink:0;color:var(--muted);font-size:11px}
.message{padding-bottom:21px}
.message-time{position:absolute;bottom:0;left:0;font-size:11px;line-height:17px;color:var(--muted);font-variant-numeric:tabular-nums}
.message.user .message-time{left:auto;right:0}
.message-time{position:static;flex:0 0 auto;max-width:none;padding:0 0 2px;font-size:11px;line-height:1.2;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.message.assistant .message-body,.message.assistant .message-stack .message-body{border-radius:16px}
.message.assistant.cluster-follow .message-body,.message.assistant.cluster-follow .message-stack .message-body{border-top-left-radius:6px}
.message.assistant:not(.cluster-end) .message-body,.message.assistant:not(.cluster-end) .message-stack .message-body{border-bottom-left-radius:6px}
.message.user .message-body{border-radius:16px}
.message.user.cluster-follow .message-body{border-top-right-radius:6px}
.message.user:not(.cluster-end) .message-body{border-bottom-right-radius:6px}
/* The day is said once, centred above the first message of that day, so each
message only needs its clock time. */
.day-divider{display:flex;justify-content:center;width:var(--chat-col);margin:6px auto -4px;user-select:none}
.day-divider time{padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.06);color:var(--muted);font-size:11px;line-height:16px;letter-spacing:.02em}
.day-divider{display:flex;justify-content:center;width:100%;margin:0 0 2px;user-select:none;position:relative;z-index:0}
.day-divider time{padding:2px 10px;border-radius:999px;background:rgba(255,255,255,.06);color:var(--muted);font-size:11px;line-height:16px;letter-spacing:.02em}
/* Who the room sent a message to: one thin line in the space under the bubble,
opposite the timestamp, so it reads as a note about the message and never as

View File

@ -3,7 +3,7 @@
.state-dot.running{background:var(--success)}
.state-dot.booting{background:#f5a03c;animation:pulse 1s infinite}
.state-dot.error{background:var(--danger)}
.desktop-frame{display:block;width:100%;height:100%;border:0;background:var(--inset)}
.desktop-frame{display:block;width:100%;height:100%;border:0;background:#111}
.empty-computer{height:100%;display:grid;place-content:center;justify-items:center;gap:7px;color:var(--muted);text-align:center}
.empty-computer svg{width:28px}
.empty-computer span{font-size:12px}
@ -15,29 +15,25 @@
.computer-overlay .avatar{width:30px;height:30px}
.control-badge{padding:5px 10px;border-radius:999px;background:rgba(48,162,75,.14);color:var(--success);font-size:13px;max-width:min(42vw,280px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.control-badge.is-off{background:rgba(255,255,255,.08);color:var(--muted)}
.overlay-screen{flex:1;min-height:0;padding:20px;display:flex;justify-content:center}
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
.overlay-screen{flex:1;min-height:0;padding:0;display:flex;justify-content:center}
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
.computer-toggle{background:var(--surface)}
.meeting-stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--panel);border-right:1px solid var(--hairline)}
.meeting-stage.is-hidden{display:none}
.meeting-stage-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid var(--hairline)}
.meeting-stage .side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
.meeting-stage .side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:0;overflow:hidden}
.meeting-stage.is-meeting .meeting-extras{display:none}
.meeting-stage.is-meeting .computer-part{overflow:hidden}
.meeting-stage.is-meeting .computer-part>.preview{flex:1 1 auto;flex-shrink:1;width:100%;height:auto;min-height:0;max-height:none;aspect-ratio:auto}
.meeting-stage.is-meeting .computer-part .preview .desktop-frame,
.meeting-stage.is-meeting .computer-part .preview .empty-computer{height:100%;aspect-ratio:auto;border-radius:12px;overflow:hidden}
.meeting-stage.is-meeting .computer-part .preview .empty-computer{height:100%;aspect-ratio:auto;border-radius:0;overflow:hidden}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
.computer-part.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
.computer-status-row>span{margin-right:auto;color:var(--ink);font-weight:600}
.computer-part .preview{flex:1;min-height:160px;aspect-ratio:auto}
/* Preserve the monitor's 16:10 shape as the right panel gets narrower. */
.computer-part .preview{position:relative;flex:0 1 auto;width:100%;height:auto;min-height:0;max-height:min(52vh,380px);aspect-ratio:16 / 10}
.computer-part .preview .desktop-frame{display:block;aspect-ratio:16 / 10}
.overlay-screen .overlay-desktop{position:relative;width:min(100%,1440px);height:100%}
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
.computer-part .preview{position:relative;flex:1;min-height:160px;aspect-ratio:auto}
.overlay-screen .overlay-desktop{position:relative;width:100%;height:100%;max-width:none}
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:0;border-radius:0;overflow:hidden;background:#111}
/* The waiting veil sits over the remote pixels, not beside them: while the
desktop is booting, waking, or changing hands, the signal mascot breathes
under its amber orbit and the label shimmers. It never takes a click, so the
@ -53,9 +49,38 @@
.computer-overlay header .computer-actions{justify-content:flex-end}
.control-badge.teaching{background:rgba(239,85,85,.16);color:#ff8a8a}
.computer-login-hint{margin:0;color:var(--faint);font-size:12px;line-height:1.45}
.computer-part{scrollbar-gutter:stable;overflow-x:hidden}
.computer-part>*{flex-shrink:0;min-width:0}
.computer-part .preview{flex:none;max-height:none}
.computer-part{scrollbar-gutter:auto;overflow:hidden;gap:0}
.computer-part>*{min-width:0}
.computer-part .preview,
.computer-part .preview.preview{
position:relative;
flex:1 1 auto;
width:100%;
height:auto;
min-height:0;
max-height:none;
aspect-ratio:auto;
}
.computer-part .preview .desktop-frame,
.computer-part .preview .empty-computer{
display:block;
width:100%;
height:100%;
aspect-ratio:auto;
border:0;
border-radius:0;
background:#111;
}
.overlay-screen{padding:0}
.overlay-screen .overlay-desktop,
.overlay-screen .overlay-desktop>.desktop-frame,
.overlay-screen .overlay-desktop>.empty-computer{
width:100%;
height:100%;
max-width:none;
border:0;
border-radius:0;
}
.computer-status-row>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.computer-status-row>small{min-width:0;max-width:52%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.computer-status-row>small,.state-dot{flex-shrink:0}

View File

@ -90,28 +90,6 @@
background: rgba(62, 197, 168, 0.08);
}
.bot-row .row-delete {
display: none;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 8px;
color: var(--muted);
}
.bot-row:hover .row-delete,
.bot-row.selected .row-delete {
display: grid;
}
.bot-row .row-delete:hover,
.bot-row .row-delete:focus-visible {
color: var(--danger-soft);
background: rgba(239, 85, 85, 0.12);
}
.bot-row .row-delete svg {
width: 14px;
height: 14px;
}
.context-backdrop {
position: fixed;
inset: 0;
@ -183,7 +161,6 @@
text-decoration: underline;
}
.phone-tabs { display: none; }
@media (max-width: 700px) {
.topbar {
grid-template-columns: 44px 32px minmax(0, 1fr) auto;
@ -205,33 +182,9 @@
height: 44px;
background: transparent;
}
}
@media (max-width: 700px) {
.phone-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
padding: 6px 8px env(safe-area-inset-bottom, 0px);
background: var(--side);
border-top: 1px solid var(--hairline);
.composer-dock {
padding: 12px var(--chat-gutter) calc(16px + env(safe-area-inset-bottom, 0px));
}
.phone-tabs button {
min-height: 44px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--muted);
font-weight: 650;
}
.phone-tabs button[aria-current="true"] {
color: var(--ink);
background: var(--surface);
}
.composer-dock { padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px)); }
}

View File

@ -58,16 +58,18 @@
.clipboard-text:focus{border-color:var(--accent)}
.bot-tag{padding:3px 8px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;white-space:nowrap}
.bot-tag{padding:0 4px;border:0;border-radius:4px;color:var(--muted);font-size:9px;line-height:14px;white-space:nowrap}
.bot-row{gap:9px}
.bot-row{display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:9px;min-height:58px;padding:8px 11px}
.bot-copy{display:grid;min-width:0;flex:1}
.bot-copy{display:grid;gap:4px;min-width:0}
.bot-copy strong,.bot-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--ink);background:rgba(255,255,255,.055)}
.bot-trailing{display:grid;justify-items:end;align-content:start;gap:3px;align-self:start;padding-top:1px}
.side-tag{max-width:40px;overflow:hidden;text-overflow:ellipsis;height:14px;color:var(--muted);background:rgba(255,255,255,.06)}
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--unread);box-shadow:0 0 8px color-mix(in srgb,var(--unread) 65%,transparent)}
@ -75,7 +77,7 @@
.group-label{padding:12px 11px 4px;color:var(--muted);font-size:11px;letter-spacing:.04em}
.row-time{align-self:start;color:var(--muted);font-size:10px;white-space:nowrap}
.row-time{color:var(--muted);font-size:10px;line-height:1.2;white-space:nowrap}
.row-pin{width:13px;height:13px;color:var(--muted);transform:rotate(-20deg)}
@ -101,6 +103,28 @@
.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}
.dialog.agent-settings{width:min(400px,calc(100vw - 28px));gap:16px}
.avatar-hit{display:grid;place-items:center;margin:4px auto 2px;padding:0;border:0;background:transparent;cursor:pointer}
.avatar-hit:hover{opacity:.92}
.avatar-picker{display:grid;gap:16px;padding:14px;border:1px solid var(--border);border-radius:18px;background:var(--elevated)}
.avatar-picker-bar{display:flex;align-items:center;gap:8px}
.avatar-picker-bar .avatar-tabs{flex:1}
.avatar-reset{height:32px;padding:0 10px;border:0;background:transparent;color:var(--muted);cursor:pointer}
.avatar-picker .shape-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;justify-items:center}
.avatar-picker .shape-grid button{width:56px;height:56px}
.avatar-picker .shape-grid button.selected{outline:1.5px dashed #7a7a80;outline-offset:2px;border-color:transparent;background:transparent}
.avatar-picker .color-grid{justify-content:center;gap:12px}
.avatar-editor small,.settings-dialog label small{color:var(--muted)}
.settings-dialog fieldset{margin:0;padding:0;border:0}
@ -149,6 +173,19 @@
.shape-grid button.selected{border-color:#626268;box-shadow:none;background:rgba(255,255,255,.025)}
.avatar-tabs{display:flex;gap:6px;padding:2px;border:1px solid var(--border);border-radius:12px;background:var(--input)}
.avatar-tabs button{flex:1;height:36px;border:0;border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
.avatar-tabs button.selected{background:var(--surface);color:var(--ink)}
.avatar-dropzone{display:grid;justify-items:center;gap:8px;padding:28px 16px;border:1px dashed var(--border);border-radius:16px;color:var(--muted)}
.avatar-dropzone.over{border-color:var(--accent);background:rgba(62,197,168,.08)}
.avatar-upload{display:grid;gap:10px;justify-items:center}
.avatar-stage{position:relative;width:260px;height:260px;overflow:hidden;border-radius:50%;background:var(--inset);touch-action:none;cursor:grab}
.avatar-stage img{position:absolute;top:0;left:0;max-width:none;pointer-events:none}
.avatar-stage-mask{position:absolute;inset:0;border-radius:50%;box-shadow:inset 0 0 0 2px rgba(255,255,255,.18);pointer-events:none}
.avatar-zoom{display:flex;align-items:center;gap:8px;width:100%;max-width:260px}
.avatar-zoom input{flex:1}
.avatar-zoom button{width:32px;height:32px;border:1px solid var(--border);border-radius:8px;background:transparent;cursor:pointer}
.expr-grid,.bg-grid{display:flex;gap:8px;flex-wrap:wrap}
@ -494,7 +531,7 @@
height:auto;min-height:0;flex:0 0 auto;padding:8px 10px;
}
.app-shell.meeting-mode .chat-panel .topbar>.grow{display:none}
.app-shell.meeting-mode .chat-panel .topbar>strong{min-width:0;flex:1 1 80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-shell.meeting-mode .chat-panel .topbar>strong,.app-shell.meeting-mode .chat-panel .topbar>.topbar-identity{min-width:0;flex:1 1 80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-shell.meeting-mode .chat-panel .topbar>.host-chip-wrap{flex:0 1 auto;max-width:100%}
.app-shell.meeting-mode .host-chip{max-width:96px}
.app-shell.meeting-mode .session-picker{margin-left:0;flex:0 1 auto}

View File

@ -2,7 +2,7 @@
@media(max-width:700px){.app-shell{display:block}.sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 16px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
@media(max-width:700px){.chat-panel{--chat-gutter:16px}.app-shell{display:block}.message,.message-block,.day-divider{width:100%}.message.user>.message-body,.message-stack{max-width:75%}.composer-dock{padding:12px var(--chat-gutter) calc(16px + env(safe-area-inset-bottom, 0px))}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding:16px var(--chat-gutter) 8px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}}
@media(max-width:1050px){
@ -56,6 +56,7 @@
.topbar>.mobile-menu{grid-column:1;grid-row:1;width:44px;height:44px;justify-content:center}
.topbar>.avatar,.topbar>.avatar-stack{grid-column:2;grid-row:1;margin:0}
.topbar>strong{grid-column:3;grid-row:1;font-size:15px;line-height:1.3}
.topbar>.topbar-identity{grid-column:2 / 4;grid-row:1;min-width:0}
.topbar>.session-picker{grid-column:4;grid-row:1;margin:0;min-width:0}
.topbar .session-current{max-width:100%;min-height:44px;width:100%}
.topbar>.grow{display:none}

View File

@ -266,6 +266,12 @@
border-radius: 8px;
}
.lb-about-heading {
display: grid;
justify-items: center;
gap: 12px;
}
.lb-about-brand h2,
.lb-about-brand p {
margin: 0;

View File

@ -40,7 +40,7 @@ button{color:inherit}
.bot-list{overflow:auto;display:flex;flex-direction:column;gap:5px}
.bot-row{display:flex;align-items:center;gap:12px;width:100%;padding:11px;border:0;border-radius:13px;background:transparent;text-align:left;cursor:pointer}
.bot-row{display:flex;align-items:center;gap:12px;width:100%;padding:11px;border:0;border-radius:13px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
.bot-row:hover,.bot-row.selected{background:var(--hairline)}
@ -62,6 +62,10 @@ button{color:inherit}
.topbar{height:72px;flex:0 0 72px;padding:0 24px;gap:12px;border-bottom:1px solid var(--hairline)}
.topbar-identity{display:flex;align-items:center;gap:12px;min-width:0;margin:0;padding:0;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;text-align:left}
.topbar-identity strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.grow{flex:1}
.danger-ghost{min-height:36px;border-radius:10px;padding:0 15px;border:1px solid transparent;background:transparent;color:var(--danger);cursor:pointer}

View File

@ -1,3 +1,4 @@
import { memo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@ -10,7 +11,7 @@ const components: Components = {
),
};
export function MarkdownBody({ content }: { content: string }) {
export const MarkdownBody = memo(function MarkdownBody({ content }: { content: string }) {
return (
<div className="message-body md">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
@ -18,4 +19,4 @@ export function MarkdownBody({ content }: { content: string }) {
</ReactMarkdown>
</div>
);
}
});

View File

@ -3,7 +3,7 @@ import { LOCALE_OPTIONS, useI18n, type Locale } from "./i18n";
import { persistTheme, readTheme, THEME_PREFS, type ThemePref } from "./theme";
export const APP_NAME = "LazyBoy";
export const APP_VERSION = "0.1.0";
export const APP_VERSION = (import.meta.env.LAZYBOY_VERSION || "0.1.0").trim() || "0.1.0";
export type SettingsSectionId = "general" | "updates";
@ -296,7 +296,7 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
<span className="lb-about-icon">
<img src="/lazyboy-icon.png" width={64} height={64} alt="" draggable={false} decoding="async" />
</span>
<div>
<div className="lb-about-heading">
<h2>{APP_NAME}</h2>
<p>{format("versionLine", { version: APP_VERSION })}</p>
</div>

View File

@ -7,6 +7,24 @@ function intlLocale(locale: Locale): string {
return "en";
}
export function formatInboxTime(iso: string, locale: Locale, now = new Date()): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const loc = intlLocale(locale);
const start = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const diff = Math.round((start(now) - start(date)) / 86400000);
if (diff === 0) {
return new Intl.DateTimeFormat(loc, { hour: "numeric", minute: "2-digit" }).format(date);
}
if (diff > 0 && diff < 7) {
return new Intl.DateTimeFormat(loc, { weekday: "short" }).format(date);
}
if (date.getFullYear() === now.getFullYear()) {
return new Intl.DateTimeFormat(loc, { month: "numeric", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(loc, { year: "numeric", month: "numeric", day: "numeric" }).format(date);
}
export function formatClock(iso: string, locale: Locale): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
@ -48,3 +66,18 @@ export function sameLocalDay(a?: string, b?: string): boolean {
left.getDate() === right.getDate()
);
}
/** LINE-style: same sender within one minute stays in one cluster. */
export function sameChatCluster(
a: { role: string; at?: string } | undefined,
b: { role: string; at?: string } | undefined,
): boolean {
if (!a || !b) return false;
if (a.role !== b.role) return false;
if (!sameLocalDay(a.at, b.at)) return false;
if (!a.at || !b.at) return false;
const left = new Date(a.at).getTime();
const right = new Date(b.at).getTime();
if (Number.isNaN(left) || Number.isNaN(right)) return false;
return Math.abs(left - right) < 60_000;
}

View File

@ -1,3 +1,3 @@
export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"droplet"|"hexagon"|"sun"|"triangle";
export type AvatarShape = BlobatarShape|"blob"|"squircle"|"diamond"|"drop"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"|"cat"|"bunny"|"star"|"heart"|"egg"|"ghost"|"sprout"|"cactus"|"mushroom"|"paw";
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape; avatarUrl?:string }

View File

@ -1 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly LAZYBOY_VERSION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -1,16 +1,48 @@
import { defineConfig } from "vite";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv, type ServerOptions } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function localCerts(): { cert: Buffer; key: Buffer } | undefined {
const home = homedir();
for (const dir of [join(home, ".lazyboy", "certs"), join(home, ".grokboy", "certs")]) {
const cert = join(dir, "server.pem");
const key = join(dir, "server.key");
if (existsSync(cert) && existsSync(key)) {
return { cert: readFileSync(cert), key: readFileSync(key) };
}
}
return undefined;
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, rootDir, "");
const version = (process.env.LAZYBOY_VERSION || env.LAZYBOY_VERSION || "0.1.0").trim() || "0.1.0";
const https = localCerts();
const backend = https ? "https://127.0.0.1:8787" : "http://127.0.0.1:8787";
const server: ServerOptions = {
port: 5173,
host: true,
proxy: {
"/api": "http://127.0.0.1:8787",
"/novnc": { target: "http://127.0.0.1:8787", ws: true },
"/api": { target: backend, secure: false },
"/novnc": { target: backend, ws: true, secure: false },
"/lazyboy-ca.crt": { target: backend, secure: false },
},
},
preview: {
port: 4173,
},
};
if (https) server.https = https;
return {
plugins: [react()],
define: {
"import.meta.env.LAZYBOY_VERSION": JSON.stringify(version),
},
server,
preview: {
port: 4173,
},
};
});