Open email context for agent, email search across All Mail, cookbook serve polish

- Agent: pass the open email reader (uid/folder/account/from/subject/body
  preview) on every chat submit so 'reply to this' / 'write email saying
  hi' route to ui_control open_email_reply with the right UID instead of
  inventing a new .md draft. Code-level enforcement (chat_routes strips
  create_document + send_email when active_email is set); cross-session
  active_doc_id is now trusted instead of being silently dropped.
  set_active_email/clear_active_email tool-layer helpers in
  tool_implementations.

- ui_control open_email_reply: optional body argument so the agent can
  open-and-write in one call; envelope now forwards uid/folder/account/
  body/panel through tool_output. Tool description sharpened and the
  parser rejects empty bodies on reply/reply-all (forces the agent to
  write rather than open an empty draft).

- Email library: search now runs against [Gmail]/All Mail when the
  current folder is INBOX (archived emails surface). Whirlpool spinner
  + 'Searching…' placeholder while in flight. Each search result is
  stamped with its source folder so clicks open the right email instead
  of whatever shares its UID in INBOX. Search no longer re-applies the
  same text pill locally (which only checks subject/from/snippet, never
  body) so body-only matches don't get dropped after IMAP returns them.
  Initial inbox load bumped 100→500.

- Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the
  card menu and the open-reader more menu, backed by a new
  /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always
  bubble to the top of the grid regardless of active sort.

- AI reply in doc editor: never overwrites existing draft text or the
  quoted history. AI suggestion is prepended; AI-generated 'On …
  wrote:' re-quotes are stripped so the original quote isn't visually
  edited.

- Cookbook serve: pre-launch GPU driver / has_gpu / install / version-
  floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0
  etc.) before the launch chain starts. Detect 'another model already
  running on this host' and offer Stop & launch (with graceful then
  force tmux kill helpers, port release wait). Per-vendor deep-link
  buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend
  picker is now a custom dropdown with accent-coloured logos for vLLM,
  SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to
  package names in Dependencies. Runtime-readiness note moved inside
  the panel (green when ready, red when missing) with an × dismiss.
  Esc collapses the expanded card; expanded card scrolls when it
  overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce
  Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on
  one row (Reasoning Parser auto-detected per model family).
  Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto'
  input — command builders read from the GPU button strip. Default
  cookbook open is Download tab.

- Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by
  release_date; release dates can be backfilled with the new
  scripts/backfill_model_release_dates.py and recipe metadata pulled
  with scripts/import_from_vllm_recipes.py against the upstream
  vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped
  on entries).

- Calendar: Quick add hint cycles a random Odysseus-themed example per
  open (wooden horse Friday, crew muster 10am daily, council on
  Ithaca, …). Typing a time like '11pm' in the event title updates
  the hero clock live.

- Doc editor: email-mode Reply button (sparkle icon, accent) opens the
  same Fast/Full + context popover the email reader uses; Ctrl+Alt+M
  toggles markdown preview.

- Memories panel: custom sort picker with per-option icons, default
  'Latest', visible Enabled/Disabled toggle text matching the section
  description style.
