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:
@@ -65,6 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
|||||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
|
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
|
||||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
||||||
|
- **A chat between users is its members' alone.** `server/lib/chat.js` (`/api/chat`, server mode only): every read and write resolves the room through `roomFor(caller, id)` — not a member is `404`, an admin included, and there is no route listing another user's rooms; `chat_message`/`chat_room` frames go only to the ids in `to` (`exactAudience`). Files are stored under a server-made name in `<data dir>/chat/<room>/` and served only to members under a `sandbox` CSP + `nosniff` (raster images inline, anything else an attachment). Do not add an admin view of chats.
|
||||||
- **A web page is previewed by URL in the session panel's Preview tab, never on the dashboard's origin, and every link follows ONE rule.** `client/src/components/lanes/UrlPreview.tsx` shows an `<iframe src>` whose sandbox has no `allow-top-navigation*`; `normalizePreviewUrl` (`client/src/lib/openInPreview.ts`) takes http(s) only, no userinfo, and refuses the dashboard's own origin (with `allow-same-origin` that frame would BE the dashboard). A chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens there only while a session panel answers the event and the dashboard itself is on loopback; anything else — and any Ctrl/Cmd click — opens a browser tab. Do not add a second preview surface or a per-place button, and do not proxy a page through the dashboard's origin.
|
- **A web page is previewed by URL in the session panel's Preview tab, never on the dashboard's origin, and every link follows ONE rule.** `client/src/components/lanes/UrlPreview.tsx` shows an `<iframe src>` whose sandbox has no `allow-top-navigation*`; `normalizePreviewUrl` (`client/src/lib/openInPreview.ts`) takes http(s) only, no userinfo, and refuses the dashboard's own origin (with `allow-same-origin` that frame would BE the dashboard). A chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens there only while a session panel answers the event and the dashboard itself is on loopback; anything else — and any Ctrl/Cmd click — opens a browser tab. Do not add a second preview surface or a per-place button, and do not proxy a page through the dashboard's origin.
|
||||||
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
|
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
|
||||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||||
|
|||||||
@@ -0,0 +1,846 @@
|
|||||||
|
/**
|
||||||
|
* @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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
* shares the single WebSocket.
|
* shares the single WebSocket.
|
||||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
* 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
|
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||||
* itself is answered there, anything else goes to the AI.
|
* 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.
|
||||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
@@ -100,6 +103,8 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
|
|||||||
import { workspaceHref } from "../SessionRowMenu";
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
import { Heart } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||||
|
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||||
|
import { useChat } from "./useChat";
|
||||||
|
|
||||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
// 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.
|
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||||
@@ -238,6 +243,14 @@ function SagiBody() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const href = useSettingsHref();
|
const href = useSettingsHref();
|
||||||
const brain = useSagiBrain();
|
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);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||||
@@ -416,6 +429,20 @@ function SagiBody() {
|
|||||||
re-attaches the same session. */}
|
re-attaches the same session. */}
|
||||||
{open && (
|
{open && (
|
||||||
<SagiFlyout anchor={anchor} beside>
|
<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
|
<PetChat
|
||||||
name={name}
|
name={name}
|
||||||
title={
|
title={
|
||||||
@@ -457,6 +484,8 @@ function SagiBody() {
|
|||||||
}}
|
}}
|
||||||
onBusy={onBusy}
|
onBusy={onBusy}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -550,6 +579,11 @@ function SagiBody() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{people.unread > 0 && (
|
||||||
|
<span className="sagi-chat-dot" aria-hidden>
|
||||||
|
{people.unread > 9 ? "9+" : people.unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{brain.status.errorCount > 0 ? (
|
{brain.status.errorCount > 0 ? (
|
||||||
<span className="sagi-error-dot" aria-hidden>
|
<span className="sagi-error-dot" aria-hidden>
|
||||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
* 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),
|
* 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
|
* which made the whole desktop stutter. All motion is disabled under
|
||||||
* [data-reduced="1"] and prefers-reduced-motion.
|
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
|
||||||
|
* chat messages from other users (server mode).
|
||||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -406,6 +407,25 @@
|
|||||||
outline: 2px solid #818cf8;
|
outline: 2px solid #818cf8;
|
||||||
outline-offset: 3px;
|
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 {
|
.sagi-error-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* @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 };
|
||||||
|
}
|
||||||
@@ -77,13 +77,21 @@
|
|||||||
"run_done_other": "{{count}} runs finished 🐾"
|
"run_done_other": "{{count}} runs finished 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"greet": {
|
||||||
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
"morning": [
|
||||||
|
"good morning! ☀️",
|
||||||
|
"morning! coffee first? ☕",
|
||||||
|
"rise and shine 🌤️"
|
||||||
|
],
|
||||||
"afternoon": [
|
"afternoon": [
|
||||||
"good afternoon! 🐾",
|
"good afternoon! 🐾",
|
||||||
"hey, welcome back 😺",
|
"hey, welcome back 😺",
|
||||||
"afternoon! ready when you are ✨"
|
"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": [
|
"night": [
|
||||||
"burning the midnight oil? 🌙",
|
"burning the midnight oil? 🌙",
|
||||||
"late one, huh? I'll keep watch 🦉",
|
"late one, huh? I'll keep watch 🦉",
|
||||||
@@ -106,7 +114,11 @@
|
|||||||
"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.",
|
"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.",
|
"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}}.",
|
"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_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.",
|
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||||
"errorsNone": "no errors - all clean 🐾",
|
"errorsNone": "no errors - all clean 🐾",
|
||||||
@@ -163,17 +175,41 @@
|
|||||||
"can't reach the server 📡",
|
"can't reach the server 📡",
|
||||||
"no signal - taking a nap 💤"
|
"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": [
|
"stuck": [
|
||||||
"a session's been quiet a while… 🤔",
|
"a session's been quiet a while… 🤔",
|
||||||
"is something stuck? ⏳",
|
"is something stuck? ⏳",
|
||||||
"still chewing on it… 😾"
|
"still chewing on it… 😾"
|
||||||
],
|
],
|
||||||
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
"happy": [
|
||||||
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
"great run! 😻",
|
||||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
"love a tidy finish ✨",
|
||||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
"purrfect 💜"
|
||||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
],
|
||||||
|
"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": {
|
"chat": {
|
||||||
"label": "Chat with {{name}}",
|
"label": "Chat with {{name}}",
|
||||||
@@ -191,6 +227,41 @@
|
|||||||
"starting": "Starting {{tool}}…",
|
"starting": "Starting {{tool}}…",
|
||||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
"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."
|
"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}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -77,10 +77,26 @@
|
|||||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"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 🌤️"],
|
"morning": [
|
||||||
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
"chào buổi sáng! ☀️",
|
||||||
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
"sáng rồi! cà phê chưa? ☕",
|
||||||
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
"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_one": "{{count}} phiên đang chạy",
|
||||||
"summary_other": "{{count}} phiên đang chạy",
|
"summary_other": "{{count}} phiên đang chạy",
|
||||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||||
@@ -98,7 +114,11 @@
|
|||||||
"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.",
|
"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.",
|
"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}}.",
|
"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_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é.",
|
"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ẽ 🐾",
|
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||||
@@ -155,13 +175,41 @@
|
|||||||
"không gọi được máy chủ 📡",
|
"không gọi được máy chủ 📡",
|
||||||
"mất sóng - đi ngủ chút 💤"
|
"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ạ 🫣"],
|
"worried": [
|
||||||
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
"có vẻ không ổn 😟",
|
||||||
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
"mình đang để mắt đây 👀",
|
||||||
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
"hừm, có gì đó lạ lạ 🫣"
|
||||||
"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 🐈"],
|
"stuck": [
|
||||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
"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": {
|
"chat": {
|
||||||
"label": "Trò chuyện với {{name}}",
|
"label": "Trò chuyện với {{name}}",
|
||||||
@@ -179,6 +227,41 @@
|
|||||||
"starting": "Đang khởi động {{tool}}…",
|
"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.",
|
"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."
|
"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}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -547,6 +547,38 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
|||||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
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`). */
|
/** One account in Settings → Users (`GET /api/users`). */
|
||||||
export type ServerUserUsage = AuthUser & {
|
export type ServerUserUsage = AuthUser & {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -1567,6 +1599,55 @@ export const api = {
|
|||||||
request<HostHistory>(`/host/history?range=${range}`),
|
request<HostHistory>(`/host/history?range=${range}`),
|
||||||
},
|
},
|
||||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
// ───────────────────────── 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: {
|
users: {
|
||||||
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
|
|||||||
@@ -1917,10 +1917,13 @@ export interface WSMessage {
|
|||||||
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
||||||
* remote_source.status → RemoteSourceStatusPayload;
|
* remote_source.status → RemoteSourceStatusPayload;
|
||||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
* 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:
|
type:
|
||||||
| "session_created"
|
| "session_created"
|
||||||
| "session_updated"
|
| "session_updated"
|
||||||
|
| "chat_message"
|
||||||
|
| "chat_room"
|
||||||
| "agent_created"
|
| "agent_created"
|
||||||
| "agent_updated"
|
| "agent_updated"
|
||||||
| "new_event"
|
| "new_event"
|
||||||
@@ -1967,7 +1970,9 @@ export interface WSMessage {
|
|||||||
| { lane?: Lane; removed?: number }
|
| { lane?: Lane; removed?: number }
|
||||||
| LaneHookOutputPayload
|
| LaneHookOutputPayload
|
||||||
| LaneRuntimePayload
|
| 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
|
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||||
* same instant the underlying event occurred). */
|
* same instant the underlying event occurred). */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
+18
@@ -3497,6 +3497,24 @@ A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles s
|
|||||||
|
|
||||||
**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).
|
**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
|
### Session snapshots
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* @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", {})], []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1912,6 +1912,47 @@ db.exec(`
|
|||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
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
|
// An account's own agent instructions, put before every workspace's in each
|
||||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ function createApp() {
|
|||||||
app.use("/api/projects", projectsRouter);
|
app.use("/api/projects", projectsRouter);
|
||||||
app.use("/api/locks", locksRouter);
|
app.use("/api/locks", locksRouter);
|
||||||
app.use("/api/pets", petsRouter);
|
app.use("/api/pets", petsRouter);
|
||||||
|
app.use("/api/chat", require("./routes/chat"));
|
||||||
app.use("/api/snapshots", snapshotsRouter);
|
app.use("/api/snapshots", snapshotsRouter);
|
||||||
app.use("/api/artifacts", require("./routes/artifacts"));
|
app.use("/api/artifacts", require("./routes/artifacts"));
|
||||||
app.use("/api/alerts", alertsRouter);
|
app.use("/api/alerts", alertsRouter);
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
+7
-1
@@ -340,10 +340,16 @@ function broadcastAudience(type, data) {
|
|||||||
* the role-based rules above. Pure (no DB): asked for every client. A change
|
* 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`,
|
* 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
|
* routes/cc-config.js) names their files: it reaches that member and nobody
|
||||||
* else, an admin or analyst included.
|
* else, an admin or analyst included. A chat frame (`chat_message`,
|
||||||
|
* `chat_room`) reaches exactly the users in its `to`.
|
||||||
*/
|
*/
|
||||||
function exactAudience(type, data) {
|
function exactAudience(type, data) {
|
||||||
const d = data && typeof data === "object" ? 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]);
|
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ module.exports = combine(
|
|||||||
overrideCore,
|
overrideCore,
|
||||||
overridePricingAlerts,
|
overridePricingAlerts,
|
||||||
overrideOps,
|
overrideOps,
|
||||||
gitRefs
|
gitRefs,
|
||||||
|
require("./openapi-extra/chat")
|
||||||
);
|
);
|
||||||
// The branch & tag writes join the git action entry in place.
|
// The branch & tag writes join the git action entry in place.
|
||||||
gitRefs.extend(module.exports);
|
gitRefs.extend(module.exports);
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @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 };
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
Reference in New Issue
Block a user