session.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { createStore, produce } from "solid-js/store"
  2. import { createSimpleContext } from "./helper"
  3. import { batch, createEffect, createMemo } from "solid-js"
  4. import { useSync } from "./sync"
  5. import { makePersisted } from "@solid-primitives/storage"
  6. import { TextSelection } from "./local"
  7. import { pipe, sumBy } from "remeda"
  8. import { AssistantMessage } from "@opencode-ai/sdk"
  9. export const { use: useSession, provider: SessionProvider } = createSimpleContext({
  10. name: "Session",
  11. init: (props: { sessionId?: string }) => {
  12. const sync = useSync()
  13. const [store, setStore] = makePersisted(
  14. createStore<{
  15. messageId?: string
  16. tabs: {
  17. active?: string
  18. opened: string[]
  19. }
  20. prompt: Prompt
  21. cursor?: number
  22. }>({
  23. tabs: {
  24. opened: [],
  25. },
  26. prompt: clonePrompt(DEFAULT_PROMPT),
  27. cursor: undefined,
  28. }),
  29. {
  30. name: props.sessionId ?? "new-session",
  31. },
  32. )
  33. createEffect(() => {
  34. if (!props.sessionId) return
  35. sync.session.sync(props.sessionId)
  36. })
  37. const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
  38. const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
  39. const userMessages = createMemo(() =>
  40. messages()
  41. .filter((m) => m.role === "user")
  42. .sort((a, b) => b.id.localeCompare(a.id)),
  43. )
  44. const lastUserMessage = createMemo(() => {
  45. return userMessages()?.at(0)
  46. })
  47. const activeMessage = createMemo(() => {
  48. if (!store.messageId) return lastUserMessage()
  49. return userMessages()?.find((m) => m.id === store.messageId)
  50. })
  51. const working = createMemo(() => {
  52. if (!props.sessionId) return false
  53. const last = lastUserMessage()
  54. if (!last) return false
  55. const assistantMessages = sync.data.message[props.sessionId]?.filter(
  56. (m) => m.role === "assistant" && m.parentID == last?.id,
  57. ) as AssistantMessage[]
  58. const error = assistantMessages?.find((m) => m?.error)?.error
  59. return !last?.summary?.body && !error
  60. })
  61. const cost = createMemo(() => {
  62. const total = pipe(
  63. messages(),
  64. sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
  65. )
  66. return new Intl.NumberFormat("en-US", {
  67. style: "currency",
  68. currency: "USD",
  69. }).format(total)
  70. })
  71. const last = createMemo(
  72. () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
  73. )
  74. const model = createMemo(() =>
  75. last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
  76. )
  77. const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
  78. const tokens = createMemo(() => {
  79. if (!last()) return
  80. const tokens = last().tokens
  81. return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
  82. })
  83. const context = createMemo(() => {
  84. const total = tokens()
  85. const limit = model()?.limit.context
  86. if (!total || !limit) return 0
  87. return Math.round((total / limit) * 100)
  88. })
  89. return {
  90. id: props.sessionId,
  91. info,
  92. working,
  93. diffs,
  94. prompt: {
  95. current: createMemo(() => store.prompt),
  96. cursor: createMemo(() => store.cursor),
  97. dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
  98. set(prompt: Prompt, cursorPosition?: number) {
  99. const next = clonePrompt(prompt)
  100. batch(() => {
  101. setStore("prompt", next)
  102. if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
  103. })
  104. },
  105. },
  106. messages: {
  107. all: messages,
  108. user: userMessages,
  109. last: lastUserMessage,
  110. active: activeMessage,
  111. setActive(id: string | undefined) {
  112. setStore("messageId", id)
  113. },
  114. },
  115. usage: {
  116. tokens,
  117. cost,
  118. context,
  119. },
  120. layout: {
  121. tabs: store.tabs,
  122. setActiveTab(tab: string | undefined) {
  123. setStore("tabs", "active", tab)
  124. },
  125. setOpenedTabs(tabs: string[]) {
  126. setStore("tabs", "opened", tabs)
  127. },
  128. async openTab(tab: string) {
  129. if (tab === "chat") {
  130. setStore("tabs", "active", undefined)
  131. return
  132. }
  133. if (tab !== "review") {
  134. if (!store.tabs.opened.includes(tab)) {
  135. setStore("tabs", "opened", [...store.tabs.opened, tab])
  136. }
  137. }
  138. setStore("tabs", "active", tab)
  139. },
  140. closeTab(tab: string) {
  141. batch(() => {
  142. setStore(
  143. "tabs",
  144. "opened",
  145. store.tabs.opened.filter((x) => x !== tab),
  146. )
  147. if (store.tabs.active === tab) {
  148. const index = store.tabs.opened.findIndex((f) => f === tab)
  149. const previous = store.tabs.opened[Math.max(0, index - 1)]
  150. setStore("tabs", "active", previous)
  151. }
  152. })
  153. },
  154. moveTab(tab: string, to: number) {
  155. const index = store.tabs.opened.findIndex((f) => f === tab)
  156. if (index === -1) return
  157. setStore(
  158. "tabs",
  159. "opened",
  160. produce((opened) => {
  161. opened.splice(to, 0, opened.splice(index, 1)[0])
  162. }),
  163. )
  164. },
  165. },
  166. }
  167. },
  168. })
  169. interface PartBase {
  170. content: string
  171. start: number
  172. end: number
  173. }
  174. export interface TextPart extends PartBase {
  175. type: "text"
  176. }
  177. export interface FileAttachmentPart extends PartBase {
  178. type: "file"
  179. path: string
  180. selection?: TextSelection
  181. }
  182. export type ContentPart = TextPart | FileAttachmentPart
  183. export type Prompt = ContentPart[]
  184. export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
  185. export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
  186. if (promptA.length !== promptB.length) return false
  187. for (let i = 0; i < promptA.length; i++) {
  188. const partA = promptA[i]
  189. const partB = promptB[i]
  190. if (partA.type !== partB.type) return false
  191. if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
  192. return false
  193. }
  194. if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
  195. return false
  196. }
  197. }
  198. return true
  199. }
  200. function cloneSelection(selection?: TextSelection) {
  201. if (!selection) return undefined
  202. return { ...selection }
  203. }
  204. function clonePart(part: ContentPart): ContentPart {
  205. if (part.type === "text") return { ...part }
  206. return {
  207. ...part,
  208. selection: cloneSelection(part.selection),
  209. }
  210. }
  211. function clonePrompt(prompt: Prompt): Prompt {
  212. return prompt.map(clonePart)
  213. }