Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 |
@@ -114,6 +114,20 @@ const transcribingVoice = ref(false)
|
|||||||
const recorder = useVoiceRecorder()
|
const recorder = useVoiceRecorder()
|
||||||
const silenceDetector = useSilenceDetector()
|
const silenceDetector = useSilenceDetector()
|
||||||
|
|
||||||
|
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||||
|
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for
|
||||||
|
// visibility). The button itself stays put so the mic icon is always
|
||||||
|
// legible on top. 0.2 baseline breathes on silence; loud speech drives
|
||||||
|
// the disc out to ~2.6× the button size with a softer radial fade.
|
||||||
|
const micGlowStyle = computed(() => {
|
||||||
|
if (!recorder.recording.value) return { display: 'none' }
|
||||||
|
const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8
|
||||||
|
return {
|
||||||
|
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||||
|
opacity: String(0.45 + pulse * 0.4),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function toggleVoice() {
|
async function toggleVoice() {
|
||||||
if (transcribingVoice.value) return
|
if (transcribingVoice.value) return
|
||||||
if (recorder.recording.value) {
|
if (recorder.recording.value) {
|
||||||
@@ -230,23 +244,25 @@ defineExpose({ focus, prefill })
|
|||||||
class="input-textarea"
|
class="input-textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<!-- PTT mic -->
|
<!-- PTT mic (with live amplitude halo behind) -->
|
||||||
<button
|
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||||
v-if="voiceEnabled"
|
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||||
class="btn-icon btn-mic"
|
<button
|
||||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
class="btn-icon btn-mic"
|
||||||
@click.prevent="toggleVoice"
|
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||||
:disabled="transcribingVoice || !store.chatReady"
|
@click.prevent="toggleVoice"
|
||||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
:disabled="transcribingVoice || !store.chatReady"
|
||||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
: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">
|
<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"/>
|
<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>
|
||||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
<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"/>
|
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Abort (streaming) or Send -->
|
<!-- Abort (streaming) or Send -->
|
||||||
<button
|
<button
|
||||||
@@ -343,9 +359,50 @@ defineExpose({ focus, prefill })
|
|||||||
.btn-icon:hover { opacity: 1; }
|
.btn-icon:hover { opacity: 1; }
|
||||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
.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; }
|
.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-wrapper { position: relative; }
|
||||||
.note-picker-dropdown {
|
.note-picker-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,15 +1,54 @@
|
|||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue'
|
||||||
|
|
||||||
export interface SilenceDetectorOptions {
|
export interface SilenceDetectorOptions {
|
||||||
thresholdDb?: number // default -40
|
/**
|
||||||
silenceDurationMs?: number // default 1500
|
* Absolute fallback silence threshold in dBFS, used during the first
|
||||||
minRecordingMs?: number // default 500
|
* moments before any real speech has been observed. Once the session peak
|
||||||
|
* clears `dynamicArmDb` we switch to the dynamic threshold instead.
|
||||||
|
*/
|
||||||
|
fallbackThresholdDb?: number // default -45
|
||||||
|
/** How many dB below the session peak counts as "silent" once armed. */
|
||||||
|
dropFromPeakDb?: number // default 15
|
||||||
|
/**
|
||||||
|
* Session peak must reach this level (dBFS) before dynamic thresholding
|
||||||
|
* kicks in. Until then the fallback threshold is used so we don't lock
|
||||||
|
* onto a noise-floor peak.
|
||||||
|
*/
|
||||||
|
dynamicArmDb?: number // default -25
|
||||||
|
/** How long to wait at the start of recording before running silence checks. */
|
||||||
|
graceMs?: number // default 1500
|
||||||
|
silenceDurationMs?: number // default 2000
|
||||||
|
minRecordingMs?: number // default 500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mic silence detector + live amplitude signal.
|
||||||
|
*
|
||||||
|
* Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space,
|
||||||
|
* then derives dBFS for the silence threshold. The previous implementation
|
||||||
|
* ran RMS over `getByteFrequencyData` bytes — those bytes are already a
|
||||||
|
* dB-scaled quantity, so taking their RMS and re-log'ing it produced
|
||||||
|
* numbers that didn't line up with real dBFS and made any static threshold
|
||||||
|
* unpredictable.
|
||||||
|
*
|
||||||
|
* Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||||
|
* "silent" as "current level is at least [dropFromPeakDb] below peak."
|
||||||
|
* This auto-calibrates to whatever mic and room the user is on. Until the
|
||||||
|
* peak climbs above [dynamicArmDb] we fall back to a conservative static
|
||||||
|
* threshold so a quiet room doesn't spin forever. A grace period at the
|
||||||
|
* start of the recording gives the user time to begin speaking before
|
||||||
|
* silence checks arm.
|
||||||
|
*
|
||||||
|
* `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet
|
||||||
|
* speech still visibly moves UI that binds to it.
|
||||||
|
*/
|
||||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||||
const {
|
const {
|
||||||
thresholdDb = -40,
|
fallbackThresholdDb = -45,
|
||||||
silenceDurationMs = 1500,
|
dropFromPeakDb = 15,
|
||||||
|
dynamicArmDb = -25,
|
||||||
|
graceMs = 1500,
|
||||||
|
silenceDurationMs = 2000,
|
||||||
minRecordingMs = 500,
|
minRecordingMs = 500,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -18,31 +57,43 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
|||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let silenceMs = 0
|
let silenceMs = 0
|
||||||
let startedAt = 0
|
let startedAt = 0
|
||||||
|
let peakDb = -100
|
||||||
|
|
||||||
function start(stream: MediaStream, onSilence: () => void): void {
|
function start(stream: MediaStream, onSilence: () => void): void {
|
||||||
stop()
|
stop()
|
||||||
audioCtx = new AudioContext()
|
audioCtx = new AudioContext()
|
||||||
// Some browsers start AudioContext in "suspended" state — resume so
|
// Some browsers start AudioContext in "suspended" state — resume so
|
||||||
// getByteFrequencyData returns real values instead of all zeros.
|
// the analyser returns real values instead of all zeros.
|
||||||
audioCtx.resume().catch(() => {})
|
audioCtx.resume().catch(() => {})
|
||||||
const source = audioCtx.createMediaStreamSource(stream)
|
const source = audioCtx.createMediaStreamSource(stream)
|
||||||
const analyser = audioCtx.createAnalyser()
|
const analyser = audioCtx.createAnalyser()
|
||||||
analyser.fftSize = 256
|
analyser.fftSize = 1024
|
||||||
source.connect(analyser)
|
source.connect(analyser)
|
||||||
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
const samples = new Float32Array(analyser.fftSize)
|
||||||
silenceMs = 0
|
silenceMs = 0
|
||||||
startedAt = Date.now()
|
startedAt = Date.now()
|
||||||
|
peakDb = -100
|
||||||
|
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(() => {
|
||||||
analyser.getByteFrequencyData(data)
|
analyser.getFloatTimeDomainData(samples)
|
||||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
let sumSq = 0
|
||||||
amplitude.value = rms
|
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
||||||
|
const rms = Math.sqrt(sumSq / samples.length)
|
||||||
|
amplitude.value = Math.min(rms * 5, 1)
|
||||||
|
|
||||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100
|
||||||
if (db < thresholdDb) {
|
if (db > peakDb) peakDb = db
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startedAt
|
||||||
|
if (elapsed < graceMs) return
|
||||||
|
|
||||||
|
const threshold =
|
||||||
|
peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb
|
||||||
|
|
||||||
|
if (db < threshold) {
|
||||||
silenceMs += 100
|
silenceMs += 100
|
||||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) {
|
||||||
stop()
|
stop()
|
||||||
onSilence()
|
onSilence()
|
||||||
}
|
}
|
||||||
@@ -63,6 +114,7 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
|||||||
}
|
}
|
||||||
amplitude.value = 0
|
amplitude.value = 0
|
||||||
silenceMs = 0
|
silenceMs = 0
|
||||||
|
peakDb = -100
|
||||||
}
|
}
|
||||||
|
|
||||||
return { amplitude: readonly(amplitude), start, stop }
|
return { amplitude: readonly(amplitude), start, stop }
|
||||||
|
|||||||
@@ -537,10 +537,16 @@ async def discuss_article(item_id: int):
|
|||||||
# exchange and the conversational seed user prompt into the conversation.
|
# exchange and the conversational seed user prompt into the conversation.
|
||||||
# The /news from-article route calls the same helper so behavior stays
|
# The /news from-article route calls the same helper so behavior stays
|
||||||
# byte-identical across entry points.
|
# byte-identical across entry points.
|
||||||
from fabledassistant.services.article_context import seed_article_discussion
|
from fabledassistant.services.article_context import (
|
||||||
|
EmptyArticleError,
|
||||||
|
seed_article_discussion,
|
||||||
|
)
|
||||||
|
|
||||||
model = await get_setting(uid, "default_model", "") or ""
|
model = await get_setting(uid, "default_model", "") or ""
|
||||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
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
|
# Reload conversation with fresh messages to build history
|
||||||
conv = await get_conversation(uid, conv_id)
|
conv = await get_conversation(uid, conv_id)
|
||||||
|
|||||||
@@ -521,7 +521,10 @@ async def create_conversation_from_article(item_id: int):
|
|||||||
from sqlalchemy import select as _select
|
from sqlalchemy import select as _select
|
||||||
from fabledassistant.models import async_session as _async_session
|
from fabledassistant.models import async_session as _async_session
|
||||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||||
from fabledassistant.services.article_context import seed_article_discussion
|
from fabledassistant.services.article_context import (
|
||||||
|
EmptyArticleError,
|
||||||
|
seed_article_discussion,
|
||||||
|
)
|
||||||
|
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
|
|
||||||
@@ -540,7 +543,16 @@ async def create_conversation_from_article(item_id: int):
|
|||||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||||
|
|
||||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||||
discuss_prompt = await seed_article_discussion(conv.id, item, 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
|
||||||
|
|
||||||
# Reload conversation so we see the two messages the helper just added.
|
# Reload conversation so we see the two messages the helper just added.
|
||||||
conv = await get_conversation(uid, conv.id)
|
conv = await get_conversation(uid, conv.id)
|
||||||
|
|||||||
@@ -182,6 +182,15 @@ ARTICLE_DISCUSS_SEED = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def seed_article_discussion(
|
||||||
conv_id: int,
|
conv_id: int,
|
||||||
item: RssItem,
|
item: RssItem,
|
||||||
@@ -213,15 +222,30 @@ async def seed_article_discussion(
|
|||||||
article_content = item.context_prepared
|
article_content = item.context_prepared
|
||||||
else:
|
else:
|
||||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
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(
|
article_content = await prepare_article_context(
|
||||||
item.title or "", item.url, raw_body, model,
|
item.title or "", item.url, raw_body, model,
|
||||||
)
|
)
|
||||||
if article_content:
|
if not article_content.strip():
|
||||||
async with async_session() as session:
|
raise EmptyArticleError(
|
||||||
fresh = await session.get(RssItem, item.id)
|
"Couldn't extract any readable text from this article."
|
||||||
if fresh is not None:
|
)
|
||||||
fresh.context_prepared = article_content
|
async with async_session() as session:
|
||||||
await session.commit()
|
fresh = await session.get(RssItem, item.id)
|
||||||
|
if fresh is not None:
|
||||||
|
fresh.context_prepared = article_content
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
synthetic_tool_calls = [{
|
synthetic_tool_calls = [{
|
||||||
"function": "read_article",
|
"function": "read_article",
|
||||||
|
|||||||
@@ -724,18 +724,27 @@ async def build_context(
|
|||||||
orphan_only = rag_project_id is None
|
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
|
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
|
||||||
|
|
||||||
try:
|
# Skip RAG auto-injection on the first turn of a seeded article discussion.
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
# The article body is already the sole context the user wants — pulling in
|
||||||
for score, note in await semantic_search_notes(
|
# unrelated orphan notes tricks the model into summarizing those instead.
|
||||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
# Follow-up turns keep RAG on because by then the user's own messages drive
|
||||||
project_id=effective_project_id,
|
# the query rather than the generic seed prompt.
|
||||||
orphan_only=orphan_only,
|
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
|
||||||
):
|
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
|
||||||
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:
|
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)
|
keywords = _extract_keywords(user_message)
|
||||||
if keywords:
|
if keywords:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user