mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
229 lines
9.1 KiB
TypeScript
229 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { AssistantRuntimeProvider } from '@assistant-ui/react'
|
|
import type { ApiClient } from '@/api/client'
|
|
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
|
|
import type { NormalizedMessage } from '@/chat/types'
|
|
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
|
import { reduceChatBlocks } from '@/chat/reducer'
|
|
import { Button } from '@/components/ui/button'
|
|
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
|
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
|
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
|
import { SessionHeader } from '@/components/SessionHeader'
|
|
import { MessageBubble } from '@/components/MessageBubble'
|
|
import { getTelegramWebApp } from '@/hooks/useTelegram'
|
|
|
|
export function SessionChat(props: {
|
|
api: ApiClient
|
|
session: Session
|
|
messages: DecryptedMessage[]
|
|
messagesWarning: string | null
|
|
hasMoreMessages: boolean
|
|
isLoadingMessages: boolean
|
|
isLoadingMoreMessages: boolean
|
|
isSending: boolean
|
|
onBack: () => void
|
|
onRefresh: () => void
|
|
onLoadMore: () => void
|
|
onSend: (text: string) => void
|
|
onRetryMessage?: (localId: string) => void
|
|
}) {
|
|
const controlsDisabled = !props.session.active
|
|
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
|
|
|
useEffect(() => {
|
|
normalizedCacheRef.current.clear()
|
|
}, [props.session.id])
|
|
|
|
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
|
const cache = normalizedCacheRef.current
|
|
const normalized: NormalizedMessage[] = []
|
|
const seen = new Set<string>()
|
|
for (const message of props.messages) {
|
|
seen.add(message.id)
|
|
const cached = cache.get(message.id)
|
|
if (cached && cached.source === message) {
|
|
if (cached.normalized) normalized.push(cached.normalized)
|
|
continue
|
|
}
|
|
const next = normalizeDecryptedMessage(message)
|
|
cache.set(message.id, { source: message, normalized: next })
|
|
if (next) normalized.push(next)
|
|
}
|
|
for (const id of cache.keys()) {
|
|
if (!seen.has(id)) {
|
|
cache.delete(id)
|
|
}
|
|
}
|
|
return normalized
|
|
}, [props.messages])
|
|
|
|
const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, props.session.agentState), [normalizedMessages, props.session.agentState])
|
|
|
|
const [debugViewMode, setDebugViewMode] = useState<'reduced' | 'raw'>('reduced')
|
|
const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced'
|
|
|
|
// Permission mode change handler
|
|
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
|
try {
|
|
await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan')
|
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
|
|
props.onRefresh()
|
|
} catch (e) {
|
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
|
|
console.error('Failed to set permission mode:', e)
|
|
}
|
|
}, [props.api, props.session.id, props.onRefresh])
|
|
|
|
// Model mode change handler
|
|
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
|
|
try {
|
|
await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus')
|
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
|
|
props.onRefresh()
|
|
} catch (e) {
|
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
|
|
console.error('Failed to set model mode:', e)
|
|
}
|
|
}, [props.api, props.session.id, props.onRefresh])
|
|
|
|
// Abort handler
|
|
const handleAbort = useCallback(async () => {
|
|
await props.api.abortSession(props.session.id)
|
|
props.onRefresh()
|
|
}, [props.api, props.session.id, props.onRefresh])
|
|
|
|
const runtime = useHappyRuntime({
|
|
session: props.session,
|
|
blocks: viewMode === 'raw' ? [] : reduced.blocks,
|
|
isSending: props.isSending,
|
|
onSendMessage: props.onSend,
|
|
onAbort: handleAbort
|
|
})
|
|
|
|
const threadHeader = useMemo(() => {
|
|
if (props.isLoadingMessages) {
|
|
return (
|
|
<div className="text-sm text-[var(--app-hint)]">
|
|
Loading…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{props.messagesWarning ? (
|
|
<div className="mb-3 rounded-md bg-amber-500/10 p-2 text-xs">
|
|
{props.messagesWarning}
|
|
</div>
|
|
) : null}
|
|
|
|
{import.meta.env.DEV ? (
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Button
|
|
variant={viewMode === 'reduced' ? 'default' : 'secondary'}
|
|
size="sm"
|
|
onClick={() => setDebugViewMode('reduced')}
|
|
>
|
|
Reduced
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'raw' ? 'default' : 'secondary'}
|
|
size="sm"
|
|
onClick={() => setDebugViewMode('raw')}
|
|
>
|
|
Raw
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{props.hasMoreMessages ? (
|
|
<div className="mb-3">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={props.onLoadMore}
|
|
disabled={props.isLoadingMoreMessages}
|
|
>
|
|
{props.isLoadingMoreMessages ? 'Loading…' : 'Load older'}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? (
|
|
<div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs">
|
|
Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`).
|
|
</div>
|
|
) : null}
|
|
|
|
{viewMode === 'raw' ? (
|
|
<div className="flex flex-col gap-2">
|
|
{props.messages.map((m) => (
|
|
<MessageBubble
|
|
key={m.id}
|
|
message={m}
|
|
onRetry={m.localId && m.status === 'failed' && props.onRetryMessage
|
|
? () => props.onRetryMessage!(m.localId!)
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}, [
|
|
props.isLoadingMessages,
|
|
props.messagesWarning,
|
|
props.hasMoreMessages,
|
|
props.isLoadingMoreMessages,
|
|
props.onLoadMore,
|
|
props.messages,
|
|
props.messages.length,
|
|
props.onRetryMessage,
|
|
viewMode,
|
|
normalizedMessages.length
|
|
])
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<SessionHeader
|
|
session={props.session}
|
|
onBack={props.onBack}
|
|
/>
|
|
|
|
{controlsDisabled ? (
|
|
<div className="bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
|
|
Session is inactive. Controls are disabled.
|
|
</div>
|
|
) : null}
|
|
|
|
<AssistantRuntimeProvider runtime={runtime}>
|
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
<HappyThread
|
|
api={props.api}
|
|
sessionId={props.session.id}
|
|
metadata={props.session.metadata}
|
|
disabled={controlsDisabled}
|
|
onRefresh={props.onRefresh}
|
|
onRetryMessage={props.onRetryMessage}
|
|
header={threadHeader}
|
|
/>
|
|
|
|
<HappyComposer
|
|
disabled={props.isSending || controlsDisabled}
|
|
permissionMode={props.session.permissionMode}
|
|
modelMode={props.session.modelMode}
|
|
active={props.session.active}
|
|
thinking={props.session.thinking}
|
|
agentState={props.session.agentState}
|
|
contextSize={reduced.latestUsage?.contextSize}
|
|
onPermissionModeChange={handlePermissionModeChange}
|
|
onModelModeChange={handleModelModeChange}
|
|
/>
|
|
</div>
|
|
</AssistantRuntimeProvider>
|
|
</div>
|
|
)
|
|
}
|