chore: add message store v2 baseline
This commit is contained in:
422
packages/ui/src/components/message-stream-v2.tsx
Normal file
422
packages/ui/src/components/message-stream-v2.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import { For, Show, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
|
||||||
|
import MessageItem from "./message-item"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
import type { Message, MessageInfo, ClientPart } from "../types/message"
|
||||||
|
import { computeDisplayParts } from "../stores/session-messages"
|
||||||
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { getScrollCache, setScrollCache } from "../lib/scroll-cache"
|
||||||
|
import { sseManager } from "../lib/sse-manager"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
|
|
||||||
|
const SCROLL_SCOPE = "session"
|
||||||
|
const TOOL_ICON = "🔧"
|
||||||
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
|
function formatTokens(tokens: number): string {
|
||||||
|
return formatTokenTotal(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface MessageStreamV2Props {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageDisplayItem {
|
||||||
|
type: "message"
|
||||||
|
message: Message
|
||||||
|
combinedParts: ClientPart[]
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
isQueued: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDisplayItem {
|
||||||
|
type: "tool"
|
||||||
|
key: string
|
||||||
|
toolPart: ToolCallPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
messageId: string
|
||||||
|
messageVersion: number
|
||||||
|
partVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayItem = MessageDisplayItem | ToolDisplayItem
|
||||||
|
|
||||||
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
function isToolPart(part: ClientPart): part is ToolCallPart {
|
||||||
|
return part.type === "tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToMessage(record: MessageRecord): Message {
|
||||||
|
const parts = record.partIds
|
||||||
|
.map((partId) => record.parts[partId]?.data)
|
||||||
|
.filter((part): part is ClientPart => Boolean(part))
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
type: record.role,
|
||||||
|
parts,
|
||||||
|
timestamp: record.createdAt,
|
||||||
|
status: record.status,
|
||||||
|
version: record.revision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRenderableContent(message: Message, combinedParts: ClientPart[], info?: MessageInfo): boolean {
|
||||||
|
if (message.type !== "assistant" && message.type !== "user") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (message.type !== "assistant" || combinedParts.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (info && info.role === "assistant" && info.error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return message.status === "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
const messageRecords = createMemo(() =>
|
||||||
|
messageIds()
|
||||||
|
.map((id) => store().getMessage(id))
|
||||||
|
.filter((record): record is MessageRecord => Boolean(record)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||||
|
const sessionInfo = createMemo(() =>
|
||||||
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
|
cost: 0,
|
||||||
|
contextWindow: 0,
|
||||||
|
isSubscriptionModel: false,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = usageSnapshot()
|
||||||
|
const info = sessionInfo()
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||||
|
avail: info.contextAvailableTokens,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||||
|
const handleCommandPaletteClick = () => {
|
||||||
|
showCommandPalette(props.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageInfoMap = createMemo(() => {
|
||||||
|
const map = new Map<string, MessageInfo>()
|
||||||
|
messageIds().forEach((id) => {
|
||||||
|
const info = store().getMessageInfo(id)
|
||||||
|
if (info) {
|
||||||
|
map.set(id, info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId))
|
||||||
|
|
||||||
|
const displayItems = createMemo<DisplayItem[]>(() => {
|
||||||
|
const infoMap = messageInfoMap()
|
||||||
|
const showThinking = preferences().showThinkingBlocks
|
||||||
|
const revert = revertTarget()
|
||||||
|
const items: DisplayItem[] = []
|
||||||
|
|
||||||
|
const records = messageRecords()
|
||||||
|
let lastAssistantIndex = -1
|
||||||
|
for (let i = records.length - 1; i >= 0; i--) {
|
||||||
|
if (records[i].role === "assistant") {
|
||||||
|
lastAssistantIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < records.length; index++) {
|
||||||
|
const record = records[index]
|
||||||
|
if (revert?.messageID && record.id === revert.messageID) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMessage = recordToMessage(record)
|
||||||
|
const displayParts = computeDisplayParts(baseMessage, showThinking)
|
||||||
|
baseMessage.displayParts = displayParts
|
||||||
|
const combinedParts = displayParts.combined
|
||||||
|
const messageInfo = infoMap.get(record.id)
|
||||||
|
const isQueued =
|
||||||
|
baseMessage.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
|
||||||
|
|
||||||
|
if (hasRenderableContent(baseMessage, combinedParts, messageInfo)) {
|
||||||
|
items.push({
|
||||||
|
type: "message",
|
||||||
|
message: baseMessage,
|
||||||
|
combinedParts,
|
||||||
|
messageInfo,
|
||||||
|
isQueued,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolParts: ToolCallPart[] = displayParts.tool.filter(isToolPart)
|
||||||
|
toolParts.forEach((toolPart, toolIndex) => {
|
||||||
|
const partVersion = typeof toolPart.version === "number" ? toolPart.version : 0
|
||||||
|
const messageVersion = typeof baseMessage.version === "number" ? baseMessage.version : 0
|
||||||
|
const key = toolPart.id || `${record.id}-tool-${toolIndex}`
|
||||||
|
items.push({
|
||||||
|
type: "tool",
|
||||||
|
key,
|
||||||
|
toolPart,
|
||||||
|
messageInfo,
|
||||||
|
messageId: record.id,
|
||||||
|
messageVersion,
|
||||||
|
partVersion,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeToken = createMemo(() => {
|
||||||
|
const entries = displayItems()
|
||||||
|
return entries
|
||||||
|
.map((item) => {
|
||||||
|
if (item.type === "message") {
|
||||||
|
return `${item.message.id}:${item.message.version}:${item.combinedParts.length}`
|
||||||
|
}
|
||||||
|
const status = item.toolPart.state?.status || "unknown"
|
||||||
|
return `tool:${item.key}:${item.partVersion}:${status}`
|
||||||
|
})
|
||||||
|
.join("|")
|
||||||
|
})
|
||||||
|
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||||
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
function isNearBottom(element: HTMLDivElement, offset = 48) {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element
|
||||||
|
return scrollHeight - (scrollTop + clientHeight) <= offset
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(immediate = false) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
|
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||||
|
setAutoScroll(true)
|
||||||
|
persistScrollState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollState() {
|
||||||
|
if (!containerRef) return
|
||||||
|
setScrollCache(
|
||||||
|
{ instanceId: props.instanceId, sessionId: props.sessionId, scope: SCROLL_SCOPE },
|
||||||
|
{
|
||||||
|
scrollTop: containerRef.scrollTop,
|
||||||
|
atBottom: isNearBottom(containerRef),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(event: Event) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const atBottom = isNearBottom(containerRef)
|
||||||
|
setShowScrollButton(!atBottom)
|
||||||
|
if (event.isTrusted) {
|
||||||
|
setAutoScroll(atBottom)
|
||||||
|
}
|
||||||
|
persistScrollState()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const scrollSnapshot = getScrollCache({ instanceId: props.instanceId, sessionId: props.sessionId, scope: SCROLL_SCOPE })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!containerRef) return
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||||
|
containerRef.scrollTop = Math.min(scrollSnapshot.scrollTop, maxScrollTop)
|
||||||
|
setAutoScroll(scrollSnapshot.atBottom)
|
||||||
|
setShowScrollButton(!scrollSnapshot.atBottom)
|
||||||
|
} else {
|
||||||
|
scrollToBottom(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let previousToken: string | undefined
|
||||||
|
createEffect(() => {
|
||||||
|
const token = changeToken()
|
||||||
|
if (!token || token === previousToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previousToken = token
|
||||||
|
if (autoScroll()) {
|
||||||
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (displayItems().length === 0) {
|
||||||
|
setShowScrollButton(false)
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
persistScrollState()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-stream-container">
|
||||||
|
<div class="connection-status">
|
||||||
|
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokens(tokenStats().used)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||||
|
<span class="font-semibold text-primary">
|
||||||
|
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
|
<div class="connection-status-shortcut-action">
|
||||||
|
<button type="button" class="connection-status-button" onClick={handleCommandPaletteClick} aria-label="Open command palette">
|
||||||
|
Command Palette
|
||||||
|
</button>
|
||||||
|
<span class="connection-status-shortcut-hint">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||||
|
<Show when={connectionStatus() === "connected"}>
|
||||||
|
<span class="status-indicator connected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={connectionStatus() === "connecting"}>
|
||||||
|
<span class="status-indicator connecting">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Connecting...
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||||
|
<span class="status-indicator disconnected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="message-stream"
|
||||||
|
ref={(element) => {
|
||||||
|
containerRef = element || undefined
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<Show when={!props.loading && displayItems().length === 0}>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-content">
|
||||||
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||||
|
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
|
</div>
|
||||||
|
<h3>Start a conversation</h3>
|
||||||
|
<p>Type a message below or open the Command Palette:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
|
</li>
|
||||||
|
<li>Ask about your codebase</li>
|
||||||
|
<li>
|
||||||
|
Attach files with <code>@</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<p>Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={displayItems()}>
|
||||||
|
{(item) => {
|
||||||
|
if (item.type === "message") {
|
||||||
|
return (
|
||||||
|
<MessageItem
|
||||||
|
message={item.message}
|
||||||
|
messageInfo={item.messageInfo}
|
||||||
|
parts={item.combinedParts}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
isQueued={item.isQueued}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="tool-call-message" data-key={item.key}>
|
||||||
|
<div class="tool-call-header-label">
|
||||||
|
<div class="tool-call-header-meta">
|
||||||
|
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||||
|
<span>Tool Call</span>
|
||||||
|
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToolCall
|
||||||
|
toolCall={item.toolPart}
|
||||||
|
toolCallId={item.key}
|
||||||
|
messageId={item.messageId}
|
||||||
|
messageVersion={item.messageVersion}
|
||||||
|
partVersion={item.partVersion}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showScrollButton()}>
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label="Scroll to latest message">
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,17 @@ import { Show, createMemo, createEffect, onCleanup, type Component } from "solid
|
|||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageStream from "../message-stream"
|
import MessageStreamV2 from "../message-stream-v2"
|
||||||
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
|
|
||||||
|
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
|
||||||
|
return part?.type === "text" && typeof (part as any).text === "string"
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionViewProps {
|
interface SessionViewProps {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
activeSessions: Map<string, Session>
|
activeSessions: Map<string, Session>
|
||||||
@@ -19,6 +24,7 @@ interface SessionViewProps {
|
|||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||||
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const currentSession = session()
|
const currentSession = session()
|
||||||
@@ -36,6 +42,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUserMessageText(messageId: string): string | null {
|
function getUserMessageText(messageId: string): string | null {
|
||||||
|
const normalizedMessage = messageStore().getMessage(messageId)
|
||||||
|
if (normalizedMessage && normalizedMessage.role === "user") {
|
||||||
|
const parts = normalizedMessage.partIds
|
||||||
|
.map((partId) => normalizedMessage.parts[partId]?.data)
|
||||||
|
.filter((part): part is ClientPart => Boolean(part))
|
||||||
|
const textParts = parts.filter(isTextPart)
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
return textParts.map((part) => part.text).join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentSession = session()
|
const currentSession = session()
|
||||||
if (!currentSession) return null
|
if (!currentSession) return null
|
||||||
|
|
||||||
@@ -45,7 +62,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
|
const textParts = targetMessage.parts.filter(isTextPart)
|
||||||
if (textParts.length === 0) {
|
if (textParts.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -129,12 +146,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<div class="session-view">
|
<div class="session-view">
|
||||||
<MessageStream
|
<MessageStreamV2
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={s().id}
|
sessionId={s().id}
|
||||||
messages={s().messages || []}
|
|
||||||
messagesInfo={s().messagesInfo}
|
|
||||||
revert={s().revert}
|
|
||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
@@ -346,7 +347,15 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const { preferences, setDiffViewMode } = useConfig()
|
const { preferences, setDiffViewMode } = useConfig()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||||
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallId() || props.toolCall?.id))
|
||||||
|
const pendingPermission = createMemo(() => {
|
||||||
|
const state = permissionState()
|
||||||
|
if (state) {
|
||||||
|
return { permission: state.entry.permission, active: state.active }
|
||||||
|
}
|
||||||
|
return props.toolCall.pendingPermission
|
||||||
|
})
|
||||||
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
|
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
|
||||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
|
|||||||
97
packages/ui/src/lib/global-cache.ts
Normal file
97
packages/ui/src/lib/global-cache.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
interface CacheLocation {
|
||||||
|
instanceId?: string
|
||||||
|
sessionId?: string
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_KEY = "GLOBAL"
|
||||||
|
|
||||||
|
type CacheScope = Map<string, unknown>
|
||||||
|
type ScopeCollection = Map<string, CacheScope>
|
||||||
|
type SessionMap = Map<string, ScopeCollection>
|
||||||
|
const cacheRoot = new Map<string, SessionMap>()
|
||||||
|
|
||||||
|
function resolveKey(value?: string) {
|
||||||
|
return value && value.length > 0 ? value : GLOBAL_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCacheScope(location: CacheLocation, createIfMissing: boolean): CacheScope | undefined {
|
||||||
|
const instanceKey = resolveKey(location.instanceId)
|
||||||
|
const sessionKey = resolveKey(location.sessionId)
|
||||||
|
const scopeKey = resolveKey(location.scope)
|
||||||
|
|
||||||
|
let sessionMap = cacheRoot.get(instanceKey)
|
||||||
|
if (!sessionMap) {
|
||||||
|
if (!createIfMissing) return undefined
|
||||||
|
sessionMap = new Map()
|
||||||
|
cacheRoot.set(instanceKey, sessionMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopeCollection = sessionMap.get(sessionKey)
|
||||||
|
if (!scopeCollection) {
|
||||||
|
if (!createIfMissing) return undefined
|
||||||
|
scopeCollection = new Map()
|
||||||
|
sessionMap.set(sessionKey, scopeCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cacheScope = scopeCollection.get(scopeKey)
|
||||||
|
if (!cacheScope) {
|
||||||
|
if (!createIfMissing) return undefined
|
||||||
|
cacheScope = new Map()
|
||||||
|
scopeCollection.set(scopeKey, cacheScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGlobalCacheValue(location: CacheLocation, key: string, value: unknown): void {
|
||||||
|
const cacheScope = resolveCacheScope(location, true)
|
||||||
|
cacheScope?.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGlobalCacheValue<T = unknown>(location: CacheLocation, key: string): T | undefined {
|
||||||
|
const cacheScope = resolveCacheScope(location, false)
|
||||||
|
return (cacheScope?.get(key) as T | undefined) ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGlobalCacheValue(location: CacheLocation, key: string): void {
|
||||||
|
const cacheScope = resolveCacheScope(location, false)
|
||||||
|
cacheScope?.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGlobalCacheScope(location: CacheLocation): void {
|
||||||
|
const instanceKey = resolveKey(location.instanceId)
|
||||||
|
const sessionKey = resolveKey(location.sessionId)
|
||||||
|
const scopeKey = resolveKey(location.scope)
|
||||||
|
const sessionMap = cacheRoot.get(instanceKey)
|
||||||
|
if (!sessionMap) return
|
||||||
|
const scopeCollection = sessionMap.get(sessionKey)
|
||||||
|
if (!scopeCollection) return
|
||||||
|
scopeCollection.delete(scopeKey)
|
||||||
|
if (scopeCollection.size === 0) {
|
||||||
|
sessionMap.delete(sessionKey)
|
||||||
|
}
|
||||||
|
if (sessionMap.size === 0) {
|
||||||
|
cacheRoot.delete(instanceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGlobalCacheSession(instanceId?: string, sessionId?: string): void {
|
||||||
|
const instanceKey = resolveKey(instanceId)
|
||||||
|
const sessionKey = resolveKey(sessionId)
|
||||||
|
const sessionMap = cacheRoot.get(instanceKey)
|
||||||
|
if (!sessionMap) return
|
||||||
|
sessionMap.delete(sessionKey)
|
||||||
|
if (sessionMap.size === 0) {
|
||||||
|
cacheRoot.delete(instanceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGlobalCacheInstance(instanceId?: string): void {
|
||||||
|
const instanceKey = resolveKey(instanceId)
|
||||||
|
cacheRoot.delete(instanceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllGlobalCache(): void {
|
||||||
|
cacheRoot.clear()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Accessor } from "solid-js"
|
|||||||
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
||||||
import { createCommandRegistry, type Command } from "../commands"
|
import { createCommandRegistry, type Command } from "../commands"
|
||||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||||
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
import {
|
import {
|
||||||
activeParentSessionId,
|
activeParentSessionId,
|
||||||
activeSessionId as activeSessionMap,
|
activeSessionId as activeSessionMap,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
|
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||||
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
|
|
||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
@@ -29,6 +32,18 @@ export interface UseCommandsOptions {
|
|||||||
getActiveSessionIdForInstance: () => string | null
|
getActiveSessionIdForInstance: () => string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractUserTextFromRecord(record?: MessageRecord): string | null {
|
||||||
|
if (!record) return null
|
||||||
|
const parts = record.partIds
|
||||||
|
.map((partId) => record.parts[partId]?.data)
|
||||||
|
.filter((part): part is ClientPart => Boolean(part))
|
||||||
|
const textParts = parts.filter((part): part is ClientPart & { type: "text"; text: string } => part.type === "text" && typeof (part as any).text === "string")
|
||||||
|
if (textParts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return textParts.map((part) => (part as any).text as string).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
export function useCommands(options: UseCommandsOptions) {
|
export function useCommands(options: UseCommandsOptions) {
|
||||||
const commandRegistry = createCommandRegistry()
|
const commandRegistry = createCommandRegistry()
|
||||||
const [commands, setCommands] = createSignal<Command[]>([])
|
const [commands, setCommands] = createSignal<Command[]>([])
|
||||||
@@ -232,34 +247,56 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
const session = sessions.find((s) => s.id === sessionId)
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
let after = 0
|
const store = messageStoreBus.getOrCreate(instance.id)
|
||||||
const revert = session.revert
|
const messageIds = store.getSessionMessageIds(sessionId)
|
||||||
|
const infoMap = new Map<string, MessageInfo>()
|
||||||
|
messageIds.forEach((id) => {
|
||||||
|
const info = store.getMessageInfo(id)
|
||||||
|
if (info) infoMap.set(id, info)
|
||||||
|
})
|
||||||
|
|
||||||
if (revert?.messageID) {
|
const revertState = store.getSessionRevert(sessionId) ?? session.revert
|
||||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
let after = 0
|
||||||
const msg = session.messages[i]
|
if (revertState?.messageID) {
|
||||||
const info = session.messagesInfo.get(msg.id)
|
const revertInfo = infoMap.get(revertState.messageID) ?? session.messagesInfo.get(revertState.messageID)
|
||||||
if (info?.id === revert.messageID) {
|
after = revertInfo?.time?.created || 0
|
||||||
after = info.time?.created || 0
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageID = ""
|
let messageID = ""
|
||||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
let restoredText: string | null = null
|
||||||
const msg = session.messages[i]
|
for (let i = messageIds.length - 1; i >= 0; i--) {
|
||||||
const info = session.messagesInfo.get(msg.id)
|
const id = messageIds[i]
|
||||||
|
const record = store.getMessage(id)
|
||||||
if (msg.type === "user" && info?.time?.created) {
|
const info = infoMap.get(id)
|
||||||
|
if (record?.role === "user" && info?.time?.created) {
|
||||||
if (after > 0 && info.time.created >= after) {
|
if (after > 0 && info.time.created >= after) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
messageID = msg.id
|
messageID = id
|
||||||
|
restoredText = extractUserTextFromRecord(record)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!messageID) {
|
||||||
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = session.messages[i]
|
||||||
|
const info = session.messagesInfo.get(msg.id)
|
||||||
|
|
||||||
|
if (msg.type === "user" && info?.time?.created) {
|
||||||
|
if (after > 0 && info.time.created >= after) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messageID = msg.id
|
||||||
|
const textParts = msg.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text" && typeof (p as any).text === "string")
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
restoredText = textParts.map((p) => (p as any).text as string).join("\n")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!messageID) {
|
if (!messageID) {
|
||||||
showAlertDialog("Nothing to undo", {
|
showAlertDialog("Nothing to undo", {
|
||||||
title: "No actions to undo",
|
title: "No actions to undo",
|
||||||
@@ -274,20 +311,27 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
body: { messageID },
|
body: { messageID },
|
||||||
})
|
})
|
||||||
|
|
||||||
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
if (!restoredText) {
|
||||||
const revertedInfo = session.messagesInfo.get(messageID)
|
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
||||||
|
const revertedInfo = session.messagesInfo.get(messageID)
|
||||||
if (revertedMessage && revertedInfo?.role === "user") {
|
if (revertedMessage && revertedInfo?.role === "user") {
|
||||||
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
|
const textParts = revertedMessage.parts.filter(
|
||||||
if (textParts.length > 0) {
|
(p): p is ClientPart & { type: "text"; text: string } => p.type === "text" && typeof (p as any).text === "string",
|
||||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
)
|
||||||
if (textarea) {
|
if (textParts.length > 0) {
|
||||||
textarea.value = textParts.map((p: any) => p.text).join("\n")
|
restoredText = textParts.map((p) => (p as any).text as string).join("\n")
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
|
||||||
textarea.focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (restoredText) {
|
||||||
|
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = restoredText
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||||
|
textarea.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to revert message:", error)
|
console.error("Failed to revert message:", error)
|
||||||
showAlertDialog("Failed to revert message", {
|
showAlertDialog("Failed to revert message", {
|
||||||
|
|||||||
53
packages/ui/src/lib/scroll-cache.ts
Normal file
53
packages/ui/src/lib/scroll-cache.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ScrollSnapshot } from "../stores/message-v2/types"
|
||||||
|
|
||||||
|
interface ScrollCacheParams {
|
||||||
|
instanceId?: string
|
||||||
|
sessionId?: string
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollCache = new Map<string, ScrollSnapshot>()
|
||||||
|
const DEFAULT_SCOPE = "session"
|
||||||
|
|
||||||
|
function resolve(value?: string) {
|
||||||
|
return value && value.length > 0 ? value : "GLOBAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKey(params: ScrollCacheParams) {
|
||||||
|
return `${resolve(params.instanceId)}:${resolve(params.sessionId)}:${params.scope ?? DEFAULT_SCOPE}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setScrollCache(params: ScrollCacheParams, snapshot: Omit<ScrollSnapshot, "updatedAt">) {
|
||||||
|
scrollCache.set(makeKey(params), { ...snapshot, updatedAt: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollCache(params: ScrollCacheParams): ScrollSnapshot | undefined {
|
||||||
|
return scrollCache.get(makeKey(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearScrollCacheScope(params: ScrollCacheParams) {
|
||||||
|
const key = makeKey(params)
|
||||||
|
scrollCache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearScrollCacheForSession(instanceId?: string, sessionId?: string) {
|
||||||
|
const match = `${resolve(instanceId)}:${resolve(sessionId)}:`
|
||||||
|
for (const key of scrollCache.keys()) {
|
||||||
|
if (key.startsWith(match)) {
|
||||||
|
scrollCache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearScrollCacheForInstance(instanceId?: string) {
|
||||||
|
const match = `${resolve(instanceId)}:`
|
||||||
|
for (const key of scrollCache.keys()) {
|
||||||
|
if (key.startsWith(match)) {
|
||||||
|
scrollCache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllScrollCache() {
|
||||||
|
scrollCache.clear()
|
||||||
|
}
|
||||||
175
packages/ui/src/stores/message-v2/bridge.ts
Normal file
175
packages/ui/src/stores/message-v2/bridge.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Permission } from "@opencode-ai/sdk"
|
||||||
|
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||||
|
import type { Session } from "../../types/session"
|
||||||
|
import { messageStoreBus } from "./bus"
|
||||||
|
import type { MessageStatus, SessionRevertState } from "./types"
|
||||||
|
|
||||||
|
interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
parentId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined {
|
||||||
|
if (!session) return undefined
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
parentId: session.parentId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status: Message["status"]): MessageStatus {
|
||||||
|
switch (status) {
|
||||||
|
case "sending":
|
||||||
|
case "sent":
|
||||||
|
case "streaming":
|
||||||
|
case "complete":
|
||||||
|
case "error":
|
||||||
|
return status
|
||||||
|
default:
|
||||||
|
return "complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedSessionMessagesV2(
|
||||||
|
instanceId: string,
|
||||||
|
session: Session | SessionMetadata,
|
||||||
|
messages: Message[],
|
||||||
|
messageInfos?: Map<string, MessageInfo>,
|
||||||
|
): void {
|
||||||
|
if (!session || !Array.isArray(messages)) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const metadata: SessionMetadata = "id" in session ? { id: session.id, title: session.title, parentId: session.parentId ?? null } : session
|
||||||
|
const messageIds = messages.map((message) => message.id)
|
||||||
|
|
||||||
|
store.addOrUpdateSession({
|
||||||
|
id: metadata.id,
|
||||||
|
title: metadata.title,
|
||||||
|
parentId: metadata.parentId ?? null,
|
||||||
|
messageIds,
|
||||||
|
revert: (session as Session)?.revert ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
store.upsertMessage({
|
||||||
|
id: message.id,
|
||||||
|
sessionId: message.sessionId,
|
||||||
|
role: message.type,
|
||||||
|
status: normalizeStatus(message.status),
|
||||||
|
createdAt: message.timestamp,
|
||||||
|
updatedAt: message.timestamp,
|
||||||
|
parts: message.parts,
|
||||||
|
isEphemeral: message.status === "sending" || message.status === "streaming",
|
||||||
|
bumpRevision: false,
|
||||||
|
})
|
||||||
|
const info = messageInfos?.get(message.id)
|
||||||
|
if (info) {
|
||||||
|
store.setMessageInfo(message.id, info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (messageInfos) {
|
||||||
|
store.rebuildUsage(metadata.id, messageInfos.values())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageInfoOptions {
|
||||||
|
status?: MessageStatus
|
||||||
|
bumpRevision?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null | undefined, options?: MessageInfoOptions): void {
|
||||||
|
if (!info || typeof info.id !== "string" || typeof info.sessionID !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
|
||||||
|
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
||||||
|
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
|
||||||
|
|
||||||
|
store.upsertMessage({
|
||||||
|
id: info.id,
|
||||||
|
sessionId: info.sessionID,
|
||||||
|
role: info.role === "user" ? "user" : "assistant",
|
||||||
|
status: options?.status ?? "complete",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: completedAt ?? createdAt,
|
||||||
|
bumpRevision: Boolean(options?.bumpRevision),
|
||||||
|
})
|
||||||
|
store.setMessageInfo(info.id, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | undefined): void {
|
||||||
|
if (!part || typeof part.messageID !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.applyPartUpdate({
|
||||||
|
messageId: part.messageID,
|
||||||
|
part,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||||
|
if (!oldId || !newId || oldId === newId) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.replaceMessageId({ oldId, newId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPermissionMessageId(permission: Permission): string | undefined {
|
||||||
|
return (permission as any).messageID || (permission as any).messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPermissionPartId(permission: Permission): string | undefined {
|
||||||
|
const metadata = (permission as any).metadata || {}
|
||||||
|
return (
|
||||||
|
(permission as any).callID ||
|
||||||
|
(permission as any).callId ||
|
||||||
|
(permission as any).toolCallID ||
|
||||||
|
(permission as any).toolCallId ||
|
||||||
|
metadata.partId ||
|
||||||
|
metadata.partID ||
|
||||||
|
metadata.callID ||
|
||||||
|
metadata.callId ||
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPermissionV2(instanceId: string, permission: Permission): void {
|
||||||
|
if (!permission) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.upsertPermission({
|
||||||
|
permission,
|
||||||
|
messageId: extractPermissionMessageId(permission),
|
||||||
|
partId: extractPermissionPartId(permission),
|
||||||
|
enqueuedAt: (permission as any).time?.created ?? Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||||
|
if (!permissionId) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.removePermission(permissionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
|
||||||
|
if (!session) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.addOrUpdateSession({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
parentId: session.parentId ?? null,
|
||||||
|
messageIds: session.messages.map((message: Message) => message.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionMetadataFromStore(session?: Session | null): SessionMetadata | undefined {
|
||||||
|
return resolveSessionMetadata(session ?? undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionRevertV2(instanceId: string, sessionId: string, revert?: SessionRevertState | null): void {
|
||||||
|
if (!sessionId) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.setSessionRevert(sessionId, revert ?? null)
|
||||||
|
}
|
||||||
41
packages/ui/src/stores/message-v2/bus.ts
Normal file
41
packages/ui/src/stores/message-v2/bus.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createInstanceMessageStore } from "./instance-store"
|
||||||
|
import type { InstanceMessageStore } from "./instance-store"
|
||||||
|
|
||||||
|
class MessageStoreBus {
|
||||||
|
private stores = new Map<string, InstanceMessageStore>()
|
||||||
|
|
||||||
|
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
|
||||||
|
if (this.stores.has(instanceId)) {
|
||||||
|
return this.stores.get(instanceId) as InstanceMessageStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = store ?? createInstanceMessageStore(instanceId)
|
||||||
|
this.stores.set(instanceId, resolved)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstance(instanceId: string): InstanceMessageStore | undefined {
|
||||||
|
return this.stores.get(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreate(instanceId: string): InstanceMessageStore {
|
||||||
|
return this.registerInstance(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterInstance(instanceId: string) {
|
||||||
|
const store = this.stores.get(instanceId)
|
||||||
|
if (store) {
|
||||||
|
store.clearInstance()
|
||||||
|
}
|
||||||
|
this.stores.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll() {
|
||||||
|
for (const [instanceId, store] of this.stores.entries()) {
|
||||||
|
store.clearInstance()
|
||||||
|
this.stores.delete(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageStoreBus = new MessageStoreBus()
|
||||||
523
packages/ui/src/stores/message-v2/instance-store.ts
Normal file
523
packages/ui/src/stores/message-v2/instance-store.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import type { SetStoreFunction } from "solid-js/store"
|
||||||
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
|
import type {
|
||||||
|
InstanceMessageState,
|
||||||
|
MessageRecord,
|
||||||
|
MessageUpsertInput,
|
||||||
|
PartUpdateInput,
|
||||||
|
PendingPartEntry,
|
||||||
|
PermissionEntry,
|
||||||
|
ReplaceMessageIdOptions,
|
||||||
|
ScrollSnapshot,
|
||||||
|
SessionRecord,
|
||||||
|
SessionUpsertInput,
|
||||||
|
SessionUsageState,
|
||||||
|
UsageEntry,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
function createInitialState(instanceId: string): InstanceMessageState {
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
sessions: {},
|
||||||
|
sessionOrder: [],
|
||||||
|
messages: {},
|
||||||
|
messageInfo: {},
|
||||||
|
pendingParts: {},
|
||||||
|
permissions: {
|
||||||
|
queue: [],
|
||||||
|
active: null,
|
||||||
|
byMessage: {},
|
||||||
|
},
|
||||||
|
usage: {},
|
||||||
|
scrollState: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePartId(messageId: string, part: ClientPart, index: number): string {
|
||||||
|
if (typeof part.id === "string" && part.id.length > 0) {
|
||||||
|
return part.id
|
||||||
|
}
|
||||||
|
return `${messageId}-part-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePart(part: ClientPart): ClientPart {
|
||||||
|
return JSON.parse(JSON.stringify(part)) as ClientPart
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyUsageState(): SessionUsageState {
|
||||||
|
return {
|
||||||
|
entries: {},
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
totalReasoningTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
latestMessageId: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null {
|
||||||
|
if (!info || info.role !== "assistant") return null
|
||||||
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
|
if (!messageId) return null
|
||||||
|
const tokens = info.tokens
|
||||||
|
if (!tokens) return null
|
||||||
|
const inputTokens = tokens.input ?? 0
|
||||||
|
const outputTokens = tokens.output ?? 0
|
||||||
|
const reasoningTokens = tokens.reasoning ?? 0
|
||||||
|
const cacheReadTokens = tokens.cache?.read ?? 0
|
||||||
|
const cacheWriteTokens = tokens.cache?.write ?? 0
|
||||||
|
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const combinedTokens = info.summary ? outputTokens : inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
reasoningTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens,
|
||||||
|
combinedTokens,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
timestamp: info.time?.created ?? 0,
|
||||||
|
hasContextUsage: inputTokens + cacheReadTokens + cacheWriteTokens > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUsageState(state: SessionUsageState, entry: UsageEntry | null) {
|
||||||
|
if (!entry) return
|
||||||
|
state.entries[entry.messageId] = entry
|
||||||
|
state.totalInputTokens += entry.inputTokens
|
||||||
|
state.totalOutputTokens += entry.outputTokens
|
||||||
|
state.totalReasoningTokens += entry.reasoningTokens
|
||||||
|
state.totalCost += entry.cost
|
||||||
|
if (!state.latestMessageId || entry.timestamp >= (state.entries[state.latestMessageId]?.timestamp ?? 0)) {
|
||||||
|
state.latestMessageId = entry.messageId
|
||||||
|
state.actualUsageTokens = entry.combinedTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
|
||||||
|
if (!messageId) return
|
||||||
|
const existing = state.entries[messageId]
|
||||||
|
if (!existing) return
|
||||||
|
state.totalInputTokens -= existing.inputTokens
|
||||||
|
state.totalOutputTokens -= existing.outputTokens
|
||||||
|
state.totalReasoningTokens -= existing.reasoningTokens
|
||||||
|
state.totalCost -= existing.cost
|
||||||
|
delete state.entries[messageId]
|
||||||
|
if (state.latestMessageId === messageId) {
|
||||||
|
state.latestMessageId = undefined
|
||||||
|
state.actualUsageTokens = 0
|
||||||
|
let latest: UsageEntry | null = null
|
||||||
|
for (const candidate of Object.values(state.entries) as UsageEntry[]) {
|
||||||
|
if (!latest || candidate.timestamp >= latest.timestamp) {
|
||||||
|
latest = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (latest) {
|
||||||
|
state.latestMessageId = latest.messageId
|
||||||
|
state.actualUsageTokens = latest.combinedTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildUsageStateFromInfos(infos: Iterable<MessageInfo>): SessionUsageState {
|
||||||
|
const usageState = createEmptyUsageState()
|
||||||
|
for (const info of infos) {
|
||||||
|
const entry = extractUsageEntry(info)
|
||||||
|
if (entry) {
|
||||||
|
applyUsageState(usageState, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usageState
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceMessageStore {
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
state: InstanceMessageState
|
||||||
|
setState: SetStoreFunction<InstanceMessageState>
|
||||||
|
addOrUpdateSession: (input: SessionUpsertInput) => void
|
||||||
|
upsertMessage: (input: MessageUpsertInput) => void
|
||||||
|
applyPartUpdate: (input: PartUpdateInput) => void
|
||||||
|
bufferPendingPart: (entry: PendingPartEntry) => void
|
||||||
|
flushPendingParts: (messageId: string) => void
|
||||||
|
replaceMessageId: (options: ReplaceMessageIdOptions) => void
|
||||||
|
setMessageInfo: (messageId: string, info: MessageInfo) => void
|
||||||
|
getMessageInfo: (messageId: string) => MessageInfo | undefined
|
||||||
|
upsertPermission: (entry: PermissionEntry) => void
|
||||||
|
removePermission: (permissionId: string) => void
|
||||||
|
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
||||||
|
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
||||||
|
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
||||||
|
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
||||||
|
getSessionUsage: (sessionId: string) => SessionUsageState | undefined
|
||||||
|
setScrollSnapshot: (sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) => void
|
||||||
|
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||||
|
getSessionMessageIds: (sessionId: string) => string[]
|
||||||
|
getMessage: (messageId: string) => MessageRecord | undefined
|
||||||
|
clearInstance: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInstanceMessageStore(instanceId: string): InstanceMessageStore {
|
||||||
|
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||||
|
|
||||||
|
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
||||||
|
setState("usage", sessionId, (current) => {
|
||||||
|
const draft = current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
entries: { ...current.entries },
|
||||||
|
}
|
||||||
|
: createEmptyUsageState()
|
||||||
|
updater(draft)
|
||||||
|
return draft
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUsageWithInfo(info: MessageInfo | undefined) {
|
||||||
|
if (!info || typeof info.sessionID !== "string") return
|
||||||
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
|
if (!messageId) return
|
||||||
|
withUsageState(info.sessionID, (draft) => {
|
||||||
|
removeUsageEntry(draft, messageId)
|
||||||
|
const entry = extractUsageEntry(info)
|
||||||
|
if (entry) {
|
||||||
|
applyUsageState(draft, entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildUsage(sessionId: string, infos: Iterable<MessageInfo>) {
|
||||||
|
const usageState = rebuildUsageStateFromInfos(infos)
|
||||||
|
setState("usage", sessionId, usageState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionUsage(sessionId: string) {
|
||||||
|
return state.usage[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSessionEntry(sessionId: string): SessionRecord {
|
||||||
|
const existing = state.sessions[sessionId]
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const session: SessionRecord = {
|
||||||
|
id: sessionId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
messageIds: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("sessions", sessionId, session)
|
||||||
|
setState("sessionOrder", (order) => (order.includes(sessionId) ? order : [...order, sessionId]))
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrUpdateSession(input: SessionUpsertInput) {
|
||||||
|
const session = ensureSessionEntry(input.id)
|
||||||
|
const nextMessageIds = Array.isArray(input.messageIds) ? input.messageIds : session.messageIds
|
||||||
|
|
||||||
|
setState("sessions", input.id, {
|
||||||
|
...session,
|
||||||
|
title: input.title ?? session.title,
|
||||||
|
parentId: input.parentId ?? session.parentId ?? null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messageIds: nextMessageIds,
|
||||||
|
revert: input.revert ?? session.revert ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMessageIntoSession(sessionId: string, messageId: string) {
|
||||||
|
ensureSessionEntry(sessionId)
|
||||||
|
setState("sessions", sessionId, "messageIds", (ids = []) => {
|
||||||
|
if (ids.includes(messageId)) {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
return [...ids, messageId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParts(messageId: string, parts: ClientPart[] | undefined) {
|
||||||
|
if (!parts || parts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const map: MessageRecord["parts"] = {}
|
||||||
|
const ids: string[] = []
|
||||||
|
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
const id = ensurePartId(messageId, part, index)
|
||||||
|
const cloned = clonePart(part)
|
||||||
|
if (typeof cloned.version !== "number") {
|
||||||
|
cloned.version = 0
|
||||||
|
}
|
||||||
|
map[id] = {
|
||||||
|
id,
|
||||||
|
data: cloned,
|
||||||
|
revision: cloned.version,
|
||||||
|
}
|
||||||
|
ids.push(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { map, ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMessage(input: MessageUpsertInput) {
|
||||||
|
const normalizedParts = normalizeParts(input.id, input.parts)
|
||||||
|
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
setState("messages", input.id, (previous) => {
|
||||||
|
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
role: input.role,
|
||||||
|
status: input.status,
|
||||||
|
createdAt: input.createdAt ?? previous?.createdAt ?? now,
|
||||||
|
updatedAt: input.updatedAt ?? now,
|
||||||
|
isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false,
|
||||||
|
revision,
|
||||||
|
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
||||||
|
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
insertMessageIntoSession(input.sessionId, input.id)
|
||||||
|
flushPendingParts(input.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferPendingPart(entry: PendingPartEntry) {
|
||||||
|
setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPartUpdate(input: PartUpdateInput) {
|
||||||
|
const message = state.messages[input.messageId]
|
||||||
|
if (!message) {
|
||||||
|
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
||||||
|
const cloned = clonePart(input.part)
|
||||||
|
|
||||||
|
setState(
|
||||||
|
"messages",
|
||||||
|
input.messageId,
|
||||||
|
produce((draft: MessageRecord) => {
|
||||||
|
if (!draft.partIds.includes(partId)) {
|
||||||
|
draft.partIds = [...draft.partIds, partId]
|
||||||
|
}
|
||||||
|
const existing = draft.parts[partId]
|
||||||
|
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
|
||||||
|
draft.parts[partId] = {
|
||||||
|
id: partId,
|
||||||
|
data: cloned,
|
||||||
|
revision: nextRevision,
|
||||||
|
}
|
||||||
|
draft.updatedAt = Date.now()
|
||||||
|
if (input.bumpRevision ?? true) {
|
||||||
|
draft.revision += 1
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingParts(messageId: string) {
|
||||||
|
const pending = state.pendingParts[messageId]
|
||||||
|
if (!pending || pending.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
|
||||||
|
setState("pendingParts", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[messageId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMessageId(options: ReplaceMessageIdOptions) {
|
||||||
|
if (options.oldId === options.newId) return
|
||||||
|
const existing = state.messages[options.oldId]
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
const cloned: MessageRecord = {
|
||||||
|
...existing,
|
||||||
|
id: options.newId,
|
||||||
|
isEphemeral: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("messages", options.newId, cloned)
|
||||||
|
setState("messages", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[options.oldId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.values(state.sessions).forEach((session) => {
|
||||||
|
const index = session.messageIds.indexOf(options.oldId)
|
||||||
|
if (index === -1) return
|
||||||
|
setState("sessions", session.id, "messageIds", (ids) => {
|
||||||
|
const next = [...ids]
|
||||||
|
next[index] = options.newId
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const infoEntry = state.messageInfo[options.oldId]
|
||||||
|
if (infoEntry) {
|
||||||
|
setState("messageInfo", options.newId, infoEntry)
|
||||||
|
setState("messageInfo", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[options.oldId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionMap = state.permissions.byMessage[options.oldId]
|
||||||
|
if (permissionMap) {
|
||||||
|
setState("permissions", "byMessage", options.newId, permissionMap)
|
||||||
|
setState("permissions", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
const nextByMessage = { ...next.byMessage }
|
||||||
|
delete nextByMessage[options.oldId]
|
||||||
|
next.byMessage = nextByMessage
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = state.pendingParts[options.oldId]
|
||||||
|
if (pending) {
|
||||||
|
setState("pendingParts", options.newId, pending)
|
||||||
|
setState("pendingParts", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[options.oldId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMessageInfo(messageId: string, info: MessageInfo) {
|
||||||
|
if (!messageId) return
|
||||||
|
setState("messageInfo", messageId, info)
|
||||||
|
updateUsageWithInfo(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageInfo(messageId: string) {
|
||||||
|
return state.messageInfo[messageId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPermission(entry: PermissionEntry) {
|
||||||
|
const messageKey = entry.messageId ?? "__global__"
|
||||||
|
const partKey = entry.partId ?? "__global__"
|
||||||
|
|
||||||
|
setState(
|
||||||
|
"permissions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||||
|
draft.byMessage[messageKey][partKey] = entry
|
||||||
|
const existingIndex = draft.queue.findIndex((item) => item.permission.id === entry.permission.id)
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
draft.queue.push(entry)
|
||||||
|
} else {
|
||||||
|
draft.queue[existingIndex] = entry
|
||||||
|
}
|
||||||
|
if (!draft.active || draft.active.permission.id === entry.permission.id) {
|
||||||
|
draft.active = entry
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePermission(permissionId: string) {
|
||||||
|
setState(
|
||||||
|
"permissions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.queue = draft.queue.filter((item) => item.permission.id !== permissionId)
|
||||||
|
if (draft.active?.permission.id === permissionId) {
|
||||||
|
draft.active = draft.queue[0] ?? null
|
||||||
|
}
|
||||||
|
Object.keys(draft.byMessage).forEach((messageKey) => {
|
||||||
|
const partEntries = draft.byMessage[messageKey]
|
||||||
|
Object.keys(partEntries).forEach((partKey) => {
|
||||||
|
if (partEntries[partKey].permission.id === permissionId) {
|
||||||
|
delete partEntries[partKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (Object.keys(partEntries).length === 0) {
|
||||||
|
delete draft.byMessage[messageKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionState(messageId?: string, partId?: string) {
|
||||||
|
const messageKey = messageId ?? "__global__"
|
||||||
|
const partKey = partId ?? "__global__"
|
||||||
|
const entry = state.permissions.byMessage[messageKey]?.[partKey]
|
||||||
|
if (!entry) return null
|
||||||
|
const active = state.permissions.active?.permission.id === entry.permission.id
|
||||||
|
return { entry, active }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
|
||||||
|
if (!sessionId) return
|
||||||
|
ensureSessionEntry(sessionId)
|
||||||
|
setState("sessions", sessionId, "revert", revert ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionRevert(sessionId: string) {
|
||||||
|
return state.sessions[sessionId]?.revert ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScrollKey(sessionId: string, scope: string) {
|
||||||
|
return `${sessionId}:${scope}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScrollSnapshot(sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) {
|
||||||
|
const key = makeScrollKey(sessionId, scope)
|
||||||
|
setState("scrollState", key, { ...snapshot, updatedAt: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollSnapshot(sessionId: string, scope: string) {
|
||||||
|
const key = makeScrollKey(sessionId, scope)
|
||||||
|
return state.scrollState[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInstance() {
|
||||||
|
setState(reconcile(createInitialState(instanceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
addOrUpdateSession,
|
||||||
|
upsertMessage,
|
||||||
|
applyPartUpdate,
|
||||||
|
bufferPendingPart,
|
||||||
|
flushPendingParts,
|
||||||
|
replaceMessageId,
|
||||||
|
setMessageInfo,
|
||||||
|
getMessageInfo,
|
||||||
|
upsertPermission,
|
||||||
|
removePermission,
|
||||||
|
getPermissionState,
|
||||||
|
setSessionRevert,
|
||||||
|
getSessionRevert,
|
||||||
|
rebuildUsage,
|
||||||
|
getSessionUsage,
|
||||||
|
setScrollSnapshot,
|
||||||
|
getScrollSnapshot,
|
||||||
|
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||||
|
getMessage: (messageId: string) => state.messages[messageId],
|
||||||
|
clearInstance,
|
||||||
|
}
|
||||||
|
}
|
||||||
138
packages/ui/src/stores/message-v2/types.ts
Normal file
138
packages/ui/src/stores/message-v2/types.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
|
import type { Permission } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
||||||
|
export type MessageRole = "user" | "assistant"
|
||||||
|
|
||||||
|
export interface NormalizedPartRecord {
|
||||||
|
id: string
|
||||||
|
data: ClientPart
|
||||||
|
revision: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageRecord {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
role: MessageRole
|
||||||
|
status: MessageStatus
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
revision: number
|
||||||
|
isEphemeral?: boolean
|
||||||
|
partIds: string[]
|
||||||
|
parts: Record<string, NormalizedPartRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRevertState {
|
||||||
|
messageID?: string
|
||||||
|
partID?: string
|
||||||
|
snapshot?: string
|
||||||
|
diff?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
parentId?: string | null
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messageIds: string[]
|
||||||
|
revert?: SessionRevertState | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingPartEntry {
|
||||||
|
messageId: string
|
||||||
|
part: ClientPart
|
||||||
|
receivedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionEntry {
|
||||||
|
permission: Permission
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
enqueuedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstancePermissionState {
|
||||||
|
queue: PermissionEntry[]
|
||||||
|
active: PermissionEntry | null
|
||||||
|
byMessage: Record<string, Record<string, PermissionEntry>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollSnapshot {
|
||||||
|
scrollTop: number
|
||||||
|
atBottom: boolean
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageEntry {
|
||||||
|
messageId: string
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
reasoningTokens: number
|
||||||
|
cacheReadTokens: number
|
||||||
|
cacheWriteTokens: number
|
||||||
|
combinedTokens: number
|
||||||
|
cost: number
|
||||||
|
timestamp: number
|
||||||
|
hasContextUsage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionUsageState {
|
||||||
|
entries: Record<string, UsageEntry>
|
||||||
|
totalInputTokens: number
|
||||||
|
totalOutputTokens: number
|
||||||
|
totalReasoningTokens: number
|
||||||
|
totalCost: number
|
||||||
|
actualUsageTokens: number
|
||||||
|
latestMessageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceMessageState {
|
||||||
|
instanceId: string
|
||||||
|
sessions: Record<string, SessionRecord>
|
||||||
|
sessionOrder: string[]
|
||||||
|
messages: Record<string, MessageRecord>
|
||||||
|
messageInfo: Record<string, MessageInfo>
|
||||||
|
pendingParts: Record<string, PendingPartEntry[]>
|
||||||
|
permissions: InstancePermissionState
|
||||||
|
usage: Record<string, SessionUsageState>
|
||||||
|
scrollState: Record<string, ScrollSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionUpsertInput {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
parentId?: string | null
|
||||||
|
messageIds?: string[]
|
||||||
|
revert?: SessionRevertState | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageUpsertInput {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
role: MessageRole
|
||||||
|
status: MessageStatus
|
||||||
|
parts?: ClientPart[]
|
||||||
|
createdAt?: number
|
||||||
|
updatedAt?: number
|
||||||
|
isEphemeral?: boolean
|
||||||
|
bumpRevision?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartUpdateInput {
|
||||||
|
messageId: string
|
||||||
|
part: ClientPart
|
||||||
|
bumpRevision?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceMessageIdOptions {
|
||||||
|
oldId: string
|
||||||
|
newId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollCacheKey {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
rebuildSessionUsage,
|
rebuildSessionUsage,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
|
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
||||||
|
|
||||||
interface SessionForkResponse {
|
interface SessionForkResponse {
|
||||||
id: string
|
id: string
|
||||||
@@ -610,6 +611,14 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
|
||||||
|
id: sessionId,
|
||||||
|
title: session?.title,
|
||||||
|
parentId: session?.parentId ?? null,
|
||||||
|
revert: session?.revert,
|
||||||
|
}
|
||||||
|
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load messages:", error)
|
console.error("Failed to load messages:", error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ import {
|
|||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
import { loadMessages } from "./session-api"
|
import { loadMessages } from "./session-api"
|
||||||
import { setSessionCompactionState } from "./session-compaction"
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
|
import {
|
||||||
|
applyPartUpdateV2,
|
||||||
|
replaceMessageIdV2,
|
||||||
|
upsertMessageInfoV2,
|
||||||
|
upsertPermissionV2,
|
||||||
|
removePermissionV2,
|
||||||
|
setSessionRevertV2,
|
||||||
|
} from "./message-v2/bridge"
|
||||||
|
|
||||||
interface TuiToastEvent {
|
interface TuiToastEvent {
|
||||||
type: "tui.toast.show"
|
type: "tui.toast.show"
|
||||||
@@ -195,6 +203,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
index.partIndex.delete(oldId)
|
index.partIndex.delete(oldId)
|
||||||
index.partIndex.set(message.id, existingPartMap)
|
index.partIndex.set(message.id, existingPartMap)
|
||||||
}
|
}
|
||||||
|
replaceMessageIdV2(instanceId, oldId, message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredSynthetics || replacedTemp) {
|
if (filteredSynthetics || replacedTemp) {
|
||||||
@@ -212,6 +221,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
/* mutations already applied above */
|
/* mutations already applied above */
|
||||||
})
|
})
|
||||||
|
|
||||||
|
applyPartUpdateV2(instanceId, part)
|
||||||
updateSessionInfo(instanceId, part.sessionID)
|
updateSessionInfo(instanceId, part.sessionID)
|
||||||
refreshPermissionsForSession(instanceId, part.sessionID)
|
refreshPermissionsForSession(instanceId, part.sessionID)
|
||||||
} else if (event.type === "message.updated") {
|
} else if (event.type === "message.updated") {
|
||||||
@@ -270,6 +280,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
index.partIndex.delete(oldId)
|
index.partIndex.delete(oldId)
|
||||||
index.partIndex.set(message.id, existingPartMap)
|
index.partIndex.set(message.id, existingPartMap)
|
||||||
}
|
}
|
||||||
|
replaceMessageIdV2(instanceId, oldId, message.id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newMessage: any = {
|
const newMessage: any = {
|
||||||
@@ -305,8 +316,11 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upsertMessageInfoV2(instanceId, info, { status: "complete" })
|
||||||
|
|
||||||
session.messagesInfo.set(info.id, info)
|
session.messagesInfo.set(info.id, info)
|
||||||
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
|
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
|
||||||
|
|
||||||
withSession(instanceId, info.sessionID, () => {
|
withSession(instanceId, info.sessionID, () => {
|
||||||
/* ensure reactivity */
|
/* ensure reactivity */
|
||||||
})
|
})
|
||||||
@@ -359,6 +373,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
next.set(instanceId, updated)
|
next.set(instanceId, updated)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||||
|
|
||||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||||
} else {
|
} else {
|
||||||
@@ -391,6 +406,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
next.set(instanceId, updated)
|
next.set(instanceId, updated)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +506,7 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
|
|||||||
|
|
||||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||||
addPermissionToQueue(instanceId, permission)
|
addPermissionToQueue(instanceId, permission)
|
||||||
|
upsertPermissionV2(instanceId, permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||||
@@ -498,6 +515,7 @@ function handlePermissionReplied(instanceId: string, event: EventPermissionRepli
|
|||||||
|
|
||||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||||
removePermissionFromQueue(instanceId, permissionID)
|
removePermissionFromQueue(instanceId, permissionID)
|
||||||
|
removePermissionV2(instanceId, permissionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Session, SessionStatus } from "../types/session"
|
import type { Session, SessionStatus } from "../types/session"
|
||||||
import type { Message, MessageInfo } from "../types/message"
|
import type { Message, MessageInfo } from "../types/message"
|
||||||
|
import type { MessageRecord } from "./message-v2/types"
|
||||||
import { sessions } from "./sessions"
|
import { sessions } from "./sessions"
|
||||||
import { isSessionCompactionActive } from "./session-compaction"
|
import { isSessionCompactionActive } from "./session-compaction"
|
||||||
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
|
|
||||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
@@ -17,21 +19,49 @@ function isSessionCompacting(session: Session): boolean {
|
|||||||
return Boolean(compactingFlag)
|
return Boolean(compactingFlag)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessageTimestamp(session: Session, message?: Message): number {
|
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||||
if (!message) return Number.NEGATIVE_INFINITY
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
|
const messageIds = store.getSessionMessageIds(sessionId)
|
||||||
return message.timestamp
|
let latest: MessageInfo | undefined
|
||||||
|
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
for (const id of messageIds) {
|
||||||
|
const info = store.getMessageInfo(id)
|
||||||
|
if (!info) continue
|
||||||
|
if (role && info.role !== role) continue
|
||||||
|
const timestamp = info.time?.created ?? 0
|
||||||
|
if (timestamp >= latestTimestamp) {
|
||||||
|
latest = info
|
||||||
|
latestTimestamp = timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const info = session.messagesInfo.get(message.id)
|
return latest
|
||||||
return info?.time?.created ?? Number.NEGATIVE_INFINITY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastMessage(session: Session): Message | undefined {
|
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const messageIds = store.getSessionMessageIds(sessionId)
|
||||||
|
let latest: MessageRecord | undefined
|
||||||
|
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
for (const id of messageIds) {
|
||||||
|
const record = store.getMessage(id)
|
||||||
|
if (!record) continue
|
||||||
|
const info = store.getMessageInfo(id)
|
||||||
|
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
|
||||||
|
if (timestamp >= latestTimestamp) {
|
||||||
|
latest = record
|
||||||
|
latestTimestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyLastMessage(session: Session): Message | undefined {
|
||||||
let latest: Message | undefined
|
let latest: Message | undefined
|
||||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||||
for (const message of session.messages) {
|
for (const message of session.messages) {
|
||||||
if (!message) continue
|
if (!message) continue
|
||||||
const timestamp = getMessageTimestamp(session, message)
|
const info = session.messagesInfo.get(message.id)
|
||||||
|
const timestamp = info?.time?.created ?? message.timestamp ?? Number.NEGATIVE_INFINITY
|
||||||
if (timestamp >= latestTimestamp) {
|
if (timestamp >= latestTimestamp) {
|
||||||
latest = message
|
latest = message
|
||||||
latestTimestamp = timestamp
|
latestTimestamp = timestamp
|
||||||
@@ -40,7 +70,7 @@ function getLastMessage(session: Session): Message | undefined {
|
|||||||
return latest
|
return latest
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
|
function getLegacyLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||||
if (session.messagesInfo.size === 0) {
|
if (session.messagesInfo.size === 0) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -92,7 +122,28 @@ function isAssistantInfoPending(info?: MessageInfo): boolean {
|
|||||||
return completed < created
|
return completed < created
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean {
|
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
|
||||||
|
if (record.role !== "assistant") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "error") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "streaming" || record.status === "sending") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
|
||||||
|
if (completedAt !== undefined && completedAt !== null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(record.status === "complete" || record.status === "sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantStillGeneratingLegacy(message: Message, info?: MessageInfo): boolean {
|
||||||
if (message.type !== "assistant") {
|
if (message.type !== "assistant") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -119,15 +170,20 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
|||||||
return "idle"
|
return "idle"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
|
||||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||||
return "compacting"
|
return "compacting"
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestUserInfo = getLastMessageInfo(session, "user")
|
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user") ?? getLegacyLastMessageInfo(session, "user")
|
||||||
const latestAssistantInfo = getLastMessageInfo(session, "assistant")
|
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant") ?? getLegacyLastMessageInfo(session, "assistant")
|
||||||
const lastMessage = getLastMessage(session)
|
|
||||||
if (!lastMessage) {
|
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
|
||||||
const latestInfo = getLastMessageInfo(session)
|
const legacyFallbackMessage = lastRecord ? undefined : getLegacyLastMessage(session)
|
||||||
|
|
||||||
|
if (!lastRecord && !legacyFallbackMessage) {
|
||||||
|
const latestInfo = latestUserInfo ?? latestAssistantInfo ?? getLegacyLastMessageInfo(session)
|
||||||
if (!latestInfo) {
|
if (!latestInfo) {
|
||||||
return "idle"
|
return "idle"
|
||||||
}
|
}
|
||||||
@@ -138,13 +194,22 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
|||||||
return infoCompleted ? "idle" : "working"
|
return infoCompleted ? "idle" : "working"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastMessage.type === "user") {
|
if (lastRecord) {
|
||||||
return "working"
|
if (lastRecord.role === "user") {
|
||||||
}
|
return "working"
|
||||||
|
}
|
||||||
const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo
|
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
|
||||||
if (isAssistantStillGenerating(lastMessage, infoForMessage)) {
|
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
|
||||||
return "working"
|
return "working"
|
||||||
|
}
|
||||||
|
} else if (legacyFallbackMessage) {
|
||||||
|
if (legacyFallbackMessage.type === "user") {
|
||||||
|
return "working"
|
||||||
|
}
|
||||||
|
const infoForLegacy = session.messagesInfo.get(legacyFallbackMessage.id) ?? latestAssistantInfo
|
||||||
|
if (isAssistantStillGeneratingLegacy(legacyFallbackMessage, infoForLegacy)) {
|
||||||
|
return "working"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAssistantInfoPending(latestAssistantInfo)) {
|
if (isAssistantInfoPending(latestAssistantInfo)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user