Compare commits

...

20 Commits

Author SHA1 Message Date
bvandeusen 72708475a3 Release v26.04.16.1 — Fix tool actions mismatch in system prompt 2026-04-16 12:39:02 +00:00
bvandeusen e07d8436b7 fix(llm): sync available actions list with actual registered tools
The system prompt listed phantom tools (create_task, delete_task, get_note)
that don't exist, causing the model to spiral when users asked to create
tasks under a project. Replaced the stale hardcoded string with a
dynamically-built actions list matching all registered tools, and added
conditional searxng/caldav extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:16:36 -04:00
bvandeusen 1711ee6a1f Release v26.04.15.1 — Chat UI overhaul + silent gen fix 2026-04-16 05:52:47 +00:00
bvandeusen 369cf0a7c9 fix(chat): default context sidebar to hidden
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 23:29:14 -04:00
bvandeusen 3f2813d16f feat(chat): Phase D empty-state + overlay context sidebar
Empty-state rethink: replace "Start a conversation." with a richer
landing showing recent conversations (top 5 as resume links) and a
voice-mode entry button. Gated to full variant only; widget/briefing
keep the simple message.

Context sidebar: switch from grid column (4-col layout) to absolute
overlay on the right gutter (3-col layout). The reading column is now
always symmetrically centered regardless of whether the sidebar is
visible — eliminates the leftward shift that occurred when context
notes appeared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:35:39 -04:00
bvandeusen fddac2aa2f fix(chat): always think on qwen3, drop content-based classifier
Content-based gating (_should_think) was introduced in 87fcaa6 to cut
TTFT on simple prompts, but it has no way to tell that short prompts
like "create a task titled X" are going to trigger a tool call — and
qwen3:14b's tool-call template is unreliable at think=False, producing
intermittent silent generations where output tokens burn but nothing
parses into content or tool_calls.

Reverting to always-on thinking restores the pre-87fcaa6 reliability
of tool emission at the cost of TTFT latency on short conversational
prompts. This also lets us delete the silent-round retry loop (which
can no longer fire) along with its bookkeeping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:09:16 -04:00
bvandeusen 1261e93ede chore(generation): track CTX_DIAG per attempt not per round
Retry attempts were previously conflated with the initial call,
making prompt_tokens and headroom look cumulative and useless for
diagnosing the silent-round behavior. Move start-of-attempt captures
inside the retry loop and emit attempt_start / attempt_end lines so
each attempt's numbers stand alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:39:49 -04:00
bvandeusen b6165e56e5 chore(generation): add CTX_DIAG logs for silent-round investigation
Log num_ctx, message count, prompt/output tokens, headroom, and a
silent flag per round so we can correlate silent generations against
context pressure on the dev instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:09:08 -04:00
bvandeusen 5e281c534a fix(chat): add bottom padding to messages container to clear input bar
Streaming bubble (Generating response…) was flush against the input
wrapper top border. Give the scroll column 0.75rem of breathing room
so the assistant bubble sits visibly separate from the input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:29:14 -04:00
bvandeusen 9082281225 feat(chat): Phase C — collapsible context sidebar with header toggle
Context sidebar sections (Auto-included / Suggested / In Context) are
now collapsible with chevrons, and collapsed state persists per
conversation via localStorage. A new Context (N) button in the chat
header toggles the whole sidebar — open by default on wide screens
(>=1200px), closed by default below. Visual differentiation: auto-
included notes get a muted background with an AUTO pill, in-context
notes get an active-chip style with a primary-colored left border,
and each section header shows its item count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:12:11 -04:00
bvandeusen 058d6089b1 fix(chat): retry silent rounds with think=True before falling back
Qwen3's tool-call tokens sometimes fail to parse into either content
or tool_calls, burning output tokens and producing empty bubbles.
Detect the signature within a round (empty content, no tool calls,
eval_count > 0) and re-run the same round once with reasoning mode
enabled, which emits more reliable output. The post-loop fallback
remains as the final catch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:22:28 -04:00
bvandeusen ba0cb07c91 fix(chat): surface silent generations instead of empty bubbles
Qwen3:14b sometimes burns output tokens on tool-calling attempts whose
emission doesn't parse into any field we read — eval_count > 0 but no
thinking/content/tool_calls ever stream to the caller. Generation
completes "successfully," the user sees an empty assistant bubble, and
no error is logged. Seen in conv 220 today.

Two safety rails:

- stream_chat_with_tools now tracks whether it yielded anything; when
  Ollama's done frame reports eval_count > 0 with zero yields, log a
  warning including the last ~5 raw frames so the next occurrence leaves
  breadcrumbs for diagnosis.

- run_generation checks the same post-condition after the tool loop
  exits and, if content is empty with no tool calls but output_tokens
  > 0, substitutes a visible fallback message and streams it as a chunk
  so the user gets something readable instead of a blank bubble.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:35:30 -04:00
bvandeusen 4228e9a384 feat(chat): move scope chip to header, consolidate voice controls into input bar
Phase B of chat view refinement. Bumps chat reading column to 1200px,
lifts the RAG scope chip out of the footer and into the ChatView header
(next to the title), and collapses the listen toggle / volume slider /
stop-playback buttons into a single speaker popover on the input bar.
Listen mode + TTS are now available in briefing and workspace chats too,
since the controls live on the shared ChatInputBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:44:16 -04:00
bvandeusen 035ec0c0dc feat(chat): Phase A chrome cleanup — kebabs, search, full-width new chat
Header:
- Remove Research button + modal (skill fires reliably from normal chat)
- Move Summarize-as-Note into a header kebab (disabled until the
  conversation has messages)

Left sidebar:
- Full-width "+ New Chat" primary button
- Search input filters conversations by title (case-insensitive
  substring) with a "no matches" empty state
- Move Select into a sidebar kebab next to search

