Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda |
@@ -1,846 +0,0 @@
|
||||
/**
|
||||
* @file ChatHub.tsx
|
||||
* @description Chat between users inside the pet's window (server mode,
|
||||
* `/api/chat`, state from `useChat.ts`). `ChatRail` is the narrow column
|
||||
* beside the window: the pet itself first (its AI chat, `PetChat.tsx`), then
|
||||
* the user's direct chats and groups, newest first, each with its unread
|
||||
* count, and **+** for a new chat. `ChatRoomView` is one room, the same size as
|
||||
* the pet's chat: messages (a group shows who wrote each), older pages on
|
||||
* demand, text with links, pasted / dropped / attached files (images shown
|
||||
* inline, anything else a download, all served only to the room's members),
|
||||
* and for a group its settings — the creator renames it and adds or removes
|
||||
* people; anyone may leave. `NewChat` picks people: one person opens the
|
||||
* direct chat with them, more (or a name) makes a group. Only a room's members
|
||||
* ever see it; an admin has no view of other people's chats.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
LogOut,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Settings2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
||||
import { formatBytes, formatTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import type { ChatState } from "./useChat";
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** Shown name of a person (display name, else login). */
|
||||
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
|
||||
p ? p.displayName || p.username : "?";
|
||||
|
||||
/** Everyone the view may need a name for: the people list plus the viewer. */
|
||||
function useDirectory(chat: ChatState) {
|
||||
const { state } = useAuth();
|
||||
const self = state.mode === "server" ? state.user : null;
|
||||
return useMemo(() => {
|
||||
const m = new Map<number, ChatPerson>(chat.people.map((p) => [p.id, p]));
|
||||
if (self)
|
||||
m.set(self.id, {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
displayName: self.displayName ?? null,
|
||||
avatarAt: self.avatarAt ?? null,
|
||||
});
|
||||
return m;
|
||||
}, [chat.people, self]);
|
||||
}
|
||||
|
||||
/** The other person of a direct chat. */
|
||||
const otherOf = (room: ChatRoom, me: number | null) =>
|
||||
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
|
||||
|
||||
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
|
||||
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
|
||||
if (room.name) return room.name;
|
||||
return room.members
|
||||
.filter((id) => id !== me)
|
||||
.map((id) => personName(dir.get(id)))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function RoomIcon({
|
||||
room,
|
||||
me,
|
||||
dir,
|
||||
className,
|
||||
}: {
|
||||
room: ChatRoom;
|
||||
me: number | null;
|
||||
dir: Map<number, ChatPerson>;
|
||||
className: string;
|
||||
}) {
|
||||
if (room.kind === "dm") {
|
||||
const p = dir.get(otherOf(room, me));
|
||||
return (
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p?.id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center justify-center rounded-full bg-surface-4 text-fg-secondary ${className}`}
|
||||
>
|
||||
<Users size={16} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ n }: { n: number }) =>
|
||||
n > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-[1.1rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-[1.1rem] text-white">
|
||||
{n > 99 ? "99+" : n}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/** The column beside the window: the pet, the rooms, and New chat. */
|
||||
export function ChatRail({
|
||||
chat,
|
||||
active,
|
||||
onSelect,
|
||||
petName,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
/** `"pet"`, `"new"` or a room id. */
|
||||
active: "pet" | "new" | number;
|
||||
onSelect: (v: "pet" | "new" | number) => void;
|
||||
petName: string;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
const ring = (on: boolean) =>
|
||||
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
|
||||
return (
|
||||
<nav
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto rounded-3xl border border-border-light py-3 shadow-2xl"
|
||||
aria-label={t("sagi.people.rail")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("pet")}
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
|
||||
title={petName}
|
||||
aria-label={petName}
|
||||
aria-pressed={active === "pet"}
|
||||
>
|
||||
<span aria-hidden>🐾</span>
|
||||
</button>
|
||||
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
||||
{chat.rooms.map((r) => {
|
||||
const title = roomTitle(r, chat.me, dir);
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
|
||||
title={title}
|
||||
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
|
||||
aria-pressed={active === r.id}
|
||||
>
|
||||
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
|
||||
<Badge n={r.unread} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("new")}
|
||||
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
|
||||
title={t("sagi.people.new")}
|
||||
aria-label={t("sagi.people.new")}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** People picker with checkboxes. */
|
||||
function PeoplePicker({
|
||||
people,
|
||||
picked,
|
||||
onToggle,
|
||||
}: {
|
||||
people: ChatPerson[];
|
||||
picked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [q, setQ] = useState("");
|
||||
const shown = people.filter((p) =>
|
||||
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("sagi.people.search")}
|
||||
aria-label={t("sagi.people.search")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-4">
|
||||
<input type="checkbox" checked={picked.has(p.id)} onChange={() => onToggle(p.id)} />
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p.id}
|
||||
avatarAt={p.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!shown.length && (
|
||||
<li className="px-2 py-4 text-center text-xs text-fg-muted">{t("sagi.people.none")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Start a chat: one person → the direct chat; more, or a name → a group. */
|
||||
export function NewChat({
|
||||
chat,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
onOpen: (roomId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const group = picked.size > 1 || name.trim() !== "";
|
||||
const toggle = (id: number) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const start = async () => {
|
||||
if (!picked.size) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { room } = group
|
||||
? await api.chat.createGroup(name.trim(), [...picked])
|
||||
: await api.chat.openDm([...picked][0]!);
|
||||
chat.upsert(room);
|
||||
onOpen(room.id);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Shell label={t("sagi.people.new")}>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
{t("sagi.people.new")}
|
||||
</div>
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
|
||||
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
|
||||
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pet-chat-foot px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary w-full justify-center"
|
||||
disabled={!picked.size || busy}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
{group ? t("sagi.people.createGroup", { n: picked.size }) : t("sagi.people.startDm")}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
/** Text with its http(s) links clickable (a new tab). */
|
||||
function Linkified({ text }: { text: string }) {
|
||||
const parts = text.split(URL_RE);
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<a key={i} href={p} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{p}
|
||||
</a>
|
||||
) : (
|
||||
<span key={i}>{p}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
|
||||
const [kind, ids] = m.body.split(":");
|
||||
const names = (ids ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((id) => personName(dir.get(Number(id))))
|
||||
.join(", ");
|
||||
const text =
|
||||
kind === "added"
|
||||
? t("sagi.people.sysAdded", { who, names })
|
||||
: kind === "removed"
|
||||
? t("sagi.people.sysRemoved", { who, names })
|
||||
: kind === "left"
|
||||
? t("sagi.people.sysLeft", { who })
|
||||
: kind === "renamed"
|
||||
? t("sagi.people.sysRenamed", { who })
|
||||
: t("sagi.people.sysCreated", { who });
|
||||
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
|
||||
}
|
||||
|
||||
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
|
||||
return (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{m.files.map((f) => {
|
||||
const url = api.chat.fileUrl(m.roomId, f.id);
|
||||
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
|
||||
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={url}
|
||||
alt={f.name}
|
||||
loading="lazy"
|
||||
className="max-h-48 max-w-full rounded-xl object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
key={f.id}
|
||||
href={url}
|
||||
download={f.name}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
|
||||
>
|
||||
<FileText size={14} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<span className="shrink-0 opacity-70">{formatBytes(f.size)}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group's settings: name, members, Leave. */
|
||||
function GroupSettings({
|
||||
chat,
|
||||
room,
|
||||
dir,
|
||||
onBack,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
dir: Map<number, ChatPerson>;
|
||||
onBack: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const owner = room.createdBy === chat.me;
|
||||
const [name, setName] = useState(room.name);
|
||||
const [adding, setAdding] = useState<Set<number>>(new Set());
|
||||
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
|
||||
try {
|
||||
const r = await api.chat.updateGroup(room.id, patch);
|
||||
chat.upsert(r.room);
|
||||
setAdding(new Set());
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
const leave = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("sagi.people.leaveTitle"),
|
||||
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
|
||||
confirmLabel: t("sagi.people.leave"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.chat.leave(room.id);
|
||||
await chat.reload();
|
||||
onLeft();
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
const outside = chat.people.filter((p) => !room.members.includes(p.id));
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 self-start text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft size={13} /> {t("sagi.people.back")}
|
||||
</button>
|
||||
{owner && (
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save({ name });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input flex-1 text-sm"
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-secondary" disabled={name === room.name}>
|
||||
{t("sagi.people.rename")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{room.members.map((id) => {
|
||||
const p = dir.get(id);
|
||||
return (
|
||||
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
{id === room.createdBy && (
|
||||
<span className="ml-1 text-[10px] text-fg-muted">
|
||||
{t("sagi.people.creator")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{owner && id !== chat.me && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1 text-fg-muted hover:text-status-danger"
|
||||
aria-label={t("sagi.people.remove", { name: personName(p) })}
|
||||
title={t("sagi.people.remove", { name: personName(p) })}
|
||||
onClick={() => void save({ remove: [id] })}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{owner && outside.length > 0 && (
|
||||
<div className="flex min-h-[10rem] flex-col gap-1.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.add")}
|
||||
</p>
|
||||
<PeoplePicker
|
||||
people={outside}
|
||||
picked={adding}
|
||||
onToggle={(id) =>
|
||||
setAdding((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary justify-center"
|
||||
disabled={!adding.size}
|
||||
onClick={() => void save({ add: [...adding] })}
|
||||
>
|
||||
{t("sagi.people.addN", { n: adding.size })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
|
||||
onClick={() => void leave()}
|
||||
>
|
||||
<LogOut size={14} /> {t("sagi.people.leave")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One direct chat or group. */
|
||||
export function ChatRoomView({
|
||||
chat,
|
||||
room,
|
||||
onClose,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
onClose: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
const title = roomTitle(room, chat.me, dir);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const stick = useRef(true);
|
||||
const { markRead } = chat;
|
||||
|
||||
const read = useCallback(
|
||||
(upTo: number) => {
|
||||
markRead(room.id, upTo);
|
||||
api.chat.read(room.id, upTo).catch(() => undefined);
|
||||
},
|
||||
[room.id, markRead]
|
||||
);
|
||||
|
||||
// The room's newest page on open, then each new message as it arrives.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setMessages([]);
|
||||
setSettings(false);
|
||||
stick.current = true;
|
||||
api.chat
|
||||
.messages(room.id)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages(r.messages);
|
||||
setHasMore(r.hasMore);
|
||||
const last = r.messages[r.messages.length - 1];
|
||||
if (last) read(last.id);
|
||||
})
|
||||
.catch((e) => toast.error(e));
|
||||
chat.onMessage((m) => {
|
||||
if (m.roomId !== room.id) return;
|
||||
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
|
||||
if (document.visibilityState === "visible") read(m.id);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
chat.onMessage(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const older = async () => {
|
||||
const first = messages[0];
|
||||
if (!first) return;
|
||||
try {
|
||||
const r = await api.chat.messages(room.id, first.id);
|
||||
stick.current = false;
|
||||
setMessages((prev) => [...r.messages, ...prev]);
|
||||
setHasMore(r.hasMore);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = (list: FileList | File[] | null) => {
|
||||
if (!list) return;
|
||||
const picked = [...list];
|
||||
const big = picked.find((f) => f.size > MAX_FILE_BYTES);
|
||||
if (big) toast.error(t("sagi.people.tooBig", { name: big.name }));
|
||||
setFiles((prev) =>
|
||||
[...prev, ...picked.filter((f) => f.size <= MAX_FILE_BYTES)].slice(0, MAX_FILES)
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if ((!draft.trim() && !files.length) || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { message } = await api.chat.post(room.id, draft, files);
|
||||
stick.current = true;
|
||||
setMessages((prev) => (prev.some((x) => x.id === message.id) ? prev : [...prev, message]));
|
||||
setDraft("");
|
||||
setFiles([]);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell label={t("sagi.people.roomLabel", { name: title })}>
|
||||
<div className="pet-chat-head flex items-center gap-2 px-2.5 py-2">
|
||||
<RoomIcon room={room} me={chat.me} dir={dir} className="h-7 w-7 text-[10px]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<HeadButton label={t("sagi.people.settings")} onClick={() => setSettings((v) => !v)}>
|
||||
<Settings2 size={15} />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</div>
|
||||
|
||||
{settings && room.kind === "group" ? (
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
room={room}
|
||||
dir={dir}
|
||||
onBack={() => setSettings(false)}
|
||||
onLeft={onLeft}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-auto block text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
onClick={() => void older()}
|
||||
>
|
||||
{t("sagi.people.older")}
|
||||
</button>
|
||||
)}
|
||||
{!messages.length && (
|
||||
<p className="py-10 text-center text-xs text-fg-muted">
|
||||
{t("sagi.people.empty", { name: title })}
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m, i) => {
|
||||
if (m.kind === "system") return <SystemLine key={m.id} m={m} dir={dir} />;
|
||||
const own = m.userId === chat.me;
|
||||
const prev = messages[i - 1];
|
||||
const showName =
|
||||
!own &&
|
||||
room.kind === "group" &&
|
||||
(!prev || prev.userId !== m.userId || prev.kind === "system");
|
||||
return (
|
||||
<div key={m.id} className={`flex flex-col ${own ? "items-end" : "items-start"}`}>
|
||||
{showName && (
|
||||
<span className="mb-0.5 px-1 text-[10px] text-fg-muted">
|
||||
{personName(m.userId != null ? dir.get(m.userId) : undefined)}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
|
||||
own
|
||||
? "rounded-br-md bg-accent-solid text-white"
|
||||
: "pet-chat-reply rounded-bl-md text-fg-primary"
|
||||
}`}
|
||||
title={formatTime(m.createdAt)}
|
||||
>
|
||||
{m.body && <Linkified text={m.body} />}
|
||||
{m.files.length > 0 && <Files m={m} own={own} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{files.length > 0 && (
|
||||
<ul className="mb-1.5 flex flex-wrap gap-1">
|
||||
{files.map((f, i) => (
|
||||
<li
|
||||
key={`${f.name}-${i}`}
|
||||
className="flex max-w-full items-center gap-1 rounded-full bg-surface-4 py-0.5 pl-2 pr-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="max-w-[10rem] truncate">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:text-status-danger"
|
||||
aria-label={t("sagi.people.unattach", { name: f.name })}
|
||||
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
aria-label={t("sagi.people.attach")}
|
||||
title={t("sagi.people.attach")}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.people.placeholder", { name: title })}
|
||||
aria-label={t("sagi.people.placeholder", { name: title })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
e.preventDefault();
|
||||
addFiles(e.clipboardData.files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!draft.trim() && !files.length) || sending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
||||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
||||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
||||
* who are you…) is answered right here; anything else — and every message once
|
||||
* a chat runs — goes to the AI: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||
* reopening the window continues it; **New chat** STOPS that session (kills
|
||||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
/** What the container made of a message: an answer the pet gives itself, or
|
||||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
||||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** Shown in place of the plain name (the rename button). */
|
||||
title?: ReactNode;
|
||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||
headerActions?: ReactNode;
|
||||
/** Under the header: what the pet knows (status, sessions that need you). */
|
||||
info?: ReactNode;
|
||||
/** Asked first while no chat runs. */
|
||||
onLocal?: (text: string) => PetLocalReply;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onLocal,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
// The pet's own answers, given before any chat ran.
|
||||
const [local, setLocal] = useState<Line[]>([]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = [...local, ...chatLines(messages)];
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
if (!chat && onLocal) {
|
||||
const reply = onLocal(prompt);
|
||||
if (reply && "handled" in reply) return;
|
||||
if (reply) {
|
||||
const n = Date.now();
|
||||
setLocal((prev) => [
|
||||
...prev,
|
||||
{ key: `lu${n}`, role: "user", text: prompt },
|
||||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t, onLocal]
|
||||
);
|
||||
|
||||
// New chat: the old one's session is stopped, not left running unseen —
|
||||
// asking first only while it is still writing a reply.
|
||||
const newChat = async () => {
|
||||
if (chat) {
|
||||
if (
|
||||
busy &&
|
||||
!(await confirmDialog({
|
||||
title: t("sagi.chat.endTitle"),
|
||||
message: t("sagi.chat.endBody"),
|
||||
confirmLabel: t("sagi.chat.endConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(chat.runId, { archive: true });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
remember(null);
|
||||
setLocal([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions}
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => void newChat()}
|
||||
disabled={(!chat && !local.length) || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{info}
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file PetPanel.tsx
|
||||
* @description The pet's window — clicking the pet opens it: its name (which
|
||||
* the user can rename), a pat button, and what the pet knows (`info`: live /
|
||||
* waiting / errored, the sessions that need you, Mute, Clear alerts —
|
||||
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
|
||||
* question, a heavy process on a shared server, so the chat was removed
|
||||
* (v2.8.68); this window is where the pet's own care comes next.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function PetPanel({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onClose,
|
||||
}: {
|
||||
name: string;
|
||||
/** The name line (a rename button); plain text without it. */
|
||||
title?: ReactNode;
|
||||
/** Buttons before Close (the pat). */
|
||||
headerActions?: ReactNode;
|
||||
/** What the pet knows (`SagiInfo`). */
|
||||
info: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
</div>
|
||||
{headerActions}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
title={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||
status chips' row then showed a horizontal scrollbar. */}
|
||||
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,8 @@
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI. In server mode a
|
||||
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
|
||||
* groups with other users, and a blue dot on the pet counts their unread
|
||||
* messages.
|
||||
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
|
||||
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -18,10 +14,7 @@
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||
* chat cannot read back starts there and its session view opens
|
||||
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -94,17 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { useChat } from "./useChat";
|
||||
import { PetPanel } from "./PetPanel";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -234,7 +222,7 @@ function SagiBody() {
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||
// The pet's window (PetPanel.tsx).
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
@@ -243,14 +231,6 @@ function SagiBody() {
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
|
||||
const people = useChat();
|
||||
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
|
||||
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
|
||||
// A room that went away (left, removed): back to the pet.
|
||||
useEffect(() => {
|
||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
||||
}, [pane, room, people.rooms.length]);
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||
@@ -353,43 +333,6 @@ function SagiBody() {
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
// Asked by the chat before any session runs.
|
||||
const onLocal = useCallback(
|
||||
(query: string): PetLocalReply => {
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return { answer: result.text };
|
||||
}
|
||||
// A question the pet cannot answer: the chat starts a session of the
|
||||
// default AI tool and shows it as chat.
|
||||
if (petChatTool()) return null;
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
||||
} finally {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return { handled: true };
|
||||
},
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
@@ -411,8 +354,6 @@ function SagiBody() {
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -425,67 +366,44 @@ function SagiBody() {
|
||||
return (
|
||||
<>
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||
re-attaches the same session. */}
|
||||
it 1:1). */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<div className="flex items-start gap-2">
|
||||
{people.enabled && (
|
||||
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
|
||||
)}
|
||||
{people.enabled && pane === "new" ? (
|
||||
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
|
||||
) : people.enabled && room ? (
|
||||
<ChatRoomView
|
||||
chat={people}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("pet")}
|
||||
/>
|
||||
) : (
|
||||
<PetChat
|
||||
<PetPanel
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -579,11 +497,6 @@ function SagiBody() {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{people.unread > 0 && (
|
||||
<span className="sagi-chat-dot" aria-hidden>
|
||||
{people.unread > 9 ? "9+" : people.unread}
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SagiInfo.tsx
|
||||
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
||||
* @description What the pet knows, shown inside its window (`PetPanel`):
|
||||
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||
* always in sight, and on expanding, the sessions that need the user, by
|
||||
@@ -118,8 +118,8 @@ export function SagiInfo({
|
||||
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={t("sagi.chat.status")}
|
||||
title={t("sagi.chat.status")}
|
||||
aria-label={t("sagi.panel.status")}
|
||||
title={t("sagi.panel.status")}
|
||||
>
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* @file ChatHub.test.tsx
|
||||
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
|
||||
* lists the pet then the rooms with their unread counts; New chat opens a
|
||||
* direct chat for one person and makes a group for several; a room reads its
|
||||
* messages (a group names who wrote each), marks them read, appends one that
|
||||
* arrives live, and sends text with the attached files.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ChatMessage, ChatRoom } from "../../../lib/api";
|
||||
import type { ChatState } from "../useChat";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: {
|
||||
openDm: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
post: vi.fn(),
|
||||
read: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
api,
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
useAuth: () => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
|
||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
||||
id,
|
||||
roomId: 7,
|
||||
userId,
|
||||
kind: "text",
|
||||
body,
|
||||
createdAt: "2026-11-03T02:00:00Z",
|
||||
files: [],
|
||||
});
|
||||
const group: ChatRoom = {
|
||||
id: 7,
|
||||
kind: "group",
|
||||
name: "Team",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2, 3],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 2,
|
||||
};
|
||||
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
|
||||
|
||||
function state(over: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
enabled: true,
|
||||
me: 1,
|
||||
rooms: [group, dm],
|
||||
people: [
|
||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
||||
{ id: 3, username: "binh", displayName: null, avatarAt: null },
|
||||
],
|
||||
unread: 2,
|
||||
reload: vi.fn(async () => {}),
|
||||
upsert: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ChatHub", () => {
|
||||
it("lists the pet, then each room with its unread count", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
|
||||
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(7);
|
||||
// A direct chat is named after the other person.
|
||||
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a direct chat for one person and a group for several", async () => {
|
||||
const chat = state();
|
||||
const onOpen = vi.fn();
|
||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
||||
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
|
||||
expect(api.chat.openDm).toHaveBeenCalledWith(2);
|
||||
|
||||
api.chat.createGroup.mockResolvedValue({ room: group });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
|
||||
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
|
||||
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
|
||||
});
|
||||
|
||||
it("reads the room, names group senders, marks it read and sends with files", async () => {
|
||||
const chat = state();
|
||||
api.chat.messages.mockResolvedValue({
|
||||
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
|
||||
hasMore: false,
|
||||
});
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
||||
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
|
||||
expect(await screen.findByText("chào")).toBeTruthy();
|
||||
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
|
||||
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
|
||||
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
|
||||
|
||||
// A message arriving live is appended.
|
||||
const calls = vi.mocked(chat.onMessage).mock.calls;
|
||||
const push = calls[calls.length - 1]![0]!;
|
||||
push(msg(3, 3, "mới"));
|
||||
expect(await screen.findByText("mới")).toBeTruthy();
|
||||
|
||||
const file = new File(["x"], "a.png", { type: "image/png" });
|
||||
const box = screen.getByLabelText("Message Team");
|
||||
fireEvent.paste(box, { clipboardData: { files: [file] } });
|
||||
expect(screen.getByText("a.png")).toBeTruthy();
|
||||
fireEvent.change(box, { target: { value: "xem nè" } });
|
||||
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the chat on click and answers a local status question there", () => {
|
||||
it("opens its window on click — status, no chat box", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("live")).toBeInTheDocument();
|
||||
expect(within(panel).queryByRole("textbox")).toBeNull();
|
||||
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
|
||||
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the chat", () => {
|
||||
it("names the errored session in its bubble and in its window", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the chat header", () => {
|
||||
it("renames the pet from its window header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the AI", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||
ensure.mockRestore();
|
||||
});
|
||||
|
||||
it("reopens the remembered chat, and New chat stops its session", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.pet.chat",
|
||||
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
||||
);
|
||||
const get = vi
|
||||
.spyOn(api.run, "get")
|
||||
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
||||
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||
});
|
||||
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
||||
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
||||
get.mockRestore();
|
||||
kill.mockRestore();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||
it("a pat shows its reaction on the pet even while its window is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
@@ -187,14 +148,14 @@ describe("Sagi widget", () => {
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the chat status", () => {
|
||||
it("reflects the live count in its status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
@@ -208,7 +169,7 @@ describe("Sagi widget", () => {
|
||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||
@@ -220,7 +181,7 @@ describe("Sagi widget", () => {
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the chat", () => {
|
||||
it("a tap (no movement) still opens its window", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
@@ -228,7 +189,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
@@ -257,7 +218,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
|
||||
* chat messages from other users (server mode).
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -407,25 +406,6 @@
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
/* Unread chat messages from other users: top-left, opposite the error dot. */
|
||||
.sagi-chat-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 2px;
|
||||
border-radius: 9999px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
}
|
||||
|
||||
.sagi-error-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -549,13 +529,10 @@
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
/* ════════ the pet's window (PetPanel.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
/* A tint of the theme's accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -567,61 +544,6 @@
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* @file useChat.ts
|
||||
* @description The pet window's chat between users (server mode only): the
|
||||
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
|
||||
* chat with (`GET /api/chat/people`, read once), and the total unread count
|
||||
* for the badge on the pet. A `chat_message` frame updates its room's newest
|
||||
* message and unread count in place and is handed to the open room's view
|
||||
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
|
||||
* re-reads the list. Off in local mode — one person has no one to chat with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
|
||||
export type ChatState = {
|
||||
/** Server mode with a login: the chat exists. */
|
||||
enabled: boolean;
|
||||
me: number | null;
|
||||
rooms: ChatRoom[];
|
||||
people: ChatPerson[];
|
||||
unread: number;
|
||||
reload: () => Promise<void>;
|
||||
/** A room changed here (opened, renamed, read): put it in the list now. */
|
||||
upsert: (room: ChatRoom) => void;
|
||||
/** The open room's view, told of each new message for it. */
|
||||
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
|
||||
/** Mark a room read locally (the view also tells the server). */
|
||||
markRead: (roomId: number, upTo: number) => void;
|
||||
};
|
||||
|
||||
export function useChat(): ChatState {
|
||||
const { state } = useAuth();
|
||||
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
|
||||
const enabled = me != null;
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
const [people, setPeople] = useState<ChatPerson[]>([]);
|
||||
const listener = useRef<((m: ChatMessage) => void) | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
setRooms((await api.chat.rooms()).rooms);
|
||||
} catch {
|
||||
// The pet's chat with the AI still works; the list retries on the next frame.
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
api.chat
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch(() => undefined);
|
||||
}, [enabled, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const off = eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type === "chat_room") {
|
||||
void reload();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "chat_message") return;
|
||||
const m = (msg.data as { message?: ChatMessage }).message;
|
||||
if (!m) return;
|
||||
let known = false;
|
||||
setRooms((prev) => {
|
||||
const next = prev.map((r) => {
|
||||
if (r.id !== m.roomId) return r;
|
||||
known = true;
|
||||
const own = m.userId === me;
|
||||
return {
|
||||
...r,
|
||||
lastMessage: m,
|
||||
lastMessageAt: m.createdAt,
|
||||
unread: own ? 0 : r.unread + 1,
|
||||
lastReadId: own ? m.id : r.lastReadId,
|
||||
};
|
||||
});
|
||||
return next.sort((a, b) =>
|
||||
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
|
||||
);
|
||||
});
|
||||
// A room this list has not seen yet (added a moment ago): read it all.
|
||||
if (!known) void reload();
|
||||
listener.current?.(m);
|
||||
});
|
||||
// Reconnected: frames may have been missed.
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void reload();
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
offConn();
|
||||
};
|
||||
}, [enabled, me, reload]);
|
||||
|
||||
const upsert = useCallback((room: ChatRoom) => {
|
||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
||||
}, []);
|
||||
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
|
||||
listener.current = fn;
|
||||
}, []);
|
||||
const markRead = useCallback((roomId: number, upTo: number) => {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
|
||||
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
|
||||
}
|
||||
@@ -132,6 +132,8 @@ export function LaneGitBar({
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
// Issue #88: typing narrows the switcher to the branches whose name holds it.
|
||||
const [branchQuery, setBranchQuery] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
@@ -160,6 +162,7 @@ export function LaneGitBar({
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setBranchQuery("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
@@ -176,6 +179,26 @@ export function LaneGitBar({
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
// The switcher's lists, narrowed by the search box (case-insensitive, any
|
||||
// part of the name). The picked branch always stays in its list, so the
|
||||
// select never shows a value it has no option for.
|
||||
const query = branchQuery.trim().toLowerCase();
|
||||
const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked;
|
||||
const shownBranches = (branches || []).filter(matches);
|
||||
const shownRemote = remoteBranches.filter(matches);
|
||||
const matchCount = query
|
||||
? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query))
|
||||
.length
|
||||
: null;
|
||||
const searchBranches = (text: string) => {
|
||||
setBranchQuery(text);
|
||||
const q = text.trim().toLowerCase();
|
||||
if (!q) return;
|
||||
const hits = [...(branches || []), ...remoteBranches].filter((b) =>
|
||||
b.toLowerCase().includes(q)
|
||||
);
|
||||
if (hits.length === 1) setPicked(hits[0]!);
|
||||
};
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -526,6 +549,27 @@ export function LaneGitBar({
|
||||
|
||||
{switching && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
type="search"
|
||||
aria-label={t("gitBar.branchSearch")}
|
||||
placeholder={t("gitBar.branchSearch")}
|
||||
value={branchQuery}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => searchBranches(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && branchQuery) {
|
||||
e.stopPropagation();
|
||||
setBranchQuery("");
|
||||
} else if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
const first = [...shownBranches, ...shownRemote].find(
|
||||
(b) => b.toLowerCase().includes(query) && b !== git.branch
|
||||
);
|
||||
if (first) setPicked(first);
|
||||
}
|
||||
}}
|
||||
className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
@@ -533,8 +577,12 @@ export function LaneGitBar({
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
<option value="">
|
||||
{matchCount === null
|
||||
? t("gitBar.switchLabel")
|
||||
: t("gitBar.branchMatches", { count: matchCount })}
|
||||
</option>
|
||||
{shownBranches.map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
@@ -543,9 +591,9 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
{shownRemote.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
{shownRemote.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
|
||||
@@ -337,6 +337,47 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
|
||||
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const search = screen.getByLabelText("Search branches…");
|
||||
const options = () =>
|
||||
Array.from(select.querySelectorAll("option"))
|
||||
.map((o) => o.value)
|
||||
.filter(Boolean);
|
||||
|
||||
await user.type(search, "CAMPAIGN");
|
||||
expect(options()).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
|
||||
expect(select).toHaveValue("");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(select).toHaveValue("feat/campaign-pause");
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "unit_t");
|
||||
// One match: picked at once (the earlier pick stays listed until replaced).
|
||||
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
|
||||
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(search).toHaveValue("");
|
||||
expect(options()).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||
* running test runners and container commands on this box
|
||||
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
|
||||
* with per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type MemberPolicyState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function MemberPolicySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.memberPolicy()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
return (
|
||||
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("memberPolicy.switch")}
|
||||
description={t("memberPolicy.hint")}
|
||||
checked={state.allowTests}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setMemberPolicy(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
|
||||
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { MemberPolicySettings } from "../MemberPolicySettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||
|
||||
describe("MemberPolicySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin allow tests, off by default", async () => {
|
||||
get.mockResolvedValue({ available: true, allowTests: false });
|
||||
set.mockResolvedValue({ available: true, allowTests: true });
|
||||
render(<MemberPolicySettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
|
||||
it("hides outside S3", async () => {
|
||||
get.mockResolvedValue({ available: false, allowTests: false });
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("asks nothing for a non-admin or in local mode", () => {
|
||||
perms.isAdmin = false;
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
perms.isAdmin = true;
|
||||
perms.local = true;
|
||||
render(<MemberPolicySettings />);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,9 @@
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.branchSearch": "Search branches…",
|
||||
"gitBar.branchMatches_one": "{{count}} matching branch",
|
||||
"gitBar.branchMatches_other": "{{count}} matching branches",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"send": "Send",
|
||||
"rename": "Click to rename {{name}}",
|
||||
"renameLabel": "Pet name",
|
||||
"renameHint": "Enter to save · empty = default name",
|
||||
@@ -53,13 +52,15 @@
|
||||
"attention": "Needs you",
|
||||
"attentionError": "errored",
|
||||
"attentionWaiting": "waiting on you",
|
||||
"attentionMore": "+{{count}} more in Sessions"
|
||||
"attentionMore": "+{{count}} more in Sessions",
|
||||
"label": "Pet {{name}}",
|
||||
"close": "Close {{name}}",
|
||||
"status": "Status — click to show the sessions that need you"
|
||||
},
|
||||
"bubbleDismiss": "Dismiss",
|
||||
"open": "Open {{name}} companion",
|
||||
"closeAvatar": "Close {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||
"startFailed": "Couldn't start that session: {{error}}",
|
||||
"bubbleOpen": "Open",
|
||||
"bubbleMore": "{{list}} +{{count}} more",
|
||||
"notify": {
|
||||
@@ -77,21 +78,13 @@
|
||||
"run_done_other": "{{count}} runs finished 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"good morning! ☀️",
|
||||
"morning! coffee first? ☕",
|
||||
"rise and shine 🌤️"
|
||||
],
|
||||
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||
"afternoon": [
|
||||
"good afternoon! 🐾",
|
||||
"hey, welcome back 😺",
|
||||
"afternoon! ready when you are ✨"
|
||||
],
|
||||
"evening": [
|
||||
"good evening! 🌆",
|
||||
"evening shift? I'm in 😼",
|
||||
"hey there, night owl-to-be 🌙"
|
||||
],
|
||||
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||
"night": [
|
||||
"burning the midnight oil? 🌙",
|
||||
"late one, huh? I'll keep watch 🦉",
|
||||
@@ -114,11 +107,7 @@
|
||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||
"hello": "hi there! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"anytime! 💜",
|
||||
"happy to help 😸",
|
||||
"you got it 🐾"
|
||||
],
|
||||
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||
"errorsNone": "no errors - all clean 🐾",
|
||||
@@ -175,93 +164,17 @@
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤"
|
||||
],
|
||||
"worried": [
|
||||
"that didn't look right 😟",
|
||||
"keeping an eye out 👀",
|
||||
"hmm, something's off 🫣"
|
||||
],
|
||||
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
"stuck": [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"great run! 😻",
|
||||
"love a tidy finish ✨",
|
||||
"purrfect 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"hmm, let me look… 🤔",
|
||||
"sniffing around… 🐾",
|
||||
"one sec, checking 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"on the prowl 👀",
|
||||
"watching your sessions 😼",
|
||||
"eyes peeled 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"wake me if something happens 😴",
|
||||
"curled up, all calm 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"all quiet 😺",
|
||||
"ready when you are 🐾",
|
||||
"just vibing ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
"close": "Close {{name}}",
|
||||
"new": "New chat",
|
||||
"status": "Status — click to show the sessions that need you",
|
||||
"endTitle": "End this chat?",
|
||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
||||
"endConfirm": "Stop and start new",
|
||||
"openWorkspace": "Open in workspace",
|
||||
"typing": "typing…",
|
||||
"placeholder": "Ask {{name}} anything…",
|
||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
||||
"starting": "Starting {{tool}}…",
|
||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
||||
},
|
||||
"people": {
|
||||
"rail": "Chats",
|
||||
"new": "New chat",
|
||||
"newHint": "Pick one person for a direct chat, or several for a group.",
|
||||
"search": "Search people",
|
||||
"none": "No one found",
|
||||
"groupName": "Group name (optional)",
|
||||
"createGroup": "Create group ({{n}} people)",
|
||||
"startDm": "Start chat",
|
||||
"close": "Close",
|
||||
"unreadIn": "{{name}}, {{n}} unread",
|
||||
"roomLabel": "Chat with {{name}}",
|
||||
"members": "{{n}} members",
|
||||
"settings": "Group settings",
|
||||
"back": "Back to the chat",
|
||||
"rename": "Rename",
|
||||
"creator": "· created the group",
|
||||
"remove": "Remove {{name}}",
|
||||
"add": "Add people",
|
||||
"addN": "Add {{n}}",
|
||||
"leave": "Leave group",
|
||||
"leaveTitle": "Leave this group?",
|
||||
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
|
||||
"older": "Load older messages",
|
||||
"empty": "No messages yet. Say hello to {{name}}.",
|
||||
"placeholder": "Message {{name}}",
|
||||
"attach": "Attach files",
|
||||
"unattach": "Remove {{name}}",
|
||||
"tooBig": "{{name}} is over 25 MB",
|
||||
"sysCreated": "{{who}} created the group",
|
||||
"sysRenamed": "{{who}} renamed the group",
|
||||
"sysLeft": "{{who}} left",
|
||||
"sysAdded": "{{who}} added {{names}}",
|
||||
"sysRemoved": "{{who}} removed {{names}}"
|
||||
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
@@ -1240,6 +1153,11 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"memberPolicy": {
|
||||
"title": "Tests on this server",
|
||||
"switch": "Allow members to run tests",
|
||||
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.branchSearch": "Tìm nhánh…",
|
||||
"gitBar.branchMatches_one": "{{count}} nhánh khớp",
|
||||
"gitBar.branchMatches_other": "{{count}} nhánh khớp",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"mute": "Tắt tiếng",
|
||||
"unmute": "Bật tiếng",
|
||||
"clearAlerts": "Xoá cảnh báo",
|
||||
"send": "Gửi",
|
||||
"rename": "Bấm để đổi tên {{name}}",
|
||||
"renameLabel": "Tên pet",
|
||||
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||
@@ -53,13 +52,15 @@
|
||||
"attention": "Cần bạn xem",
|
||||
"attentionError": "bị lỗi",
|
||||
"attentionWaiting": "đang chờ bạn",
|
||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
||||
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||
"label": "Pet {{name}}",
|
||||
"close": "Đóng {{name}}",
|
||||
"status": "Trạng thái — bấm để xem các phiên cần bạn"
|
||||
},
|
||||
"bubbleDismiss": "Đóng",
|
||||
"open": "Mở trợ lý {{name}}",
|
||||
"closeAvatar": "Đóng {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||
"startFailed": "Không khởi động được phiên: {{error}}",
|
||||
"bubbleOpen": "Mở",
|
||||
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||
"notify": {
|
||||
@@ -77,26 +78,10 @@
|
||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"chào buổi sáng! ☀️",
|
||||
"sáng rồi! cà phê chưa? ☕",
|
||||
"ngày mới tốt lành nha 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"chào buổi chiều! 🐾",
|
||||
"bạn quay lại rồi 😺",
|
||||
"chiều nay làm gì đây? ✨"
|
||||
],
|
||||
"evening": [
|
||||
"chào buổi tối! 🌆",
|
||||
"ca tối hả? có mình đây 😼",
|
||||
"tối rồi, cố lên nhé 🌙"
|
||||
],
|
||||
"night": [
|
||||
"khuya rồi mà vẫn làm à? 🌙",
|
||||
"để mình canh giúp cho 🦉",
|
||||
"nhớ nghỉ ngơi nha 💤"
|
||||
],
|
||||
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||
"summary_one": "{{count}} phiên đang chạy",
|
||||
"summary_other": "{{count}} phiên đang chạy",
|
||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||
@@ -114,11 +99,7 @@
|
||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||
"hello": "chào bạn! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"có gì đâu! 💜",
|
||||
"rất vui được giúp 😸",
|
||||
"chuyện nhỏ 🐾"
|
||||
],
|
||||
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||
@@ -175,93 +156,13 @@
|
||||
"không gọi được máy chủ 📡",
|
||||
"mất sóng - đi ngủ chút 💤"
|
||||
],
|
||||
"worried": [
|
||||
"có vẻ không ổn 😟",
|
||||
"mình đang để mắt đây 👀",
|
||||
"hừm, có gì đó lạ lạ 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"một phiên im lặng lâu rồi… 🤔",
|
||||
"có gì bị kẹt không? ⏳",
|
||||
"vẫn đang nhai… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"tuyệt vời! 😻",
|
||||
"kết thúc gọn ghê ✨",
|
||||
"hoàn hảo 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"để mình xem… 🤔",
|
||||
"đang đánh hơi… 🐾",
|
||||
"chờ chút, đang kiểm tra 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"đang tuần tra 👀",
|
||||
"đang canh các phiên của bạn 😼",
|
||||
"mắt mở to 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"có gì thì gọi mình dậy nhé 😴",
|
||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"yên ắng quá 😺",
|
||||
"sẵn sàng khi bạn cần 🐾",
|
||||
"chill thôi ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
"close": "Đóng {{name}}",
|
||||
"new": "Cuộc trò chuyện mới",
|
||||
"status": "Trạng thái — bấm để xem các phiên cần bạn",
|
||||
"endTitle": "Kết thúc chat này?",
|
||||
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
|
||||
"endConfirm": "Dừng và bắt đầu mới",
|
||||
"openWorkspace": "Mở trong workspace",
|
||||
"typing": "đang trả lời…",
|
||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
|
||||
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
|
||||
"starting": "Đang khởi động {{tool}}…",
|
||||
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
||||
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
||||
},
|
||||
"people": {
|
||||
"rail": "Trò chuyện",
|
||||
"new": "Chat mới",
|
||||
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
|
||||
"search": "Tìm người",
|
||||
"none": "Không tìm thấy ai",
|
||||
"groupName": "Tên nhóm (không bắt buộc)",
|
||||
"createGroup": "Tạo nhóm ({{n}} người)",
|
||||
"startDm": "Bắt đầu chat",
|
||||
"close": "Đóng",
|
||||
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
|
||||
"roomLabel": "Chat với {{name}}",
|
||||
"members": "{{n}} thành viên",
|
||||
"settings": "Cài đặt nhóm",
|
||||
"back": "Quay lại cuộc chat",
|
||||
"rename": "Đổi tên",
|
||||
"creator": "· người tạo nhóm",
|
||||
"remove": "Xoá {{name}} khỏi nhóm",
|
||||
"add": "Thêm người",
|
||||
"addN": "Thêm {{n}} người",
|
||||
"leave": "Rời nhóm",
|
||||
"leaveTitle": "Rời nhóm này?",
|
||||
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
|
||||
"older": "Xem tin cũ hơn",
|
||||
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
|
||||
"placeholder": "Nhắn cho {{name}}",
|
||||
"attach": "Đính kèm file",
|
||||
"unattach": "Bỏ {{name}}",
|
||||
"tooBig": "{{name}} lớn hơn 25 MB",
|
||||
"sysCreated": "{{who}} đã tạo nhóm",
|
||||
"sysRenamed": "{{who}} đã đổi tên nhóm",
|
||||
"sysLeft": "{{who}} đã rời nhóm",
|
||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}"
|
||||
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
@@ -1240,6 +1141,11 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"memberPolicy": {
|
||||
"title": "Chạy test trên server này",
|
||||
"switch": "Cho phép thành viên chạy test",
|
||||
"hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp."
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||
|
||||
+15
-81
@@ -547,38 +547,6 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
||||
|
||||
/** Someone to chat with (`GET /api/chat/people`). */
|
||||
export type ChatPerson = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarAt: string | null;
|
||||
};
|
||||
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
|
||||
export type ChatFile = { id: string; name: string; type: string; size: number };
|
||||
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
roomId: number;
|
||||
userId: number | null;
|
||||
kind: "text" | "system";
|
||||
body: string;
|
||||
createdAt: string;
|
||||
files: ChatFile[];
|
||||
};
|
||||
/** A direct chat or a group, as the caller sees it. */
|
||||
export type ChatRoom = {
|
||||
id: number;
|
||||
kind: "dm" | "group";
|
||||
name: string;
|
||||
createdBy: number | null;
|
||||
createdAt: string;
|
||||
lastMessageAt: string | null;
|
||||
members: number[];
|
||||
lastMessage: ChatMessage | null;
|
||||
lastReadId: number;
|
||||
unread: number;
|
||||
};
|
||||
/** One account in Settings → Users (`GET /api/users`). */
|
||||
export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
@@ -1599,55 +1567,6 @@ export const api = {
|
||||
request<HostHistory>(`/host/history?range=${range}`),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
/** `/api/chat` — chat between users in server mode (the pet's window). */
|
||||
chat: {
|
||||
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
|
||||
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
|
||||
/** The one direct chat with `userId`, made on first use. */
|
||||
openDm: (userId: number) =>
|
||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "dm", userId }),
|
||||
}),
|
||||
createGroup: (name: string, userIds: number[]) =>
|
||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "group", name, userIds }),
|
||||
}),
|
||||
/** The group's creator only. */
|
||||
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
|
||||
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
|
||||
messages: (id: number, before?: number) =>
|
||||
request<{ messages: ChatMessage[]; hasMore: boolean }>(
|
||||
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
|
||||
),
|
||||
/** Text and files (multipart when there are files). */
|
||||
post: (id: number, body: string, files: File[] = []) => {
|
||||
if (!files.length)
|
||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append("body", body);
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
read: (id: number, upTo: number) =>
|
||||
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upTo }),
|
||||
}),
|
||||
fileUrl: (roomId: number, fileId: string) =>
|
||||
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
|
||||
},
|
||||
users: {
|
||||
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
@@ -3863,6 +3782,14 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
|
||||
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
|
||||
/** PUT /api/ai-tools/member-policy - an admin's switch. */
|
||||
setMemberPolicy: (allowTests: boolean) =>
|
||||
request<MemberPolicyState>("/ai-tools/member-policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ allowTests }),
|
||||
}),
|
||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||
@@ -5355,6 +5282,13 @@ export interface ArtifactCandidate {
|
||||
}
|
||||
|
||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||
export interface MemberPolicyState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
/** May members' Claude run test runners and container commands? Off by default. */
|
||||
allowTests: boolean;
|
||||
}
|
||||
|
||||
export interface OmpSharingState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
|
||||
@@ -1917,13 +1917,10 @@ export interface WSMessage {
|
||||
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
||||
* remote_source.status → RemoteSourceStatusPayload;
|
||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
||||
* lane_update → { lane?: Lane; removed?: number };
|
||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
|
||||
* lane_update → { lane?: Lane; removed?: number }. */
|
||||
type:
|
||||
| "session_created"
|
||||
| "session_updated"
|
||||
| "chat_message"
|
||||
| "chat_room"
|
||||
| "agent_created"
|
||||
| "agent_updated"
|
||||
| "new_event"
|
||||
@@ -1970,9 +1967,7 @@ export interface WSMessage {
|
||||
| { lane?: Lane; removed?: number }
|
||||
| LaneHookOutputPayload
|
||||
| LaneRuntimePayload
|
||||
| LaneHookResultPayload
|
||||
/** chat_message / chat_room (lib/api.ts ChatMessage). */
|
||||
| { roomId: number; message?: unknown; to?: number[] };
|
||||
| LaneHookResultPayload;
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
|
||||
@@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
@@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
<OmpSharingSettings />
|
||||
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||
<MemberPolicySettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.65 (rebuild client to match)
|
||||
UI build v2.8.69 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+7
-23
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
@@ -197,9 +197,11 @@ POST /api/lanes/ensure
|
||||
```
|
||||
|
||||
`cwd` is optional — when missing or empty the server falls back to
|
||||
`os.homedir()` (the user's home directory). This is how the Sagi mascot
|
||||
adopts its dedicated workspace without the client needing to know an absolute
|
||||
path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||
`os.homedir()` (the user's home directory) — or, for a server-mode member
|
||||
with an own folder, `<their folder>/sagi`, made as them on first use (the
|
||||
host's home lies outside their folder and would be `403 EFOLDER`). This is
|
||||
how the Sagi mascot adopts its dedicated workspace without the client
|
||||
needing to know an absolute path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||
optional and, like `title`, applies **only when a workspace is actually created**
|
||||
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
|
||||
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
||||
@@ -3491,30 +3493,12 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) no longer chats (v2.8.68): a click opens its window — its name, a pat and the status of your sessions. It can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
|
||||
### Chat between users (server mode)
|
||||
|
||||
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
|
||||
|
||||
```
|
||||
GET /api/chat/people {people: [{id, username, displayName, avatarAt}]} — every enabled account but you
|
||||
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, unread
|
||||
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
|
||||
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
|
||||
POST /api/chat/rooms/:id/leave Leave a group; the last one out deletes it and its files
|
||||
GET /api/chat/rooms/:id/messages?before=&limit= {messages (oldest first), hasMore} — 50 by default, ≤ 200
|
||||
POST /api/chat/rooms/:id/messages JSON {body} or multipart `body` + ≤ 10 `files` of ≤ 25 MB; body ≤ 8000 chars
|
||||
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back
|
||||
GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment
|
||||
```
|
||||
|
||||
Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`) or `chat_room` (`{roomId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
|
||||
|
||||
### Session snapshots
|
||||
|
||||
```
|
||||
|
||||
+1
-1
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Branch** → **Switch branch** | the **Search branches…** box beside the list narrows it, local and remote, to names containing what you type (one match is picked at once, Enter picks the first, Esc clears — #88); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
|
||||
@@ -1156,6 +1156,52 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's
|
||||
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
||||
action: delete") and changes nothing.
|
||||
|
||||
The same re-run (v2.8.68) keeps members from freezing the box — on 11-02
|
||||
some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow
|
||||
disk, and only a hard reset brought it back. The box is for writing code;
|
||||
tests run on each member's own machine:
|
||||
|
||||
- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`,
|
||||
`OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server
|
||||
AND every member pane, so memory runs out inside the unit first and the
|
||||
kernel kills one process there by `oom_score_adj`: every command a member's
|
||||
Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`,
|
||||
which also tells Claude why its command died), a Terminal tab 800, an AI
|
||||
session 300, the dashboard and tmux -100. A test run dies; the session
|
||||
holding the conversation does not. Codex runs its commands without that
|
||||
wrapper, so its commands sit at 300 beside it.
|
||||
- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and
|
||||
best-effort IO priority 7 (the nice cannot be taken back without root), and
|
||||
defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2.
|
||||
- **No tests or containers from Claude.** A synchronous `PreToolUse` hook
|
||||
(`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts
|
||||
only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`,
|
||||
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||
at members' next Bash call, no restart.
|
||||
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
|
||||
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
|
||||
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
|
||||
`cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into
|
||||
a Terminal tab counts and the tab itself does not. A new pane past the
|
||||
limit answers `429 ESESSIONLIMIT`; a session started by hand past it has
|
||||
every prompt refused by the guard's `UserPromptSubmit` until another one
|
||||
ends. The host account (admins) is not limited.
|
||||
|
||||
The memory caps apply to the running unit at once (the re-run does
|
||||
`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the
|
||||
guard script in `/opt/agenthub/app` arrive with the next restart
|
||||
(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes
|
||||
survive, `KillMode=process`). Panes started before the re-run keep their old
|
||||
priority. Check with
|
||||
`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy`
|
||||
and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj;
|
||||
echo $CLAUDE_CODE_SHELL_PREFIX`.
|
||||
|
||||
### Deleting a user
|
||||
|
||||
Users page → Edit → **Delete user** (the admin's own password, then a red
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.69",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.69",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.69",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file member-guard.js
|
||||
* @description Server mode, one Linux user per account (S3): the members'
|
||||
* SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json
|
||||
* (install-system.sh), run AS the member from /opt/agenthub/app. The box is
|
||||
* shared and small, so it is for writing code only:
|
||||
*
|
||||
* - `PreToolUse` (matcher Bash): denies a test run or a container command
|
||||
* (`isHeavy`) — the member commits, pulls the branch on their own machine
|
||||
* and runs it there. Claude gets the reason and stops trying. An admin
|
||||
* can allow them again (Settings → AI tools, server/lib/member-policy.js).
|
||||
* - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions
|
||||
* at once (server/lib/ai-procs.js). The dashboard refuses a pane past the
|
||||
* limit itself (ESESSIONLIMIT); this catches a `claude` typed into a
|
||||
* Terminal tab, by refusing every prompt of a session that is not among
|
||||
* the account's oldest `limit` ones.
|
||||
*
|
||||
* Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like
|
||||
* every AgentHub hook: any error lets the call through. A person's own
|
||||
* terminal commands and Codex (whose hooks are opt-in) are not covered.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js"));
|
||||
const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js"));
|
||||
|
||||
/** Test runners and container tools, by the program a command segment runs. */
|
||||
const HEAVY_BINS = new Set([
|
||||
"vitest",
|
||||
"jest",
|
||||
"mocha",
|
||||
"ava",
|
||||
"karma",
|
||||
"playwright",
|
||||
"cypress",
|
||||
"phpunit",
|
||||
"pest",
|
||||
"paratest",
|
||||
"pytest",
|
||||
"py.test",
|
||||
"tox",
|
||||
"nox",
|
||||
"rspec",
|
||||
"ctest",
|
||||
"nextest",
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"podman",
|
||||
"podman-compose",
|
||||
"nerdctl",
|
||||
"buildah",
|
||||
"kind",
|
||||
"minikube",
|
||||
"k3d",
|
||||
]);
|
||||
/** Wrappers that run the command after them. */
|
||||
const PASS_THROUGH = new Set([
|
||||
"sudo",
|
||||
"env",
|
||||
"time",
|
||||
"nice",
|
||||
"ionice",
|
||||
"timeout",
|
||||
"npx",
|
||||
"bunx",
|
||||
"pnpx",
|
||||
"exec",
|
||||
"command",
|
||||
"xvfb-run",
|
||||
"stdbuf",
|
||||
]);
|
||||
const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/;
|
||||
|
||||
/** The words of one command segment, wrappers and env assignments dropped. */
|
||||
function words(segment) {
|
||||
const w = segment
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((x) => x.replace(/^['"]|['"]$/g, ""))
|
||||
.filter(Boolean);
|
||||
for (;;) {
|
||||
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
||||
if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break;
|
||||
w.shift();
|
||||
// a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`)
|
||||
while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift();
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Does this one segment run tests or containers? */
|
||||
function segmentIsHeavy(segment) {
|
||||
const w = words(segment);
|
||||
if (!w.length) return false;
|
||||
const bin = path.basename(w[0]);
|
||||
const [a, b] = [w[1] || "", w[2] || ""];
|
||||
if (HEAVY_BINS.has(bin)) return true;
|
||||
if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) {
|
||||
if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit
|
||||
if (["run", "run-script", "exec", "x", "dlx"].includes(a)) {
|
||||
return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) {
|
||||
return ["test", "verify", "integrationTest"].includes(a);
|
||||
}
|
||||
if (bin === "cargo") return a === "test" || a === "nextest";
|
||||
if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check");
|
||||
if (bin === "php" || bin === "artisan") {
|
||||
const rest = bin === "php" ? w.slice(1) : w;
|
||||
const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x));
|
||||
if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true;
|
||||
return rest.some((x) => HEAVY_BINS.has(path.basename(x)));
|
||||
}
|
||||
if (/^python[0-9.]*$/.test(bin)) {
|
||||
const i = w.indexOf("-m");
|
||||
return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Does a Bash command run tests or containers anywhere in it? */
|
||||
function isHeavy(command) {
|
||||
if (typeof command !== "string") return false;
|
||||
// Inner `bash -c "..."` / `sh -c '...'` bodies are checked too.
|
||||
const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map(
|
||||
(m) => m[2]
|
||||
);
|
||||
return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy));
|
||||
}
|
||||
|
||||
const DENY_REASON =
|
||||
"AgentHub: this shared server is for writing code only — tests and containers " +
|
||||
"(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " +
|
||||
"a few of them at once freeze it for the whole team. Do not try another way to " +
|
||||
"run them. Finish the change, tell the user which tests to run, and let them " +
|
||||
"commit, pull the branch on their own machine and test there.";
|
||||
|
||||
/** The admin's switch (Settings → AI tools), beside this script in the app root. */
|
||||
const testsAllowed = () =>
|
||||
policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true;
|
||||
|
||||
function guardTool(input, allowed = testsAllowed()) {
|
||||
if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: DENY_REASON,
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
function guardPrompt() {
|
||||
const limit = procs.sessionLimit();
|
||||
if (!limit) return;
|
||||
const mine = procs.sessionOf(process.pid);
|
||||
if (mine == null) return;
|
||||
const all = procs.sessionsOf(process.getuid());
|
||||
const at = all.findIndex((s) => s.pid === mine);
|
||||
if (at < limit) return; // among the oldest `limit` (or not found: let it be)
|
||||
process.stderr.write(
|
||||
`AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` +
|
||||
"server, and this one started past the limit. Close another session (or this one) first.\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const event = process.argv[2];
|
||||
let raw = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => (raw += c));
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
if (!os.userInfo().username.startsWith("ah-")) return;
|
||||
if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}"));
|
||||
else if (event === "UserPromptSubmit") guardPrompt();
|
||||
} catch {
|
||||
/* fail-open */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { isHeavy, segmentIsHeavy, words, guardTool };
|
||||
@@ -16,11 +16,13 @@
|
||||
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
||||
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
||||
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
||||
# /opt/agenthub/libexec/tool-run wraps each command a member's Claude runs (OOM: it dies first)
|
||||
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
||||
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
||||
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
||||
# /etc/systemd/system/agenthub.service reads the host-owned env file
|
||||
# /etc/systemd/system/agenthub.service reads the host-owned env file; caps
|
||||
# the cgroup (server + every pane) at 85% of RAM, 1G swap
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
@@ -106,6 +108,7 @@ chmod 0644 /etc/agenthub/server.conf
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/tool-run.sh" /opt/agenthub/libexec/tool-run
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
||||
|
||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||
@@ -163,7 +166,14 @@ else
|
||||
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
||||
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
|
||||
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
|
||||
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
|
||||
const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)]));
|
||||
// The guard is synchronous (it decides): no test runs or containers from
|
||||
// Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js).
|
||||
const guard = (e) => ({ type: "command", timeout: 5,
|
||||
command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` });
|
||||
hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] });
|
||||
hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] });
|
||||
process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n");
|
||||
' >"$MANAGED"
|
||||
chmod 0644 "$MANAGED"
|
||||
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
||||
@@ -203,6 +213,14 @@ ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=process
|
||||
# Every member pane lives in this cgroup. Capped below RAM with little swap
|
||||
# (swap on a slow disk froze the whole box on 11-02), a runaway test run is
|
||||
# OOM-killed inside the unit — pane-launch makes panes the first pick — and
|
||||
# "continue" keeps the dashboard up when that happens.
|
||||
MemoryMax=85%
|
||||
MemorySwapMax=1G
|
||||
OOMPolicy=continue
|
||||
OOMScoreAdjust=-100
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# the environment and execs the command; no shell ever parses a value.
|
||||
#
|
||||
# The file is NUL-separated: the number of environment entries, the
|
||||
# NAME=value entries, then the argv (none = a login shell).
|
||||
# NAME=value entries, then the argv (none = a login shell). Every pane starts
|
||||
# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick.
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
f=${1:?usage: pane-launch <launch file>}
|
||||
@@ -22,6 +23,27 @@ for kv in "${parts[@]:1:n}"; do
|
||||
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
||||
export "$kv"
|
||||
done
|
||||
# A member's pane shares the 4-core box with the dashboard and every other
|
||||
# member, so it runs at a lower CPU/IO priority (a member cannot take the
|
||||
# nice back: that needs root). Memory (11-02: 36 sessions plus test runs
|
||||
# filled RAM and swap, and the box froze until a hard reset): the unit caps
|
||||
# its cgroup below RAM (install-system.sh), and inside it the kernel kills
|
||||
# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session
|
||||
# at 300, a Terminal tab at 800, and every command Claude runs at 1000
|
||||
# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and
|
||||
# the session that holds the conversation is told why; a member who lowers
|
||||
# their own value can go no lower than -100.
|
||||
renice -n 10 -p $$ >/dev/null 2>&1 || true
|
||||
ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true
|
||||
TOOL_RUN=/opt/agenthub/libexec/tool-run
|
||||
[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}"
|
||||
# Vitest ≤ 3 starts one worker per core by default and these override the
|
||||
# config; a member may set their own in the pane.
|
||||
export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}"
|
||||
argv=("${parts[@]:n+1}")
|
||||
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
|
||||
if [ ${#argv[@]} -eq 0 ]; then
|
||||
echo 800 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||
exec -l "${SHELL:-/bin/bash}"
|
||||
fi
|
||||
echo 300 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||
exec "${argv[@]}"
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# @file tool-run.sh
|
||||
# @description Server mode, one Linux user per account (S3): installed as
|
||||
# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in
|
||||
# every member pane (pane-launch). Claude Code runs each Bash command (and
|
||||
# hook) as `tool-run '<command string>'`. The command runs at the kernel's
|
||||
# highest OOM preference, so when memory runs out it is the command — a test
|
||||
# run, a build — that is killed, never the Claude session above it that holds
|
||||
# the conversation (pane-launch puts that one at 300). When the cgroup's OOM
|
||||
# count moved while the command ran, Claude is told why it died.
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
[ $# -gt 0 ] || exec /bin/bash
|
||||
echo 1000 >/proc/self/oom_score_adj 2>/dev/null
|
||||
cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null)
|
||||
events=/sys/fs/cgroup${cg}/memory.events
|
||||
kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; }
|
||||
before=$(kills)
|
||||
/bin/bash -c "$1"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then
|
||||
printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* @file chat.test.js
|
||||
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
|
||||
* a direct chat is one per pair, a group's creator alone changes it, only a
|
||||
* room's members read it — an admin included —, unread counts and read marks,
|
||||
* files served only to members under a sandbox CSP, local mode refused, and
|
||||
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
const prevMode = process.env.AGENTHUB_MODE;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
|
||||
const { db } = require("../db");
|
||||
const scope = require("../lib/scope");
|
||||
|
||||
const USERS = {
|
||||
1: { id: 1, username: "boss", role: "admin" },
|
||||
2: { id: 2, username: "an", role: "developer" },
|
||||
3: { id: 3, username: "binh", role: "developer" },
|
||||
4: { id: 4, username: "chi", role: "analyst" },
|
||||
};
|
||||
let srv;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role);
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.user = USERS[req.headers["x-user"]];
|
||||
next();
|
||||
});
|
||||
app.use("/api/chat", require("../routes/chat"));
|
||||
await new Promise((r) => (srv = app.listen(0, r)));
|
||||
base = `http://127.0.0.1:${srv.address().port}/api/chat`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
srv?.close();
|
||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = prevMode;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function call(user, method, p, body) {
|
||||
const init = { method, headers: { "x-user": String(user) } };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
init.headers["content-type"] = "application/json";
|
||||
}
|
||||
const r = await fetch(base + p, init);
|
||||
const type = r.headers.get("content-type") || "";
|
||||
return {
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: type.includes("json") ? await r.json() : await r.text(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("chat", () => {
|
||||
let dm;
|
||||
let group;
|
||||
|
||||
it("lists the other enabled users to chat with", async () => {
|
||||
const r = await call(2, "GET", "/people");
|
||||
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
|
||||
});
|
||||
|
||||
it("opens one direct chat per pair, whoever starts it", async () => {
|
||||
const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 });
|
||||
assert.equal(a.status, 201);
|
||||
const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 });
|
||||
assert.equal(b.status, 200);
|
||||
assert.equal(b.body.room.id, a.body.room.id);
|
||||
dm = a.body.room.id;
|
||||
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400);
|
||||
});
|
||||
|
||||
it("keeps a chat to its members — an admin gets 404 like a missing room", async () => {
|
||||
const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" });
|
||||
assert.equal(m.status, 201);
|
||||
assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404);
|
||||
assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404);
|
||||
assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []);
|
||||
const seen = await call(3, "GET", `/rooms/${dm}/messages`);
|
||||
assert.deepEqual(
|
||||
seen.body.messages.map((x) => x.body),
|
||||
["chào Bình"]
|
||||
);
|
||||
});
|
||||
|
||||
it("counts unread messages until they are marked read", async () => {
|
||||
let rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
||||
assert.equal(rooms[0].unread, 1);
|
||||
assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own
|
||||
await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id });
|
||||
rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
||||
assert.equal(rooms[0].unread, 0);
|
||||
});
|
||||
|
||||
it("lets a group's creator alone change it, and any member leave", async () => {
|
||||
const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] });
|
||||
assert.equal(g.status, 201);
|
||||
group = g.body.room.id;
|
||||
assert.deepEqual(g.body.room.members, [2, 3]);
|
||||
assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403);
|
||||
const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" });
|
||||
assert.deepEqual(up.body.room.members, [2, 3, 4]);
|
||||
assert.equal(up.body.room.name, "Team A");
|
||||
assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200);
|
||||
assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404);
|
||||
assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400);
|
||||
});
|
||||
|
||||
it("stores files and serves them only to members, sandboxed", async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("body", "ảnh nè");
|
||||
fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png");
|
||||
fd.append("files", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
|
||||
const m = await call(3, "POST", `/rooms/${group}/messages`, fd);
|
||||
assert.equal(m.status, 201);
|
||||
const [img, page] = m.body.message.files;
|
||||
assert.equal(img.name, "màn hình.png");
|
||||
const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`);
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.body, "PNGDATA");
|
||||
assert.match(got.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.match(got.headers.get("content-disposition"), /^inline/);
|
||||
const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`);
|
||||
assert.match(html.headers.get("content-disposition"), /^attachment/);
|
||||
assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404);
|
||||
assert.equal(
|
||||
(await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status,
|
||||
404
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an empty message", async () => {
|
||||
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
|
||||
});
|
||||
|
||||
it("is refused in local mode", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE");
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
|
||||
it("sends a chat frame to exactly its `to`, an admin not included", () => {
|
||||
const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] });
|
||||
assert.deepEqual([...only], [2, 3]);
|
||||
assert.deepEqual([...scope.exactAudience("chat_room", {})], []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file Tests for the S3 members' synchronous hook (scripts/member-guard.js)
|
||||
* and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js):
|
||||
* which Bash commands count as a test run or a container command, and which
|
||||
* processes count as one top-level AI session, read from a fake /proc.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { isHeavy, guardTool } = require("../../scripts/member-guard.js");
|
||||
const procs = require("../lib/ai-procs.js");
|
||||
|
||||
describe("member-guard: a test run or a container command", () => {
|
||||
const heavy = [
|
||||
"npx vitest run",
|
||||
"vitest",
|
||||
"npm test",
|
||||
"npm t",
|
||||
"npm run test:unit",
|
||||
"pnpm test",
|
||||
"yarn test --watch=false",
|
||||
"bun test",
|
||||
"cd client && NODE_ENV=test npx vitest related src/a.ts --run",
|
||||
"timeout 300 npx jest",
|
||||
"./vendor/bin/phpunit --filter Foo",
|
||||
"php artisan test",
|
||||
"vendor/bin/pest",
|
||||
"python -m pytest -q",
|
||||
"pytest tests/",
|
||||
"go test ./...",
|
||||
"cargo test",
|
||||
"docker compose up -d",
|
||||
"docker-compose up",
|
||||
"podman run alpine",
|
||||
"node_modules/.bin/playwright test",
|
||||
'bash -c "npm run e2e"',
|
||||
"make test",
|
||||
"npm run build && npm test 2>&1 | tail -20",
|
||||
];
|
||||
const fine = [
|
||||
"npm run build",
|
||||
"npm install -D vitest",
|
||||
"git commit -m 'add vitest tests'",
|
||||
"rg -n docker src/",
|
||||
"cat tests/a.test.js",
|
||||
"ls node_modules/.bin/vitest",
|
||||
"echo run npm test later",
|
||||
"npx tsc -b",
|
||||
"npm run lint",
|
||||
"php artisan migrate:status",
|
||||
"go build ./...",
|
||||
"git log --grep test",
|
||||
"composer install",
|
||||
];
|
||||
for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true));
|
||||
for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false));
|
||||
|
||||
it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => {
|
||||
const out = [];
|
||||
const write = process.stdout.write;
|
||||
process.stdout.write = (s) => out.push(String(s));
|
||||
try {
|
||||
guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } });
|
||||
guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } });
|
||||
guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } });
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
assert.equal(out.length, 1);
|
||||
const o = JSON.parse(out[0]).hookSpecificOutput;
|
||||
assert.equal(o.hookEventName, "PreToolUse");
|
||||
assert.equal(o.permissionDecision, "deny");
|
||||
assert.match(o.permissionDecisionReason, /own machine/);
|
||||
});
|
||||
|
||||
it("lets everything through once an admin allows tests", () => {
|
||||
const out = [];
|
||||
const write = process.stdout.write;
|
||||
process.stdout.write = (s) => out.push(String(s));
|
||||
try {
|
||||
guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true);
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */
|
||||
function fakeProc(list) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-"));
|
||||
for (const p of list) {
|
||||
const d = path.join(root, String(p.pid));
|
||||
fs.mkdirSync(d);
|
||||
// pid (comm) state ppid ... field 22 = starttime (index 19 after the ')')
|
||||
const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)];
|
||||
fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`);
|
||||
fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("member-policy: the admin's switch, read by the guard", () => {
|
||||
it("blocks tests without a file, and writes a world-readable one", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-"));
|
||||
const prev = process.env.AGENTHUB_APP_ROOT;
|
||||
process.env.AGENTHUB_APP_ROOT = dir;
|
||||
try {
|
||||
const policy = require("../lib/member-policy.js");
|
||||
assert.equal(policy.read().allowTests, false);
|
||||
fs.writeFileSync(path.join(dir, policy.FILE), "not json");
|
||||
assert.equal(policy.read().allowTests, false);
|
||||
assert.equal(policy.setAllowTests(true).allowTests, true);
|
||||
const f = path.join(dir, policy.FILE);
|
||||
assert.equal(fs.statSync(f).mode & 0o777, 0o644);
|
||||
assert.equal(policy.read(f).allowTests, true);
|
||||
assert.equal(policy.setAllowTests(false).allowTests, false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT;
|
||||
else process.env.AGENTHUB_APP_ROOT = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-procs: a member's top-level AI sessions", () => {
|
||||
const uid = process.getuid();
|
||||
const root = fakeProc([
|
||||
{ pid: 10, ppid: 1, start: 1, argv: ["tmux"] },
|
||||
{ pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] },
|
||||
{ pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] },
|
||||
{ pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted
|
||||
{ pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab
|
||||
{ pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] },
|
||||
{ pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child
|
||||
{ pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server
|
||||
{ pid: 31, ppid: 10, start: 8, argv: ["vitest"] },
|
||||
]);
|
||||
|
||||
it("counts each session once, oldest first, and no tool server", () => {
|
||||
assert.deepEqual(
|
||||
procs.sessionsOf(uid, root).map((s) => s.pid),
|
||||
[21, 11]
|
||||
);
|
||||
});
|
||||
it("finds the session a hook process runs under", () => {
|
||||
assert.equal(procs.sessionOf(13, root), 11);
|
||||
assert.equal(procs.sessionOf(31, root), null);
|
||||
});
|
||||
it("reads the limit from server.conf, 4 without it, 0 = off", () => {
|
||||
const conf = path.join(root, "server.conf");
|
||||
assert.equal(procs.sessionLimit(conf), 4);
|
||||
fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n");
|
||||
assert.equal(procs.sessionLimit(conf), 0);
|
||||
});
|
||||
});
|
||||
@@ -192,3 +192,31 @@ describe("a member's new project folder", () => {
|
||||
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pet chat's workspace for a member", () => {
|
||||
it("is <their folder>/sagi, made as them — never the host's home (EFOLDER)", async () => {
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
|
||||
});
|
||||
const cm = login.headers.get("set-cookie").split(";")[0];
|
||||
const ensure = () =>
|
||||
fetch(`${BASE}/api/lanes/ensure`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie: cm },
|
||||
body: JSON.stringify({ title: "Sagi" }),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
const first = await ensure();
|
||||
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||
const want = fs.realpathSync(path.join(USERS, "mia", "sagi"));
|
||||
assert.equal(first.body.lane.cwd, want);
|
||||
const mk = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||
assert.ok(mk && mk.includes("ah-mia") && mk.includes("mkdir"));
|
||||
const again = await ensure();
|
||||
assert.equal(again.body.lane.id, first.body.lane.id, "the same workspace next time");
|
||||
});
|
||||
});
|
||||
|
||||
+16
-41
@@ -1854,6 +1854,22 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
|
||||
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
|
||||
try {
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS chat_files;
|
||||
DROP TABLE IF EXISTS chat_messages;
|
||||
DROP TABLE IF EXISTS chat_members;
|
||||
DROP TABLE IF EXISTS chat_rooms;
|
||||
`);
|
||||
if (!process.env.DASHBOARD_DB_PATH) {
|
||||
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
/* best effort: a leftover is unused */
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
@@ -1912,47 +1928,6 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
// Chat between users (server mode, server/lib/chat.js): a direct chat (`dm`,
|
||||
// one per pair — `dm_key` is "<low id>:<high id>") or a group. Only a room's
|
||||
// members read it, an admin included. Files live under <data dir>/chat/.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('dm', 'group')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dm_key TEXT UNIQUE,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_message_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_members (
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_read_id INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_members_user ON chat_members(user_id);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'system')),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, id);
|
||||
CREATE TABLE IF NOT EXISTS chat_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
|
||||
`);
|
||||
|
||||
// An account's own agent instructions, put before every workspace's in each
|
||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||
try {
|
||||
|
||||
@@ -237,7 +237,6 @@ function createApp() {
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
app.use("/api/chat", require("./routes/chat"));
|
||||
app.use("/api/snapshots", snapshotsRouter);
|
||||
app.use("/api/artifacts", require("./routes/artifacts"));
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @file ai-procs.js
|
||||
* @description Server mode, one Linux user per account (S3): counts the AI
|
||||
* Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever
|
||||
* started them (a dashboard pane, or `claude` typed into a terminal tab).
|
||||
* A session is a process whose program is one of the catalog tools' binaries
|
||||
* (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process
|
||||
* among its ancestors, so a nested `claude -p`, Codex's native child behind
|
||||
* its npm launcher, or a subagent never counts twice. Used by `spawnRun`
|
||||
* (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js`
|
||||
* (refuses the prompt of a session over the limit started by hand), which
|
||||
* runs AS the member and sees only that member's processes (hidepid) — that
|
||||
* is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
/** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */
|
||||
const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]);
|
||||
/** Interpreters whose first argument names the tool (an npm launcher). */
|
||||
const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]);
|
||||
/** A tool's server, not a session (the host's omp share). */
|
||||
const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]);
|
||||
|
||||
const DEFAULT_LIMIT = 4;
|
||||
const CONF = "/etc/agenthub/server.conf";
|
||||
|
||||
const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, "");
|
||||
|
||||
/** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */
|
||||
function readProc(pid, procRoot = "/proc") {
|
||||
try {
|
||||
const dir = path.join(procRoot, String(pid));
|
||||
const stat = fs.readFileSync(path.join(dir, "stat"), "utf8");
|
||||
// comm may hold spaces and parentheses: the fields start after the LAST ')'.
|
||||
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
||||
const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean);
|
||||
const uid = fs.statSync(dir).uid;
|
||||
let name = base(argv[0]);
|
||||
let rest = argv.slice(1);
|
||||
if (INTERPRETERS.has(name)) {
|
||||
const i = rest.findIndex((a) => !a.startsWith("-"));
|
||||
name = i === -1 ? "" : base(rest[i]);
|
||||
rest = i === -1 ? [] : rest.slice(i + 1);
|
||||
}
|
||||
const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a));
|
||||
return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai };
|
||||
} catch {
|
||||
return null; // exited while we looked, or not ours to read
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's top-level AI sessions, oldest first.
|
||||
* @param {number} uid
|
||||
* @returns {{pid:number, start:number}[]}
|
||||
*/
|
||||
function sessionsOf(uid, procRoot = "/proc") {
|
||||
const procs = new Map();
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(procRoot);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const n of names) {
|
||||
if (!/^\d+$/.test(n)) continue;
|
||||
const p = readProc(n, procRoot);
|
||||
if (p) procs.set(p.pid, p);
|
||||
}
|
||||
const out = [];
|
||||
for (const p of procs.values()) {
|
||||
if (!p.ai || p.uid !== uid) continue;
|
||||
let nested = false;
|
||||
const seen = new Set();
|
||||
for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) {
|
||||
seen.add(a.pid);
|
||||
if (a.ai) {
|
||||
nested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nested) out.push({ pid: p.pid, start: p.start });
|
||||
}
|
||||
return out.sort((a, b) => a.start - b.start || a.pid - b.pid);
|
||||
}
|
||||
|
||||
/** The pid of the top-level AI session `pid` runs under (itself included), or null. */
|
||||
function sessionOf(pid, procRoot = "/proc") {
|
||||
let top = null;
|
||||
const seen = new Set();
|
||||
for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) {
|
||||
seen.add(p.pid);
|
||||
if (p.ai) top = p.pid;
|
||||
if (p.ppid <= 1) break;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in
|
||||
* server.conf (0 = no limit), else 4.
|
||||
*/
|
||||
function sessionLimit(confFile = CONF) {
|
||||
try {
|
||||
const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m);
|
||||
if (m) return Number(m[1]);
|
||||
} catch {
|
||||
/* no server.conf: the default */
|
||||
}
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit };
|
||||
@@ -1,378 +0,0 @@
|
||||
/**
|
||||
* @file chat.js
|
||||
* @description Chat between the users of a server-mode install, shown in the
|
||||
* pet's window beside the chat with the pet: a direct chat (`dm`, at most one
|
||||
* per pair of users) or a group anyone may start and name. Only a room's
|
||||
* MEMBERS read or write it — an admin or analyst included, and there is no
|
||||
* route that lists another user's rooms. A group's creator adds, removes and
|
||||
* renames; any member may leave. A message is text (≤ 8000 chars) and up to
|
||||
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under
|
||||
* `<data dir>/chat/<room id>/<random id>` — a name the server makes, never one
|
||||
* from the request — and served back only to the room's members (the route
|
||||
* adds a `sandbox` CSP and `nosniff`; only raster images are shown inline).
|
||||
* Every change is told to exactly the users concerned
|
||||
* (`chat_message` / `chat_room` frames, `to` = their ids, see
|
||||
* `scope.exactAudience`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const db = () => require("../db").db;
|
||||
const sql = (q) => db().prepare(q);
|
||||
|
||||
const MAX_BODY = 8000;
|
||||
const MAX_NAME = 80;
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_GROUP = 200;
|
||||
/** Raster types shown inline; anything else is a download. */
|
||||
const INLINE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
function err(code, message, status = 400) {
|
||||
return Object.assign(new Error(message), { code, status });
|
||||
}
|
||||
|
||||
const chatDir = () => path.join(getDataDir(), "chat");
|
||||
const fileDir = (roomId) => path.join(chatDir(), String(Number(roomId)));
|
||||
|
||||
/** A user who may be chatted with: exists and is not disabled. */
|
||||
function activeUser(id) {
|
||||
const n = Number(id);
|
||||
if (!Number.isSafeInteger(n) || n <= 0) return null;
|
||||
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
|
||||
}
|
||||
|
||||
/** Every enabled account but the caller — who a chat can be started with. */
|
||||
function people(me) {
|
||||
return sql(
|
||||
`SELECT id, username, display_name, avatar_at FROM users
|
||||
WHERE COALESCE(disabled, 0) = 0 AND id != ? ORDER BY COALESCE(display_name, username)`
|
||||
)
|
||||
.all(me)
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.display_name ?? null,
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function memberIds(roomId) {
|
||||
return sql("SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY user_id")
|
||||
.all(roomId)
|
||||
.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/** The room, when `me` is one of its members; else ENOTFOUND (404) — never "forbidden". */
|
||||
function roomFor(me, roomId) {
|
||||
const id = Number(roomId);
|
||||
const row =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? sql(
|
||||
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
|
||||
WHERE r.id = ? AND m.user_id = ?`
|
||||
).get(id, me)
|
||||
: null;
|
||||
if (!row) throw err("ENOTFOUND", "no such chat", 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
function filesOf(messageIds) {
|
||||
if (!messageIds.length) return new Map();
|
||||
const rows = sql(
|
||||
`SELECT id, message_id, name, type, size FROM chat_files
|
||||
WHERE message_id IN (${messageIds.map(() => "?").join(",")}) ORDER BY rowid`
|
||||
).all(...messageIds);
|
||||
const out = new Map();
|
||||
for (const f of rows) {
|
||||
if (!out.has(f.message_id)) out.set(f.message_id, []);
|
||||
out.get(f.message_id).push({ id: f.id, name: f.name, type: f.type, size: f.size });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shapeMessages(rows) {
|
||||
const files = filesOf(rows.map((r) => r.id));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
roomId: r.room_id,
|
||||
userId: r.user_id,
|
||||
kind: r.kind,
|
||||
body: r.body,
|
||||
createdAt: r.created_at,
|
||||
files: files.get(r.id) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** One room as `me` sees it: members, newest message, unread count. */
|
||||
function shapeRoom(me, r) {
|
||||
const mine = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
|
||||
r.id,
|
||||
me
|
||||
);
|
||||
const last = sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1").get(
|
||||
r.id
|
||||
);
|
||||
const unread = sql(
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?"
|
||||
).get(r.id, mine ? mine.last_read_id : 0, me).n;
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
createdBy: r.created_by,
|
||||
createdAt: r.created_at,
|
||||
lastMessageAt: r.last_message_at,
|
||||
members: memberIds(r.id),
|
||||
lastMessage: last ? shapeMessages([last])[0] : null,
|
||||
lastReadId: mine ? mine.last_read_id : 0,
|
||||
unread,
|
||||
};
|
||||
}
|
||||
|
||||
/** `me`'s rooms, the most recently active first. */
|
||||
function listRooms(me) {
|
||||
return sql(
|
||||
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
|
||||
WHERE m.user_id = ? ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
|
||||
)
|
||||
.all(me)
|
||||
.map((r) => shapeRoom(me, r));
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
if (name === undefined || name === null) return "";
|
||||
if (typeof name !== "string") throw err("EBADFIELD", "name: text");
|
||||
return name.trim().slice(0, MAX_NAME);
|
||||
}
|
||||
|
||||
function userList(value, field) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > MAX_GROUP)
|
||||
throw err("EBADFIELD", `${field}: a list of user ids`);
|
||||
const ids = [...new Set(value.map(Number))];
|
||||
for (const id of ids) if (!activeUser(id)) throw err("ENOUSER", `no such user: ${id}`);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** The direct chat between `me` and `other`, made on first use. `created` says which. */
|
||||
function openDm(me, other) {
|
||||
const them = activeUser(other);
|
||||
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
|
||||
const key = [me, them.id].sort((a, b) => a - b).join(":");
|
||||
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
|
||||
if (found) return { room: shapeRoom(me, found), created: false };
|
||||
const id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
|
||||
).run(key, me);
|
||||
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
add.run(lastInsertRowid, me);
|
||||
add.run(lastInsertRowid, them.id);
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
return {
|
||||
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id)),
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
function systemLine(roomId, me, body) {
|
||||
sql("INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)").run(
|
||||
roomId,
|
||||
me,
|
||||
body
|
||||
);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(roomId);
|
||||
}
|
||||
|
||||
/** A new group of `me` plus `userIds`. */
|
||||
function createGroup(me, { name, userIds }) {
|
||||
const title = cleanName(name);
|
||||
const ids = userList(userIds, "userIds").filter((id) => id !== me);
|
||||
if (!ids.length) throw err("EBADFIELD", "a group needs at least one other member");
|
||||
const id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_rooms (kind, name, created_by) VALUES ('group', ?, ?)"
|
||||
).run(title, me);
|
||||
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
for (const uid of [me, ...ids]) add.run(lastInsertRowid, uid);
|
||||
systemLine(lastInsertRowid, me, "created");
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
return shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's creator renames it, adds or removes members. Answers the room and
|
||||
* `notify` = every user to tell (removed ones included, so they drop it).
|
||||
*/
|
||||
function updateGroup(me, roomId, { name, add, remove }) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat has no members to change");
|
||||
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator changes it", 403);
|
||||
const before = memberIds(room.id);
|
||||
const adds = userList(add, "add").filter((id) => !before.includes(id));
|
||||
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
|
||||
db().transaction(() => {
|
||||
if (name !== undefined) {
|
||||
const title = cleanName(name);
|
||||
if (title !== room.name) {
|
||||
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
|
||||
systemLine(room.id, me, "renamed");
|
||||
}
|
||||
}
|
||||
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
for (const uid of adds) ins.run(room.id, uid);
|
||||
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
|
||||
for (const uid of drops) del.run(room.id, uid);
|
||||
if (adds.length) systemLine(room.id, me, `added:${adds.join(",")}`);
|
||||
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`);
|
||||
})();
|
||||
return {
|
||||
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
|
||||
notify: [...new Set([...before, ...adds])],
|
||||
};
|
||||
}
|
||||
|
||||
/** `me` leaves a group; the last one out deletes it and its files. Answers who to tell. */
|
||||
function leaveGroup(me, roomId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
|
||||
const before = memberIds(room.id);
|
||||
db().transaction(() => {
|
||||
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
|
||||
if (before.length > 1) systemLine(room.id, me, "left");
|
||||
else sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
})();
|
||||
if (before.length <= 1) fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return { notify: before };
|
||||
}
|
||||
|
||||
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
|
||||
function messages(me, roomId, { before, limit } = {}) {
|
||||
const room = roomFor(me, roomId);
|
||||
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
||||
const b = parseInt(before, 10);
|
||||
const rows = Number.isSafeInteger(b)
|
||||
? sql("SELECT * FROM chat_messages WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?").all(
|
||||
room.id,
|
||||
b,
|
||||
n
|
||||
)
|
||||
: sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?").all(room.id, n);
|
||||
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
|
||||
}
|
||||
|
||||
/** A file's media type, kept only when it is a plain `type/subtype`. */
|
||||
function cleanType(t) {
|
||||
return typeof t === "string" && /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(t)
|
||||
? t.toLowerCase()
|
||||
: "application/octet-stream";
|
||||
}
|
||||
|
||||
function cleanFileName(n) {
|
||||
const base = path.basename(String(n || "file")).replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
return base.slice(0, 200) || "file";
|
||||
}
|
||||
|
||||
/**
|
||||
* `me` posts to a room: `body` text and `files` ([{name, type, buffer}]).
|
||||
* Answers the message and who to tell (every member).
|
||||
*/
|
||||
function post(me, roomId, { body, files = [] }) {
|
||||
const room = roomFor(me, roomId);
|
||||
const text = typeof body === "string" ? body.replace(/\s+$/, "") : "";
|
||||
if (text.length > MAX_BODY)
|
||||
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
|
||||
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
|
||||
for (const f of files)
|
||||
if (f.buffer.length > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
|
||||
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
|
||||
const dir = fileDir(room.id);
|
||||
const stored = files.map((f) => ({
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
name: cleanFileName(f.name),
|
||||
type: cleanType(f.type),
|
||||
buffer: f.buffer,
|
||||
}));
|
||||
if (stored.length) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
for (const f of stored) fs.writeFileSync(path.join(dir, f.id), f.buffer, { mode: 0o600 });
|
||||
}
|
||||
let id;
|
||||
try {
|
||||
id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_messages (room_id, user_id, body) VALUES (?, ?, ?)"
|
||||
).run(room.id, me, text);
|
||||
const ins = sql(
|
||||
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const f of stored)
|
||||
ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.buffer.length);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
|
||||
).run(lastInsertRowid, room.id);
|
||||
// Your own message is read.
|
||||
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
|
||||
lastInsertRowid,
|
||||
room.id,
|
||||
me
|
||||
);
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
} catch (e) {
|
||||
for (const f of stored) fs.rmSync(path.join(dir, f.id), { force: true });
|
||||
throw e;
|
||||
}
|
||||
const [message] = shapeMessages([sql("SELECT * FROM chat_messages WHERE id = ?").get(id)]);
|
||||
return { message, notify: memberIds(room.id) };
|
||||
}
|
||||
|
||||
/** `me` has read the room up to message `upTo` (never moves back). */
|
||||
function markRead(me, roomId, upTo) {
|
||||
const room = roomFor(me, roomId);
|
||||
const n = Number(upTo);
|
||||
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
|
||||
sql(
|
||||
"UPDATE chat_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?"
|
||||
).run(n, room.id, me);
|
||||
return { lastReadId: Math.max(n, 0) };
|
||||
}
|
||||
|
||||
/** A file of a room `me` is in: `{path, name, type, inline}`. */
|
||||
function file(me, roomId, fileId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
|
||||
throw err("ENOTFOUND", "no such file", 404);
|
||||
const f = sql("SELECT * FROM chat_files WHERE id = ? AND room_id = ?").get(fileId, room.id);
|
||||
const p = f && path.join(fileDir(room.id), f.id);
|
||||
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
|
||||
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_FILES,
|
||||
MAX_FILE_BYTES,
|
||||
people,
|
||||
listRooms,
|
||||
openDm,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
leaveGroup,
|
||||
messages,
|
||||
post,
|
||||
markRead,
|
||||
file,
|
||||
roomFor,
|
||||
shapeRoom,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file member-policy.js
|
||||
* @description Server mode, one Linux user per account (S3): the admin's
|
||||
* switch for what members' Claude may run on this box — today whether test
|
||||
* runners and container commands are allowed (`allowTests`, off by default).
|
||||
* Kept in `<app root>/member-policy.json` (/opt/agenthub/app, host-owned,
|
||||
* world-readable), because `scripts/member-guard.js` runs AS the member and
|
||||
* reads it from there; a missing or unreadable file means the default, so
|
||||
* tests stay blocked unless an admin turned them on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const FILE = "member-policy.json";
|
||||
const DEFAULTS = Object.freeze({ allowTests: false });
|
||||
|
||||
const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app";
|
||||
const file = () => path.join(appRoot(), FILE);
|
||||
|
||||
/** The policy in force (defaults for anything missing or unreadable). */
|
||||
function read(p = file()) {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
return { allowTests: j?.allowTests === true };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
/** Does it apply here? Only S3 runs members' panes as their own accounts. */
|
||||
function available() {
|
||||
try {
|
||||
return require("./linux-accounts").s3Enabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function state() {
|
||||
return { available: available(), ...read() };
|
||||
}
|
||||
|
||||
/** Writes the switch (atomically, 0644: members' hooks read it). */
|
||||
function setAllowTests(on) {
|
||||
const next = { ...read(), allowTests: on === true };
|
||||
fs.mkdirSync(appRoot(), { recursive: true });
|
||||
const tmp = `${file()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o644 });
|
||||
fs.chmodSync(tmp, 0o644);
|
||||
fs.renameSync(tmp, file());
|
||||
return state();
|
||||
}
|
||||
|
||||
module.exports = { FILE, DEFAULTS, read, available, state, setAllowTests };
|
||||
@@ -126,6 +126,30 @@ function laneIdFromRunId(id) {
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: a member runs at most `MAX_AI_SESSIONS` AI sessions at once (default 4,
|
||||
* server.conf), counted from the processes their account runs right now —
|
||||
* a `claude` typed into a Terminal tab counts, the tab itself does not
|
||||
* (ai-procs.js; scripts/member-guard.js refuses the prompts of one started
|
||||
* past the limit by hand). The host account is an admin's and not limited.
|
||||
* @throws ESESSIONLIMIT
|
||||
*/
|
||||
function assertSessionRoom(laneId) {
|
||||
const account = require("./run-as").accountForLane(laneId);
|
||||
if (!account) return;
|
||||
const procs = require("./ai-procs");
|
||||
const limit = procs.sessionLimit();
|
||||
if (!limit) return;
|
||||
const running = procs.sessionsOf(account.uid).length;
|
||||
if (running >= limit) {
|
||||
throw makeErr(
|
||||
"ESESSIONLIMIT",
|
||||
`AgentHub: you are running ${running} AI sessions — at most ${limit} at once on this server. ` +
|
||||
"Close one (end its pane) and start again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makeErr(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
@@ -475,6 +499,7 @@ function spawnRun(args) {
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||
assertSessionRoom(laneId); // ESESSIONLIMIT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
|
||||
+1
-7
@@ -340,16 +340,10 @@ function broadcastAudience(type, data) {
|
||||
* the role-based rules above. Pure (no DB): asked for every client. A change
|
||||
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
|
||||
* routes/cc-config.js) names their files: it reaches that member and nobody
|
||||
* else, an admin or analyst included. A chat frame (`chat_message`,
|
||||
* `chat_room`) reaches exactly the users in its `to`.
|
||||
* else, an admin or analyst included.
|
||||
*/
|
||||
function exactAudience(type, data) {
|
||||
const d = data && typeof data === "object" ? data : {};
|
||||
// Chat (server/lib/chat.js): only the room's members, whatever their role —
|
||||
// an admin reads no one else's chat. No list = nobody.
|
||||
if (type === "chat_message" || type === "chat_room") {
|
||||
return new Set(Array.isArray(d.to) ? d.to : []);
|
||||
}
|
||||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,7 @@ module.exports = combine(
|
||||
overrideCore,
|
||||
overridePricingAlerts,
|
||||
overrideOps,
|
||||
gitRefs,
|
||||
require("./openapi-extra/chat")
|
||||
gitRefs
|
||||
);
|
||||
// The branch & tag writes join the git action entry in place.
|
||||
gitRefs.extend(module.exports);
|
||||
|
||||
@@ -173,6 +173,43 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/member-policy": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "What members' Claude may run on this server",
|
||||
description:
|
||||
"Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).",
|
||||
operationId: "aiToolsMemberPolicy",
|
||||
responses: {
|
||||
200: { description: "`{available, allowTests}`." },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Allow or block members' test runs",
|
||||
description:
|
||||
"An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.",
|
||||
operationId: "aiToolsSetMemberPolicy",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["allowTests"],
|
||||
properties: { allowTests: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The new state, as GET." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "Not an admin" },
|
||||
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}": {
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between
|
||||
* the users of a server-mode install — direct chats and groups, text and
|
||||
* files. Every route acts on the caller's own rooms only (404 otherwise, an
|
||||
* admin included); local mode answers `400 ELOCALMODE`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
||||
const json = (schema) => ({ content: { "application/json": { schema } } });
|
||||
const fail = (codes) =>
|
||||
Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }]));
|
||||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Chat",
|
||||
description:
|
||||
"Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.",
|
||||
},
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
ChatFile: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" },
|
||||
name: { type: "string", example: "screenshot.png" },
|
||||
type: { type: "string", example: "image/png" },
|
||||
size: { type: "integer", example: 20480 },
|
||||
},
|
||||
},
|
||||
ChatMessage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer", example: 42 },
|
||||
roomId: { type: "integer", example: 3 },
|
||||
userId: { type: "integer", nullable: true, example: 2 },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["text", "system"],
|
||||
description:
|
||||
"`system`: a group change — body `created`, `renamed`, `left`, `added:<ids>` or `removed:<ids>`.",
|
||||
},
|
||||
body: { type: "string", example: "Chào cả nhóm" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } },
|
||||
},
|
||||
},
|
||||
ChatRoom: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer", example: 3 },
|
||||
kind: { type: "string", enum: ["dm", "group"] },
|
||||
name: { type: "string", description: "A group's name; empty for a direct chat." },
|
||||
createdBy: { type: "integer", nullable: true },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
lastMessageAt: { type: "string", format: "date-time", nullable: true },
|
||||
members: { type: "array", items: { type: "integer" } },
|
||||
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
|
||||
lastReadId: { type: "integer" },
|
||||
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const roomResp = json({
|
||||
type: "object",
|
||||
properties: { room: { $ref: "#/components/schemas/ChatRoom" } },
|
||||
});
|
||||
|
||||
const paths = {
|
||||
"/api/chat/people": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "Users the caller can chat with",
|
||||
description: "Every enabled account but the caller: id, username, display name, avatar time.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "People",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
people: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
username: { type: "string" },
|
||||
displayName: { type: "string", nullable: true },
|
||||
avatarAt: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "The caller's rooms",
|
||||
description:
|
||||
"Most recently active first, each with its members, newest message and unread count.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Rooms",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Open a direct chat or create a group",
|
||||
description:
|
||||
"`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["kind"],
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["dm", "group"] },
|
||||
userId: { type: "integer" },
|
||||
name: { type: "string", maxLength: 80 },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The existing direct chat", ...roomResp },
|
||||
201: { description: "Created", ...roomResp },
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}": {
|
||||
patch: {
|
||||
tags: ["Chat"],
|
||||
summary: "Rename a group, add or remove members",
|
||||
description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
add: { type: "array", items: { type: "integer" } },
|
||||
remove: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/leave": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Leave a group",
|
||||
description: "The last member out deletes the group and its files.",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Left",
|
||||
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/messages": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "A page of a room's messages",
|
||||
description:
|
||||
"Oldest first: the newest `limit` (default 50, max 200), or those before `before`.",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "before", in: "query", schema: { type: "integer" } },
|
||||
{ name: "limit", in: "query", schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Messages",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
|
||||
hasMore: { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Post a message",
|
||||
description:
|
||||
"JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } },
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Posted",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: { message: { $ref: "#/components/schemas/ChatMessage" } },
|
||||
}),
|
||||
},
|
||||
...fail([400, 404, 413]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/read": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Mark a room read up to a message",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Read mark",
|
||||
...json({ type: "object", properties: { lastReadId: { type: "integer" } } }),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/files/{fileId}": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "One file of a room",
|
||||
description:
|
||||
"Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "fileId", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file's bytes", content: { "application/octet-stream": {} } },
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
@@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
// What members' Claude may run on this box (server/lib/member-policy.js,
|
||||
// read by scripts/member-guard.js): S3 only, an admin's switch.
|
||||
router.get("/member-policy", (_req, res) => {
|
||||
res.json(require("../lib/member-policy").state());
|
||||
});
|
||||
|
||||
router.put("/member-policy", sameOriginGuard, (req, res) => {
|
||||
const on = req.body?.allowTests;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "allowTests: boolean" } });
|
||||
}
|
||||
const policy = require("../lib/member-policy");
|
||||
if (!policy.available()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOS3", message: "this needs server mode with per-user accounts" },
|
||||
});
|
||||
}
|
||||
const state = policy.setAllowTests(on);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`members' test runs ${on ? "allowed" : "blocked"}`
|
||||
);
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||
// Every field checked before anything is written: a bad one saves nothing.
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* @file chat.js (routes)
|
||||
* @description `/api/chat` — chat between users in server mode (the pet's
|
||||
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
|
||||
* the caller's rooms, a direct chat or a group, messages (text + files, a
|
||||
* multipart `POST`), read marks and a room's files. Everything acts on the
|
||||
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
|
||||
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
|
||||
* Each change is broadcast as `chat_message` / `chat_room` to exactly the
|
||||
* users concerned (`to`, `scope.exactAudience`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const chat = require("../lib/chat");
|
||||
const { isServerMode } = require("../lib/auth");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function send(res, e) {
|
||||
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
|
||||
if (status === 500) console.error("[chat]", e);
|
||||
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
|
||||
const handle = (fn) => (req, res) => {
|
||||
if (!isServerMode() || !req.user) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
|
||||
}
|
||||
try {
|
||||
fn(req.user.id, req, res);
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
function tell(type, data, to) {
|
||||
try {
|
||||
require("../websocket").broadcast(type, { ...data, to });
|
||||
} catch {
|
||||
// no socket server (tests): nothing to tell
|
||||
}
|
||||
}
|
||||
|
||||
/** Multipart `files` + `body`, in memory, within the chat's limits. */
|
||||
function upload(req, res, next) {
|
||||
if (!req.is("multipart/form-data")) return next();
|
||||
const multer = require("multer");
|
||||
multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
|
||||
}).array("files")(req, res, (e) => {
|
||||
if (!e) return next();
|
||||
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
|
||||
return res.status(big ? 413 : 400).json({
|
||||
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/people",
|
||||
handle((me, _req, res) => res.json({ people: chat.people(me) }))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms",
|
||||
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const b = req.body || {};
|
||||
if (b.kind === "dm") {
|
||||
const { room, created } = chat.openDm(me, b.userId);
|
||||
if (created) tell("chat_room", { roomId: room.id }, room.members);
|
||||
return res.status(created ? 201 : 200).json({ room });
|
||||
}
|
||||
if (b.kind === "group") {
|
||||
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
|
||||
tell("chat_room", { roomId: room.id }, room.members);
|
||||
return res.status(201).json({ room });
|
||||
}
|
||||
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/rooms/:id",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const b = req.body || {};
|
||||
const { room, notify } = chat.updateGroup(me, req.params.id, {
|
||||
name: b.name,
|
||||
add: b.add,
|
||||
remove: b.remove,
|
||||
});
|
||||
tell("chat_room", { roomId: room.id }, notify);
|
||||
res.json({ room });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms/:id/leave",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const { notify } = chat.leaveGroup(me, req.params.id);
|
||||
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms/:id/messages",
|
||||
handle((me, req, res) =>
|
||||
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
|
||||
)
|
||||
);
|
||||
|
||||
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
|
||||
handle((me) => {
|
||||
const files = (req.files || []).map((f) => ({
|
||||
// multer reads names as latin1; browsers send UTF-8.
|
||||
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
|
||||
type: f.mimetype,
|
||||
buffer: f.buffer,
|
||||
}));
|
||||
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
|
||||
tell("chat_message", { roomId: message.roomId, message }, notify);
|
||||
res.status(201).json({ message });
|
||||
})(req, res)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms/:id/read",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => res.json(chat.markRead(me, req.params.id, req.body?.upTo)))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms/:id/files/:fileId",
|
||||
handle((me, req, res) => {
|
||||
const f = chat.file(me, req.params.id, req.params.fileId);
|
||||
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
||||
res.set({
|
||||
"Content-Type": f.type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
|
||||
});
|
||||
require("../lib/send-file").sendFile(res, f.path);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
+36
-4
@@ -436,6 +436,23 @@ function refuseFolder(res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's folder for a workspace no caller picked a directory for (the
|
||||
* pet chat): `<own folder>/sagi`, made AS the member under S3 (`mkdirAs`,
|
||||
* never a host write in their home). The caller still runs it through
|
||||
* `folderAllowed`, so a link planted there cannot lead outside.
|
||||
*/
|
||||
function ownDefaultFolder(own) {
|
||||
const target = path.join(own, "sagi");
|
||||
if (!fs.existsSync(target)) {
|
||||
const runAs = require("../lib/run-as");
|
||||
const acct = runAs.accountForPath(target);
|
||||
if (acct) runAs.mkdirAs(acct, target);
|
||||
else fs.mkdirSync(target, { recursive: true });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
|
||||
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
|
||||
@@ -447,10 +464,24 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
||||
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
||||
// through unchanged.
|
||||
// A member's default is NOT the host's home (outside their folder: EFOLDER,
|
||||
// the pet chat could never start) nor their own home (no workspace under
|
||||
// S3), but `<their folder>/sagi`, made as them on first use.
|
||||
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
||||
const cwd = lanesLib.canonicalCwd(
|
||||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
||||
);
|
||||
let cwd;
|
||||
if (typeof body.cwd === "string" && body.cwd.trim()) {
|
||||
cwd = lanesLib.canonicalCwd(body.cwd.trim());
|
||||
} else if (scope.ownFolder(req)) {
|
||||
try {
|
||||
cwd = lanesLib.canonicalCwd(ownDefaultFolder(scope.ownFolder(req)));
|
||||
} catch (err) {
|
||||
return res.status(err.code === "EMEMBERGONE" ? 409 : 500).json({
|
||||
error: { code: err.code || "EMKDIR", message: err.message },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cwd = lanesLib.canonicalCwd(os.homedir());
|
||||
}
|
||||
|
||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
@@ -3533,11 +3564,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
|
||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||
? 400
|
||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||
? 409
|
||||
: err.code === "EQUOTA"
|
||||
: err.code === "EQUOTA" || err.code === "ESESSIONLIMIT"
|
||||
? 429
|
||||
: 500;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
|
||||
@@ -673,7 +673,7 @@ router.post("/", (req, res) => {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
||||
if (err.code === "EQUOTA") {
|
||||
if (err.code === "EQUOTA" || err.code === "ESESSIONLIMIT") {
|
||||
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||
|
||||
Reference in New Issue
Block a user