Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72708475a3 | |||
| e07d8436b7 | |||
| 1711ee6a1f | |||
| 369cf0a7c9 | |||
| 3f2813d16f | |||
| fddac2aa2f | |||
| 1261e93ede | |||
| b6165e56e5 | |||
| 5e281c534a | |||
| 9082281225 | |||
| 058d6089b1 | |||
| ba0cb07c91 | |||
| 4228e9a384 | |||
| 035ec0c0dc | |||
| f0c93ffa3b | |||
| ad4fe3d783 | |||
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b |
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { apiGet, transcribeAudio } from '@/api/client'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
@@ -29,6 +32,32 @@ const emit = defineEmits<{
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
|
||||
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const speakerPopoverOpen = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core input ────────────────────────────────────────────────────────────────
|
||||
const messageInput = ref('')
|
||||
@@ -114,6 +143,20 @@ const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
const silenceDetector = useSilenceDetector()
|
||||
|
||||
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for
|
||||
// visibility). The button itself stays put so the mic icon is always
|
||||
// legible on top. 0.2 baseline breathes on silence; loud speech drives
|
||||
// the disc out to ~2.6× the button size with a softer radial fade.
|
||||
const micGlowStyle = computed(() => {
|
||||
if (!recorder.recording.value) return { display: 'none' }
|
||||
const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8
|
||||
return {
|
||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||
opacity: String(0.45 + pulse * 0.4),
|
||||
}
|
||||
})
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
@@ -159,6 +202,15 @@ async function stopRecording() {
|
||||
finally { transcribingVoice.value = false }
|
||||
}
|
||||
|
||||
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
if (!speakerPopoverOpen.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
|
||||
|
||||
// ── Exposed interface ─────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
@@ -230,23 +282,72 @@ defineExpose({ focus, prefill })
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- PTT mic -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<!-- Speaker / listen-mode popover -->
|
||||
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
|
||||
<button
|
||||
class="btn-icon btn-speaker"
|
||||
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
|
||||
@click="speakerPopoverOpen = !speakerPopoverOpen"
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
class="speaker-row speaker-row--toggle"
|
||||
:class="{ 'speaker-row--active': listenMode }"
|
||||
@click="toggleListen"
|
||||
>
|
||||
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
|
||||
<span class="speaker-switch" :class="{ on: listenMode }"></span>
|
||||
</button>
|
||||
<div class="speaker-row">
|
||||
<span class="speaker-row-label">Volume</span>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="speaker-volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PTT mic (with live amplitude halo behind) -->
|
||||
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||
<button
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
@@ -343,9 +444,50 @@ defineExpose({ focus, prefill })
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
|
||||
.btn-mic.mic-recording {
|
||||
opacity: 1;
|
||||
/* White icon sits on top of the red halo so it stays legible at any
|
||||
pulse size. */
|
||||
color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
computed `micGlowStyle` so the user gets unmistakable feedback
|
||||
that their voice is being picked up while the mic icon itself
|
||||
stays put and readable on top. */
|
||||
.mic-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mic-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 68, 68, 0.9) 0%,
|
||||
rgba(239, 68, 68, 0.6) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||
rather than stepping. */
|
||||
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
@@ -418,4 +560,77 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
/* Speaker / listen-mode popover */
|
||||
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
|
||||
.speaker-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.35rem;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.speaker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.speaker-row--toggle { cursor: pointer; }
|
||||
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
|
||||
.speaker-row--active { color: var(--color-primary); }
|
||||
.speaker-row--stop {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
|
||||
.speaker-row-label { flex: 1; }
|
||||
.speaker-volume-range { flex: 1; min-width: 0; }
|
||||
.speaker-volume-pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.speaker-switch {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-border);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.speaker-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-card);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.speaker-switch.on { background: var(--color-primary); }
|
||||
.speaker-switch.on::after { transform: translateX(12px); }
|
||||
</style>
|
||||
|
||||
@@ -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)">×</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">×</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)">×</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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Scope chip -->
|
||||
<div v-if="showScopeChip" class="scope-chip-row">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listen / volume controls -->
|
||||
<div v-if="voiceTtsEnabled" class="voice-controls">
|
||||
<div class="volume-wrapper">
|
||||
<button
|
||||
class="btn-voice"
|
||||
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
|
||||
@click="toggleListen"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||||
aria-label="Toggle listen mode"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-voice btn-volume-icon"
|
||||
@click="showVolumeSlider = !showVolumeSlider"
|
||||
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showVolumeSlider" class="volume-popup">
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="btn-voice"
|
||||
@click="tts.stop()"
|
||||
title="Stop playback"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Unified input bar -->
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
@@ -476,6 +459,7 @@ defineExpose({ focus, prefill, send })
|
||||
@submit="onSubmit"
|
||||
@abort="store.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -528,18 +512,31 @@ defineExpose({ focus, prefill, send })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Full variant layout ── */
|
||||
.chat-body {
|
||||
/* ── Full variant layout ──
|
||||
* Single grid owns the reading column + context sidebar + input bar so
|
||||
* messages and input bar share one centered reading track while the
|
||||
* context sidebar spans the full height from header to bottom of view.
|
||||
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
|
||||
* The context sidebar overlays the right gutter so it never shifts layout.
|
||||
*/
|
||||
.chat-full {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
minmax(0, var(--chat-reading-width))
|
||||
1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
}
|
||||
@@ -558,31 +555,79 @@ defineExpose({ focus, prefill, send })
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Context sidebar */
|
||||
/* Context sidebar — overlays right gutter, never shifts reading column */
|
||||
.context-sidebar {
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--chat-context-sidebar-width);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.78rem;
|
||||
z-index: 2;
|
||||
}
|
||||
.context-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.15rem 0.1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.context-sidebar-header:hover { color: var(--color-text); }
|
||||
.context-sidebar-header .chev {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.context-sidebar-header.collapsed .chev {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
||||
.context-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.context-note-auto {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.context-note-included {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.context-note-name {
|
||||
flex: 1;
|
||||
@@ -616,8 +661,10 @@ defineExpose({ focus, prefill, send })
|
||||
.context-note-remove:hover { color: #ef4444; }
|
||||
.context-note-add:hover { color: var(--color-primary); }
|
||||
|
||||
/* Input wrapper */
|
||||
/* Input wrapper — lives in the same reading column as messages */
|
||||
.input-wrapper {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
@@ -625,94 +672,6 @@ defineExpose({ focus, prefill, send })
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Scope chip */
|
||||
.scope-chip-row { display: flex; }
|
||||
.scope-chip-wrapper { position: relative; }
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
/* Voice controls */
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
|
||||
.btn-voice {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.65;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-voice:hover { opacity: 1; }
|
||||
.btn-voice--active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-voice--busy { opacity: 1; color: #f59e0b; }
|
||||
.volume-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
}
|
||||
.volume-range { width: 80px; }
|
||||
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
|
||||
/* Queued messages */
|
||||
.queued-message { opacity: 0.45; }
|
||||
.queued-bubble {
|
||||
@@ -754,6 +713,108 @@ defineExpose({ focus, prefill, send })
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.5rem;
|
||||
padding: 3rem 1rem 2rem;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-recent-item + .empty-recent-item {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.empty-recent-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-recent-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-voice-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
.widget-response {
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
/**
|
||||
* Absolute fallback silence threshold in dBFS, used during the first
|
||||
* moments before any real speech has been observed. Once the session peak
|
||||
* clears `dynamicArmDb` we switch to the dynamic threshold instead.
|
||||
*/
|
||||
fallbackThresholdDb?: number // default -45
|
||||
/** How many dB below the session peak counts as "silent" once armed. */
|
||||
dropFromPeakDb?: number // default 15
|
||||
/**
|
||||
* Session peak must reach this level (dBFS) before dynamic thresholding
|
||||
* kicks in. Until then the fallback threshold is used so we don't lock
|
||||
* onto a noise-floor peak.
|
||||
*/
|
||||
dynamicArmDb?: number // default -25
|
||||
/** How long to wait at the start of recording before running silence checks. */
|
||||
graceMs?: number // default 1500
|
||||
silenceDurationMs?: number // default 2000
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Mic silence detector + live amplitude signal.
|
||||
*
|
||||
* Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space,
|
||||
* then derives dBFS for the silence threshold. The previous implementation
|
||||
* ran RMS over `getByteFrequencyData` bytes — those bytes are already a
|
||||
* dB-scaled quantity, so taking their RMS and re-log'ing it produced
|
||||
* numbers that didn't line up with real dBFS and made any static threshold
|
||||
* unpredictable.
|
||||
*
|
||||
* Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||
* "silent" as "current level is at least [dropFromPeakDb] below peak."
|
||||
* This auto-calibrates to whatever mic and room the user is on. Until the
|
||||
* peak climbs above [dynamicArmDb] we fall back to a conservative static
|
||||
* threshold so a quiet room doesn't spin forever. A grace period at the
|
||||
* start of the recording gives the user time to begin speaking before
|
||||
* silence checks arm.
|
||||
*
|
||||
* `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet
|
||||
* speech still visibly moves UI that binds to it.
|
||||
*/
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
fallbackThresholdDb = -45,
|
||||
dropFromPeakDb = 15,
|
||||
dynamicArmDb = -25,
|
||||
graceMs = 1500,
|
||||
silenceDurationMs = 2000,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
@@ -18,31 +57,43 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
let peakDb = -100
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// getByteFrequencyData returns real values instead of all zeros.
|
||||
// the analyser returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
analyser.fftSize = 1024
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
peakDb = -100
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
||||
const rms = Math.sqrt(sumSq / samples.length)
|
||||
amplitude.value = Math.min(rms * 5, 1)
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100
|
||||
if (db > peakDb) peakDb = db
|
||||
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed < graceMs) return
|
||||
|
||||
const threshold =
|
||||
peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb
|
||||
|
||||
if (db < threshold) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
@@ -63,6 +114,7 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
peakDb = -100
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
|
||||
+295
-131
@@ -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;
|
||||
|
||||
@@ -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 5–20s 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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user