Shared .btn-kebab + .kebab-menu styles give future conv-level actions
a home. Document mousedown + Escape dismiss both kebabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:28:48 -04:00
bvandeusen f0c93ffa3b fix(chat): drop display:flex from .chat-panel-fill so grid layout wins
The inherited .chat-panel-fill class lands on ChatPanel's new .chat-full
root via attribute inheritance, and its display:flex was overriding the
grid layout — stacking messages, sidebar, and input bar vertically
inside the reading column instead of placing the sidebar in grid col 4.
Layout is ChatPanel's responsibility now; .chat-panel-fill only sizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:40:44 -04:00
bvandeusen ad4fe3d783 feat(chat): center chat reading column and full-height context sidebar
Replaces the full-variant flex layout with a single CSS grid owning the
messages column, input bar, and context sidebar. Messages and input bar
share a centered ~820px reading track (--chat-reading-width token) so
the mic button can't get pushed off-screen on wide monitors, and the
context sidebar spans grid-row 1/-1 so it runs the full height from the
chat header down to the bottom edge instead of stopping above the input
bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:11:08 -04:00
bvandeusen dcbe018297 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold + filled mic halo' (#35) from dev into main 2026-04-15 04:38:27 +00:00
bvandeusen 36fb71699b feat(voice): dynamic silence threshold + filled red mic halo
The web silence detector previously ran RMS over getByteFrequencyData
bytes (which are already dB-scaled) and re-log'd the result, producing
numbers that didn't line up with real dBFS. Combined with a static
-40 dB threshold that sat right on top of room ambient, silence
detection rarely fired and the user had to click stop manually.

- Rewrite useSilenceDetector to use getFloatTimeDomainData for honest
  linear RMS → dBFS.
- Silence threshold is now dynamic: track session peak dBFS and treat
  "silent" as 15 dB below peak. Auto-calibrates per mic/room.
- Grace period (1500 ms) at start so the user can begin speaking
  before checks arm; static -45 dB fallback until peak clears -25 dB
  so dead-silent sessions don't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Visual: detached red radial-gradient disc sits behind the mic in a
