diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2751a..4d5057f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog — FlowDeck +## v4.12.1 (2026-09-05) — Corrections configuration Agent & IA + AI Meeting Note + +> Correctifs UI sur la section **Settings → Agent & IA** et remplacement de l'ancien bouton +> « AI meeting note » (qui insérait du texte) par une vraie **boîte de réunion interactive**. + +### Fix +- **Champs « Clé API »** : le `x-text` embarquait le SVG de l'icône œil dans l'attribut HTML, ce qui + cassait l'attribut et laissait du texte résiduel (`sk-…': '👁'" style="top:50%;">`). Les boutons + d'affichage/masquage utilisent désormais deux `` avec l'icône en contenu. +- **Champs « URL API »** : pré-remplis avec l'URL réellement utilisée par l'application (base par + défaut du fournisseur, ex. `https://api.openai.com/v1`) quand aucune URL personnalisée n'est + stockée — exposée via `base_url` dans `/api/agent/providers` (fournisseur par défaut + au + changement de provider). + +### Changed +- **AI Meeting Note** : le bouton « Get started » ne génère plus du texte via l'agent. Il crée un + bloc **`meeting`** interactif dans la page (page renommée « Meeting @date » si vide) : + - Étape 1 — consentement (moi-même / message audio auto) + « Start transcribing » ; + - Étape 2 — transcription **live** via Web Speech API (fr-FR, horodatée), boutons Pause / Stop, + fallback « sans audio » (transcript à saisir/coller) si le navigateur ne supporte pas ; + - Étape 3 — onglets Notes / Transcript / Summary, bouton « Générer le résumé » (helper headless + `window.fdAgent.generate` sur le FlowDeck Agent) produisant Résumé / Décisions / Action items, + copie presse-papiers et « Nouvelle transcription ». + - Persistance du bloc et de son état dans la page (champ `meeting` ajouté aux blocs sauvegardés). + +### Infra +- `VERSION` → 4.12.1 ; `app/main.py` version → 4.12.1. + ## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide > Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau diff --git a/VERSION b/VERSION index 815588e..53cf85e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.12.0 +4.12.1 diff --git a/app/main.py b/app/main.py index a0991c9..b33952d 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.12.0", + version="4.12.1", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/services/llm_config.py b/app/services/llm_config.py index 8c3c0f1..3c4d5c2 100644 --- a/app/services/llm_config.py +++ b/app/services/llm_config.py @@ -126,9 +126,13 @@ def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> N def provider_info() -> list[dict]: - """Providers list for the UI: known models + whether an API key is required.""" + """Providers list for the UI: known models + whether an API key is required. + + ``base_url`` is the endpoint the application uses by default for this + provider's chat requests (shown in the Settings "URL API" fields). + """ out: list[dict] = [] - for name, (_base, default_model) in PROVIDERS.items(): + for name, (base, default_model) in PROVIDERS.items(): models = list(PROVIDER_MODELS.get(name) or ()) if default_model and default_model not in models: models.insert(0, default_model) @@ -137,6 +141,7 @@ def provider_info() -> list[dict]: "name": name.capitalize(), "default_model": default_model, "models": models, + "base_url": (base or ""), "requires_key": name not in ("offline", "ollama"), }) return out diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index d990b29..73e0c77 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -204,6 +204,7 @@ get isOpen(){return SM._o;}}; function renderBlock(b,idx){ + if(b.type==='meeting')return renderMeetingBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx); if(b.type==='divider')return `

