Add ISAC mechanic gap handoff actions
This commit is contained in:
@@ -8,6 +8,10 @@
|
||||
AiMechanicTranscriptEntry,
|
||||
AiMechanicValueColumn
|
||||
} from '$lib/ai/mechanic-authoring';
|
||||
import {
|
||||
buildMechanicDeveloperPrompt,
|
||||
type AiMechanicDeveloperHandoff
|
||||
} from '$lib/ai/mechanic-developer-handoff';
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
import { onDestroy, tick, untrack } from 'svelte';
|
||||
|
||||
@@ -46,6 +50,11 @@
|
||||
let errorMessage = $state('');
|
||||
let activityMessage = $state('');
|
||||
let confirmApply = $state(false);
|
||||
let gapHandoff = $state<AiMechanicDeveloperHandoff | null>(null);
|
||||
let gapReportPending = $state(false);
|
||||
let gapReportSubmitted = $state(false);
|
||||
let handoffMessage = $state('');
|
||||
let handoffMessageKind = $state<'success' | 'error'>('success');
|
||||
let resultRegion = $state<HTMLElement>();
|
||||
let requestController: AbortController | undefined;
|
||||
let trackedContextKey = $state('');
|
||||
@@ -129,6 +138,11 @@
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
gapHandoff = null;
|
||||
gapReportPending = false;
|
||||
gapReportSubmitted = false;
|
||||
handoffMessage = '';
|
||||
handoffMessageKind = 'success';
|
||||
}
|
||||
|
||||
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
|
||||
@@ -154,20 +168,24 @@
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
gapHandoff = null;
|
||||
gapReportSubmitted = false;
|
||||
handoffMessage = '';
|
||||
|
||||
try {
|
||||
const authoringRequest = {
|
||||
context: { ...context, sourceDescription: sourceText.trim() },
|
||||
currentDraft,
|
||||
valueColumns,
|
||||
allowAssignedAbility,
|
||||
allowProtocolUpgradeScaling,
|
||||
transcript: nextTranscript
|
||||
};
|
||||
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
|
||||
})
|
||||
body: JSON.stringify(authoringRequest)
|
||||
});
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
const payload = contentType.includes('application/json')
|
||||
@@ -189,6 +207,16 @@
|
||||
}
|
||||
|
||||
response = payload as AiMechanicAuthoringResponse;
|
||||
if (response.status === 'capability_gap') {
|
||||
gapHandoff = {
|
||||
context: authoringRequest.context,
|
||||
valueColumns: authoringRequest.valueColumns,
|
||||
allowAssignedAbility: authoringRequest.allowAssignedAbility,
|
||||
allowProtocolUpgradeScaling: authoringRequest.allowProtocolUpgradeScaling,
|
||||
transcript: authoringRequest.transcript,
|
||||
response
|
||||
};
|
||||
}
|
||||
answers = {};
|
||||
activityMessage = response.status === 'clarification_required'
|
||||
? 'Clarification required before a reliable draft can be created.'
|
||||
@@ -226,6 +254,9 @@
|
||||
errorMessage = '';
|
||||
activityMessage = '';
|
||||
confirmApply = false;
|
||||
gapHandoff = null;
|
||||
gapReportSubmitted = false;
|
||||
handoffMessage = '';
|
||||
void requestDraft([]);
|
||||
}
|
||||
|
||||
@@ -235,6 +266,53 @@
|
||||
confirmApply = false;
|
||||
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
|
||||
}
|
||||
|
||||
async function copyDeveloperPrompt() {
|
||||
if (!gapHandoff) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildMechanicDeveloperPrompt(gapHandoff));
|
||||
handoffMessageKind = 'success';
|
||||
handoffMessage = 'Developer prompt copied to the clipboard.';
|
||||
} catch {
|
||||
handoffMessageKind = 'error';
|
||||
handoffMessage = 'Clipboard access was blocked. Check this browser’s clipboard permission and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitGapReport() {
|
||||
if (!gapHandoff || gapReportPending || gapReportSubmitted) return;
|
||||
gapReportPending = true;
|
||||
handoffMessage = '';
|
||||
try {
|
||||
const request = await fetch('/api/isac/mechanic-gap-report', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(gapHandoff)
|
||||
});
|
||||
const payload = request.headers.get('content-type')?.includes('application/json')
|
||||
? await request.json()
|
||||
: null;
|
||||
if (!request.ok) {
|
||||
throw new Error(
|
||||
typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: `The mechanic gap could not be submitted (${request.status}).`
|
||||
);
|
||||
}
|
||||
gapReportSubmitted = true;
|
||||
handoffMessageKind = 'success';
|
||||
handoffMessage = payload?.alreadySubmitted
|
||||
? 'This mechanic gap was already submitted to the admin team.'
|
||||
: `Mechanic gap submitted to ${Number(payload?.recipientCount ?? 0)} ${Number(payload?.recipientCount ?? 0) === 1 ? 'administrator' : 'administrators'}.`;
|
||||
} catch (error) {
|
||||
handoffMessageKind = 'error';
|
||||
handoffMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'The mechanic gap could not be submitted to the admin team.';
|
||||
} finally {
|
||||
gapReportPending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
|
||||
@@ -414,11 +492,36 @@
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<div class="result-actions handoff-actions">
|
||||
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
||||
REVISE SOURCE WORDING
|
||||
</button>
|
||||
<div>
|
||||
<button type="button" class="secondary" disabled={!gapHandoff} onclick={copyDeveloperPrompt}>
|
||||
COPY DEVELOPER PROMPT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!gapHandoff || gapReportPending || gapReportSubmitted}
|
||||
aria-busy={gapReportPending}
|
||||
onclick={submitGapReport}
|
||||
>
|
||||
{gapReportPending
|
||||
? 'SUBMITTING GAP…'
|
||||
: gapReportSubmitted
|
||||
? 'SUBMITTED TO ADMIN TEAM'
|
||||
: 'SUBMIT TO ADMIN TEAM'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if handoffMessage}
|
||||
<p
|
||||
class="handoff-message"
|
||||
class:error={handoffMessageKind === 'error'}
|
||||
role={handoffMessageKind === 'error' ? 'alert' : 'status'}
|
||||
>{handoffMessage}</p>
|
||||
{/if}
|
||||
{:else if response?.status === 'draft_ready'}
|
||||
<div class="result-heading ready">
|
||||
<div>
|
||||
@@ -524,6 +627,7 @@
|
||||
.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)}
|
||||
.handoff-actions>div{display:flex;gap:var(--shd-gap-xs)}
|
||||
.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}
|
||||
@@ -552,10 +656,13 @@
|
||||
.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)}
|
||||
.handoff-message{margin:0;padding:7px 9px;border-left:2px solid var(--shd-success);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.handoff-message.error{border-left-color:var(--shd-danger);color:var(--shd-danger)}
|
||||
@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}
|
||||
.handoff-actions>div{display:grid;grid-template-columns:1fr;width:100%}
|
||||
.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}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
|
||||
let recipeKey = $state<TriggerActionKey | ''>('');
|
||||
let recipeCount = $state(9);
|
||||
let recipeWindow = $state(3);
|
||||
let recipeDistance = $state(10);
|
||||
@@ -118,13 +118,15 @@
|
||||
}
|
||||
|
||||
function applyRecipe(replace = false) {
|
||||
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
|
||||
if (!recipeKey) return;
|
||||
const selectedRecipeKey = recipeKey;
|
||||
const result = createMechanicRecipe(selectedRecipeKey, recipeParams() as never);
|
||||
if (result.status !== 'supported') return;
|
||||
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
||||
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
|
||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
|
||||
key: `${slug(selectedRecipeKey)}-${rule.clauses.length + index + 1}`,
|
||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[selectedRecipeKey].label,
|
||||
event: seed.when,
|
||||
recipeKey,
|
||||
recipeKey: selectedRecipeKey,
|
||||
if: seed.if ?? { kind: 'true' },
|
||||
reducer: seed.reducer,
|
||||
cooldownSeconds: seed.cooldownSeconds,
|
||||
@@ -519,6 +521,7 @@
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="RECIPE"
|
||||
emptySelectionLabel="NO RECIPE SELECTED"
|
||||
bind:value={recipeKey}
|
||||
/>
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
|
||||
@@ -530,8 +533,8 @@
|
||||
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
|
||||
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
||||
{/if}
|
||||
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||
{#if rule.clauses.length}
|
||||
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||
{#if rule.clauses.length && recipeKey}
|
||||
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user