session-turn.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. import {
  2. AssistantMessage,
  3. type SnapshotFileDiff,
  4. Message as MessageType,
  5. Part as PartType,
  6. } from "@kirincode-ai/sdk/v2/client"
  7. import type { SessionStatus } from "@kirincode-ai/sdk/v2"
  8. import { useData } from "../context"
  9. import { useFileComponent } from "@kirincode-ai/ui/context/file"
  10. import { Binary } from "@kirincode-ai/core/util/binary"
  11. import { getDirectory, getFilename } from "@kirincode-ai/core/util/path"
  12. import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
  13. import { createStore } from "solid-js/store"
  14. import { Dynamic } from "solid-js/web"
  15. import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
  16. import { Card } from "@kirincode-ai/ui/card"
  17. import { Accordion } from "@kirincode-ai/ui/accordion"
  18. import { StickyAccordionHeader } from "@kirincode-ai/ui/sticky-accordion-header"
  19. import { DiffChanges } from "@kirincode-ai/ui/diff-changes"
  20. import { Icon } from "@kirincode-ai/ui/icon"
  21. import { TextShimmer } from "@kirincode-ai/ui/text-shimmer"
  22. import { SessionRetry } from "./session-retry"
  23. import { TextReveal } from "@kirincode-ai/ui/text-reveal"
  24. import { createAutoScroll } from "@kirincode-ai/ui/hooks"
  25. import { useI18n } from "@kirincode-ai/ui/context/i18n"
  26. import { normalize } from "./session-diff"
  27. function record(value: unknown): value is Record<string, unknown> {
  28. return !!value && typeof value === "object" && !Array.isArray(value)
  29. }
  30. function unwrap(message: string) {
  31. const text = message.replace(/^Error:\s*/, "").trim()
  32. const parse = (value: string) => {
  33. try {
  34. return JSON.parse(value) as unknown
  35. } catch {
  36. return undefined
  37. }
  38. }
  39. const read = (value: string) => {
  40. const first = parse(value)
  41. if (typeof first !== "string") return first
  42. return parse(first.trim())
  43. }
  44. let json = read(text)
  45. if (json === undefined) {
  46. const start = text.indexOf("{")
  47. const end = text.lastIndexOf("}")
  48. if (start !== -1 && end > start) {
  49. json = read(text.slice(start, end + 1))
  50. }
  51. }
  52. if (!record(json)) return message
  53. const err = record(json.error) ? json.error : undefined
  54. if (err) {
  55. const type = typeof err.type === "string" ? err.type : undefined
  56. const msg = typeof err.message === "string" ? err.message : undefined
  57. if (type && msg) return `${type}: ${msg}`
  58. if (msg) return msg
  59. if (type) return type
  60. const code = typeof err.code === "string" ? err.code : undefined
  61. if (code) return code
  62. }
  63. const msg = typeof json.message === "string" ? json.message : undefined
  64. if (msg) return msg
  65. const reason = typeof json.error === "string" ? json.error : undefined
  66. if (reason) return reason
  67. return message
  68. }
  69. function same<T>(a: readonly T[], b: readonly T[]) {
  70. if (a === b) return true
  71. if (a.length !== b.length) return false
  72. return a.every((x, i) => x === b[i])
  73. }
  74. function list<T>(value: T[] | undefined | null, fallback: T[]) {
  75. if (Array.isArray(value)) return value
  76. return fallback
  77. }
  78. type SummaryDiff = SnapshotFileDiff & { file: string }
  79. function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
  80. return typeof value.file === "string"
  81. }
  82. const hidden = new Set(["todowrite"])
  83. function partState(part: PartType, showReasoningSummaries: boolean) {
  84. if (part.type === "tool") {
  85. if (hidden.has(part.tool)) return
  86. if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
  87. return "visible" as const
  88. }
  89. if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
  90. if (part.type === "reasoning") {
  91. if (showReasoningSummaries && part.text?.trim()) return "visible" as const
  92. return
  93. }
  94. if (PART_MAPPING[part.type]) return "visible" as const
  95. return
  96. }
  97. function clean(value: string) {
  98. return value
  99. .replace(/`([^`]+)`/g, "$1")
  100. .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
  101. .replace(/[*_~]+/g, "")
  102. .trim()
  103. }
  104. function heading(text: string) {
  105. const markdown = text.replace(/\r\n?/g, "\n")
  106. const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
  107. if (html?.[1]) {
  108. const value = clean(html[1].replace(/<[^>]+>/g, " "))
  109. if (value) return value
  110. }
  111. const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
  112. if (atx?.[1]) {
  113. const value = clean(atx[1])
  114. if (value) return value
  115. }
  116. const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
  117. if (setext?.[1]) {
  118. const value = clean(setext[1])
  119. if (value) return value
  120. }
  121. const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
  122. if (strong?.[1]) {
  123. const value = clean(strong[1])
  124. if (value) return value
  125. }
  126. }
  127. export function SessionTurn(
  128. props: ParentProps<{
  129. sessionID: string
  130. messageID: string
  131. messages?: MessageType[]
  132. actions?: UserActions
  133. showReasoningSummaries?: boolean
  134. shellToolDefaultOpen?: boolean
  135. editToolDefaultOpen?: boolean
  136. active?: boolean
  137. status?: SessionStatus
  138. onUserInteracted?: () => void
  139. classes?: {
  140. root?: string
  141. content?: string
  142. container?: string
  143. }
  144. }>,
  145. ) {
  146. const data = useData()
  147. const i18n = useI18n()
  148. const fileComponent = useFileComponent()
  149. const emptyMessages: MessageType[] = []
  150. const emptyParts: PartType[] = []
  151. const emptyAssistant: AssistantMessage[] = []
  152. const emptyDiffs: SummaryDiff[] = []
  153. const idle = { type: "idle" as const }
  154. const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
  155. const messageIndex = createMemo(() => {
  156. const messages = allMessages() ?? emptyMessages
  157. const result = Binary.search(messages, props.messageID, (m) => m.id)
  158. const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID)
  159. if (index < 0) return -1
  160. const msg = messages[index]
  161. if (!msg || msg.role !== "user") return -1
  162. return index
  163. })
  164. const message = createMemo(() => {
  165. const index = messageIndex()
  166. if (index < 0) return undefined
  167. const messages = allMessages() ?? emptyMessages
  168. const msg = messages[index]
  169. if (!msg || msg.role !== "user") return undefined
  170. return msg
  171. })
  172. const pending = createMemo(() => {
  173. if (typeof props.active === "boolean") return
  174. const messages = allMessages() ?? emptyMessages
  175. return messages.findLast(
  176. (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
  177. )
  178. })
  179. const pendingUser = createMemo(() => {
  180. const item = pending()
  181. if (!item?.parentID) return
  182. const messages = allMessages() ?? emptyMessages
  183. const result = Binary.search(messages, item.parentID, (m) => m.id)
  184. const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
  185. if (!msg || msg.role !== "user") return
  186. return msg
  187. })
  188. const active = createMemo(() => {
  189. if (typeof props.active === "boolean") return props.active
  190. const msg = message()
  191. const parent = pendingUser()
  192. if (!msg || !parent) return false
  193. return parent.id === msg.id
  194. })
  195. const parts = createMemo(() => {
  196. const msg = message()
  197. if (!msg) return emptyParts
  198. return list(data.store.part?.[msg.id], emptyParts)
  199. })
  200. const compaction = createMemo(() => parts().find((part) => part.type === "compaction"))
  201. const diffs = createMemo(() => {
  202. const files = message()?.summary?.diffs
  203. if (!files?.length) return emptyDiffs
  204. const seen = new Set<string>()
  205. return files
  206. .reduceRight<SummaryDiff[]>((result, diff) => {
  207. if (!summaryDiff(diff)) return result
  208. if (seen.has(diff.file)) return result
  209. seen.add(diff.file)
  210. result.push(diff)
  211. return result
  212. }, [])
  213. .reverse()
  214. })
  215. const MAX_FILES = 10
  216. const edited = createMemo(() => diffs().length)
  217. const [state, setState] = createStore({
  218. showAll: false,
  219. expanded: [] as string[],
  220. })
  221. const showAll = () => state.showAll
  222. const expanded = () => state.expanded
  223. const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
  224. const visible = createMemo(() => (showAll() ? diffs() : diffs().slice(0, MAX_FILES)))
  225. const toggleAll = () => {
  226. autoScroll.pause()
  227. setState("showAll", !showAll())
  228. }
  229. const assistantMessages = createMemo(
  230. () => {
  231. const msg = message()
  232. if (!msg) return emptyAssistant
  233. const messages = allMessages() ?? emptyMessages
  234. if (messageIndex() < 0) return emptyAssistant
  235. const result: AssistantMessage[] = []
  236. for (let i = 0; i < messages.length; i++) {
  237. const item = messages[i]
  238. if (!item) continue
  239. if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
  240. }
  241. return result
  242. },
  243. emptyAssistant,
  244. { equals: same },
  245. )
  246. const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
  247. const divider = createMemo(() => {
  248. if (compaction()) return i18n.t("ui.messagePart.compaction")
  249. if (interrupted()) return i18n.t("ui.message.interrupted")
  250. return ""
  251. })
  252. const error = createMemo(
  253. () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
  254. )
  255. const showAssistantCopyPartID = createMemo(() => {
  256. const messages = assistantMessages()
  257. for (let i = messages.length - 1; i >= 0; i--) {
  258. const message = messages[i]
  259. if (!message) continue
  260. const parts = list(data.store.part?.[message.id], emptyParts)
  261. for (let j = parts.length - 1; j >= 0; j--) {
  262. const part = parts[j]
  263. if (!part || part.type !== "text" || !part.text?.trim()) continue
  264. return part.id
  265. }
  266. }
  267. return undefined
  268. })
  269. const errorText = createMemo(() => {
  270. const msg = error()?.data?.message
  271. if (typeof msg === "string") return unwrap(msg)
  272. if (msg === undefined || msg === null) return ""
  273. // oxlint-disable-next-line no-base-to-string -- msg is unknown from error data, coercion is intentional
  274. return unwrap(String(msg))
  275. })
  276. const status = createMemo(() => {
  277. if (props.status !== undefined) return props.status
  278. if (typeof props.active === "boolean" && !props.active) return idle
  279. return data.store.session_status[props.sessionID] ?? idle
  280. })
  281. const working = createMemo(() => status().type !== "idle" && active())
  282. const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
  283. const assistantCopyPartID = createMemo(() => {
  284. if (working()) return null
  285. return showAssistantCopyPartID() ?? null
  286. })
  287. const turnDurationMs = createMemo(() => {
  288. const start = message()?.time.created
  289. if (typeof start !== "number") return undefined
  290. const end = assistantMessages().reduce<number | undefined>((max, item) => {
  291. const completed = item.time.completed
  292. if (typeof completed !== "number") return max
  293. if (max === undefined) return completed
  294. return Math.max(max, completed)
  295. }, undefined)
  296. if (typeof end !== "number") return undefined
  297. if (end < start) return undefined
  298. return end - start
  299. })
  300. const assistantDerived = createMemo(() => {
  301. let visible = 0
  302. let reason: string | undefined
  303. const show = showReasoningSummaries()
  304. for (const message of assistantMessages()) {
  305. for (const part of list(data.store.part?.[message.id], emptyParts)) {
  306. if (partState(part, show) === "visible") {
  307. visible++
  308. }
  309. if (part.type === "reasoning" && part.text) {
  310. const h = heading(part.text)
  311. if (h) reason = h
  312. }
  313. }
  314. }
  315. return { visible, reason }
  316. })
  317. const assistantVisible = createMemo(() => assistantDerived().visible)
  318. const reasoningHeading = createMemo(() => assistantDerived().reason)
  319. const showThinking = createMemo(() => {
  320. if (!working() || !!error()) return false
  321. if (status().type === "retry") return false
  322. if (showReasoningSummaries()) return assistantVisible() === 0
  323. return true
  324. })
  325. const autoScroll = createAutoScroll({
  326. working,
  327. onUserInteracted: props.onUserInteracted,
  328. overflowAnchor: "dynamic",
  329. })
  330. return (
  331. <div data-component="session-turn" class={props.classes?.root}>
  332. <div
  333. ref={autoScroll.scrollRef}
  334. onScroll={autoScroll.handleScroll}
  335. data-slot="session-turn-content"
  336. class={props.classes?.content}
  337. >
  338. <div onClick={autoScroll.handleInteraction}>
  339. <Show when={message()}>
  340. <div
  341. ref={autoScroll.contentRef}
  342. data-message={message()!.id}
  343. data-slot="session-turn-message-container"
  344. class={props.classes?.container}
  345. >
  346. <div data-slot="session-turn-message-content" aria-live="off">
  347. <Message message={message()!} parts={parts()} actions={props.actions} />
  348. </div>
  349. <Show when={divider()}>
  350. <div data-slot="session-turn-compaction">
  351. <MessageDivider label={divider()} />
  352. </div>
  353. </Show>
  354. <Show when={assistantMessages().length > 0}>
  355. <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
  356. <AssistantParts
  357. messages={assistantMessages()}
  358. showAssistantCopyPartID={assistantCopyPartID()}
  359. turnDurationMs={turnDurationMs()}
  360. working={working()}
  361. showReasoningSummaries={showReasoningSummaries()}
  362. shellToolDefaultOpen={props.shellToolDefaultOpen}
  363. editToolDefaultOpen={props.editToolDefaultOpen}
  364. />
  365. </div>
  366. </Show>
  367. <Show when={showThinking()}>
  368. <div data-slot="session-turn-thinking">
  369. <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
  370. <Show when={!showReasoningSummaries()}>
  371. <TextReveal
  372. text={reasoningHeading()}
  373. class="session-turn-thinking-heading"
  374. travel={25}
  375. duration={700}
  376. />
  377. </Show>
  378. </div>
  379. </Show>
  380. <SessionRetry status={status()} show={active()} />
  381. <Show when={edited() > 0 && !working()}>
  382. <div
  383. data-slot="session-turn-diffs"
  384. data-component="session-turn-diffs-group"
  385. data-show-all={showAll() || undefined}
  386. >
  387. <div data-slot="session-turn-diffs-header">
  388. <span data-slot="session-turn-diffs-label">
  389. {i18n.t(
  390. edited() === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
  391. { count: String(edited()) },
  392. )}
  393. </span>
  394. <DiffChanges changes={diffs()} />
  395. <Show when={overflow() > 0}>
  396. <span data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
  397. {showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
  398. </span>
  399. </Show>
  400. </div>
  401. <div data-component="session-turn-diffs-content">
  402. <Accordion
  403. multiple
  404. style={{ "--sticky-accordion-offset": "44px" }}
  405. value={expanded()}
  406. onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
  407. >
  408. <For each={visible()}>
  409. {(diff) => {
  410. const view = normalize(diff)
  411. const active = createMemo(() => expanded().includes(diff.file))
  412. const [shown, setShown] = createSignal(false)
  413. createEffect(
  414. on(
  415. active,
  416. (value) => {
  417. if (!value) {
  418. setShown(false)
  419. return
  420. }
  421. requestAnimationFrame(() => {
  422. if (!active()) return
  423. setShown(true)
  424. })
  425. },
  426. { defer: true },
  427. ),
  428. )
  429. return (
  430. <Accordion.Item value={diff.file}>
  431. <StickyAccordionHeader>
  432. <Accordion.Trigger>
  433. <div data-slot="session-turn-diff-trigger">
  434. <span data-slot="session-turn-diff-path">
  435. <Show when={diff.file.includes("/")}>
  436. <span data-slot="session-turn-diff-directory">
  437. {`\u202A${getDirectory(diff.file)}\u202C`}
  438. </span>
  439. </Show>
  440. <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
  441. </span>
  442. <div data-slot="session-turn-diff-meta">
  443. <span data-slot="session-turn-diff-changes">
  444. <DiffChanges changes={diff} />
  445. </span>
  446. <span data-slot="session-turn-diff-chevron">
  447. <Icon name="chevron-down" size="small" />
  448. </span>
  449. </div>
  450. </div>
  451. </Accordion.Trigger>
  452. </StickyAccordionHeader>
  453. <Accordion.Content>
  454. <Show when={shown()}>
  455. <div data-slot="session-turn-diff-view" data-scrollable>
  456. <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
  457. </div>
  458. </Show>
  459. </Accordion.Content>
  460. </Accordion.Item>
  461. )
  462. }}
  463. </For>
  464. </Accordion>
  465. <Show when={!showAll() && overflow() > 0}>
  466. <div data-slot="session-turn-diffs-more" onClick={toggleAll}>
  467. {i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
  468. </div>
  469. </Show>
  470. </div>
  471. </div>
  472. </Show>
  473. <Show when={error()}>
  474. <Card variant="error" class="error-card">
  475. {errorText()}
  476. </Card>
  477. </Show>
  478. </div>
  479. </Show>
  480. {props.children}
  481. </div>
  482. </div>
  483. </div>
  484. )
  485. }