This commit is contained in:
113
apps/worker/src/ai.ts
Normal file
113
apps/worker/src/ai.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, type WorldStarter } from '@dng/shared'
|
||||
import type { DiceRoll } from '@dng/shared'
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
|
||||
interface AiProvider {
|
||||
name: 'openrouter' | 'deepseek'
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
model: string
|
||||
headers: Record<string, string>
|
||||
providerOptions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getProvider(): AiProvider {
|
||||
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
|
||||
if (provider === 'deepseek') {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is not configured')
|
||||
return {
|
||||
name: 'deepseek',
|
||||
endpoint: process.env.DEEPSEEK_API_ENDPOINT ?? 'https://api.deepseek.com/chat/completions',
|
||||
apiKey,
|
||||
model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',
|
||||
headers: {},
|
||||
}
|
||||
}
|
||||
if (provider !== 'openrouter') throw new Error(`Unsupported AI_PROVIDER: ${provider}`)
|
||||
const apiKey = process.env.OPENROUTER_API_KEY
|
||||
if (!apiKey) throw new Error('OPENROUTER_API_KEY is not configured')
|
||||
return {
|
||||
name: 'openrouter',
|
||||
endpoint: process.env.OPENROUTER_API_ENDPOINT ?? 'https://openrouter.ai/api/v1/chat/completions',
|
||||
apiKey,
|
||||
model: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
|
||||
headers: {
|
||||
'HTTP-Referer': process.env.APP_URL ?? 'http://localhost:3000',
|
||||
'X-Title': 'Dungeons & Ground',
|
||||
},
|
||||
providerOptions: { require_parameters: true, data_collection: 'deny', allow_fallbacks: true },
|
||||
}
|
||||
}
|
||||
|
||||
function assert13Plus(...texts: string[]): void {
|
||||
const result = moderate13Plus(texts.join('\n'))
|
||||
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
|
||||
}
|
||||
|
||||
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T): Promise<T> {
|
||||
const provider = getProvider()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 45_000)
|
||||
try {
|
||||
const response = await fetch(provider.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...provider.headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: provider.name === 'deepseek'
|
||||
? [
|
||||
{ role: 'system', content: `${messages[0]?.content ?? ''}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(schema)}` },
|
||||
...messages.slice(1),
|
||||
]
|
||||
: messages,
|
||||
temperature: 0.65,
|
||||
max_tokens: 4000,
|
||||
...(provider.providerOptions ? { provider: provider.providerOptions } : {}),
|
||||
...(provider.name === 'deepseek' ? { thinking: { type: 'disabled' } } : {}),
|
||||
response_format: provider.name === 'deepseek'
|
||||
? { type: 'json_object' }
|
||||
: { type: 'json_schema', json_schema: { name, strict: true, schema } },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
|
||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: unknown }
|
||||
const content = payload.choices?.[0]?.message?.content
|
||||
if (!content) throw new Error('AI provider returned an empty response')
|
||||
return parse(JSON.parse(content))
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
||||
assert13Plus(...messages.map(message => message.content))
|
||||
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs and two factions. Make the opening immediately playable.' },
|
||||
...messages,
|
||||
], value => WorldStarterSchema.parse(value))
|
||||
assert13Plus(world.title, world.premise, world.hook, world.hiddenThreat, world.openingScene)
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): Promise<RoundPlan> {
|
||||
return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { scene: string; intents: PlayerIntent[]; aiActions: RoundPlan['aiActions']; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round using the supplied, authoritative dice results. Do not change them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundResolutionSchema.parse(value))
|
||||
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
|
||||
return resolution
|
||||
}
|
||||
Reference in New Issue
Block a user