Skip to content

Commit 33f75c5

Browse files
committed
feat: enhance chat components and token usage tracking
- Refactor AgentReasoning component to remove autoClose prop. - Update ChatHeader to include detailed output and input token information. - Add SelectedAttachments component in ChatInput for better file management. - Improve ChatMessages by extracting source documents from message parts. - Extend TokenUsage interface to include inputTokenDetails for better tracking. - Modify ChatProvider to handle detailed token usage data including cache details.
1 parent d233d57 commit 33f75c5

6 files changed

Lines changed: 243 additions & 50 deletions

File tree

app/chat/components/agent-reasoning.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export function AgentReasoning({
2222
<Reasoning
2323
isStreaming={isStreaming}
2424
duration={duration}
25-
autoClose={false}
2625
className={className}
2726
>
2827
<ReasoningTrigger />

app/chat/components/chat-header.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ export function ChatHeader() {
199199
inputTokens: usage.inputTokens,
200200
outputTokens: usage.outputTokens,
201201
totalTokens: usage.totalTokens,
202+
outputTokenDetails:
203+
typeof usage.outputTokens === 'number'
204+
? { textTokens: usage.outputTokens, reasoningTokens: 0 }
205+
: {
206+
textTokens: (usage.outputTokens as unknown as { textTokens?: number }).textTokens ?? 0,
207+
reasoningTokens: (usage.outputTokens as unknown as { reasoningTokens?: number }).reasoningTokens ?? 0,
208+
},
209+
inputTokenDetails: {
210+
cacheReadTokens: (usage.inputTokenDetails as unknown as { cacheReadTokens?: number }).cacheReadTokens ?? 0,
211+
cacheWriteTokens: (usage.inputTokenDetails as unknown as { cacheWriteTokens?: number }).cacheWriteTokens ?? 0,
212+
noCacheTokens: (usage.inputTokenDetails as unknown as { noCacheTokens?: number }).noCacheTokens ?? 0,
213+
}
202214
}}
203215
>
204216
<ContextTrigger />

app/chat/components/chat-input.tsx

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,13 @@ import {
77
PromptInputTools,
88
PromptInputButton,
99
PromptInputSubmit,
10-
PromptInputAttachments,
11-
PromptInputAttachment,
1210
PromptInputHeader,
1311
PromptInputBody,
1412
PromptInputActionMenu,
1513
PromptInputActionMenuTrigger,
1614
PromptInputActionMenuContent,
1715
PromptInputActionAddAttachments,
18-
PromptInputSpeechButton,
16+
usePromptInputAttachments,
1917
} from '@/src/components/ai-elements/prompt-input'
2018
import {
2119
ModelSelector,
@@ -48,6 +46,7 @@ import {
4846
MicIcon,
4947
SparklesIcon,
5048
ListTodoIcon,
49+
XIcon,
5150
} from 'lucide-react'
5251
import { useMemo, useState, useRef } from 'react'
5352
import { MODEL_CONFIGS } from '../config/models'
@@ -58,6 +57,35 @@ import {
5857
} from '../config/agents'
5958
import { cn } from '@/lib/utils'
6059

60+
function SelectedAttachments() {
61+
const { attachments, removeAttachment } = usePromptInputAttachments()
62+
63+
if (attachments.length === 0) {return null}
64+
65+
return (
66+
<div className="flex flex-wrap gap-2 p-2 border-b border-border/50">
67+
{attachments.map((file, index) => (
68+
<Badge
69+
key={`${file.name}-${index}`}
70+
variant="secondary"
71+
className="h-6 gap-1 pr-1"
72+
>
73+
<span className="max-w-30 truncate text-[10px]">
74+
{file.name}
75+
</span>
76+
<button
77+
type="button"
78+
onClick={() => removeAttachment(index)}
79+
className="rounded-full hover:bg-foreground/10 transition-colors"
80+
>
81+
<XIcon className="size-3" />
82+
</button>
83+
</Badge>
84+
))}
85+
</div>
86+
)
87+
}
88+
6189
export function ChatInput() {
6290
const {
6391
sendMessage,
@@ -90,16 +118,9 @@ export function ChatInput() {
90118

91119
const agentsByCategory = useMemo(() => getAgentsByCategory(), [])
92120

93-
/* Agent Selector - compact dropdown in input toolbar */
94-
95-
// Model Selector
96-
97-
const handleSubmit = async (message: {
98-
text: string
99-
files: unknown[]
100-
}) => {
121+
const handleSubmit = (message: { text: string; files: File[] }) => {
101122
if (message.text.trim()) {
102-
sendMessage(message.text, message.files as File[])
123+
sendMessage(message.text, message.files)
103124
setInput('')
104125
}
105126
}
@@ -125,7 +146,6 @@ export function ChatInput() {
125146
<span className="flex items-center gap-1.5">
126147
<BotIcon className="size-3" />
127148
{agentConfig?.name ?? selectedAgent}
128-
129149
</span>
130150
<span className="flex items-center gap-1.5">
131151
<CpuIcon className="size-3" />
@@ -154,16 +174,7 @@ export function ChatInput() {
154174
globalDrop
155175
>
156176
<PromptInputHeader>
157-
{supportsFiles && (
158-
<PromptInputAttachments>
159-
{(file) => (
160-
<PromptInputAttachment
161-
key={file.id}
162-
data={file}
163-
/>
164-
)}
165-
</PromptInputAttachments>
166-
)}
177+
{supportsFiles && <SelectedAttachments />}
167178
</PromptInputHeader>
168179

169180
<PromptInputBody>
@@ -187,18 +198,17 @@ export function ChatInput() {
187198
</PromptInputActionMenuContent>
188199
</PromptInputActionMenu>
189200

190-
<PromptInputSpeechButton
191-
onTranscriptionChange={setInput}
192-
textareaRef={textareaRef}
201+
<PromptInputButton
193202
className={cn(
194203
'magnetic transition-all duration-300',
195204
isSpeaking &&
196205
'text-primary glow-primary animate-ambient-pulse scale-110'
197206
)}
198207
onClick={() => setIsSpeaking(!isSpeaking)}
208+
title="Speech to text"
199209
>
200210
<MicIcon className="size-4" />
201-
</PromptInputSpeechButton>
211+
</PromptInputButton>
202212

203213
<PromptInputButton
204214
onClick={() => {

app/chat/components/chat-messages.tsx

Lines changed: 121 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import {
1515
MessageToolbar,
1616
MessageActions,
1717
MessageAction,
18-
MessageAttachment,
19-
MessageAttachments,
2018
} from '@/src/components/ai-elements/message'
2119
import { Loader } from '@/src/components/ai-elements/loader'
2220
import {
@@ -54,13 +52,57 @@ import {
5452
ActivityIcon,
5553
NetworkIcon,
5654
} from 'lucide-react'
57-
import { useState, useCallback, useMemo, Fragment } from 'react'
58-
import type { UIMessage, FileUIPart } from 'ai'
55+
import { useState, useCallback, useMemo, Fragment, memo } from 'react'
56+
import type {
57+
UIMessage,
58+
DynamicToolUIPart,
59+
TextUIPart,
60+
ReasoningUIPart,
61+
ToolUIPart,
62+
TextStreamPart,
63+
TextPart,
64+
ToolResultPart,
65+
ReasoningOutput,
66+
UIDataPartSchemas,
67+
UIMessageChunk,
68+
UIMessagePart,
69+
DataContent,
70+
FinishReason,
71+
FileUIPart,
72+
Tool,
73+
DataUIPart,
74+
SourceDocumentUIPart,
75+
SourceUrlUIPart,
76+
StepResult,
77+
PrepareStepResult,
78+
StepStartUIPart,
79+
InferSchema,
80+
InferUIDataParts,
81+
InferAgentUIMessage,
82+
InferToolInput,
83+
InferUIMessageChunk,
84+
InferToolOutput,
85+
InferUITool,
86+
InferUITools,
87+
InferGenerateOutput,
88+
InferStreamOutput,
89+
} from 'ai'
5990
import {
60-
isTextUIPart,
61-
isReasoningUIPart,
62-
isToolOrDynamicToolUIPart,
91+
safeValidateUIMessages,
92+
getToolName,
93+
getStaticToolName,
94+
getTextFromDataUrl,
95+
isDataUIPart,
6396
isFileUIPart,
97+
isReasoningUIPart,
98+
isTextUIPart,
99+
isToolUIPart,
100+
isStaticToolUIPart,
101+
isDeepEqualData,
102+
InvalidResponseDataError,
103+
InvalidMessageRoleError,
104+
InvalidArgumentError,
105+
generateId
64106
} from 'ai'
65107
import type { BundledLanguage } from 'shiki'
66108
import { Button } from '@/ui/button'
@@ -84,6 +126,58 @@ type MastraDataPart =
84126
| NetworkDataPart
85127
| { type: `data-${string}`; id?: string; data: unknown }
86128

129+
interface ChatMessagesProps {
130+
messages: UIMessage[]
131+
status: string
132+
error: Error | undefined
133+
onSuggestionClick: (suggestion: string) => void
134+
onCopyMessage?: (messageId: string, content: string) => void
135+
onRegenerate?: (messageId: string) => void
136+
}
137+
138+
interface SourceDocument {
139+
title?: string
140+
url?: string
141+
description?: string
142+
sourceDocument?: string
143+
}
144+
145+
146+
function isSourceUrlPart(part: UIMessage['parts'][number]): part is SourceUrlUIPart {
147+
return part.type === 'source-url'
148+
}
149+
150+
function isSourceDocumentPart(
151+
part: UIMessage['parts'][number]
152+
): part is SourceDocumentUIPart {
153+
return part.type === 'source-document'
154+
}
155+
156+
157+
// Extract sources from message parts
158+
const getSourcesFromParts = (parts: UIMessage['parts']): SourceDocument[] => {
159+
const sources: SourceDocument[] = []
160+
for (const part of parts) {
161+
if (isSourceUrlPart(part)) {
162+
sources.push({
163+
title: part.title,
164+
url: part.url,
165+
description: part.url,
166+
})
167+
continue
168+
}
169+
170+
if (isSourceDocumentPart(part)) {
171+
sources.push({
172+
title: part.title,
173+
description: part.filename ?? part.mediaType,
174+
})
175+
}
176+
}
177+
return sources
178+
}
179+
180+
87181
/**
88182
* Type guard to check for type property
89183
*/
@@ -207,8 +301,8 @@ function WorkflowDataSection({ part }: { part: WorkflowDataPart }) {
207301
workflowData.status === 'success' || (workflowData.status as string) === 'completed'
208302
? 'default'
209303
: workflowData.status === 'failed'
210-
? 'destructive'
211-
: 'secondary'
304+
? 'destructive'
305+
: 'secondary'
212306
}
213307
className="text-xs"
214308
>
@@ -243,8 +337,8 @@ function WorkflowDataSection({ part }: { part: WorkflowDataPart }) {
243337
? 'default'
244338
: stepData.status ===
245339
'failed'
246-
? 'destructive'
247-
: 'secondary'
340+
? 'destructive'
341+
: 'secondary'
248342
}
249343
className="text-xs"
250344
>
@@ -364,8 +458,8 @@ function NetworkDataSection({ part }: { part: NetworkDataPart }) {
364458
step.status === 'success' || (step.status as string) === 'completed'
365459
? 'default'
366460
: step.status === 'failed'
367-
? 'destructive'
368-
: 'secondary'
461+
? 'destructive'
462+
: 'secondary'
369463
}
370464
className="text-xs"
371465
>
@@ -726,7 +820,7 @@ function MessageItem({
726820
const tools: ToolInvocationState[] = []
727821

728822
for (const p of parts) {
729-
if (isToolOrDynamicToolUIPart(p)) {
823+
if (isToolUIPart(p)) {
730824
tools.push(p as ToolInvocationState)
731825
}
732826
}
@@ -778,8 +872,8 @@ function MessageItem({
778872
statusText === 'in-progress'
779873
? 'in-progress'
780874
: statusText.trim().length > 0
781-
? 'done'
782-
: ''
875+
? 'done'
876+
: ''
783877
return {
784878
message: messageText,
785879
status: normalizedStatus,
@@ -904,14 +998,20 @@ function MessageItem({
904998
<MessageContent>
905999
{/* User file attachments */}
9061000
{isUser && fileParts && fileParts.length > 0 && (
907-
<MessageAttachments>
1001+
<div className="my-2 flex flex-wrap gap-2">
9081002
{fileParts.map((file, idx) => (
909-
<MessageAttachment
1003+
<div
9101004
key={`file-${idx}`}
911-
data={file}
912-
/>
1005+
className="flex items-center gap-2 rounded-lg border bg-muted/20 px-3 py-1.5 text-xs font-medium"
1006+
>
1007+
<span className="truncate max-w-50">
1008+
{file.filename ??
1009+
file.mediaType ??
1010+
`File ${idx + 1}`}
1011+
</span>
1012+
</div>
9131013
))}
914-
</MessageAttachments>
1014+
</div>
9151015
)}
9161016

9171017
{/* Non-image files with inline preview controls */}

app/chat/providers/chat-context-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Source {
2929
}
3030

3131
export interface TokenUsage {
32+
inputTokenDetails: any
3233
inputTokens: number
3334
outputTokens: number
3435
totalTokens: number

0 commit comments

Comments
 (0)