feat(alpha): complete closed-alpha readiness stage
Some checks failed
CI / validate (push) Has been cancelled

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-03 20:40:40 +05:00
parent b5a9e2d61f
commit e67167648b
8 changed files with 384 additions and 40 deletions

View File

@@ -16,6 +16,16 @@ interface DraftSession {
interface UsageSummary {
quota: { usedToday: number; dailyRequestLimit: number; dailyTokenLimit: number }
totals: { requests: number; inputTokens: number; outputTokens: number; costUsd: number; averageLatencyMs: number }
rounds: {
completed: number
failed: number
failureRate: number
averageCostUsd: number
averageLatencyMs: number
p95LatencyMs: number
averageContextTokens: number
latestFailures: Array<{ id: string; campaignId: string; campaign: string; round: number; message: string; occurredAt: string }>
}
}
const { api } = useDngApi()
@@ -26,6 +36,7 @@ const drafts = ref<DraftSession[]>([])
const usage = ref<UsageSummary | null>(null)
const loading = ref(true)
const error = ref('')
const actionError = ref('')
const renamingCampaignId = ref<string | null>(null)
const newName = ref('')
@@ -37,6 +48,7 @@ const displayName = computed(() => {
const value = auth.session.value?.user.user_metadata?.display_name
return typeof value === 'string' && value.trim() ? value.trim() : 'ADVENTURER'
})
const userId = computed(() => auth.session.value?.user.id ?? '')
function draftTitle(draft: DraftSession) {
return draft.generated_world?.title || 'UNFINISHED UNIVERSE'
@@ -52,7 +64,18 @@ function compactNumber(value: number) {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
}
function duration(value: number) {
if (value < 1_000) return `${Math.round(value)}ms`
return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}s`
}
function messageFrom(cause: unknown) {
const value = cause as { data?: { statusMessage?: string; message?: string }; message?: string }
return value.data?.statusMessage ?? value.data?.message ?? value.message ?? 'The request could not be completed.'
}
function openRename(campaign: CampaignRow) {
actionError.value = ''
renamingCampaignId.value = campaign.id
newName.value = campaign.title
}
@@ -66,18 +89,26 @@ function handleCampaignAction(payload: { action: 'rename' | 'delete'; campaignId
async function confirmRename() {
if (!renamingCampaignId.value || !newName.value.trim()) return
actionError.value = ''
try {
await api(`/api/v1/campaigns/${renamingCampaignId.value}`, { method: 'PATCH', body: { title: newName.value } })
campaigns.value = campaigns.value.map(c => c.id === renamingCampaignId.value ? { ...c, title: newName.value.trim(), updated_at: new Date().toISOString() } : c)
} catch { /* handled silently */ } finally { renamingCampaignId.value = null; newName.value = '' }
renamingCampaignId.value = null
newName.value = ''
} catch (cause) {
actionError.value = messageFrom(cause)
}
}
async function requestDelete(campaign: CampaignRow) {
if (!confirm(`Delete "${campaign.title}"? This cannot be undone.`)) return
actionError.value = ''
try {
await api(`/api/v1/campaigns/${campaign.id}`, { method: 'DELETE' })
campaigns.value = campaigns.value.filter(c => c.id !== campaign.id)
} catch { /* handled silently — refresh on next mount */ }
} catch (cause) {
actionError.value = messageFrom(cause)
}
}
onMounted(async () => {
@@ -108,13 +139,28 @@ onMounted(async () => {
<NuxtLink to="/worlds/new" class="create-button"><span></span> CREATE A UNIVERSE</NuxtLink>
</section>
<AlphaOnboarding v-if="!loading && !error && userId" :user-id="userId" :campaigns="campaigns" :drafts="drafts" />
<p v-if="actionError" class="action-error" role="alert"><span>{{ actionError }}</span><button type="button" @click="actionError = ''">DISMISS</button></p>
<section v-if="usage" class="usage-deck" aria-label="AI usage dashboard">
<div><small>AI REQUESTS / TODAY</small><b>{{ usage.quota.usedToday }}<em>/ {{ usage.quota.dailyRequestLimit }}</em></b></div>
<div><small>TOKENS / 30 DAYS</small><b>{{ compactNumber(usage.totals.inputTokens + usage.totals.outputTokens) }}</b></div>
<div><small>EST. COST / 30 DAYS</small><b>${{ usage.totals.costUsd.toFixed(3) }}</b></div>
<div><small>AVG. AI LATENCY</small><b>{{ (usage.totals.averageLatencyMs / 1000).toFixed(1) }}s</b></div>
<div><small>AVG. COST / ROUND</small><b>{{ usage.rounds.completed ? `$${usage.rounds.averageCostUsd.toFixed(3)}` : '—' }}</b></div>
<div><small>AVG. ROUND LATENCY</small><b>{{ usage.rounds.completed ? duration(usage.rounds.averageLatencyMs) : '—' }}<em v-if="usage.rounds.p95LatencyMs">P95 {{ duration(usage.rounds.p95LatencyMs) }}</em></b></div>
<div><small>AVG. ROUND CONTEXT</small><b>{{ usage.rounds.averageContextTokens ? compactNumber(usage.rounds.averageContextTokens) : '—' }}<em v-if="usage.rounds.averageContextTokens">TOKENS</em></b></div>
<div :class="{ warning: usage.rounds.failed }"><small>ROUND FAILURES / 30 DAYS</small><b>{{ usage.rounds.failed }}<em>/ {{ usage.rounds.completed + usage.rounds.failed }}</em></b></div>
</section>
<details v-if="usage?.rounds.latestFailures.length" class="failure-log">
<summary>RECENT ROUND FAILURES <span>{{ usage.rounds.failed }} IN 30 DAYS</span></summary>
<article v-for="failure in usage.rounds.latestFailures" :key="failure.id">
<div><b>{{ failure.campaign }}</b><small>ROUND {{ failure.round }} · {{ new Date(failure.occurredAt).toLocaleString() }}</small></div>
<p>{{ failure.message }}</p>
<NuxtLink :to="`/campaign/${failure.campaignId}`">OPEN CAMPAIGN </NuxtLink>
</article>
</details>
<div class="filter-row">
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
@@ -151,8 +197,9 @@ onMounted(async () => {
<form @submit.prevent="confirmRename" class="rename-panel">
<small>RENAME UNIVERSE</small>
<input v-model="newName" required maxlength="200" autofocus placeholder="Universe name" aria-label="New universe name" />
<p v-if="actionError" class="dialog-error" role="alert">{{ actionError }}</p>
<div class="rename-actions">
<button type="button" class="ghost-button" @click="renamingCampaignId = null; newName = ''">CANCEL</button>
<button type="button" class="ghost-button" @click="renamingCampaignId = null; newName = ''; actionError = ''">CANCEL</button>
<button class="acid-button" type="submit">SAVE</button>
</div>
</form>
@@ -170,7 +217,9 @@ onMounted(async () => {
.dash-head p{color:var(--muted)}
.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}
.create-button span{font-size:20px}
.usage-deck{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:34px;border:1px solid var(--line);background:#0d0d0c}.usage-deck>div{display:grid;gap:10px;padding:18px 20px;border-right:1px solid var(--line)}.usage-deck>div:last-child{border-right:0}.usage-deck small{font:500 7px var(--mono);letter-spacing:.14em;color:var(--muted)}.usage-deck b{font:600 20px var(--mono);color:var(--acid)}.usage-deck em{margin-left:4px;color:var(--muted);font:500 9px var(--mono);font-style:normal}
.action-error{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:22px 0 0;padding:13px 15px;border:1px solid #6a3a31;background:#251613;color:#ffb29f;font:500 9px/1.5 var(--mono)}.action-error button{border:0;background:none;color:inherit;font:600 7px var(--mono);letter-spacing:.1em}
.usage-deck{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));margin-top:34px;border:1px solid var(--line);background:#0d0d0c}.usage-deck>div{display:grid;align-content:start;gap:10px;min-width:0;padding:18px 16px;border-right:1px solid var(--line)}.usage-deck>div:last-child{border-right:0}.usage-deck small{font:500 7px/1.4 var(--mono);letter-spacing:.12em;color:var(--muted)}.usage-deck b{display:flex;flex-wrap:wrap;align-items:baseline;gap:5px;font:600 clamp(15px,1.6vw,20px) var(--mono);color:var(--acid)}.usage-deck em{color:var(--muted);font:500 7px var(--mono);font-style:normal}.usage-deck .warning b{color:#ff9d85}
.failure-log{margin-top:12px;border:1px solid #56362f;background:#140f0e}.failure-log summary{display:flex;justify-content:space-between;gap:20px;padding:15px 18px;color:#ffab98;font:600 8px var(--mono);letter-spacing:.12em;cursor:pointer}.failure-log summary span{color:var(--muted);font-weight:500}.failure-log article{display:grid;grid-template-columns:minmax(150px,.4fr) minmax(0,1fr) auto;gap:18px;align-items:center;padding:14px 18px;border-top:1px solid #382521}.failure-log article>div{display:grid;gap:5px}.failure-log article b{font:600 9px var(--display)}.failure-log article small{font:500 7px var(--mono);color:var(--muted)}.failure-log article p{margin:0;color:#d7a89e;font-size:10px;line-height:1.5;overflow-wrap:anywhere}.failure-log article a{color:var(--acid);text-decoration:none;font:600 7px var(--mono);letter-spacing:.09em;white-space:nowrap}
.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}
.filter-row button{padding:0 4px 16px;margin-right:18px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em}
.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}
@@ -205,8 +254,10 @@ onMounted(async () => {
.rename-panel small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
.rename-panel input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid var(--line);background:#0e0e0d;color:var(--ink);font:12px var(--body);outline:none}
.rename-panel input:focus{border-color:var(--acid)}
.dialog-error{margin:0;padding:11px 13px;border:1px solid #6a3a31;background:#251613;color:#ffb29f;font:500 8px/1.5 var(--mono)}
.rename-actions{display:flex;justify-content:flex-end;gap:10px}
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.usage-deck{grid-template-columns:repeat(2,1fr)}.usage-deck>div:nth-child(2){border-right:0}.usage-deck>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
@media(max-width:1100px){.usage-deck{grid-template-columns:repeat(3,1fr)}.usage-deck>div{border-bottom:1px solid var(--line)}.usage-deck>div:nth-child(3n){border-right:0}.usage-deck>div:nth-last-child(-n+3){border-bottom:0}}
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}.failure-log article{grid-template-columns:1fr}.failure-log article a{justify-self:start}}
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.usage-deck{grid-template-columns:repeat(2,1fr)}.usage-deck>div:nth-child(3n){border-right:1px solid var(--line)}.usage-deck>div:nth-child(2n){border-right:0}.usage-deck>div:nth-last-child(-n+3){border-bottom:1px solid var(--line)}.usage-deck>div:nth-last-child(-n+2){border-bottom:0}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}.failure-log summary{align-items:flex-start;flex-direction:column}}
</style>