Skip to content
10 changes: 10 additions & 0 deletions apps/kitchensink-react/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ResourceProvider} from '@sanity/sdk-react'
import {type JSX} from 'react'
import {Route, Routes} from 'react-router'

Expand All @@ -14,6 +15,7 @@ import {SearchRoute} from './DocumentCollection/SearchRoute'
import {PresenceRoute} from './Presence/PresenceRoute'
import {ProjectAuthHome} from './ProjectAuthentication/ProjectAuthHome'
import {ProtectedRoute} from './ProtectedRoute'
import {AgentActionsRoute} from './routes/AgentActionsRoute'
import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
Expand Down Expand Up @@ -121,6 +123,14 @@ export function AppRoutes(): JSX.Element {
{documentCollectionRoutes.map((route) => (
<Route key={route.path} path={route.path} element={route.element} />
))}
<Route
path="agent-actions"
element={
<ResourceProvider projectId="vo1ysemo" dataset="production" fallback={null}>
<AgentActionsRoute />
</ResourceProvider>
}
/>
<Route path="users/:userId" element={<UserDetailRoute />} />
<Route path="comlink-demo" element={<ParentApp />} />
<Route path="releases" element={<ReleasesRoute />} />
Expand Down
136 changes: 136 additions & 0 deletions apps/kitchensink-react/src/routes/AgentActionsRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
type AgentGenerateOptions,
type AgentPromptOptions,
useAgentGenerate,
useAgentPrompt,
} from '@sanity/sdk-react'
import {Box, Button, Card, Code, Label, Stack, Text} from '@sanity/ui'
import {type JSX, useMemo, useState} from 'react'

import {PageLayout} from '../components/PageLayout'

export function AgentActionsRoute(): JSX.Element {
const generate = useAgentGenerate()
const prompt = useAgentPrompt()
const [text, setText] = useState('Write a short poem about typescript and cats')
const [promptResult, setPromptResult] = useState<string>('')
const [generateResult, setGenerateResult] = useState<string>('')
const [isLoadingPrompt, setIsLoadingPrompt] = useState(false)
const [isLoadingGenerate, setIsLoadingGenerate] = useState(false)
const generateOptions = useMemo<AgentGenerateOptions>(() => {
return {
// Use the schema collection id (workspace name), not the type name
schemaId: '_.schemas.default',
targetDocument: {
operation: 'create',
_id: crypto.randomUUID(),
_type: 'movie',
},
instruction:
'Generate a title and overview for a movie about $topic based on a famous movie. Try to not pick the same movie as someone else would pick.',
instructionParams: {topic: 'Sanity SDK'},
target: {include: ['title', 'overview']},
noWrite: true,
}
}, [])

const promptOptions = useMemo<AgentPromptOptions>(
() => ({instruction: text, format: 'string'}),
[text],
)

return (
<PageLayout title="Agent Actions" subtitle="Prompt and generate using the movie schema">
<Stack space={4}>
<Card padding={4} radius={2} shadow={1} tone="inherit">
<Stack space={3}>
<Label size={1}>Prompt</Label>
<Text muted size={1}>
Sends an instruction to the LLM and returns plain text (or JSON if requested). Does
not reference a schema or write any data.
</Text>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isLoadingPrompt}
style={{
width: '100%',
height: 120,
border: '1px solid #ccc',
borderRadius: 4,
padding: 8,
}}
/>
<Box>
<Button
text="Run prompt"
tone="primary"
disabled={isLoadingPrompt}
onClick={() => {
setIsLoadingPrompt(true)
prompt(promptOptions)
.then((value) => setPromptResult(String(value ?? '')))
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err)
})
.finally(() => setIsLoadingPrompt(false))
}}
/>
</Box>
{promptResult && (
<Card padding={3} radius={2} tone="transparent">
<Text>
<Code style={{whiteSpace: 'pre-wrap'}}>{promptResult}</Code>
</Text>
</Card>
)}
</Stack>
</Card>

