276 lines
15 KiB
Vue
276 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { WorldStarterSchema, type WorldStarter } from '@dng/shared'
|
||
|
||
type Stage = 'seed' | 'questions' | 'generating' | 'preview' | 'confirming'
|
||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||
interface DynamicQuestion { id: string; label: string; options: string[] }
|
||
interface RespondResult { readyToGenerate: boolean; question?: DynamicQuestion }
|
||
interface StoredMessage { role: 'user' | 'assistant'; content: string }
|
||
interface StoredSession {
|
||
id: string
|
||
status: 'collecting' | 'generating' | 'ready' | 'failed' | 'confirmed'
|
||
messages: StoredMessage[]
|
||
current_question?: DynamicQuestion | null
|
||
generatedWorld?: unknown
|
||
generated_world?: unknown
|
||
confirmed_world_id?: string | null
|
||
}
|
||
|
||
const questionLimit = 4
|
||
|
||
const { api } = useDngApi()
|
||
const route = useRoute()
|
||
const stage = ref<Stage>('seed')
|
||
const busy = ref(false)
|
||
const sessionId = ref<string | null>(null)
|
||
const seed = ref('A science-fiction mystery about a dead relay sending messages from the future.')
|
||
const answers = reactive<Record<string, string>>({})
|
||
const currentQuestion = ref<DynamicQuestion | null>(null)
|
||
const answerCount = ref(0)
|
||
const draft = ref<WorldStarter | null>(null)
|
||
const retryAction = ref<'begin' | 'respond' | 'generate'>('begin')
|
||
const pendingAnswer = ref<{ key: string; value: string } | null>(null)
|
||
const messages = ref<Message[]>([
|
||
{ id: 'welcome', role: 'coauthor', body: 'Give me the first impossible sentence. I’ll ask a few focused questions, then turn it into a private world your party can enter tonight.' },
|
||
])
|
||
|
||
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
||
const conversationMessages = computed(() => {
|
||
if (!currentQuestion.value) return messages.value
|
||
const currentIndex = messages.value.findLastIndex(message =>
|
||
message.role === 'coauthor' && message.body === currentQuestion.value?.label,
|
||
)
|
||
return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex)
|
||
})
|
||
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, questionLimit) / questionLimit) * 55))
|
||
|
||
function addMessage(role: Message['role'], body: string, label?: string) {
|
||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
||
}
|
||
|
||
async function begin() {
|
||
const prompt = seed.value.trim()
|
||
if (!prompt || stage.value !== 'seed' || busy.value) return
|
||
busy.value = true
|
||
retryAction.value = 'begin'
|
||
try {
|
||
const result = await api<{ session: { id: string } }>('/api/v1/coauthor/sessions', { method: 'POST', body: { message: prompt } })
|
||
sessionId.value = result.session.id
|
||
addMessage('player', prompt)
|
||
stage.value = 'questions'
|
||
retryAction.value = 'respond'
|
||
await requestNextQuestion()
|
||
} catch (error) {
|
||
addMessage('status', errorText(error) || 'Could not start a private coauthor session. Sign in and try again.', 'ERROR')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function resume(id: string) {
|
||
busy.value = true
|
||
retryAction.value = 'respond'
|
||
try {
|
||
const result = await api<{ session: StoredSession }>(`/api/v1/coauthor/sessions/${id}`)
|
||
const session = result.session
|
||
sessionId.value = session.id
|
||
const storedMessages = Array.isArray(session.messages) ? session.messages : []
|
||
seed.value = storedMessages.find(message => message.role === 'user')?.content ?? seed.value
|
||
answerCount.value = Math.max(0, storedMessages.filter(message => message.role === 'user').length - 1)
|
||
let questionNumber = 0
|
||
messages.value = [messages.value[0]!, ...storedMessages.map((message, index) => {
|
||
if (message.role === 'assistant') questionNumber += 1
|
||
return {
|
||
id: `stored-${index}`,
|
||
role: message.role === 'assistant' ? 'coauthor' as const : 'player' as const,
|
||
body: message.content,
|
||
label: message.role === 'assistant' ? `COAUTHOR · QUESTION ${questionNumber} OF 4` : undefined,
|
||
}
|
||
})]
|
||
|
||
if (session.status === 'confirmed') {
|
||
await navigateTo('/dashboard')
|
||
return
|
||
}
|
||
if (session.status === 'ready') {
|
||
const parsed = WorldStarterSchema.safeParse(session.generatedWorld ?? session.generated_world)
|
||
if (!parsed.success) throw new Error('This saved world draft is invalid. Generate it again.')
|
||
draft.value = parsed.data
|
||
stage.value = 'preview'
|
||
addMessage('status', `“${parsed.data.title}” was restored.`, 'DRAFT RESTORED')
|
||
return
|
||
}
|
||
if (session.status === 'generating') {
|
||
busy.value = false
|
||
await generate()
|
||
return
|
||
}
|
||
if (session.status === 'failed') {
|
||
stage.value = 'questions'
|
||
retryAction.value = 'generate'
|
||
addMessage('status', 'The previous generation failed. Your answers are safe.', 'ERROR')
|
||
return
|
||
}
|
||
|
||
stage.value = 'questions'
|
||
currentQuestion.value = session.current_question ?? null
|
||
if (!currentQuestion.value) await requestNextQuestion()
|
||
} catch (error) {
|
||
stage.value = 'seed'
|
||
sessionId.value = null
|
||
addMessage('status', errorText(error) || 'This saved coauthor session could not be restored.', 'ERROR')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function answerQuestion(key: string, value: string) {
|
||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||
const answeredQuestion = currentQuestion.value
|
||
busy.value = true
|
||
pendingAnswer.value = { key, value }
|
||
try {
|
||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/messages`, {
|
||
method: 'POST', body: { content: value },
|
||
})
|
||
answers[key] = value
|
||
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
|
||
addMessage('player', value)
|
||
currentQuestion.value = null
|
||
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
|
||
pendingAnswer.value = null
|
||
retryAction.value = 'respond'
|
||
await requestNextQuestion()
|
||
} catch (error) {
|
||
addMessage('status', errorText(error) || 'That answer could not be saved. Try again.', 'ERROR')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function requestNextQuestion() {
|
||
if (!sessionId.value) return
|
||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
||
if (result.readyToGenerate || answerCount.value >= questionLimit) {
|
||
currentQuestion.value = null
|
||
await generate()
|
||
return
|
||
}
|
||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||
currentQuestion.value = result.question
|
||
}
|
||
|
||
function errorText(error: unknown) {
|
||
if (error && typeof error === 'object') {
|
||
const value = error as { data?: { statusMessage?: string; message?: string }; statusMessage?: string; message?: string }
|
||
return value.data?.statusMessage || value.data?.message || value.statusMessage || value.message
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
async function generate() {
|
||
if (!sessionId.value) return
|
||
stage.value = 'generating'
|
||
retryAction.value = 'generate'
|
||
addMessage('status', 'Building premise, location, key people, factions, hidden pressure, and an opening scene…', 'GENERATING')
|
||
try {
|
||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/generate`, { method: 'POST' })
|
||
const deadline = Date.now() + 90_000
|
||
while (Date.now() < deadline) {
|
||
const result = await api<{ session: { status: string; generatedWorld?: unknown; generated_world?: unknown } }>(`/api/v1/coauthor/sessions/${sessionId.value}`)
|
||
if (result.session.status === 'ready') {
|
||
draft.value = (result.session.generatedWorld ?? result.session.generated_world) as WorldStarter
|
||
break
|
||
}
|
||
if (result.session.status === 'failed') throw new Error('The coauthor job failed. Try generation again.')
|
||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||
}
|
||
if (!draft.value) throw new Error('World generation is taking too long. Your session is saved; try again shortly.')
|
||
messages.value.pop()
|
||
addMessage('status', `“${draft.value.title}” is ready for your review. Every visible field remains editable.`, 'READY')
|
||
stage.value = 'preview'
|
||
} catch (error) {
|
||
messages.value.pop()
|
||
addMessage('status', errorText(error) || 'The coauthor could not generate this world. Your answers are safe—try again.', 'ERROR')
|
||
stage.value = 'questions'
|
||
}
|
||
}
|
||
|
||
function retry() {
|
||
const last = messages.value.at(-1)
|
||
if (last?.role === 'status' && last.label === 'ERROR') messages.value.pop()
|
||
if (retryAction.value === 'begin') begin()
|
||
else if (retryAction.value === 'generate') generate()
|
||
else if (pendingAnswer.value && currentQuestion.value) answerQuestion(pendingAnswer.value.key, pendingAnswer.value.value)
|
||
else requestNextQuestion().catch(error => addMessage('status', errorText(error) || 'The coauthor could not continue. Try again.', 'ERROR'))
|
||
}
|
||
|
||
function revise() {
|
||
stage.value = 'seed'
|
||
sessionId.value = null
|
||
currentQuestion.value = null
|
||
answerCount.value = 0
|
||
pendingAnswer.value = null
|
||
for (const key of Object.keys(answers)) delete answers[key]
|
||
draft.value = null
|
||
addMessage('coauthor', 'Let’s reshape it from the opening sentence. I’ll adapt the next questions to your new direction.', 'COAUTHOR · REVISION')
|
||
}
|
||
|
||
async function confirm() {
|
||
if (!draft.value || !sessionId.value || stage.value === 'confirming') return
|
||
stage.value = 'confirming'
|
||
try {
|
||
const confirmed = await api<{ worldId: string }>(`/api/v1/coauthor/sessions/${sessionId.value}/confirm`, {
|
||
method: 'POST', body: { world: draft.value },
|
||
})
|
||
const created = await api<{ campaignId: string }>('/api/v1/campaigns', {
|
||
method: 'POST', body: { worldId: confirmed.worldId, title: draft.value.title },
|
||
})
|
||
await navigateTo(`/campaign/${created.campaignId}`)
|
||
} catch (error) {
|
||
addMessage('status', errorText(error) || 'The world could not be confirmed. Your draft is still safe.', 'ERROR')
|
||
stage.value = 'preview'
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
const resumeId = typeof route.query.session === 'string' ? route.query.session : ''
|
||
if (resumeId) void resume(resumeId)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AppShell section="WORLD FORGE">
|
||
<div class="forge noise">
|
||
<aside>
|
||
<p class="step-label">CREATION PROTOCOL</p>
|
||
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
|
||
<ol>
|
||
<li :class="{ active: stage === 'seed', done: stage !== 'seed' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||
<li :class="{ active: stage === 'questions', done: answerCount >= 4 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / 4 answers captured.</small></span></li>
|
||
<li :class="{ active: stage === 'generating', done: stage === 'preview' || stage === 'confirming' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||
<li :class="{ active: stage === 'preview' || stage === 'confirming' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||
</ol>
|
||
<div class="boundary"><span>13+ BOUNDARY</span><p>Dark themes are welcome. Explicit sexual content and extreme graphic violence are not.</p></div>
|
||
</aside>
|
||
|
||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
||
<CoauthorConversation :messages="conversationMessages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||
</form>
|
||
<div v-if="stage === 'generating'" class="scanner" aria-label="Generating world"><i /><i /><i /><span>D&G</span></div>
|
||
</main>
|
||
|
||
<CoauthorWorldPreview v-else-if="draft" v-model="draft" @revise="revise" @confirm="confirm" />
|
||
</div>
|
||
</AppShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}.progress{height:3px;margin-top:22px;background:#252522}.progress i{display:block;height:100%;background:var(--acid);transition:width .35s ease}ol{list-style:none;padding:20px 0;margin:0}li{display:flex;gap:18px;padding:18px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b,li.done b{color:var(--acid)}li.active small{color:var(--muted)}li.done:not(.active){color:#77776f}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor{padding:clamp(44px,6vw,82px);max-width:1000px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 44px}.coauthor h1 span{color:var(--acid)}.seed{display:flex;align-items:stretch;max-width:780px;margin-top:22px}.seed textarea{width:100%;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.seed button:disabled{opacity:.4}.scanner{position:relative;width:180px;height:180px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:40px auto 0;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:28px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:6px;height:6px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:8px}.scanner i:nth-child(2){left:13px;bottom:40px}.scanner i:nth-child(3){right:3px;top:64px}.scanner span{font:600 20px var(--display);color:var(--acid)}@keyframes rotate{to{transform:rotate(360deg)}}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor{box-sizing:border-box;padding:40px 20px}}
|
||
</style>
|