using Invercargill; using Invercargill.DataStructures; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle { /** * One row of a chat's transcript (goal 27). The `kind` is the * stored wire kind — 'user', 'assistant', 'tool', 'error' or * 'system' — and drives the bubble styling. A tool row carries * its call's {@link tool_name}, {@link arguments} and * {@link result} (parsed out of the row's JSON payload); a * question row carries its {@link options} plus answer state. */ public class ChatMessageView : Object { public int64 id { get; set; } /** user / assistant / tool / error / system. */ public string kind { get; set; default = "user"; } public string content { get; set; default = ""; } public bool is_question { get; set; } /** * True when this assistant row carries a tool call AND said * nothing worth a bubble (empty content or the bare "@tool * call" marker): the paired tool row's details expander is * its render, so the transcript shows no redundant bubble. */ public bool bubble_hidden { get; set; } public string[] options { get; set; default = {}; } public bool answered { get; set; } public string answer { get; set; default = ""; } public string when_display { get; set; default = ""; } /** Tool rows only ("" elsewhere). */ public string tool_name { get; set; default = ""; } /** Tool rows only: the call's raw JSON arguments (pretty when parseable). */ public string arguments { get; set; default = ""; } /** Tool rows only: the call's raw JSON result (pretty when parseable). */ public string result { get; set; default = ""; } } /** * One suggestion card (goal 27): a task or goal the advisor * proposed mid-chat. While `pending` the card offers the decision * buttons; afterwards it states the outcome and links what was * created (task_id/goal_id > 0). */ public class ChatSuggestionView : Object { public int64 id { get; set; } /** "task" or "goal". */ public string kind { get; set; default = "task"; } public string status { get; set; default = "pending"; } public string title { get; set; default = ""; } /** Task suggestions: the mandate; goal suggestions: the brief. */ public string body { get; set; default = ""; } public string mode { get; set; default = ""; } public int64 priority { get; set; } public string branch_name { get; set; default = ""; } public int64 project_id { get; set; } public int64 task_id { get; set; } public int64 goal_id { get; set; } public string created_display { get; set; default = ""; } public Statum.Model.ActionDto? decide { get; set; } } /** Public data of the `chat` slot (the /chats/{id} page). */ public class ChatPublic : Object { public int64 id { get; set; } public string title { get; set; default = ""; } public string status { get; set; default = "idle"; } public string status_display { get; set; default = "idle"; } public bool advisor_configured { get; set; } public Statum.Model.ActionDto? send { get; set; } public Statum.Model.ActionDto? delete { get; set; } public Statum.Model.ActionDto? refresh { get; set; } } /** * Builds the `chat` PAGE slot shared by the chat detail entrypoint * and the advisor actions ({@link SendAdvisorMessageAction} answers * with a rebuilt slot; {@link RefreshAdvisorChatAction} re-runs it * on every StatePulse bump) so all paths hydrate identical state: * the chat row, the transcript (tool/question payloads parsed), * the suggestion cards and their sealed decision action. */ public class ChatState : Object { /** The human label of a chat's loop status. */ public static string status_display(string status) { if (status == "running") { return "thinking"; } if (status == "awaiting_answer") { return "awaiting answer"; } return "idle"; } /** * Builds the slot for `chat_id` as owned by `viewer_id`; an * unknown id (or someone else's — chats are private per user) * yields the state with id 0 so the template can render its * not-found notice. */ public static async Statum.Slot build(StateService state_service, ChatService chats, AdvisorService advisor, ActionRegistry? actions, int64 viewer_id, int64 chat_id) throws GLib.Error { var chat = yield chats.get_chat(chat_id, viewer_id); return yield build_from(state_service, chats, advisor, actions, viewer_id, chat); } /** * The same slot over an ALREADY-LOADED chat row (actions that * just created or verified the chat skip the re-read). */ public static async Statum.Slot build_from(StateService state_service, ChatService chats, AdvisorService advisor, ActionRegistry? actions, int64 viewer_id, AdvisorChat? chat) throws GLib.Error { var pub = new ChatPublic() { advisor_configured = advisor.configured }; PropertyDictionary props; if (chat != null) { pub.id = ((!)chat).id; pub.title = ((!)chat).title; pub.status = ((!)chat).status; pub.status_display = status_display(((!)chat).status); if (actions != null) { var sealed = new AdvisorChatActionPrivate() { chat_id = ((!)chat).id }; pub.send = ((!)actions).author_private(sealed); pub.delete = ((!)actions).author_private(sealed); // GET-sealed (author_private): statum.act urlencodes // GET parameters as a QUERY STRING no form reader // ever sees — an unsealed id parses 0 and the // refresh would navigate away on every bump. pub.refresh = ((!)actions).author_private(sealed, "GET"); } var messages = new Vector(); foreach (var row in yield chats.list_messages(((!)chat).id)) { messages.add(message_view(row)); } var suggestions = new Vector(); foreach (var row in yield chats.list_suggestions(((!)chat).id)) { suggestions.add(suggestion_view(row, actions)); } // to_properties SNAPSHOTS the object's values: every // scalar must be final before the mapping (the lists // then ride the dictionary directly). props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["messages"] = StateJson.list( messages.to_immutable_buffer()); props["suggestions"] = StateJson.list( suggestions.to_immutable_buffer()); } else { props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["messages"] = StateJson.list( new Vector().to_immutable_buffer()); props["suggestions"] = StateJson.list( new Vector().to_immutable_buffer()); } return state_service.new_slot(Scope.PAGE, new Statum.State() { type_name = "chat", public_data = props, private_data = new PropertyDictionary() }); } /** Shapes one transcript row (payloads parsed for render). */ private static ChatMessageView message_view(AdvisorMessage row) { var view = new ChatMessageView() { id = row.id, kind = row.kind, content = row.content, when_display = format_stamp(row.created_at) }; if (row.answer != null) { view.answered = true; view.answer = (!)row.answer; } if (row.payload == null) { return view; } var body = parse_object(((!)row.payload)); if (body == null) { return view; } var parsed = (!)body; if (parsed.has_member("question")) { view.is_question = true; var options = new Vector(); var options_member = parsed.get_member("options"); if (options_member != null && options_member.get_node_type() == Json.NodeType.ARRAY) { foreach (var node in options_member.get_array().get_elements()) { if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { options.add((!)node.get_string()); } } } view.options = options.to_array(); return view; } if (parsed.has_member("name") && parsed.has_member("call_id")) { // An assistant row that carries a tool call: it shows // no bubble of its own unless the model wrote real // prose alongside the call (the paired tool row's // details expander is the call's render). view.tool_name = member_string(parsed, "name"); view.arguments = pretty(member_string(parsed, "arguments")); view.result = member_string(parsed, "result"); string spoken = view.content.strip(); if (spoken.length == 0 || spoken == "@tool call") { view.bubble_hidden = true; } } else if (parsed.has_member("name")) { // The tool RESULT row (call_id + name + result). view.tool_name = member_string(parsed, "name"); view.arguments = pretty(member_string(parsed, "arguments")); view.result = pretty(member_string(parsed, "result")); } return view; } /** Shapes one suggestion row (payload fields + sealed decide). */ private static ChatSuggestionView suggestion_view(AdvisorSuggestion row, ActionRegistry? actions) throws GLib.Error { var view = new ChatSuggestionView() { id = row.id, kind = row.kind, status = row.status, task_id = row.task_id, goal_id = row.goal_id, created_display = format_stamp(row.created_at) }; var body = parse_object(row.payload); if (body != null) { var parsed = (!)body; view.title = member_string(parsed, "title"); view.body = member_string(parsed, row.kind == "goal" ? "brief" : "mandate"); view.mode = member_string(parsed, "mode"); view.branch_name = member_string(parsed, "branch_name"); if (parsed.has_member("priority") && parsed.get_member("priority").get_node_type() == Json.NodeType.VALUE) { view.priority = parsed.get_int_member("priority"); } if (parsed.has_member("project_id") && parsed.get_member("project_id").get_node_type() == Json.NodeType.VALUE) { view.project_id = parsed.get_int_member("project_id"); } } if (actions != null && row.status == "pending") { view.decide = ((!)actions) .author_private( new AdvisorSuggestionActionPrivate() { suggestion_id = row.id, chat_id = row.chat_id }); } return view; } /** A JSON payload's root object, or null when unparseable. */ private static Json.Object? parse_object(string payload) { var parser = new Json.Parser(); try { parser.load_from_data(payload); } catch (Error e) { return null; } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return null; } return ((!)root).get_object(); } private static string member_string(Json.Object body, string member) { if (!body.has_member(member)) { return ""; } var node = body.get_member(member); if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { return (!)node.get_string(); } return ""; } /** Re-indents JSON text for the collapsed tool-call details. */ private static string pretty(string json) { if (json.length == 0) { return ""; } var parser = new Json.Parser(); try { parser.load_from_data(json); } catch (Error e) { return json; } var root = parser.get_root(); if (root == null) { return json; } return Json.to_string(((!)root), true); } } /** * Entrypoint for /chats/{id} (goal 27): one advisor chat — the * transcript (user/assistant bubbles, collapsed tool calls, * question cards with option buttons, error and system notices), * the pending suggestion cards, the composer and the delete * action. The page subscribes to the global pulse and re-runs its * sealed refresh action on every bump, so a running turn's * appends stream in without a reload. */ public class ChatDetailEntrypoint : StatumEntrypoint { private ChatService chat_service = Inversion.inject(); private AdvisorService advisor_service = Inversion.inject(); private UserService users = Inversion.inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var me = SpryAuth.current(request); int64 viewer_id = me != null ? ((!)me).user_id : 0; int64 chat_id = int64.parse(request.route("chat_id") ?? "0"); var state = yield ChatState.build(state_service, chat_service, advisor_service, action_registry, viewer_id, chat_id); return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE); } } }