feat: add AI-assisted ISAC authoring
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import PseudoScript from '$lib/components/PseudoScript.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import type {
|
||||
AiCalculationAuthoringResponse,
|
||||
AiCalculationProfileDraft,
|
||||
AiCalculationRevisionDraft,
|
||||
AiCalculationStageDraft,
|
||||
AiCalculationTranscriptEntry,
|
||||
AiSkillCalculationProfileDraft
|
||||
} from '$lib/ai/calculation-authoring';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
|
||||
export type CalculationStageDraft = {
|
||||
stageKey: string;
|
||||
label: string;
|
||||
operator: string;
|
||||
displayOrder: number;
|
||||
lockedPosition: string | null;
|
||||
};
|
||||
export type CalculationProfileDraft = {
|
||||
statAttributeId: string;
|
||||
stageKey: string;
|
||||
weight: number | string;
|
||||
softCap: number | string | null;
|
||||
hardCap: number | string | null;
|
||||
curveType: string;
|
||||
curveParameter: number | string | null;
|
||||
weaponCategory: string;
|
||||
};
|
||||
export type SkillCalculationProfileDraft = {
|
||||
statAttributeId: string;
|
||||
stageKey: string;
|
||||
operator: 'add' | 'multiply';
|
||||
weight: number | string;
|
||||
};
|
||||
export type CalculationRevisionDraft = {
|
||||
id: string;
|
||||
basedOnRevisionId: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
stages: CalculationStageDraft[];
|
||||
profiles: CalculationProfileDraft[];
|
||||
skillProfiles: SkillCalculationProfileDraft[];
|
||||
};
|
||||
|
||||
type Answer = string | number | boolean;
|
||||
|
||||
let {
|
||||
currentRevision,
|
||||
onapply,
|
||||
disabled = false
|
||||
}: {
|
||||
currentRevision: CalculationRevisionDraft;
|
||||
onapply: (draft: AiCalculationRevisionDraft) => void;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const permissions = $derived((page.data.permissions as string[] | undefined) ?? []);
|
||||
const canGenerate = $derived(permissions.includes('*') || permissions.includes('ai.calculation.generate'));
|
||||
const canUse = $derived(canGenerate && !disabled);
|
||||
|
||||
let expanded = $state(false);
|
||||
let requirement = $state('');
|
||||
let response = $state<AiCalculationAuthoringResponse | null>(null);
|
||||
let transcript = $state<AiCalculationTranscriptEntry[]>([]);
|
||||
let answers = $state<Record<string, Answer | undefined>>({});
|
||||
let pending = $state(false);
|
||||
let errorMessage = $state('');
|
||||
let activityMessage = $state('');
|
||||
let confirmApply = $state(false);
|
||||
let resultRegion = $state<HTMLElement>();
|
||||
let requestController: AbortController | undefined;
|
||||
|
||||
const questions = $derived(response?.status === 'clarification_required' ? response.questions : []);
|
||||
const allQuestionsAnswered = $derived(
|
||||
questions.length > 0 && questions.every((question) => {
|
||||
const answer = answers[question.id];
|
||||
if (typeof answer === 'string') return answer.trim().length > 0;
|
||||
if (typeof answer === 'number') return Number.isFinite(answer);
|
||||
return typeof answer === 'boolean';
|
||||
})
|
||||
);
|
||||
const scriptLines = $derived(
|
||||
response?.status === 'draft_ready' ? response.pseudoScript.split('\n') : []
|
||||
);
|
||||
const beforeCounts = $derived({
|
||||
stages: currentRevision.stages.length,
|
||||
weaponProfiles: currentRevision.profiles.length,
|
||||
skillProfiles: currentRevision.skillProfiles.length
|
||||
});
|
||||
const afterCounts = $derived(
|
||||
response?.status === 'draft_ready'
|
||||
? {
|
||||
stages: response.draft.stages.length,
|
||||
weaponProfiles: response.draft.profiles.length,
|
||||
skillProfiles: response.draft.skillProfiles.length
|
||||
}
|
||||
: beforeCounts
|
||||
);
|
||||
|
||||
onDestroy(() => requestController?.abort());
|
||||
|
||||
function setAnswer(questionId: string, answer: Answer | undefined) {
|
||||
answers = { ...answers, [questionId]: answer };
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
requestController?.abort();
|
||||
response = null;
|
||||
transcript = [];
|
||||
answers = {};
|
||||
pending = false;
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
}
|
||||
|
||||
async function focusResult() {
|
||||
await tick();
|
||||
resultRegion?.focus();
|
||||
}
|
||||
|
||||
function requestRevision(): AiCalculationRevisionDraft {
|
||||
const optionalNumber = (value: number | string | null) => {
|
||||
if (value === '' || value === null) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
return {
|
||||
name: currentRevision.name,
|
||||
notes: currentRevision.notes,
|
||||
stages: currentRevision.stages.map((stage): AiCalculationStageDraft => ({
|
||||
...stage,
|
||||
operator: stage.operator as AiCalculationStageDraft['operator'],
|
||||
lockedPosition: stage.lockedPosition as AiCalculationStageDraft['lockedPosition']
|
||||
})),
|
||||
profiles: currentRevision.profiles.map((profile): AiCalculationProfileDraft => ({
|
||||
...profile,
|
||||
weight: Number(profile.weight),
|
||||
softCap: optionalNumber(profile.softCap),
|
||||
hardCap: optionalNumber(profile.hardCap),
|
||||
curveType: profile.curveType as AiCalculationProfileDraft['curveType'],
|
||||
curveParameter: optionalNumber(profile.curveParameter)
|
||||
})),
|
||||
skillProfiles: currentRevision.skillProfiles.map((profile): AiSkillCalculationProfileDraft => ({
|
||||
...profile,
|
||||
stageKey: profile.stageKey as AiSkillCalculationProfileDraft['stageKey'],
|
||||
weight: Number(profile.weight)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function requestDraft(nextTranscript = transcript) {
|
||||
if (!canUse || pending) return;
|
||||
if (!requirement.trim()) {
|
||||
errorMessage = 'Describe the calculation change before requesting a draft.';
|
||||
activityMessage = '';
|
||||
return;
|
||||
}
|
||||
|
||||
requestController?.abort();
|
||||
requestController = new AbortController();
|
||||
pending = true;
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
|
||||
try {
|
||||
const request = await fetch('/api/isac/calculation-assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
signal: requestController.signal,
|
||||
body: JSON.stringify({
|
||||
instructions: requirement.trim(),
|
||||
baseRevisionId: currentRevision.id || currentRevision.basedOnRevisionId || null,
|
||||
currentDraft: requestRevision(),
|
||||
transcript: nextTranscript
|
||||
})
|
||||
});
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
const payload = contentType.includes('application/json') ? await request.json() : null;
|
||||
if (!request.ok) {
|
||||
throw new Error(
|
||||
typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: `The calculation assistant could not complete the request (${request.status}).`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!payload
|
||||
|| !['clarification_required', 'draft_ready', 'capability_gap'].includes(payload.status)
|
||||
) throw new Error('The calculation assistant returned an unrecognized response.');
|
||||
|
||||
response = payload as AiCalculationAuthoringResponse;
|
||||
answers = {};
|
||||
activityMessage = response.status === 'clarification_required'
|
||||
? 'Clarification is required before a reliable revision can be prepared.'
|
||||
: response.status === 'draft_ready'
|
||||
? 'Draft revision ready. No manual fields have been changed.'
|
||||
: 'The current calculation model cannot represent every requested behavior.';
|
||||
await focusResult();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'The calculation assistant could not complete the request.';
|
||||
await focusResult();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitClarifications() {
|
||||
if (!allQuestionsAnswered || response?.status !== 'clarification_required') return;
|
||||
const entries = response.questions.map((question): AiCalculationTranscriptEntry => ({
|
||||
questionId: question.id,
|
||||
question: question.question,
|
||||
answer: String(answers[question.id])
|
||||
}));
|
||||
const nextTranscript = [...transcript, ...entries];
|
||||
transcript = nextTranscript;
|
||||
void requestDraft(nextTranscript);
|
||||
}
|
||||
|
||||
function restartAnalysis() {
|
||||
resetResult();
|
||||
void requestDraft([]);
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
if (response?.status !== 'draft_ready') return;
|
||||
onapply($state.snapshot(response.draft));
|
||||
confirmApply = false;
|
||||
activityMessage = 'AI draft applied to the unsaved revision. Review every field before saving.';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="calculation-assistant" class:locked={!canGenerate}>
|
||||
<header>
|
||||
<div>
|
||||
<span>ISAC AUTHORING ASSIST</span>
|
||||
<strong>CALCULATION REVISION DRAFTING</strong>
|
||||
</div>
|
||||
<b class:available={canGenerate}>{canGenerate ? 'AUTHORIZED' : 'PERMISSION REQUIRED'}</b>
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-toggle"
|
||||
disabled={!canGenerate}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="calculation-assistant-workspace"
|
||||
onclick={() => expanded = !expanded}
|
||||
>
|
||||
{expanded ? 'CLOSE ASSISTANT' : 'OPEN ASSISTANT'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if !canGenerate}
|
||||
<p class="permission-note">
|
||||
AI calculation drafting is optional. The manual revision editor remains available.
|
||||
Request <b>AI calculation generation</b> permission from an administrator to use this helper.
|
||||
</p>
|
||||
{:else if !expanded}
|
||||
<p class="assistant-summary">
|
||||
Describe a calculation change in plain language, then review a typed revision draft before
|
||||
applying it to this unsaved editor.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if expanded}
|
||||
<div class="assistant-workspace" id="calculation-assistant-workspace">
|
||||
<label class="requirement-field">
|
||||
<span>CALCULATION REQUIREMENT</span>
|
||||
<textarea
|
||||
rows="5"
|
||||
bind:value={requirement}
|
||||
disabled={!canUse || pending}
|
||||
placeholder="Describe the calculation stages, Attribute behavior, caps, curves, or skill bindings that should change."
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="request-actions">
|
||||
<p>
|
||||
The assistant can only draft supported calculation data. It cannot save, publish, or
|
||||
activate a revision.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!canUse || pending || !requirement.trim()}
|
||||
aria-busy={pending}
|
||||
onclick={() => {
|
||||
if (response || transcript.length) restartAnalysis();
|
||||
else void requestDraft();
|
||||
}}
|
||||
>
|
||||
{pending ? 'ANALYZING CALCULATION…' : response || transcript.length ? 'RESTART ANALYSIS' : 'GENERATE REVISION'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="result-region"
|
||||
bind:this={resultRegion}
|
||||
tabindex="-1"
|
||||
aria-live="polite"
|
||||
aria-busy={pending}
|
||||
>
|
||||
{#if errorMessage}
|
||||
<div class="assistant-message error" role="alert">
|
||||
<strong>REQUEST FAILED</strong>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
{:else if response?.status === 'clarification_required'}
|
||||
<div class="result-heading">
|
||||
<div><span>CLARIFICATION REQUIRED</span><strong>{questions.length} {questions.length === 1 ? 'QUESTION' : 'QUESTIONS'}</strong></div>
|
||||
<small>Answer only what materially affects calculation behavior.</small>
|
||||
</div>
|
||||
<div class="question-stack">
|
||||
{#each questions as question, index}
|
||||
<section class="question-card">
|
||||
<header>
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<div><strong>{question.question}</strong><small>{question.reason}</small></div>
|
||||
</header>
|
||||
{#if question.answerType === 'boolean'}
|
||||
<SegmentedChoice
|
||||
legend="ANSWER"
|
||||
name={`calculation-ai-${question.id}`}
|
||||
options={[{ value: 'true', label: 'YES' }, { value: 'false', label: 'NO' }]}
|
||||
value={answers[question.id] === undefined ? '' : String(answers[question.id])}
|
||||
onchange={(values) => setAnswer(question.id, values[0] === 'true')}
|
||||
/>
|
||||
{:else if question.answerType === 'choice' && question.options.length}
|
||||
<SegmentedChoice
|
||||
legend="ANSWER"
|
||||
name={`calculation-ai-${question.id}`}
|
||||
options={question.options.map((option) => ({ value: option, label: option.toUpperCase() }))}
|
||||
value={typeof answers[question.id] === 'string' ? answers[question.id] as string : ''}
|
||||
columns={Math.min(3, question.options.length)}
|
||||
onchange={(values) => setAnswer(question.id, values[0])}
|
||||
/>
|
||||
{:else}
|
||||
<label class="answer-field">
|
||||
<span>ANSWER</span>
|
||||
<input
|
||||
type={question.answerType === 'number' ? 'number' : 'text'}
|
||||
step={question.answerType === 'number' ? 'any' : undefined}
|
||||
value={answers[question.id] ?? ''}
|
||||
oninput={(event) => setAnswer(
|
||||
question.id,
|
||||
question.answerType === 'number'
|
||||
? event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value)
|
||||
: event.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button type="button" class="secondary" onclick={resetResult}>CANCEL QUESTIONS</button>
|
||||
<button type="button" class="primary" disabled={!allQuestionsAnswered || pending} aria-busy={pending} onclick={submitClarifications}>
|
||||
{pending ? 'REFINING REVISION…' : 'SUBMIT ANSWERS'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if response?.status === 'capability_gap'}
|
||||
<div class="result-heading capability">
|
||||
<div><span>DEVELOPER HANDOFF</span><strong>CALCULATION MODEL GAP</strong></div>
|
||||
<small>The assistant did not invent unsupported runtime behavior.</small>
|
||||
</div>
|
||||
<p class="result-summary">{response.summary}</p>
|
||||
<div class="gap-stack">
|
||||
{#each response.gaps as gap}
|
||||
<article class="gap-card">
|
||||
<header><span>AREA</span><strong>{gap.area}</strong></header>
|
||||
<dl>
|
||||
<div><dt>REQUIRED BEHAVIOR</dt><dd>{gap.requiredBehavior}</dd></div>
|
||||
<div><dt>WHY UNSUPPORTED</dt><dd>{gap.whyUnsupported}</dd></div>
|
||||
<div><dt>DEVELOPER CHANGE</dt><dd>{gap.developerChange}</dd></div>
|
||||
{#if gap.workaround}<div><dt>CURRENT WORKAROUND</dt><dd>{gap.workaround}</dd></div>{/if}
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="result-actions"><button type="button" class="secondary" onclick={resetResult}>REVISE REQUIREMENT</button></div>
|
||||
{:else if response?.status === 'draft_ready'}
|
||||
<div class="result-heading ready">
|
||||
<div><span>DRAFT READY</span><strong>REVIEW CALCULATION REVISION</strong></div>
|
||||
<small>No manual fields have been changed.</small>
|
||||
</div>
|
||||
<p class="result-summary">{response.summary}</p>
|
||||
<div class="change-counts" aria-label="Current and proposed revision counts">
|
||||
<div><span>STAGES</span><b>{beforeCounts.stages}</b><i>→</i><strong>{afterCounts.stages}</strong></div>
|
||||
<div><span>WEAPON PROFILES</span><b>{beforeCounts.weaponProfiles}</b><i>→</i><strong>{afterCounts.weaponProfiles}</strong></div>
|
||||
<div><span>SKILL PROFILES</span><b>{beforeCounts.skillProfiles}</b><i>→</i><strong>{afterCounts.skillProfiles}</strong></div>
|
||||
</div>
|
||||
{#if response.assumptions.length || response.warnings.length}
|
||||
<div class="review-notes">
|
||||
{#if response.assumptions.length}<section><strong>ASSUMPTIONS</strong><ul>{#each response.assumptions as item}<li>{item}</li>{/each}</ul></section>{/if}
|
||||
{#if response.warnings.length}<section class="warnings"><strong>REVIEW WARNINGS</strong><ul>{#each response.warnings as item}<li>{item}</li>{/each}</ul></section>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<PseudoScript title="PROPOSED CALCULATION REVISION" lines={scriptLines} />
|
||||
{#if confirmApply}
|
||||
<div class="apply-confirm" role="alert">
|
||||
<div>
|
||||
<strong>APPLY THIS DRAFT TO THE UNSAVED REVISION?</strong>
|
||||
<p>
|
||||
Stages {beforeCounts.stages} → {afterCounts.stages}; weapon profiles
|
||||
{beforeCounts.weaponProfiles} → {afterCounts.weaponProfiles}; skill profiles
|
||||
{beforeCounts.skillProfiles} → {afterCounts.skillProfiles}. This does not save or
|
||||
activate the revision.
|
||||
</p>
|
||||
</div>
|
||||
<div><button type="button" class="secondary" onclick={() => confirmApply = false}>CANCEL</button><button type="button" class="primary" onclick={applyDraft}>CONFIRM APPLY</button></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result-actions">
|
||||
<button type="button" class="secondary" onclick={resetResult}>DISCARD DRAFT</button>
|
||||
<button type="button" class="primary" onclick={() => confirmApply = true}>APPLY TO REVISION</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if pending}
|
||||
<div class="assistant-message pending">
|
||||
<strong>ANALYZING CALCULATION</strong>
|
||||
<p>Comparing the requested behavior with supported stages, Attributes, caps, and curves.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if activityMessage}<p class="activity-message" role="status">{activityMessage}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.calculation-assistant{border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}
|
||||
.calculation-assistant>header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--shd-gap-sm);padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.calculation-assistant>header>div,.result-heading>div{display:grid;gap:2px}.calculation-assistant>header span,.result-heading span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
|
||||
.calculation-assistant>header b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.calculation-assistant>header b.available{border-color:color-mix(in srgb,var(--shd-info) 55%,var(--shd-border));color:var(--shd-info)}
|
||||
.assistant-toggle{min-height:36px;padding:0 11px;border:1px solid var(--shd-info);background:transparent;color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.assistant-toggle:hover,.assistant-toggle:focus-visible{background:color-mix(in srgb,var(--shd-info) 12%,var(--shd-surface))}.assistant-toggle:disabled{cursor:not-allowed;opacity:.5}
|
||||
.assistant-toggle:active,.request-actions button:active,.result-actions button:active,.apply-confirm button:active{transform:translateY(1px);filter:brightness(.84)}
|
||||
.permission-note,.assistant-summary{margin:0;padding:9px 11px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.permission-note b{color:var(--shd-text)}.locked{border-left-color:var(--shd-border)}
|
||||
.assistant-workspace{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.requirement-field,.answer-field{display:grid;gap:5px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.requirement-field textarea,.answer-field input{width:100%;box-sizing:border-box;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text)}.requirement-field textarea{min-height:118px;padding:10px;resize:vertical;font-size:var(--shd-type-body);font-weight:400;letter-spacing:0;line-height:1.5}.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
|
||||
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
|
||||
.result-region{display:grid;gap:var(--shd-gap-sm);outline:none}.result-region:focus-visible{outline:1px solid var(--shd-orange);outline-offset:3px}.result-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.result-heading small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.result-heading.capability{border-left:2px solid var(--shd-danger)}.result-heading.capability span{color:var(--shd-danger)}.result-heading.ready{border-left:2px solid var(--shd-success)}.result-heading.ready span{color:var(--shd-success)}
|
||||
.assistant-message{display:grid;gap:3px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}.assistant-message p,.result-summary,.activity-message{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.assistant-message.error{border-left-color:var(--shd-danger)}.assistant-message.error strong{color:var(--shd-danger)}
|
||||
.question-stack,.gap-stack{display:grid;gap:var(--shd-gap-xs)}.question-card,.gap-card{display:grid;gap:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.question-card>header{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start}.question-card>header>span{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900}.question-card>header>div{display:grid;gap:3px}.question-card small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.gap-card>header{display:flex;align-items:center;gap:8px}.gap-card>header span,.gap-card dt,.review-notes strong,.change-counts span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.gap-card dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border-soft);border-left:1px solid var(--shd-border-soft)}.gap-card dl>div{display:grid;gap:4px;padding:8px;border-right:1px solid var(--shd-border-soft);border-bottom:1px solid var(--shd-border-soft)}.gap-card dd{margin:0;color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.change-counts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-xs)}.change-counts>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;align-items:center;gap:7px;padding:8px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.change-counts b{color:var(--shd-text-muted)}.change-counts i{color:var(--shd-text-dim);font-style:normal}.change-counts strong{color:var(--shd-orange)}
|
||||
.review-notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-xs)}.review-notes section{padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}.review-notes section.warnings{border-left-color:var(--shd-orange)}.review-notes ul{display:grid;gap:5px;margin:6px 0 0;padding-left:18px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.apply-confirm{padding:9px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 8%,var(--shd-surface))}.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
@media(max-width:760px){.calculation-assistant>header{grid-template-columns:1fr auto}.calculation-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}.gap-card dl,.review-notes,.change-counts{grid-template-columns:1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.assistant-toggle,.request-actions button,.result-actions button,.apply-confirm button{transition:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,566 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import PseudoScript from '$lib/components/PseudoScript.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import type {
|
||||
AiMechanicAuthoringResponse,
|
||||
AiMechanicContext,
|
||||
AiMechanicTranscriptEntry,
|
||||
AiMechanicValueColumn
|
||||
} from '$lib/ai/mechanic-authoring';
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
import { onDestroy, tick, untrack } from 'svelte';
|
||||
|
||||
type ClarificationAnswer = string | number | boolean;
|
||||
|
||||
let {
|
||||
context,
|
||||
currentDraft,
|
||||
valueColumns,
|
||||
idPrefix = 'mechanic',
|
||||
allowAssignedAbility = false,
|
||||
allowProtocolUpgradeScaling = false,
|
||||
disabled = false,
|
||||
onapply
|
||||
}: {
|
||||
context: AiMechanicContext;
|
||||
currentDraft: ConditionalMechanicDraftV1;
|
||||
valueColumns: AiMechanicValueColumn[];
|
||||
idPrefix?: string;
|
||||
allowAssignedAbility?: boolean;
|
||||
allowProtocolUpgradeScaling?: boolean;
|
||||
disabled?: boolean;
|
||||
onapply: (draft: ConditionalMechanicDraftV1) => void;
|
||||
} = $props();
|
||||
|
||||
const permissions = $derived((page.data.permissions as string[] | undefined) ?? []);
|
||||
const canGenerate = $derived(permissions.includes('*') || permissions.includes('ai.mechanic.generate'));
|
||||
const canUseAssistant = $derived(canGenerate && !disabled);
|
||||
|
||||
let expanded = $state(false);
|
||||
let sourceText = $state('');
|
||||
let response = $state<AiMechanicAuthoringResponse | null>(null);
|
||||
let transcript = $state<AiMechanicTranscriptEntry[]>([]);
|
||||
let answers = $state<Record<string, ClarificationAnswer | undefined>>({});
|
||||
let pending = $state(false);
|
||||
let errorMessage = $state('');
|
||||
let activityMessage = $state('');
|
||||
let confirmApply = $state(false);
|
||||
let resultRegion = $state<HTMLElement>();
|
||||
let requestController: AbortController | undefined;
|
||||
let trackedContextKey = $state('');
|
||||
const contextIdentity = $derived(`${context.sourceKind}:${context.sourceName}`);
|
||||
const contextKey = $derived(
|
||||
`${idPrefix}-${context.sourceKind}-${context.sourceName}`
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|| 'mechanic'
|
||||
);
|
||||
|
||||
const questions = $derived(
|
||||
response?.status === 'clarification_required' ? response.questions : []
|
||||
);
|
||||
const allQuestionsAnswered = $derived(
|
||||
questions.length > 0 && questions.every((question) => {
|
||||
const answer = answers[question.id];
|
||||
if (typeof answer === 'string') return answer.trim().length > 0;
|
||||
if (typeof answer === 'number') return Number.isFinite(answer);
|
||||
return typeof answer === 'boolean';
|
||||
})
|
||||
);
|
||||
const pseudoScriptLines = $derived(
|
||||
response?.status === 'draft_ready'
|
||||
? response.pseudoScript.split('\n')
|
||||
: []
|
||||
);
|
||||
const draftReadiness = $derived(
|
||||
response?.status !== 'draft_ready'
|
||||
? undefined
|
||||
: response.draft.simulationReadiness === 'ready'
|
||||
? 'READY'
|
||||
: response.draft.simulationReadiness === 'requires_assumptions'
|
||||
? 'REQUIRES ASSUMPTIONS'
|
||||
: 'CAPTURED ONLY'
|
||||
);
|
||||
const currentDraftCounts = $derived(summarizeDraft(currentDraft));
|
||||
const proposedDraftCounts = $derived(
|
||||
response?.status === 'draft_ready' ? summarizeDraft(response.draft) : null
|
||||
);
|
||||
|
||||
function summarizeDraft(draft: ConditionalMechanicDraftV1) {
|
||||
return {
|
||||
branches: draft.clauses.length,
|
||||
pools: draft.pools.length,
|
||||
controls: draft.scenarioInputs.length,
|
||||
outcomes: draft.clauses.reduce((total, clause) => total + clause.outcomes.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
function countLabel(value: number, singular: string) {
|
||||
return `${value} ${singular}${value === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function formatDraftCounts(counts: ReturnType<typeof summarizeDraft>) {
|
||||
return [
|
||||
countLabel(counts.branches, 'branch'),
|
||||
countLabel(counts.pools, 'pool'),
|
||||
countLabel(counts.controls, 'control'),
|
||||
countLabel(counts.outcomes, 'outcome')
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const nextKey = contextIdentity;
|
||||
if (nextKey === trackedContextKey) return;
|
||||
trackedContextKey = nextKey;
|
||||
untrack(() => resetAssistant(context.sourceDescription ?? ''));
|
||||
});
|
||||
|
||||
onDestroy(() => requestController?.abort());
|
||||
|
||||
function resetAssistant(description = context.sourceDescription ?? '') {
|
||||
requestController?.abort();
|
||||
sourceText = description;
|
||||
response = null;
|
||||
transcript = [];
|
||||
answers = {};
|
||||
pending = false;
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
}
|
||||
|
||||
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
|
||||
answers = { ...answers, [questionId]: answer };
|
||||
}
|
||||
|
||||
async function focusResult() {
|
||||
await tick();
|
||||
resultRegion?.focus();
|
||||
}
|
||||
|
||||
async function requestDraft(nextTranscript = transcript) {
|
||||
if (!canUseAssistant || pending) return;
|
||||
if (!sourceText.trim()) {
|
||||
errorMessage = 'Enter the mechanic wording from the game before requesting a draft.';
|
||||
activityMessage = '';
|
||||
return;
|
||||
}
|
||||
|
||||
requestController?.abort();
|
||||
requestController = new AbortController();
|
||||
pending = true;
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
|
||||
try {
|
||||
const request = await fetch('/api/isac/mechanic-assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
signal: requestController.signal,
|
||||
body: JSON.stringify({
|
||||
context: { ...context, sourceDescription: sourceText.trim() },
|
||||
currentDraft,
|
||||
valueColumns,
|
||||
allowAssignedAbility,
|
||||
allowProtocolUpgradeScaling,
|
||||
transcript: nextTranscript
|
||||
})
|
||||
});
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
const payload = contentType.includes('application/json')
|
||||
? await request.json()
|
||||
: null;
|
||||
|
||||
if (!request.ok) {
|
||||
throw new Error(
|
||||
typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: `The authoring assistant could not complete the request (${request.status}).`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!payload
|
||||
|| !['clarification_required', 'draft_ready', 'capability_gap'].includes(payload.status)
|
||||
) {
|
||||
throw new Error('The authoring assistant returned an unrecognized response.');
|
||||
}
|
||||
|
||||
response = payload as AiMechanicAuthoringResponse;
|
||||
answers = {};
|
||||
activityMessage = response.status === 'clarification_required'
|
||||
? 'Clarification required before a reliable draft can be created.'
|
||||
: response.status === 'draft_ready'
|
||||
? 'Draft ready for review. Nothing has been applied or saved.'
|
||||
: 'The current mechanic language cannot represent every requested behavior.';
|
||||
await focusResult();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'The authoring assistant could not complete the request.';
|
||||
await focusResult();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitClarifications() {
|
||||
if (!allQuestionsAnswered || response?.status !== 'clarification_required') return;
|
||||
const entries = response.questions.map((question): AiMechanicTranscriptEntry => ({
|
||||
questionId: question.id,
|
||||
question: question.question,
|
||||
answer: String(answers[question.id])
|
||||
}));
|
||||
const nextTranscript = [...transcript, ...entries];
|
||||
transcript = nextTranscript;
|
||||
void requestDraft(nextTranscript);
|
||||
}
|
||||
|
||||
function restartAnalysis() {
|
||||
response = null;
|
||||
transcript = [];
|
||||
answers = {};
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
void requestDraft([]);
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
if (response?.status !== 'draft_ready') return;
|
||||
onapply($state.snapshot(response.draft));
|
||||
confirmApply = false;
|
||||
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
|
||||
<header>
|
||||
<div>
|
||||
<span>ISAC AUTHORING ASSIST</span>
|
||||
<strong id={`${contextKey}-assistant-title`}>AI MECHANIC DRAFTING</strong>
|
||||
</div>
|
||||
<b class:available={canGenerate}>{canGenerate ? 'AUTHORIZED' : 'PERMISSION REQUIRED'}</b>
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-toggle"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`${contextKey}-assistant-workspace`}
|
||||
disabled={!canGenerate}
|
||||
onclick={() => expanded = !expanded}
|
||||
>
|
||||
{expanded ? 'CLOSE ASSISTANT' : 'OPEN ASSISTANT'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if !canGenerate}
|
||||
<p class="permission-note">
|
||||
AI assistance is optional. Manual mechanic authoring remains available below.
|
||||
Request <b>AI mechanic generation</b> permission from an administrator to use this helper.
|
||||
</p>
|
||||
{:else if !expanded}
|
||||
<p class="assistant-summary">
|
||||
Convert exact in-game wording into a reviewable mechanic draft. The assistant cannot save,
|
||||
publish, or replace the manual editor.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if expanded}
|
||||
<div class="assistant-workspace" id={`${contextKey}-assistant-workspace`}>
|
||||
<div class="source-heading">
|
||||
<div>
|
||||
<span>SOURCE WORDING</span>
|
||||
<strong>{context.sourceName || 'UNTITLED MECHANIC'}</strong>
|
||||
</div>
|
||||
<small>Paste the wording exactly as it appears in the game.</small>
|
||||
</div>
|
||||
|
||||
<label class="source-field">
|
||||
<span>MECHANIC DESCRIPTION</span>
|
||||
<textarea
|
||||
rows="5"
|
||||
bind:value={sourceText}
|
||||
disabled={!canUseAssistant || pending}
|
||||
placeholder="Enter the complete in-game talent or effect wording."
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="request-actions">
|
||||
<p>
|
||||
Generated data is a draft. Applying it never saves the parent record and does not
|
||||
remove access to the manual controls.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="primary request-button"
|
||||
disabled={!canUseAssistant || pending || !sourceText.trim()}
|
||||
aria-busy={pending}
|
||||
onclick={() => response || transcript.length ? restartAnalysis() : requestDraft()}
|
||||
>
|
||||
{pending ? 'ANALYZING MECHANIC…' : transcript.length ? 'RESTART ANALYSIS' : 'GENERATE DRAFT'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="result-region"
|
||||
bind:this={resultRegion}
|
||||
tabindex="-1"
|
||||
aria-live="polite"
|
||||
aria-busy={pending}
|
||||
>
|
||||
{#if errorMessage}
|
||||
<div class="assistant-message error" role="alert">
|
||||
<strong>REQUEST FAILED</strong>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
{:else if response?.status === 'clarification_required'}
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<span>CLARIFICATION REQUIRED</span>
|
||||
<strong>{response.questions.length} {response.questions.length === 1 ? 'QUESTION' : 'QUESTIONS'}</strong>
|
||||
</div>
|
||||
<small>Answers are used only to refine this mechanic draft.</small>
|
||||
</div>
|
||||
|
||||
<div class="question-stack">
|
||||
{#each response.questions as question, index}
|
||||
<section class="question-card">
|
||||
<header>
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<div>
|
||||
<strong>{question.question}</strong>
|
||||
<small>{question.reason}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if question.answerType === 'boolean'}
|
||||
<SegmentedChoice
|
||||
legend="ANSWER"
|
||||
name={`${contextKey}-${question.id}`}
|
||||
options={[
|
||||
{ value: 'true', label: 'YES' },
|
||||
{ value: 'false', label: 'NO' }
|
||||
]}
|
||||
value={answers[question.id] === undefined ? '' : String(answers[question.id])}
|
||||
onchange={(values) => setAnswer(question.id, values[0] === 'true')}
|
||||
/>
|
||||
{:else if question.answerType === 'choice' && question.options?.length}
|
||||
<SegmentedChoice
|
||||
legend="ANSWER"
|
||||
name={`${contextKey}-${question.id}`}
|
||||
options={question.options.map((option) => ({ value: option, label: option.toUpperCase() }))}
|
||||
value={typeof answers[question.id] === 'string' ? answers[question.id] as string : ''}
|
||||
columns={Math.min(3, question.options.length)}
|
||||
onchange={(values) => setAnswer(question.id, values[0])}
|
||||
/>
|
||||
{:else}
|
||||
<label class="answer-field">
|
||||
<span>ANSWER</span>
|
||||
<input
|
||||
type={question.answerType === 'number' ? 'number' : 'text'}
|
||||
step={question.answerType === 'number' ? 'any' : undefined}
|
||||
value={answers[question.id] ?? ''}
|
||||
oninput={(event) => setAnswer(
|
||||
question.id,
|
||||
question.answerType === 'number'
|
||||
? event.currentTarget.value === ''
|
||||
? undefined
|
||||
: Number(event.currentTarget.value)
|
||||
: event.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
||||
CANCEL QUESTIONS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!allQuestionsAnswered || pending}
|
||||
aria-busy={pending}
|
||||
onclick={submitClarifications}
|
||||
>
|
||||
{pending ? 'REFINING DRAFT…' : 'SUBMIT ANSWERS'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if response?.status === 'capability_gap'}
|
||||
<div class="result-heading capability">
|
||||
<div>
|
||||
<span>DEVELOPER HANDOFF</span>
|
||||
<strong>MECHANIC LANGUAGE GAP</strong>
|
||||
</div>
|
||||
<small>The assistant did not invent unsupported calculation behavior.</small>
|
||||
</div>
|
||||
<p class="gap-summary">{response.summary}</p>
|
||||
<div class="gap-stack">
|
||||
{#each response.gaps as gap}
|
||||
<article class="gap-card">
|
||||
<header><span>AREA</span><strong>{gap.area}</strong></header>
|
||||
<dl>
|
||||
<div><dt>REQUIRED BEHAVIOR</dt><dd>{gap.requiredBehavior}</dd></div>
|
||||
<div><dt>WHY UNSUPPORTED</dt><dd>{gap.whyUnsupported}</dd></div>
|
||||
<div><dt>DEVELOPER CHANGE</dt><dd>{gap.developerChange}</dd></div>
|
||||
{#if gap.workaround}<div><dt>CURRENT WORKAROUND</dt><dd>{gap.workaround}</dd></div>{/if}
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
||||
REVISE SOURCE WORDING
|
||||
</button>
|
||||
</div>
|
||||
{:else if response?.status === 'draft_ready'}
|
||||
<div class="result-heading ready">
|
||||
<div>
|
||||
<span>DRAFT READY</span>
|
||||
<strong>REVIEW BEFORE APPLICATION</strong>
|
||||
</div>
|
||||
<small>No changes have been applied or saved.</small>
|
||||
</div>
|
||||
<p class="draft-summary">{response.summary}</p>
|
||||
|
||||
{#if response.assumptions.length || response.warnings.length}
|
||||
<div class="review-notes">
|
||||
{#if response.assumptions.length}
|
||||
<section>
|
||||
<strong>ASSUMPTIONS</strong>
|
||||
<ul>{#each response.assumptions as assumption}<li>{assumption}</li>{/each}</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if response.warnings.length}
|
||||
<section class="warnings">
|
||||
<strong>REVIEW WARNINGS</strong>
|
||||
<ul>{#each response.warnings as warning}<li>{warning}</li>{/each}</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<PseudoScript
|
||||
title="PROPOSED MECHANIC PROGRAM"
|
||||
lines={pseudoScriptLines}
|
||||
readiness={draftReadiness}
|
||||
/>
|
||||
|
||||
{#if confirmApply}
|
||||
<div class="apply-confirm" role="alert">
|
||||
<div>
|
||||
<strong>APPLY THIS DRAFT TO THE MANUAL EDITOR?</strong>
|
||||
<p>
|
||||
This replaces the current unsaved mechanic fields. It does not save or publish
|
||||
the parent record.
|
||||
</p>
|
||||
{#if proposedDraftCounts}
|
||||
<div class="replacement-summary">
|
||||
<span><b>CURRENT</b>{formatDraftCounts(currentDraftCounts)}</span>
|
||||
<span><b>PROPOSED</b>{formatDraftCounts(proposedDraftCounts)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="secondary" onclick={() => confirmApply = false}>
|
||||
CANCEL
|
||||
</button>
|
||||
<button type="button" class="primary" onclick={applyDraft}>
|
||||
CONFIRM APPLY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result-actions">
|
||||
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
||||
DISCARD DRAFT
|
||||
</button>
|
||||
<button type="button" class="primary" onclick={() => confirmApply = true}>
|
||||
APPLY TO EDITOR
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if pending}
|
||||
<div class="assistant-message pending">
|
||||
<strong>ANALYZING MECHANIC</strong>
|
||||
<p>Comparing the source wording with supported events, attributes, and value models.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if activityMessage}
|
||||
<p class="activity-message" role="status">{activityMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.authoring-assistant{border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}
|
||||
.authoring-assistant>header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--shd-gap-sm);padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.authoring-assistant>header>div,.source-heading>div,.result-heading>div{display:grid;gap:2px}
|
||||
.authoring-assistant>header span,.source-heading span,.result-heading span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
|
||||
.authoring-assistant>header b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
.authoring-assistant>header b.available{border-color:color-mix(in srgb,var(--shd-info) 55%,var(--shd-border));color:var(--shd-info)}
|
||||
.assistant-toggle{min-height:36px;padding:0 11px;border:1px solid var(--shd-info);background:transparent;color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
|
||||
.assistant-toggle:hover,.assistant-toggle:focus-visible{background:color-mix(in srgb,var(--shd-info) 12%,var(--shd-surface))}
|
||||
.assistant-toggle:disabled{cursor:not-allowed;opacity:.5}
|
||||
.assistant-toggle:active,.request-button:active,.result-actions button:active,.apply-confirm button:active{transform:translateY(1px);filter:brightness(.84)}
|
||||
.permission-note,.assistant-summary{margin:0;padding:9px 11px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.permission-note b{color:var(--shd-text)}
|
||||
.locked{border-left-color:var(--shd-border)}
|
||||
.assistant-workspace{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.source-heading,.result-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.source-heading small,.result-heading small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
|
||||
.source-field,.answer-field{display:grid;gap:5px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.source-field textarea,.answer-field input{width:100%;box-sizing:border-box;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:inherit}
|
||||
.source-field textarea{min-height:118px;padding:10px;resize:vertical;font-size:var(--shd-type-body);font-weight:400;letter-spacing:0;line-height:1.5}
|
||||
.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
|
||||
.source-field :is(textarea,input):focus-visible,.answer-field input:focus-visible{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 55%,transparent);outline-offset:1px}
|
||||
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
|
||||
.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
|
||||
.result-region{display:grid;gap:var(--shd-gap-sm);outline:none}
|
||||
.result-region:focus-visible{outline:1px solid var(--shd-orange);outline-offset:3px}
|
||||
.result-heading.capability{border-left:2px solid var(--shd-danger)}.result-heading.capability span{color:var(--shd-danger)}
|
||||
.result-heading.ready{border-left:2px solid var(--shd-success)}.result-heading.ready span{color:var(--shd-success)}
|
||||
.assistant-message{display:grid;gap:3px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.assistant-message p,.gap-summary,.draft-summary,.activity-message{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.assistant-message.error{border-left-color:var(--shd-danger)}.assistant-message.error strong{color:var(--shd-danger)}
|
||||
.question-stack,.gap-stack{display:grid;gap:var(--shd-gap-xs)}
|
||||
.question-card,.gap-card{display:grid;gap:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.question-card>header{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start}
|
||||
.question-card>header>span{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900}
|
||||
.question-card>header>div{display:grid;gap:3px}.question-card small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.gap-summary,.draft-summary{padding:0 2px;color:var(--shd-text)}
|
||||
.gap-card>header{display:flex;align-items:center;gap:8px}.gap-card>header span,.gap-card dt,.review-notes strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.gap-card dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border-soft);border-left:1px solid var(--shd-border-soft)}
|
||||
.gap-card dl>div{display:grid;gap:4px;padding:8px;border-right:1px solid var(--shd-border-soft);border-bottom:1px solid var(--shd-border-soft)}
|
||||
.gap-card dd{margin:0;color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.review-notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-xs)}
|
||||
.review-notes section{padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.review-notes section.warnings{border-left-color:var(--shd-orange)}
|
||||
.review-notes ul{display:grid;gap:5px;margin:6px 0 0;padding-left:18px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.apply-confirm{padding:9px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 8%,var(--shd-surface))}
|
||||
.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}
|
||||
.replacement-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;margin-top:4px}.replacement-summary span{display:grid;gap:2px;padding:5px 7px;border:1px solid var(--shd-border-soft);color:var(--shd-text);font-size:var(--shd-type-supporting)}.replacement-summary b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
@media(max-width:760px){
|
||||
.authoring-assistant>header{grid-template-columns:1fr auto}.authoring-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}
|
||||
.source-heading,.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}
|
||||
.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}
|
||||
.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}
|
||||
.replacement-summary{grid-template-columns:1fr}
|
||||
.gap-card dl,.review-notes{grid-template-columns:1fr}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.assistant-toggle,.request-button,.result-actions button,.apply-confirm button{transition:none}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
|
||||
import PseudoScript from '$lib/components/PseudoScript.svelte';
|
||||
import MechanicAuthoringAssistant from '$lib/components/isac/MechanicAuthoringAssistant.svelte';
|
||||
import type { AiMechanicContext } from '$lib/ai/mechanic-authoring';
|
||||
import {
|
||||
blankConditionalMechanicDraft,
|
||||
parseConditionalMechanicDraft,
|
||||
@@ -40,6 +42,7 @@
|
||||
idPrefix = 'mechanic',
|
||||
allowAssignedAbility = false,
|
||||
allowProtocolUpgradeScaling = false,
|
||||
aiContext,
|
||||
disabled = false
|
||||
}: {
|
||||
rule?: ConditionalMechanicDraftV1;
|
||||
@@ -48,6 +51,7 @@
|
||||
idPrefix?: string;
|
||||
allowAssignedAbility?: boolean;
|
||||
allowProtocolUpgradeScaling?: boolean;
|
||||
aiContext?: AiMechanicContext;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -463,6 +467,19 @@
|
||||
<MechanicLanguageHelp />
|
||||
</div>
|
||||
|
||||
{#if aiContext}
|
||||
<MechanicAuthoringAssistant
|
||||
context={aiContext}
|
||||
currentDraft={rule}
|
||||
{valueColumns}
|
||||
{idPrefix}
|
||||
{allowAssignedAbility}
|
||||
{allowProtocolUpgradeScaling}
|
||||
{disabled}
|
||||
onapply={setRule}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<SegmentedChoice
|
||||
legend="SIMULATION READINESS"
|
||||
name={`${idPrefix}-readiness`}
|
||||
|
||||
Reference in New Issue
Block a user