This commit is contained in:
pewdiepie-archdaemon
2026-06-15 20:47:51 +09:00
parent 1fcec32a3c
commit 2cbd55b8bd
18 changed files with 1417 additions and 66 deletions
+142 -17
View File
@@ -2472,6 +2472,8 @@ import * as Modals from './modalManager.js';
}
// Hide toolbar items that have no clean WYSIWYG equivalent in email (Code).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; });
// Show email-only toolbar items (AI reply button).
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'inline-flex'; });
if (emailHeader) emailHeader.style.display = '';
if (emailActions) emailActions.style.display = '';
// Emails have their own complete footer (Close / More / Send), so hide the
@@ -2864,6 +2866,8 @@ import * as Modals from './modalManager.js';
if (emailActions) emailActions.style.display = 'none';
// Restore toolbar items that were hidden for email (Code dropdown).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; });
// Re-hide email-only toolbar items (AI reply button).
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'none'; });
// Restore the generic documents action bar + its bottom footer (Close /
// Copy / Export) for non-email docs.
const docActions = document.getElementById('doc-editor-actions');
@@ -3206,7 +3210,95 @@ import * as Modals from './modalManager.js';
renderTabs();
}
async function _aiReply() {
// Fast/Full + optional context popover for the doc-editor email Reply button.
// Mirrors the email reader's AI reply choice popover so the UX is identical:
// textarea for an optional steering note, then Fast (lightning) or Full
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
_docAiReplyChoiceMenu = null;
}
}
function _showDocAiReplyChoice(btn) {
_closeDocAiReplyChoice();
if (!btn) return;
const rect = btn.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'doc-ai-reply-choice';
const menuMaxW = Math.min(240, window.innerWidth - 16);
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
const estHeight = 150;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const top = (spaceBelow >= estHeight || spaceBelow >= spaceAbove)
? Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8))
: Math.max(8, rect.top - estHeight - 6);
menu.style.cssText = [
'position:fixed',
`left:${left}px`,
`top:${top}px`,
`max-width:${menuMaxW}px`,
'box-sizing:border-box',
'z-index:10060',
'display:flex',
'gap:6px',
'padding:6px',
'background:var(--bg,#111)',
'border:1px solid var(--border,#333)',
'border-radius:7px',
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
].join(';');
menu.innerHTML = `
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Fast
</button>
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
Full
</button>
</div>
</div>
`;
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
ev.preventDefault();
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice();
await _aiReply({ mode, noteHint });
});
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
}
async function _aiReply(opts = {}) {
const { mode = 'auto', noteHint = '' } = (opts || {});
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim() || '';
const textarea = document.getElementById('doc-editor-textarea');
@@ -3251,32 +3343,43 @@ import * as Modals from './modalManager.js';
if (btn) { btn.disabled = true; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Drafting...'; }
try {
// Empty-compose path: if there's no original body, send a placeholder
// so the backend's "no body" guard doesn't fail. The user_hint carries
// the user's compose intent; the model uses To/Subject + that hint.
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
const fastFlag = mode === 'ai-reply-fast' ? true
: mode === 'ai-reply-full' ? false
: shouldUseFastAiReply();
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: to,
subject: subject,
original_body: currentBody,
original_body: bodyForApi,
model: currentModel,
session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
fast: fastFlag,
user_hint: noteHint || '',
}),
});
const data = await res.json();
if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) {
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
} else {
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
}
let cleanReply = cleanAiReplyText(data.reply);
// Strip any "On <date>, <name> wrote:" attribution + everything
// after it from the AI's output — the model sometimes re-quotes
// the original thread, and we already have the real quote in
// currentBody. Without this, AI's invented quote stacked on top
// of the real one and looked like the history had been "edited".
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
// Never overwrite the existing draft (user's typed text + the
// quoted history below it). Always prepend the AI suggestion so
// the user can read it, copy parts, or delete it — but their
// own work and the original quote are untouched.
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
await _streamEmailBodyText(textarea, newBody);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else {
@@ -3285,7 +3388,7 @@ import * as Modals from './modalManager.js';
} catch (e) {
if (uiModule) uiModule.showError('Failed to generate AI reply');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply'; }
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
}
}
@@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';
<button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button>
<button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <span style="font-size:11px;">PDF</span></button>
<select id="doc-language-select" class="doc-language-select">
<option value="">type</option>
<option value="python">python</option>
<option value="javascript">javascript</option>
<option value="typescript">typescript</option>
@@ -3881,13 +3983,14 @@ import * as Modals from './modalManager.js';
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
<div class="md-toolbar-items" id="md-toolbar-items">
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</span>
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
</span>
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
<span class="md-toolbar-sep"></span>
@@ -4397,6 +4500,24 @@ import * as Modals from './modalManager.js';
}
});
}
// Ctrl+Alt+M (and Cmd+Opt+M on mac) flips Edit ↔ Preview on a markdown
// doc. Bound once globally; gated on the doc panel being open and the
// active doc being markdown so it doesn't fire while the user is typing
// in a non-markdown context.
if (!window._docMdToggleBound) {
window._docMdToggleBound = true;
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === 'm' || e.key === 'M' || e.code === 'KeyM')) {
if (!isOpen) return;
const doc = activeDocId && docs.get(activeDocId);
const lang = (doc?.language || 'markdown').toLowerCase();
if (lang !== 'markdown') return;
e.preventDefault();
toggleMarkdownPreview();
_syncHeaderActions();
}
});
}
document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => {
document.getElementById('doc-email-more-menu').style.display = 'none';
_saveDraft();
@@ -4411,7 +4532,11 @@ import * as Modals from './modalManager.js';
document.getElementById('doc-email-more-menu').style.display = 'none';
_scheduleSend(anchor);
});
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_showDocAiReplyChoice(ev.currentTarget);
});
const collapseBtn = document.getElementById('doc-email-collapse-btn');
if (collapseBtn && !collapseBtn._emailCollapseWired) {