dhuser's picture
add_support_ilaas_llm_provider (#2)
53aefb2
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LREC 2026 — LLM Annotator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
colors: {
ink: {
950: '#0b0f19',
900: '#111827',
800: '#1f2937',
700: '#374151',
500: '#6b7280',
400: '#9ca3af',
300: '#d1d5db',
200: '#e5e7eb',
100: '#f3f4f6',
50: '#f9fafb'
},
accent: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca'
},
}
}
}
}
</script>
<link rel="stylesheet" href="/static/styles.css?v=20260516f">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script>
</head>
<body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak>
<!-- =====================================================================
TOP BAR
===================================================================== -->
<header class="sticky top-0 z-40 bg-white/95 backdrop-blur border-b border-ink-200">
<div class="max-w-[1400px] mx-auto px-5 py-3 flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center text-white text-lg">
</div>
<div>
<h1 class="font-semibold text-ink-900 leading-tight">LLM-as-Annotator</h1>
<div class="text-[11px] text-ink-500 leading-tight">LREC 2026 · <a
class="hover:text-accent-600 inline-flex items-center gap-1 align-middle" target="_blank"
href="https://aclanthology.org/2026.loreslm-1.28/" title="Read on ACL Anthology"><img
src="/static/logos/acl-anthology.svg" alt="ACL Anthology"
class="inline-block h-3 w-3 align-middle">paper: Vidal-Gorène et al. 2026, LoResLM →</a></div>
</div>
</div>
<span class="text-ink-300">·</span>
<!-- Task pill -->
<button @click="modal='task'" class="pill" :title="schema?.task_name">
<span class="pill-icon">📋</span>
<span class="pill-label">Task</span>
<span class="pill-value truncate max-w-[160px]" x-text="schema?.task_name || 'choose…'"></span>
</button>
<!-- Models pill -->
<button @click="modal='models'" class="pill"
:class="(state.provider === 'openrouter' && state.models.length >= 2) ? 'pill-moe' : ''"
:title="state.models.join(', ')">
<span class="pill-icon"
x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? '🧠' : '🤖'"></span>
<span class="pill-label"
x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? 'MoE' : 'Model'"></span>
<span class="pill-value"
x-text="state.models.length + ((state.provider === 'openrouter' && state.models.length >= 2) ? ' active' : '')"></span>
</button>
<!-- API key pill -->
<button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'"
:title="localKey ? state.provider + ' key stored in this tab only. Click to manage.' : (state.provider === 'openrouter' && state.has_env_key ? 'Using server-side OPENROUTER_API_KEY env.' : 'No key yet for ' + state.provider)">
<span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span>
<span class="pill-label capitalize" x-text="hasKey ? state.provider : 'Add'"></span>
<span class="pill-value"
x-text="localKey ? 'tab-only' : ((state.provider === 'openrouter' && state.has_env_key) ? 'shared env' : 'API key')"></span>
</button>
<!-- Cite pill -->
<button @click="modal='cite'" class="pill" title="Cite this work">
<span class="pill-icon">📚</span>
<span class="pill-label">Cite</span>
</button>
<div class="flex-1"></div>
<!-- Selection bulk bar -->
<template x-if="selection.size > 0">
<div class="flex items-center gap-2 mr-2">
<span class="text-xs text-ink-500" x-text="selection.size + ' selected'"></span>
<button @click="modal='bulk'" class="btn btn-secondary text-xs">Apply tag…</button>
<button @click="clearSelection()" class="btn btn-ghost text-xs">Clear</button>
</div>
</template>
<button @click="modal='help'" class="btn btn-ghost" title="Keyboard shortcuts & help">?</button>
<button @click="modal='advanced'" class="btn btn-ghost" title="Advanced settings"></button>
<button @click="annotateAll()"
:disabled="!canRun || loading"
class="btn btn-primary"
:class="{'opacity-50 cursor-not-allowed': !canRun || loading}">
<span x-show="!loading">▶ Annotate all</span>
<span x-show="loading" class="flex items-center gap-2"><span class="spinner"></span><span
x-text="progressText"></span></span>
</button>
</div>
<!-- Sub-bar with corpus context -->
<div class="max-w-[1400px] mx-auto px-5 pb-2 flex items-center gap-3 text-xs text-ink-500">
<span x-show="state.language">📍 <span class="text-ink-700 font-medium" x-text="state.language"></span></span>
<span x-show="state.sentences.length > 0">·</span>
<span x-show="state.sentences.length > 0">
<span class="text-ink-700 font-medium" x-text="state.sentences.length"></span> sentences,
<span class="text-ink-700 font-medium" x-text="totalTokens"></span> tokens
</span>
<span x-show="totalDisagreements > 0">· ⚠ <span class="text-amber-700 font-medium"
x-text="totalDisagreements"></span> disagreements</span>
<span class="flex-1"></span>
<span>ICL pool · v<span class="text-ink-700 font-medium" x-text="state.icl_pool.version"></span> · <span
class="text-ink-700 font-medium" x-text="state.icl_pool.size"></span> ex</span>
<button @click="modal='icl'" class="text-accent-600 hover:underline">view</button>
<span>·</span>
<button @click="modal='exports'" class="text-accent-600 hover:underline">export</button>
</div>
</header>
<!-- =====================================================================
LAYOUT
===================================================================== -->
<div class="max-w-[1400px] mx-auto px-5 py-6 grid grid-cols-12 gap-6">
<!-- Sidebar -->
<aside class="col-span-3 space-y-5">
<section class="card">
<h3 class="card-title">🚀 Quick start</h3>
<p class="text-xs text-ink-500 mb-3">Load a sandbox corpus from the LREC sandbox in one click.</p>
<div class="space-y-2">
<template x-for="ex in state.exercises" :key="ex.idx">
<button @click="loadExercise(ex.idx)"
class="w-full text-left p-3 rounded-lg border border-ink-200 hover:border-accent-300 hover:bg-accent-50 transition">
<div class="font-medium text-sm" x-text="ex.title.split('—')[1]?.trim() || ex.title"></div>
<div class="text-[11px] text-ink-500 mt-0.5" x-text="ex.summary.slice(0,90)+'…'"></div>
</button>
</template>
</div>
</section>
<section class="card">
<h3 class="card-title">📝 Your text</h3>
<button @click="modal='paste'" class="btn btn-secondary w-full">+ Paste text</button>
<button @click="clearCorpus()" x-show="state.sentences.length > 0"
class="btn btn-ghost w-full mt-2 text-xs">Clear corpus only
</button>
<button @click="resetAll()" class="btn btn-ghost w-full mt-1 text-xs text-red-600 hover:!bg-red-50">
🗑 Reset everything
</button>
</section>
<div class="rounded-lg border-2 border-red-400 bg-red-50 text-red-800 p-3 text-xs leading-relaxed">
<strong>⚠ Heavy use?</strong> Duplicate this Space to your own account for intensive use and to avoid data
loss — shared instances may be reset at any time.
</div>
<section class="card" x-show="state.sentences.length > 0">
<h3 class="card-title">⚡ Shortcuts</h3>
<ul class="text-[11px] text-ink-500 space-y-1 font-mono">
<li><kbd>j</kbd> / <kbd>k</kbd> next / prev token</li>
<li><kbd>e</kbd> edit focused token</li>
<li><kbd>1</kbd><kbd>9</kbd> assign tag (in editor)</li>
<li><kbd>x</kbd> toggle selection</li>
<li><kbd>r</kbd> re-annotate sentence</li>
<li><kbd>esc</kbd> close popup</li>
</ul>
</section>
</aside>
<!-- Main corpus panel -->
<main class="col-span-9 space-y-4" tabindex="0" @keydown="handleKey($event)" x-ref="main">
<!-- Guide banner — contextual, updates with state -->
<section x-show="guide && !guideDismissed"
class="rounded-xl border border-accent-200 bg-gradient-to-br from-accent-50 to-purple-50 p-4 flex items-start gap-4"
x-transition>
<div class="text-2xl shrink-0" x-text="guide?.icon || '📘'"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[11px] font-semibold uppercase tracking-wider text-accent-700"
x-text="`Step ${guide?.step}/5`"></span>
<span class="text-[11px] text-ink-500">·</span>
<span class="text-sm font-semibold text-ink-900" x-text="guide?.title"></span>
</div>
<p class="text-xs text-ink-700 mt-1 leading-relaxed" x-html="guide?.body"></p>
<div class="flex flex-wrap gap-2 mt-3" x-show="guide?.actions?.length > 0">
<template x-for="(a, i) in (guide?.actions || [])" :key="i">
<button @click="runGuideAction(a)"
class="text-xs"
:class="i === 0 ? 'btn btn-primary' : 'btn btn-secondary'"
x-text="a.label"></button>
</template>
</div>
</div>
<div class="flex flex-col gap-1 shrink-0">
<button @click="guideDismissed=true; localStorage.setItem('guideDismissed','1')"
class="btn btn-ghost btn-icon" title="Hide guide">
</button>
</div>
</section>
<!-- MoE explainer — only meaningful on OpenRouter -->
<section
x-show="state.provider === 'openrouter' && state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0"
class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3"
x-transition>
<span class="text-base">🧠</span>
<div class="flex-1">
<strong>Mixture-of-Experts is ON.</strong>
<span x-text="state.models.length"></span> models will run in parallel — answers are voted per token,
and contested tokens (⚠) are highlighted for your review.
</div>
<button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon"></button>
</section>
<section
x-show="state.provider === 'openrouter' && state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed"
class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3"
x-transition>
<span class="text-base">💡</span>
<div class="flex-1">
Single-model mode. Add a 2nd model in
<button class="text-accent-600 underline" @click="modal='models'">Models</button>
to compare per token (Mixture-of-Experts).
</div>
<button @click="moeHintDismissed=true" class="btn btn-ghost btn-icon"></button>
</section>
<!-- Empty state -->
<template x-if="state.sentences.length === 0">
<div class="card text-center py-16">
<div class="text-5xl mb-4">📜</div>
<h2 class="text-xl font-semibold mb-2">No corpus loaded</h2>
<p class="text-ink-500 mb-6 max-w-md mx-auto">Pick a sandbox example from the sidebar, or paste your own
text.</p>
<div class="flex justify-center gap-2">
<button @click="loadExercise(0)" class="btn btn-primary">Try Ancient Greek</button>
<button @click="modal='paste'" class="btn btn-secondary">Paste my text</button>
</div>
</div>
</template>
<!-- Sentence cards -->
<template x-for="(sent, sidx) in state.sentences" :key="sent.id + '-' + rev">
<article class="card !p-0 overflow-hidden">
<header class="px-4 py-2.5 bg-ink-50 border-b border-ink-200 flex items-center gap-3">
<span class="font-mono text-xs text-ink-500" x-text="sent.id"></span>
<span class="text-xs" x-show="sent.n_disagreements > 0">
<span class="dot dot-warn"></span>
<span class="text-amber-700" x-text="sent.n_disagreements + ' disagree'"></span>
</span>
<span class="text-xs" x-show="sent.status === 'done' && sent.n_disagreements === 0">
<span class="dot dot-ok"></span>
<span class="text-emerald-700">consensus</span>
</span>
<span class="text-xs" x-show="sent.status === 'annotating'">
<span class="spinner-sm"></span>
<span class="text-accent-600 ml-1">annotating…</span>
</span>
<span class="text-xs" x-show="sent.status === 'pending'">
<span class="dot dot-pending"></span>
<span class="text-ink-500">pending</span>
</span>
<span class="text-xs text-red-600" x-show="sent.status === 'error'"
x-text="'⚠ ' + (sent.error || 'error').slice(0,80)"></span>
<div class="flex-1"></div>
<button @click="annotateOne(sidx)" :disabled="loading" class="btn btn-secondary text-xs">
<span x-show="sent.status === 'done'">↻ Re-annotate</span>
<span x-show="sent.status !== 'done'">▶ Annotate</span>
</button>
<!-- Score toggle — reveals per-model accuracy vs current annotation. Purely display. -->
<button x-show="sent.status === 'done' && !sent.validated"
@click="setValidated(sidx, true)"
class="btn btn-secondary text-xs"
title="Compute per-model accuracy against your current annotation. Doesn't affect export.">
📊 Score
</button>
<span x-show="sent.validated"
class="inline-flex items-center gap-1 text-xs text-accent-700 font-medium px-2 py-1 rounded bg-accent-50 border border-accent-200 cursor-pointer"
@click="setValidated(sidx, false)"
title="Click to hide the scores">
📊 Scored
</span>
<button @click="addSentenceToIcl(sidx)" :disabled="sent.status !== 'done'"
class="btn btn-ghost text-xs"
:class="{'opacity-50': sent.status !== 'done'}"
title="Add this annotation to the ICL pool as a few-shot example (also reveals scores).">
📥 to ICL
</button>
</header>
<!-- Per-model accuracy strip — purely informational, gated behind the Score toggle -->
<div class="px-4 pt-2 -mb-1 flex flex-wrap items-center gap-1.5"
x-show="sent.validated && Object.keys(sent.per_model || {}).length > 0">
<span class="text-[10px] uppercase tracking-wider text-accent-700 font-semibold mr-1"
title="% of task-meaningful fields each model got right, compared to your current annotation">match · your version</span>
<template x-for="m in (sent._accuracy || modelAccuracy(sent))" :key="m.model">
<span class="accuracy-pill" :class="accuracyClass(m.pct)"
:title="`${m.model}: ${m.correct}/${m.total} fields match your current annotation`">
<span class="font-mono opacity-80" x-text="modelShort(m.model)"></span>
<strong x-text="m.pct + '%'"></strong>
</span>
</template>
</div>
<div class="p-4">
<div class="tokens-flow">
<template x-for="(tok, tidx) in sent.tokens" :key="sent.id + '-' + tidx + '-' + rev">
<button class="token"
:class="tokenClass(sent, sidx, tidx, tok)"
:title="tokenTooltip(sent, tidx)"
@click="onTokenClick($event, sidx, tidx)"
@contextmenu.prevent="openTokenContext($event, sidx, tidx)"
:data-sent="sidx" :data-tok="tidx"
x-ref="`tok-${sidx}-${tidx}`">
<span class="token-surface" x-text="tok.surface"></span>
<span class="token-tag" x-text="primaryTag(tok)" x-show="primaryTag(tok)"></span>
</button>
</template>
</div>
</div>
</article>
</template>
</main>
</div>
<!-- =====================================================================
MODALS
===================================================================== -->
<!-- Token editor -->
<template x-if="modal === 'token' && editor.tok">
<div class="modal-backdrop" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-2xl">
<header class="modal-header">
<div>
<div class="text-xs text-ink-500">Editing token</div>
<div class="font-mono text-xl mt-0.5" x-text="editor.tok?.surface"></div>
</div>
<div class="flex items-center gap-2">
<button @click="moveToken(-1)" class="btn btn-ghost btn-icon" title="Previous (←)"></button>
<button @click="moveToken(1)" class="btn btn-ghost btn-icon" title="Next (→)"></button>
<button @click="closeModal()" class="btn btn-ghost btn-icon" title="Close (Esc)"></button>
</div>
</header>
<div class="grid grid-cols-2 gap-5 p-5">
<!-- Fields -->
<div class="space-y-4">
<template x-for="f in schemaFields" :key="f.name">
<div>
<label class="lbl" x-text="f.name + (f.nullable ? ' (optional)' : '')"></label>
<!-- enum -->
<template x-if="f.type === 'enum'">
<div>
<input class="input" type="text" :placeholder="'Search ' + f.name + '…'"
x-model="editor.search[f.name]"
@input.debounce.50ms="refreshFilter(f.name, f.values)">
<div class="chips mt-2 max-h-44 overflow-y-auto">
<template x-for="v in (editor.filtered[f.name] || f.values)" :key="v">
<button type="button"
class="chip"
:class="editor.tok && editor.tok[f.name] === v ? 'chip-active' : ''"
@click="if (editor.tok) editor.tok[f.name] = v"
x-text="v"></button>
</template>
</div>
</div>
</template>
<!-- string -->
<template x-if="f.type === 'string'">
<input class="input"
type="text"
:value="editor.tok ? (editor.tok[f.name] || '') : ''"
@input="if (editor.tok) editor.tok[f.name] = $event.target.value"
:placeholder="f.name">
</template>
<!-- object -->
<template x-if="f.type === 'object'">
<div class="space-y-2">
<template x-for="sub in f.subfields" :key="sub.name">
<div class="flex items-center gap-2">
<label class="text-xs text-ink-500 w-24" x-text="sub.name"></label>
<input class="input flex-1"
type="text"
:value="editor.tok ? ((editor.tok[f.name] || {})[sub.name] || '') : ''"
@input="if (editor.tok) editor.tok[f.name] = {...(editor.tok[f.name] || {}), [sub.name]: $event.target.value}">
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<!-- Per-model votes & metadata -->
<div class="space-y-4">
<div>
<label class="lbl">Per-model output</label>
<div class="text-xs space-y-1.5 mt-1.5"
x-show="editor.perModel && Object.keys(editor.perModel).length > 0">
<template x-for="[m, ann] in Object.entries(editor.perModel || {})" :key="m">
<div class="flex items-start gap-2 p-2 rounded bg-ink-50">
<span class="font-mono text-[11px] text-ink-700 min-w-[110px] truncate" :title="m"
x-text="modelShort(m)"></span>
<span class="text-[11px] text-ink-900"
x-text="modelTokenSummary(ann, editor.tidx)"></span>
<button class="ml-auto text-[10px] text-accent-600 hover:underline"
@click="adoptFromModel(m)">adopt
</button>
</div>
</template>
</div>
<p class="text-[11px] text-ink-500 mt-1.5"
x-show="!editor.perModel || Object.keys(editor.perModel || {}).length === 0">
(run annotation first to see per-model output)
</p>
</div>
<div>
<label class="lbl">Disagreements on this token</label>
<div class="text-xs space-y-1 mt-1.5">
<template x-for="d in editor.disagreementCells" :key="d.field_path">
<div class="text-amber-700"><span class="font-mono" x-text="d.field_path"></span>:
agreement <span x-text="(d.agreement_ratio*100).toFixed(0)+'%'"></span></div>
</template>
<div x-show="!editor.disagreementCells || editor.disagreementCells.length === 0"
class="text-ink-500">none — consensus reached.
</div>
</div>
</div>
<div>
<label class="lbl">Re-ask one model</label>
<div class="flex flex-wrap gap-1.5 mt-1.5">
<template x-for="m in state.models" :key="m">
<button class="chip chip-sm" @click="reaskOneToken(m)" x-text="modelShort(m)"></button>
</template>
</div>
</div>
</div>
</div>
<!-- Propagate option (shown when other tokens share the surface AND user has changed something) -->
<div class="px-5 pb-3"
x-show="editor.tok && matchingTokenCount() > 0 && Object.keys(fieldChanges()).length > 0"
x-transition>
<label class="flex items-start gap-2 p-3 rounded-lg border border-accent-200 bg-accent-50 cursor-pointer">
<input type="checkbox" x-model="editor.propagateToSimilar" class="mt-0.5">
<div class="text-xs leading-relaxed flex-1">
<div>Also apply to <strong x-text="matchingTokenCount()"></strong> other
<code class="font-mono bg-white px-1.5 py-0.5 rounded border border-accent-200"
x-text="editor.tok?.surface || ''"></code>
token<span x-show="matchingTokenCount() > 1">s</span> in the corpus.
</div>
<div class="text-ink-500 mt-1">
Changes: <span class="font-mono text-ink-700" x-text="fieldChangesSummary()"></span>
</div>
</div>
</label>
</div>
<footer class="modal-footer">
<span class="text-xs text-ink-500" x-show="editor.autoAdvance">↵ saves &amp; auto-advances to next disagreement</span>
<span class="flex-1"></span>
<label class="text-xs text-ink-500 mr-3 flex items-center gap-1.5"><input type="checkbox"
x-model="editor.autoAdvance">
auto-advance</label>
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-primary" @click="saveToken()">Save (↵)</button>
</footer>
</div>
</div>
</template>
<!-- Task -->
<div class="modal-backdrop" x-show="modal === 'task'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-3xl">
<header class="modal-header">
<h2 class="font-semibold">Task & label schema</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-5">
<div>
<label class="lbl">Preset</label>
<div class="grid grid-cols-2 gap-2 mt-2">
<template x-for="p in state.presets" :key="p.key">
<button class="preset-card"
:class="currentPresetMatches(p.key) ? 'preset-card-active' : ''"
@click="setPreset(p.key)">
<div class="font-medium text-sm" x-text="p.label"></div>
</button>
</template>
</div>
</div>
<div>
<label class="lbl">Current schema fields (advanced)</label>
<details class="mt-1">
<summary class="text-xs text-ink-500 cursor-pointer">edit fields…</summary>
<textarea class="input font-mono text-xs mt-2" rows="14" x-model="taskEditor.json"
@change="applyTaskJson()"></textarea>
<p class="text-[11px] text-ink-500 mt-1">JSON of the internal schema (fields with
name/type/values/nullable/aggregator). Click outside to apply.</p>
</details>
</div>
</div>
</div>
</div>
<!-- Models -->
<div class="modal-backdrop" x-show="modal === 'models'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-2xl">
<header class="modal-header">
<h2 class="font-semibold">Models</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-4">
<!-- Provider selector -->
<div>
<label class="lbl">Provider</label>
<div class="grid grid-cols-3 gap-2">
<template x-for="p in state.providers" :key="p">
<button class="preset-card text-xs capitalize"
:class="state.provider === p ? 'preset-card-active' : ''"
@click="setProvider(p)">
<div class="font-semibold" x-text="p"></div>
<div class="text-[10px] text-ink-500 mt-0.5 font-mono normal-case truncate"
x-text="p === 'openrouter' ? 'openrouter.ai/api/v1 · MoE' : p === 'mistral' ? 'api.mistral.ai/v1' : p === 'openai' ? 'api.openai.com/v1' : p === 'ilaas' ? 'llm.ilaas.fr/v1' : p"></div>
</button>
</template>
</div>
<p class="text-[11px] text-ink-500 mt-1.5">
<span x-show="state.provider === 'openrouter'">🧠 OpenRouter supports MoE — you can pick several models.</span>
<span x-show="state.provider !== 'openrouter'">Single-model only. For Mixture-of-Experts (parallel models + vote), use <strong>OpenRouter</strong>.</span>
</p>
</div>
<!-- Curated models for the active provider -->
<div>
<label class="lbl">Models for <span x-text="state.provider"></span></label>
<div class="space-y-1.5 max-h-60 overflow-y-auto border border-ink-200 rounded-lg p-1">
<template x-for="m in allDisplayableModels()" :key="m">
<label class="flex items-center gap-3 p-2 rounded hover:bg-ink-50 cursor-pointer">
<input type="checkbox" :checked="state.models.includes(m)" @change="toggleModel(m)">
<span class="font-mono text-sm" x-text="m"></span>
<span class="badge badge-uploaded text-[10px]" x-show="!state.curated_models.includes(m)">custom</span>
</label>
</template>
</div>
</div>
<!-- Custom slug -->
<div>
<label class="lbl">Add a custom model slug for <span x-text="state.provider"></span></label>
<div class="flex gap-2 mt-1">
<input class="input flex-1" type="text" x-model="modelEditor.custom"
placeholder="model-id (e.g. mistral-medium-2505)">
<button class="btn btn-secondary" @click="addCustomModel()">Add</button>
</div>
</div>
<!-- MoE priority — only meaningful when OpenRouter + ≥2 models -->
<div x-show="state.provider === 'openrouter'">
<label class="lbl">MoE priority (tie-break order, comma-separated slugs)</label>
<input class="input" type="text" x-model="modelEditor.priority"
@change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})"
placeholder="model1, model2, model3">
</div>
</div>
</div>
</div>
<!-- API key -->
<div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-md">
<header class="modal-header">
<h2 class="font-semibold capitalize"><span x-text="state.provider"></span> API key</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-3">
<!-- Provider switcher (compact) -->
<div>
<label class="lbl">Provider</label>
<div class="flex gap-1.5">
<template x-for="p in state.providers" :key="p">
<button class="chip capitalize"
:class="state.provider === p ? 'chip-active' : ''"
@click="setProvider(p)" x-text="p"></button>
</template>
</div>
</div>
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900">
<strong>🔒 Tab-scoped storage.</strong> Keys live only in this browser tab's <code>sessionStorage</code>
(one slot per provider). Sent as <code>X-API-Key</code> + <code>X-LLM-Provider</code> headers — never
stored on the server.
</div>
<template x-if="localKey">
<div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2">
<span class="text-emerald-700"></span>
<span class="text-ink-700 flex-1">A <span
x-text="state.provider"></span> key is set in this tab.</span>
<button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear</button>
</div>
</template>
<label class="lbl"
x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label>
<input class="input" type="password"
:placeholder="state.provider === 'openrouter' ? 'sk-or-v1-…' : state.provider === 'openai' ? 'sk-…' : state.provider === 'mistral' ? 'Mistral key' : state.provider === 'ilaas' ? 'ILAAS key' : 'API key'"
x-model="keyEditor.value"
@keydown.enter.prevent="saveKey()"
x-ref="keyInput"
autocomplete="off"
autocorrect="off"
spellcheck="false">
<div class="flex items-center gap-2">
<button class="btn btn-primary flex-1" @click="saveKey()"
x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button>
<button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing"
:title="`Test against ${state.provider} and save if it works`">
<span x-show="!keyEditor.testing">Test &amp; save</span>
<span x-show="keyEditor.testing" class="spinner-sm"></span>
</button>
</div>
<div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'"
x-text="keyEditor.result"></div>
<template x-if="state.has_env_key && state.provider === 'openrouter'">
<div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2">
ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the
server fallback is used.
</div>
</template>
<p class="text-[11px] text-ink-400">
Get a key →
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openrouter'"
href="https://openrouter.ai/keys">openrouter.ai/keys</a>
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'mistral'"
href="https://console.mistral.ai/api-keys">console.mistral.ai/api-keys</a>
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openai'"
href="https://platform.openai.com/api-keys">platform.openai.com/api-keys</a>
<a class="text-accent-600 underline"
target="_blank"
x-show="state.provider === 'ilaas'"
href="https://www.ilaas.fr/services-inference/">
ilaas.fr/services-inference
</a>
</p>
</div>
</div>
</div>
<!-- Paste -->
<div class="modal-backdrop" x-show="modal === 'paste'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-3xl">
<header class="modal-header">
<h2 class="font-semibold">Load your own corpus</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-5">
<!-- 1. Text -->
<div>
<label class="lbl">① Your text</label>
<textarea class="input font-mono text-sm" rows="6" x-model="pasteEditor.text"
placeholder="One sentence per line. With 'one token per line', put each token on its own line and separate sentences with a blank line."></textarea>
<div class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="lbl">Tokenization</label>
<select class="input" x-model="pasteEditor.tokenizer">
<option value="whitespace">Split on whitespace</option>
<option value="as_is">One token per line</option>
<option value="newline">Newline-separated</option>
</select>
</div>
<div>
<label class="lbl">Language (free text)</label>
<input class="input" type="text" x-model="pasteEditor.language"
placeholder="e.g. Ancient Greek">
</div>
</div>
</div>
<!-- 2. Task preset -->
<div>
<label class="lbl">② What should the LLM annotate?</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<template x-for="p in state.presets" :key="p.key">
<button class="preset-card text-xs"
:class="pasteEditor.presetKey === p.key ? 'preset-card-active' : ''"
@click="pasteEditor.presetKey = p.key">
<div class="font-medium" x-text="p.label"></div>
</button>
</template>
</div>
</div>
<!-- 3. Custom inline editor — only when 'custom' is picked -->
<div x-show="pasteEditor.presetKey === 'custom'"
class="rounded-lg border border-accent-200 bg-accent-50/60 p-3 space-y-3" x-transition>
<div>
<label class="lbl">Task name</label>
<input class="input" type="text" x-model="pasteEditor.customTaskName"
placeholder="e.g. Medieval NER, Diplomatic tag…">
</div>
<div>
<label class="lbl">Your tag set (the LLM picks one per token)</label>
<div class="flex flex-wrap gap-1.5 mb-2" x-show="pasteEditor.customTags.length > 0">
<template x-for="(t, i) in pasteEditor.customTags" :key="t + '-' + i">
<span class="chip chip-active inline-flex items-center gap-1.5">
<span x-text="t"></span>
<button type="button" @click="pasteEditor.customTags.splice(i, 1)"
class="hover:opacity-70 leading-none"></button>
</span>
</template>
</div>
<div class="flex gap-2">
<input class="input flex-1" type="text"
x-model="pasteEditor.customTagInput"
@keydown="onTagKeydown($event)"
@blur="if ((pasteEditor.customTagInput || '').trim()) addCustomTag()"
placeholder="Type a tag, then Enter or comma…">
<button type="button" class="btn btn-secondary text-xs whitespace-nowrap"
@click="addCustomTag()"
:disabled="!(pasteEditor.customTagInput || '').trim()">+ Add
</button>
</div>
<p class="text-[11px] text-ink-500 mt-1">Press <kbd>Enter</kbd>, <kbd>,</kbd> or <kbd>;</kbd> to
add. You can also paste <code>PER, LOC, ORG, WORK</code> and press Enter to add them all at
once.</p>
<label class="flex items-center gap-2 mt-3 text-sm cursor-pointer">
<input type="checkbox" x-model="pasteEditor.includeNone">
<span>Add an <strong>"O"</strong> tag for tokens that match <em>none</em> of your labels
<span class="text-ink-500">(recommended — without it, the LLM is forced to pick a label for every token)</span>
</span>
</label>
</div>
<div>
<label class="lbl">Self-reported metadata, per token</label>
<div class="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" x-model="pasteEditor.includeConfidence">
<span>Confidence <span class="text-ink-500">(low / medium / high)</span></span>
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" x-model="pasteEditor.includeComment">
<span>Optional comment</span>
</label>
</div>
</div>
<p class="text-[11px] text-ink-500">
Need lemmatization, morphology, or several tag columns? Use one of the POS / Lemma presets above, or
open <strong>📋 Task</strong> after loading for finer control.
</p>
</div>
<div class="flex justify-end gap-2 pt-1">
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-primary"
@click="loadPaste()"
:disabled="!pasteEditor.text || (pasteEditor.presetKey === 'custom' && pasteEditor.customTags.length === 0)">
Load corpus
</button>
</div>
</div>
</div>
</div>
<!-- ICL Pool -->
<div class="modal-backdrop" x-show="modal === 'icl'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-2xl">
<header class="modal-header">
<h2 class="font-semibold">ICL pool · v<span x-text="state.icl_pool.version"></span></h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-3">
<p class="text-xs text-ink-500">Corrected sentences live here and are re-injected as few-shot examples on
the next annotation run. The pool filters by <code>(language, schema_hash)</code> so cross-task
contamination cannot happen.</p>
<div class="max-h-72 overflow-y-auto border border-ink-200 rounded">
<table class="w-full text-xs">
<thead class="bg-ink-50 sticky top-0">
<tr>
<th class="th">#</th>
<th class="th">lang</th>
<th class="th">source</th>
<th class="th">preview</th>
</tr>
</thead>
<tbody>
<template x-for="e in state.icl_pool.entries" :key="e.idx">
<tr class="border-t border-ink-100">
<td class="td font-mono" x-text="e.idx"></td>
<td class="td" x-text="e.language"></td>
<td class="td">
<span class="badge" :class="'badge-' + e.source" x-text="e.source"></span>
</td>
<td class="td font-mono text-ink-700" x-text="e.preview"></td>
</tr>
</template>
<tr x-show="state.icl_pool.size === 0">
<td colspan="4" class="td text-center text-ink-500 py-6">empty — correct a sentence and click 📥
to ICL.
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex gap-2">
<a class="btn btn-secondary" href="/api/icl/download" download>⬇ Download .jsonl</a>
<button class="btn btn-ghost" @click="clearIcl()">Clear pool</button>
</div>
</div>
</div>
</div>
<!-- Exports -->
<div class="modal-backdrop" x-show="modal === 'exports'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-md">
<header class="modal-header">
<h2 class="font-semibold">Export corpus</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-2">
<p class="text-xs text-ink-500 mb-3">All sentences in the corpus, with their current (possibly corrected)
annotations.</p>
<a class="btn btn-secondary w-full justify-start" href="/api/export/tsv" download>
<span class="mr-2"></span>TSV <span
class="text-ink-400 text-xs ml-auto">round-trip with PIE baseline</span>
</a>
<a class="btn btn-secondary w-full justify-start" href="/api/export/json" download>
<span class="mr-2"></span>JSON <span class="text-ink-400 text-xs ml-auto">schema-conformant</span>
</a>
<a class="btn btn-secondary w-full justify-start" href="/api/export/conllu" download>
<span class="mr-2"></span>CoNLL-U <span class="text-ink-400 text-xs ml-auto">UD-standard</span>
</a>
<a class="btn btn-secondary w-full justify-start" href="/api/export/jsonl" download>
<span class="mr-2"></span>JSONL <span class="text-ink-400 text-xs ml-auto">fine-tune format</span>
</a>
</div>
</div>
</div>
<!-- Bulk apply -->
<div class="modal-backdrop" x-show="modal === 'bulk'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-md">
<header class="modal-header">
<h2 class="font-semibold">Apply to <span x-text="selection.size"></span> tokens</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-3">
<div>
<label class="lbl">Field</label>
<select class="input" x-model="bulkEditor.field">
<template x-for="f in schemaFields" :key="f.name">
<option :value="f.name" x-text="f.name + ' (' + f.type + ')'"></option>
</template>
</select>
</div>
<div>
<label class="lbl">Value</label>
<template x-if="bulkSelectedField()?.type === 'enum'">
<select class="input" x-model="bulkEditor.value">
<template x-for="v in (bulkSelectedField()?.values || [])" :key="v">
<option :value="v" x-text="v"></option>
</template>
</select>
</template>
<template x-if="bulkSelectedField()?.type !== 'enum'">
<input class="input" type="text" x-model="bulkEditor.value">
</template>
</div>
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-primary" @click="applyBulk()">Apply</button>
</div>
</div>
</div>
</div>
<!-- Advanced (prompt + temperature + n_icl) -->
<div class="modal-backdrop" x-show="modal === 'advanced'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-3xl">
<header class="modal-header">
<h2 class="font-semibold">⚙ Advanced settings</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="lbl">N few-shot examples (per request)</label>
<input class="input" type="number" min="0" max="50" x-model.number="advEditor.n_icl">
</div>
<div>
<label class="lbl">Temperature</label>
<input class="input" type="number" min="0" max="1" step="0.05"
x-model.number="advEditor.temperature">
</div>
</div>
<div>
<label class="lbl">System prompt</label>
<textarea class="input font-mono text-xs" rows="6" x-model="advEditor.system_prompt"></textarea>
</div>
<div>
<label class="lbl">User template (variables: {tokens}, {tagset}, {schema}, {few_shot_examples},
{language}, …)</label>
<textarea class="input font-mono text-xs" rows="14" x-model="advEditor.user_template"></textarea>
</div>
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-primary" @click="saveAdvanced()">Save</button>
</div>
</div>
</div>
</div>
<!-- Help -->
<!-- Cite modal -->
<div class="modal-backdrop" x-show="modal === 'cite'" @click.self="closeModal()" x-transition style="display:none">
<div class="modal-card max-w-2xl">
<header class="modal-header">
<h2 class="font-semibold">Cite this work</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-3">
<p class="text-xs text-ink-500">If you use this tool, please cite:</p>
<pre id="bibtex-block"
class="text-[11px] bg-ink-50 border border-ink-200 rounded-lg p-3 overflow-auto font-mono whitespace-pre">@inproceedings{vidal-gorene-etal-2026-resourced,
title = "Under-resourced studies of under-resourced languages: lemmatization and {POS}-tagging with {LLM} annotators for historical {A}rmenian, {G}eorgian, {G}reek and {S}yriac",
author = "Vidal-Gor{\`e}ne, Chahan and
Kindt, Bastien and
Cafiero, Florian",
editor = "Hettiarachchi, Hansi and
Ranasinghe, Tharindu and
Plum, Alistair and
Rayson, Paul and
Mitkov, Ruslan and
Gaber, Mohamed and
Premasiri, Damith and
Tan, Fiona Anting and
Uyangodage, Lasitha",
booktitle = "Proceedings of the Second Workshop on Language Models for Low-Resource Languages ({L}o{R}es{LM} 2026)",
month = mar,
year = "2026",
address = "Rabat, Morocco",
publisher = "Association for Computational Linguistics",
url = "https://aclanthology.org/2026.loreslm-1.28/",
doi = "10.18653/v1/2026.loreslm-1.28",
pages = "324--334",
ISBN = "979-8-89176-377-7"
}</pre>
<div class="flex gap-2">
<button class="btn btn-primary"
@click="navigator.clipboard.writeText(document.getElementById('bibtex-block').innerText); $event.target.textContent='✓ Copied'; setTimeout(()=>$event.target.textContent='Copy BibTeX', 1500)">
Copy BibTeX
</button>
<a class="btn btn-secondary" target="_blank" href="https://aclanthology.org/2026.loreslm-1.28/">Open on
ACL Anthology ↗</a>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" x-show="modal === 'help'" @click.self="closeModal()" x-transition>
<div class="modal-card max-w-2xl">
<header class="modal-header">
<h2 class="font-semibold">Help</h2>
<button @click="closeModal()" class="btn btn-ghost btn-icon"></button>
</header>
<div class="p-5 space-y-4">
<div>
<h3 class="font-semibold text-sm mb-2">Keyboard shortcuts</h3>
<table class="text-xs w-full">
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>j</kbd> / <kbd>k</kbd></td>
<td>focus next / previous token</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>e</kbd> / <kbd></kbd></td>
<td>edit focused token</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>1</kbd><kbd>9</kbd></td>
<td>(in editor) assign the i-th visible tag</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>x</kbd></td>
<td>toggle selection of focused token</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>r</kbd></td>
<td>re-annotate the focused sentence</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd></kbd></td>
<td>save token edit and advance to next disagreement</td>
</tr>
<tr class="border-t border-ink-100">
<td class="py-1.5 font-mono"><kbd>Esc</kbd></td>
<td>close popup</td>
</tr>
</table>
</div>
<div>
<h3 class="font-semibold text-sm mb-2">Workflow</h3>
<ol class="text-xs space-y-1.5 list-decimal list-inside text-ink-700">
<li>Pick a sandbox example (sidebar) or paste your text.</li>
<li>Choose a task preset (top bar → 📋 Task).</li>
<li>Set OpenRouter API key (top bar → 🔑) and one or more models.</li>
<li>Click <strong>▶ Annotate all</strong>. Disagreement tokens turn amber.</li>
<li>Click a token to fix it. Click <strong>📥 to ICL</strong> to feed corrections back.</li>
<li>Export TSV / JSON / CoNLL-U / JSONL when done.</li>
</ol>
</div>
</div>
</div>
</div>
<!-- =====================================================================
FOOTER LOGOS
===================================================================== -->
<footer class="orgs-footer">
<div class="max-w-[1400px] mx-auto px-5 py-2.5 flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<img src="/static/logos/LREC2026.png" alt="LREC 2026" title="LREC 2026" class="org-logo">
<img src="/static/logos/chartes.png" alt="École nationale des chartes — PSL"
title="École nationale des chartes — PSL" class="org-logo">
<img src="/static/logos/cjm.png" alt="Centre Jean-Mabillon" title="Centre Jean-Mabillon" class="org-logo">
<img src="/static/logos/culturelab.png" alt="PSL CultureLab" title="PSL CultureLab (ANR-10-IDEX-0001)"
class="org-logo">
<img src="/static/logos/DALiH_logo_200.png" alt="DALiH" title="ANR DALiH (ANR-21-CE38-0006)" class="org-logo">
</div>
</footer>
<!-- Token right-click context -->
<div x-show="ctxMenu.open"
class="fixed z-50 bg-white shadow-xl border border-ink-200 rounded-lg py-1.5 text-sm min-w-[180px]"
:style="`top:${ctxMenu.y}px; left:${ctxMenu.x}px`"
@click.outside="ctxMenu.open=false">
<button class="ctx-item" @click="openTokenEditor(ctxMenu.s, ctxMenu.t); ctxMenu.open=false">✎ Edit token</button>
<button class="ctx-item" @click="toggleSelectionIdx(ctxMenu.s, ctxMenu.t); ctxMenu.open=false">⊕ Toggle selection
</button>
<div class="border-t border-ink-100 my-1"></div>
<template x-for="m in state.models" :key="m">
<button class="ctx-item text-xs" @click="reaskOneTokenAt(ctxMenu.s, ctxMenu.t, m); ctxMenu.open=false">
↻ Re-ask <span class="font-mono" x-text="modelShort(m)"></span>
</button>
</template>
</div>
<!-- Toast -->
<div class="fixed bottom-4 right-4 z-50 space-y-2">
<template x-for="t in toasts" :key="t.id">
<div class="toast" :class="'toast-' + t.kind" x-text="t.msg"></div>
</template>
</div>
<script src="/static/app.js"></script>
</body>
</html>