Compare commits

...

32 Commits

Author SHA1 Message Date
bvandeusen c4ffe418b5 Merge pull request 'Fix CSP blocking Silero VAD WASM compilation' (#39) from dev into main 2026-04-17 00:23:59 +00:00
bvandeusen 6cf880506d fix(voice): allow WASM compilation in CSP for Silero VAD
Add 'wasm-unsafe-eval' to script-src and blob: to worker-src in
Content-Security-Policy header. Required by onnxruntime-web to compile
the Silero VAD ONNX model. Also surface VAD init errors as a toast
instead of silent console log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:29 -04:00
bvandeusen aca34e2dfb Merge pull request 'Release v26.04.16.3 — Silero VAD + Briefing layout' (#38) from dev into main 2026-04-16 23:37:04 +00:00
bvandeusen c07a0f0f7e fix(chat): remove input bar border across all chat views
Remove border-top from .input-wrapper in ChatPanel directly instead
of overriding per-view. Applies to chat, workspace, and briefing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:25:48 -04:00
bvandeusen 776e5edb68 fix(briefing): responsive sidebar, remove dividers, scale forecast icons
Switch sidebar from fixed px to proportional 35% so it resizes with
the viewport. Remove border between chat and input bar and between
weather and news. Use container query units for weather/forecast icons
so they scale with column width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:10:49 -04:00
bvandeusen 8b0dd732f7 feat(briefing): tabbed weather locations, sticky weather, wider sidebar
Add location tabs when multiple weather sources configured. Weather
section stays pinned at top of sidebar while news scrolls independently.
Bump sidebar width from 420px to 620px max for better readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:30:16 -04:00
bvandeusen f12d29563a fix(briefing): 2-column layout with table-based weather forecast
Collapse briefing from 3-column to 2-column grid (chat + sidebar).
Weather moves to top of news column, forecast uses an HTML table so
rows align cleanly. Drop verbose condition text from forecast days
(emoji already conveys it). Switch CI node_modules cache to npm
download cache to avoid tar size error from onnxruntime-web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:10:46 -04:00
bvandeusen a4995606e5 chore(voice): remove amplitude-based silence detector (replaced by VAD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:47:45 -04:00
bvandeusen 0a8a755909 feat(voice): replace silence detector with Silero VAD in ChatInputBar
Swaps useSilenceDetector for useVad. Adds no-speech guard: when the
user manually stops recording without VAD ever detecting speech, show
a toast and skip the Whisper round-trip instead of sending pure noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:45:21 -04:00
bvandeusen 719de12a6c feat(voice): add useVad composable wrapping Silero VAD
Provides speech-start/speech-end detection via @ricky0123/vad-web.
Retains amplitude-based visual feedback for mic pulse animation but no
longer uses it for stop decisions. Exposes stopAndCheck() which fires
onNoSpeech when the user manually stops without VAD ever detecting
speech — avoids wasted Whisper round-trips on noise-only recordings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:43:17 -04:00
bvandeusen 72018aa389 feat(voice): add ONNX WASM idle preloader for Silero VAD
Configures vite-plugin-static-copy to serve @ricky0123/vad-web's ONNX model,
audio worklet, and onnxruntime-web WASM files from the site root.
useOnnxPreloader warms the model cache during page idle so the first mic
click is instant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:41:17 -04:00
bvandeusen 1e73ea04f1 chore(frontend): add @ricky0123/vad-web dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 13:37:37 -04:00
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
19 changed files with 1761 additions and 2092 deletions
+4 -9
View File
@@ -68,19 +68,14 @@ jobs:
steps:
- uses: actions/checkout@v6
# Cache node_modules directly (not the npm download cache).
# npm ci still has to extract + link every module even with a
# warm download cache, which is where the real time goes. Caching
# the output directory lets us skip npm ci entirely on hits.
- name: Cache node_modules
id: npm-cache
- name: Cache npm download cache
uses: actions/cache@v4
with:
path: frontend/node_modules
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
path: ~/.npm
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-cache-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
working-directory: frontend
+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
+393 -1276
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"@ricky0123/vad-web": "^0.0.30",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
@@ -35,6 +36,7 @@
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.0",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^4.0.1",
"vue-tsc": "^3.0.0"
}
}
+4
View File
@@ -5,6 +5,7 @@ import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
@@ -12,6 +13,9 @@ import { apiGet, apiPut } from "@/api/client";
useTheme();
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
scheduleVadPreload();
const router = useRouter();
const appVersion = ref("dev");
const authStore = useAuthStore();
+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;
+266 -20
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useSilenceDetector } from '@/composables/useSilenceDetector'
import { useVad } from '@/composables/useVad'
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('')
@@ -109,15 +138,50 @@ function removeAttachedNote() {
attachedNote.value = null
}
// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
const silenceDetector = useSilenceDetector()
// Tracks whether VAD detected speech during the current recording session.
// Used to skip the Whisper round-trip when the user manually stops without
// ever speaking — the recording is ambient noise and will transcribe to "".
const vadSawSpeech = ref(false)
const vad = useVad({
onSpeechEnd: () => {
// VAD auto-stop: speech was detected and the user has paused. Proceed
// to transcribe the MediaRecorder output.
void stopRecording(false)
},
onNoSpeech: () => {
useToastStore().show('No speech detected', 'warning')
},
onVadError: (msg) => {
useToastStore().show(`VAD failed: ${msg}`, 'error')
},
})
// Live mic halo. A solid red disc sits behind the mic button and scales
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
const micGlowStyle = computed(() => {
if (!recorder.recording.value) return { display: 'none' }
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
return {
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
opacity: String(0.45 + pulse * 0.4),
}
})
// vad.speaking flips true on speech-start. Watch it once per session to
// capture that speech was detected at some point, without clearing when
// speech ends. This drives the no-speech guard in stopRecording.
watch(() => vad.speaking.value, (on) => {
if (on) vadSawSpeech.value = true
})
async function toggleVoice() {
if (transcribingVoice.value) return
if (recorder.recording.value) {
await stopRecording()
await stopRecording(true)
} else {
await startRecording()
}
@@ -129,22 +193,32 @@ async function startRecording() {
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
return
}
vadSawSpeech.value = false
await recorder.startRecording()
if (recorder.error.value) {
useToastStore().show(recorder.error.value, 'error')
return
}
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, () => stopRecording())
await vad.start(recorder.stream.value)
}
}
async function stopRecording() {
silenceDetector.stop()
async function stopRecording(manual: boolean) {
if (manual) {
await vad.stopAndCheck()
} else {
await vad.stop()
}
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
// No-speech guard: user manually stopped without VAD seeing speech.
// Skip Whisper — the toast was already shown by onNoSpeech.
if (manual && !vadSawSpeech.value) {
return
}
// Pass last assistant message as context to reduce STT mishearings
const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
@@ -159,6 +233,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 +313,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 +475,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 +591,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 -280
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,103 +661,16 @@ 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 {
border-top: 1px solid var(--color-border);
grid-column: 2;
grid-row: 2;
padding: 0.5rem 1rem 0.75rem;
display: flex;
flex-direction: column;
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 +712,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;
+58 -43
View File
@@ -85,22 +85,30 @@ const fetchedAtLabel = computed(() => {
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div class="weather-forecast" v-if="weather.forecast.length">
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
<span class="forecast-condition">{{ day.condition }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
💧 {{ day.precip_probability }}%
</span>
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
💧 {{ day.precip_mm.toFixed(1) }}mm
</span>
<span v-else class="forecast-precip forecast-precip--dry">💧 </span>
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</span>
</div>
</div>
<table class="weather-forecast" v-if="weather.forecast.length">
<thead>
<tr>
<th></th>
<th></th>
<th>Hi / Lo</th>
<th>💧</th>
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="day in weather.forecast" :key="day.day">
<td class="forecast-day-name">{{ day.day }}</td>
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }">
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
<template v-else>&mdash;</template>
</td>
<td class="forecast-wind">{{ day.windspeed_max }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="weather-card weather-unavailable">
Weather data unavailable will retry at next slot.
@@ -115,6 +123,7 @@ const fetchedAtLabel = computed(() => {
padding: 1rem 1.25rem;
margin-bottom: 1rem;
font-size: 0.9rem;
container-type: inline-size;
}
.weather-header {
@@ -142,12 +151,12 @@ const fetchedAtLabel = computed(() => {
}
.weather-icon {
font-size: 2rem;
font-size: clamp(1.5rem, 5cqi, 2.5rem);
line-height: 1;
}
.weather-temp {
font-size: 2rem;
font-size: clamp(1.5rem, 5cqi, 2.5rem);
font-weight: 700;
line-height: 1;
}
@@ -169,20 +178,31 @@ const fetchedAtLabel = computed(() => {
}
.weather-forecast {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.75rem;
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
}
.weather-forecast-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
.weather-forecast thead th {
font-size: 0.68rem;
font-weight: 600;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 0.4rem 0.25rem;
white-space: nowrap;
}
.weather-forecast thead th:first-child,
.weather-forecast thead th:nth-child(2) {
text-align: left;
}
.weather-forecast tbody td {
padding: 0.3rem 0.4rem;
white-space: nowrap;
vertical-align: middle;
}
.forecast-day-name {
@@ -190,26 +210,16 @@ const fetchedAtLabel = computed(() => {
}
.forecast-icon {
font-size: 1.2rem;
font-size: clamp(0.9rem, 3cqi, 1.3rem);
line-height: 1;
}
.forecast-condition {
font-size: 0.7rem;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.forecast-temps {
white-space: nowrap;
text-align: right;
}
.forecast-precip,
.forecast-wind {
font-size: 0.72rem;
white-space: nowrap;
.forecast-precip {
text-align: right;
color: var(--color-text-muted);
}
@@ -217,6 +227,11 @@ const fetchedAtLabel = computed(() => {
opacity: 0.35;
}
.forecast-wind {
text-align: right;
color: var(--color-text-muted);
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
@@ -0,0 +1,43 @@
import { ref, readonly } from 'vue'
/**
* Idle-preload the Silero VAD ONNX model and its companion assets.
*
* `@ricky0123/vad-web` pulls ~2MB of ONNX model + ~5MB of onnxruntime-web
* WASM the first time `MicVAD.new()` runs. Calling `defaultModelFetcher`
* during page idle warms the browser cache so the first mic click feels
* instant. If the preload fails, VAD simply loads lazily on first click.
*/
const ready = ref(false)
let loading = false
export function useOnnxPreloader() {
async function preload() {
if (ready.value || loading) return
loading = true
try {
const { defaultModelFetcher } = await import('@ricky0123/vad-web')
// Asset lives at site root because vite-plugin-static-copy drops it
// there. Match whatever path MicVAD will use at call time (see useVad).
await defaultModelFetcher('/silero_vad_v5.onnx')
ready.value = true
} catch {
// Non-fatal — MicVAD will load the model itself on first use.
} finally {
loading = false
}
}
function schedulePreload() {
if (ready.value) return
if (typeof window === 'undefined') return
if ('requestIdleCallback' in window) {
(window as Window & { requestIdleCallback: (cb: () => void, opts: { timeout: number }) => void })
.requestIdleCallback(() => void preload(), { timeout: 5000 })
} else {
setTimeout(() => void preload(), 5000)
}
}
return { ready: readonly(ready), schedulePreload }
}
@@ -1,69 +0,0 @@
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
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.
audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
+154
View File
@@ -0,0 +1,154 @@
import { ref, readonly } from 'vue'
import type { MicVAD } from '@ricky0123/vad-web'
export interface UseVadOptions {
onSpeechEnd: () => void
onNoSpeech: () => void
onVadError?: (message: string) => void
graceMs?: number
minRecordingMs?: number
}
/**
* Silero VAD-based speech detector.
*
* Replaces amplitude-based silence detection. Uses the Silero VAD v5 ONNX
* model via `@ricky0123/vad-web`. The package ships the model + audio
* worklet, and depends on `onnxruntime-web`'s WASM. Vite is configured
* (see `vite.config.ts`) to serve these assets from the site root, which
* is why we pass `baseAssetPath: "/"` and `onnxWASMBasePath: "/"`.
*
* `amplitude` is computed separately from a Web Audio API analyser node
* on the same stream — it drives the mic pulse animation and is not part
* of the stop decision. VAD's `onSpeechEnd` is the stop trigger.
*
* If VAD never detects speech during a session, calling `stopAndCheck()`
* fires `onNoSpeech` instead of treating the recording as transcribable.
*/
export function useVad(options: UseVadOptions) {
const {
onSpeechEnd,
onNoSpeech,
graceMs = 1500,
minRecordingMs = 500,
} = options
const speaking = ref(false)
const amplitude = ref(0)
const loaded = ref(false)
let micVad: MicVAD | null = null
let audioCtx: AudioContext | null = null
let analyserInterval: ReturnType<typeof setInterval> | null = null
let speechDetected = false
let speechStartedAt = 0
let recordingStartedAt = 0
let stopped = false
async function start(stream: MediaStream): Promise<void> {
await stop()
stopped = false
speechDetected = false
speechStartedAt = 0
recordingStartedAt = Date.now()
// Visual-only amplitude signal. This does NOT drive the stop decision.
audioCtx = new AudioContext()
await audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 1024
source.connect(analyser)
const samples = new Float32Array(analyser.fftSize)
analyserInterval = setInterval(() => {
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)
}, 100)
try {
const { MicVAD } = await import('@ricky0123/vad-web')
micVad = await MicVAD.new({
model: 'v5',
baseAssetPath: '/',
onnxWASMBasePath: '/',
// MicVAD manages its own audio graph, so we hand it the same stream
// the MediaRecorder is using. It won't consume or alter the stream.
getStream: async () => stream,
onSpeechStart: () => {
speaking.value = true
if (!speechDetected) {
speechDetected = true
speechStartedAt = Date.now()
}
},
onSpeechEnd: () => {
speaking.value = false
if (stopped) return
const now = Date.now()
const totalElapsed = now - recordingStartedAt
const sinceSpeechStart = speechStartedAt > 0 ? now - speechStartedAt : 0
if (totalElapsed >= minRecordingMs && sinceSpeechStart >= graceMs) {
stopped = true
onSpeechEnd()
}
},
onVADMisfire: () => {
// Speech-like blip that was too short to count. Reset the local flag
// so a true "no speech" session still hits onNoSpeech.
speaking.value = false
},
})
await micVad.start()
loaded.value = true
} catch (e) {
console.error('VAD init failed:', e)
options.onVadError?.(e instanceof Error ? e.message : String(e))
}
}
async function stop(): Promise<{ hadSpeech: boolean }> {
const had = speechDetected
stopped = true
speaking.value = false
amplitude.value = 0
if (analyserInterval !== null) {
clearInterval(analyserInterval)
analyserInterval = null
}
if (audioCtx) {
await audioCtx.close().catch(() => {})
audioCtx = null
}
if (micVad) {
try {
await micVad.pause()
await micVad.destroy()
} catch {
// Already destroyed or failed — nothing to clean up further.
}
micVad = null
}
return { hadSpeech: had }
}
async function stopAndCheck(): Promise<void> {
const { hadSpeech } = await stop()
if (!hadSpeech) {
onNoSpeech()
}
}
return {
speaking: readonly(speaking),
amplitude: readonly(amplitude),
loaded: readonly(loaded),
start,
stop,
stopAndCheck,
}
}
+80 -56
View File
@@ -56,8 +56,9 @@ const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Weather panel (left column)
// Weather panel
const weatherData = ref<WeatherData[]>([])
const selectedWeatherIdx = ref(0)
const tempUnit = ref<string>('C')
interface CurrentConditions {
@@ -261,22 +262,7 @@ onMounted(async () => {
</div>
</header>
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<!-- Forecast card -->
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
:key="(loc as WeatherData).location"
:weather="loc"
:temp-unit="tempUnit"
/>
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
<!-- Center column: Chat -->
<!-- Left column: Chat -->
<div class="briefing-center">
<ChatPanel
variant="full"
@@ -287,8 +273,27 @@ onMounted(async () => {
/>
</div>
<!-- Right column: News -->
<!-- Right column: Weather + News -->
<div class="briefing-right">
<!-- Weather section (sticky) -->
<div class="weather-section" v-if="weatherData.length">
<div class="weather-tabs" v-if="weatherData.length > 1">
<button
v-for="(loc, i) in weatherData"
:key="(loc as WeatherData).location"
class="weather-tab"
:class="{ active: selectedWeatherIdx === i }"
@click="selectedWeatherIdx = i"
>{{ (loc as WeatherData).location }}</button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
:temp-unit="tempUnit"
/>
</div>
<!-- News section (scrollable) -->
<div class="news-section">
<div class="panel-label-row">
<div class="panel-label">Today's News</div>
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
@@ -333,6 +338,7 @@ onMounted(async () => {
>💬</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -348,7 +354,7 @@ onMounted(async () => {
.briefing-shell {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-columns: 1fr minmax(320px, 35%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
@@ -421,27 +427,10 @@ onMounted(async () => {
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-left :deep(.weather-card) {
margin-bottom: 0;
}
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
.briefing-center {
grid-column: 2;
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
@@ -453,14 +442,60 @@ onMounted(async () => {
min-height: 0;
}
/* ─── Right column (News) ────────────────────────────────────────────────── */
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
.briefing-right {
grid-column: 3;
grid-column: 2;
grid-row: 2;
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.weather-section {
flex-shrink: 0;
padding: 1rem 1rem 0.5rem;
}
.weather-section :deep(.weather-card) {
margin-bottom: 0;
}
.weather-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.weather-tab {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: none;
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.weather-tab:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.weather-tab.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
.news-section {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -581,29 +616,18 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.briefing-header {
grid-column: 1;
grid-row: 1;
}
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: none;
border-bottom: 1px solid var(--color-border);
max-height: 220px;
grid-template-rows: auto 1fr auto;
}
.briefing-center {
grid-column: 1;
grid-row: 3;
grid-row: 2;
}
.briefing-right {
grid-column: 1;
grid-row: 4;
grid-row: 3;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 260px;
max-height: 300px;
}
}
</style>
+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;
+31 -1
View File
@@ -1,9 +1,39 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { resolve } from "path";
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
// @ricky0123/vad-web ships ONNX + audio-worklet assets and depends on
// onnxruntime-web's WASM binaries. Copy them into the served root so
// MicVAD can load them at runtime via `baseAssetPath: "/"`.
viteStaticCopy({
targets: [
{
src: "node_modules/@ricky0123/vad-web/dist/*.onnx",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/onnxruntime-web/dist/*.wasm",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/onnxruntime-web/dist/*.mjs",
dest: "",
rename: { stripBase: true },
},
],
}),
],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
+3 -2
View File
@@ -143,10 +143,11 @@ def create_app() -> Quart:
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
"base-uri 'self'; worker-src 'self';"
"base-uri 'self'; worker-src 'self' blob:;"
)
return response
+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