<Card padding={4} radius={2} shadow={1} tone="inherit">
<Stack space={3}>
<Label size={1}>Generaten a Sanity document (no write)</Label>
<Text muted size={1}>
Generates title and overview for a movie; does not persist changes.
</Text>
<Text muted size={1}>
Schema‑aware content generation targeting the current project/dataset. Use schemaId of
your document type (e.g. &quot;movie&quot;) and target to specify fields. Set noWrite
to preview without saving.
</Text>
<Box>
<Button
text="Generate"
tone="primary"
disabled={isLoadingGenerate}
onClick={() => {
setIsLoadingGenerate(true)
const sub = generate(generateOptions).subscribe({
next: (value) => {
setGenerateResult(JSON.stringify(value, null, 2))
setIsLoadingGenerate(false)
sub.unsubscribe()
},
error: (err) => {
// eslint-disable-next-line no-console
console.error(err)
setIsLoadingGenerate(false)
},
})
}}
/>
</Box>
{generateResult && (
<Card padding={3} radius={2} tone="transparent">
<Text>
<Code style={{whiteSpace: 'pre-wrap'}}>{generateResult}</Code>
</Text>
</Card>
)}
</Stack>
</Card>
</Stack>
</PageLayout>
)
}
19 changes: 19 additions & 0 deletions packages/core/src/_exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import {type SanityProject as _SanityProject} from '@sanity/client'
*/
export type SanityProject = _SanityProject

export type {
AgentGenerateOptions,
AgentGenerateResult,
AgentPatchOptions,
AgentPatchResult,
AgentPromptOptions,
AgentPromptResult,
AgentTransformOptions,
AgentTransformResult,
AgentTranslateOptions,
AgentTranslateResult,
} from '../agent/agentActions'
export {
agentGenerate,
agentPatch,
agentPrompt,
agentTransform,
agentTranslate,
} from '../agent/agentActions'
export {AuthStateType} from '../auth/authStateType'
export {
type AuthState,
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/agent/agentActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {firstValueFrom, of} from 'rxjs'
import {beforeEach, describe, expect, it, vi} from 'vitest'

import {
agentGenerate,
agentPatch,
agentPrompt,
agentTransform,
agentTranslate,
} from './agentActions'

let mockClient: any

vi.mock('../client/clientStore', () => {
return {
getClientState: () => ({observable: of(mockClient)}),
}
})

describe('agent actions', () => {
beforeEach(() => {
mockClient = {
observable: {
agent: {
action: {
generate: vi.fn(),
transform: vi.fn(),
translate: vi.fn(),
},
},
},
agent: {
action: {
prompt: vi.fn(),
patch: vi.fn(),
},
},
}
})

it('agentGenerate returns observable from client', async () => {
mockClient.observable.agent.action.generate.mockReturnValue(of('gen'))
const instance = {config: {projectId: 'p', dataset: 'd'}} as any
const value = await firstValueFrom(agentGenerate(instance, {foo: 'bar'} as any))
expect(value).toBe('gen')
expect(mockClient.observable.agent.action.generate).toHaveBeenCalledWith({foo: 'bar'})
})

it('agentTransform returns observable from client', async () => {
mockClient.observable.agent.action.transform.mockReturnValue(of('xform'))
const instance = {config: {projectId: 'p', dataset: 'd'}} as any
const value = await firstValueFrom(agentTransform(instance, {a: 1} as any))
expect(value).toBe('xform')
expect(mockClient.observable.agent.action.transform).toHaveBeenCalledWith({a: 1})
})

it('agentTranslate returns observable from client', async () => {
mockClient.observable.agent.action.translate.mockReturnValue(of('xlate'))
const instance = {config: {projectId: 'p', dataset: 'd'}} as any
const value = await firstValueFrom(agentTranslate(instance, {b: 2} as any))
expect(value).toBe('xlate')
expect(mockClient.observable.agent.action.translate).toHaveBeenCalledWith({b: 2})
})

it('agentPrompt wraps promise into observable', async () => {
mockClient.agent.action.prompt.mockResolvedValue('prompted')
const instance = {config: {projectId: 'p', dataset: 'd'}} as any
const value = await firstValueFrom(agentPrompt(instance, {p: true} as any))
expect(value).toBe('prompted')
expect(mockClient.agent.action.prompt).toHaveBeenCalledWith({p: true})
})

it('agentPatch wraps promise into observable', async () => {
mockClient.agent.action.patch.mockResolvedValue('patched')
const instance = {config: {projectId: 'p', dataset: 'd'}} as any
const value = await firstValueFrom(agentPatch(instance, {q: false} as any))
expect(value).toBe('patched')
expect(mockClient.agent.action.patch).toHaveBeenCalledWith({q: false})
})
})
Loading
Loading