ChatDetailEntrypoint.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Spry;
  4. using Spry.Authentication;
  5. using Statum;
  6. using ClankerSaddle.Data;
  7. using ClankerSaddle.Services;
  8. namespace ClankerSaddle {
  9. /**
  10. * One row of a chat's transcript (goal 27). The `kind` is the
  11. * stored wire kind — 'user', 'assistant', 'tool', 'error' or
  12. * 'system' — and drives the bubble styling. A tool row carries
  13. * its call's {@link tool_name}, {@link arguments} and
  14. * {@link result} (parsed out of the row's JSON payload); a
  15. * question row carries its {@link options} plus answer state.
  16. */
  17. public class ChatMessageView : Object {
  18. public int64 id { get; set; }
  19. /** user / assistant / tool / error / system. */
  20. public string kind { get; set; default = "user"; }
  21. public string content { get; set; default = ""; }
  22. public bool is_question { get; set; }
  23. /**
  24. * True when this assistant row carries a tool call AND said
  25. * nothing worth a bubble (empty content or the bare "@tool
  26. * call" marker): the paired tool row's details expander is
  27. * its render, so the transcript shows no redundant bubble.
  28. */
  29. public bool bubble_hidden { get; set; }
  30. public string[] options { get; set; default = {}; }
  31. public bool answered { get; set; }
  32. public string answer { get; set; default = ""; }
  33. public string when_display { get; set; default = ""; }
  34. /** Tool rows only ("" elsewhere). */
  35. public string tool_name { get; set; default = ""; }
  36. /** Tool rows only: the call's raw JSON arguments (pretty when parseable). */
  37. public string arguments { get; set; default = ""; }
  38. /** Tool rows only: the call's raw JSON result (pretty when parseable). */
  39. public string result { get; set; default = ""; }
  40. }
  41. /**
  42. * One suggestion card (goal 27): a task or goal the advisor
  43. * proposed mid-chat. While `pending` the card offers the decision
  44. * buttons; afterwards it states the outcome and links what was
  45. * created (task_id/goal_id > 0).
  46. */
  47. public class ChatSuggestionView : Object {
  48. public int64 id { get; set; }
  49. /** "task" or "goal". */
  50. public string kind { get; set; default = "task"; }
  51. public string status { get; set; default = "pending"; }
  52. public string title { get; set; default = ""; }
  53. /** Task suggestions: the mandate; goal suggestions: the brief. */
  54. public string body { get; set; default = ""; }
  55. public string mode { get; set; default = ""; }
  56. public int64 priority { get; set; }
  57. public string branch_name { get; set; default = ""; }
  58. public int64 project_id { get; set; }
  59. public int64 task_id { get; set; }
  60. public int64 goal_id { get; set; }
  61. public string created_display { get; set; default = ""; }
  62. public Statum.Model.ActionDto? decide { get; set; }
  63. }
  64. /** Public data of the `chat` slot (the /chats/{id} page). */
  65. public class ChatPublic : Object {
  66. public int64 id { get; set; }
  67. public string title { get; set; default = ""; }
  68. public string status { get; set; default = "idle"; }
  69. public string status_display { get; set; default = "idle"; }
  70. public bool advisor_configured { get; set; }
  71. public Statum.Model.ActionDto? send { get; set; }
  72. public Statum.Model.ActionDto? delete { get; set; }
  73. public Statum.Model.ActionDto? refresh { get; set; }
  74. }
  75. /**
  76. * Builds the `chat` PAGE slot shared by the chat detail entrypoint
  77. * and the advisor actions ({@link SendAdvisorMessageAction} answers
  78. * with a rebuilt slot; {@link RefreshAdvisorChatAction} re-runs it
  79. * on every StatePulse bump) so all paths hydrate identical state:
  80. * the chat row, the transcript (tool/question payloads parsed),
  81. * the suggestion cards and their sealed decision action.
  82. */
  83. public class ChatState : Object {
  84. /** The human label of a chat's loop status. */
  85. public static string status_display(string status) {
  86. if (status == "running") {
  87. return "thinking";
  88. }
  89. if (status == "awaiting_answer") {
  90. return "awaiting answer";
  91. }
  92. return "idle";
  93. }
  94. /**
  95. * Builds the slot for `chat_id` as owned by `viewer_id`; an
  96. * unknown id (or someone else's — chats are private per user)
  97. * yields the state with id 0 so the template can render its
  98. * not-found notice.
  99. */
  100. public static async Statum.Slot build(StateService state_service,
  101. ChatService chats, AdvisorService advisor,
  102. ActionRegistry? actions, int64 viewer_id, int64 chat_id)
  103. throws GLib.Error {
  104. var chat = yield chats.get_chat(chat_id, viewer_id);
  105. return yield build_from(state_service, chats, advisor, actions,
  106. viewer_id, chat);
  107. }
  108. /**
  109. * The same slot over an ALREADY-LOADED chat row (actions that
  110. * just created or verified the chat skip the re-read).
  111. */
  112. public static async Statum.Slot build_from(StateService state_service,
  113. ChatService chats, AdvisorService advisor,
  114. ActionRegistry? actions, int64 viewer_id, AdvisorChat? chat)
  115. throws GLib.Error {
  116. var pub = new ChatPublic() {
  117. advisor_configured = advisor.configured
  118. };
  119. PropertyDictionary props;
  120. if (chat != null) {
  121. pub.id = ((!)chat).id;
  122. pub.title = ((!)chat).title;
  123. pub.status = ((!)chat).status;
  124. pub.status_display = status_display(((!)chat).status);
  125. if (actions != null) {
  126. var sealed = new AdvisorChatActionPrivate() {
  127. chat_id = ((!)chat).id
  128. };
  129. pub.send = ((!)actions).author_private<SendAdvisorMessageAction,
  130. AdvisorChatActionPrivate>(sealed);
  131. pub.delete = ((!)actions).author_private<DeleteAdvisorChatAction,
  132. AdvisorChatActionPrivate>(sealed);
  133. // GET-sealed (author_private): statum.act urlencodes
  134. // GET parameters as a QUERY STRING no form reader
  135. // ever sees — an unsealed id parses 0 and the
  136. // refresh would navigate away on every bump.
  137. pub.refresh = ((!)actions).author_private<RefreshAdvisorChatAction,
  138. AdvisorChatActionPrivate>(sealed, "GET");
  139. }
  140. var messages = new Vector<ChatMessageView>();
  141. foreach (var row in yield chats.list_messages(((!)chat).id)) {
  142. messages.add(message_view(row));
  143. }
  144. var suggestions = new Vector<ChatSuggestionView>();
  145. foreach (var row in yield chats.list_suggestions(((!)chat).id)) {
  146. suggestions.add(suggestion_view(row, actions));
  147. }
  148. // to_properties SNAPSHOTS the object's values: every
  149. // scalar must be final before the mapping (the lists
  150. // then ride the dictionary directly).
  151. props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  152. props["messages"] = StateJson.list<ChatMessageView>(
  153. messages.to_immutable_buffer());
  154. props["suggestions"] = StateJson.list<ChatSuggestionView>(
  155. suggestions.to_immutable_buffer());
  156. } else {
  157. props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  158. props["messages"] = StateJson.list<ChatMessageView>(
  159. new Vector<ChatMessageView>().to_immutable_buffer());
  160. props["suggestions"] = StateJson.list<ChatSuggestionView>(
  161. new Vector<ChatSuggestionView>().to_immutable_buffer());
  162. }
  163. return state_service.new_slot(Scope.PAGE, new Statum.State() {
  164. type_name = "chat",
  165. public_data = props,
  166. private_data = new PropertyDictionary()
  167. });
  168. }
  169. /** Shapes one transcript row (payloads parsed for render). */
  170. private static ChatMessageView message_view(AdvisorMessage row) {
  171. var view = new ChatMessageView() {
  172. id = row.id,
  173. kind = row.kind,
  174. content = row.content,
  175. when_display = format_stamp(row.created_at)
  176. };
  177. if (row.answer != null) {
  178. view.answered = true;
  179. view.answer = (!)row.answer;
  180. }
  181. if (row.payload == null) {
  182. return view;
  183. }
  184. var body = parse_object(((!)row.payload));
  185. if (body == null) {
  186. return view;
  187. }
  188. var parsed = (!)body;
  189. if (parsed.has_member("question")) {
  190. view.is_question = true;
  191. var options = new Vector<string>();
  192. var options_member = parsed.get_member("options");
  193. if (options_member != null
  194. && options_member.get_node_type() == Json.NodeType.ARRAY) {
  195. foreach (var node in options_member.get_array().get_elements()) {
  196. if (node.get_node_type() == Json.NodeType.VALUE
  197. && node.get_string() != null) {
  198. options.add((!)node.get_string());
  199. }
  200. }
  201. }
  202. view.options = options.to_array();
  203. return view;
  204. }
  205. if (parsed.has_member("name") && parsed.has_member("call_id")) {
  206. // An assistant row that carries a tool call: it shows
  207. // no bubble of its own unless the model wrote real
  208. // prose alongside the call (the paired tool row's
  209. // details expander is the call's render).
  210. view.tool_name = member_string(parsed, "name");
  211. view.arguments = pretty(member_string(parsed, "arguments"));
  212. view.result = member_string(parsed, "result");
  213. string spoken = view.content.strip();
  214. if (spoken.length == 0 || spoken == "@tool call") {
  215. view.bubble_hidden = true;
  216. }
  217. } else if (parsed.has_member("name")) {
  218. // The tool RESULT row (call_id + name + result).
  219. view.tool_name = member_string(parsed, "name");
  220. view.arguments = pretty(member_string(parsed, "arguments"));
  221. view.result = pretty(member_string(parsed, "result"));
  222. }
  223. return view;
  224. }
  225. /** Shapes one suggestion row (payload fields + sealed decide). */
  226. private static ChatSuggestionView suggestion_view(AdvisorSuggestion row,
  227. ActionRegistry? actions) throws GLib.Error {
  228. var view = new ChatSuggestionView() {
  229. id = row.id,
  230. kind = row.kind,
  231. status = row.status,
  232. task_id = row.task_id,
  233. goal_id = row.goal_id,
  234. created_display = format_stamp(row.created_at)
  235. };
  236. var body = parse_object(row.payload);
  237. if (body != null) {
  238. var parsed = (!)body;
  239. view.title = member_string(parsed, "title");
  240. view.body = member_string(parsed,
  241. row.kind == "goal" ? "brief" : "mandate");
  242. view.mode = member_string(parsed, "mode");
  243. view.branch_name = member_string(parsed, "branch_name");
  244. if (parsed.has_member("priority")
  245. && parsed.get_member("priority").get_node_type()
  246. == Json.NodeType.VALUE) {
  247. view.priority = parsed.get_int_member("priority");
  248. }
  249. if (parsed.has_member("project_id")
  250. && parsed.get_member("project_id").get_node_type()
  251. == Json.NodeType.VALUE) {
  252. view.project_id = parsed.get_int_member("project_id");
  253. }
  254. }
  255. if (actions != null && row.status == "pending") {
  256. view.decide = ((!)actions)
  257. .author_private<DecideAdvisorSuggestionAction,
  258. AdvisorSuggestionActionPrivate>(
  259. new AdvisorSuggestionActionPrivate() {
  260. suggestion_id = row.id,
  261. chat_id = row.chat_id
  262. });
  263. }
  264. return view;
  265. }
  266. /** A JSON payload's root object, or null when unparseable. */
  267. private static Json.Object? parse_object(string payload) {
  268. var parser = new Json.Parser();
  269. try {
  270. parser.load_from_data(payload);
  271. } catch (Error e) {
  272. return null;
  273. }
  274. var root = parser.get_root();
  275. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  276. return null;
  277. }
  278. return ((!)root).get_object();
  279. }
  280. private static string member_string(Json.Object body, string member) {
  281. if (!body.has_member(member)) {
  282. return "";
  283. }
  284. var node = body.get_member(member);
  285. if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
  286. return (!)node.get_string();
  287. }
  288. return "";
  289. }
  290. /** Re-indents JSON text for the collapsed tool-call details. */
  291. private static string pretty(string json) {
  292. if (json.length == 0) {
  293. return "";
  294. }
  295. var parser = new Json.Parser();
  296. try {
  297. parser.load_from_data(json);
  298. } catch (Error e) {
  299. return json;
  300. }
  301. var root = parser.get_root();
  302. if (root == null) {
  303. return json;
  304. }
  305. return Json.to_string(((!)root), true);
  306. }
  307. }
  308. /**
  309. * Entrypoint for /chats/{id} (goal 27): one advisor chat — the
  310. * transcript (user/assistant bubbles, collapsed tool calls,
  311. * question cards with option buttons, error and system notices),
  312. * the pending suggestion cards, the composer and the delete
  313. * action. The page subscribes to the global pulse and re-runs its
  314. * sealed refresh action on every bump, so a running turn's
  315. * appends stream in without a reload.
  316. */
  317. public class ChatDetailEntrypoint : StatumEntrypoint {
  318. private ChatService chat_service = Inversion.inject<ChatService>();
  319. private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
  320. private UserService users = Inversion.inject<UserService>();
  321. public override async DirectiveBuilder handle() throws GLib.Error {
  322. var guard = yield SpryAuth.require_login(directives(), request, users);
  323. if (guard != null) {
  324. return (!)guard;
  325. }
  326. var me = SpryAuth.current(request);
  327. int64 viewer_id = me != null ? ((!)me).user_id : 0;
  328. int64 chat_id = int64.parse(request.route("chat_id") ?? "0");
  329. var state = yield ChatState.build(state_service, chat_service,
  330. advisor_service, action_registry, viewer_id, chat_id);
  331. return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
  332. }
  333. }
  334. }