notification.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { createStore, reconcile } from "solid-js/store"
  2. import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
  3. import { useParams, useSearchParams } from "@solidjs/router"
  4. import { createSimpleContext } from "@kirincode-ai/ui/context"
  5. import type { ServerSDK } from "./server-sdk"
  6. import type { ServerSync } from "./server-sync"
  7. import { usePlatform } from "@/context/platform"
  8. import { useLanguage } from "@/context/language"
  9. import { useSettings } from "@/context/settings"
  10. import { base64Encode } from "@kirincode-ai/core/util/encode"
  11. import { decode64 } from "@/utils/base64"
  12. import { EventSessionError } from "@kirincode-ai/sdk/v2"
  13. import { Persist, persisted } from "@/utils/persist"
  14. import { playSoundById } from "@/utils/sound"
  15. import { useGlobal } from "./global"
  16. import { ServerConnection, useServer } from "./server"
  17. import { type DraftTab, useTabs } from "./tabs"
  18. import { requireServerKey } from "@/utils/session-route"
  19. import type { ServerScope } from "@/utils/server-scope"
  20. type NotificationBase = {
  21. directory?: string
  22. session?: string
  23. metadata?: unknown
  24. time: number
  25. viewed: boolean
  26. }
  27. type TurnCompleteNotification = NotificationBase & {
  28. type: "turn-complete"
  29. }
  30. type ErrorNotification = NotificationBase & {
  31. type: "error"
  32. error: EventSessionError["properties"]["error"]
  33. }
  34. export type Notification = TurnCompleteNotification | ErrorNotification
  35. type NotificationIndex = {
  36. session: {
  37. all: Record<string, Notification[]>
  38. unseen: Record<string, Notification[]>
  39. unseenCount: Record<string, number>
  40. unseenHasError: Record<string, boolean>
  41. }
  42. project: {
  43. all: Record<string, Notification[]>
  44. unseen: Record<string, Notification[]>
  45. unseenCount: Record<string, number>
  46. unseenHasError: Record<string, boolean>
  47. }
  48. }
  49. const MAX_NOTIFICATIONS = 500
  50. const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
  51. function pruneNotifications(list: Notification[]) {
  52. const cutoff = Date.now() - NOTIFICATION_TTL_MS
  53. const pruned = list.filter((n) => n.time >= cutoff)
  54. if (pruned.length <= MAX_NOTIFICATIONS) return pruned
  55. return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
  56. }
  57. function createNotificationIndex(): NotificationIndex {
  58. return {
  59. session: {
  60. all: {},
  61. unseen: {},
  62. unseenCount: {},
  63. unseenHasError: {},
  64. },
  65. project: {
  66. all: {},
  67. unseen: {},
  68. unseenCount: {},
  69. unseenHasError: {},
  70. },
  71. }
  72. }
  73. function buildNotificationIndex(list: Notification[]) {
  74. const index = createNotificationIndex()
  75. list.forEach((notification) => {
  76. if (notification.session) {
  77. const all = index.session.all[notification.session] ?? []
  78. index.session.all[notification.session] = [...all, notification]
  79. if (!notification.viewed) {
  80. const unseen = index.session.unseen[notification.session] ?? []
  81. index.session.unseen[notification.session] = [...unseen, notification]
  82. index.session.unseenCount[notification.session] = unseen.length + 1
  83. if (notification.type === "error") index.session.unseenHasError[notification.session] = true
  84. }
  85. }
  86. if (notification.directory) {
  87. const all = index.project.all[notification.directory] ?? []
  88. index.project.all[notification.directory] = [...all, notification]
  89. if (!notification.viewed) {
  90. const unseen = index.project.unseen[notification.directory] ?? []
  91. index.project.unseen[notification.directory] = [...unseen, notification]
  92. index.project.unseenCount[notification.directory] = unseen.length + 1
  93. if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
  94. }
  95. }
  96. })
  97. return index
  98. }
  99. export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
  100. name: "Notification",
  101. gate: false,
  102. init: () => {
  103. const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
  104. const [search] = useSearchParams<{ draftId?: string }>()
  105. const global = useGlobal()
  106. const server = useServer()
  107. const tabs = useTabs()
  108. const platform = usePlatform()
  109. const settings = useSettings()
  110. const language = useLanguage()
  111. const owner = getOwner()
  112. const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
  113. const activeServer = createMemo(() => {
  114. if (params.serverKey) return requireServerKey(params.serverKey)
  115. if (search.draftId) {
  116. const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
  117. if (draft) return draft.server
  118. }
  119. return server.key
  120. })
  121. const activeDirectory = createMemo(() => decode64(params.dir))
  122. const activeSession = createMemo(() => params.id)
  123. const ensure = (key: ServerConnection.Key) => {
  124. const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
  125. if (!conn) throw new Error(`Notification server not found: ${key}`)
  126. const ctx = global.ensureServerCtx(conn)
  127. const existing = states.get(ctx.sdk.scope)
  128. if (existing) return existing.state
  129. const root = createRoot(
  130. (dispose) => ({
  131. dispose,
  132. state: createServerNotificationState({
  133. sdk: ctx.sdk,
  134. sync: ctx.sync,
  135. active: () => server.scope(activeServer()) === ctx.sdk.scope,
  136. directory: activeDirectory,
  137. sessionID: activeSession,
  138. platform,
  139. settings,
  140. language,
  141. }),
  142. }),
  143. owner ?? undefined,
  144. )
  145. states.set(ctx.sdk.scope, root)
  146. return root.state
  147. }
  148. createEffect(() => {
  149. global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
  150. })
  151. createEffect(() => {
  152. const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
  153. states.forEach((value, scope) => {
  154. if (scopes.has(scope)) return
  155. value.dispose()
  156. states.delete(scope)
  157. })
  158. })
  159. onCleanup(() => states.forEach((value) => value.dispose()))
  160. const selected = () => ensure(activeServer())
  161. return {
  162. ready: () => selected().ready(),
  163. ensureServerState: ensure,
  164. session: {
  165. all: (session: string) => selected().session.all(session),
  166. unseen: (session: string) => selected().session.unseen(session),
  167. unseenCount: (session: string) => selected().session.unseenCount(session),
  168. unseenHasError: (session: string) => selected().session.unseenHasError(session),
  169. markViewed: (session: string) => selected().session.markViewed(session),
  170. },
  171. project: {
  172. all: (directory: string) => selected().project.all(directory),
  173. unseen: (directory: string) => selected().project.unseen(directory),
  174. unseenCount: (directory: string) => selected().project.unseenCount(directory),
  175. unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
  176. markViewed: (directory: string) => selected().project.markViewed(directory),
  177. },
  178. }
  179. },
  180. })
  181. type NotificationState = ReturnType<typeof createServerNotificationState>
  182. function createServerNotificationState(input: {
  183. sdk: ServerSDK
  184. sync: ServerSync
  185. active: Accessor<boolean>
  186. directory: Accessor<string | undefined>
  187. sessionID: Accessor<string | undefined>
  188. platform: ReturnType<typeof usePlatform>
  189. settings: ReturnType<typeof useSettings>
  190. language: ReturnType<typeof useLanguage>
  191. }) {
  192. const serverSDK = () => input.sdk
  193. const serverSync = () => input.sync
  194. const platform = input.platform
  195. const settings = input.settings
  196. const language = input.language
  197. const empty: Notification[] = []
  198. const currentDirectory = input.directory
  199. const currentSession = input.sessionID
  200. const [store, setStore, _, ready] = persisted(
  201. Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
  202. createStore({
  203. list: [] as Notification[],
  204. }),
  205. )
  206. const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
  207. const meta = { pruned: false, disposed: false }
  208. const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
  209. setIndex(scope, "unseen", key, unseen)
  210. setIndex(scope, "unseenCount", key, unseen.length)
  211. setIndex(
  212. scope,
  213. "unseenHasError",
  214. key,
  215. unseen.some((notification) => notification.type === "error"),
  216. )
  217. }
  218. const appendToIndex = (notification: Notification) => {
  219. if (notification.session) {
  220. setIndex("session", "all", notification.session, (all = []) => [...all, notification])
  221. if (!notification.viewed) {
  222. setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
  223. setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
  224. if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
  225. }
  226. }
  227. if (notification.directory) {
  228. setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
  229. if (!notification.viewed) {
  230. setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
  231. setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
  232. if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
  233. }
  234. }
  235. }
  236. const removeFromIndex = (notification: Notification) => {
  237. if (notification.session) {
  238. setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
  239. if (!notification.viewed) {
  240. const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
  241. updateUnseen("session", notification.session, unseen)
  242. }
  243. }
  244. if (notification.directory) {
  245. setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
  246. if (!notification.viewed) {
  247. const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
  248. updateUnseen("project", notification.directory, unseen)
  249. }
  250. }
  251. }
  252. createEffect(() => {
  253. if (!ready()) return
  254. if (meta.pruned) return
  255. meta.pruned = true
  256. const list = pruneNotifications(store.list)
  257. batch(() => {
  258. setStore("list", list)
  259. setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
  260. })
  261. })
  262. const append = (notification: Notification) => {
  263. const list = pruneNotifications([...store.list, notification])
  264. const keep = new Set(list)
  265. const removed = store.list.filter((n) => !keep.has(n))
  266. batch(() => {
  267. if (keep.has(notification)) appendToIndex(notification)
  268. removed.forEach((n) => removeFromIndex(n))
  269. setStore("list", list)
  270. })
  271. }
  272. const lookup = async (directory: string, sessionID?: string) => {
  273. if (!sessionID) return undefined
  274. const sync = serverSync().ensureDirSyncContext(directory)
  275. const session = sync.session.get(sessionID)
  276. if (session) return session
  277. return sync.session
  278. .sync(sessionID)
  279. .then(() => sync.session.get(sessionID))
  280. .catch(() => undefined)
  281. }
  282. const viewedInCurrentSession = (directory: string, sessionID?: string) => {
  283. if (!input.active()) return false
  284. const activeDirectory = currentDirectory()
  285. const activeSession = currentSession()
  286. if (!activeSession) return false
  287. if (!sessionID) return false
  288. if (activeDirectory && directory !== activeDirectory) return false
  289. return sessionID === activeSession
  290. }
  291. const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
  292. const sessionID = event.properties.sessionID
  293. void lookup(directory, sessionID).then((session) => {
  294. if (meta.disposed) return
  295. if (!session) return
  296. if (session.parentID) return
  297. if (settings.sounds.agentEnabled()) {
  298. void playSoundById(settings.sounds.agent())
  299. }
  300. append({
  301. directory,
  302. time,
  303. viewed: viewedInCurrentSession(directory, sessionID),
  304. type: "turn-complete",
  305. session: sessionID,
  306. })
  307. const href = `/${base64Encode(directory)}/session/${sessionID}`
  308. if (settings.notifications.agent()) {
  309. void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
  310. }
  311. })
  312. }
  313. const handleSessionError = (
  314. directory: string,
  315. event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
  316. time: number,
  317. ) => {
  318. const sessionID = event.properties.sessionID
  319. void lookup(directory, sessionID).then((session) => {
  320. if (meta.disposed) return
  321. if (session?.parentID) return
  322. if (settings.sounds.errorsEnabled()) {
  323. void playSoundById(settings.sounds.errors())
  324. }
  325. const error = "error" in event.properties ? event.properties.error : undefined
  326. append({
  327. directory,
  328. time,
  329. viewed: viewedInCurrentSession(directory, sessionID),
  330. type: "error",
  331. session: sessionID ?? "global",
  332. error,
  333. })
  334. const description =
  335. session?.title ??
  336. (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
  337. const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
  338. if (settings.notifications.errors()) {
  339. void platform.notify(language.t("notification.session.error.title"), description, href)
  340. }
  341. })
  342. }
  343. const unsub = serverSDK().event.listen((e) => {
  344. const event = e.details
  345. if (event.type !== "session.idle" && event.type !== "session.error") return
  346. const directory = e.name
  347. const time = Date.now()
  348. if (event.type === "session.idle") {
  349. handleSessionIdle(directory, event, time)
  350. return
  351. }
  352. handleSessionError(directory, event, time)
  353. })
  354. onCleanup(() => {
  355. meta.disposed = true
  356. unsub()
  357. })
  358. return {
  359. ready,
  360. session: {
  361. all(session: string) {
  362. return index.session.all[session] ?? empty
  363. },
  364. unseen(session: string) {
  365. return index.session.unseen[session] ?? empty
  366. },
  367. unseenCount(session: string) {
  368. return index.session.unseenCount[session] ?? 0
  369. },
  370. unseenHasError(session: string) {
  371. return index.session.unseenHasError[session] ?? false
  372. },
  373. markViewed(session: string) {
  374. const unseen = index.session.unseen[session] ?? empty
  375. if (!unseen.length) return
  376. const projects = [
  377. ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
  378. ]
  379. batch(() => {
  380. setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
  381. updateUnseen("session", session, [])
  382. projects.forEach((directory) => {
  383. const next = (index.project.unseen[directory] ?? empty).filter(
  384. (notification) => notification.session !== session,
  385. )
  386. updateUnseen("project", directory, next)
  387. })
  388. })
  389. },
  390. },
  391. project: {
  392. all(directory: string) {
  393. return index.project.all[directory] ?? empty
  394. },
  395. unseen(directory: string) {
  396. return index.project.unseen[directory] ?? empty
  397. },
  398. unseenCount(directory: string) {
  399. return index.project.unseenCount[directory] ?? 0
  400. },
  401. unseenHasError(directory: string) {
  402. return index.project.unseenHasError[directory] ?? false
  403. },
  404. markViewed(directory: string) {
  405. const unseen = index.project.unseen[directory] ?? empty
  406. if (!unseen.length) return
  407. const sessions = [
  408. ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
  409. ]
  410. batch(() => {
  411. setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
  412. updateUnseen("project", directory, [])
  413. sessions.forEach((session) => {
  414. const next = (index.session.unseen[session] ?? empty).filter(
  415. (notification) => notification.directory !== directory,
  416. )
  417. updateUnseen("session", session, next)
  418. })
  419. })
  420. },
  421. },
  422. }
  423. }