| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- import { createStore, reconcile } from "solid-js/store"
- import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
- import { useParams, useSearchParams } from "@solidjs/router"
- import { createSimpleContext } from "@kirincode-ai/ui/context"
- import type { ServerSDK } from "./server-sdk"
- import type { ServerSync } from "./server-sync"
- import { usePlatform } from "@/context/platform"
- import { useLanguage } from "@/context/language"
- import { useSettings } from "@/context/settings"
- import { base64Encode } from "@kirincode-ai/core/util/encode"
- import { decode64 } from "@/utils/base64"
- import { EventSessionError } from "@kirincode-ai/sdk/v2"
- import { Persist, persisted } from "@/utils/persist"
- import { playSoundById } from "@/utils/sound"
- import { useGlobal } from "./global"
- import { ServerConnection, useServer } from "./server"
- import { type DraftTab, useTabs } from "./tabs"
- import { requireServerKey } from "@/utils/session-route"
- import type { ServerScope } from "@/utils/server-scope"
- type NotificationBase = {
- directory?: string
- session?: string
- metadata?: unknown
- time: number
- viewed: boolean
- }
- type TurnCompleteNotification = NotificationBase & {
- type: "turn-complete"
- }
- type ErrorNotification = NotificationBase & {
- type: "error"
- error: EventSessionError["properties"]["error"]
- }
- export type Notification = TurnCompleteNotification | ErrorNotification
- type NotificationIndex = {
- session: {
- all: Record<string, Notification[]>
- unseen: Record<string, Notification[]>
- unseenCount: Record<string, number>
- unseenHasError: Record<string, boolean>
- }
- project: {
- all: Record<string, Notification[]>
- unseen: Record<string, Notification[]>
- unseenCount: Record<string, number>
- unseenHasError: Record<string, boolean>
- }
- }
- const MAX_NOTIFICATIONS = 500
- const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
- function pruneNotifications(list: Notification[]) {
- const cutoff = Date.now() - NOTIFICATION_TTL_MS
- const pruned = list.filter((n) => n.time >= cutoff)
- if (pruned.length <= MAX_NOTIFICATIONS) return pruned
- return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
- }
- function createNotificationIndex(): NotificationIndex {
- return {
- session: {
- all: {},
- unseen: {},
- unseenCount: {},
- unseenHasError: {},
- },
- project: {
- all: {},
- unseen: {},
- unseenCount: {},
- unseenHasError: {},
- },
- }
- }
- function buildNotificationIndex(list: Notification[]) {
- const index = createNotificationIndex()
- list.forEach((notification) => {
- if (notification.session) {
- const all = index.session.all[notification.session] ?? []
- index.session.all[notification.session] = [...all, notification]
- if (!notification.viewed) {
- const unseen = index.session.unseen[notification.session] ?? []
- index.session.unseen[notification.session] = [...unseen, notification]
- index.session.unseenCount[notification.session] = unseen.length + 1
- if (notification.type === "error") index.session.unseenHasError[notification.session] = true
- }
- }
- if (notification.directory) {
- const all = index.project.all[notification.directory] ?? []
- index.project.all[notification.directory] = [...all, notification]
- if (!notification.viewed) {
- const unseen = index.project.unseen[notification.directory] ?? []
- index.project.unseen[notification.directory] = [...unseen, notification]
- index.project.unseenCount[notification.directory] = unseen.length + 1
- if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
- }
- }
- })
- return index
- }
- export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
- name: "Notification",
- gate: false,
- init: () => {
- const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
- const [search] = useSearchParams<{ draftId?: string }>()
- const global = useGlobal()
- const server = useServer()
- const tabs = useTabs()
- const platform = usePlatform()
- const settings = useSettings()
- const language = useLanguage()
- const owner = getOwner()
- const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
- const activeServer = createMemo(() => {
- if (params.serverKey) return requireServerKey(params.serverKey)
- if (search.draftId) {
- const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
- if (draft) return draft.server
- }
- return server.key
- })
- const activeDirectory = createMemo(() => decode64(params.dir))
- const activeSession = createMemo(() => params.id)
- const ensure = (key: ServerConnection.Key) => {
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
- if (!conn) throw new Error(`Notification server not found: ${key}`)
- const ctx = global.ensureServerCtx(conn)
- const existing = states.get(ctx.sdk.scope)
- if (existing) return existing.state
- const root = createRoot(
- (dispose) => ({
- dispose,
- state: createServerNotificationState({
- sdk: ctx.sdk,
- sync: ctx.sync,
- active: () => server.scope(activeServer()) === ctx.sdk.scope,
- directory: activeDirectory,
- sessionID: activeSession,
- platform,
- settings,
- language,
- }),
- }),
- owner ?? undefined,
- )
- states.set(ctx.sdk.scope, root)
- return root.state
- }
- createEffect(() => {
- global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
- })
- createEffect(() => {
- const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
- states.forEach((value, scope) => {
- if (scopes.has(scope)) return
- value.dispose()
- states.delete(scope)
- })
- })
- onCleanup(() => states.forEach((value) => value.dispose()))
- const selected = () => ensure(activeServer())
- return {
- ready: () => selected().ready(),
- ensureServerState: ensure,
- session: {
- all: (session: string) => selected().session.all(session),
- unseen: (session: string) => selected().session.unseen(session),
- unseenCount: (session: string) => selected().session.unseenCount(session),
- unseenHasError: (session: string) => selected().session.unseenHasError(session),
- markViewed: (session: string) => selected().session.markViewed(session),
- },
- project: {
- all: (directory: string) => selected().project.all(directory),
- unseen: (directory: string) => selected().project.unseen(directory),
- unseenCount: (directory: string) => selected().project.unseenCount(directory),
- unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
- markViewed: (directory: string) => selected().project.markViewed(directory),
- },
- }
- },
- })
- type NotificationState = ReturnType<typeof createServerNotificationState>
- function createServerNotificationState(input: {
- sdk: ServerSDK
- sync: ServerSync
- active: Accessor<boolean>
- directory: Accessor<string | undefined>
- sessionID: Accessor<string | undefined>
- platform: ReturnType<typeof usePlatform>
- settings: ReturnType<typeof useSettings>
- language: ReturnType<typeof useLanguage>
- }) {
- const serverSDK = () => input.sdk
- const serverSync = () => input.sync
- const platform = input.platform
- const settings = input.settings
- const language = input.language
- const empty: Notification[] = []
- const currentDirectory = input.directory
- const currentSession = input.sessionID
- const [store, setStore, _, ready] = persisted(
- Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
- createStore({
- list: [] as Notification[],
- }),
- )
- const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
- const meta = { pruned: false, disposed: false }
- const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
- setIndex(scope, "unseen", key, unseen)
- setIndex(scope, "unseenCount", key, unseen.length)
- setIndex(
- scope,
- "unseenHasError",
- key,
- unseen.some((notification) => notification.type === "error"),
- )
- }
- const appendToIndex = (notification: Notification) => {
- if (notification.session) {
- setIndex("session", "all", notification.session, (all = []) => [...all, notification])
- if (!notification.viewed) {
- setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
- setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
- if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
- }
- }
- if (notification.directory) {
- setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
- if (!notification.viewed) {
- setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
- setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
- if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
- }
- }
- }
- const removeFromIndex = (notification: Notification) => {
- if (notification.session) {
- setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
- if (!notification.viewed) {
- const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
- updateUnseen("session", notification.session, unseen)
- }
- }
- if (notification.directory) {
- setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
- if (!notification.viewed) {
- const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
- updateUnseen("project", notification.directory, unseen)
- }
- }
- }
- createEffect(() => {
- if (!ready()) return
- if (meta.pruned) return
- meta.pruned = true
- const list = pruneNotifications(store.list)
- batch(() => {
- setStore("list", list)
- setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
- })
- })
- const append = (notification: Notification) => {
- const list = pruneNotifications([...store.list, notification])
- const keep = new Set(list)
- const removed = store.list.filter((n) => !keep.has(n))
- batch(() => {
- if (keep.has(notification)) appendToIndex(notification)
- removed.forEach((n) => removeFromIndex(n))
- setStore("list", list)
- })
- }
- const lookup = async (directory: string, sessionID?: string) => {
- if (!sessionID) return undefined
- const sync = serverSync().ensureDirSyncContext(directory)
- const session = sync.session.get(sessionID)
- if (session) return session
- return sync.session
- .sync(sessionID)
- .then(() => sync.session.get(sessionID))
- .catch(() => undefined)
- }
- const viewedInCurrentSession = (directory: string, sessionID?: string) => {
- if (!input.active()) return false
- const activeDirectory = currentDirectory()
- const activeSession = currentSession()
- if (!activeSession) return false
- if (!sessionID) return false
- if (activeDirectory && directory !== activeDirectory) return false
- return sessionID === activeSession
- }
- const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
- const sessionID = event.properties.sessionID
- void lookup(directory, sessionID).then((session) => {
- if (meta.disposed) return
- if (!session) return
- if (session.parentID) return
- if (settings.sounds.agentEnabled()) {
- void playSoundById(settings.sounds.agent())
- }
- append({
- directory,
- time,
- viewed: viewedInCurrentSession(directory, sessionID),
- type: "turn-complete",
- session: sessionID,
- })
- const href = `/${base64Encode(directory)}/session/${sessionID}`
- if (settings.notifications.agent()) {
- void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
- }
- })
- }
- const handleSessionError = (
- directory: string,
- event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
- time: number,
- ) => {
- const sessionID = event.properties.sessionID
- void lookup(directory, sessionID).then((session) => {
- if (meta.disposed) return
- if (session?.parentID) return
- if (settings.sounds.errorsEnabled()) {
- void playSoundById(settings.sounds.errors())
- }
- const error = "error" in event.properties ? event.properties.error : undefined
- append({
- directory,
- time,
- viewed: viewedInCurrentSession(directory, sessionID),
- type: "error",
- session: sessionID ?? "global",
- error,
- })
- const description =
- session?.title ??
- (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
- const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
- if (settings.notifications.errors()) {
- void platform.notify(language.t("notification.session.error.title"), description, href)
- }
- })
- }
- const unsub = serverSDK().event.listen((e) => {
- const event = e.details
- if (event.type !== "session.idle" && event.type !== "session.error") return
- const directory = e.name
- const time = Date.now()
- if (event.type === "session.idle") {
- handleSessionIdle(directory, event, time)
- return
- }
- handleSessionError(directory, event, time)
- })
- onCleanup(() => {
- meta.disposed = true
- unsub()
- })
- return {
- ready,
- session: {
- all(session: string) {
- return index.session.all[session] ?? empty
- },
- unseen(session: string) {
- return index.session.unseen[session] ?? empty
- },
- unseenCount(session: string) {
- return index.session.unseenCount[session] ?? 0
- },
- unseenHasError(session: string) {
- return index.session.unseenHasError[session] ?? false
- },
- markViewed(session: string) {
- const unseen = index.session.unseen[session] ?? empty
- if (!unseen.length) return
- const projects = [
- ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
- ]
- batch(() => {
- setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
- updateUnseen("session", session, [])
- projects.forEach((directory) => {
- const next = (index.project.unseen[directory] ?? empty).filter(
- (notification) => notification.session !== session,
- )
- updateUnseen("project", directory, next)
- })
- })
- },
- },
- project: {
- all(directory: string) {
- return index.project.all[directory] ?? empty
- },
- unseen(directory: string) {
- return index.project.unseen[directory] ?? empty
- },
- unseenCount(directory: string) {
- return index.project.unseenCount[directory] ?? 0
- },
- unseenHasError(directory: string) {
- return index.project.unseenHasError[directory] ?? false
- },
- markViewed(directory: string) {
- const unseen = index.project.unseen[directory] ?? empty
- if (!unseen.length) return
- const sessions = [
- ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
- ]
- batch(() => {
- setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
- updateUnseen("project", directory, [])
- sessions.forEach((session) => {
- const next = (index.session.unseen[session] ?? empty).filter(
- (notification) => notification.directory !== directory,
- )
- updateUnseen("session", session, next)
- })
- })
- },
- },
- }
- }
|