chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
'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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user