wrapper; the button no longer scales, so the white mic icon stays
legible on top while the halo grows from 1x to ~2.6x with live
amplitude. Much more obvious "you are live" signal than the prior
subtle box-shadow pulse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:26:52 -04:00
bvandeusen 027fbec606 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#34) from dev into main 2026-04-15 02:54:42 +00:00
bvandeusen 730dbfaf7b feat(voice): pulse mic button with live amplitude
The mic button now scales and glows proportional to the silence
detector's RMS amplitude instead of sitting static. Gives obvious
feedback that audio is actually being picked up — silence still
breathes via a 0.1 floor, loud input caps at ~1.18x scale so it
doesn't punch through the input bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:37 -04:00
9 changed files with 1017 additions and 642 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
### Tool Routing
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. A thinking-mode heuristic (`_should_think()`) detects complex prompts and enables extended reasoning.
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
### Tool Loop
+2
View File
@@ -54,6 +54,8 @@
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
+228 -13
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useSilenceDetector } from '@/composables/useSilenceDetector'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useToastStore } from '@/stores/toast'
@@ -29,6 +32,32 @@ const emit = defineEmits<{
const store = useChatStore()
const settingsStore = useSettingsStore()
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
const listenMode = useListenMode()
const audio = useVoiceAudio()
const speakerPopoverOpen = ref(false)
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
function lastAssistantContent(): string {
const msgs = store.currentConversation?.messages ?? []
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}
function toggleListen() {
listenMode.value = !listenMode.value
if (listenMode.value) {
tts.speak(lastAssistantContent())
} else {
tts.stop()
}
}
// ── Core input ────────────────────────────────────────────────────────────────
const messageInput = ref('')
@@ -114,6 +143,20 @@ const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
const silenceDetector = useSilenceDetector()
// Live mic halo. A solid red disc sits behind the mic button and scales
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for
// visibility). The button itself stays put so the mic icon is always
// legible on top. 0.2 baseline breathes on silence; loud speech drives
// the disc out to ~2.6× the button size with a softer radial fade.
const micGlowStyle = computed(() => {
if (!recorder.recording.value) return { display: 'none' }
const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8
return {
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
opacity: String(0.45 + pulse * 0.4),
}
})
async function toggleVoice() {
if (transcribingVoice.value) return
if (recorder.recording.value) {
@@ -159,6 +202,15 @@ async function stopRecording() {
finally { transcribingVoice.value = false }
}
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
function onDocumentMousedown(e: MouseEvent) {
if (!speakerPopoverOpen.value) return
const target = e.target as HTMLElement | null
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
inputEl.value?.focus()
@@ -230,23 +282,72 @@ defineExpose({ focus, prefill })
class="input-textarea"
></textarea>
<!-- PTT mic -->
<button
v-if="voiceEnabled"
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<!-- Speaker / listen-mode popover -->
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
<button
class="btn-icon btn-speaker"
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
@click="speakerPopoverOpen = !speakerPopoverOpen"
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
aria-label="Listen and volume settings"
>
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<div v-if="speakerPopoverOpen" class="speaker-popover">
<button
class="speaker-row speaker-row--toggle"
:class="{ 'speaker-row--active': listenMode }"
@click="toggleListen"
>
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
<span class="speaker-switch" :class="{ on: listenMode }"></span>
</button>
<div class="speaker-row">
<span class="speaker-row-label">Volume</span>
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="speaker-volume-range"
aria-label="Volume"
/>
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
<button
v-if="tts.speaking.value"
class="speaker-row speaker-row--stop"
@click="tts.stop()"
>
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
<span>Stop playback</span>
</button>
</div>
</div>
<!-- PTT mic (with live amplitude halo behind) -->
<div v-if="voiceEnabled" class="mic-wrapper">
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
<button
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
</svg>
</button>
</button>
</div>
<!-- Abort (streaming) or Send -->
<button
@@ -343,9 +444,50 @@ defineExpose({ focus, prefill })
.btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
.btn-mic.mic-recording {
opacity: 1;
/* White icon sits on top of the red halo so it stays legible at any
pulse size. */
color: #fff;
position: relative;
z-index: 1;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
absolutely positioned behind the button, scaled/faded by the
computed `micGlowStyle` so the user gets unmistakable feedback
that their voice is being picked up while the mic icon itself
stays put and readable on top. */
.mic-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mic-glow {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(239, 68, 68, 0.9) 0%,
rgba(239, 68, 68, 0.6) 55%,
rgba(239, 68, 68, 0) 100%
);
transform: translate(-50%, -50%) scale(1);
transform-origin: center;
pointer-events: none;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
z-index: 0;
}
.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
position: absolute;
@@ -418,4 +560,77 @@ defineExpose({ focus, prefill })
flex-shrink: 0;
}
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
/* Speaker / listen-mode popover */
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
.speaker-popover {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
padding: 0.35rem;
z-index: 30;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.speaker-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.85rem;
cursor: default;
text-align: left;
width: 100%;
}
.speaker-row--toggle { cursor: pointer; }
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
.speaker-row--active { color: var(--color-primary); }
.speaker-row--stop {
cursor: pointer;
color: var(--color-text-muted);
justify-content: flex-start;
}
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
.speaker-row-label { flex: 1; }
.speaker-volume-range { flex: 1; min-width: 0; }
.speaker-volume-pct {
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 2.5rem;
text-align: right;
}
.speaker-switch {
position: relative;
width: 28px;
height: 16px;
border-radius: 9999px;
background: var(--color-border);
transition: background 0.15s;
flex-shrink: 0;
}
.speaker-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-bg-card);
transition: transform 0.15s;
}
.speaker-switch.on { background: var(--color-primary); }
.speaker-switch.on::after { transform: translateX(12px); }
</style>
+340 -279
View File
@@ -1,11 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { apiGet } from '@/api/client'
import ChatMessage from '@/components/ChatMessage.vue'
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
import ChatInputBar from '@/components/ChatInputBar.vue'
@@ -34,33 +29,6 @@ const emit = defineEmits<{
}>()
const store = useChatStore()
const settingsStore = useSettingsStore()
// ── Voice / TTS ───────────────────────────────────────────────────────────────
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const listenMode = useListenMode()
const audio = useVoiceAudio()
const showVolumeSlider = ref(false)
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
function lastAssistantContent(): string {
const msgs = store.currentConversation?.messages ?? []
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}
function toggleListen() {
listenMode.value = !listenMode.value
if (listenMode.value) {
tts.speak(lastAssistantContent())
} else {
tts.stop()
}
}
// ── Scroll ────────────────────────────────────────────────────────────────────
const messagesEl = ref<HTMLElement | null>(null)
@@ -89,47 +57,6 @@ watch(() => store.streamingToolCalls, (calls) => {
}, { deep: true })
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
const projects = ref<{ id: number; title: string }[]>([])
const scopeDropdownOpen = ref(false)
const scopePulse = ref(false)
const showScopeChip = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId
)
const scopeLabel = computed(() => {
const id = store.ragProjectId
if (id === -1) return 'All notes'
if (id === null) return 'Orphan notes'
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
})
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
projects.value = data.projects ?? []
} catch {
projects.value = []
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false
const convId = store.currentConversation?.id
if (!convId) return
await store.updateRagScope(convId, value)
scopePulse.value = true
setTimeout(() => { scopePulse.value = false }, 600)
}
// Pulse when model-driven scope change fires
watch(() => store.ragProjectId, () => {
if (!showScopeChip.value) return
scopePulse.value = true
setTimeout(() => { scopePulse.value = false }, 600)
})
// ── Note context (full variant — included / suggested / auto-injected notes) ──
const includedNoteIds = ref<Set<number>>(new Set())
const includedNotes = ref<{ id: number; title: string }[]>([])
@@ -190,11 +117,92 @@ function removeIncludedNote(noteId: number) {
}
}
const hasContextSidebar = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId &&
(autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
const contextCount = computed(
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
)
const hasContextData = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
)
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
const NARROW_BREAKPOINT = 1200
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
const sidebarOpen = ref(false)
function onResize() {
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
function toggleContextSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
type SectionKey = 'auto' | 'suggested' | 'included'
const collapsedSections = ref<Set<SectionKey>>(new Set())
function storageKey(): string | null {
const id = store.currentConversation?.id
return id ? `fa_chat_ctx_collapsed_${id}` : null
}
function loadCollapsed() {
const key = storageKey()
if (!key) { collapsedSections.value = new Set(); return }
try {
const raw = localStorage.getItem(key)
if (!raw) { collapsedSections.value = new Set(); return }
const arr = JSON.parse(raw) as SectionKey[]
collapsedSections.value = new Set(arr)
} catch {
collapsedSections.value = new Set()
}
}
function saveCollapsed() {
const key = storageKey()
if (!key) return
try {
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
} catch { /* ignore */ }
}
function toggleSection(key: SectionKey) {
const next = new Set(collapsedSections.value)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedSections.value = next
saveCollapsed()
}
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
const showEmptyState = computed(
() =>
props.variant === 'full'
&& !props.briefingMode
&& !props.projectId
&& !store.streaming
&& (store.currentConversation?.messages.length ?? 0) === 0
)
const recentConversations = computed(() => {
const currentId = store.currentConversation?.id
return store.conversations
.filter((c) => c.id !== currentId && c.message_count > 0)
.slice(0, 5)
})
function startVoiceMode() {
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
}
// ── Send ──────────────────────────────────────────────────────────────────────
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
@@ -271,8 +279,7 @@ async function handleSaveAsNote(messageId: number) {
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
if (showScopeChip.value) await loadProjects()
onMounted(() => {
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
})
@@ -306,13 +313,13 @@ function hasEarlierBriefingSlot(index: number): boolean {
return false
}
defineExpose({ focus, prefill, send })
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
</script>
<template>
<!-- FULL VARIANT -->
<template v-if="variant === 'full'">
<div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
<div class="chat-full">
<!-- Message list -->
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
@@ -350,8 +357,27 @@ defineExpose({ focus, prefill, send })
</button>
</div>
</template>
<div v-if="showEmptyState" class="chat-empty-state">
<h2 class="empty-greeting">What's on your mind?</h2>
<div v-if="recentConversations.length" class="empty-recent">
<div class="empty-section-label">Jump back in</div>
<router-link
v-for="conv in recentConversations"
:key="conv.id"
:to="`/chat/${conv.id}`"
class="empty-recent-item"
>
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
</router-link>
</div>
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
<span class="voice-icon">🎤</span>
<span>Start in voice mode</span>
</button>
</div>
<p
v-if="!store.currentConversation?.messages.length && !store.streaming"
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg"
>Start a conversation.</p>
</div>
@@ -360,114 +386,71 @@ defineExpose({ focus, prefill, send })
<!-- Context sidebar (full, non-briefing, non-workspace) -->
<aside v-if="hasContextSidebar" class="context-sidebar">
<template v-if="autoInjectedNotes.length">
<div class="context-sidebar-header">Auto-included</div>
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)">&times;</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('auto') }"
@click="toggleSection('auto')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>Auto-included</span>
<span class="section-count">{{ autoInjectedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('auto')">
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div>
</div>
</template>
<template v-if="suggestedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
@click="toggleSection('suggested')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>Suggested</span>
<span class="section-count">{{ suggestedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('suggested')">
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</div>
</template>
<template v-if="includedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)">&times;</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
@click="toggleSection('included')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>In Context</span>
<span class="section-count">{{ includedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('included')">
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
</div>
</div>
</template>
</aside>
</div>
<!-- Input area (hidden when readOnly) -->
<div v-if="!readOnly" class="input-wrapper">
<!-- Scope chip -->
<div v-if="showScopeChip" class="scope-chip-row">
<div class="scope-chip-wrapper">
<button
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
</div>
</div>
</div>
<!-- Listen / volume controls -->
<div v-if="voiceTtsEnabled" class="voice-controls">
<div class="volume-wrapper">
<button
class="btn-voice"
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
@click="toggleListen"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode"
>
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<button
class="btn-voice btn-volume-icon"
@click="showVolumeSlider = !showVolumeSlider"
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
aria-label="Volume"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<div v-if="showVolumeSlider" class="volume-popup">
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="volume-range"
aria-label="Volume"
/>
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
</div>
<button
v-if="tts.speaking.value"
class="btn-voice"
@click="tts.stop()"
title="Stop playback"
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
</button>
</div>
<!-- Input area (hidden when readOnly) -->
<div v-if="!readOnly" class="input-wrapper">
<!-- Unified input bar -->
<ChatInputBar
ref="inputBarRef"
@@ -476,6 +459,7 @@ defineExpose({ focus, prefill, send })
@submit="onSubmit"
@abort="store.cancelGeneration()"
/>
</div>
</div>
</template>
@@ -528,18 +512,31 @@ defineExpose({ focus, prefill, send })
</template>
<style scoped>
/* ── Full variant layout ── */
.chat-body {
/* ── Full variant layout ──
* Single grid owns the reading column + context sidebar + input bar so
* messages and input bar share one centered reading track while the
* context sidebar spans the full height from header to bottom of view.
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
* The context sidebar overlays the right gutter so it never shifts layout.
*/
.chat-full {
position: relative;
flex: 1;
display: flex;
min-height: 0;
display: grid;
grid-template-columns:
1fr
minmax(0, var(--chat-reading-width))
1fr;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.messages-container {
flex: 1;
grid-column: 2;
grid-row: 1;
overflow-y: auto;
padding: 1rem;
padding: 1rem 1rem 0.75rem;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
}
@@ -558,31 +555,79 @@ defineExpose({ focus, prefill, send })
opacity: 0.35;
}
/* Context sidebar */
/* Context sidebar — overlays right gutter, never shifts reading column */
.context-sidebar {
width: 200px;
min-width: 160px;
max-width: 220px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--chat-context-sidebar-width);
border-left: 1px solid var(--color-border);
padding: 0.75rem 0.5rem;
overflow-y: auto;
background: var(--color-bg-secondary);
font-size: 0.78rem;
z-index: 2;
}
.context-sidebar-header {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
background: none;
border: none;
padding: 0.15rem 0.1rem;
font-family: inherit;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.35rem;
cursor: pointer;
text-align: left;
}
.context-sidebar-header:hover { color: var(--color-text); }
.context-sidebar-header .chev {
transition: transform 0.15s ease;
}
.context-sidebar-header.collapsed .chev {
transform: rotate(-90deg);
}
.context-sidebar-header .section-count {
margin-left: auto;
color: var(--color-text-muted);
font-weight: 600;
background: var(--color-bg);
padding: 0.05rem 0.3rem;
border-radius: 8px;
}
.context-sidebar-header-gap { margin-top: 0.75rem; }
.context-note {
display: flex;
align-items: center;
gap: 0.2rem;
gap: 0.25rem;
margin-bottom: 0.25rem;
padding: 0.2rem 0.35rem;
border-radius: var(--radius-sm);
}
.context-note-auto {
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
opacity: 0.85;
}
.context-note-included {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-left: 2px solid var(--color-primary);
}
.auto-pill {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.05rem 0.3rem;
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text-muted);
flex-shrink: 0;
}
.context-note-name {
flex: 1;
@@ -616,8 +661,10 @@ defineExpose({ focus, prefill, send })
.context-note-remove:hover { color: #ef4444; }
.context-note-add:hover { color: var(--color-primary); }
/* Input wrapper */
/* Input wrapper — lives in the same reading column as messages */
.input-wrapper {
grid-column: 2;
grid-row: 2;
border-top: 1px solid var(--color-border);
padding: 0.5rem 1rem 0.75rem;
display: flex;
@@ -625,94 +672,6 @@ defineExpose({ focus, prefill, send })
gap: 0.4rem;
}
/* Scope chip */
.scope-chip-row { display: flex; }
.scope-chip-wrapper { position: relative; }
.scope-chip {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
@keyframes pulse-chip {
0%, 100% { border-color: var(--color-border); }
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
}
.scope-dot { font-size: 0.6rem; }
.scope-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
padding: 0.45rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }
/* Voice controls */
.voice-controls {
display: flex;
align-items: center;
gap: 0.2rem;
}
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
.btn-voice {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
opacity: 0.65;
padding: 0.2rem;
display: flex;
align-items: center;
border-radius: var(--radius-sm);
transition: opacity 0.15s, color 0.15s;
}
.btn-voice:hover { opacity: 1; }
.btn-voice--active { opacity: 1; color: var(--color-primary); }
.btn-voice--busy { opacity: 1; color: #f59e0b; }
.volume-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 20;
box-shadow: 0 4px 16px var(--color-shadow);
}
.volume-range { width: 80px; }
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
/* Queued messages */
.queued-message { opacity: 0.45; }
.queued-bubble {
@@ -754,6 +713,108 @@ defineExpose({ focus, prefill, send })
color: var(--color-accent-warm, #d4a017);
}
.chat-empty-state {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.5rem;
padding: 3rem 1rem 2rem;
max-width: 520px;
margin: 0 auto;
}
.empty-greeting {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-weight: 400;
font-size: 1.75rem;
color: var(--color-accent-warm, #d4a017);
text-align: center;
margin: 0;
}
.empty-section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
.empty-recent {
display: flex;
flex-direction: column;
}
.empty-recent-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: var(--radius-md, 10px);
border: 1px solid var(--color-border);
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.empty-recent-item + .empty-recent-item {
margin-top: 0.4rem;
}
.empty-recent-item:hover {
border-color: var(--color-primary);
background: rgba(99, 102, 241, 0.05);
transform: translateX(2px);
}
.empty-recent-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.empty-recent-count {
font-size: 0.7rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.empty-voice-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.75rem 1.2rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
align-self: center;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.empty-voice-btn:hover {
border-color: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
}
.empty-voice-btn .voice-icon {
font-size: 1.05rem;
}
/* ── Widget variant ── */
.widget-response {
margin-top: 0.75rem;
+66 -14
View File
@@ -1,15 +1,54 @@
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
/**
* Absolute fallback silence threshold in dBFS, used during the first
* moments before any real speech has been observed. Once the session peak
* clears `dynamicArmDb` we switch to the dynamic threshold instead.
*/
fallbackThresholdDb?: number // default -45
/** How many dB below the session peak counts as "silent" once armed. */
dropFromPeakDb?: number // default 15
/**
* Session peak must reach this level (dBFS) before dynamic thresholding
* kicks in. Until then the fallback threshold is used so we don't lock
* onto a noise-floor peak.
*/
dynamicArmDb?: number // default -25
/** How long to wait at the start of recording before running silence checks. */
graceMs?: number // default 1500
silenceDurationMs?: number // default 2000
minRecordingMs?: number // default 500
}
/**
* Mic silence detector + live amplitude signal.
*
* Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space,
* then derives dBFS for the silence threshold. The previous implementation
* ran RMS over `getByteFrequencyData` bytes — those bytes are already a
* dB-scaled quantity, so taking their RMS and re-log'ing it produced
* numbers that didn't line up with real dBFS and made any static threshold
* unpredictable.
*
* Silence threshold is dynamic: we track the session peak dBFS and treat
* "silent" as "current level is at least [dropFromPeakDb] below peak."
* This auto-calibrates to whatever mic and room the user is on. Until the
* peak climbs above [dynamicArmDb] we fall back to a conservative static
* threshold so a quiet room doesn't spin forever. A grace period at the
* start of the recording gives the user time to begin speaking before
* silence checks arm.
*
* `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet
* speech still visibly moves UI that binds to it.
*/
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
fallbackThresholdDb = -45,
dropFromPeakDb = 15,
dynamicArmDb = -25,
graceMs = 1500,
silenceDurationMs = 2000,
minRecordingMs = 500,
} = options
@@ -18,31 +57,43 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
let peakDb = -100
function start(stream: MediaStream, onSilence: () => void): void {
stop()
audioCtx = new AudioContext()
// Some browsers start AudioContext in "suspended" state — resume so
// getByteFrequencyData returns real values instead of all zeros.
// the analyser returns real values instead of all zeros.
audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
analyser.fftSize = 1024
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
const samples = new Float32Array(analyser.fftSize)
silenceMs = 0
startedAt = Date.now()
peakDb = -100
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
analyser.getFloatTimeDomainData(samples)
let sumSq = 0
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
const rms = Math.sqrt(sumSq / samples.length)
amplitude.value = Math.min(rms * 5, 1)
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100
if (db > peakDb) peakDb = db
const elapsed = Date.now() - startedAt
if (elapsed < graceMs) return
const threshold =
peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb
if (db < threshold) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) {
stop()
onSilence()
}
@@ -63,6 +114,7 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
}
amplitude.value = 0
silenceMs = 0
peakDb = -100
}
return { amplitude: readonly(amplitude), start, stop }
+295 -131
View File
@@ -2,6 +2,7 @@
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { apiGet } from "@/api/client";
import ChatPanel from "@/components/ChatPanel.vue";
const route = useRoute();
@@ -10,6 +11,49 @@ const store = useChatStore();
const summarizing = ref(false);
const sidebarOpen = ref(false);
const convSearchQuery = ref("");
const headerKebabOpen = ref(false);
const sidebarKebabOpen = ref(false);
// ── RAG scope chip ────────────────────────────────────────────────────────────
const projects = ref<{ id: number; title: string }[]>([]);
const scopeDropdownOpen = ref(false);
const scopePulse = ref(false);
const scopeLabel = computed(() => {
const id = store.ragProjectId;
if (id === -1) return "All notes";
if (id === null) return "Orphan notes";
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
});
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>(
"/api/projects?status=active"
);
projects.value = data.projects ?? [];
} catch {
projects.value = [];
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false;
const id = store.currentConversation?.id;
if (!id) return;
await store.updateRagScope(id, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
watch(
() => store.ragProjectId,
() => {
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
);
let prevConvId: number | null = null;
@@ -46,7 +90,12 @@ const groupedConversations = computed((): ConvGroup[] => {
const buckets: Record<string, typeof store.conversations> = {};
const order: string[] = [];
for (const conv of store.conversations) {
const q = convSearchQuery.value.trim().toLowerCase();
const filtered = q
? store.conversations.filter((c) => (c.title || "").toLowerCase().includes(q))
: store.conversations;
for (const conv of filtered) {
const d = new Date(conv.updated_at);
let key: string;
if (d >= startOfToday) {
@@ -69,6 +118,8 @@ const groupedConversations = computed((): ConvGroup[] => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("mousedown", onDocumentMousedown);
loadProjects();
await store.fetchConversations();
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
@@ -187,36 +238,35 @@ async function handleSummarize() {
}
}
// ── Research modal ────────────────────────────────────────────────────────────
const showResearchModal = ref(false);
const researchTopic = ref("");
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
function toggleResearchModal() {
showResearchModal.value = !showResearchModal.value;
if (showResearchModal.value) {
researchTopic.value = "";
nextTick(() => {
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
el?.focus();
});
}
const contextCount = computed(() => chatPanelRef.value?.contextCount ?? 0);
const contextSidebarOpen = computed(() => chatPanelRef.value?.sidebarOpen ?? false);
function toggleContextSidebar() {
chatPanelRef.value?.toggleContextSidebar();
}
function submitResearch() {
const topic = researchTopic.value.trim();
if (!topic) return;
showResearchModal.value = false;
researchTopic.value = "";
// Prefill sends via ChatPanel — user sees it in the input, then it auto-submits
chatPanelRef.value?.send(`Research: ${topic}`);
// ── Kebab dismissal ───────────────────────────────────────────────────────────
function onDocumentMousedown(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
headerKebabOpen.value = false;
}
if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
sidebarKebabOpen.value = false;
}
if (scopeDropdownOpen.value && !target?.closest(".scope-chip-wrapper")) {
scopeDropdownOpen.value = false;
}
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (showResearchModal.value) {
showResearchModal.value = false;
if (headerKebabOpen.value || sidebarKebabOpen.value || scopeDropdownOpen.value) {
headerKebabOpen.value = false;
sidebarKebabOpen.value = false;
scopeDropdownOpen.value = false;
return;
}
if (sidebarOpen.value) {
@@ -228,6 +278,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("mousedown", onDocumentMousedown);
if (prevConvId) {
const conv = store.conversations.find((c) => c.id === prevConvId);
if (conv && conv.message_count === 0) {
@@ -246,13 +297,35 @@ onUnmounted(() => {
></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-top-bar">
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
<button
class="btn-select-mode"
:class="{ active: selectMode }"
@click="toggleSelectMode"
title="Select conversations"
>Select</button>
<button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
<div class="sidebar-search-row">
<input
v-model="convSearchQuery"
class="conv-search-input"
type="search"
placeholder="Search conversations…"
aria-label="Search conversations"
/>
<div class="sidebar-kebab-wrapper">
<button
class="btn-kebab"
:class="{ active: sidebarKebabOpen }"
@click="sidebarKebabOpen = !sidebarKebabOpen"
aria-label="List actions"
title="List actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
</button>
<div v-if="sidebarKebabOpen" class="kebab-menu">
<button
class="kebab-item"
@click="sidebarKebabOpen = false; toggleSelectMode()"
>{{ selectMode ? "Exit select mode" : "Select conversations" }}</button>
</div>
</div>
</div>
</div>
<div v-if="selectMode" class="bulk-bar">
@@ -304,6 +377,10 @@ onUnmounted(() => {
<p v-if="!store.conversations.length" class="empty-msg">
No conversations yet
</p>
<p
v-else-if="convSearchQuery && !groupedConversations.length"
class="empty-msg"
>No matches for {{ convSearchQuery }}</p>
</div>
</aside>
@@ -319,40 +396,68 @@ onUnmounted(() => {
</button>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<!-- Research modal trigger -->
<div class="research-wrapper">
<div class="scope-chip-wrapper">
<button
class="btn-attach"
@click="toggleResearchModal"
:disabled="store.streaming || !store.chatReady"
title="Research a topic"
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="showResearchModal" class="research-modal">
<div class="research-modal-header">Research topic</div>
<input
class="research-topic-input"
v-model="researchTopic"
placeholder="e.g. quantum computing"
@keydown.enter="submitResearch"
@keydown.escape="showResearchModal = false"
/>
<div class="research-modal-actions">
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
</div>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button
class="scope-option"
:class="{ active: store.ragProjectId === null }"
@click="onScopeSelect(null)"
>Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button
class="scope-option"
:class="{ active: store.ragProjectId === -1 }"
@click="onScopeSelect(-1)"
>All notes</button>
</div>
</div>
<button
v-if="store.currentConversation.messages.length"
class="btn-summarize"
@click="handleSummarize"
:disabled="summarizing || store.streaming"
>{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
v-if="contextCount > 0"
class="btn-context-toggle"
:class="{ active: contextSidebarOpen }"
@click="toggleContextSidebar"
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
>
<span class="context-icon">📎</span>
<span class="context-label">Context</span>
<span class="context-count-badge">{{ contextCount }}</span>
</button>
<div class="header-kebab-wrapper">
<button
class="btn-kebab"
:class="{ active: headerKebabOpen }"
@click="headerKebabOpen = !headerKebabOpen"
aria-label="Conversation actions"
title="Conversation actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
</button>
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
<button
class="kebab-item"
:disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
@click="headerKebabOpen = false; handleSummarize()"
>{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
</div>
</div>
</div>
<ChatPanel
@@ -397,12 +502,12 @@ onUnmounted(() => {
.sidebar-top-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
}
.btn-new-conv {
flex: 1;
padding: 0.5rem;
background: var(--color-primary);
color: #fff;
@@ -412,23 +517,31 @@ onUnmounted(() => {
font-size: 0.9rem;
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-new-conv--full { width: 100%; }
.btn-new-conv:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
}
.btn-select-mode {
background: none;
.sidebar-search-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.conv-search-input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
white-space: nowrap;
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
outline: none;
font-family: inherit;
}
.btn-select-mode:hover,
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
.conv-search-input:focus { border-color: var(--color-primary); }
.conv-search-input::placeholder { color: var(--color-text-muted); }
.bulk-bar {
display: flex;
@@ -522,12 +635,12 @@ onUnmounted(() => {
overflow: hidden;
}
/* ChatPanel fills the remaining space in chat-main */
/* ChatPanel fills the remaining space in chat-main. Layout (grid vs
* flex) is owned by ChatPanel's own .chat-full root — don't force a
* display here or it overrides the grid. */
.chat-panel-fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-header {
@@ -548,87 +661,138 @@ onUnmounted(() => {
min-width: 0;
}
.btn-summarize {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-summarize:hover:not(:disabled) {
background: var(--color-primary); color: #fff; border-color: var(--color-primary);
}
.btn-summarize:disabled { opacity: 0.6; cursor: default; }
.btn-attach {
/* Kebab button + dropdown menu — shared by header and sidebar */
.btn-kebab {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
opacity: 0.6;
padding: 0.2rem;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.btn-attach:hover { opacity: 1; }
.btn-attach:disabled { opacity: 0.25; cursor: default; }
.btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
.btn-kebab.active { color: var(--color-primary); }
.research-wrapper { position: relative; }
.research-modal {
.header-kebab-wrapper,
.sidebar-kebab-wrapper {
position: relative;
}
/* RAG scope chip (header) */
.scope-chip-wrapper { position: relative; flex-shrink: 0; }
.scope-chip {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
@keyframes pulse-chip {
0%, 100% { border-color: var(--color-border); }
50% {
border-color: var(--color-primary);
color: var(--color-primary);
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
}
}
.scope-dot { font-size: 0.6rem; }
.scope-dropdown {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
padding: 0.45rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }
/* Context toggle button (header) */
.btn-context-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.btn-context-toggle:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-context-toggle.active {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.context-icon { font-size: 0.85rem; line-height: 1; }
.context-count-badge {
font-size: 0.65rem;
font-weight: 700;
background: var(--color-bg);
padding: 0.05rem 0.35rem;
border-radius: 8px;
color: var(--color-text);
}
.kebab-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 20px var(--color-shadow);
padding: 0.75rem;
min-width: 280px;
padding: 0.25rem;
z-index: 20;
}
.research-modal-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.research-topic-input {
.kebab-menu--right { left: auto; right: 0; }
.kebab-item {
display: block;
width: 100%;
padding: 0.45rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
text-align: left;
background: none;
border: none;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
font-size: 0.85rem;
padding: 0.45rem 0.65rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.research-topic-input:focus { border-color: var(--color-primary); }
.research-modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.btn-research-cancel {
background: none; border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 0.3rem 0.65rem;
font-size: 0.85rem; cursor: pointer; color: var(--color-text-muted);
}
.btn-research-cancel:hover { border-color: var(--color-text-muted); }
.btn-research-go {
background: var(--color-primary); color: #fff; border: none;
border-radius: var(--radius-sm); padding: 0.3rem 0.75rem;
font-size: 0.85rem; cursor: pointer; font-weight: 600;
}
.btn-research-go:disabled { opacity: 0.4; cursor: default; }
.btn-research-go:not(:disabled):hover { opacity: 0.9; }
.kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
.kebab-item:disabled { opacity: 0.4; cursor: default; }
.no-conversation {
flex: 1;
+51 -70
View File
@@ -115,72 +115,6 @@ async def _maybe_save_article_discussion_note(
conv_id, exc_info=True,
)
# ---------------------------------------------------------------------------
# Thinking decision
# ---------------------------------------------------------------------------
#
# `_should_think` is the single source of truth for whether a qwen3-class
# model should engage chain-of-thought for a given request. Frontend callers
# should NOT hardcode think=True — leave it False and let the classifier
# decide from message content. An explicit think_requested=True still acts
# as an override for callers (e.g. a future UI toggle or MCP client) that
# want to force extended reasoning regardless of content.
#
# Why gate it: on qwen3:14b, thinking adds 520s of latency before the first
# visible content token, and most conversational messages do not benefit.
# Gating by content keeps quick chats fast while preserving reasoning depth
# for prompts that actually need it.
#
# Models that don't support extended reasoning (e.g. llama3, mistral) simply
# ignore the `think` parameter in the Ollama chat request, so the decision
# here is harmless on non-thinking models.
# Keywords that strongly suggest the user wants reasoning / analysis. Matched
# case-insensitively as whole-ish phrases.
_THINK_KEYWORDS: tuple[str, ...] = (
"why", "how does", "how do i", "how would", "how should",
"explain", "analyze", "analyse", "compare", "contrast",
"design", "architect", "architecture", "plan out", "strategize",
"debug", "diagnose", "troubleshoot", "root cause",
"review", "critique", "evaluate", "trade-off", "tradeoff", "trade off",
"pros and cons", "step by step", "walk me through",
"prove", "derive", "figure out", "work through",
"discuss", # covers briefing /discuss-article + /discuss-topic entry points
)
# Messages shorter than this and without any think-keyword are treated as
# simple/conversational and skip the thinking phase.
_SHORT_MESSAGE_CHARS = 80
# Messages longer than this are treated as substantive regardless of keywords.
_LONG_MESSAGE_CHARS = 400
def _should_think(user_content: str, think_requested: bool) -> bool:
"""Return whether extended thinking should be used for this request.
``think_requested`` acts as an explicit override: if True, thinking is
forced on regardless of content. If False (the default), the decision is
made by inspecting the message: long or keyword-bearing messages get
thinking; short conversational messages skip it.
"""
if think_requested:
return True
text = (user_content or "").strip()
if not text:
return False
if len(text) >= _LONG_MESSAGE_CHARS:
return True
lowered = text.lower()
if any(kw in lowered for kw in _THINK_KEYWORDS):
return True
if len(text) < _SHORT_MESSAGE_CHARS:
return False
# Medium-length message with no obvious reasoning cue: default off.
return False
# Human-readable labels for each tool, shown in the status indicator
_TOOL_LABELS: dict[str, str] = {
"create_note": "Creating note/task",
@@ -368,11 +302,12 @@ async def run_generation(
# Emit context event
buf.append_event("context", {"context": context_meta})
# `_should_think` is authoritative — frontend callers pass think=False by
# default and let this classifier decide based on message content. An
# explicit think=True still forces on as an override.
# Always think on qwen3-class models: reasoning mode is the only reliable
# path for the tool-call template. Content-based gating was tried in 87fcaa6
# but exposed silent-generation failures on short tool-intent prompts, since
# the classifier had no way to tell that "create a task" needs a tool call.
think_requested = think
think = _should_think(user_content, think_requested)
think = True
t_start = time.monotonic()
timing: dict = {
@@ -410,6 +345,14 @@ async def run_generation(
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
t_stream = time.monotonic()
approx_msg_chars = sum(len(str(m.get("content", ""))) for m in messages)
round_content_start = len(buf.content_so_far)
round_output_tokens_start = timing.get("output_tokens") or 0
round_prompt_tokens_start = timing.get("prompt_tokens") or 0
logger.info(
"CTX_DIAG round_start conv=%d round=%d num_ctx=%d msgs=%d approx_chars=%d think=%s",
conv_id, _round, num_ctx, len(messages), approx_msg_chars, think,
)
async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx):
if buf.cancel_event.is_set():
cancelled = True
@@ -508,6 +451,21 @@ async def run_generation(
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
round_content_added = len(buf.content_so_far) - round_content_start
round_output_tokens_added = (timing.get("output_tokens") or 0) - round_output_tokens_start
round_prompt_tokens = (timing.get("prompt_tokens") or 0) - round_prompt_tokens_start
headroom = num_ctx - round_prompt_tokens if round_prompt_tokens else None
is_silent = (
not round_tool_calls
and round_content_added == 0
and round_output_tokens_added > 0
)
logger.info(
"CTX_DIAG round_end conv=%d round=%d think=%s prompt_tokens=%d output_tokens=%d headroom=%s content_added=%d tool_calls=%d silent=%s",
conv_id, _round, think, round_prompt_tokens, round_output_tokens_added,
headroom, round_content_added, len(round_tool_calls), is_silent,
)
timing["generation_ms"] = int((time.monotonic() - t_stream) * 1000)
if cancelled:
@@ -542,6 +500,29 @@ async def run_generation(
# Strip model artifacts from final content
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
# Silent-generation safety net: the model burned output tokens but
# nothing landed in content or tool_calls (seen with qwen3:14b when
# its tool-call emission doesn't parse). Show a visible fallback so
# the user isn't staring at an empty bubble.
if (
not cancelled
and not buf.content_so_far.strip()
and not all_tool_calls
and (timing.get("output_tokens") or 0) > 0
):
logger.warning(
"Silent generation for conv %d: output_tokens=%s but empty content "
"and no tool calls (model=%s)",
conv_id, timing.get("output_tokens"), model,
)
fallback = (
"I wasn't able to produce a usable response — the model generated "
"tokens that couldn't be parsed as content or a tool call. "
"Please try rephrasing, or try again."
)
buf.content_so_far = fallback
buf.append_event("chunk", {"chunk": fallback})
# Final save
logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d",
conv_id, len(buf.content_so_far), len(all_tool_calls))
+34 -7
View File
@@ -265,20 +265,31 @@ async def stream_chat_with_tools(
) as resp:
await _raise_ollama_error(resp, model)
accumulated_tool_calls: list[dict] = []
# Silent-generation diagnostic: if Ollama reports non-zero eval_count
# but we never yielded any thinking/content/tool_calls, something
# in the frames isn't landing in a field we read. Capture the last
# few frames so we can see what Ollama actually sent.
yielded_anything = False
recent_frames: list[str] = []
async for line in resp.aiter_lines():
if not line.strip():
continue
if len(recent_frames) >= 5:
recent_frames.pop(0)
recent_frames.append(line[:500])
data = json.loads(line)
msg = data.get("message", {})
# Thinking chunks (qwen3 chain-of-thought, only when think=True)
thinking = msg.get("thinking", "")
if thinking:
yielded_anything = True
yield ChatChunk(type="thinking", content=thinking)
# Content chunks
chunk = msg.get("content", "")
if chunk:
yielded_anything = True
yield ChatChunk(type="content", content=chunk)
# Collect tool calls from any message (some models
@@ -294,13 +305,21 @@ async def stream_chat_with_tools(
len(accumulated_tool_calls),
json.dumps(accumulated_tool_calls)[:500],
)
yielded_anything = True
yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls)
else:
logger.debug("Ollama done with no tool calls")
eval_count = data.get("eval_count") or 0
if not yielded_anything and eval_count > 0:
logger.warning(
"Ollama silent generation: model=%s eval_count=%d but no "
"thinking/content/tool_calls were yielded. Last frames: %s",
model, eval_count, recent_frames,
)
yield ChatChunk(
type="done",
prompt_tokens=data.get("prompt_eval_count"),
output_tokens=data.get("eval_count"),
output_tokens=eval_count,
)
break
@@ -568,18 +587,26 @@ async def build_context(
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
]
actions = [
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
"read_note", "list_notes", "list_tasks", "log_work", "search_notes",
"create_project", "list_projects", "get_project", "update_project",
"search_projects", "create_milestone", "update_milestone", "list_milestones",
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
"get_rss_items", "add_rss_feed", "read_article",
]
if has_caldav:
tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
)
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
tool_lines.append(
"For calendar events, use ISO 8601 datetime format with the user's timezone offset (stated in context below). "
"Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
)
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
if Config.searxng_enabled():
actions.extend(["search_web", "research_topic", "search_images"])
tool_lines.append(f"Available actions: {', '.join(actions)}.")
tool_lines.append(
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
"Always include the UTC offset when creating events (user's timezone is stated in context below)."
@@ -658,7 +685,7 @@ async def build_context(
f"\n\n--- Active Workspace ---\n"
f"You are in the \"{wp.title}\" project workspace.\n"
f"All notes and tasks you create or update MUST belong to this project.\n"
f"Always pass project=\"{wp.title}\" when calling create_note or create_task.\n"
f"Always pass project=\"{wp.title}\" when calling create_note.\n"
f"--- End Active Workspace ---"
)
except Exception:
-127
View File
@@ -1,127 +0,0 @@
"""Tests for the `_should_think` classifier.
`_should_think` decides whether qwen3-class models should engage chain-of-
thought for a given chat turn. It is the single source of truth: frontend
callers pass `think_requested=False` by default and defer to this function,
while explicit `think_requested=True` acts as an override for curated
analytical entry points.
These tests lock in the content-based behavior so future tweaks don't
silently regress the short / long / keyword boundaries.
"""
import pytest
from fabledassistant.services.generation_task import (
_LONG_MESSAGE_CHARS,
_SHORT_MESSAGE_CHARS,
_should_think,
)
class TestExplicitOverride:
def test_override_forces_on_for_empty(self):
assert _should_think("", think_requested=True) is True
def test_override_forces_on_for_short_greeting(self):
assert _should_think("hi", think_requested=True) is True
def test_override_forces_on_for_medium_no_keyword(self):
text = "just checking in on the status of things for the week"
assert _should_think(text, think_requested=True) is True
class TestEmptyAndWhitespace:
def test_empty_string_off(self):
assert _should_think("", think_requested=False) is False
def test_none_content_off(self):
# _should_think defensively handles None content from upstream callers
assert _should_think(None, think_requested=False) is False # type: ignore[arg-type]
def test_whitespace_only_off(self):
assert _should_think(" \n\t ", think_requested=False) is False
class TestShortMessages:
def test_short_greeting_off(self):
assert _should_think("hi", think_requested=False) is False
def test_short_thanks_off(self):
assert _should_think("thanks!", think_requested=False) is False
def test_short_acknowledgement_off(self):
assert _should_think("ok sounds good", think_requested=False) is False
def test_just_below_short_threshold_off(self):
text = "a" * (_SHORT_MESSAGE_CHARS - 1)
assert _should_think(text, think_requested=False) is False
class TestLongMessages:
def test_at_long_threshold_on(self):
text = "a" * _LONG_MESSAGE_CHARS
assert _should_think(text, think_requested=False) is True
def test_well_above_long_threshold_on(self):
text = "x" * (_LONG_MESSAGE_CHARS * 3)
assert _should_think(text, think_requested=False) is True
class TestMediumMessages:
def test_medium_no_keyword_off(self):
# Between the short and long thresholds with no reasoning cue.
text = "a" * ((_SHORT_MESSAGE_CHARS + _LONG_MESSAGE_CHARS) // 2)
assert _should_think(text, think_requested=False) is False
class TestKeywordTriggers:
@pytest.mark.parametrize(
"text",
[
"why is this failing",
"how does caching work here",
"how do i configure this",
"explain the retry logic",
"analyze the latency breakdown",
"compare gemma3 vs qwen3 for tool use",
"please design the schema for X",
"debug this error",
"troubleshoot the connection issue",
"root cause the outage",
"review this PR",
"critique my approach",
"walk me through the flow",
"step by step instructions please",
"pros and cons of each option",
"help me figure out what's wrong",
"discuss this article", # covers briefing /discuss entry points
],
)
def test_keyword_forces_on(self, text):
assert _should_think(text, think_requested=False) is True
def test_keyword_case_insensitive(self):
assert _should_think("WHY does this break?", think_requested=False) is True
def test_keyword_in_longer_sentence(self):
text = "hey quick one — can you explain what caching does for qwen3"
assert _should_think(text, think_requested=False) is True
class TestNonTriggers:
"""Messages that look chatty and should NOT trigger thinking."""
@pytest.mark.parametrize(
"text",
[
"hey",
"yep",
"no worries",
"got it, thanks",
"good morning",
"remind me later", # no reasoning keyword, short
],
)
def test_chatty_messages_off(self, text):
assert _should_think(text, think_requested=False) is False