d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
232 lines
9.6 KiB
TypeScript
232 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useChat } from '@ai-sdk/react'
|
|
import { DefaultChatTransport } from 'ai'
|
|
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
|
|
import { Streamdown } from 'streamdown'
|
|
import { cn } from '@/lib/utils'
|
|
import 'streamdown/styles.css'
|
|
|
|
interface DocSource {
|
|
title: string
|
|
url: string
|
|
}
|
|
|
|
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
|
|
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
|
|
const seen = new Set<string>()
|
|
const sources: DocSource[] = []
|
|
|
|
for (const part of parts) {
|
|
if (part.type !== 'tool-searchDocs') continue
|
|
const output = (part as { output?: unknown }).output
|
|
if (!Array.isArray(output)) continue
|
|
for (const item of output as DocSource[]) {
|
|
if (!item?.url || seen.has(item.url)) continue
|
|
seen.add(item.url)
|
|
sources.push({ title: item.title, url: item.url })
|
|
}
|
|
}
|
|
|
|
return sources
|
|
}
|
|
|
|
/** Concatenate the streamed text parts of a message. */
|
|
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
|
|
return parts
|
|
.filter((part) => part.type === 'text')
|
|
.map((part) => (part as unknown as { text: string }).text)
|
|
.join('')
|
|
}
|
|
|
|
interface AskAIProps {
|
|
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
|
|
locale: string
|
|
}
|
|
|
|
export function AskAI({ locale }: AskAIProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [input, setInput] = useState('')
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Stable transport; the locale is sent per-message (below) so it stays current
|
|
// after a language switch instead of being frozen into the transport.
|
|
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
|
|
|
|
const { messages, sendMessage, status, stop, error } = useChat({ transport })
|
|
|
|
const isBusy = status === 'submitted' || status === 'streaming'
|
|
|
|
// Jump to the bottom instantly when the panel opens (a mount transition).
|
|
useEffect(() => {
|
|
if (!open) return
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
}, [open])
|
|
|
|
// Smooth-scroll as new messages stream in (an explicit re-orientation cue).
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
const handleSubmit = (event: FormEvent) => {
|
|
event.preventDefault()
|
|
const text = input.trim()
|
|
if (!text || isBusy) return
|
|
sendMessage({ text }, { body: { locale } })
|
|
setInput('')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{!open && (
|
|
<button
|
|
type='button'
|
|
aria-label='Ask Sim'
|
|
onClick={() => setOpen(true)}
|
|
className='fixed right-4 bottom-4 z-50 flex h-11 items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-4 font-season text-[var(--text-body)] text-sm shadow-[var(--shadow-medium)] transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
|
|
>
|
|
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
|
Ask Sim
|
|
</button>
|
|
)}
|
|
|
|
{open && (
|
|
<div className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'>
|
|
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
|
|
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
|
|
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
|
Ask Sim
|
|
</span>
|
|
<button
|
|
type='button'
|
|
aria-label='Close'
|
|
onClick={() => {
|
|
stop()
|
|
setOpen(false)
|
|
}}
|
|
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
|
|
>
|
|
<X className='size-[16px]' />
|
|
</button>
|
|
</div>
|
|
|
|
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
|
|
{messages.length === 0 && (
|
|
<p className='text-[var(--text-muted)] text-sm'>
|
|
Ask anything about building, deploying, and managing AI agents in Sim.
|
|
</p>
|
|
)}
|
|
|
|
{messages.map((message, index) => {
|
|
const text = getText(message.parts)
|
|
// Only the in-progress (last) message should show the loading state.
|
|
const isStreaming = isBusy && index === messages.length - 1
|
|
const sources = message.role === 'assistant' ? getSources(message.parts) : []
|
|
return (
|
|
<div
|
|
key={message.id}
|
|
className={cn(
|
|
'flex flex-col gap-1.5',
|
|
message.role === 'user' ? 'items-end' : 'items-start'
|
|
)}
|
|
>
|
|
{message.role === 'user' ? (
|
|
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
|
|
{text}
|
|
</div>
|
|
) : (
|
|
<div className='max-w-full text-[var(--text-primary)] text-base'>
|
|
{text ? (
|
|
<Streamdown
|
|
className={cn(
|
|
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
|
|
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
|
|
'[&_strong]:font-[600]',
|
|
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
|
|
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
|
|
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
|
|
)}
|
|
>
|
|
{text}
|
|
</Streamdown>
|
|
) : isStreaming ? (
|
|
'…'
|
|
) : sources.length === 0 ? (
|
|
<span className='text-[var(--text-muted)]'>No answer returned.</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{sources.length > 0 && (
|
|
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
|
|
{sources.map((source) => (
|
|
<a
|
|
key={source.url}
|
|
href={source.url}
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
|
|
>
|
|
{source.title || source.url}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{error && (
|
|
<p className='text-[var(--text-muted)] text-sm'>
|
|
Something went wrong. Please try again.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className='px-3 pb-3'>
|
|
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
|
|
<textarea
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
handleSubmit(event)
|
|
}
|
|
}}
|
|
rows={1}
|
|
placeholder='Ask Sim about the docs…'
|
|
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
|
|
/>
|
|
{isBusy ? (
|
|
<button
|
|
type='button'
|
|
aria-label='Stop'
|
|
onClick={() => stop()}
|
|
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
|
>
|
|
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type='submit'
|
|
aria-label='Send'
|
|
disabled={!input.trim()}
|
|
className={cn(
|
|
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
|
|
input.trim()
|
|
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
|
: 'bg-[#808080]'
|
|
)}
|
|
>
|
|
<ArrowUp className='size-[16px] text-white dark:text-black' />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|