refactor: extract chat normalization and reducer logic, cleanup legacy credentials

- Split normalize.ts into focused modules: normalizeAgent, normalizeUser, normalizeUtils
- Extract reducer.ts into specialized modules: reducerCliOutput, reducerEvents, reducerTimeline, reducerTools
- Remove legacy credentials support (zod schema, readCredentials, writeCredentialsLegacy)
- Refactor NewSession component from monolithic file into modular subcomponents
- Improves code organization and maintainability across chat and authentication layers
This commit is contained in:
weishu
2026-01-03 23:39:10 +08:00
parent 63f3bcac3d
commit 34cfe49f87
20 changed files with 1510 additions and 1457 deletions
+4 -552
View File
@@ -1,563 +1,15 @@
import type { AgentState } from '@/types/api'
import type { AgentEvent, ChatBlock, ChatToolCall, CliOutputBlock, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types'
import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types'
import { traceMessages, type TracedMessage } from '@/chat/tracer'
const CLI_TAG_REGEX = /<(?:local-command-[a-z-]+|command-(?:name|message|args))>/i
const CLI_COMMAND_NAME_REGEX = /<command-name>/i
const CLI_COMMAND_STDOUT_REGEX = /<local-command-stdout>/i
import { dedupeAgentEvents } from '@/chat/reducerEvents'
import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools'
import { reduceTimeline } from '@/chat/reducerTimeline'
// Calculate context size from usage data
function calculateContextSize(usage: UsageData): number {
return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens
}
function parseClaudeUsageLimit(text: string): number | null {
const match = text.match(/^Claude AI usage limit reached\|(\d+)$/)
if (!match) return null
const timestamp = Number.parseInt(match[1], 10)
if (!Number.isFinite(timestamp)) return null
return timestamp
}
function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null {
if (msg.isSidechain) return null
if (msg.role !== 'agent') return null
for (const content of msg.content) {
if (content.type === 'text') {
const limitReached = parseClaudeUsageLimit(content.text)
if (limitReached !== null) {
return { type: 'limit-reached', endsAt: limitReached }
}
}
}
return null
}
function extractTitleFromChangeTitleInput(input: unknown): string | null {
if (!input || typeof input !== 'object') return null
const title = (input as { title?: unknown }).title
return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null
}
function collectTitleChanges(messages: NormalizedMessage[]): Map<string, string> {
const map = new Map<string, string>()
for (const msg of messages) {
if (msg.role !== 'agent') continue
for (const content of msg.content) {
if (content.type !== 'tool-call') continue
if (content.name !== 'mcp__hapi__change_title') continue
const title = extractTitleFromChangeTitleInput(content.input)
if (!title) continue
map.set(content.id, title)
}
}
return map
}
function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] {
const result: ChatBlock[] = []
let prevEventKey: string | null = null
let prevTitleChangedTo: string | null = null
for (const block of blocks) {
if (block.kind !== 'agent-event') {
result.push(block)
prevEventKey = null
prevTitleChangedTo = null
continue
}
const event = block.event as { type: string; [key: string]: unknown }
if (event.type === 'title-changed' && typeof event.title === 'string') {
const title = event.title.trim()
const key = `title-changed:${title}`
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = title
continue
}
if (event.type === 'message' && typeof event.message === 'string') {
const message = event.message.trim()
const key = `message:${message}`
if (key === prevEventKey) {
continue
}
if (prevTitleChangedTo && message === prevTitleChangedTo) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
continue
}
let key: string
try {
key = `event:${JSON.stringify(event)}`
} catch {
key = `event:${String(event.type)}`
}
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
}
return result
}
type PermissionEntry = {
toolName: string
input: unknown
permission: ToolPermission
}
function getPermissions(agentState: AgentState | null | undefined): Map<string, PermissionEntry> {
const map = new Map<string, PermissionEntry>()
const completed = agentState?.completedRequests ?? null
if (completed) {
for (const [id, entry] of Object.entries(completed)) {
map.set(id, {
toolName: entry.tool,
input: entry.arguments,
permission: {
id,
status: entry.status,
reason: entry.reason ?? undefined,
mode: entry.mode ?? undefined,
decision: entry.decision ?? undefined,
allowedTools: entry.allowTools,
answers: entry.answers,
createdAt: entry.createdAt ?? null,
completedAt: entry.completedAt ?? null
}
})
}
}
const requests = agentState?.requests ?? null
if (requests) {
for (const [id, request] of Object.entries(requests)) {
if (map.has(id)) continue
map.set(id, {
toolName: request.tool,
input: request.arguments,
permission: {
id,
status: 'pending',
createdAt: request.createdAt ?? null
}
})
}
}
return map
}
function getMetaSentFrom(meta: unknown): string | null {
if (!meta || typeof meta !== 'object') return null
const sentFrom = (meta as { sentFrom?: unknown }).sentFrom
return typeof sentFrom === 'string' ? sentFrom : null
}
function hasCliOutputTags(text: string): boolean {
return CLI_TAG_REGEX.test(text)
}
function hasCommandNameTag(text: string): boolean {
return CLI_COMMAND_NAME_REGEX.test(text)
}
function hasLocalCommandStdoutTag(text: string): boolean {
return CLI_COMMAND_STDOUT_REGEX.test(text)
}
function isCliOutputText(text: string, meta: unknown): boolean {
return getMetaSentFrom(meta) === 'cli' && hasCliOutputTags(text)
}
function createCliOutputBlock(props: {
id: string
localId: string | null
createdAt: number
text: string
source: CliOutputBlock['source']
meta?: unknown
}): CliOutputBlock {
return {
kind: 'cli-output',
id: props.id,
localId: props.localId,
createdAt: props.createdAt,
text: props.text,
source: props.source,
meta: props.meta
}
}
function mergeCliOutputBlocks(blocks: ChatBlock[]): ChatBlock[] {
const merged: ChatBlock[] = []
for (const block of blocks) {
if (block.kind !== 'cli-output') {
merged.push(block)
continue
}
const prev = merged[merged.length - 1]
if (
prev
&& prev.kind === 'cli-output'
&& prev.source === block.source
&& hasCommandNameTag(prev.text)
&& !hasLocalCommandStdoutTag(prev.text)
&& hasLocalCommandStdoutTag(block.text)
) {
const separator = prev.text.endsWith('\n') || block.text.startsWith('\n') ? '' : '\n'
merged[merged.length - 1] = { ...prev, text: `${prev.text}${separator}${block.text}` }
continue
}
merged.push(block)
}
return merged
}
function ensureToolBlock(
blocks: ChatBlock[],
toolBlocksById: Map<string, ToolCallBlock>,
id: string,
seed: {
createdAt: number
localId: string | null
meta?: unknown
name: string
input: unknown
description: string | null
permission?: ToolPermission
}
): ToolCallBlock {
const existing = toolBlocksById.get(id)
if (existing) {
const isPlaceholderToolName = (name: string): boolean => {
const normalized = name.trim().toLowerCase()
return normalized === '' || normalized === 'tool' || normalized === 'unknown'
}
// Preserve earliest createdAt for stable ordering.
if (seed.createdAt < existing.createdAt) {
existing.createdAt = seed.createdAt
existing.tool.createdAt = seed.createdAt
}
if (seed.permission) {
existing.tool.permission = { ...existing.tool.permission, ...seed.permission }
if (existing.tool.state === 'running' && seed.permission.status === 'pending') {
existing.tool.state = 'pending'
}
}
if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) {
existing.tool.name = seed.name
}
if (seed.input !== null && seed.input !== undefined) {
existing.tool.input = seed.input
}
if (seed.description !== null) {
existing.tool.description = seed.description
}
return existing
}
const initialState: ChatToolCall['state'] = seed.permission?.status === 'pending'
? 'pending'
: seed.permission?.status === 'denied' || seed.permission?.status === 'canceled'
? 'error'
: 'running'
const tool: ChatToolCall = {
id,
name: seed.name,
state: initialState,
input: seed.input,
createdAt: seed.createdAt,
startedAt: initialState === 'running' ? seed.createdAt : null,
completedAt: null,
description: seed.description,
permission: seed.permission
}
const block: ToolCallBlock = {
kind: 'tool-call',
id,
localId: seed.localId,
createdAt: seed.createdAt,
tool,
children: [],
meta: seed.meta
}
toolBlocksById.set(id, block)
blocks.push(block)
return block
}
function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set<string> {
const ids = new Set<string>()
for (const msg of messages) {
if (msg.role !== 'agent') continue
for (const content of msg.content) {
if (content.type === 'tool-call') {
ids.add(content.id)
} else if (content.type === 'tool-result') {
ids.add(content.tool_use_id)
}
}
}
return ids
}
function reduceTimeline(
messages: TracedMessage[],
context: {
permissionsById: Map<string, PermissionEntry>
groups: Map<string, TracedMessage[]>
consumedGroupIds: Set<string>
titleChangesByToolUseId: Map<string, string>
emittedTitleChangeToolUseIds: Set<string>
}
): { blocks: ChatBlock[]; toolBlocksById: Map<string, ToolCallBlock>; hasReadyEvent: boolean } {
const blocks: ChatBlock[] = []
const toolBlocksById = new Map<string, ToolCallBlock>()
let hasReadyEvent = false
for (const msg of messages) {
if (msg.role === 'event') {
if (msg.content.type === 'ready') {
hasReadyEvent = true
continue
}
blocks.push({
kind: 'agent-event',
id: msg.id,
createdAt: msg.createdAt,
event: msg.content,
meta: msg.meta
})
continue
}
const event = parseMessageAsEvent(msg)
if (event) {
blocks.push({
kind: 'agent-event',
id: msg.id,
createdAt: msg.createdAt,
event,
meta: msg.meta
})
continue
}
if (msg.role === 'user') {
if (isCliOutputText(msg.content.text, msg.meta)) {
blocks.push(createCliOutputBlock({
id: msg.id,
localId: msg.localId,
createdAt: msg.createdAt,
text: msg.content.text,
source: 'user',
meta: msg.meta
}))
continue
}
blocks.push({
kind: 'user-text',
id: msg.id,
localId: msg.localId,
createdAt: msg.createdAt,
text: msg.content.text,
status: msg.status,
originalText: msg.originalText,
meta: msg.meta
})
continue
}
if (msg.role === 'agent') {
for (let idx = 0; idx < msg.content.length; idx += 1) {
const c = msg.content[idx]
if (c.type === 'text') {
if (isCliOutputText(c.text, msg.meta)) {
blocks.push(createCliOutputBlock({
id: `${msg.id}:${idx}`,
localId: msg.localId,
createdAt: msg.createdAt,
text: c.text,
source: 'assistant',
meta: msg.meta
}))
continue
}
blocks.push({
kind: 'agent-text',
id: `${msg.id}:${idx}`,
localId: msg.localId,
createdAt: msg.createdAt,
text: c.text,
meta: msg.meta
})
continue
}
if (c.type === 'reasoning') {
blocks.push({
kind: 'agent-reasoning',
id: `${msg.id}:${idx}`,
localId: msg.localId,
createdAt: msg.createdAt,
text: c.text,
meta: msg.meta
})
continue
}
if (c.type === 'summary') {
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'message', message: c.summary },
meta: msg.meta
})
continue
}
if (c.type === 'tool-call') {
if (c.name === 'mcp__hapi__change_title') {
const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input)
if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) {
context.emittedTitleChangeToolUseIds.add(c.id)
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'title-changed', title },
meta: msg.meta
})
}
continue
}
const permission = context.permissionsById.get(c.id)?.permission
const block = ensureToolBlock(blocks, toolBlocksById, c.id, {
createdAt: msg.createdAt,
localId: msg.localId,
meta: msg.meta,
name: c.name,
input: c.input,
description: c.description,
permission
})
if (block.tool.state === 'pending') {
block.tool.state = 'running'
block.tool.startedAt = msg.createdAt
}
if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) {
const sidechain = context.groups.get(msg.id) ?? null
if (sidechain && sidechain.length > 0) {
context.consumedGroupIds.add(msg.id)
const child = reduceTimeline(sidechain, context)
hasReadyEvent = hasReadyEvent || child.hasReadyEvent
block.children = child.blocks
}
}
continue
}
if (c.type === 'tool-result') {
const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null
if (title) {
if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) {
context.emittedTitleChangeToolUseIds.add(c.tool_use_id)
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'title-changed', title },
meta: msg.meta
})
}
continue
}
const permissionEntry = context.permissionsById.get(c.tool_use_id)
const permissionFromResult = c.permissions ? ({
id: c.tool_use_id,
status: c.permissions.result === 'approved' ? 'approved' : 'denied',
date: c.permissions.date,
mode: c.permissions.mode,
allowedTools: c.permissions.allowedTools,
decision: c.permissions.decision
} satisfies ToolPermission) : undefined
const permission = (() => {
if (permissionFromResult && permissionEntry?.permission) {
return {
...permissionEntry.permission,
...permissionFromResult,
allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools,
decision: permissionFromResult.decision ?? permissionEntry.permission.decision
} satisfies ToolPermission
}
return permissionFromResult ?? permissionEntry?.permission
})()
const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, {
createdAt: msg.createdAt,
localId: msg.localId,
meta: msg.meta,
name: permissionEntry?.toolName ?? 'Tool',
input: permissionEntry?.input ?? null,
description: null,
permission
})
block.tool.result = c.content
block.tool.completedAt = msg.createdAt
block.tool.state = c.is_error ? 'error' : 'completed'
continue
}
if (c.type === 'sidechain') {
blocks.push({
kind: 'user-text',
id: `${msg.id}:${idx}`,
localId: null,
createdAt: msg.createdAt,
text: c.prompt
})
}
}
}
}
return { blocks: mergeCliOutputBlocks(blocks), toolBlocksById, hasReadyEvent }
}
export type LatestUsage = {
inputTokens: number
outputTokens: number