| 1 | /** |
| 2 | * @g1t's side of chat, as the site shows it: the direct message every |
| 3 | * member has with g1t, which the top bar's Ask (components/ask-composer) |
| 4 | * and Chat both show. Pure, so it is tested on its own (g1t-chat.test.ts). |
| 5 | */ |
| 6 | import type { AskPage, ChatAnswer, ChatLiveEvent, ChatMessage } from "@g1t/contracts"; |
| 7 | |
| 8 | /** Whether a message is from @g1t itself. */ |
| 9 | export function fromG1t(message: Pick<ChatMessage, "author">): boolean { |
| 10 | return message.author.kind === "system" && message.author.id === "g1t"; |
| 11 | } |
| 12 | |
| 13 | /** Whether an answer is still being written. */ |
| 14 | export function answering(answer: ChatAnswer | null | undefined): boolean { |
| 15 | return answer?.state === "thinking" || answer?.state === "streaming"; |
| 16 | } |
| 17 | |
| 18 | /** Whether any answer in the conversation is still being written: Stop shows, Send waits. */ |
| 19 | export function anyAnswering(messages: readonly ChatMessage[]): boolean { |
| 20 | return messages.some((message) => !message.deleted_at && answering(message.answer)); |
| 21 | } |
| 22 | |
| 23 | /** Who wrote an answer: "Anthropic · claude-…", once the provider said. */ |
| 24 | export function answerLabel(answer: ChatAnswer | null | undefined): string | null { |
| 25 | if (!answer?.provider_label) return null; |
| 26 | return answer.model ? `${answer.provider_label} · ${answer.model}` : answer.provider_label; |
| 27 | } |
| 28 | |
| 29 | /** Whether an answer was cut off at its length limit. */ |
| 30 | export function cutOff(answer: ChatAnswer | null | undefined): boolean { |
| 31 | return answer?.stop_reason === "max_tokens" || answer?.stop_reason === "length"; |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * A conversation's top-level messages after one live event: new and |
| 36 | * changed ones by id, oldest first; an answer as it streams; deleted ones |
| 37 | * gone. Thread replies and other conversations' events change nothing. |
| 38 | */ |
| 39 | export function applyLive(list: readonly ChatMessage[], event: ChatLiveEvent, channelId: string): ChatMessage[] { |
| 40 | switch (event.type) { |
| 41 | case "message.created": |
| 42 | case "message.updated": { |
| 43 | const message = event.message; |
| 44 | if (message.channel_id !== channelId || message.thread_root) return list as ChatMessage[]; |
| 45 | if (message.deleted_at) return list.filter((m) => m.id !== message.id); |
| 46 | const at = list.findIndex((m) => m.id === message.id); |
| 47 | if (at >= 0) return list.map((m) => (m.id === message.id ? { ...m, ...message } : m)); |
| 48 | return [...list, message].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); |
| 49 | } |
| 50 | case "message.streaming": { |
| 51 | if (event.channel_id !== channelId) return list as ChatMessage[]; |
| 52 | return list.map((m) => |
| 53 | // Never back over what a stored message already has: a late frame after the end changes nothing. |
| 54 | m.id === event.id && answering(m.answer) ? { ...m, body: event.body, answer: event.answer } : m, |
| 55 | ); |
| 56 | } |
| 57 | case "message.deleted": |
| 58 | if (event.channel_id !== channelId) return list as ChatMessage[]; |
| 59 | return list.filter((m) => m.id !== event.id); |
| 60 | default: |
| 61 | return list as ChatMessage[]; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * What a reader may ask first, from where they are: about the issue or |
| 67 | * pull request in front of them, the repository, or their work. |
| 68 | */ |
| 69 | export function suggestions(page: AskPage): string[] { |
| 70 | if (page.pull) return [`Summarize pull request #${page.pull}`, `What's left before #${page.pull} can merge?`, "What changed in this pull request, file by file?", "What should I check when reviewing this?"]; |
| 71 | if (page.issue) return [`Summarize issue #${page.issue}`, `What's blocking #${page.issue}?`, "How would you approach this issue?", "Which code does this issue touch?"]; |
| 72 | if (page.repo) return [`What is ${page.repo.name} for?`, "What shipped here this week?", "What's open and needs attention?", "Where do I start reading the code?"]; |
| 73 | return ["What shipped today?", "What needs my attention?", "Summarize what my team is working on", "How do I set up a deployment?"]; |
| 74 | } |
| 75 | |
| 76 | /** A link g1t knows how to show as a chip: an issue or pull request, a commit, or another page of the site. */ |
| 77 | export type RefTarget = |
| 78 | | { kind: "issue"; repo: string; number: number; href: string } |
| 79 | | { kind: "commit"; repo: string; sha: string; href: string } |
| 80 | | { kind: "page"; href: string }; |
| 81 | |
| 82 | /** The site's own hosts: a full link to one of them is shown as the page it leads to. */ |
| 83 | const SITE_HOSTS = new Set(["g1t.sh", "www.g1t.sh"]); |
| 84 | |
| 85 | /** |
| 86 | * Where a link leads on the site, if it does: `/acme/web/issues/12`, |
| 87 | * `/acme/web/pull/12`, `/acme/web/commit/<sha>`, or the same as a full |
| 88 | * `https://g1t.sh/…` link (or one to `origin`, the site the reader is on). |
| 89 | */ |
| 90 | export function refTarget(href: string, origin: string | null = null): RefTarget | null { |
| 91 | let path: string; |
| 92 | if (href.startsWith("/") && !href.startsWith("//")) path = href; |
| 93 | else { |
| 94 | let url: URL; |
| 95 | try { |
| 96 | url = new URL(href); |
| 97 | } catch { |
| 98 | return null; |
| 99 | } |
| 100 | if (url.protocol !== "https:" && url.protocol !== "http:") return null; |
| 101 | if (!SITE_HOSTS.has(url.host) && url.origin !== origin) return null; |
| 102 | path = `${url.pathname}${url.search}${url.hash}`; |
| 103 | } |
| 104 | const bare = path.split(/[?#]/)[0]!; |
| 105 | const issue = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/(?:issues|pull)\/(\d+)\/?$/i); |
| 106 | if (issue) return { kind: "issue", repo: `${issue[1]!.toLowerCase()}/${issue[2]}`, number: Number(issue[3]), href: path }; |
| 107 | const commit = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/commit\/([0-9a-f]{7,40})\/?$/i); |
| 108 | if (commit) return { kind: "commit", repo: `${commit[1]!.toLowerCase()}/${commit[2]}`, sha: commit[3]!.toLowerCase(), href: path }; |
| 109 | return { kind: "page", href: path }; |
| 110 | } |