mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
Implement CLI output message type for displaying command output from user/assistant messages. Adds CliOutputBlock component and type with detection logic based on message metadata and CLI tags. Includes merging of adjacent CLI output blocks for cleaner presentation. Enhance layout throughout components with proper overflow handling and width constraints for improved text wrapping and scrolling behavior.
217 lines
9.4 KiB
TypeScript
217 lines
9.4 KiB
TypeScript
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
|
import type { ChatBlock } from '@/chat/types'
|
|
import type { ToolCallBlock } from '@/chat/types'
|
|
import { getEventPresentation } from '@/chat/presentation'
|
|
import { CodeBlock } from '@/components/CodeBlock'
|
|
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
|
|
import { LazyRainbowText } from '@/components/LazyRainbowText'
|
|
import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator'
|
|
import { ToolCard } from '@/components/ToolCard/ToolCard'
|
|
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
|
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object'
|
|
}
|
|
|
|
function safeStringify(value: unknown): string {
|
|
if (typeof value === 'string') return value
|
|
try {
|
|
const stringified = JSON.stringify(value, null, 2)
|
|
return typeof stringified === 'string' ? stringified : String(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function isToolCallBlock(value: unknown): value is ToolCallBlock {
|
|
if (!isObject(value)) return false
|
|
if (value.kind !== 'tool-call') return false
|
|
if (typeof value.id !== 'string') return false
|
|
if (value.localId !== null && typeof value.localId !== 'string') return false
|
|
if (typeof value.createdAt !== 'number') return false
|
|
if (!Array.isArray(value.children)) return false
|
|
if (!isObject(value.tool)) return false
|
|
if (typeof value.tool.name !== 'string') return false
|
|
if (!('input' in value.tool)) return false
|
|
if (value.tool.description !== null && typeof value.tool.description !== 'string') return false
|
|
if (value.tool.state !== 'pending' && value.tool.state !== 'running' && value.tool.state !== 'completed' && value.tool.state !== 'error') return false
|
|
return true
|
|
}
|
|
|
|
function HappyNestedBlockList(props: {
|
|
blocks: ChatBlock[]
|
|
}) {
|
|
const ctx = useHappyChatContext()
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{props.blocks.map((block) => {
|
|
if (block.kind === 'user-text') {
|
|
const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm'
|
|
const status = block.status
|
|
const canRetry = status === 'failed' && typeof block.localId === 'string' && Boolean(ctx.onRetryMessage)
|
|
const onRetry = canRetry ? () => ctx.onRetryMessage!(block.localId!) : undefined
|
|
|
|
return (
|
|
<div key={`user:${block.id}`} className={userBubbleClass}>
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<LazyRainbowText text={block.text} />
|
|
</div>
|
|
{status ? (
|
|
<div className="shrink-0 self-end pb-0.5">
|
|
<MessageStatusIndicator status={status} onRetry={onRetry} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (block.kind === 'agent-text') {
|
|
return (
|
|
<div key={`agent:${block.id}`} className="px-1">
|
|
<MarkdownRenderer content={block.text} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (block.kind === 'cli-output') {
|
|
const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : ''
|
|
return (
|
|
<div key={`cli:${block.id}`} className="px-1 min-w-0 max-w-full overflow-x-hidden">
|
|
<div className={alignClass}>
|
|
<CliOutputBlock text={block.text} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (block.kind === 'agent-event') {
|
|
const presentation = getEventPresentation(block.event)
|
|
return (
|
|
<div key={`event:${block.id}`} className="py-1">
|
|
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
|
|
<span className="inline-flex items-center gap-1">
|
|
{presentation.icon ? <span aria-hidden="true">{presentation.icon}</span> : null}
|
|
<span>{presentation.text}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (block.kind === 'tool-call') {
|
|
const isTask = block.tool.name === 'Task'
|
|
|
|
return (
|
|
<div key={`tool:${block.id}`} className="py-1">
|
|
<ToolCard
|
|
api={ctx.api}
|
|
sessionId={ctx.sessionId}
|
|
metadata={ctx.metadata}
|
|
disabled={ctx.disabled}
|
|
onDone={ctx.onRefresh}
|
|
block={block}
|
|
/>
|
|
{block.children.length > 0 ? (
|
|
isTask ? (
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
|
|
Task details ({block.children.length})
|
|
</summary>
|
|
<div className="mt-2 pl-3">
|
|
<HappyNestedBlockList blocks={block.children} />
|
|
</div>
|
|
</details>
|
|
) : (
|
|
<div className="mt-2 pl-3">
|
|
<HappyNestedBlockList blocks={block.children} />
|
|
</div>
|
|
)
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return null
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function HappyToolMessage(props: ToolCallMessagePartProps) {
|
|
const ctx = useHappyChatContext()
|
|
const artifact = props.artifact
|
|
|
|
if (!isToolCallBlock(artifact)) {
|
|
const argsText = typeof props.argsText === 'string' ? props.argsText.trim() : ''
|
|
const hasArgsText = argsText.length > 0
|
|
const hasResult = props.result !== undefined
|
|
const resultText = hasResult ? safeStringify(props.result) : ''
|
|
|
|
return (
|
|
<div className="py-1 min-w-0 max-w-full overflow-x-hidden">
|
|
<div className="rounded-xl bg-[var(--app-secondary-bg)] p-3 shadow-sm">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<div className="font-mono text-[var(--app-hint)]">
|
|
Tool: {props.toolName}
|
|
</div>
|
|
{props.isError ? (
|
|
<span className="text-red-500">Error</span>
|
|
) : null}
|
|
{props.status.type === 'running' && !hasResult ? (
|
|
<span className="text-[var(--app-hint)]">Running…</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{hasArgsText ? (
|
|
<div className="mt-2">
|
|
<CodeBlock code={argsText} language="json" />
|
|
</div>
|
|
) : null}
|
|
|
|
{hasResult ? (
|
|
<div className="mt-2">
|
|
<CodeBlock code={resultText} language={typeof props.result === 'string' ? 'text' : 'json'} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const block = artifact
|
|
const isTask = block.tool.name === 'Task'
|
|
|
|
return (
|
|
<div className="py-1 min-w-0 max-w-full overflow-x-hidden">
|
|
<ToolCard
|
|
api={ctx.api}
|
|
sessionId={ctx.sessionId}
|
|
metadata={ctx.metadata}
|
|
disabled={ctx.disabled}
|
|
onDone={ctx.onRefresh}
|
|
block={block}
|
|
/>
|
|
{block.children.length > 0 ? (
|
|
isTask ? (
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
|
|
Task details ({block.children.length})
|
|
</summary>
|
|
<div className="mt-2 pl-3">
|
|
<HappyNestedBlockList blocks={block.children} />
|
|
</div>
|
|
</details>
|
|
) : (
|
|
<div className="mt-2 pl-3">
|
|
<HappyNestedBlockList blocks={block.children} />
|
|
</div>
|
|
)
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|