using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** * Advisor chat storage (goal 27): the CRUD the chat pages and * actions sit on — create/delete/list chats (private per user: * every read takes the owning user_id and returns nothing for * anyone else), the message transcript, and the suggestion rows. * The TURN LOOP itself lives in {@link AdvisorService}, which owns * message appends that must bump the pulse; everything the UI * needs to RENDER (including recording plain user/system messages * on behalf of actions) goes through here. */ public class ChatService : Object { private OrmSession db = inject(); /** * Creates a chat titled `title` (trimmed; empty falls back to * "New chat") owned by `user_id`, starting idle. */ public async AdvisorChat create_chat(int64 user_id, string title) throws Error { string trimmed = title.strip(); var now = new DateTime.now_local(); var chat = new AdvisorChat() { user_id = user_id, title = trimmed.length > 0 ? trimmed : "New chat", status = "idle", created_at = now, updated_at = now }; yield db.insert_async(chat); return chat; } /** * Deletes the chat owned by `user_id` (idempotent for anyone * else's id): the row plus its messages and suggestions. */ public async void delete_chat(int64 id, int64 user_id) throws Error { var chat = yield get_chat(id, user_id); if (chat == null) { return; } foreach (var message in yield list_messages(id)) { yield db.delete_async(message); } foreach (var suggestion in yield list_suggestions(id)) { yield db.delete_async(suggestion); } yield db.delete_async((!)chat); } /** * The chat owned by `user_id`, or null when the id is unknown * or belongs to someone else — chats are private per user. */ public async AdvisorChat? get_chat(int64 id, int64 user_id) throws Error { return yield db.query() .where(expr("id == $0 && user_id == $1", elem(id), elem(user_id))) .first_async(); } /** The user's chats, newest first. */ public async ImmutableLot list_chats(int64 user_id) throws Error { return yield db.query() .where(expr("user_id == $0", elem(user_id))) .order_by_desc(expr("updated_at")) .order_by_desc(expr("id")) .materialise_async(); } /** One chat's transcript, oldest first. */ public async ImmutableLot list_messages(int64 chat_id) throws Error { return yield db.query() .where(expr("chat_id == $0", elem(chat_id))) .order_by(expr("id")) .materialise_async(); } /** One chat's suggestions, oldest first. */ public async ImmutableLot list_suggestions(int64 chat_id) throws Error { return yield db.query() .where(expr("chat_id == $0", elem(chat_id))) .order_by(expr("id")) .materialise_async(); } /** * Appends a message row verbatim (the advisor loop's writer; * actions use it for user/system rows) and touches the chat's * updated_at. `payload` is JSON or null. */ public async AdvisorMessage append_message(int64 chat_id, string kind, string content, string? payload) throws Error { var message = new AdvisorMessage() { chat_id = chat_id, kind = kind, content = content, payload = payload, created_at = new DateTime.now_local() }; yield db.insert_async(message); yield touch_chat(chat_id); return message; } /** Records a plain user message (no payload). */ public async AdvisorMessage record_user_message(int64 chat_id, string content) throws Error { return yield append_message(chat_id, "user", content, null); } /** Records an advisor-external system notice (task callbacks). */ public async AdvisorMessage record_system_message(int64 chat_id, string content) throws Error { return yield append_message(chat_id, "system", content, null); } /** Bumps the chat's updated_at (every append's trailer). */ public async void touch_chat(int64 chat_id) throws Error { var chat = yield db.query() .where(expr("id == $0", elem(chat_id))) .first_async(); if (chat != null) { ((!)chat).updated_at = new DateTime.now_local(); yield db.update_async((!)chat); } } /** * Sets the chat's loop status ('idle' / 'running' / * 'awaiting_answer') and touches updated_at. */ public async void set_chat_status(int64 chat_id, string status) throws Error { var chat = yield db.query() .where(expr("id == $0", elem(chat_id))) .first_async(); if (chat != null) { ((!)chat).status = status; ((!)chat).updated_at = new DateTime.now_local(); yield db.update_async((!)chat); } } /** * The chat's newest unanswered question row (an assistant * message whose payload is a question object), or null. */ public async AdvisorMessage? pending_question(int64 chat_id) throws Error { AdvisorMessage? found = null; foreach (var message in yield list_messages(chat_id)) { if (message.kind == "assistant" && is_question(message) && message.answer == null) { found = message; } } return found; } /** True when `message` is a parked ask_question row. */ public static bool is_question(AdvisorMessage message) { if (message.payload == null) { return false; } var parser = new Json.Parser(); try { parser.load_from_data(((!)message.payload)); } catch (Error e) { return false; } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return false; } return ((!)root).get_object().has_member("question"); } } }