ChatService.vala 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using ClankerSaddle.Data;
  7. namespace ClankerSaddle.Services {
  8. /**
  9. * Advisor chat storage (goal 27): the CRUD the chat pages and
  10. * actions sit on — create/delete/list chats (private per user:
  11. * every read takes the owning user_id and returns nothing for
  12. * anyone else), the message transcript, and the suggestion rows.
  13. * The TURN LOOP itself lives in {@link AdvisorService}, which owns
  14. * message appends that must bump the pulse; everything the UI
  15. * needs to RENDER (including recording plain user/system messages
  16. * on behalf of actions) goes through here.
  17. */
  18. public class ChatService : Object {
  19. private OrmSession db = inject<OrmSession>();
  20. /**
  21. * Creates a chat titled `title` (trimmed; empty falls back to
  22. * "New chat") owned by `user_id`, starting idle.
  23. */
  24. public async AdvisorChat create_chat(int64 user_id, string title) throws Error {
  25. string trimmed = title.strip();
  26. var now = new DateTime.now_local();
  27. var chat = new AdvisorChat() {
  28. user_id = user_id,
  29. title = trimmed.length > 0 ? trimmed : "New chat",
  30. status = "idle",
  31. created_at = now,
  32. updated_at = now
  33. };
  34. yield db.insert_async<AdvisorChat>(chat);
  35. return chat;
  36. }
  37. /**
  38. * Deletes the chat owned by `user_id` (idempotent for anyone
  39. * else's id): the row plus its messages and suggestions.
  40. */
  41. public async void delete_chat(int64 id, int64 user_id) throws Error {
  42. var chat = yield get_chat(id, user_id);
  43. if (chat == null) {
  44. return;
  45. }
  46. foreach (var message in yield list_messages(id)) {
  47. yield db.delete_async<AdvisorMessage>(message);
  48. }
  49. foreach (var suggestion in yield list_suggestions(id)) {
  50. yield db.delete_async<AdvisorSuggestion>(suggestion);
  51. }
  52. yield db.delete_async<AdvisorChat>((!)chat);
  53. }
  54. /**
  55. * The chat owned by `user_id`, or null when the id is unknown
  56. * or belongs to someone else — chats are private per user.
  57. */
  58. public async AdvisorChat? get_chat(int64 id, int64 user_id) throws Error {
  59. return yield db.query<AdvisorChat>()
  60. .where(expr("id == $0 && user_id == $1",
  61. elem<int64?>(id), elem<int64?>(user_id)))
  62. .first_async();
  63. }
  64. /** The user's chats, newest first. */
  65. public async ImmutableLot<AdvisorChat> list_chats(int64 user_id) throws Error {
  66. return yield db.query<AdvisorChat>()
  67. .where(expr("user_id == $0", elem<int64?>(user_id)))
  68. .order_by_desc(expr("updated_at"))
  69. .order_by_desc(expr("id"))
  70. .materialise_async();
  71. }
  72. /** One chat's transcript, oldest first. */
  73. public async ImmutableLot<AdvisorMessage> list_messages(int64 chat_id) throws Error {
  74. return yield db.query<AdvisorMessage>()
  75. .where(expr("chat_id == $0", elem<int64?>(chat_id)))
  76. .order_by(expr("id"))
  77. .materialise_async();
  78. }
  79. /** One chat's suggestions, oldest first. */
  80. public async ImmutableLot<AdvisorSuggestion> list_suggestions(int64 chat_id) throws Error {
  81. return yield db.query<AdvisorSuggestion>()
  82. .where(expr("chat_id == $0", elem<int64?>(chat_id)))
  83. .order_by(expr("id"))
  84. .materialise_async();
  85. }
  86. /**
  87. * Appends a message row verbatim (the advisor loop's writer;
  88. * actions use it for user/system rows) and touches the chat's
  89. * updated_at. `payload` is JSON or null.
  90. */
  91. public async AdvisorMessage append_message(int64 chat_id, string kind,
  92. string content, string? payload) throws Error {
  93. var message = new AdvisorMessage() {
  94. chat_id = chat_id,
  95. kind = kind,
  96. content = content,
  97. payload = payload,
  98. created_at = new DateTime.now_local()
  99. };
  100. yield db.insert_async<AdvisorMessage>(message);
  101. yield touch_chat(chat_id);
  102. return message;
  103. }
  104. /** Records a plain user message (no payload). */
  105. public async AdvisorMessage record_user_message(int64 chat_id,
  106. string content) throws Error {
  107. return yield append_message(chat_id, "user", content, null);
  108. }
  109. /** Records an advisor-external system notice (task callbacks). */
  110. public async AdvisorMessage record_system_message(int64 chat_id,
  111. string content) throws Error {
  112. return yield append_message(chat_id, "system", content, null);
  113. }
  114. /** Bumps the chat's updated_at (every append's trailer). */
  115. public async void touch_chat(int64 chat_id) throws Error {
  116. var chat = yield db.query<AdvisorChat>()
  117. .where(expr("id == $0", elem<int64?>(chat_id)))
  118. .first_async();
  119. if (chat != null) {
  120. ((!)chat).updated_at = new DateTime.now_local();
  121. yield db.update_async<AdvisorChat>((!)chat);
  122. }
  123. }
  124. /**
  125. * Sets the chat's loop status ('idle' / 'running' /
  126. * 'awaiting_answer') and touches updated_at.
  127. */
  128. public async void set_chat_status(int64 chat_id, string status) throws Error {
  129. var chat = yield db.query<AdvisorChat>()
  130. .where(expr("id == $0", elem<int64?>(chat_id)))
  131. .first_async();
  132. if (chat != null) {
  133. ((!)chat).status = status;
  134. ((!)chat).updated_at = new DateTime.now_local();
  135. yield db.update_async<AdvisorChat>((!)chat);
  136. }
  137. }
  138. /**
  139. * The chat's newest unanswered question row (an assistant
  140. * message whose payload is a question object), or null.
  141. */
  142. public async AdvisorMessage? pending_question(int64 chat_id) throws Error {
  143. AdvisorMessage? found = null;
  144. foreach (var message in yield list_messages(chat_id)) {
  145. if (message.kind == "assistant" && is_question(message)
  146. && message.answer == null) {
  147. found = message;
  148. }
  149. }
  150. return found;
  151. }
  152. /** True when `message` is a parked ask_question row. */
  153. public static bool is_question(AdvisorMessage message) {
  154. if (message.payload == null) {
  155. return false;
  156. }
  157. var parser = new Json.Parser();
  158. try {
  159. parser.load_from_data(((!)message.payload));
  160. } catch (Error e) {
  161. return false;
  162. }
  163. var root = parser.get_root();
  164. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  165. return false;
  166. }
  167. return ((!)root).get_object().has_member("question");
  168. }
  169. }
  170. }