| 1 | import type { ChatSidebar, Principal, User, WorkspaceAgent } from "@g1t/contracts"; |
| 2 | |
| 3 | import type { Mentionable } from "./chat"; |
| 4 | import { chat, identity, workspaceAgents } from "./services.server"; |
| 5 | |
| 6 | /** |
| 7 | * Who can be talked to in a workspace: its people and its agents, for |
| 8 | * mentions and for starting a direct message. Each list is empty, not an |
| 9 | * error, when its service does not answer: chat and agents may arrive a |
| 10 | * little after the site does. |
| 11 | */ |
| 12 | export async function workspacePeople(slug: string, viewer: User): Promise<{ people: Mentionable[]; agents: WorkspaceAgent[] }> { |
| 13 | const [members, agents] = await Promise.all([ |
| 14 | identity.listMembers(slug, viewer).catch(() => null), |
| 15 | workspaceAgents.list(slug, viewer).catch(() => null), |
| 16 | ]); |
| 17 | const people: Mentionable[] = members?.ok |
| 18 | ? members.value.map((member) => ({ |
| 19 | kind: "user" as const, |
| 20 | name: member.username, |
| 21 | display_name: member.name?.trim() || member.username, |
| 22 | avatar: member.avatar ?? null, |
| 23 | })) |
| 24 | : []; |
| 25 | return { people, agents: agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : [] }; |
| 26 | } |
| 27 | |
| 28 | /** An agent as a mention: by its handle, with its role beside it. */ |
| 29 | export function agentMentionable(agent: WorkspaceAgent): Mentionable { |
| 30 | return { kind: "agent", name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role }; |
| 31 | } |
| 32 | |
| 33 | /** The sidebar, or null when chat does not answer. */ |
| 34 | export async function sidebarOrNull(slug: string, viewer: User): Promise<ChatSidebar | null> { |
| 35 | try { |
| 36 | const result = await chat.sidebar(slug, viewer); |
| 37 | return result.ok ? result.value : null; |
| 38 | } catch { |
| 39 | return null; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Members as a form sends them, `user:<username>` or `agent:<id>`, as the |
| 45 | * chat service names them: a person by their account id. Anyone who is not |
| 46 | * a member of the workspace is left out. |
| 47 | */ |
| 48 | export async function principalsFrom(slug: string, viewer: User, fields: string[]): Promise<Principal[]> { |
| 49 | const usernames = fields.filter((f) => f.startsWith("user:")).map((f) => f.slice(5).toLowerCase()); |
| 50 | const agentIds = fields.filter((f) => f.startsWith("agent:")).map((f) => f.slice(6)).filter(Boolean); |
| 51 | const members = usernames.length > 0 ? await identity.listMembers(slug, viewer).catch(() => null) : null; |
| 52 | const known = new Set(members?.ok ? members.value.map((m) => m.username.toLowerCase()) : []); |
| 53 | const people = await Promise.all( |
| 54 | usernames |
| 55 | .filter((name) => known.has(name) && name !== viewer.username.toLowerCase()) |
| 56 | .map((name) => identity.userByUsername(name).catch(() => null)), |
| 57 | ); |
| 58 | return [ |
| 59 | ...people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id })), |
| 60 | ...agentIds.map((id) => ({ kind: "agent" as const, id })), |
| 61 | ]; |
| 62 | } |
| 63 | |
| 64 | /** The cookie that remembers the last conversation open, so Chat opens there again. */ |
| 65 | export const LAST_CHAT_COOKIE = "g1t_chat"; |
| 66 | |
| 67 | export function rememberChat(path: string, secure: boolean): string { |
| 68 | return `${LAST_CHAT_COOKIE}=${encodeURIComponent(path)}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`; |
| 69 | } |