feat(alpha): complete closed-alpha readiness stage
Some checks failed
CI / validate (push) Has been cancelled
Some checks failed
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user