Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,332 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { inferLiveToolName } from "@/lib/tool-call-normalization"
import {
countUnifiedDiffLineChanges,
estimateChangedLineStats,
} from "@/lib/line-change-stats"
import { FilePenLine, Timer, Wrench } from "lucide-react"
function formatElapsed(ms: number): string {
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
return `${(ms / 1_000).toFixed(1)}s`
}
interface LiveTurnStatsProps {
message: LiveMessage
}
interface LineChangeStats {
additions: number
deletions: number
}
interface LiveEditStats extends LineChangeStats {
files: number
}
const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
})
function formatCompactInt(n: number): string {
if (n < 1000) return String(n)
return COMPACT_NUMBER.format(n).toUpperCase()
}
function asObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseInputObject(
input: string | null
): Record<string, unknown> | null {
if (!input) return null
try {
return asObject(JSON.parse(input))
} catch {
return null
}
}
function unescapeInlineEscapes(text: string): string {
return text
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
}
function looksLikeDiffPayload(input: string): boolean {
const normalized = unescapeInlineEscapes(input)
return (
normalized.includes("*** Begin Patch") ||
normalized.includes("*** Update File:") ||
/^diff --git /m.test(normalized) ||
(/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) ||
/^@@ /m.test(normalized)
)
}
function extractPatchText(
rawInput: string | null,
parsed: Record<string, unknown> | null
): string | null {
if (!rawInput) return null
if (looksLikeDiffPayload(rawInput)) return unescapeInlineEscapes(rawInput)
if (!parsed) return null
const candidates = [
parsed.patch,
parsed.diff,
parsed.unified_diff,
parsed.unifiedDiff,
parsed.command,
parsed.input,
parsed.arguments,
parsed.payload,
]
for (const candidate of candidates) {
if (typeof candidate !== "string") continue
if (looksLikeDiffPayload(candidate)) return unescapeInlineEscapes(candidate)
}
return null
}
function addPathIfValid(paths: Set<string>, value: unknown): void {
if (typeof value !== "string") return
const path = value.trim()
if (!path) return
paths.add(path)
}
function collectParsedPaths(
parsed: Record<string, unknown> | null
): Set<string> {
const paths = new Set<string>()
if (!parsed) return paths
addPathIfValid(
paths,
parsed.file_path ?? parsed.filePath ?? parsed.path ?? parsed.notebook_path
)
const changes = asObject(parsed.changes)
if (changes) {
for (const path of Object.keys(changes)) {
addPathIfValid(paths, path)
}
}
return paths
}
function parseApplyPatchStats(patch: string): {
files: Set<string>
additions: number
deletions: number
} {
const files = new Set<string>()
let additions = 0
let deletions = 0
for (const line of patch.split("\n")) {
if (line.startsWith("*** Add File: ")) {
addPathIfValid(files, line.slice(14))
continue
}
if (line.startsWith("*** Update File: ")) {
addPathIfValid(files, line.slice(17))
continue
}
if (line.startsWith("*** Delete File: ")) {
addPathIfValid(files, line.slice(17))
continue
}
if (line.startsWith("+++ ")) {
const normalized = line.slice(4).replace(/^b\//, "").trim()
if (normalized && normalized !== "/dev/null") {
files.add(normalized)
}
continue
}
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
}
return { files, additions, deletions }
}
function extractEditStats(parsed: Record<string, unknown>): LineChangeStats {
const changes = asObject(parsed.changes)
if (changes) {
let additions = 0
let deletions = 0
for (const change of Object.values(changes)) {
const record = asObject(change)
if (!record) continue
const unifiedDiff =
(typeof record.unifiedDiff === "string" && record.unifiedDiff) ||
(typeof record.unified_diff === "string" && record.unified_diff) ||
null
if (unifiedDiff) {
const stats = countUnifiedDiffLineChanges(unifiedDiff)
additions += stats.additions
deletions += stats.deletions
continue
}
const oldString =
(typeof record.oldText === "string" && record.oldText) ||
(typeof record.old_string === "string" && record.old_string) ||
""
const newString =
(typeof record.newText === "string" && record.newText) ||
(typeof record.new_string === "string" && record.new_string) ||
""
const estimated = estimateChangedLineStats(oldString, newString)
additions += estimated.additions
deletions += estimated.deletions
}
return { additions, deletions }
}
const oldString =
(typeof parsed.old_string === "string" && parsed.old_string) ||
(typeof parsed.oldText === "string" && parsed.oldText) ||
""
const newString =
(typeof parsed.new_string === "string" && parsed.new_string) ||
(typeof parsed.newText === "string" && parsed.newText) ||
""
return estimateChangedLineStats(oldString, newString)
}
function extractWriteStats(parsed: Record<string, unknown>): LineChangeStats {
const content =
(typeof parsed.content === "string" && parsed.content) ||
(typeof parsed.new_source === "string" && parsed.new_source) ||
""
const additions = content.length === 0 ? 0 : content.split("\n").length
return { additions, deletions: 0 }
}
function extractLiveEditStats(message: LiveMessage): LiveEditStats {
const files = new Set<string>()
let additions = 0
let deletions = 0
for (const block of message.content) {
if (block.type !== "tool_call") continue
const toolName = inferLiveToolName({
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
if (
toolName !== "edit" &&
toolName !== "write" &&
toolName !== "apply_patch"
)
continue
const parsed = parseInputObject(block.info.raw_input)
for (const path of collectParsedPaths(parsed)) files.add(path)
if (toolName === "apply_patch") {
const patch = extractPatchText(block.info.raw_input, parsed)
if (!patch) continue
const stats = parseApplyPatchStats(patch)
for (const path of stats.files) files.add(path)
additions += stats.additions
deletions += stats.deletions
continue
}
if (!parsed) continue
const stats =
toolName === "edit" ? extractEditStats(parsed) : extractWriteStats(parsed)
additions += stats.additions
deletions += stats.deletions
}
return { files: files.size, additions, deletions }
}
export function LiveTurnStats({ message }: LiveTurnStatsProps) {
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
const editStats = useMemo(() => extractLiveEditStats(message), [message])
useEffect(() => {
const timer = setInterval(() => {
setElapsed(Date.now() - message.startedAt)
}, 100)
return () => clearInterval(timer)
}, [message.startedAt])
// Count tool calls from live content
let toolCallCount = 0
let isThinking = false
for (const block of message.content) {
if (block.type === "tool_call") {
toolCallCount++
} else if (block.type === "thinking") {
isThinking = true
}
}
// If the last block is thinking, mark as currently thinking
const lastBlock = message.content[message.content.length - 1]
if (lastBlock?.type === "thinking") {
isThinking = true
}
return (
<div className="flex h-8 shrink-0 items-center justify-center gap-3 px-4 text-xs leading-none text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse shrink-0" />
{isThinking && message.content.length <= 1 ? (
<span>Thinking...</span>
) : (
<span>Streaming</span>
)}
<span className="text-border leading-none">|</span>
<span className="inline-flex items-center gap-1 leading-none">
<Timer className="h-3 w-3 shrink-0" />
{formatElapsed(elapsed)}
</span>
{editStats.files > 0 && (
<>
<span className="text-border leading-none">|</span>
<span className="inline-flex items-center gap-1 leading-none">
<FilePenLine className="h-3 w-3 shrink-0" />
{editStats.files}F +{formatCompactInt(editStats.additions)}/-
{formatCompactInt(editStats.deletions)}
</span>
</>
)}
{toolCallCount > 0 && (
<>
<span className="text-border leading-none">|</span>
<span className="inline-flex items-center gap-1 leading-none">
<Wrench className="h-3 w-3 shrink-0" />
{toolCallCount} tool {toolCallCount === 1 ? "use" : "uses"}
</span>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,60 @@
"use client"
import { memo } from "react"
import { formatDistanceToNow } from "date-fns"
import { User, Bot, Terminal, Cpu } from "lucide-react"
import { cn } from "@/lib/utils"
import { ContentPartsRenderer } from "./content-parts-renderer"
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
interface MessageBubbleProps {
message: AdaptedMessage
}
function RoleIcon({ role }: { role: string }) {
switch (role) {
case "user":
return <User className="h-4 w-4" />
case "assistant":
return <Bot className="h-4 w-4" />
case "system":
return <Cpu className="h-4 w-4" />
case "tool":
return <Terminal className="h-4 w-4" />
default:
return <Bot className="h-4 w-4" />
}
}
export const MessageBubble = memo(function MessageBubble({
message,
}: MessageBubbleProps) {
const isUser = message.role === "user"
const timeAgo = formatDistanceToNow(new Date(message.timestamp), {
addSuffix: true,
})
return (
<div className={cn("flex gap-3 px-4 py-3", isUser ? "bg-muted/30" : "")}>
<div
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5",
isUser
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground"
)}
>
<RoleIcon role={message.role} />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium capitalize">{message.role}</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
<ContentPartsRenderer parts={message.content} role={message.role} />
</div>
</div>
)
})

View File

@@ -0,0 +1,320 @@
"use client"
import { memo, useEffect, useMemo, useRef } from "react"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import { ContentPartsRenderer } from "./content-parts-renderer"
import {
adaptMessageTurns,
type AdaptedMessage,
type AdaptedContentPart,
type MessageGroup,
type UserResourceDisplay,
groupAdaptedMessages,
extractUserResourcesFromText,
} from "@/lib/adapters/ai-elements-adapter"
import { TurnStats } from "./turn-stats"
import { LiveTurnStats } from "./live-turn-stats"
import { UserResourceLinks } from "./user-resource-links"
import { useSessionStats } from "@/contexts/session-stats-context"
import { LiveMessageBlock } from "@/components/chat/live-message-block"
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import {
MessageThread,
MessageThreadContent,
} from "@/components/ai-elements/message-thread"
import { Message, MessageContent } from "@/components/ai-elements/message"
import { Loader2 } from "lucide-react"
import {
buildPlanKey,
extractLatestPlanEntriesFromMessages,
} from "@/lib/agent-plan"
import type { ConnectionStatus } from "@/lib/types"
interface MessageListViewProps {
conversationId: number
connStatus?: ConnectionStatus | null
liveMessage?: LiveMessage | null
pendingMessages?: AdaptedMessage[]
onPendingClear?: () => void
isActive?: boolean
}
interface ResolvedMessageGroup extends MessageGroup {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
}
function fallbackExtractUserResources(group: MessageGroup): {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
} {
if (group.role !== "user") {
return {
parts: group.parts,
resources: group.userResources ?? [],
}
}
const parsedResources: UserResourceDisplay[] = []
const parsedParts: AdaptedContentPart[] = []
for (const part of group.parts) {
if (part.type !== "text") {
parsedParts.push(part)
continue
}
const extracted = extractUserResourcesFromText(part.text)
if (extracted.resources.length > 0) {
parsedResources.push(...extracted.resources)
if (extracted.text.length > 0) {
parsedParts.push({ type: "text", text: extracted.text })
}
} else {
parsedParts.push(part)
}
}
const resources = [...(group.userResources ?? []), ...parsedResources]
const dedupedResources: UserResourceDisplay[] = []
const seen = new Set<string>()
for (const resource of resources) {
const key = `${resource.name}::${resource.uri}`
if (seen.has(key)) continue
seen.add(key)
dedupedResources.push(resource)
}
if (parsedParts.length === 0 && dedupedResources.length > 0) {
parsedParts.push({ type: "text", text: "Attached resources" })
}
return { parts: parsedParts, resources: dedupedResources }
}
function resolveMessageGroup(group: MessageGroup): ResolvedMessageGroup {
const resolved = fallbackExtractUserResources(group)
return {
...group,
parts: resolved.parts,
resources: resolved.resources,
}
}
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group,
}: {
group: ResolvedMessageGroup
}) {
return (
<div
style={{
contentVisibility: "auto",
containIntrinsicSize: "auto 120px",
}}
>
<Message from={group.role}>
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
{group.role === "assistant" && (
<TurnStats
usage={group.usage}
duration_ms={group.duration_ms}
model={group.model}
models={group.models}
/>
)}
</div>
)
})
const PendingMessageGroup = memo(function PendingMessageGroup({
group,
}: {
group: ResolvedMessageGroup
}) {
return (
<div className="opacity-70">
<Message from={group.role}>
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
</div>
)
})
export function MessageListView({
conversationId,
connStatus,
liveMessage,
pendingMessages,
onPendingClear,
isActive = true,
}: MessageListViewProps) {
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
const turnCount = detail?.turns.length ?? 0
// Refetch when agent turn completes (prompting → other status)
const prevStatusRef = useRef(connStatus)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = connStatus
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
refetch()
}
}, [connStatus, refetch])
// Clear pending when detail gains new turns (new data fetched successfully)
const prevTurnCountRef = useRef(turnCount)
const prevConvIdRef = useRef(conversationId)
useEffect(() => {
if (prevConvIdRef.current !== conversationId) {
// Conversation switched — reset baseline, don't clear
prevConvIdRef.current = conversationId
prevTurnCountRef.current = turnCount
return
}
if (turnCount > prevTurnCountRef.current && onPendingClear) {
onPendingClear()
}
prevTurnCountRef.current = turnCount
}, [turnCount, onPendingClear, conversationId])
const { setSessionStats } = useSessionStats()
const sessionStats = detail?.session_stats ?? null
useEffect(() => {
if (isActive) {
setSessionStats(sessionStats)
}
}, [isActive, sessionStats, setSessionStats])
// Track whether the initial scroll has happened.
// After that, disable resize-triggered scroll so tab switches
// don't jump to the bottom.
const shouldUseSmoothResize = !(isActive && !loading && detail)
const messages = useMemo(
() => (detail ? adaptMessageTurns(detail.turns) : []),
[detail]
)
const groups = useMemo(() => groupAdaptedMessages(messages), [messages])
const historicalPlanEntries = useMemo(
() => extractLatestPlanEntriesFromMessages(messages),
[messages]
)
const historicalPlanKey = useMemo(
() => buildPlanKey(historicalPlanEntries),
[historicalPlanEntries]
)
const pendingGroups = useMemo(
() =>
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
[pendingMessages]
)
const resolvedGroups = useMemo(
() => groups.map(resolveMessageGroup),
[groups]
)
const resolvedPendingGroups = useMemo(
() => pendingGroups.map(resolveMessageGroup),
[pendingGroups]
)
const showLiveMessage = Boolean(
liveMessage &&
(connStatus === "prompting" ||
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
)
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
if (loading && !detail) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-destructive text-sm">Error: {error}</p>
</div>
</div>
)
}
if (!detail) return null
return (
<div className="relative flex h-full min-h-0 flex-col">
{/* Messages */}
<MessageThread
className="flex-1 min-h-0"
resize={shouldUseSmoothResize ? "smooth" : undefined}
>
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
{resolvedGroups.length === 0 &&
resolvedPendingGroups.length === 0 &&
!showLiveMessage ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-sm">
No messages in this conversation.
</p>
</div>
) : (
<>
{resolvedGroups.map((group) => (
<HistoricalMessageGroup key={group.id} group={group} />
))}
{resolvedPendingGroups.map((group) => (
<PendingMessageGroup key={group.id} group={group} />
))}
{resolvedPendingGroups.length > 0 && !showLiveMessage && (
<Message from="assistant">
<MessageContent>
<div className="flex items-center gap-1.5 py-1">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
</div>
</MessageContent>
</Message>
)}
{showLiveMessage && liveMessage && (
<LiveMessageBlock message={liveMessage} />
)}
</>
)}
</MessageThreadContent>
</MessageThread>
{showLiveMessage && liveMessage && (
<LiveTurnStats message={liveMessage} />
)}
<AgentPlanOverlay
key={agentPlanOverlayKey}
message={liveMessage ?? null}
entries={historicalPlanEntries}
planKey={historicalPlanKey}
defaultExpanded={showLiveMessage}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import { useState } from "react"
import { ChevronRight, ChevronDown, Wrench, AlertCircle } from "lucide-react"
import { cn } from "@/lib/utils"
interface ToolCallBlockProps {
type: "tool_use" | "tool_result"
toolName?: string
content: string | null
isError?: boolean
}
export function ToolCallBlock({
type,
toolName,
content,
isError = false,
}: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false)
return (
<div
className={cn(
"border rounded-md text-xs",
isError
? "border-destructive/30 bg-destructive/5"
: "border-border bg-muted/30"
)}
>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors"
>
{expanded ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
{type === "tool_use" ? (
<>
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{toolName || "Tool"}</span>
</>
) : (
<>
{isError ? (
<AlertCircle className="h-3 w-3 shrink-0 text-destructive" />
) : (
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
<span className="font-medium">{isError ? "Error" : "Result"}</span>
</>
)}
</button>
{expanded && content && (
<div className="px-3 pb-2 border-t border-border">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all mt-2 max-h-64 overflow-auto">
{content}
</pre>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,52 @@
import type { TurnUsage } from "@/lib/types"
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
return `${(ms / 1_000).toFixed(1)}s`
}
interface TurnStatsProps {
usage?: TurnUsage | null
duration_ms?: number | null
model?: string | null
models?: string[]
}
export function TurnStats({
usage,
duration_ms,
model,
models,
}: TurnStatsProps) {
if (!usage && !duration_ms) return null
const displayModels = models?.length ? models : model ? [model] : []
const parts: string[] = []
if (displayModels.length > 0) parts.push(displayModels.join(", "))
if (usage) {
parts.push(`${formatTokenCount(usage.input_tokens)} input`)
parts.push(`${formatTokenCount(usage.output_tokens)} output`)
if (usage.cache_read_input_tokens > 0)
parts.push(
`${formatTokenCount(usage.cache_read_input_tokens)} cache read`
)
if (usage.cache_creation_input_tokens > 0)
parts.push(
`${formatTokenCount(usage.cache_creation_input_tokens)} cache write`
)
}
if (duration_ms) parts.push(formatDuration(duration_ms))
return (
<div className="text-xs text-muted-foreground mt-2">
{"[ " + parts.join(" · ") + " ]"}
</div>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { FileSearch } from "lucide-react"
import type { UserResourceDisplay } from "@/lib/adapters/ai-elements-adapter"
interface UserResourceLinksProps {
resources: UserResourceDisplay[]
className?: string
}
export function UserResourceLinks({
resources,
className,
}: UserResourceLinksProps) {
if (resources.length === 0) return null
return (
<div className={className}>
<div className="flex flex-wrap gap-1.5">
{resources.map((resource, index) => (
<div
key={`${resource.uri}-${index}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
>
<FileSearch className="h-3 w-3" />
<span className="max-w-56 truncate">{resource.name}</span>
</div>
))}
</div>
</div>
)
}