P2c: in-app choir agent with proposals and approval
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import type { AgentMessage, AgentProposal, MeterFrame, Pattern, State } from '../lib/types';
|
||||
import { VEINS } from '../lib/types';
|
||||
import { commandLabel } from '../lib/format';
|
||||
|
||||
let {
|
||||
messages,
|
||||
proposals,
|
||||
busy,
|
||||
project,
|
||||
meter,
|
||||
onask,
|
||||
onapprove,
|
||||
onreject
|
||||
}: {
|
||||
messages: AgentMessage[];
|
||||
proposals: AgentProposal[];
|
||||
busy: boolean;
|
||||
project: State | null;
|
||||
meter: MeterFrame;
|
||||
onask: (text: string) => void;
|
||||
onapprove: (id: number) => void;
|
||||
onreject: (id: number) => void;
|
||||
} = $props();
|
||||
|
||||
let text = $state('');
|
||||
let log: HTMLDivElement | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
messages.length;
|
||||
proposals.length;
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
|
||||
function notes(pattern: Pattern): number {
|
||||
return pattern.rows.reduce(
|
||||
(total, row) => total + row.velocities.filter((velocity) => velocity > 0).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function send() {
|
||||
const value = text.trim();
|
||||
if (!value || busy) return;
|
||||
text = '';
|
||||
onask(value);
|
||||
}
|
||||
|
||||
function listen() {
|
||||
if (!busy) onask('Listen to the room. What do you hear?');
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="choir">
|
||||
<header class="choir-head">
|
||||
<div>
|
||||
<h1>choir</h1>
|
||||
<p>the second performer · same verbs · proposals need a hand</p>
|
||||
</div>
|
||||
<div class="choir-readout">
|
||||
<span>{project?.transport.playing ? 'playing' : 'still'}</span>
|
||||
<span>{Math.round(project?.transport.bpm ?? 128)} bpm</span>
|
||||
<span>{project?.transport.loop_bars ?? 8} bars</span>
|
||||
<span>{project?.cores.length ?? 0} cores</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="choir-log" bind:this={log}>
|
||||
{#if messages.length === 0}
|
||||
<p class="choir-empty">
|
||||
Ask the choir to listen, to lift the bass, to open section B, or to render the eight bars.
|
||||
It hears the levels; project edits arrive as proposals.
|
||||
</p>
|
||||
{/if}
|
||||
{#each messages as message}
|
||||
<div class="choir-message" class:is-user={message.role === 'user'}>
|
||||
<span class="choir-role">{message.role === 'agent' ? 'choir' : message.role}</span>
|
||||
<p>{message.text}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#each proposals as proposal}
|
||||
<div class="choir-proposal">
|
||||
<div class="choir-proposal-head">
|
||||
<span>proposal</span>
|
||||
<span>{proposal.commands.length} move{proposal.commands.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<p>{proposal.description}</p>
|
||||
<div class="choir-moves">
|
||||
{#each proposal.commands as command}
|
||||
<span class="choir-move">{commandLabel(command)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="choir-proposal-actions">
|
||||
<button onclick={() => onapprove(proposal.id)} disabled={busy}>approve</button>
|
||||
<button onclick={() => onreject(proposal.id)} disabled={busy}>reject</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="choir-live">
|
||||
<div class="choir-sections" aria-label="sections">
|
||||
{#each project?.arrangement ?? [] as section, index}
|
||||
<span class="choir-section" class:is-on={index === project?.active_section}>
|
||||
<b>{String.fromCharCode(65 + index)}</b>
|
||||
{notes(section)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="choir-veins" aria-label="levels">
|
||||
{#each VEINS as vein, index}
|
||||
<span class="choir-vein">
|
||||
<i style={`--level:${meter.veins[index] ?? 0}`}></i>
|
||||
{vein.slice(0, 3)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="choir-form"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<button type="button" class="choir-listen" onclick={listen} disabled={busy}>listen</button>
|
||||
<input
|
||||
bind:value={text}
|
||||
onkeydown={handleKey}
|
||||
placeholder={busy ? 'the choir is listening…' : 'ask the choir…'}
|
||||
disabled={busy}
|
||||
aria-label="ask the choir"
|
||||
/>
|
||||
<button type="submit" class="choir-send" disabled={busy || !text.trim()}>send</button>
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user