7bd1548f71
Discuss flow was hallucinating unrelated content when article extraction returned empty or RAG pulled in orphan notes that looked more relevant than the generic seed prompt. - seed_article_discussion raises EmptyArticleError on empty body; briefing and /news routes return 422 instead of staging an empty synthetic tool result. - build_context skips RAG auto-injection when user_message matches ARTICLE_DISCUSS_SEED so the article IS the context on turn one; follow-up turns keep RAG on. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
588 lines
22 KiB
Python
588 lines
22 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
import httpx
|
|
from quart import Blueprint, Response, jsonify, request
|
|
|
|
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
|
from fabledassistant.routes.utils import not_found, parse_pagination
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.chat import (
|
|
add_message,
|
|
bulk_delete_conversations,
|
|
create_conversation,
|
|
delete_conversation,
|
|
get_conversation,
|
|
list_conversations,
|
|
save_response_as_note,
|
|
summarize_conversation_as_note,
|
|
update_conversation,
|
|
)
|
|
from fabledassistant.services.generation_buffer import (
|
|
GenerationState,
|
|
create_buffer,
|
|
get_buffer,
|
|
)
|
|
from fabledassistant.services.generation_task import run_generation
|
|
from fabledassistant.services.notes import get_notes_by_ids
|
|
from fabledassistant.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
|
|
|
|
|
@chat_bp.route("/conversations", methods=["GET"])
|
|
@login_required
|
|
async def list_conversations_route():
|
|
uid = get_current_user_id()
|
|
limit, offset = parse_pagination()
|
|
conv_type = request.args.get("type", "chat")
|
|
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
|
|
return jsonify({
|
|
"conversations": conversations,
|
|
"total": total,
|
|
})
|
|
|
|
|
|
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
|
|
@login_required
|
|
async def bulk_delete_conversations_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
ids = data.get("ids", []) if isinstance(data, dict) else []
|
|
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
|
|
return jsonify({"error": "ids must be a list of integers"}), 400
|
|
count = await bulk_delete_conversations(uid, ids)
|
|
return jsonify({"deleted": count})
|
|
|
|
|
|
@chat_bp.route("/conversations", methods=["POST"])
|
|
@login_required
|
|
async def create_conversation_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json(force=True, silent=True) or {}
|
|
title = data.get("title", "")
|
|
model = data.get("model", Config.OLLAMA_MODEL)
|
|
conversation_type = data.get("conversation_type", "chat")
|
|
# Only allow known types to prevent accidental misuse
|
|
if conversation_type not in ("chat", "mcp", "voice"):
|
|
conversation_type = "chat"
|
|
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
|
|
return jsonify(conv.to_dict()), 201
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_conversation_route(conv_id: int):
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
result = conv.to_dict()
|
|
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
|
|
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
|
|
result["messages"] = []
|
|
for m in conv.messages:
|
|
msg_dict = m.to_dict()
|
|
if m.context_note_id and m.context_note_id in note_map:
|
|
msg_dict["context_note_title"] = note_map[m.context_note_id].title
|
|
result["messages"].append(msg_dict)
|
|
return jsonify(result)
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_conversation_route(conv_id: int):
|
|
uid = get_current_user_id()
|
|
deleted = await delete_conversation(uid, conv_id)
|
|
if not deleted:
|
|
return not_found("Conversation")
|
|
return "", 204
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
|
|
@login_required
|
|
async def update_conversation_route(conv_id: int):
|
|
from fabledassistant.services.chat import _UNSET
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
title = data.get("title")
|
|
model = data.get("model")
|
|
rag_project_id = data.get("rag_project_id", _UNSET)
|
|
if title is None and model is None and rag_project_id is _UNSET:
|
|
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
|
|
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
return jsonify(conv.to_dict())
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
|
|
@login_required
|
|
async def send_message_route(conv_id: int):
|
|
"""Start generation: save user message, launch background task, return 202."""
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
|
|
data = await request.get_json()
|
|
content = data.get("content", "").strip()
|
|
if not content:
|
|
return jsonify({"error": "content is required"}), 400
|
|
context_note_id = data.get("context_note_id")
|
|
include_note_ids = data.get("include_note_ids") or []
|
|
excluded_note_ids = data.get("excluded_note_ids") or []
|
|
think = bool(data.get("think", False))
|
|
rag_project_id = data.get("rag_project_id") or None
|
|
workspace_project_id = data.get("workspace_project_id") or None
|
|
user_timezone = data.get("user_timezone") or None
|
|
if not user_timezone:
|
|
user_timezone = await get_setting(uid, "user_timezone") or None
|
|
effective_rag_project_id = workspace_project_id or rag_project_id
|
|
|
|
# Reject if generation already running for this conversation
|
|
existing = get_buffer(conv_id)
|
|
if existing and existing.state == GenerationState.RUNNING:
|
|
return jsonify({"error": "Generation already in progress"}), 409
|
|
|
|
# Save user message
|
|
await add_message(conv_id, "user", content, context_note_id=context_note_id)
|
|
|
|
# Create placeholder assistant message
|
|
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
|
|
|
try:
|
|
buf = create_buffer(conv_id, assistant_msg.id)
|
|
except RuntimeError:
|
|
return jsonify({"error": "Generation already in progress"}), 409
|
|
|
|
# Build history from existing messages (excluding system and the placeholder)
|
|
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", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
|
|
|
# Launch background generation task (context building happens inside the task)
|
|
asyncio.create_task(run_generation(
|
|
buf, history, model,
|
|
uid, conv_id, conv.title, content,
|
|
context_note_id=context_note_id,
|
|
include_note_ids=include_note_ids,
|
|
excluded_note_ids=excluded_note_ids,
|
|
think=think,
|
|
rag_project_id=effective_rag_project_id,
|
|
workspace_project_id=workspace_project_id,
|
|
user_timezone=user_timezone,
|
|
voice_mode=(conv.conversation_type == "voice"),
|
|
))
|
|
|
|
return jsonify({
|
|
"assistant_message_id": assistant_msg.id,
|
|
"status": "generating",
|
|
}), 202
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
|
|
@login_required
|
|
async def generation_stream_route(conv_id: int):
|
|
"""SSE endpoint that tails the generation buffer for a conversation."""
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
|
|
buf = get_buffer(conv_id)
|
|
if buf is None:
|
|
return jsonify({"error": "No active generation"}), 404
|
|
|
|
# Determine starting point from Last-Event-ID header or query param
|
|
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
|
try:
|
|
last_id = int(last_id_str) if last_id_str is not None else -1
|
|
except (ValueError, TypeError):
|
|
last_id = -1
|
|
|
|
async def stream():
|
|
cursor = last_id
|
|
while True:
|
|
# Replay any buffered events past the cursor
|
|
pending = buf.events_after(cursor)
|
|
for event in pending:
|
|
yield buf.format_sse(event)
|
|
cursor = event.index
|
|
|
|
# If generation is done and all events delivered, close stream
|
|
if buf.state != GenerationState.RUNNING:
|
|
break
|
|
|
|
# Wait for new events or send keepalive on timeout
|
|
got_new = await buf.wait_for_event(cursor, timeout=15.0)
|
|
if not got_new:
|
|
yield ": keepalive\n\n"
|
|
|
|
# Final drain: deliver any events appended between the last yield
|
|
# and the state check above (e.g. the 'done' event).
|
|
for event in buf.events_after(cursor):
|
|
yield buf.format_sse(event)
|
|
|
|
return Response(
|
|
stream(),
|
|
content_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
|
|
@login_required
|
|
async def confirm_generation_route(conv_id: int):
|
|
"""Resolve a pending tool confirmation (accept or decline)."""
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
|
|
buf = get_buffer(conv_id)
|
|
if buf is None or buf.state != GenerationState.RUNNING:
|
|
return jsonify({"error": "No active generation"}), 404
|
|
|
|
if buf.confirmation_future is None or buf.confirmation_future.done():
|
|
return jsonify({"error": "No pending tool confirmation"}), 409
|
|
|
|
data = await request.get_json(force=True, silent=True) or {}
|
|
decision = bool(data.get("confirmed", False))
|
|
buf.confirmation_future.set_result(decision)
|
|
return jsonify({"status": "ok", "confirmed": decision})
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
|
|
@login_required
|
|
async def cancel_generation_route(conv_id: int):
|
|
"""Cancel an active generation for a conversation."""
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
|
|
buf = get_buffer(conv_id)
|
|
if buf is None or buf.state != GenerationState.RUNNING:
|
|
return jsonify({"error": "No active generation"}), 404
|
|
|
|
buf.cancel_event.set()
|
|
return jsonify({"status": "cancelled"})
|
|
|
|
|
|
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
|
|
@login_required
|
|
async def save_message_as_note_route(message_id: int):
|
|
uid = get_current_user_id()
|
|
try:
|
|
note = await save_response_as_note(uid, message_id)
|
|
return jsonify(note), 201
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
|
|
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
|
|
@login_required
|
|
async def summarize_conversation_route(conv_id: int):
|
|
uid = get_current_user_id()
|
|
conv = await get_conversation(uid, conv_id)
|
|
if conv is None:
|
|
return not_found("Conversation")
|
|
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
|
try:
|
|
note = await summarize_conversation_as_note(uid, conv_id, model)
|
|
return jsonify(note), 201
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
|
|
@chat_bp.route("/ps", methods=["GET"])
|
|
@login_required
|
|
async def running_models_route():
|
|
"""Return currently loaded (hot) models from Ollama."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
models = [
|
|
{
|
|
"name": m["name"],
|
|
"size": m.get("size", 0),
|
|
"size_vram": m.get("size_vram", 0),
|
|
"expires_at": m.get("expires_at", ""),
|
|
}
|
|
for m in data.get("models", [])
|
|
]
|
|
return jsonify({"models": models})
|
|
except Exception as e:
|
|
logger.debug("Failed to query Ollama /api/ps: %s", e)
|
|
return jsonify({"models": []})
|
|
|
|
|
|
@chat_bp.route("/warm", methods=["POST"])
|
|
@login_required
|
|
async def warm_model_route():
|
|
"""Pre-load a model into Ollama memory."""
|
|
data = await request.get_json(force=True, silent=True) or {}
|
|
model = data.get("model", "").strip()
|
|
if not model:
|
|
return jsonify({"error": "model is required"}), 400
|
|
|
|
async def _warm():
|
|
from fabledassistant.services.llm import keep_alive_for
|
|
try:
|
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
await client.post(
|
|
f"{Config.OLLAMA_URL}/api/generate",
|
|
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
|
|
)
|
|
logger.info("Warmed model %s", model)
|
|
except Exception as e:
|
|
logger.warning("Failed to warm model %s: %s", model, e)
|
|
|
|
asyncio.create_task(_warm())
|
|
return jsonify({"status": "warming"}), 202
|
|
|
|
|
|
@chat_bp.route("/status", methods=["GET"])
|
|
@login_required
|
|
async def chat_status_route():
|
|
"""Check Ollama availability, model installation, and model load state."""
|
|
uid = get_current_user_id()
|
|
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
|
# Guard against empty-string rows written by older code when user selected "default"
|
|
if not default_model:
|
|
default_model = Config.OLLAMA_MODEL
|
|
result = {
|
|
"ollama": "unavailable",
|
|
"model": "not_found",
|
|
"default_model": default_model,
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# Query installed models and loaded models in parallel
|
|
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
|
|
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
|
|
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
|
|
|
|
if isinstance(tags_resp, Exception):
|
|
logger.debug("Ollama /api/tags failed: %s", tags_resp)
|
|
else:
|
|
tags_resp.raise_for_status()
|
|
result["ollama"] = "available"
|
|
model_names = {m["name"] for m in tags_resp.json().get("models", [])}
|
|
base = default_model.removesuffix(":latest")
|
|
if default_model in model_names or f"{base}:latest" in model_names or base in model_names:
|
|
# Installed — now check if currently loaded in memory
|
|
result["model"] = "cold"
|
|
if not isinstance(ps_resp, Exception):
|
|
try:
|
|
ps_resp.raise_for_status()
|
|
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
|
if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names:
|
|
result["model"] = "loaded"
|
|
except Exception:
|
|
logger.debug("Ollama /api/ps check failed", exc_info=True)
|
|
except Exception:
|
|
logger.debug("Ollama status check failed", exc_info=True)
|
|
return jsonify(result)
|
|
|
|
|
|
@chat_bp.route("/models", methods=["GET"])
|
|
@login_required
|
|
async def list_models_route():
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
|
|
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
|
|
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
|
|
|
|
loaded_names: set[str] = set()
|
|
if not isinstance(ps_resp, Exception):
|
|
try:
|
|
ps_resp.raise_for_status()
|
|
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
|
except Exception:
|
|
pass
|
|
|
|
models = []
|
|
if not isinstance(tags_resp, Exception):
|
|
tags_resp.raise_for_status()
|
|
for m in tags_resp.json().get("models", []):
|
|
models.append({
|
|
"name": m["name"],
|
|
"size": m.get("size", 0),
|
|
"modified_at": m.get("modified_at", ""),
|
|
"loaded": m["name"] in loaded_names,
|
|
})
|
|
return jsonify({"models": models})
|
|
except Exception as e:
|
|
logger.warning("Failed to list Ollama models: %s", e)
|
|
return jsonify({"models": [], "error": str(e)}), 200
|
|
|
|
|
|
@chat_bp.route("/models/pull", methods=["POST"])
|
|
@admin_required
|
|
async def pull_model_route():
|
|
"""Pull a model from Ollama, streaming progress via SSE."""
|
|
data = await request.get_json()
|
|
model_name = data.get("model", "").strip()
|
|
if not model_name:
|
|
return jsonify({"error": "model is required"}), 400
|
|
|
|
async def generate():
|
|
try:
|
|
async with httpx.AsyncClient(timeout=1800.0) as client:
|
|
async with client.stream(
|
|
"POST",
|
|
f"{Config.OLLAMA_URL}/api/pull",
|
|
json={"name": model_name},
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
async for line in resp.aiter_lines():
|
|
if not line.strip():
|
|
continue
|
|
progress = json.loads(line)
|
|
event_data = json.dumps(progress)
|
|
yield f"data: {event_data}\n\n"
|
|
yield f"data: {json.dumps({'status': 'success'})}\n\n"
|
|
except Exception as e:
|
|
logger.exception("Error pulling model %s", model_name)
|
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
|
|
return Response(
|
|
generate(),
|
|
content_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
@chat_bp.route("/models/delete", methods=["POST"])
|
|
@admin_required
|
|
async def delete_model_route():
|
|
"""Delete a model from Ollama."""
|
|
data = await request.get_json()
|
|
model_name = data.get("model", "").strip()
|
|
if not model_name:
|
|
return jsonify({"error": "model is required"}), 400
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.request(
|
|
"DELETE",
|
|
f"{Config.OLLAMA_URL}/api/delete",
|
|
json={"name": model_name},
|
|
)
|
|
resp.raise_for_status()
|
|
return jsonify({"status": "deleted", "model": model_name})
|
|
except httpx.HTTPStatusError as e:
|
|
logger.warning("Failed to delete model %s: %s", model_name, e)
|
|
return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400
|
|
except Exception as e:
|
|
logger.warning("Failed to delete model %s: %s", model_name, e)
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@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 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.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)
|
|
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
|
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
|
)
|
|
item = result.scalars().first()
|
|
|
|
if item is None:
|
|
return jsonify({"error": "Article not found"}), 404
|
|
|
|
conv_title = (item.title or "Article discussion")[:80]
|
|
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
|
|
|
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
|
|
|
|
# Reload conversation so we see the two messages the helper just added.
|
|
conv = await get_conversation(uid, conv.id)
|
|
assert conv is not None
|
|
|
|
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
|