`; if(b.type==='image')return `
${esc(b.alt||'')}
`; @@ -282,6 +283,235 @@ return `
${esc(c.content)}
`; } + // ═══════════ AI Meeting Note — widget de réunion (v4.12.0) ═══════════ + // Bloc interactif « Notion-like » : consentement, transcription live (Web + // Speech API quand disponible), transcript horodaté + notes, puis résumé + // généré par le FlowDeck Agent. + function renderMeetingBlock(b, idx){ + var actions=idx>=0?`
`:''; + return `
+
+ ${actions} +
Préparation de la réunion…
+
`; + } + + function _fmFind(bid){var e=window.E;if(e&&e.blocks){for(var i=0;i${_fmEsc(st)}
`:''; + var head=`
+ 🎙️ + + 📅 ${_fmEsc(m.date)} + ${pill} +
`; + + var body=''; + if(!m.started){ + body+=`
+
1 · Consentement des participants
+ + +
${audioOk?'Notion AI est prêt : la réunion sera transcrite en direct. Autorisez le micro lorsque le navigateur le demande.':'Ce navigateur ne supporte pas la transcription audio : vous pourrez saisir ou coller la transcription manuellement.'}
+ +
`; + }else{ + var tabs=`
+ + + +
`; + var ctrl=''; + if(m.status==='running'){ + ctrl=`
•••|ı|ı|ı|ı•••
+
+ + +
`; + }else if(m.status==='paused'){ + ctrl=`
+ + +
`; + }else if(m.status==='manual'){ + ctrl=`
`; + }else if(m.status==='done'){ + ctrl=`
`; + } + var panel=''; + if(m.lastTab==='notes'){ + panel=``; + }else if(m.lastTab==='transcript'){ + var lines=(m.lines||[]).map(function(l){return `
${_fmEsc(l.t||'')}${_fmEsc(l.who||'Vous')}${_fmEsc(l.text)}
`;}).join(''); + if(m.status==='running'){lines+=`
·Vous${_fmEsc(m.interim||'')}${(m.interim||'')?'':'…'}
`;} + var manual=m.status!=='running'?`
Transcript saisi ou collé (si aucune capture audio)
`:''; + panel=`
${lines||'
La transcription apparaîtra ici en direct. Enregistrez la réunion pour générer un transcript.
'}
${manual}
`; + }else{ + var summaryHtml=''; + if(m.summaryLoading){ + summaryHtml=`
Génération du résumé…
`; + }else if(m.summary){ + summaryHtml=` +
`; + }else if(m.summaryError){ + summaryHtml=`
${_fmEsc(m.summaryError)}
+ `; + }else{ + var has=!!(_fmTranscriptText(b).trim()); + if(m.status==='done'&&has){ + summaryHtml=` +
L'agent analyse le transcript et produit un aperçu structuré (sujets, décisions, actions).
`; + }else{ + summaryHtml=`
Arrêtez l'enregistrement (ou ajoutez un transcript) puis générez le résumé ici.
`; + } + } + panel=summaryHtml; + } + body=`
${ctrl}${tabs}${panel}
`; + } + return `
${head}${body}
`; + }, + onInput(bid,el){ + var b=_fmEnsure(bid);if(!b||!el)return;var m=b.meeting;var k=el.getAttribute&&el.getAttribute('data-fm'); + if(!k)return; + if(k==='title'){m.title=el.value||'';var tEl=document.getElementById('_titleEl');if(tEl&&!((tEl.textContent||'').trim())){tEl.textContent=m.title;if(window.E)window.E.pageTitle=m.title;}} + else if(k==='notes'){m.notes=el.value;} + else if(k==='manual'){m.manualTranscript=el.value;} + else if(k==='summary'){m.summary=el.value;} + else if(k==='consent'){m.consent=el.value;} + else return; + _fmSave(); + }, + act(bid,act,el){ + var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var self=this; + if(act==='start'){ + if(m.status==='done'||m.status==='idle'||m.status==='paused'){ + if(m.status==='done'){m.lines=[];m.summary='';m.summaryError='';m.manualTranscript='';} + m.started=true;m.lastTab='transcript'; + if(!self._audio()){m.status='manual';m.interim='';_fmSave();self.paint(bid);return;} + if(m.consent==='auto'&&window.speechSynthesis){ + var u=new SpeechSynthesisUtterance('Cette réunion est enregistrée et transcrite par FlowDeck. Merci de votre attention.'); + try{window.speechSynthesis.speak(u);}catch(e){} + } + setTimeout(function(){self._startRec(bid);},m.consent==='auto'?1300:0); + } + }else if(act==='pause'){ + this._stopRec(bid,false);m.status='paused';m.interim='';_fmSave();this.paint(bid); + }else if(act==='stop'){ + this._stopRec(bid,true);m.status='done';m.lastTab='transcript';m.interim='';_fmSave();this.paint(bid); + }else if(act==='new'){ + m.lines=[];m.summary='';m.summaryError='';m.status='idle';m.started=false;m.lastTab='transcript';_fmSave();this.paint(bid); + }else if(act==='tab'){ + m.lastTab=(el&&el.getAttribute&&el.getAttribute('data-tab'))||'transcript';_fmSave();this.paint(bid); + }else if(act==='generate'){ + this._generate(bid); + }else if(act==='copy'){ + var txt=(m.summary||'').trim(); + if(!txt)return; + if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).catch(function(){});} + } + }, + _startRec(bid){ + var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var s=this._rt[bid]||(this._rt[bid]={}); + var SR=window.SpeechRecognition||window.webkitSpeechRecognition; + if(!SR){m.status='manual';_fmSave();this.paint(bid);return;} + if(s.rec){try{s.rec.stop();}catch(e){}} + var rec=new SR();s.rec=rec;s.offset=0;s.stopped=false; + rec.lang='fr-FR';rec.continuous=true;rec.interimResults=true;rec.maxAlternatives=1; + var self=this; + rec.onresult=function(ev){ + var final='',interim=''; + for(var i=(s.offset||0);i${_fmEsc(l.t||'')}${_fmEsc(l.who||'Vous')}${_fmEsc(l.text)}`;}).join('');return lines||'
La transcription apparaîtra ici en direct…
';}, + _generate(bid){ + var b=_fmEnsure(bid);if(!b)return;var m=b.meeting; + var content=_fmTranscriptText(b); + if(!content.trim()){m.summaryError='Aucun contenu de réunion à résumer.';_fmSave();this.paint(bid);return;} + m.summaryLoading=true;m.summaryError='';_fmSave();this.paint(bid); + var self=this; + var prompt='Rédige le résumé structuré de cette réunion (format AI Meeting Note) : une ligne d\'en-tête avec la date du '+_fmEsc(m.date)+', puis les sections « Résumé » (Overview), « Décisions » et « Action items » avec des puces concrètes. Si le contenu est trop court ou fragmentaire, indique « No meaningful content could be extracted ». Ne précède pas la réponse de commentaires.'; + if(!(window.fdAgent&&window.fdAgent.generate)){ + m.summaryLoading=false;m.summaryError='Agent IA indisponible — configurez-le dans Paramètres → Agent & IA.';_fmSave();this.paint(bid);return; + } + window.fdAgent.generate(prompt, content).then(function(text){ + m.summary=(text||'').trim();m.summaryLoading=false;m.summaryError='';_fmSave();self.paint(bid); + }).catch(function(err){ + m.summaryLoading=false;m.summaryError=(err&&err.message)?err.message:'Échec de génération du résumé';_fmSave();self.paint(bid); + }); + } + }; + window.FDMeeting=FDMeeting; + window.E=null; function editorState(){ return { @@ -377,8 +607,10 @@ var self=this; ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');}); ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});}); - // Initialize inline database blocks (scripts in innerHTML don't execute) - setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50); + // Initialize inline database blocks (scripts in innerHTML don't execute) + setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50); + // Initialize AI Meeting Note widgets (block type 'meeting') + setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50); ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();})}); ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); }, @@ -401,7 +633,42 @@ this.aiRun('Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.'); }, meetingNote(){ - this.aiRun('Crée le squelette d\'une « AI meeting note » FlowDeck : une ligne d\'en-tête avec la date et les participants, puis les sections « Résumé », « Décisions » et « Action items » avec des exemples réalistes. Sépare chaque bloc/paragraphe par une ligne vide.'); + // v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu + // d'insérer du texte. La page est renommée « Meeting @date » si elle + // est vide, puis le widget est ajouté (ou remplace le bloc vide initial). + var self=this; + this.gsMoreOpen=false; + var today=new Date(); + var iso=today.toISOString().slice(0,10); + var title='Meeting @'+iso; + var tEl=document.getElementById('_titleEl'); + var curTitle=tEl?(tEl.textContent||'').trim():''; + var blank=!curTitle||curTitle==='Untitled'||curTitle==='New page'; + if(blank){ + this.pageTitle=title; + if(tEl)tEl.textContent=title; + } + var mb=this.mkB('meeting',''); + mb.meeting={ + title:title, + date:iso, + consent:'self', + started:false, + status:'idle', + lines:[], + interim:'', + notes:'', + summary:'', + summaryError:'', + lastTab:'transcript' + }; + this.sync(); + var emptyFirst=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim(); + if(emptyFirst){ this.blocks=[mb]; } else { this.blocks.push(mb); } + this.dirty=true; this.autoSave(); this.render(); + this.showToast('AI Meeting Note : bloc de réunion ajouté. Cliquez sur « Start transcribing ».'); + var bid=mb.id; + setTimeout(function(){ var el=document.querySelector('[data-meeting-bid="'+bid+'"]'); if(el&&el.scrollIntoView)el.scrollIntoView({behavior:'smooth',block:'center'}); if(el&&window.FDMeeting)window.FDMeeting.focus(bid); },120); }, aiRun(prompt){ const self=this; @@ -828,7 +1095,7 @@ const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page'; this.saving=true; const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';}); if(cb)cb(); diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index b142789..0ed6f7a 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -163,6 +163,54 @@ self.input = t; setTimeout(function(){ self.send(); }, 60); }; + // Headless generation (no panel): used by the AI Meeting Note block to + // build its Summary. Returns a Promise of the final assistant text. + window.fdAgent.generate = function(message, context){ + message = (message || '').trim(); + if(!message) return Promise.resolve(''); + var payload = {message: message}; + if(context && context.trim()) payload.context = String(context).slice(0, 20000); + return fetch('/api/agent/conversations', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({title:'AI Meeting Note'}) + }).then(function(r){ return r.json(); }).then(function(conv){ + var cid = conv && conv.id; + if(!cid) throw new Error('Impossible de créer une conversation agent'); + return fetch('/api/agent/conversations/' + cid + '/run', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }).then(function(resp){ + if(!resp.ok){ + return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP ' + resp.status)); }); + } + var reader = resp.body.getReader(); + var dec = new TextDecoder(); + var buf = '', finalText = '', lastError = ''; + function pump(){ + return reader.read().then(function(res){ + if(res.done){ + if(lastError) throw new Error(lastError); + return finalText; + } + buf += dec.decode(res.value, {stream:true}); + var parts = buf.split('\n\n'); + buf = parts.pop(); + for(var i=0;iNew password
- +
@@ -578,7 +581,10 @@
Clé API
- +
@@ -620,8 +626,10 @@
- +
@@ -996,7 +1004,9 @@ document.addEventListener('alpine:init', function() { this.llmProviders.forEach(p => { var k = keys.find(x => x.provider === p.id); var f = this.keyForm(p.id); - f.api_base = (k && k.api_base) || ''; + // Pré-remplit l'URL avec celle réellement utilisée par l'application + // (base par défaut du fournisseur) tant qu'aucune URL personnalisée n'est stockée. + f.api_base = (k && k.api_base) || p.base_url || ''; f.model = (k && k.default_model) || (p.default_model) || ''; f.models = (k && k.models) || []; f.has_key = !!(k && k.has_key); @@ -1122,7 +1132,11 @@ document.addEventListener('alpine:init', function() { this.llmProviders = d.providers || []; this.llmDefaultProvider = d.default_provider || d.provider || 'offline'; this.llmHasKey = !!d.has_api_key; - this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''}; + var defp = this.providerById(d.provider || 'offline'); + // Affiche l'URL réellement utilisée par l'application (base par défaut + // du fournisseur) quand aucune URL personnalisée n'est encore stockée. + this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', + api_base: d.api_base || (defp && defp.base_url) || ''}; this.applyLlmModels(); this.syncLlmStatus(); } catch(e) {} @@ -1137,7 +1151,11 @@ document.addEventListener('alpine:init', function() { onLlmProviderChange() { this.applyLlmModels(); var cur = this.providerById(this.llmForm.provider); - if (cur) this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || ''); + if (cur) { + this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || ''); + // Affiche l'URL utilisée par l'application pour ce fournisseur. + this.llmForm.api_base = cur.base_url || ''; + } }, async saveLlmConfig() { this.llmSaving = true; diff --git a/static/css/app.css b/static/css/app.css index 759d661..094dc3c 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2966,6 +2966,74 @@ button.breadcrumb-current { max-width: 260px; } .gs-more-item:hover { background: var(--bg-hover); } .gs-more-sep { height: 1px; background: var(--border); margin: 4px 0; } +/* ═══════════ AI Meeting Note — widget réunion (v4.12.0) ═══════════ */ +.block-meeting { cursor: default; } +.fm { + background: var(--bg-secondary, #1f1f1f); border: 1px solid var(--border, #333); + border-radius: 12px; padding: 12px 14px; font-size: 13px; + color: var(--text-primary, #eee); display: flex; flex-direction: column; gap: 10px; +} +.fm-loading { color: var(--text-dim); font-size: 13px; padding: 8px 2px; } +.fm-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.fm-ico { font-size: 20px; } +.fm-title { + flex: 1; min-width: 160px; background: transparent; border: 1px solid transparent; + color: var(--text-primary); font-size: 14px; font-weight: 600; outline: none; + border-radius: 6px; padding: 3px 6px; font-family: inherit; +} +.fm-title:focus { border-color: var(--accent); background: var(--bg-tertiary); } +.fm-date { font-size: 12px; color: var(--text-dim); white-space: nowrap; } +.fm-pill { + font-size: 11px; padding: 3px 10px; border-radius: 99px; + background: var(--bg-tertiary); color: var(--text-secondary); white-space: nowrap; +} +.fm-pill-ok { color: #2ea043; } +.fm-step-title { + font-size: 12px; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; +} +.fm-step-desc { font-size: 12px; color: var(--text-dim); line-height: 1.45; } +.fm-radio { + display: flex; gap: 8px; align-items: center; font-size: 13px; + color: var(--text-primary); padding: 4px 2px; cursor: pointer; +} +.fm-radio input { accent-color: var(--accent); } +.fm-btn { + background: var(--bg-tertiary); border: 1px solid var(--border); + color: var(--text-primary); border-radius: 8px; padding: 7px 14px; + font-size: 13px; cursor: pointer; font-family: inherit; +} +.fm-btn:hover { background: var(--bg-hover); } +.fm-btn-primary { background: var(--accent, #2383E2); border-color: transparent; color: #fff; font-weight: 600; } +.fm-btn-primary:hover { background: #2C8CEB; } +.fm-btn:disabled { opacity: .55; cursor: not-allowed; } +.fm-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.fm-wave { + font-family: monospace; letter-spacing: 2px; color: var(--accent); + font-size: 14px; padding: 2px 0; +} +.fm-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-top: 2px; } +.fm-tab { + background: none; border: none; color: var(--text-dim); font-size: 12.5px; + cursor: pointer; padding: 6px 12px; border-radius: 7px; font-weight: 500; +} +.fm-tab.active { background: var(--bg-tertiary); color: var(--text-primary); } +.fm-empty { color: var(--text-dim); font-size: 12.5px; padding: 8px 0; } +.fm-error { color: #e5484d; font-size: 12.5px; padding: 8px 0; } +.fm-line { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; color: var(--text-primary); } +.fm-line .fm-ts { color: var(--text-dim); font-family: monospace; font-size: 11px; flex-shrink: 0; padding-top: 2px; } +.fm-line .fm-who { color: var(--accent); font-weight: 600; flex-shrink: 0; } +.fm-interim { opacity: .55; font-style: italic; } +.fm-area { + width: 100%; box-sizing: border-box; background: var(--bg-tertiary); + border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); + font-size: 13px; padding: 8px 10px; outline: none; font-family: inherit; resize: vertical; +} +.fm-area:focus { border-color: var(--accent); } +.fm-manual { margin-top: 8px; } +.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; } +.fm-transcript { max-height: 40vh; overflow-y: auto; } + /* ═══════════ Page Editor — Top Bar ═══════════ */ .page-topbar { display: flex; align-items: center; justify-content: flex-end;