diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index b96e986..67fe1aa 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -7,6 +7,7 @@ import WeatherCard from '@/components/WeatherCard.vue' import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue' import { apiGet, + apiPost, getBriefingConfig, getBriefingConversations, getBriefingToday, @@ -113,26 +114,19 @@ watch(selectedConvId, async (id) => { } }) -// Keep send() as a helper for discussArticle -async function send(text: string) { - if (!text || !todayConvId.value || chatStore.streaming) return - if (chatStore.currentConversation?.id !== todayConvId.value) { - await chatStore.fetchConversation(todayConvId.value) - } - await chatStore.sendMessage(text, undefined, undefined, true) -} - async function discussArticle(item: NewsItem) { - if (!todayConvId.value) return + if (!todayConvId.value || chatStore.streaming) return if (!isToday.value) selectedConvId.value = todayConvId.value - const body = (item.content || item.snippet || '').trim() - const bodyBlock = body ? `\n\n---\n${body}\n---` : '' - const text = `Please summarize and share your thoughts on this article. Do not use any research or search tools — respond conversationally based only on the content below.\n\n**${item.title}**\nSource: ${item.source}${bodyBlock}` - // Scroll to chat column await nextTick(() => { document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) - await send(text) + try { + await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value }) + } catch { + return + } + await chatStore.fetchConversation(todayConvId.value) + await chatStore.reconnectIfGenerating(todayConvId.value) } // RSS reactions: map of rss_item_id -> 'up' | 'down' | null diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index 518d46d..4c623ef 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -10,7 +10,7 @@ from sqlalchemy import select from fabledassistant.auth import login_required from fabledassistant.models import async_session from fabledassistant.models.conversation import Conversation, Message -from fabledassistant.models.rss_feed import RssFeed +from fabledassistant.models.rss_feed import RssFeed, RssItem from fabledassistant.services import rss as rss_svc from fabledassistant.services import weather as weather_svc from fabledassistant.services.briefing_conversations import ( @@ -18,6 +18,9 @@ from fabledassistant.services.briefing_conversations import ( list_briefing_conversations, post_message, ) +from fabledassistant.services.chat import add_message, get_conversation +from fabledassistant.services.generation_buffer import create_buffer, get_buffer +from fabledassistant.services.generation_task import run_generation from fabledassistant.services.settings import get_setting, set_settings_batch logger = logging.getLogger(__name__) @@ -415,3 +418,90 @@ async def list_news(): for r in rows ] return jsonify({"items": items, "offset": offset, "limit": limit}) + + +# ── Article Discuss ──────────────────────────────────────────────────────────── + +@briefing_bp.route("/articles//discuss", methods=["POST"]) +@_REQUIRE +async def discuss_article(item_id: int): + """Inject article content as a synthetic tool exchange and trigger generation.""" + data = await request.get_json() or {} + conv_id = data.get("conv_id") + if not conv_id: + return jsonify({"error": "conv_id required"}), 400 + + uid = g.user.id + + # Verify item belongs to user via feed ownership + async with async_session() as session: + result = await session.execute( + select(RssItem) + .join(RssFeed, RssFeed.id == RssItem.feed_id) + .where(RssItem.id == item_id, RssFeed.user_id == uid) + ) + item = result.scalars().first() + if item is None: + return jsonify({"error": "Not found"}), 404 + + # Verify conversation belongs to user + conv = await get_conversation(uid, conv_id) + if conv is None: + return jsonify({"error": "Conversation not found"}), 404 + + # Reject if generation already running + if get_buffer(conv_id) is not None: + return jsonify({"error": "Generation already in progress"}), 409 + + article_content = item.content or "" + + # 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.") + + # Reload conversation with fresh messages to build history + conv = await get_conversation(uid, conv_id) + assert conv is not None + + history = [] + for msg in conv.messages: + if msg.role == "system": + continue + msg_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) + + 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.", + think=True, + )) + + return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202