diff --git a/.env.example b/.env.example
index be2f9ef..9b5fd8c 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,12 @@ LAZYBOY_COMPUTER_DRIVER=cua
# Linux only (optional): point this at the host's LXCFS root to make htop/free
# report the per-Agent cgroup quota. Leave the default empty directory on macOS.
LAZYBOY_LXCFS_ROOT=./data/lxcfs
+# 群組聊天「誰該回應」:沒被 @ 的時候,由一次短短的模型問從成員的工作內容與簡介裡
+# 挑出最多三個人回覆。留空(預設)=沿用空間的預設模型,多數人不用理這行;
+# 想省 token 就填一個便宜快速的 model id(要跟空間預設來自同一家 provider)。
+# 挑不到人、模型不能用、或超過 1.2 秒,一律由主持人(群組第一位成員)收場。
+LAZYBOY_ROUTER_MODEL=
+
# 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有
# 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被
# 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。
diff --git a/README.md b/README.md
index 45e189a..77ebf26 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ This is an early `0.1.0` release with desktop and phone browser UIs. You bring y
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
- **Take over any time**: sign in, pass a check, or nudge things by hand on the same desktop, then hand it back.
-- **Several agents and groups**: shared Team computers or private dedicated desktops.
+- **Several agents and groups**: shared Team computers or private dedicated desktops; `@name` decides who answers, so a message wakes the one agent it is for instead of all of them.
- **Teach by demo, then schedule**: turn a walkthrough into a skill; use cron for repeat work.
- **Your models and tools**: xAI, OpenCode Go, OpenAI-compatible endpoints, MCP, and file skills.
- **Voice calls**: after you enable a voice provider, you can talk to the agent on a call.
diff --git a/README.zh-TW.md b/README.zh-TW.md
index b79e244..7a2aa7d 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -25,7 +25,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
- **隨時人工接管**:在同一個桌面完成登入、驗證或手動調整,再交回 Agent。
-- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式。
+- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式;群組裡 @誰就由誰回,沒點名時只叫醒工作內容相關的那個,不會全部出動。
- **示範教學與排程**:把操作示範整理成技能,使用 cron 安排重複工作。
- **自選模型與工具**:支援 xAI、OpenCode Go、OpenAI 相容端點,以及 MCP 與檔案技能。
- **語音通話**:設定語音服務後,可以透過通話與 Agent 互動。
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index fadaa74..bdba7f1 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,4 +1,4 @@
-import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { FormEvent, Fragment, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons";
import UseAnimations from "./use-animations";
import loading from "react-useanimations/lib/loading";
@@ -17,6 +17,8 @@ import searchToX from "react-useanimations/lib/searchToX";
import { api, ApiError } from "./api";
import { createCoalescer, subscribeToSession } from "./live";
import { applyReplyEvent, type ReplyDrafts } from "./reply-stream";
+import { acceptMention, mentionChoices, mentionToken } from "./mentions";
+import { clockTime, dayLabel, sameDay } from "./chat-time";
import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff";
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
@@ -55,10 +57,17 @@ type WorkspacePrefs={name:string;showHidden:boolean};
function isDefaultWorkspaceName(name?:string|null){return !name||name==="Local workspace"||name==="本機工作區"}
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:isDefaultWorkspaceName(name)?t("localWorkspace"):name||t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
+// A message shows only its clock time; the day it belongs to is written once,
+// in the divider above the first message of that day, the way chat apps do.
function MessageTime({value}:{value:string}){
const date=new Date(value);
if(!Number.isFinite(date.getTime()))return null;
- return ;
+ return ;
+}
+function DayDivider({value}:{value:string}){
+ const date=new Date(value);
+ if(!Number.isFinite(date.getTime()))return null;
+ return
;
}
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return {initials}}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
@@ -217,6 +226,20 @@ export function App(){
const [slashIndex,setSlashIndex]=useState(0);
const [slashDismissed,setSlashDismissed]=useState(false);
useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]);
+ // Who answers a group message is decided by the server; here it is only
+ // typed. The `@` list opens on `@`, closes on the first space, and remembers
+ // nothing, because choosing who should talk must never feel like setup.
+ const [mentionIndex,setMentionIndex]=useState(0);
+ const [mentionDismissed,setMentionDismissed]=useState(false);
+ const [caretAt,setCaretAt]=useState(0);
+ const composerRef=useRef(null);
+ const [hostMenuOpen,setHostMenuOpen]=useState(false);
+ useEffect(()=>{setMentionIndex(0);setMentionDismissed(false);setHostMenuOpen(false)},[draft,activeRoomId]);
+ useEffect(()=>{setCaretAt(current=>current>draft.length?draft.length:current)},[draft]);
+ const mentionQuery=activeRoom&&!mentionDismissed?mentionToken(draft,caretAt):null;
+ const mentionList=mentionQuery===null||!activeRoom?[]:mentionChoices(mentionQuery,activeRoom.members,t("everyoneMention"));
+ const roomHostId=activeRoom?activeRoom.hostBotId||activeRoom.members[0]?.id||null:null;
+ const roomHost=activeRoom?.members.find(member=>member.id===roomHostId)||null;
const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase();
const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart())
? [{name:"goal",description:t("goalCommandHint"),kind:t("slashMode")},...fileSkills.map(skill=>({...skill,kind:t("slashFileSkill")}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8)
@@ -290,7 +313,12 @@ export function App(){
useEffect(()=>{if(!handingOff)return;handoffAtRef.current=Date.now();const timer=setTimeout(()=>{expectedHolderRef.current=null;setHandingOff(false)},HANDOFF_MS);return()=>clearTimeout(timer)},[handingOff]);
useEffect(()=>{const frame=desktopFrameRef.current;if(!frame?.contentWindow||!screenUrl)return;frame.contentWindow.postMessage({type:"lazyboy-view-only",viewOnly:!desktopInteractive},location.origin)},[desktopInteractive,screenUrl,desktopReady]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;if(computer.sharedInput){pushViewOnly(!desktopInteractive);return}void setControl("user")};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId,computer.sharedInput,desktopInteractive]);
- useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
+ async function setRoomHost(botId:string){
+ setHostMenuOpen(false);
+ if(!activeRoom||botId===roomHostId)return;
+ try{await api(`/api/rooms/${activeRoom.id}`,{method:"PATCH",body:JSON.stringify({hostBotId:botId})});await loadBots()}catch(error){setError(localizeError(error instanceof Error?error.message:t("operationFailed")))}
+ }
+ useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap,.host-chip-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false);setHostMenuOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
useEffect(()=>{setWorkspaceName(name=>isDefaultWorkspaceName(name)?t("localWorkspace"):name)},[locale]);
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]);
@@ -365,7 +393,8 @@ export function App(){
})}finally{sendingRef.current=false;setSendingMessage(false)}
}
- function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.currentcomposerRef.current?.focus())}
+ function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(mentionList.length){if(event.key==="Escape"){event.preventDefault();setMentionDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setMentionIndex(index=>(index+(event.key==="ArrowDown"?1:mentionList.length-1))%mentionList.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();pickMention(mentionList[mentionIndex%mentionList.length]);return}}if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
// A run that stopped to ask keeps its harness state, so the answer is just a
@@ -525,14 +554,14 @@ export function App(){
- {activeRoom?<>member.id)}/>{activeRoom.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}>:active?<>{active.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}>:<>{t("chooseBot")}{topTools}>}
+ {activeRoom?<>member.id)}/>{activeRoom.name}event.stopPropagation()}>{hostMenuOpen&&
@@ -925,7 +954,7 @@ function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:T
}
function ClipboardDialog({close,paste}:{close:()=>void;paste:(text:string)=>void}){const[text,setText]=useState("");return
{t("pasteToRemoteComputer")}
{t("pasteRemoteHelp")}
}
function CreateDialog({close,created}:{close:()=>void;created:(bot:Bot)=>void}){const[name,setName]=useState("");const[mode,setMode]=useState("team");const[busy,setBusy]=useState(false);return }
-function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:(room:Room)=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState([]);const[busy,setBusy]=useState(false);const visible=bots.filter(bot=>!bot.hidden);return }
+function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:(room:Room)=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState([]);const[busy,setBusy]=useState(false);const visible=bots.filter(bot=>!bot.hidden);return }
function RoomContextMenu({context,close,onDelete}:{context:{room:Room;x:number;y:number};close:()=>void;onDelete:()=>void}){return
e.stopPropagation()}>
}
function ConfirmRoomDelete({room,close,confirm}:{room:Room;close:()=>void;confirm:()=>void}){return
{t("deleteNamed",{name:room.name})}
{t("deleteGroupDescription")}
}
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return
}
diff --git a/apps/web/src/chat-time.ts b/apps/web/src/chat-time.ts
new file mode 100644
index 0000000..0f1b274
--- /dev/null
+++ b/apps/web/src/chat-time.ts
@@ -0,0 +1,54 @@
+/**
+ * How a chat shows when something was said: the day is written once, as a
+ * divider above the first message of that day, and each message carries only
+ * its clock time. A day is named the way people say it — 今天, 昨天 — and
+ * spelled out only when it is further away.
+ */
+
+export interface DayWords {
+ today: string;
+ yesterday: string;
+}
+
+/** Local calendar day, so two timestamps on the same date compare equal. */
+export function dayKey(value: string | Date): string {
+ const date = value instanceof Date ? value : new Date(value);
+ if (!Number.isFinite(date.getTime())) return "";
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function sameDay(a: string | Date, b: string | Date): boolean {
+ const keyA = dayKey(a);
+ return keyA !== "" && keyA === dayKey(b);
+}
+
+/** Whole local days between two moments; negative when `date` is in the future. */
+function daysBefore(date: Date, now: Date): number {
+ const start = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
+ return Math.round((start(now) - start(date)) / 86_400_000);
+}
+
+/**
+ * The divider text for a day: 今天 / 昨天, then a weekday for the past week
+ * (people remember "Wednesday" before "the 3rd"), then a short date, and the
+ * year only when it is not this one.
+ */
+export function dayLabel(value: string | Date, now: Date, locale: string, words: DayWords): string {
+ const date = value instanceof Date ? value : new Date(value);
+ if (!Number.isFinite(date.getTime())) return "";
+ const ago = daysBefore(date, now);
+ if (ago === 0) return words.today;
+ if (ago === 1) return words.yesterday;
+ if (ago > 1 && ago < 7) return date.toLocaleDateString(locale, { weekday: "long" });
+ if (date.getFullYear() === now.getFullYear()) {
+ return date.toLocaleDateString(locale, { month: "numeric", day: "numeric", weekday: "short" });
+ }
+ return date.toLocaleDateString(locale, { year: "numeric", month: "numeric", day: "numeric" });
+}
+
+/** The clock time a message shows next to itself; the day lives in the divider. */
+export function clockTime(value: string | Date, locale: string): string {
+ const date = value instanceof Date ? value : new Date(value);
+ if (!Number.isFinite(date.getTime())) return "";
+ return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
+}
diff --git a/apps/web/src/chat.css b/apps/web/src/chat.css
index 7959880..ad3bc17 100644
--- a/apps/web/src/chat.css
+++ b/apps/web/src/chat.css
@@ -77,3 +77,31 @@
.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:28px}
+
+/* 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}
+
+/* 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
+ a second message. */
+.route-note{position:absolute;bottom:0;left:0;max-width:56%;overflow:hidden;color:var(--muted);font-size:11px;line-height:17px;opacity:.78;text-overflow:ellipsis;white-space:nowrap}
+.message.user .route-note{left:0;right:auto}
+.message.assistant .route-note{left:auto;right:28px;text-align:right}
+
+/* The `@` list sits where the slash list sits: only one of them is ever open. */
+.mention-suggestions button{align-items:center;gap:8px}
+.mention-suggestions strong{min-width:0}
+
+/* The host is a name in the top bar, not a page of settings. */
+.host-chip-wrap{position:relative;display:inline-flex;align-items:center}
+.host-chip{display:inline-flex;align-items:center;gap:5px;max-width:150px;padding:3px 9px;border:1px solid var(--border);border-radius:999px;background:transparent;color:var(--muted);font-size:11px;cursor:pointer}
+.host-chip:hover,.host-chip[aria-expanded="true"]{border-color:var(--accent);color:var(--ink)}
+.host-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.host-menu{position:absolute;top:calc(100% + 8px);left:0;z-index:9;display:grid;gap:2px;min-width:190px;padding:8px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
+.host-menu button{display:flex;align-items:center;gap:8px;padding:7px 9px;border:0;border-radius:9px;background:transparent;color:var(--ink);font-size:13px;text-align:left;cursor:pointer}
+.host-menu button:hover,.host-menu button:focus-visible{background:rgba(255,255,255,.08);outline:0}
+.host-menu button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.host-menu-label{padding:2px 9px 4px;color:var(--muted);font-size:11px}
+.host-flag{margin-left:auto;color:var(--accent);font-size:11px;font-weight:650}
diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts
index 2988f6b..c954e13 100644
--- a/apps/web/src/locales/en.ts
+++ b/apps/web/src/locales/en.ts
@@ -56,7 +56,11 @@ export const en: { [K in keyof typeof zhTW]: string } = {
chooseBot: "Choose a bot",
startRoomDiscussion: "Start a discussion with {name}",
startBotWork: "Start working with {name}",
- roomWillReply: "{names} will all reply.",
+ roomWillReply: "{names} are here. Whoever is @-mentioned answers; otherwise the host, or the best fit, picks it up.",
+ everyoneMention: "everyone", mentionEveryoneHint: "every member answers", mentionList: "Members to mention",
+ routedTo: "{names} will answer this", handedTo: "→ handed to {name}",
+ today: "Today", yesterday: "Yesterday",
+ hostBadge: "Host", hostMenuLabel: "Who hosts", hostHint: "The host answers what nobody is named for. Tap to change it.",
botWelcome: "Send a message and it will get it done on its own computer.",
remembered: "Remembered",
remember: "Remember",
@@ -292,6 +296,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
privateComputerHint: "A fresh dedicated Docker desktop",
create: "Create",
groupDescription: "A group opens one chat where every selected agent speaks.",
+ groupHostHint: "{name} starts as host — one tap in the top bar swaps it.",
groupName: "Group name",
groupNamePlaceholder: "For example: product research",
chooseBots: "Choose bots (at least two)",
@@ -340,7 +345,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
helpBotsTitle: "Bots",
helpBots: "Use + at the top left to add a bot. Click the list to chat. Right-click to pin, hide, or delete.",
helpGroupsTitle: "Groups",
- helpGroups: "+ → New group, pick at least two. One message, every agent in the group replies.",
+ helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
helpComputerTitle: "Computer",
helpComputer: "The Computer tab on the right is this agent’s desktop. Start it, take over mouse and keyboard, or let it drive.",
helpMemoryTitle: "Memory",
diff --git a/apps/web/src/locales/zh-TW.ts b/apps/web/src/locales/zh-TW.ts
index 0ba970c..4d0df98 100644
--- a/apps/web/src/locales/zh-TW.ts
+++ b/apps/web/src/locales/zh-TW.ts
@@ -14,7 +14,11 @@ export const zhTW = {
unreadMessages: "{count} 則未讀訊息", members: "{count} 位成員", hideHiddenItems: "隱藏已隱藏項目", showHiddenItems: "顯示已隱藏項目",
mcpConnected: "{count} 個 MCP 已連線", connectMcpServer: "接入 MCP server",
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
- chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
+ chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 都在這裡。@誰就由誰回覆,沒點名時由主持人或最合適的 Agent 接手。",
+ everyoneMention: "所有人", mentionEveryoneHint: "每個人都回覆", mentionList: "可點名的成員",
+ routedTo: "本輪由 {names} 回覆", handedTo: "→ 交給 {name}",
+ today: "今天", yesterday: "昨天",
+ hostBadge: "主持人", hostMenuLabel: "由誰主持", hostHint: "沒被點名的時候由主持人回答。點一下換人。",
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
@@ -93,7 +97,7 @@ export const zhTW = {
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
- groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
+ groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupHostHint: "第一位({name})先擔任主持人,以後在上方一點就能換。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}?", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP,例如 http://192.168.x.x:3101。",
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
@@ -113,7 +117,7 @@ export const zhTW = {
workspaceModelHint: "先選供應商,再填金鑰與模型。OpenAI 相容需要自己填端點。",
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
- helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
+ helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
diff --git a/apps/web/src/mentions.ts b/apps/web/src/mentions.ts
new file mode 100644
index 0000000..6e3dc16
--- /dev/null
+++ b/apps/web/src/mentions.ts
@@ -0,0 +1,56 @@
+import type { RoomMember } from "./types";
+
+/** One row of the `@` list. A null `member` is the whole-room escape hatch. */
+export interface MentionChoice {
+ name: string;
+ member: RoomMember | null;
+}
+
+/**
+ * The `@`-token under the caret, or null when the caret is not writing one.
+ * A token ends at a space, so `@小美 幫我看` stops offering the list the moment
+ * the name is finished, and `mail@example.com` never opens it.
+ */
+export function mentionToken(text: string, caret: number): string | null {
+ const end = Math.max(0, Math.min(caret, text.length));
+ const hit = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, end));
+ return hit ? hit[1] : null;
+}
+
+/**
+ * Who the token could mean, in room order, capped so the list stays one glance.
+ * `everyone` — the one name that reaches the whole room — leads the list while
+ * the token is empty or could still become it, because typing `@` is where a
+ * person discovers that escape hatch.
+ */
+export function mentionChoices(
+ token: string,
+ members: RoomMember[],
+ everyone: string,
+): MentionChoice[] {
+ const needle = token.trim().toLowerCase();
+ const choices: MentionChoice[] = [];
+ if (everyone.toLowerCase().startsWith(needle)) {
+ choices.push({ name: everyone, member: null });
+ }
+ for (const member of members) {
+ if (member.name.toLowerCase().includes(needle)) choices.push({ name: member.name, member });
+ }
+ return choices.slice(0, 6);
+}
+
+/**
+ * Replace the token being typed with a chosen name and one space, leaving the
+ * rest of the message where it was. The new caret sits after that space, so the
+ * next keystroke continues the sentence instead of reopening the list.
+ */
+export function acceptMention(
+ text: string,
+ caret: number,
+ name: string,
+): { text: string; caret: number } {
+ const end = Math.max(0, Math.min(caret, text.length));
+ const head = text.slice(0, end).replace(/@([^\s@]*)$/, "");
+ const inserted = `${head}@${name} `;
+ return { text: inserted + text.slice(end), caret: inserted.length };
+}
diff --git a/apps/web/src/refinements.css b/apps/web/src/refinements.css
index 1d92596..c8bad2e 100644
--- a/apps/web/src/refinements.css
+++ b/apps/web/src/refinements.css
@@ -620,6 +620,8 @@
.dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px}
+/* One quiet sentence under a form: what will happen by default, so the human has nothing to configure. */
+.dialog-note{margin:-4px 0 0;color:var(--muted);font-size:12px;line-height:1.5}
@keyframes computer-hud-bob{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-3px) rotate(5deg)}}
diff --git a/apps/web/src/responsive.css b/apps/web/src/responsive.css
index dddc302..fcf6e88 100644
--- a/apps/web/src/responsive.css
+++ b/apps/web/src/responsive.css
@@ -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%}.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}.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}}
+@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}.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}}
@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}}
@media(max-width:1050px){
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index f9db9aa..dfef92d 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -5,10 +5,10 @@ export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"dr
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 Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean }
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
-export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null }
+export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null; replyBots?:RoomMember[] }
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
-export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
+export interface Room { id:string; name:string; members:RoomMember[]; hostBotId?:string|null; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; sharedInput?:boolean; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
/** One line of the live trail a run writes while it works. */
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice"|"memory";
diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs
index 208de0e..a1b6da3 100644
--- a/crates/api/src/main.rs
+++ b/crates/api/src/main.rs
@@ -10,6 +10,7 @@ mod monitor;
mod retention;
mod rooms;
mod routes;
+mod routing;
mod runs;
mod schedules;
mod screen_proxy;
diff --git a/crates/api/src/memory.rs b/crates/api/src/memory.rs
index 2e6a733..3b42f34 100644
--- a/crates/api/src/memory.rs
+++ b/crates/api/src/memory.rs
@@ -1280,4 +1280,91 @@ mod tests {
.id
);
}
+
+ /// A conversation that holds remembered lines can still be removed: the
+ /// memory survives, pointing at nothing, and the guard keeps rejecting a
+ /// genuine move into another agent's conversation.
+ #[sqlx::test(migrations = "../../migrations")]
+ async fn deleting_a_conversation_keeps_its_memories_and_the_guard(pool: sqlx::PgPool) {
+ sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
+ .execute(&pool)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
+ .execute(&pool)
+ .await
+ .unwrap();
+ for bot in ["a", "b"] {
+ sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)")
+ .bind(bot)
+ .execute(&pool)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)")
+ .bind(format!("thread-{bot}"))
+ .bind(bot)
+ .execute(&pool)
+ .await
+ .unwrap();
+ }
+ sqlx::query(
+ "INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,status,prompt)
+ VALUES ('run-a','s','u','a','thread-a','completed','hi')",
+ )
+ .execute(&pool)
+ .await
+ .unwrap();
+ sqlx::query(
+ "INSERT INTO messages(id,thread_id,seq,role,body,run_id)
+ VALUES ('msg-a','thread-a',1,'user','remember this','run-a')",
+ )
+ .execute(&pool)
+ .await
+ .unwrap();
+ // One saved from the chat (message + session), one by the remember tool
+ // (run + session): both link into the conversation being removed.
+ let from_chat = Uuid::new_v4();
+ let from_tool = Uuid::new_v4();
+ sqlx::query(
+ "INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,source_run_id,source_message_id,content)
+ VALUES ($1,'s','u','a','thread-a',NULL,'msg-a','saved from chat'),
+ ($2,'s','u','a','thread-a','run-a',NULL,'saved by tool')",
+ )
+ .bind(from_chat)
+ .bind(from_tool)
+ .execute(&pool)
+ .await
+ .unwrap();
+
+ sqlx::query("DELETE FROM threads WHERE id='thread-a'")
+ .execute(&pool)
+ .await
+ .expect("a conversation with remembered lines can be deleted");
+ let left: Vec<(Option, Option, Option)> = sqlx::query_as(
+ "SELECT session_id, source_run_id, source_message_id FROM memory_items
+ WHERE bot_id='a' ORDER BY content",
+ )
+ .fetch_all(&pool)
+ .await
+ .unwrap();
+ assert_eq!(left, vec![(None, None, None), (None, None, None)]);
+
+ // The guard still holds: memory cannot be pointed at another agent's
+ // conversation after the fact.
+ let moved = sqlx::query("UPDATE memory_items SET session_id='thread-b' WHERE id=$1")
+ .bind(from_chat)
+ .execute(&pool)
+ .await;
+ assert!(
+ moved.is_err(),
+ "re-homing into another agent's thread must fail"
+ );
+ let kept: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM memory_items WHERE bot_id='a' AND session_id IS NULL",
+ )
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert_eq!(kept, 2);
+ }
}
diff --git a/crates/api/src/rooms.rs b/crates/api/src/rooms.rs
index 149f99a..42e3586 100644
--- a/crates/api/src/rooms.rs
+++ b/crates/api/src/rooms.rs
@@ -5,7 +5,7 @@ use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use lazyboy_contracts::{
- CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session,
+ CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session, UpdateRoomInput,
};
use serde_json::{Value, json};
use uuid::Uuid;
@@ -19,7 +19,10 @@ type ApiError = (StatusCode, Json);
pub fn router() -> Router {
Router::new()
.route("/api/rooms", get(list_rooms).post(create_room))
- .route("/api/rooms/{id}", get(get_room).delete(delete_room))
+ .route(
+ "/api/rooms/{id}",
+ get(get_room).patch(update_room).delete(delete_room),
+ )
.route(
"/api/rooms/{id}/sessions",
get(list_sessions).post(create_session),
@@ -64,10 +67,12 @@ async fn members_for(state: &AppState, room_id: &str) -> Result,
Ok(rows.into_iter().map(member_from_row).collect())
}
-/// `room_from_id` projection: id, name, last message time, preview, unread count.
+/// `room_from_id` projection: id, name, host, last message time, preview, unread
+/// count.
type RoomSummaryRow = (
String,
String,
+ Option,
Option>,
Option,
i64,
@@ -79,7 +84,7 @@ async fn room_from_id(
id: &str,
) -> Result