| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- import {
- AssistantMessage,
- type SnapshotFileDiff,
- Message as MessageType,
- Part as PartType,
- } from "@kirincode-ai/sdk/v2/client"
- import type { SessionStatus } from "@kirincode-ai/sdk/v2"
- import { useData } from "../context"
- import { useFileComponent } from "@kirincode-ai/ui/context/file"
- import { Binary } from "@kirincode-ai/core/util/binary"
- import { getDirectory, getFilename } from "@kirincode-ai/core/util/path"
- import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
- import { createStore } from "solid-js/store"
- import { Dynamic } from "solid-js/web"
- import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
- import { Card } from "@kirincode-ai/ui/card"
- import { Accordion } from "@kirincode-ai/ui/accordion"
- import { StickyAccordionHeader } from "@kirincode-ai/ui/sticky-accordion-header"
- import { DiffChanges } from "@kirincode-ai/ui/diff-changes"
- import { Icon } from "@kirincode-ai/ui/icon"
- import { TextShimmer } from "@kirincode-ai/ui/text-shimmer"
- import { SessionRetry } from "./session-retry"
- import { TextReveal } from "@kirincode-ai/ui/text-reveal"
- import { createAutoScroll } from "@kirincode-ai/ui/hooks"
- import { useI18n } from "@kirincode-ai/ui/context/i18n"
- import { normalize } from "./session-diff"
- function record(value: unknown): value is Record<string, unknown> {
- return !!value && typeof value === "object" && !Array.isArray(value)
- }
- function unwrap(message: string) {
- const text = message.replace(/^Error:\s*/, "").trim()
- const parse = (value: string) => {
- try {
- return JSON.parse(value) as unknown
- } catch {
- return undefined
- }
- }
- const read = (value: string) => {
- const first = parse(value)
- if (typeof first !== "string") return first
- return parse(first.trim())
- }
- let json = read(text)
- if (json === undefined) {
- const start = text.indexOf("{")
- const end = text.lastIndexOf("}")
- if (start !== -1 && end > start) {
- json = read(text.slice(start, end + 1))
- }
- }
- if (!record(json)) return message
- const err = record(json.error) ? json.error : undefined
- if (err) {
- const type = typeof err.type === "string" ? err.type : undefined
- const msg = typeof err.message === "string" ? err.message : undefined
- if (type && msg) return `${type}: ${msg}`
- if (msg) return msg
- if (type) return type
- const code = typeof err.code === "string" ? err.code : undefined
- if (code) return code
- }
- const msg = typeof json.message === "string" ? json.message : undefined
- if (msg) return msg
- const reason = typeof json.error === "string" ? json.error : undefined
- if (reason) return reason
- return message
- }
- function same<T>(a: readonly T[], b: readonly T[]) {
- if (a === b) return true
- if (a.length !== b.length) return false
- return a.every((x, i) => x === b[i])
- }
- function list<T>(value: T[] | undefined | null, fallback: T[]) {
- if (Array.isArray(value)) return value
- return fallback
- }
- type SummaryDiff = SnapshotFileDiff & { file: string }
- function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
- return typeof value.file === "string"
- }
- const hidden = new Set(["todowrite"])
- function partState(part: PartType, showReasoningSummaries: boolean) {
- if (part.type === "tool") {
- if (hidden.has(part.tool)) return
- if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
- return "visible" as const
- }
- if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
- if (part.type === "reasoning") {
- if (showReasoningSummaries && part.text?.trim()) return "visible" as const
- return
- }
- if (PART_MAPPING[part.type]) return "visible" as const
- return
- }
- function clean(value: string) {
- return value
- .replace(/`([^`]+)`/g, "$1")
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
- .replace(/[*_~]+/g, "")
- .trim()
- }
- function heading(text: string) {
- const markdown = text.replace(/\r\n?/g, "\n")
- const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
- if (html?.[1]) {
- const value = clean(html[1].replace(/<[^>]+>/g, " "))
- if (value) return value
- }
- const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
- if (atx?.[1]) {
- const value = clean(atx[1])
- if (value) return value
- }
- const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
- if (setext?.[1]) {
- const value = clean(setext[1])
- if (value) return value
- }
- const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
- if (strong?.[1]) {
- const value = clean(strong[1])
- if (value) return value
- }
- }
- export function SessionTurn(
- props: ParentProps<{
- sessionID: string
- messageID: string
- messages?: MessageType[]
- actions?: UserActions
- showReasoningSummaries?: boolean
- shellToolDefaultOpen?: boolean
- editToolDefaultOpen?: boolean
- active?: boolean
- status?: SessionStatus
- onUserInteracted?: () => void
- classes?: {
- root?: string
- content?: string
- container?: string
- }
- }>,
- ) {
- const data = useData()
- const i18n = useI18n()
- const fileComponent = useFileComponent()
- const emptyMessages: MessageType[] = []
- const emptyParts: PartType[] = []
- const emptyAssistant: AssistantMessage[] = []
- const emptyDiffs: SummaryDiff[] = []
- const idle = { type: "idle" as const }
- const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
- const messageIndex = createMemo(() => {
- const messages = allMessages() ?? emptyMessages
- const result = Binary.search(messages, props.messageID, (m) => m.id)
- const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID)
- if (index < 0) return -1
- const msg = messages[index]
- if (!msg || msg.role !== "user") return -1
- return index
- })
- const message = createMemo(() => {
- const index = messageIndex()
- if (index < 0) return undefined
- const messages = allMessages() ?? emptyMessages
- const msg = messages[index]
- if (!msg || msg.role !== "user") return undefined
- return msg
- })
- const pending = createMemo(() => {
- if (typeof props.active === "boolean") return
- const messages = allMessages() ?? emptyMessages
- return messages.findLast(
- (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
- )
- })
- const pendingUser = createMemo(() => {
- const item = pending()
- if (!item?.parentID) return
- const messages = allMessages() ?? emptyMessages
- const result = Binary.search(messages, item.parentID, (m) => m.id)
- const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
- if (!msg || msg.role !== "user") return
- return msg
- })
- const active = createMemo(() => {
- if (typeof props.active === "boolean") return props.active
- const msg = message()
- const parent = pendingUser()
- if (!msg || !parent) return false
- return parent.id === msg.id
- })
- const parts = createMemo(() => {
- const msg = message()
- if (!msg) return emptyParts
- return list(data.store.part?.[msg.id], emptyParts)
- })
- const compaction = createMemo(() => parts().find((part) => part.type === "compaction"))
- const diffs = createMemo(() => {
- const files = message()?.summary?.diffs
- if (!files?.length) return emptyDiffs
- const seen = new Set<string>()
- return files
- .reduceRight<SummaryDiff[]>((result, diff) => {
- if (!summaryDiff(diff)) return result
- if (seen.has(diff.file)) return result
- seen.add(diff.file)
- result.push(diff)
- return result
- }, [])
- .reverse()
- })
- const MAX_FILES = 10
- const edited = createMemo(() => diffs().length)
- const [state, setState] = createStore({
- showAll: false,
- expanded: [] as string[],
- })
- const showAll = () => state.showAll
- const expanded = () => state.expanded
- const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
- const visible = createMemo(() => (showAll() ? diffs() : diffs().slice(0, MAX_FILES)))
- const toggleAll = () => {
- autoScroll.pause()
- setState("showAll", !showAll())
- }
- const assistantMessages = createMemo(
- () => {
- const msg = message()
- if (!msg) return emptyAssistant
- const messages = allMessages() ?? emptyMessages
- if (messageIndex() < 0) return emptyAssistant
- const result: AssistantMessage[] = []
- for (let i = 0; i < messages.length; i++) {
- const item = messages[i]
- if (!item) continue
- if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
- }
- return result
- },
- emptyAssistant,
- { equals: same },
- )
- const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
- const divider = createMemo(() => {
- if (compaction()) return i18n.t("ui.messagePart.compaction")
- if (interrupted()) return i18n.t("ui.message.interrupted")
- return ""
- })
- const error = createMemo(
- () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
- )
- const showAssistantCopyPartID = createMemo(() => {
- const messages = assistantMessages()
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i]
- if (!message) continue
- const parts = list(data.store.part?.[message.id], emptyParts)
- for (let j = parts.length - 1; j >= 0; j--) {
- const part = parts[j]
- if (!part || part.type !== "text" || !part.text?.trim()) continue
- return part.id
- }
- }
- return undefined
- })
- const errorText = createMemo(() => {
- const msg = error()?.data?.message
- if (typeof msg === "string") return unwrap(msg)
- if (msg === undefined || msg === null) return ""
- // oxlint-disable-next-line no-base-to-string -- msg is unknown from error data, coercion is intentional
- return unwrap(String(msg))
- })
- const status = createMemo(() => {
- if (props.status !== undefined) return props.status
- if (typeof props.active === "boolean" && !props.active) return idle
- return data.store.session_status[props.sessionID] ?? idle
- })
- const working = createMemo(() => status().type !== "idle" && active())
- const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
- const assistantCopyPartID = createMemo(() => {
- if (working()) return null
- return showAssistantCopyPartID() ?? null
- })
- const turnDurationMs = createMemo(() => {
- const start = message()?.time.created
- if (typeof start !== "number") return undefined
- const end = assistantMessages().reduce<number | undefined>((max, item) => {
- const completed = item.time.completed
- if (typeof completed !== "number") return max
- if (max === undefined) return completed
- return Math.max(max, completed)
- }, undefined)
- if (typeof end !== "number") return undefined
- if (end < start) return undefined
- return end - start
- })
- const assistantDerived = createMemo(() => {
- let visible = 0
- let reason: string | undefined
- const show = showReasoningSummaries()
- for (const message of assistantMessages()) {
- for (const part of list(data.store.part?.[message.id], emptyParts)) {
- if (partState(part, show) === "visible") {
- visible++
- }
- if (part.type === "reasoning" && part.text) {
- const h = heading(part.text)
- if (h) reason = h
- }
- }
- }
- return { visible, reason }
- })
- const assistantVisible = createMemo(() => assistantDerived().visible)
- const reasoningHeading = createMemo(() => assistantDerived().reason)
- const showThinking = createMemo(() => {
- if (!working() || !!error()) return false
- if (status().type === "retry") return false
- if (showReasoningSummaries()) return assistantVisible() === 0
- return true
- })
- const autoScroll = createAutoScroll({
- working,
- onUserInteracted: props.onUserInteracted,
- overflowAnchor: "dynamic",
- })
- return (
- <div data-component="session-turn" class={props.classes?.root}>
- <div
- ref={autoScroll.scrollRef}
- onScroll={autoScroll.handleScroll}
- data-slot="session-turn-content"
- class={props.classes?.content}
- >
- <div onClick={autoScroll.handleInteraction}>
- <Show when={message()}>
- <div
- ref={autoScroll.contentRef}
- data-message={message()!.id}
- data-slot="session-turn-message-container"
- class={props.classes?.container}
- >
- <div data-slot="session-turn-message-content" aria-live="off">
- <Message message={message()!} parts={parts()} actions={props.actions} />
- </div>
- <Show when={divider()}>
- <div data-slot="session-turn-compaction">
- <MessageDivider label={divider()} />
- </div>
- </Show>
- <Show when={assistantMessages().length > 0}>
- <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
- <AssistantParts
- messages={assistantMessages()}
- showAssistantCopyPartID={assistantCopyPartID()}
- turnDurationMs={turnDurationMs()}
- working={working()}
- showReasoningSummaries={showReasoningSummaries()}
- shellToolDefaultOpen={props.shellToolDefaultOpen}
- editToolDefaultOpen={props.editToolDefaultOpen}
- />
- </div>
- </Show>
- <Show when={showThinking()}>
- <div data-slot="session-turn-thinking">
- <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
- <Show when={!showReasoningSummaries()}>
- <TextReveal
- text={reasoningHeading()}
- class="session-turn-thinking-heading"
- travel={25}
- duration={700}
- />
- </Show>
- </div>
- </Show>
- <SessionRetry status={status()} show={active()} />
- <Show when={edited() > 0 && !working()}>
- <div
- data-slot="session-turn-diffs"
- data-component="session-turn-diffs-group"
- data-show-all={showAll() || undefined}
- >
- <div data-slot="session-turn-diffs-header">
- <span data-slot="session-turn-diffs-label">
- {i18n.t(
- edited() === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
- { count: String(edited()) },
- )}
- </span>
- <DiffChanges changes={diffs()} />
- <Show when={overflow() > 0}>
- <span data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
- {showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
- </span>
- </Show>
- </div>
- <div data-component="session-turn-diffs-content">
- <Accordion
- multiple
- style={{ "--sticky-accordion-offset": "44px" }}
- value={expanded()}
- onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
- >
- <For each={visible()}>
- {(diff) => {
- const view = normalize(diff)
- const active = createMemo(() => expanded().includes(diff.file))
- const [shown, setShown] = createSignal(false)
- createEffect(
- on(
- active,
- (value) => {
- if (!value) {
- setShown(false)
- return
- }
- requestAnimationFrame(() => {
- if (!active()) return
- setShown(true)
- })
- },
- { defer: true },
- ),
- )
- return (
- <Accordion.Item value={diff.file}>
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div data-slot="session-turn-diff-trigger">
- <span data-slot="session-turn-diff-path">
- <Show when={diff.file.includes("/")}>
- <span data-slot="session-turn-diff-directory">
- {`\u202A${getDirectory(diff.file)}\u202C`}
- </span>
- </Show>
- <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
- </span>
- <div data-slot="session-turn-diff-meta">
- <span data-slot="session-turn-diff-changes">
- <DiffChanges changes={diff} />
- </span>
- <span data-slot="session-turn-diff-chevron">
- <Icon name="chevron-down" size="small" />
- </span>
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content>
- <Show when={shown()}>
- <div data-slot="session-turn-diff-view" data-scrollable>
- <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
- </div>
- </Show>
- </Accordion.Content>
- </Accordion.Item>
- )
- }}
- </For>
- </Accordion>
- <Show when={!showAll() && overflow() > 0}>
- <div data-slot="session-turn-diffs-more" onClick={toggleAll}>
- {i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
- </div>
- </Show>
- </div>
- </div>
- </Show>
- <Show when={error()}>
- <Card variant="error" class="error-card">
- {errorText()}
- </Card>
- </Show>
- </div>
- </Show>
- {props.children}
- </div>
- </div>
- </div>
- )
- }
|