Nicholai 337117f895
feat(ui): add streaming state and loading skeleton support (#70)
implement short-term generative UI improvements:

- Add StreamingState type ("started" | "streaming" | "done") to ComponentContext
- Add loadingComponent field to catalog for Card, StatCard, DataTable, SchedulePreview
- Create skeleton components (CardSkeleton, StatCardSkeleton, etc.) with animate-pulse
- Add PropSkeleton component for different prop types (text, badge, table-row, etc.)
- Add useStreamingProps/useStreamingProp hooks to track prop arrival during streaming
- Add ComponentLoadingWrapper for prop-level skeleton display (opt-in via enablePropSkeletons)
- Fix JSON parse errors in API routes when request body is incomplete

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-12 06:36:21 -07:00

171 lines
4.2 KiB
TypeScript
Executable File

import {
streamText,
stepCountIs,
convertToModelMessages,
RetryError,
type UIMessage,
} from "ai"
import { APICallError } from "@ai-sdk/provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
resolveModelForUser,
createModelFromId,
DEFAULT_MODEL_ID,
} from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools"
import { githubTools } from "@/lib/agent/github-tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
import { getRegistry } from "@/lib/agent/plugins/registry"
import { saveStreamUsage } from "@/lib/agent/usage"
import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db"
export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
const { env, ctx } = await getCloudflareContext()
const db = getDb(env.DB)
const envRecord = env as unknown as Record<string, string>
const apiKey = envRecord.OPENROUTER_API_KEY
if (!apiKey) {
return new Response(
JSON.stringify({
error: "OPENROUTER_API_KEY not configured",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
const { getCustomDashboards } = await import(
"@/app/actions/dashboards"
)
const [memories, registry, dashboardResult] =
await Promise.all([
loadMemoriesForPrompt(db, user.id),
getRegistry(db, envRecord),
getCustomDashboards(),
])
const pluginSections = registry.getPromptSections()
const pluginTools = registry.getTools()
let body: { messages: UIMessage[] }
try {
body = (await req.json()) as { messages: UIMessage[] }
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
const currentPage =
req.headers.get("x-current-page") ?? undefined
const timezone =
req.headers.get("x-timezone") ?? undefined
const conversationId =
req.headers.get("x-conversation-id") ||
crypto.randomUUID()
let modelId = await resolveModelForUser(db, user.id)
if (!modelId || !modelId.includes("/")) {
console.error(
`Invalid model ID resolved: "${modelId}",` +
` falling back to default`
)
modelId = DEFAULT_MODEL_ID
}
const model = createModelFromId(apiKey, modelId)
const result = streamText({
model,
system: buildSystemPrompt({
userName: user.displayName ?? user.email,
userRole: user.role,
currentPage,
timezone,
memories,
pluginSections,
dashboards: dashboardResult.success
? dashboardResult.data
: [],
mode: "full",
}),
messages: await convertToModelMessages(
body.messages
),
tools: {
...agentTools,
...githubTools,
...pluginTools,
},
toolChoice: "auto",
stopWhen: stepCountIs(10),
onError({ error }) {
const apiErr = unwrapAPICallError(error)
if (apiErr) {
console.error(
`Agent API error [model=${modelId}]`,
`status=${apiErr.statusCode}`,
`body=${apiErr.responseBody}`
)
} else {
const msg =
error instanceof Error
? error.message
: String(error)
console.error(
`Agent error [model=${modelId}]:`,
msg
)
}
},
})
ctx.waitUntil(
saveStreamUsage(
db,
conversationId,
user.id,
modelId,
result
)
)
return result.toUIMessageStreamResponse({
onError(error) {
const apiErr = unwrapAPICallError(error)
if (apiErr) {
return (
apiErr.responseBody ??
`Provider error (${apiErr.statusCode})`
)
}
return error instanceof Error
? error.message
: "Unknown error"
},
})
}
function unwrapAPICallError(
error: unknown
): APICallError | undefined {
if (APICallError.isInstance(error)) return error
if (RetryError.isInstance(error)) {
const last: unknown = error.lastError
if (APICallError.isInstance(last)) return last
}
return undefined
}