Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 |
@@ -114,6 +114,18 @@ const transcribingVoice = ref(false)
|
|||||||
const recorder = useVoiceRecorder()
|
const recorder = useVoiceRecorder()
|
||||||
const silenceDetector = useSilenceDetector()
|
const silenceDetector = useSilenceDetector()
|
||||||
|
|
||||||
|
// Live mic pulse. `silenceDetector.amplitude` is 0..1 RMS; we floor at 0.1
|
||||||
|
// so the button still breathes on silence and cap the scale growth so loud
|
||||||
|
// input doesn't punch through the input bar.
|
||||||
|
const micStyle = computed(() => {
|
||||||
|
if (!recorder.recording.value) return {}
|
||||||
|
const pulse = 0.1 + Math.min(silenceDetector.amplitude.value, 1) * 0.9
|
||||||
|
return {
|
||||||
|
transform: `scale(${1 + pulse * 0.18})`,
|
||||||
|
boxShadow: `0 0 ${6 + pulse * 14}px ${2 + pulse * 4}px rgba(239, 68, 68, ${0.2 + pulse * 0.3})`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function toggleVoice() {
|
async function toggleVoice() {
|
||||||
if (transcribingVoice.value) return
|
if (transcribingVoice.value) return
|
||||||
if (recorder.recording.value) {
|
if (recorder.recording.value) {
|
||||||
@@ -235,6 +247,7 @@ defineExpose({ focus, prefill })
|
|||||||
v-if="voiceEnabled"
|
v-if="voiceEnabled"
|
||||||
class="btn-icon btn-mic"
|
class="btn-icon btn-mic"
|
||||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||||
|
:style="micStyle"
|
||||||
@click.prevent="toggleVoice"
|
@click.prevent="toggleVoice"
|
||||||
:disabled="transcribingVoice || !store.chatReady"
|
:disabled="transcribingVoice || !store.chatReady"
|
||||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||||
@@ -343,7 +356,14 @@ 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;
|
||||||
|
color: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||||
|
rather than stepping. */
|
||||||
|
transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
|
||||||
|
}
|
||||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||||
|
|
||||||
.note-picker-wrapper { position: relative; }
|
.note-picker-wrapper { position: relative; }
|
||||||
|
|||||||
@@ -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