| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- 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<SendAdvisorMessageAction,
- AdvisorChatActionPrivate>(sealed);
- pub.delete = ((!)actions).author_private<DeleteAdvisorChatAction,
- AdvisorChatActionPrivate>(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<RefreshAdvisorChatAction,
- AdvisorChatActionPrivate>(sealed, "GET");
- }
- var messages = new Vector<ChatMessageView>();
- foreach (var row in yield chats.list_messages(((!)chat).id)) {
- messages.add(message_view(row));
- }
- var suggestions = new Vector<ChatSuggestionView>();
- 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<ChatMessageView>(
- messages.to_immutable_buffer());
- props["suggestions"] = StateJson.list<ChatSuggestionView>(
- suggestions.to_immutable_buffer());
- } else {
- props = (PropertyDictionary) GObjectMapping.to_properties(pub);
- props["messages"] = StateJson.list<ChatMessageView>(
- new Vector<ChatMessageView>().to_immutable_buffer());
- props["suggestions"] = StateJson.list<ChatSuggestionView>(
- new Vector<ChatSuggestionView>().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<string>();
- 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<DecideAdvisorSuggestionAction,
- AdvisorSuggestionActionPrivate>(
- 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<ChatService>();
- private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
- private UserService users = Inversion.inject<UserService>();
- 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);
- }
- }
- }
|