fix group chat and linux cotainer
This commit is contained in:
parent
be47085268
commit
a8ea89e803
|
|
@ -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 的保險絲,不是額度。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
|||
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
||||
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
||||
- **隨時人工接管**:在同一個桌面完成登入、驗證或手動調整,再交回 Agent。
|
||||
- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式。
|
||||
- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式;群組裡 @誰就由誰回,沒點名時只叫醒工作內容相關的那個,不會全部出動。
|
||||
- **示範教學與排程**:把操作示範整理成技能,使用 cron 安排重複工作。
|
||||
- **自選模型與工具**:支援 xAI、OpenCode Go、OpenAI 相容端點,以及 MCP 與檔案技能。
|
||||
- **語音通話**:設定語音服務後,可以透過通話與 Agent 互動。
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,或把別人的技能檔匯入,換一個機器人也適用。",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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)}}
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mod monitor;
|
|||
mod retention;
|
||||
mod rooms;
|
||||
mod routes;
|
||||
mod routing;
|
||||
mod runs;
|
||||
mod schedules;
|
||||
mod screen_proxy;
|
||||
|
|
|
|||
|
|
@ -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<String>, Option<String>, Option<String>)> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value>);
|
|||
pub fn router() -> Router<AppState> {
|
||||
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<Vec<RoomMember>,
|
|||
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<String>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<String>,
|
||||
i64,
|
||||
|
|
@ -79,7 +84,7 @@ async fn room_from_id(
|
|||
id: &str,
|
||||
) -> Result<Option<Room>, sqlx::Error> {
|
||||
let row: Option<RoomSummaryRow> = sqlx::query_as(
|
||||
"SELECT r.id, r.name,
|
||||
"SELECT r.id, r.name, r.host_bot_id,
|
||||
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id),
|
||||
(SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id
|
||||
WHERE t.room_id=r.id ORDER BY m.created_at DESC, m.seq DESC LIMIT 1),
|
||||
|
|
@ -96,13 +101,14 @@ async fn room_from_id(
|
|||
.bind(&actor.user_id)
|
||||
.fetch_optional(state.pool())
|
||||
.await?;
|
||||
let Some((id, name, last_message_at, last_preview, unread_count)) = row else {
|
||||
let Some((id, name, host_bot_id, last_message_at, last_preview, unread_count)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Room {
|
||||
members: members_for(state, &id).await?,
|
||||
id,
|
||||
name,
|
||||
host_bot_id,
|
||||
last_message_at,
|
||||
last_preview,
|
||||
unread_count,
|
||||
|
|
@ -149,6 +155,15 @@ async fn create_room(
|
|||
Json(json!({"message":"群組需要名稱,並至少兩位機器人"})),
|
||||
));
|
||||
}
|
||||
let host_bot_id = input.host_bot_id;
|
||||
if let Some(host) = host_bot_id.as_deref()
|
||||
&& !member_ids.iter().any(|member| member.as_str() == host)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"message":"主持人必須是群組成員"})),
|
||||
));
|
||||
}
|
||||
let mut members = Vec::new();
|
||||
for bot_id in &member_ids {
|
||||
let exists: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
|
|
@ -170,17 +185,20 @@ async fn create_room(
|
|||
members.push(member_from_row(row));
|
||||
}
|
||||
let room_id = Uuid::new_v4().to_string();
|
||||
let host_id = members[0].id.clone();
|
||||
// The host is decided once, here: the member the human chose, or simply the
|
||||
// first one. Starting a group needs no other setting.
|
||||
let host_id = host_bot_id.unwrap_or_else(|| members[0].id.clone());
|
||||
let mut tx = state
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| internal(error.to_string()))?;
|
||||
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)")
|
||||
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name,host_bot_id) VALUES ($1,$2,$3,$4,$5)")
|
||||
.bind(&room_id)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.bind(name)
|
||||
.bind(&host_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| internal(error.to_string()))?;
|
||||
|
|
@ -213,6 +231,7 @@ async fn create_room(
|
|||
id: room_id,
|
||||
name: name.into(),
|
||||
members,
|
||||
host_bot_id: Some(host_id),
|
||||
last_message_at: None,
|
||||
last_preview: None,
|
||||
unread_count: 0,
|
||||
|
|
@ -258,6 +277,59 @@ async fn delete_room(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Change who leads the room. This is the only room setting that exists, and it
|
||||
/// is reached by one tap on a member — there is no settings page to open.
|
||||
async fn update_room(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<UpdateRoomInput>,
|
||||
) -> Result<Json<Room>, ApiError> {
|
||||
let actor = actor(&state).await?;
|
||||
let current = room_from_id(&state, &actor, &id)
|
||||
.await
|
||||
.map_err(|error| internal(error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"message":"group not found"})),
|
||||
)
|
||||
})?;
|
||||
let Some(host_bot_id) = input.host_bot_id else {
|
||||
return Ok(Json(current));
|
||||
};
|
||||
if !current
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| member.id == host_bot_id)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"message":"主持人必須是群組成員"})),
|
||||
));
|
||||
}
|
||||
let updated = sqlx::query(
|
||||
"UPDATE rooms SET host_bot_id=$2, updated_at=now()
|
||||
WHERE id=$1 AND space_id=$3 AND user_id=$4",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&host_bot_id)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.execute(state.pool())
|
||||
.await
|
||||
.map_err(|error| internal(error.to_string()))?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"message":"group not found"})),
|
||||
));
|
||||
}
|
||||
room_from_id(&state, &actor, &id)
|
||||
.await
|
||||
.map(|room| Json(room.unwrap_or(current)))
|
||||
.map_err(|error| internal(error.to_string()))
|
||||
}
|
||||
|
||||
async fn list_sessions(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -106,6 +106,25 @@ pub async fn send(
|
|||
let stored_body = crate::attachments::caption_for_title(text, &decoded);
|
||||
let mut stored_blocks = blocks.to_vec();
|
||||
stored_blocks.extend(crate::attachments::stored_blocks(&decoded));
|
||||
// Who answers is decided before the row lock: an `@name` settles it for
|
||||
// free, and the one model call that sometimes stands in for it must never
|
||||
// hold this thread's transaction open.
|
||||
let room = crate::routing::room_for_thread(state, thread_id).await?;
|
||||
// One run per chosen member — `@name` wins, then the picker's short list,
|
||||
// then the host. A room message keeps this list with it so the chat can say
|
||||
// who is about to talk; a direct message records nothing extra.
|
||||
let mut member_ids: Vec<String> = match room.as_ref() {
|
||||
Some(room) => {
|
||||
crate::routing::audience_for(state, actor, room, thread_id, text)
|
||||
.await
|
||||
.targets
|
||||
}
|
||||
None => vec![bot_id.to_string()],
|
||||
};
|
||||
if member_ids.is_empty() {
|
||||
member_ids.push(bot_id.to_string());
|
||||
}
|
||||
let reply_bot_ids: Option<Vec<String>> = room.map(|_| member_ids.clone());
|
||||
let mut tx = state
|
||||
.pool()
|
||||
.begin()
|
||||
|
|
@ -199,8 +218,8 @@ pub async fn send(
|
|||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,client_nonce)
|
||||
VALUES ($1,$2,$3,'user',$4,$5,$6,$7)",
|
||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,client_nonce,reply_bot_ids)
|
||||
VALUES ($1,$2,$3,'user',$4,$5,$6,$7,$8)",
|
||||
)
|
||||
.bind(&message_id)
|
||||
.bind(thread_id)
|
||||
|
|
@ -209,6 +228,7 @@ pub async fn send(
|
|||
.bind(json!(stored_blocks))
|
||||
.bind(&run_id)
|
||||
.bind(client_nonce)
|
||||
.bind(&reply_bot_ids)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
|
@ -220,18 +240,6 @@ pub async fn send(
|
|||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let mut member_ids: Vec<String> = if let Some(room_id) = room_id.as_deref() {
|
||||
sqlx::query_scalar("SELECT bot_id FROM room_members WHERE room_id=$1 ORDER BY created_at")
|
||||
.bind(room_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
} else {
|
||||
vec![bot_id.to_string()]
|
||||
};
|
||||
if member_ids.is_empty() {
|
||||
member_ids.push(bot_id.to_string());
|
||||
}
|
||||
for (index, member_id) in member_ids.iter().enumerate() {
|
||||
if merged && index == 0 {
|
||||
continue;
|
||||
|
|
@ -280,7 +288,15 @@ pub async fn send(
|
|||
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
|
||||
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
|
||||
)
|
||||
.bind(bot_id)
|
||||
// The question is about the bot that will actually answer, which in a
|
||||
// room is whoever routing put first — not the host the message was
|
||||
// addressed to.
|
||||
.bind(
|
||||
member_ids
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| bot_id.to_string()),
|
||||
)
|
||||
.bind(&run_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
|
|
@ -860,7 +876,7 @@ async fn execute_run(
|
|||
.unwrap_or_default();
|
||||
if !room_mates.is_empty() {
|
||||
preamble.push_str(&format!(
|
||||
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them.",
|
||||
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them. The human names who should answer: speak when your name is on the message, stay quiet when it is not. When the piece belongs to someone else, say one short line and name them with @TheirName, which hands this one item over once.",
|
||||
bot.name,
|
||||
room_mates.join("、")
|
||||
));
|
||||
|
|
@ -2091,6 +2107,15 @@ pub(crate) async fn append_bot_message_with(
|
|||
if body.trim().is_empty() && !has_blocks {
|
||||
return Ok(());
|
||||
}
|
||||
// A bot that names a member may hand this piece over: one hop only, and
|
||||
// never to a member who is already working. The lookup happens before the
|
||||
// transaction so the message write stays short.
|
||||
let handoff = crate::routing::handoff_target(state, run_id, bot_id, body)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!("room hand-off lookup for run {run_id}: {error}");
|
||||
None
|
||||
});
|
||||
let mut tx = state
|
||||
.pool()
|
||||
.begin()
|
||||
|
|
@ -2106,8 +2131,8 @@ pub(crate) async fn append_bot_message_with(
|
|||
.map_err(|error| error.to_string())?;
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,speaker_bot_id)
|
||||
VALUES ($1,$2,$3,'assistant',$4,$5,$6,$7)",
|
||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,speaker_bot_id,reply_bot_ids)
|
||||
VALUES ($1,$2,$3,'assistant',$4,$5,$6,$7,$8)",
|
||||
)
|
||||
.bind(&message_id)
|
||||
.bind(thread_id)
|
||||
|
|
@ -2116,9 +2141,37 @@ pub(crate) async fn append_bot_message_with(
|
|||
.bind(&blocks)
|
||||
.bind(run_id)
|
||||
.bind(bot_id)
|
||||
.bind(handoff.clone().map(|target| vec![target]))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some(target) = handoff.as_ref() {
|
||||
let owner: Option<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT r.space_id, r.user_id, b.name
|
||||
FROM runs r JOIN bots b ON b.id=r.bot_id WHERE r.id=$1",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some((space_id, user_id, speaker)) = owner {
|
||||
tracing::info!(run_id, target, "room hand-off queued");
|
||||
sqlx::query(
|
||||
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,trigger,prompt,checkpoint)
|
||||
VALUES ($1,$2,$3,$4,$5,'queued','handoff',$6,$7)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(space_id)
|
||||
.bind(target)
|
||||
.bind(thread_id)
|
||||
.bind(user_id)
|
||||
.bind(format!("{speaker} 在群組裡把這件事交給你:\n{body}"))
|
||||
.bind(json!({"handoffFrom": run_id}))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
tx.commit().await.map_err(|error| error.to_string())?;
|
||||
let _ = crate::sessions::append_event(
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ type MessageWithSpeakerRow = (
|
|||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Value,
|
||||
);
|
||||
|
||||
pub async fn messages_for_session(
|
||||
|
|
@ -494,7 +495,16 @@ pub async fn messages_for_session(
|
|||
) -> Result<Vec<SessionMessage>, ApiError> {
|
||||
let rows: Vec<MessageWithSpeakerRow> = sqlx::query_as(
|
||||
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
|
||||
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape
|
||||
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('id', r.id, 'name', r.name,
|
||||
'avatarColor', r.avatar_color,
|
||||
'avatarShape', r.avatar_shape)
|
||||
ORDER BY picked.ord)
|
||||
FROM unnest(m.reply_bot_ids) WITH ORDINALITY AS picked(bot_id, ord)
|
||||
JOIN bots r ON r.id = picked.bot_id
|
||||
), '[]'::jsonb) AS reply_bots
|
||||
FROM messages m
|
||||
JOIN threads t ON t.id=m.thread_id
|
||||
LEFT JOIN bots b ON b.id=m.speaker_bot_id
|
||||
|
|
@ -523,6 +533,8 @@ pub async fn messages_for_session(
|
|||
speaker_name: row.10,
|
||||
speaker_color: row.11,
|
||||
speaker_shape: row.12,
|
||||
// A malformed array must not take the whole transcript with it.
|
||||
reply_bots: serde_json::from_value(row.13).unwrap_or_default(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ pub struct Room {
|
|||
pub id: String,
|
||||
pub name: String,
|
||||
pub members: Vec<RoomMember>,
|
||||
/// The member that answers what nobody was named for. `None` means "the
|
||||
/// first member", which is what rooms used before this column resolve to.
|
||||
pub host_bot_id: Option<String>,
|
||||
pub last_message_at: Option<DateTime<Utc>>,
|
||||
pub last_preview: Option<String>,
|
||||
pub unread_count: i64,
|
||||
|
|
@ -26,6 +29,18 @@ pub struct Room {
|
|||
pub struct CreateRoomInput {
|
||||
pub name: String,
|
||||
pub member_ids: Vec<String>,
|
||||
/// Optional from the first version: leaving it out makes the first member
|
||||
/// the host, which is what a group needs by default.
|
||||
#[serde(default)]
|
||||
pub host_bot_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateRoomInput {
|
||||
/// Must be one of the room's members.
|
||||
#[serde(default)]
|
||||
pub host_bot_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::RoomMember;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Session {
|
||||
|
|
@ -46,6 +48,10 @@ pub struct SessionMessage {
|
|||
pub speaker_name: Option<String>,
|
||||
pub speaker_color: Option<String>,
|
||||
pub speaker_shape: Option<String>,
|
||||
/// Who the group decided should answer this message, in order. Empty for a
|
||||
/// one-to-one conversation and for messages written before routing existed.
|
||||
#[serde(default)]
|
||||
pub reply_bots: Vec<RoomMember>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
| `LAZYBOY_COMPUTER_SUDO` | 容器內免密碼 sudo;重建桌面容器後生效 | `false` |
|
||||
| `LAZYBOY_COMPUTER_DRIVER` | 只支援 `cua`。舊後端已移除;更新映像後需重建桌面容器。 | `cua` |
|
||||
| `LAZYBOY_MEMORY_ENABLED` | 長期記憶 | `true` |
|
||||
| `LAZYBOY_ROUTER_MODEL` | 群組沒被點名時挑人回覆用的模型;留空沿用空間預設 | 空間預設 |
|
||||
|
||||
完整清單與保留政策請見 [`.env.example`](../.env.example)。
|
||||
|
||||
|
|
@ -136,6 +137,25 @@ LAZYBOY_RUN_HARD_MINUTES=240 # 時間保險絲
|
|||
|
||||
真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。
|
||||
|
||||
## 群組聊天:誰說話
|
||||
|
||||
群組不是廣播。訊息進來的當下就決定好誰要回覆,其他人不被叫醒,也就不會各打一輪模型。
|
||||
|
||||
- `@名字` 是指令:只有被點名的人回。`@所有人`(`@全部`、`@all`、`@everyone`)才是全員出動,這是唯一的逃生口。
|
||||
- 沒被點名時,由一個模型照成員的工作內容與簡介挑人,最多三個;只有兩個人的群組不挑,直接由主持人回答。
|
||||
- 挑不到人、模型不能用、或挑超過 1.2 秒,一律由主持人接手。訊息不會默默消失——被無視比多回一句更糟。
|
||||
- 回覆的人可以交接一次:它在訊息裡 `@某個成員`,系統就替對方排一個 run。交接而來的 run 不能再交接,兩個愛聊的機器人不會把群組和帳單一起撐爆。
|
||||
- 主持人預設是群組第一位成員,對話頁上方點主持人名字就能換人,沒有群組設定頁要翻。
|
||||
|
||||
決定寫進 `messages.reply_bot_ids`(訊息旁邊會顯示「由誰回覆」「交接給誰」),並記進 api 日誌:
|
||||
|
||||
```text
|
||||
INFO room routing room=… thread=… reason=mention targets=["01…"]
|
||||
```
|
||||
|
||||
`reason` 只有四種:`all`(@所有人)、`mention`(點名)、`routed`(模型挑的)、`host`(沒人接,主持人上)。
|
||||
挑人用的模型是 `LAZYBOY_ROUTER_MODEL`,留空沿用空間預設;它只影響挑人這一句,回覆本身照各成員自己的模型。
|
||||
|
||||
## 容器內終端機
|
||||
|
||||
Agent 透過 Cua 操作 VNC 上的真實終端機。`session` 指定持續使用的視窗;`command` 輸入命令,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
# Build from the repository root:
|
||||
# docker build -f image/computer/Dockerfile -t lazyboy/computer:local .
|
||||
|
||||
FROM rust:1-bookworm AS controld
|
||||
# Pinned; must satisfy the workspace rust-version in Cargo.toml.
|
||||
FROM rust:1.98.1-bookworm AS controld
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .cargo .cargo
|
||||
|
|
@ -43,13 +44,30 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
|||
&& cp target/release/cua-driver /cua-driver
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
ARG TARGETARCH
|
||||
|
||||
# Keep Chromium, XFCE, CJK, AT-SPI, git. Skip Debian novnc (pulls nodejs) and
|
||||
# fonts-noto-core (Latin is DejaVu/Liberation/huninn).
|
||||
RUN printf '%s\n' \
|
||||
# dpkg never unpacks docs/man/info/foreign locales; zh_TW catalogs are kept so
|
||||
# XFCE menus are in Traditional Chinese. apt lists/archives live in cache
|
||||
# mounts, so nothing has to be removed from the layer afterwards.
|
||||
# Only Papirus (base) + Papirus-Dark icons are used; the Dark variant symlinks
|
||||
# into the base set, so the other variants are dead weight (~60 MB).
|
||||
RUN --mount=type=cache,id=lazyboy-apt-cache-$TARGETARCH,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=lazyboy-apt-lists-$TARGETARCH,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& printf '%s\n' \
|
||||
'path-exclude=/usr/share/doc/*' \
|
||||
'path-include=/usr/share/doc/*/copyright' \
|
||||
'path-exclude=/usr/share/man/*' \
|
||||
'path-exclude=/usr/share/info/*' \
|
||||
'path-exclude=/usr/share/gtk-doc/*' \
|
||||
'path-exclude=/usr/share/backgrounds/*' \
|
||||
'path-exclude=/usr/share/locale/*' \
|
||||
'path-include=/usr/share/locale/locale.alias' \
|
||||
'path-include=/usr/share/locale/zh_TW/*' \
|
||||
'path-include=/usr/share/locale/zh/*' \
|
||||
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
|
||||
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-slim \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
chromium \
|
||||
|
|
@ -65,11 +83,9 @@ RUN printf '%s\n' \
|
|||
procps \
|
||||
python3 \
|
||||
sudo \
|
||||
tmux \
|
||||
gosu \
|
||||
util-linux \
|
||||
websockify \
|
||||
wmctrl \
|
||||
x11-utils \
|
||||
x11vnc \
|
||||
xdg-utils \
|
||||
|
|
@ -83,6 +99,8 @@ RUN printf '%s\n' \
|
|||
thunar \
|
||||
adwaita-icon-theme \
|
||||
gnome-themes-extra \
|
||||
arc-theme \
|
||||
papirus-icon-theme \
|
||||
librsvg2-common \
|
||||
at-spi2-core \
|
||||
libatk-adaptor \
|
||||
|
|
@ -96,33 +114,50 @@ RUN printf '%s\n' \
|
|||
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& mkdir -p /usr/share/novnc \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /usr/share/doc/* /usr/share/man/* \
|
||||
/usr/share/i18n /usr/share/info /usr/share/gtk-doc /usr/share/backgrounds \
|
||||
&& rm -rf /usr/share/i18n /usr/share/icons/ePapirus /usr/share/icons/ePapirus-Dark /usr/share/icons/Papirus-Light \
|
||||
&& test -e /usr/share/icons/Papirus-Dark/48x48/apps/utilities-terminal.svg \
|
||||
&& test -e /usr/share/icons/Papirus-Dark/48x48/apps/web-browser.svg \
|
||||
&& test -d /usr/share/themes/Arc-Dark/xfwm4 \
|
||||
&& find /usr -name __pycache__ -type d -prune -exec rm -rf '{}' + \
|
||||
&& find /usr -name '*.py[co]' -delete
|
||||
|
||||
# jf open 粉圓 (system UI) + MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig).
|
||||
# MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig). Pinned to a
|
||||
# powerlevel10k-media commit; jf open 粉圓 (system UI) is the same verified
|
||||
# file the Cua stage embeds, copied below.
|
||||
ARG MESLO_BASE=https://raw.githubusercontent.com/romkatv/powerlevel10k-media/145eb9fbc2f42ee408dacd9b22d8e6e0e553f83d
|
||||
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \
|
||||
&& curl -fsSL -o /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf \
|
||||
https://github.com/justfont/open-huninn-font/releases/download/v2.1/jf-openhuninn-2.1.ttf \
|
||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Regular.ttf" \
|
||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Regular.ttf" \
|
||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Bold.ttf" \
|
||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold.ttf" \
|
||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Italic.ttf" \
|
||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Italic.ttf" \
|
||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Bold Italic.ttf" \
|
||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold%20Italic.ttf" \
|
||||
&& fc-cache -f
|
||||
&& cd /usr/share/fonts/truetype/meslo \
|
||||
&& curl -fsSL -o "MesloLGS NF Regular.ttf" "$MESLO_BASE/MesloLGS%20NF%20Regular.ttf" \
|
||||
&& curl -fsSL -o "MesloLGS NF Bold.ttf" "$MESLO_BASE/MesloLGS%20NF%20Bold.ttf" \
|
||||
&& curl -fsSL -o "MesloLGS NF Italic.ttf" "$MESLO_BASE/MesloLGS%20NF%20Italic.ttf" \
|
||||
&& curl -fsSL -o "MesloLGS NF Bold Italic.ttf" "$MESLO_BASE/MesloLGS%20NF%20Bold%20Italic.ttf" \
|
||||
&& printf '%s\n' \
|
||||
'd97946186e97f8d7c0139e8983abf40a1d2d086924f2c5dbf1c29bd8f2c6e57d MesloLGS NF Regular.ttf' \
|
||||
'b6c0199cf7c7483c8343ea020658925e6de0aeb318b89908152fcb4d19226003 MesloLGS NF Bold.ttf' \
|
||||
'6f357bcbe2597704e157a915625928bca38364a89c22a4ac36e7a116dcd392ef MesloLGS NF Italic.ttf' \
|
||||
'56b4131adecec052c4b324efb818dd326d586dbc316fc68f98f1cae2eb8d1220 MesloLGS NF Bold Italic.ttf' \
|
||||
| sha256sum -c
|
||||
COPY --from=cua_driver --chmod=644 /tmp/huninn.ttf /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf
|
||||
RUN fc-cache -f
|
||||
|
||||
RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /usr/share/zsh-theme-powerlevel10k \
|
||||
&& git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions.git /usr/share/zsh/plugins/zsh-autosuggestions \
|
||||
&& git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting.git /usr/share/zsh/plugins/zsh-syntax-highlighting \
|
||||
&& git clone --depth=1 https://github.com/agkozak/zsh-z.git /usr/share/zsh/plugins/zsh-z \
|
||||
&& rm -rf /usr/share/zsh-theme-powerlevel10k/.git \
|
||||
/usr/share/zsh/plugins/zsh-autosuggestions/.git \
|
||||
/usr/share/zsh/plugins/zsh-syntax-highlighting/.git \
|
||||
/usr/share/zsh/plugins/zsh-z/.git
|
||||
# zsh prompt + plugins from pinned release tarballs (reproducible, no build-time git).
|
||||
RUN set -e; mkdir -p /tmp/zsh /usr/share/zsh/plugins; \
|
||||
fetch() { \
|
||||
curl -fsSL -o "/tmp/zsh/$1.tar.gz" "$2" \
|
||||
&& echo "$3 /tmp/zsh/$1.tar.gz" | sha256sum -c \
|
||||
&& mkdir -p "$4" && tar -xzf "/tmp/zsh/$1.tar.gz" --strip-components=1 -C "$4"; \
|
||||
}; \
|
||||
fetch powerlevel10k https://codeload.github.com/romkatv/powerlevel10k/tar.gz/refs/tags/v1.20.0 \
|
||||
d8187d44b697b3a37a8c4896678b4380e717cbf2850179529358348780a2d3d7 /usr/share/zsh-theme-powerlevel10k; \
|
||||
fetch zsh-autosuggestions https://codeload.github.com/zsh-users/zsh-autosuggestions/tar.gz/refs/tags/v0.7.1 \
|
||||
0df7affff21cd87ed298e6a3970ed08a1dd66a6efa676454ee5b091ad503badf /usr/share/zsh/plugins/zsh-autosuggestions; \
|
||||
fetch zsh-syntax-highlighting https://codeload.github.com/zsh-users/zsh-syntax-highlighting/tar.gz/refs/tags/0.8.0 \
|
||||
5981c19ebaab027e356fe1ee5284f7a021b89d4405cc53dc84b476c3aee9cc32 /usr/share/zsh/plugins/zsh-syntax-highlighting; \
|
||||
fetch zsh-z https://codeload.github.com/agkozak/zsh-z/tar.gz/102fb78036ed76feedf623907483691777a1d510 \
|
||||
90edc058f50447d27915accd18e022b4059fbaf1d1b34408034fb8f14eba5b9b /usr/share/zsh/plugins/zsh-z; \
|
||||
rm -rf /tmp/zsh; \
|
||||
test -f /usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme; \
|
||||
test -f /usr/share/zsh/plugins/zsh-z/zsh-z.plugin.zsh
|
||||
|
||||
RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
|
||||
&& mkdir -p /home/lazyboy /tmp/lazyboy /usr/share/lazyboy/skel /usr/share/lazyboy/xfce-skel /etc/gtk-3.0 /etc/fonts/conf.d \
|
||||
|
|
@ -153,6 +188,7 @@ COPY --chmod=644 image/computer/xfce/xfce4-panel.xml /usr/share/lazyboy/xfce-ske
|
|||
COPY --chmod=644 image/computer/xfce/xfwm4.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml
|
||||
COPY --chmod=644 image/computer/xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
|
||||
COPY --chmod=644 image/computer/xfce/thunar.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/thunar.xml
|
||||
COPY --chmod=644 image/computer/xfce/wallpaper.svg /usr/share/lazyboy/wallpaper.svg
|
||||
COPY --chmod=644 image/computer/xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
|
||||
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
|
||||
# Traditional Chinese catalogs were retained during package installation.
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ ScrollingOnOutput=FALSE
|
|||
ScrollingOnKeystroke=TRUE
|
||||
ScrollingBar=TERMINAL_SCROLLBAR_NONE
|
||||
ScrollingLines=20000
|
||||
ColorForeground=#e2e8f0
|
||||
ColorBackground=#0b1220
|
||||
ColorCursor=#7dd3fc
|
||||
ColorForeground=#d3dae3
|
||||
ColorBackground=#2f343f
|
||||
ColorCursor=#5294e2
|
||||
ColorBoldUseDefault=FALSE
|
||||
ColorBold=#f8fafc
|
||||
TabActivityColor=#38bdf8
|
||||
ColorBold=#ffffff
|
||||
TabActivityColor=#5294e2
|
||||
Encoding=UTF-8
|
||||
TitleMode=TERMINAL_TITLE_REPLACE
|
||||
CommandLoginShell=TRUE
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[Settings]
|
||||
gtk-font-name=jf open 粉圓 2.1 12
|
||||
gtk-icon-theme-name=Adwaita
|
||||
gtk-theme-name=Adwaita-dark
|
||||
gtk-icon-theme-name=Papirus-Dark
|
||||
gtk-theme-name=Arc-Dark
|
||||
gtk-application-prefer-dark-theme=1
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ hydrate_xfce() {
|
|||
if [[ -r /etc/xdg/xfce4/helpers.rc ]]; then
|
||||
cp -f /etc/xdg/xfce4/helpers.rc "$xfce_home/.config/xfce4/helpers.rc" || true
|
||||
fi
|
||||
# Terminals launched by this desktop (boot, panel, menu) inherit
|
||||
# XDG_CONFIG_HOME=$xfce_home/.config, not $HOME/.config.
|
||||
if [[ -r /usr/share/lazyboy/skel/terminalrc ]]; then
|
||||
mkdir -p "$xfce_home/.config/xfce4/terminal"
|
||||
cp -f /usr/share/lazyboy/skel/terminalrc "$xfce_home/.config/xfce4/terminal/terminalrc" || true
|
||||
fi
|
||||
}
|
||||
|
||||
wait_display() {
|
||||
|
|
@ -159,7 +165,6 @@ start_desktop() {
|
|||
return 0
|
||||
fi
|
||||
hydrate_xfce "$xfce_home"
|
||||
DISPLAY="$display" xsetroot -solid "#0f172a" >/dev/null 2>&1 || true
|
||||
export DISPLAY="$display"
|
||||
export XDG_CONFIG_HOME="$xfce_home/.config"
|
||||
export XDG_CACHE_HOME="$xfce_home/.cache"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
WebBrowser=lazyboy-browser
|
||||
MailReader=thunderbird
|
||||
TerminalEmulator=lazyboy-terminal
|
||||
FileManager=thunar
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1200" viewBox="0 0 1920 1200">
|
||||
<defs>
|
||||
<linearGradient id="base" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#383c4a"/>
|
||||
<stop offset="0.55" stop-color="#2f343f"/>
|
||||
<stop offset="1" stop-color="#1b1e24"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glowBlue" cx="0.78" cy="0.22" r="0.55">
|
||||
<stop offset="0" stop-color="#5294e2" stop-opacity="0.38"/>
|
||||
<stop offset="0.5" stop-color="#5294e2" stop-opacity="0.10"/>
|
||||
<stop offset="1" stop-color="#5294e2" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glowTeal" cx="0.12" cy="0.88" r="0.5">
|
||||
<stop offset="0" stop-color="#4dd0c4" stop-opacity="0.22"/>
|
||||
<stop offset="0.6" stop-color="#4dd0c4" stop-opacity="0.05"/>
|
||||
<stop offset="1" stop-color="#4dd0c4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1920" height="1200" fill="url(#base)"/>
|
||||
<rect width="1920" height="1200" fill="url(#glowBlue)"/>
|
||||
<rect width="1920" height="1200" fill="url(#glowTeal)"/>
|
||||
<g fill="none" stroke="#5294e2" stroke-opacity="0.16" stroke-width="2">
|
||||
<circle cx="1500" cy="260" r="220"/>
|
||||
<circle cx="1500" cy="260" r="340"/>
|
||||
<circle cx="1500" cy="260" r="470"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1.5">
|
||||
<path d="M0 900 C 400 780, 800 1020, 1200 880 S 1700 760, 1920 840"/>
|
||||
<path d="M0 980 C 420 860, 820 1100, 1220 960 S 1720 840, 1920 920"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -18,16 +18,32 @@
|
|||
<property name="show-icons" type="bool" value="true"/>
|
||||
<property name="show-workspace-names" type="bool" value="false"/>
|
||||
</property>
|
||||
<!-- Xvfb's RANDR output is named "screen", so xfdesktop reads monitorscreen;
|
||||
monitor0 covers displays that report a numbered output. -->
|
||||
<property name="backdrop" type="empty">
|
||||
<property name="screen0" type="empty">
|
||||
<property name="monitorscreen" type="empty">
|
||||
<property name="workspace0" type="empty">
|
||||
<property name="color-style" type="int" value="0"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
|
||||
<property name="rgba1" type="array">
|
||||
<value type="double" value="0.184314"/>
|
||||
<value type="double" value="0.203922"/>
|
||||
<value type="double" value="0.247059"/>
|
||||
<value type="double" value="1"/>
|
||||
</property>
|
||||
</property>
|
||||
</property>
|
||||
<property name="monitor0" type="empty">
|
||||
<property name="workspace0" type="empty">
|
||||
<property name="color-style" type="int" value="0"/>
|
||||
<property name="image-style" type="int" value="0"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
|
||||
<property name="rgba1" type="array">
|
||||
<value type="double" value="0.0588235"/>
|
||||
<value type="double" value="0.0901961"/>
|
||||
<value type="double" value="0.164706"/>
|
||||
<value type="double" value="0.184314"/>
|
||||
<value type="double" value="0.203922"/>
|
||||
<value type="double" value="0.247059"/>
|
||||
<value type="double" value="1"/>
|
||||
</property>
|
||||
</property>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
<value type="int" value="1"/>
|
||||
<property name="dark-mode" type="bool" value="true"/>
|
||||
<property name="panel-1" type="empty">
|
||||
<property name="position" type="string" value="p=8;x=640;y=798"/>
|
||||
<property name="position" type="string" value="p=8;x=640;y=782"/>
|
||||
<property name="length" type="uint" value="100"/>
|
||||
<property name="position-locked" type="bool" value="true"/>
|
||||
<property name="size" type="uint" value="40"/>
|
||||
<property name="icon-size" type="uint" value="24"/>
|
||||
<property name="size" type="uint" value="36"/>
|
||||
<property name="icon-size" type="uint" value="22"/>
|
||||
<property name="background-style" type="uint" value="0"/>
|
||||
<property name="autohide-behavior" type="uint" value="0"/>
|
||||
<property name="mode" type="uint" value="0"/>
|
||||
|
|
@ -25,13 +25,14 @@
|
|||
<property name="plugins" type="empty">
|
||||
<property name="plugin-1" type="string" value="applicationsmenu">
|
||||
<property name="show-generic-names" type="bool" value="false"/>
|
||||
<property name="show-button-icon" type="bool" value="true"/>
|
||||
<property name="show-button-title" type="bool" value="true"/>
|
||||
<property name="button-title" type="string" value="應用程式"/>
|
||||
</property>
|
||||
<property name="plugin-2" type="string" value="tasklist">
|
||||
<property name="show-handle" type="bool" value="false"/>
|
||||
<property name="show-labels" type="bool" value="true"/>
|
||||
<property name="flat-buttons" type="bool" value="false"/>
|
||||
<property name="flat-buttons" type="bool" value="true"/>
|
||||
<property name="grouping" type="uint" value="0"/>
|
||||
<property name="include-all-workspaces" type="bool" value="true"/>
|
||||
</property>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<property name="snap_to_windows" type="bool" value="true"/>
|
||||
<property name="title_font" type="string" value="jf open 粉圓 2.1 11"/>
|
||||
<property name="button_layout" type="string" value="O|HMC"/>
|
||||
<property name="theme" type="string" value="Default"/>
|
||||
<property name="theme" type="string" value="Arc-Dark"/>
|
||||
<property name="double_click_action" type="string" value="maximize"/>
|
||||
</property>
|
||||
</channel>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
-- Group routing: an explicit host, and the audience resolved per message.
|
||||
-- A room used to wake every member for every message; the audience is now
|
||||
-- decided once when the message arrives, so it is stored with the message.
|
||||
|
||||
ALTER TABLE rooms
|
||||
ADD COLUMN IF NOT EXISTS host_bot_id TEXT REFERENCES bots (id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS reply_bot_ids TEXT[];
|
||||
|
||||
-- Existing rooms keep today's behaviour: the first member is the host.
|
||||
UPDATE rooms
|
||||
SET host_bot_id = (
|
||||
SELECT m.bot_id
|
||||
FROM room_members m
|
||||
WHERE m.room_id = rooms.id
|
||||
ORDER BY m.created_at, m.bot_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE host_bot_id IS NULL;
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
-- Deleting a conversation that holds a remembered message used to fail.
|
||||
--
|
||||
-- `DELETE FROM threads` cascades in two steps that Postgres runs one after the
|
||||
-- other: the thread's messages are removed first, and only then is
|
||||
-- memory_items.session_id set to NULL. That second step fires this guard while
|
||||
-- source_message_id still points at a message that is already gone, so the
|
||||
-- guard raised "memory message is outside agent scope" and the delete rolled
|
||||
-- back — the human saw a conversation that could not be removed.
|
||||
--
|
||||
-- The guard now checks only the references that this write actually changes.
|
||||
-- A referential action that clears one column leaves the others as they were,
|
||||
-- and those were validated when they were written. An INSERT, or a move to
|
||||
-- another agent/space/user, still validates everything; pointing the item at a
|
||||
-- different session re-checks that its run and message belong to that session.
|
||||
-- The same race exists for runs (also removed with the thread), so clearing
|
||||
-- session_id never re-checks the run or message.
|
||||
CREATE OR REPLACE FUNCTION validate_memory_item_scope() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
moved BOOLEAN := TG_OP = 'INSERT'
|
||||
OR NEW.bot_id IS DISTINCT FROM OLD.bot_id
|
||||
OR NEW.space_id IS DISTINCT FROM OLD.space_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id;
|
||||
resessioned BOOLEAN := TG_OP = 'UPDATE'
|
||||
AND NEW.session_id IS NOT NULL
|
||||
AND NEW.session_id IS DISTINCT FROM OLD.session_id;
|
||||
BEGIN
|
||||
IF NEW.session_id IS NOT NULL
|
||||
AND (moved OR resessioned)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM threads t
|
||||
WHERE t.id = NEW.session_id AND (
|
||||
(t.room_id IS NULL AND t.bot_id = NEW.bot_id)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rooms room JOIN room_members member ON member.room_id=room.id
|
||||
WHERE room.id=t.room_id AND member.bot_id=NEW.bot_id
|
||||
AND room.space_id=NEW.space_id AND room.user_id=NEW.user_id
|
||||
)
|
||||
)
|
||||
AND t.space_id = NEW.space_id AND t.user_id = NEW.user_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'memory session is outside agent scope';
|
||||
END IF;
|
||||
IF NEW.source_run_id IS NOT NULL
|
||||
AND (moved OR resessioned OR NEW.source_run_id IS DISTINCT FROM OLD.source_run_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runs r
|
||||
WHERE r.id = NEW.source_run_id AND r.bot_id = NEW.bot_id
|
||||
AND r.space_id = NEW.space_id AND r.user_id = NEW.user_id
|
||||
AND (NEW.session_id IS NULL OR r.thread_id = NEW.session_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'memory run is outside agent scope';
|
||||
END IF;
|
||||
IF NEW.source_message_id IS NOT NULL
|
||||
AND (moved OR resessioned OR NEW.source_message_id IS DISTINCT FROM OLD.source_message_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM messages m
|
||||
JOIN threads t ON t.id = m.thread_id
|
||||
WHERE m.id = NEW.source_message_id AND (
|
||||
(t.room_id IS NULL AND t.bot_id = NEW.bot_id)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rooms room JOIN room_members member ON member.room_id=room.id
|
||||
WHERE room.id=t.room_id AND member.bot_id=NEW.bot_id
|
||||
AND room.space_id=NEW.space_id AND room.user_id=NEW.user_id
|
||||
)
|
||||
)
|
||||
AND t.space_id = NEW.space_id AND t.user_id = NEW.user_id
|
||||
AND (NEW.session_id IS NULL OR t.id = NEW.session_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'memory message is outside agent scope';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -439,7 +439,7 @@ test('a burst of session events settles into one refresh',()=>{
|
|||
|
||||
test('chat follows the event stream instead of a fixed two second poll',()=>{
|
||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||
assert.match(app,/subscribeToSession\(activeSessionId,\(\)=>settle\.kick\(\)/);
|
||||
assert.match(app,/subscribeToSession\(activeSessionId,event=>\{[^}]*settle\.kick\(\)\}/,'every non-streaming event kicks the settle coalescer');
|
||||
assert.match(app,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)refresh\(\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
|
||||
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
|
||||
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
|
||||
|
|
@ -622,3 +622,93 @@ for(const hostUpdatesDuringReconnect of [false,true])test(`reconnect preserves s
|
|||
assert.equal(instances.at(-1).viewOnly,false,'a handoff made mid-reconnect still owns the new session');
|
||||
assert.equal(instances.at(-1).focused,true,'taking over lands the first keystroke');
|
||||
});
|
||||
|
||||
// Who speaks in a room is decided by three small pure functions, so the `@`
|
||||
// list is run rather than read: which token the caret is writing, who that token
|
||||
// could mean, and what the composer holds after a pick.
|
||||
const mentionsJs=ts.transpileModule(fs.readFileSync('apps/web/src/mentions.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const mentionsBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||
vm.runInNewContext(mentionsJs,mentionsBox);
|
||||
const {mentionToken,mentionChoices,acceptMention}=mentionsBox.exports;
|
||||
const EVERYONE='所有人';
|
||||
const roster=[{id:'b1',name:'小美'},{id:'b2',name:'Alex'},{id:'b3',name:'Alexa'},{id:'b4',name:'工程小助手'}];
|
||||
// The list is built inside a vm, so its arrays are not this realm's arrays:
|
||||
// compare what a person sees (names, joined) rather than the objects themselves.
|
||||
const names=(token,source=roster)=>mentionChoices(token,source,EVERYONE).map(choice=>choice.name).join(',');
|
||||
|
||||
test('a mention list opens only on the token under the caret',()=>{
|
||||
assert.equal(mentionToken('@',1),'','a bare @ is still a list, not a word');
|
||||
assert.equal(mentionToken('@小美',3),'小美');
|
||||
assert.equal(mentionToken('嗨 @小美 @',7),'','the last word wins, the earlier mention is spent');
|
||||
assert.equal(mentionToken('@小美 幫我看',4),null,'a finished name closes the list');
|
||||
assert.equal(mentionToken('幫我改一下佈景',6),null,'plain text offers nobody');
|
||||
assert.equal(mentionToken('mail@example.com',16),null,'an email address is not a mention');
|
||||
assert.equal(mentionToken('@@',2),null,'a doubled @ is a typo, not a name');
|
||||
});
|
||||
|
||||
test('the whole-room escape hatch is the first name you are offered',()=>{
|
||||
const open=mentionChoices('',roster,EVERYONE);
|
||||
assert.equal(open[0].name,EVERYONE,'@ comes with the escape hatch on top');
|
||||
assert.equal(open[0].member,null,'it reaches the room, so it is nobody in particular');
|
||||
assert.equal(names('').split(',').length,roster.length+1,'then the whole room, in order');
|
||||
assert.equal(names('').split(',')[1],roster[0].name);
|
||||
assert.equal(names('所'),EVERYONE,'a token that cannot grow into 所有人 drops it');
|
||||
assert.equal(names(EVERYONE).split(',')[0],EVERYONE,'typing it out keeps it offered');
|
||||
});
|
||||
|
||||
test('mention choices match loosely and stay one glance long',()=>{
|
||||
assert.equal(names('alex'),'Alex,Alexa','case does not matter, room order does');
|
||||
assert.equal(names('小'),'小美,工程小助手','a name anywhere in the word hits');
|
||||
assert.equal(names(' nobody '),'','a token nobody can answer to offers nobody');
|
||||
const crowd=Array.from({length:9},(_,index)=>({id:`b${index}`,name:`bot${index}`}));
|
||||
assert.equal(names('',crowd).split(',').length,6,'six rows is the cap');
|
||||
assert.equal(names('',crowd).split(',')[0],EVERYONE,'and the escape hatch still leads');
|
||||
});
|
||||
|
||||
test('accepting a mention leaves the sentence readable and the list closed',()=>{
|
||||
const fresh=acceptMention('@',1,'小美');
|
||||
assert.equal(fresh.text,'@小美 ');
|
||||
assert.equal(fresh.caret,fresh.text.length,'the caret sits past the space, so typing continues the sentence');
|
||||
assert.equal(mentionToken(fresh.text,fresh.caret),null,'and the list does not reopen behind it');
|
||||
const picked=acceptMention('幫我 @小 部署',5,'小美');
|
||||
assert.ok(picked.text.startsWith('幫我 @小美 '),'only the typed token is replaced');
|
||||
assert.ok(picked.text.includes('部署'),'the rest of the message survives');
|
||||
assert.equal(acceptMention('@小',2,'小美').text,'@小美 ','a half-typed name is replaced whole');
|
||||
});
|
||||
|
||||
// When a message was said is split the way chat apps split it: the day once,
|
||||
// as a divider, and the clock time on each message. The words for today and
|
||||
// yesterday come from the catalog; everything else is the locale's own spelling.
|
||||
const chatTimeJs=ts.transpileModule(fs.readFileSync('apps/web/src/chat-time.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const chatTimeBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||
vm.runInNewContext(chatTimeJs,chatTimeBox);
|
||||
const {dayLabel,sameDay,clockTime}=chatTimeBox.exports;
|
||||
const words={today:zhTW.today,yesterday:zhTW.yesterday};
|
||||
// A Wednesday afternoon, built in local time so the calendar day is unambiguous.
|
||||
const now=new Date(2026,9,7,15,30);
|
||||
const daysAgo=(count,hour=9)=>new Date(2026,9,7-count,hour);
|
||||
|
||||
test('a day is named the way people say it',()=>{
|
||||
assert.equal(dayLabel(daysAgo(0,0),now,'zh-TW',words),'今天','midnight still counts as today');
|
||||
assert.equal(dayLabel(daysAgo(0,23),now,'zh-TW',words),'今天','a message later today is still today, not the future');
|
||||
assert.equal(dayLabel(daysAgo(1,23),now,'zh-TW',words),'昨天','yesterday means the calendar day, not 24 hours');
|
||||
assert.equal(dayLabel(daysAgo(1),now,'en',{today:en.today,yesterday:en.yesterday}),'Yesterday');
|
||||
assert.equal(dayLabel(daysAgo(2),now,'zh-TW',words),'星期一','the past week goes by weekday');
|
||||
assert.equal(dayLabel(daysAgo(6),now,'zh-TW',words),'星期四','six days back is the last weekday');
|
||||
const week=dayLabel(daysAgo(7),now,'zh-TW',words);
|
||||
assert.match(week,/9\/30|9月30日/,'a week or more ago is written as a date');
|
||||
assert.match(week,/週三|星期三/,'with its weekday, so it reads like a chat and not a form');
|
||||
assert.ok(!/2026/.test(week),'this year is not spelled out');
|
||||
assert.match(dayLabel(new Date(2025,9,1),now,'zh-TW',words),/2025/,'another year is spelled out');
|
||||
assert.match(dayLabel(new Date(2026,8,15),now,'en',{today:en.today,yesterday:en.yesterday}),/Tue, 9\/15/,'English keeps the same short numeric date');
|
||||
assert.equal(dayLabel('not a date',now,'zh-TW',words),'','garbage shows nothing rather than Invalid Date');
|
||||
});
|
||||
|
||||
test('messages split by calendar day and show only their clock time',()=>{
|
||||
assert.ok(sameDay(new Date(2026,9,7,0,1),new Date(2026,9,7,23,59)),'first and last minute share a day');
|
||||
assert.ok(!sameDay(new Date(2026,9,7,23,59),new Date(2026,9,8,0,0)),'a minute past midnight is a new day');
|
||||
assert.ok(!sameDay('garbage','garbage'),'two unreadable dates are not the same day');
|
||||
assert.equal(clockTime(new Date(2026,9,7,9,5),'zh-TW'),'09:05','24-hour clock, zero padded');
|
||||
assert.equal(clockTime(new Date(2026,9,7,15,30),'en'),'15:30');
|
||||
assert.equal(clockTime('garbage','en'),'');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue