Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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>
)}
</>
)
}