feat(chat): direct chats and groups between users in the pet's window
Server mode: a rail beside the pet's chat lists the pet, then the user's direct chats (one per pair) and groups with unread counts, and + for a new chat. A room shows its messages (group senders named), loads older pages, and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images inline). A group's creator renames it and adds or removes people; anyone may leave. A blue dot on the pet counts unread messages. Only a room's members read or write it, an admin included (roomFor → 404). Files are stored under server-made names in <data dir>/chat/<room>/ and served to members only under a sandbox CSP + nosniff. chat_message / chat_room frames reach exactly the ids in `to` (scope.exactAudience). New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
This commit is contained in:
@@ -547,6 +547,38 @@ 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;
|
||||
@@ -1567,6 +1599,55 @@ 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) =>
|
||||
|
||||
@@ -1917,10 +1917,13 @@ 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 }. */
|
||||
* lane_update → { lane?: Lane; removed?: number };
|
||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
|
||||
type:
|
||||
| "session_created"
|
||||
| "session_updated"
|
||||
| "chat_message"
|
||||
| "chat_room"
|
||||
| "agent_created"
|
||||
| "agent_updated"
|
||||
| "new_event"
|
||||
@@ -1967,7 +1970,9 @@ export interface WSMessage {
|
||||
| { lane?: Lane; removed?: number }
|
||||
| LaneHookOutputPayload
|
||||
| LaneRuntimePayload
|
||||
| LaneHookResultPayload;
|
||||
| LaneHookResultPayload
|
||||
/** chat_message / chat_room (lib/api.ts ChatMessage). */
|
||||
| { roomId: number; message?: unknown; to?: number[] };
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
|
||||
Reference in New Issue
Block a user