Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aca34e2dfb | |||
| c07a0f0f7e | |||
| 776e5edb68 | |||
| 8b0dd732f7 | |||
| f12d29563a | |||
| a4995606e5 | |||
| 0a8a755909 | |||
| 719de12a6c | |||
| 72018aa389 | |||
| 1e73ea04f1 | |||
| 72708475a3 | |||
| e07d8436b7 | |||
| 1711ee6a1f | |||
| 369cf0a7c9 | |||
| 3f2813d16f | |||
| fddac2aa2f | |||
| 1261e93ede | |||
| b6165e56e5 | |||
| 5e281c534a | |||
| 9082281225 | |||
| 058d6089b1 | |||
| ba0cb07c91 | |||
| 4228e9a384 | |||
| 035ec0c0dc | |||
| f0c93ffa3b | |||
| ad4fe3d783 | |||
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 | |||
| 9f3b3450fa | |||
| ba90ad8132 | |||
| 9157740069 | |||
| fd885c1bc8 | |||
| 8205590f8d | |||
| 939b910372 | |||
| 70cea78c2f | |||
| 4e4dbb8783 | |||
| e4e1d1da49 |
+27
-18
@@ -1,8 +1,12 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to any branch: typecheck + lint + test
|
||||
# Push to dev: gates + build :dev + :<sha>
|
||||
# Tag v* (release): gates + build :latest + :<sha> + :<version>
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -11,6 +15,13 @@
|
||||
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
@@ -18,7 +29,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
branches: [dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
@@ -51,23 +62,20 @@ env:
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
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
|
||||
|
||||
@@ -77,6 +85,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -88,6 +97,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -118,11 +128,10 @@ jobs:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run the gates for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production image.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add content_full and context_prepared caches to rss_items.
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
Create Date: 2026-04-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0038"
|
||||
down_revision = "0037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("context_prepared", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rss_items", "content_fetched_at")
|
||||
op.drop_column("rss_items", "context_prepared")
|
||||
op.drop_column("rss_items", "content_full")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Link rss_items to their discussion-summary note.
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-04-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_rss_items_discussion_note_id",
|
||||
"rss_items",
|
||||
"notes",
|
||||
["discussion_note_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("rss_items", "discussion_note_id")
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+393
-1276
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -426,7 +426,9 @@ export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
|
||||
export async function openArticleInChat(
|
||||
itemId: number,
|
||||
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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,49 @@ 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: () => {
|
||||
// User manually stopped without VAD detecting speech. Toast it; the
|
||||
// stopRecording path below will skip Whisper based on vadSawSpeech.
|
||||
useToastStore().show('No speech detected', 'warning')
|
||||
},
|
||||
})
|
||||
|
||||
// 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 +192,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 +232,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 +312,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 +474,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 +590,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,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;
|
||||
|
||||
@@ -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>—</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 }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { MicVAD } from '@ricky0123/vad-web'
|
||||
|
||||
export interface UseVadOptions {
|
||||
/** Called when VAD detects a real speech-end event after the grace period. */
|
||||
onSpeechEnd: () => void
|
||||
/** Called when stopAndCheck() is invoked but no speech was ever detected. */
|
||||
onNoSpeech: () => void
|
||||
/** Minimum ms between speech-start and speech-end before the end event fires. */
|
||||
graceMs?: number
|
||||
/** Minimum total recording duration before speech-end can fire. */
|
||||
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) {
|
||||
// VAD init failed — the user can still manual-stop. Log for debugging.
|
||||
console.error('VAD init failed:', 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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -46,6 +46,17 @@ class RssItem(Base):
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Truncated to 2000 chars to keep DB size reasonable
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
# Full trafilatura-extracted article body, populated lazily on first
|
||||
# discuss-click / enrichment pass. Nullable — most items never get this
|
||||
# cached. Expires naturally with the item (90-day retention).
|
||||
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Map-reduced conversation-ready context derived from content_full. See
|
||||
# services/article_context.py — populated on first discuss click so
|
||||
# repeat clicks skip both the fetch and the LLM map step.
|
||||
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_fetched_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -55,6 +66,13 @@ class RssItem(Base):
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Note persisting the first-click discussion summary. Set by the article
|
||||
# discussion pipeline once the seeded chat completes its first assistant
|
||||
# reply; links back into RAG so re-discussing the same article lands the
|
||||
# prior summary in context.
|
||||
discussion_note_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@@ -330,10 +330,13 @@ async def reset_today_briefing():
|
||||
driven from the MCP when iterating on prompts. Pair with
|
||||
``POST /api/briefing/trigger`` to immediately regenerate.
|
||||
"""
|
||||
from datetime import date as _date
|
||||
from sqlalchemy import delete as _delete
|
||||
|
||||
today = _date.today()
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
# User-local briefing day (flips at 4am local), not ``date.today()`` —
|
||||
# see services/tz.py for rationale.
|
||||
today = await user_briefing_date(g.user.id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
@@ -529,25 +532,21 @@ async def discuss_article(item_id: int):
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
article_content = await _fetch_full_article(item.url) or item.content or ""
|
||||
# Shared helper handles the three-layer cache (context_prepared →
|
||||
# content_full → fresh fetch), writes the synthetic read_article tool
|
||||
# exchange and the conversational seed user prompt into the conversation.
|
||||
# The /news from-article route calls the same helper so behavior stays
|
||||
# byte-identical across entry points.
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
# Store synthetic assistant message with read_article tool result
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
# Store user message
|
||||
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
@@ -569,15 +568,13 @@ async def discuss_article(item_id: int):
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
"Please summarize and discuss this article.",
|
||||
discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
|
||||
@@ -510,47 +510,78 @@ async def delete_model_route():
|
||||
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_from_article(item_id: int):
|
||||
"""Create a chat conversation seeded with an RSS article's content."""
|
||||
"""Create a chat conversation seeded for article discussion and auto-run.
|
||||
|
||||
Mirrors the briefing ``discuss_article`` route: creates a fresh
|
||||
conversation, stages the shared synthetic read_article exchange + seed
|
||||
prompt, then kicks off generation so the client lands on an in-flight
|
||||
stream. The Flutter and web chat screens reconnect to the running buffer
|
||||
on mount.
|
||||
"""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem, RssFeed.title.label("feed_title"))
|
||||
_select(RssItem)
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
row = result.first()
|
||||
item = result.scalars().first()
|
||||
|
||||
if row is None:
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
item, feed_title = row
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
source = feed_title or "News"
|
||||
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
|
||||
seeded_text = f"**{source}**\n\n**{item.title}**"
|
||||
if content_body:
|
||||
seeded_text += f"\n\n{content_body}"
|
||||
if item.url:
|
||||
seeded_text += f"\n\nSource: {item.url}"
|
||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
# Roll back the empty conversation so the user doesn't end up with a
|
||||
# phantom entry in their chat list.
|
||||
try:
|
||||
await delete_conversation(uid, conv.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to clean up empty article conversation %s", conv.id)
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
async with _async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=seeded_text,
|
||||
msg_metadata={"rss_item_ids": [item_id]},
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
# Reload conversation so we see the two messages the helper just added.
|
||||
conv = await get_conversation(uid, conv.id)
|
||||
assert conv is not None
|
||||
|
||||
return jsonify({"conversation_id": conv.id}), 201
|
||||
history: list[dict] = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict: dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
assistant_msg = await add_message(conv.id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv.id, assistant_msg.id)
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model, uid, conv.id, conv.title or "", discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"conversation_id": conv.id,
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Prepare article bodies as conversation-ready context.
|
||||
|
||||
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
|
||||
A raw trafilatura extraction is often too large to drop whole into a chat
|
||||
history without eating the context window, so this module runs a map-reduce
|
||||
step over oversized articles and returns a compact, structured context that
|
||||
still preserves the article's meaning across sections.
|
||||
|
||||
Small articles pass through unchanged — map-reduce only fires when the raw
|
||||
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
|
||||
by the caller, so repeat discuss-clicks on the same article skip this work
|
||||
entirely.
|
||||
|
||||
The module also owns ``seed_article_discussion``, the shared routine that
|
||||
stages a synthetic ``read_article`` tool exchange plus a conversational seed
|
||||
prompt into a conversation. Both the briefing and ``/news`` entry points call
|
||||
it so the two flows stay byte-identical — the only thing that differs between
|
||||
them is whether the conversation already existed or was freshly created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.chat import add_message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
|
||||
# with room left for system prompt, chat history, and the assistant reply.
|
||||
CHAR_BUDGET = 48_000
|
||||
|
||||
# Chunk size for the map step on oversized articles. Overlap preserves
|
||||
# context across paragraph boundaries that happen to land mid-sentence.
|
||||
CHUNK_CHARS = 8_000
|
||||
CHUNK_OVERLAP = 400
|
||||
|
||||
_PARA_SPLIT = re.compile(r"\n\s*\n")
|
||||
|
||||
|
||||
def _chunk_by_paragraph(body: str) -> list[str]:
|
||||
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
|
||||
|
||||
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
|
||||
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
|
||||
a sentence straddling the boundary stays readable on both sides.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_len = 0
|
||||
for para in paragraphs:
|
||||
para_len = len(para)
|
||||
if para_len > CHUNK_CHARS:
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current, current_len = [], 0
|
||||
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
|
||||
chunks.append(para[i : i + CHUNK_CHARS])
|
||||
continue
|
||||
if current_len + para_len + 2 > CHUNK_CHARS and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
|
||||
current = [tail, para] if tail else [para]
|
||||
current_len = len(tail) + para_len + (2 if tail else 0)
|
||||
else:
|
||||
current.append(para)
|
||||
current_len += para_len + 2
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
|
||||
"""Map-step summary of one article chunk.
|
||||
|
||||
Aims for ~300 words of dense, factual prose — not bullet points — so the
|
||||
downstream chat model can quote from it naturally.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are summarizing one section of a larger article so a downstream "
|
||||
"conversation model can discuss the full article without having to read "
|
||||
"every word.\n\n"
|
||||
"Requirements:\n"
|
||||
"- 250–350 words of dense factual prose\n"
|
||||
"- Preserve specific claims, numbers, names, and quotes\n"
|
||||
"- Do NOT editorialize or add analysis\n"
|
||||
"- Do NOT use bullet points or headings\n"
|
||||
"- Do NOT say 'this section' or 'this article' — write content, not meta"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Article: {title}\n"
|
||||
f"Section {index + 1} of {total}:\n\n{chunk}"
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
# Pin num_ctx — same rationale as services/research.py:66. A large
|
||||
# chunk plus system prompt can push well past the default window;
|
||||
# silent truncation here would drop the tail of the chunk without
|
||||
# any error, producing a misleading summary.
|
||||
raw = await generate_completion(
|
||||
messages, model, max_tokens=600, num_ctx=16384
|
||||
)
|
||||
return raw.strip()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Article chunk summary failed for section %d/%d of '%s'",
|
||||
index + 1, total, title, exc_info=True,
|
||||
)
|
||||
# Fall back to the raw chunk truncated to ~1500 chars so the overall
|
||||
# pipeline still delivers something rather than dropping the section.
|
||||
return chunk[:1500]
|
||||
|
||||
|
||||
async def prepare_article_context(
|
||||
title: str,
|
||||
url: str,
|
||||
body: str,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Return a conversation-ready context block for ``body``.
|
||||
|
||||
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
|
||||
- Oversized article: runs a parallel map step over paragraph-aware
|
||||
chunks and concatenates the summaries under section headers.
|
||||
|
||||
The returned string is what should go into the ``read_article`` synthetic
|
||||
tool-result in chat history. Callers are responsible for caching it to
|
||||
``rss_items.context_prepared``.
|
||||
"""
|
||||
body = body or ""
|
||||
if len(body) <= CHAR_BUDGET:
|
||||
return body
|
||||
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
logger.info(
|
||||
"Article '%s' is %d chars, map-reducing into %d chunks",
|
||||
title, len(body), len(chunks),
|
||||
)
|
||||
|
||||
summaries = await asyncio.gather(
|
||||
*[
|
||||
_summarize_chunk(title, chunk, i, len(chunks), model)
|
||||
for i, chunk in enumerate(chunks)
|
||||
]
|
||||
)
|
||||
|
||||
header = (
|
||||
f"(This article was longer than the chat window could hold verbatim, "
|
||||
f"so the full text was split into {len(chunks)} sections and each was "
|
||||
"summarized below. Each section preserves specific claims, numbers, "
|
||||
"and quotes from the original.)\n\n"
|
||||
)
|
||||
parts = [
|
||||
f"## Section {i + 1}\n\n{summary}"
|
||||
for i, summary in enumerate(summaries)
|
||||
]
|
||||
return header + "\n\n".join(parts)
|
||||
|
||||
|
||||
# Conversational seed prompt for article discussions. Kept here so both the
|
||||
# briefing and /news entry points use the exact same wording. See
|
||||
# feedback_discuss_prompt_style memory: numbered checklists produce
|
||||
# assignment-completion responses; this conversational seed opens a dialogue.
|
||||
ARTICLE_DISCUSS_SEED = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
|
||||
|
||||
class EmptyArticleError(Exception):
|
||||
"""Raised when an article has no extractable body text.
|
||||
|
||||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||||
user sees a clear error instead of a hallucinated summary built from an
|
||||
empty synthetic tool result.
|
||||
"""
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Stage the synthetic read_article tool exchange + conversational seed.
|
||||
|
||||
Used by both the briefing ``discuss_article`` route and the ``/news``
|
||||
``from-article`` conversation creator. Handles the three-layer cache
|
||||
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
|
||||
messages into ``conv_id``:
|
||||
|
||||
1. An assistant message with a synthetic ``read_article`` tool_call whose
|
||||
``result.content`` carries the prepared article context. The message
|
||||
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
|
||||
hook in ``generation_task.py`` can locate it and persist the first
|
||||
reply as a discussion-summary Note.
|
||||
2. A user message with the shared conversational seed prompt.
|
||||
|
||||
Returns the seed prompt string so callers can pass it to ``run_generation``
|
||||
as ``user_content``.
|
||||
"""
|
||||
# Avoid circulars: rss helper imports article_context indirectly nowhere,
|
||||
# but keep this local for symmetry with the route-level imports it
|
||||
# replaces.
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
if not raw_body.strip():
|
||||
# Hard-fail rather than stage an empty synthetic tool result.
|
||||
# An empty `content` field silently tells the model "the article
|
||||
# has nothing in it" and it confabulates from RAG/history. Better
|
||||
# to surface a clean error to the user.
|
||||
logger.warning(
|
||||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||||
item.id, item.url,
|
||||
)
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if not article_content.strip():
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(
|
||||
conv_id,
|
||||
"assistant",
|
||||
"",
|
||||
status="complete",
|
||||
tool_calls=synthetic_tool_calls,
|
||||
msg_metadata={"rss_item_id": item.id, "article_seed": True},
|
||||
)
|
||||
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
|
||||
return ARTICLE_DISCUSS_SEED
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +16,10 @@ async def get_or_create_today_conversation(user_id: int, model: str) -> Conversa
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
today = date.today()
|
||||
# "Today" is the user-local briefing day (flips at 4am local), not
|
||||
# ``date.today()`` — in a UTC container the latter rolls over at
|
||||
# 19:00 NY local and makes the in-progress briefing disappear.
|
||||
today = await user_briefing_date(user_id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
|
||||
@@ -445,9 +445,12 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.tz import user_today
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
yesterday = (date.today() - timedelta(days=1))
|
||||
# User-local "yesterday" so closeout always targets the day that just
|
||||
# ended in the user's timezone, regardless of container TZ.
|
||||
yesterday = (await user_today(user_id)) - timedelta(days=1)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
|
||||
@@ -187,6 +187,7 @@ async def add_message(
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
msg_metadata: dict | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
@@ -199,6 +200,8 @@ async def add_message(
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
if msg_metadata is not None:
|
||||
kwargs["msg_metadata"] = msg_metadata
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
|
||||
@@ -36,71 +36,84 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
async def _maybe_save_article_discussion_note(
|
||||
user_id: int, conv_id: int, reply_content: str,
|
||||
) -> None:
|
||||
"""Persist a seeded article-discussion's first reply as a Note.
|
||||
|
||||
# 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
|
||||
)
|
||||
Fires after ``run_generation`` completes. Looks for a synthetic
|
||||
read_article seed message on the conversation; if found AND the linked
|
||||
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
|
||||
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
|
||||
the same article are a no-op (already linked).
|
||||
|
||||
# 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.
|
||||
Failures are logged and swallowed — the chat UI should never break because
|
||||
Note persistence hit a snag.
|
||||
"""
|
||||
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
|
||||
try:
|
||||
if not reply_content or not reply_content.strip():
|
||||
return
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models.conversation import Message as _Message
|
||||
from fabledassistant.models.rss_feed import RssItem as _RssItem
|
||||
from fabledassistant.services.notes import create_note
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(_Message)
|
||||
.where(_Message.conversation_id == conv_id)
|
||||
.order_by(_Message.id.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
seed_meta = None
|
||||
for m in messages:
|
||||
meta = m.msg_metadata or {}
|
||||
if meta.get("article_seed") and meta.get("rss_item_id"):
|
||||
seed_meta = meta
|
||||
break
|
||||
if seed_meta is None:
|
||||
return
|
||||
item_id = int(seed_meta["rss_item_id"])
|
||||
item = await session.get(_RssItem, item_id)
|
||||
if item is None or item.discussion_note_id is not None:
|
||||
return
|
||||
article_title = (item.title or "Untitled article").strip()
|
||||
article_url = item.url
|
||||
article_topics = list(item.topics or [])
|
||||
|
||||
note_title = f"Article: {article_title}"[:200]
|
||||
body_parts = [f"**Source:** {article_url}"] if article_url else []
|
||||
body_parts.append(reply_content.strip())
|
||||
note_body = "\n\n".join(body_parts)
|
||||
tags = ["article-summary"] + [t for t in article_topics if t]
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
tags=tags,
|
||||
entity_meta={
|
||||
"source": "article_discussion",
|
||||
"rss_item_id": item_id,
|
||||
"url": article_url,
|
||||
"conversation_id": conv_id,
|
||||
},
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(_RssItem, item_id)
|
||||
if fresh is not None and fresh.discussion_note_id is None:
|
||||
fresh.discussion_note_id = note.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
|
||||
note.id, item_id, conv_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist article-discussion note for conv %d",
|
||||
conv_id, exc_info=True,
|
||||
)
|
||||
|
||||
# Human-readable labels for each tool, shown in the status indicator
|
||||
_TOOL_LABELS: dict[str, str] = {
|
||||
@@ -289,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 = {
|
||||
@@ -331,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
|
||||
@@ -429,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:
|
||||
@@ -463,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))
|
||||
@@ -526,10 +586,35 @@ async def run_generation(
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
# Persist article-discussion seed conversations as a Note on their
|
||||
# first assistant reply. This makes "Discuss" summaries part of RAG
|
||||
# so the knowledge base stops being amnesiac about articles the user
|
||||
# has already engaged with. The hook detects a seeded conversation by
|
||||
# finding a synthetic read_article assistant message whose
|
||||
# msg_metadata carries ``article_seed: True`` and whose rss_items row
|
||||
# has no discussion_note_id yet. Fire-and-forget so the done event
|
||||
# lands immediately.
|
||||
asyncio.create_task(_maybe_save_article_discussion_note(
|
||||
user_id, conv_id, buf.content_so_far,
|
||||
))
|
||||
|
||||
if should_gen_title:
|
||||
title_messages = messages + [
|
||||
{"role": "assistant", "content": buf.content_so_far}
|
||||
# Feed the title model the *raw* conversation turns only — never
|
||||
# the post-build_context ``messages`` list. ``build_context``
|
||||
# prepends RAG snippets, RSS excerpts, URL content, and briefing
|
||||
# article dumps INTO the user message string itself, so filtering
|
||||
# by role="user" downstream still surfaces that noise as the
|
||||
# "user's message". That pollution caused wildly-wrong titles
|
||||
# (bug #109) — the small background model was staring at article
|
||||
# excerpts instead of what the user actually typed. Pass the
|
||||
# original history + the raw user_content + the assistant reply.
|
||||
title_messages: list[dict] = [
|
||||
{"role": m["role"], "content": m.get("content") or ""}
|
||||
for m in history
|
||||
if m.get("role") in ("user", "assistant")
|
||||
]
|
||||
title_messages.append({"role": "user", "content": user_content})
|
||||
title_messages.append({"role": "assistant", "content": buf.content_so_far})
|
||||
|
||||
async def _bg_title() -> None:
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -317,9 +336,17 @@ async def generate_completion(
|
||||
num_ctx overrides the model's context window for this call only.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
options: dict = {"num_predict": max_tokens}
|
||||
if num_ctx is not None:
|
||||
options["num_ctx"] = num_ctx
|
||||
# Default num_ctx to Config.OLLAMA_NUM_CTX (matching stream_chat /
|
||||
# stream_chat_with_tools). Without this, Ollama silently uses the model's
|
||||
# default window (~4k on qwen3) and truncates anything longer. That is
|
||||
# how the research pipeline's outline step kept falling back to a single
|
||||
# monolith note: its 12-source prompt is ~6k tokens and was being chopped
|
||||
# before the model ever saw it. Non-streaming callers must not inherit
|
||||
# that footgun — if you truly want the model default, pass num_ctx=0.
|
||||
options: dict = {
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": num_ctx if num_ctx is not None else Config.OLLAMA_NUM_CTX,
|
||||
}
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
delay = 3.0 * attempt
|
||||
@@ -560,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)."
|
||||
@@ -650,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:
|
||||
@@ -716,18 +751,27 @@ async def build_context(
|
||||
orphan_only = rag_project_id is None
|
||||
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
|
||||
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
# Skip RAG auto-injection on the first turn of a seeded article discussion.
|
||||
# The article body is already the sole context the user wants — pulling in
|
||||
# unrelated orphan notes tricks the model into summarizing those instead.
|
||||
# Follow-up turns keep RAG on because by then the user's own messages drive
|
||||
# the query rather than the generic seed prompt.
|
||||
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
|
||||
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
|
||||
|
||||
if not found_scored:
|
||||
if not _skip_rag_for_article_seed:
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
|
||||
if not found_scored and not _skip_rag_for_article_seed:
|
||||
keywords = _extract_keywords(user_message)
|
||||
if keywords:
|
||||
try:
|
||||
|
||||
@@ -63,7 +63,17 @@ async def _generate_outline(topic: str, sources: list[dict], model: str) -> list
|
||||
]
|
||||
for attempt in range(2):
|
||||
try:
|
||||
raw = await generate_completion(messages, model, max_tokens=400)
|
||||
# Pin num_ctx explicitly. The prompt carries up to 12 sources at
|
||||
# 2000 chars each (~6k tokens of source material alone) plus the
|
||||
# system prompt — well over Ollama's default model window on
|
||||
# qwen3. Without this, Ollama silently truncates the prompt, the
|
||||
# model can't see most of the sources, JSON parsing fails twice,
|
||||
# and the pipeline falls back to a single monolith note
|
||||
# (`research.py:251`). Do not remove even if `generate_completion`
|
||||
# appears to default this — see the comment there.
|
||||
raw = await generate_completion(
|
||||
messages, model, max_tokens=400, num_ctx=16384
|
||||
)
|
||||
raw = raw.strip()
|
||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||
raw = re.sub(r"\s*```$", "", raw)
|
||||
@@ -161,7 +171,13 @@ async def _generate_executive_summary(
|
||||
},
|
||||
]
|
||||
try:
|
||||
raw = await generate_completion(messages, model, max_tokens=600)
|
||||
# Pin num_ctx explicitly — see `_generate_outline` comment for the
|
||||
# rationale. This prompt carries N sections × 1500 chars of section
|
||||
# prose, which can easily exceed the default model window. Don't
|
||||
# trust the `generate_completion` default to stick.
|
||||
raw = await generate_completion(
|
||||
messages, model, max_tokens=600, num_ctx=16384
|
||||
)
|
||||
return raw.strip()
|
||||
except Exception:
|
||||
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
|
||||
|
||||
@@ -34,6 +34,34 @@ def _html_to_text(html: str) -> str:
|
||||
return html
|
||||
|
||||
|
||||
async def get_or_fetch_full_article(item: RssItem) -> str | None:
|
||||
"""Return the full article body, fetching+caching on miss.
|
||||
|
||||
Checks ``item.content_full`` first — populated either by the enrichment
|
||||
pass at feed-ingest time or by a previous discuss-click. On miss, fetches
|
||||
via ``_fetch_full_article`` and writes through. Returns ``None`` only if
|
||||
the fetch itself fails; ``item.content_full == ""`` is still a cache hit.
|
||||
|
||||
Callers must pass an RssItem attached to an open session if they want
|
||||
the write-through to persist — otherwise the fetched text is returned
|
||||
but the cache stays empty and the next click will re-fetch.
|
||||
"""
|
||||
if item.content_full is not None:
|
||||
return item.content_full
|
||||
if not item.url:
|
||||
return None
|
||||
text = await _fetch_full_article(item.url)
|
||||
if text is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.content_full = text
|
||||
fresh.content_fetched_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return text
|
||||
|
||||
|
||||
async def _fetch_full_article(url: str) -> str | None:
|
||||
"""Fetch a URL and extract its main article text via trafilatura.
|
||||
|
||||
@@ -209,6 +237,11 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
item.content = full_text
|
||||
# Populate the discuss-click cache too so the
|
||||
# first click skips straight to the map-reduce
|
||||
# step without re-fetching.
|
||||
item.content_full = full_text
|
||||
item.content_fetched_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await upsert_rss_item_embedding(
|
||||
item_id, feed_user_id, item.title or "", item.content
|
||||
|
||||
@@ -14,15 +14,37 @@ from fabledassistant.services.events import (
|
||||
)
|
||||
from fabledassistant.services.tools._helpers import resolve_project
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
from fabledassistant.services.tz import get_user_tz
|
||||
|
||||
|
||||
async def _parse_datetime_in_user_tz(
|
||||
user_id: int, value: str
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Parse a date/datetime string from the model into a UTC-aware datetime.
|
||||
|
||||
Naive inputs are interpreted in the **user's local timezone** and then
|
||||
converted to UTC for storage. Never default to UTC for naive inputs —
|
||||
that's how all-day events landed on the wrong day for non-UTC users.
|
||||
|
||||
Returns ``(utc_datetime, was_date_only)``.
|
||||
"""
|
||||
was_date_only = "T" not in value and " " not in value
|
||||
if was_date_only:
|
||||
value = f"{value}T00:00:00"
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
user_tz = await get_user_tz(user_id)
|
||||
dt = dt.replace(tzinfo=user_tz)
|
||||
return dt.astimezone(timezone.utc), was_date_only
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_event",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "A descriptive event title"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
||||
"description": {"type": "string", "description": "Optional event description"},
|
||||
"location": {"type": "string", "description": "Optional event location"},
|
||||
@@ -40,23 +62,21 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
if "T" not in start_str and " " not in start_str:
|
||||
all_day = True
|
||||
start_str = f"{start_str}T00:00:00"
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_str)
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
# Naive dates/datetimes are interpreted in the user's local
|
||||
# timezone, not UTC. Storing UTC for naive inputs caused all-day
|
||||
# events to land on the previous day for negative-offset users.
|
||||
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
|
||||
user_id, start_str
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||
if start_was_date_only:
|
||||
all_day = True
|
||||
end_dt = None
|
||||
if end_str:
|
||||
if "T" not in end_str and " " not in end_str:
|
||||
end_str = f"{end_str}T00:00:00"
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_str)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||
project_id = None
|
||||
@@ -86,25 +106,30 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="list_events",
|
||||
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.",
|
||||
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
|
||||
parameters={
|
||||
"date_from": {"type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day."},
|
||||
"date_to": {"type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."},
|
||||
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
|
||||
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
|
||||
},
|
||||
required=["date_from", "date_to"],
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def list_events_tool(*, user_id, arguments, **_ctx):
|
||||
# Bare local dates are expanded to a full local day in the user's TZ:
|
||||
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
|
||||
# UTC before the DB query. Previously the tool description told the
|
||||
# model to pass UTC ranges, which missed events for non-UTC users.
|
||||
try:
|
||||
date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00"))
|
||||
date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00"))
|
||||
date_from, _ = await _parse_datetime_in_user_tz(
|
||||
user_id, arguments["date_from"]
|
||||
)
|
||||
date_to_str = arguments["date_to"]
|
||||
if "T" not in date_to_str and " " not in date_to_str:
|
||||
date_to_str = f"{date_to_str}T23:59:59"
|
||||
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
return {"success": False, "error": f"Invalid date range: {exc}"}
|
||||
if date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=timezone.utc)
|
||||
if date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=timezone.utc)
|
||||
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -177,9 +202,9 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
try:
|
||||
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
# Naive datetimes are user-local, not UTC — see
|
||||
# ``_parse_datetime_in_user_tz`` docstring.
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
|
||||
fields[dt_field] = dt
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User-timezone helpers.
|
||||
|
||||
All datetimes in the DB are stored as UTC. The helpers here bridge between
|
||||
that UTC storage and the user's configured local timezone (IANA string in
|
||||
the ``user_timezone`` setting). Use these anywhere the model or UI talks
|
||||
in terms of "today", "tomorrow", or a bare calendar date — never
|
||||
``date.today()`` or ``datetime.now(timezone.utc)`` inside a per-user flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
# Briefing day boundary — kept in sync with the compilation slot in
|
||||
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
|
||||
# (not midnight) so the 00:00–04:00 local window still shows yesterday's
|
||||
# briefing until the 4am compilation generates the new one.
|
||||
BRIEFING_DAY_START_HOUR = 4
|
||||
|
||||
|
||||
async def get_user_tz(user_id: int) -> ZoneInfo:
|
||||
"""Return the user's IANA ``ZoneInfo``, falling back to UTC."""
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
async def user_today(user_id: int) -> date:
|
||||
"""Return today's calendar date in the user's local timezone."""
|
||||
tz = await get_user_tz(user_id)
|
||||
return datetime.now(tz).date()
|
||||
|
||||
|
||||
async def user_briefing_date(user_id: int) -> date:
|
||||
"""Return the current "briefing day" in the user's local timezone.
|
||||
|
||||
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
|
||||
aligned with the compilation slot that generates the day's briefing.
|
||||
Between 00:00 and 04:00 local this still returns *yesterday*, so the
|
||||
UI keeps showing the in-progress briefing until the new one is built.
|
||||
"""
|
||||
tz = await get_user_tz(user_id)
|
||||
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
|
||||
@@ -134,3 +134,83 @@ def test_history_builder_no_tool_calls_unchanged():
|
||||
assert len(history) == 2
|
||||
assert history[0] == {"role": "user", "content": "Hello"}
|
||||
assert history[1] == {"role": "assistant", "content": "Hi there!"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_article_context tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_article_context_small_passthrough():
|
||||
"""Articles under CHAR_BUDGET pass through unchanged with zero LLM calls."""
|
||||
from fabledassistant.services import article_context
|
||||
|
||||
body = "A short article.\n\nWith two paragraphs."
|
||||
with patch(
|
||||
"fabledassistant.services.article_context.generate_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_gen:
|
||||
out = await article_context.prepare_article_context(
|
||||
"Title", "https://example.com", body, "test-model",
|
||||
)
|
||||
|
||||
assert out == body
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_article_context_large_runs_map_reduce():
|
||||
"""Articles over CHAR_BUDGET are chunked and map-reduced via the background model."""
|
||||
from fabledassistant.services import article_context
|
||||
|
||||
# CHAR_BUDGET is 48_000 — build a body well over that with paragraph breaks
|
||||
# so the chunker has natural splits to work with.
|
||||
paragraph = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 40).strip()
|
||||
body = "\n\n".join([paragraph] * 30) # ~70k+ chars across 30 paragraphs
|
||||
assert len(body) > article_context.CHAR_BUDGET
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.article_context.generate_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Summary of this section with specific claims preserved.",
|
||||
) as mock_gen:
|
||||
out = await article_context.prepare_article_context(
|
||||
"Long Article", "https://example.com/long", body, "test-model",
|
||||
)
|
||||
|
||||
# At least one LLM call fired (the map step ran)
|
||||
assert mock_gen.await_count >= 1
|
||||
# Output carries the oversized-article header and section markers
|
||||
assert "longer than the chat window" in out
|
||||
assert "## Section 1" in out
|
||||
# Map output is much smaller than the raw body
|
||||
assert len(out) < len(body)
|
||||
|
||||
|
||||
def test_chunk_by_paragraph_respects_boundaries():
|
||||
"""Chunker splits on paragraph breaks, not mid-sentence."""
|
||||
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
|
||||
|
||||
paragraphs = [f"Paragraph {i}. " + ("x" * 1000) for i in range(20)]
|
||||
body = "\n\n".join(paragraphs)
|
||||
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
|
||||
# Each chunk stays under the budget
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= CHUNK_CHARS
|
||||
# Total content is preserved (modulo overlap duplication, so ≥ original)
|
||||
assert sum(len(c) for c in chunks) >= len(body) * 0.9
|
||||
|
||||
|
||||
def test_chunk_by_paragraph_handles_oversized_paragraph():
|
||||
"""A single paragraph larger than CHUNK_CHARS gets split mid-paragraph."""
|
||||
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
|
||||
|
||||
body = "x" * (CHUNK_CHARS * 3) # one huge paragraph, no breaks
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
|
||||
assert len(chunks) >= 3
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= CHUNK_CHARS
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Regression tests: calendar tool respects the user's configured timezone."""
|
||||
|
||||
from datetime import timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_all_day_bare_date_interprets_local_midnight():
|
||||
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
|
||||
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
|
||||
be 2026-09-29 19:00 NY and land on the wrong day)."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
start_dt = captured["start_dt"]
|
||||
assert start_dt.tzinfo is not None
|
||||
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
|
||||
utc = start_dt.astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
|
||||
assert utc.hour == 4 # EDT offset; would be 5 in EST
|
||||
assert captured["all_day"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_naive_datetime_is_user_local():
|
||||
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_explicit_offset_is_respected():
|
||||
"""Model-supplied timezone offsets must not be overridden by user TZ."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.hour, utc.minute) == (14, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_bare_date_range_covers_local_day():
|
||||
"""date_from/date_to as bare dates should cover the full LOCAL day."""
|
||||
from fabledassistant.services.tools.calendar import list_events_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_list_events(*, user_id, date_from, date_to):
|
||||
captured["date_from"] = date_from
|
||||
captured["date_to"] = date_to
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_list_events",
|
||||
side_effect=fake_list_events,
|
||||
):
|
||||
await list_events_tool(
|
||||
user_id=1,
|
||||
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
|
||||
)
|
||||
|
||||
df = captured["date_from"].astimezone(timezone.utc)
|
||||
dt = captured["date_to"].astimezone(timezone.utc)
|
||||
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
|
||||
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||
@@ -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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for user-timezone helpers (services/tz.py)."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_tz_returns_configured_zone():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="America/New_York")):
|
||||
zone = await tz_mod.get_user_tz(1)
|
||||
assert zone == ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_tz_falls_back_to_utc_on_bad_input():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="Not/AZone")):
|
||||
zone = await tz_mod.get_user_tz(1)
|
||||
assert zone == ZoneInfo("UTC")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_before_4am_returns_yesterday():
|
||||
"""00:00–03:59 local still shows yesterday's briefing day."""
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
|
||||
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
|
||||
|
||||
class _FakeDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fake_now
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
assert day.isoformat() == "2026-04-12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_after_4am_returns_today():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
fake_now = datetime(2026, 4, 13, 4, 30, tzinfo=ny)
|
||||
|
||||
class _FakeDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fake_now
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
assert day.isoformat() == "2026-04-13"
|
||||
Reference in New Issue
Block a user