using Inversion; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; namespace ClankerSaddle { /** Sealed chat id carried by the advisor chat actions. */ public class AdvisorChatActionPrivate : Object { public int64 chat_id { get; set; } } /** * Sealed suggestion + chat ids carried by the suggestion decision * action (goal 27 task 3 owns its handler). */ public class AdvisorSuggestionActionPrivate : Object { public int64 suggestion_id { get; set; } public int64 chat_id { get; set; } } /** * Starts a new advisor chat (goal 27) from the dashboard's "Ask * the advisor" composer: creates the chat titled from the first * line of the message (clamped ~80 chars), records the message as * the opening user row and kicks the background * {@link AdvisorService} turn. The POST returns before the turn * finishes — the chat page's pulse-driven refresh streams the * appends in — and the browser navigates to /chats/{id}. */ public class StartAdvisorChatAction : StatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = 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); if (me == null) { return directives().navigate("/"); } int64 viewer_id = ((!)me).user_id; string message = Forms.field_raw(request, "message"); if (message.strip().length == 0) { return directives().notify("error", "Type a question for the advisor first").navigate("/"); } var chat = yield chat_service.create_chat(viewer_id, chat_title(message)); yield chat_service.record_user_message(chat.id, message.strip()); // The turn runs in the service's own scope; this request // finishes (and navigates) immediately. advisor_service.run_turn.begin(chat.id, viewer_id); return directives().navigate(@"/chats/$(chat.id)"); } /** * The chat's title: the message's first non-empty line, * clamped to ~80 characters on a word boundary. */ private static string chat_title(string message) { string first = ""; foreach (string line in message.strip().split("\n")) { if (line.strip().length > 0) { first = line.strip(); break; } } if (first.length <= 80) { return first; } string cut = first.substring(0, 80); int last_space = cut.last_index_of(" "); if (last_space > 40) { cut = cut.substring(0, last_space); } return cut.strip() + "…"; } } /** * Appends the composer's message to a chat and kicks the next * background advisor turn, answering with the rebuilt `chat` slot * (the user's own row appears at once; the turn's appends stream * in through the pulse refresh). The chat id is sealed. */ public class SendAdvisorMessageAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } int64 viewer_id = ((!)me).user_id; int64 chat_id = ((!)ctx).chat_id; var chat = yield chat_service.get_chat(chat_id, viewer_id); if (chat == null) { return directives().notify("error", "That chat no longer exists") .navigate("/chats"); } string message = Forms.field_raw(request, "message"); // The question card answers through this action too (the // page's one sealed send DTO serves the composer, the // option buttons and the "Other" box): an answer resumes // a PARKED question, a message starts a normal turn. string answer = Forms.field_raw(request, "answer"); if (answer.strip().length > 0) { bool accepted = false; try { accepted = yield advisor_service.answer_question(chat_id, viewer_id, answer); } catch (Error e) { return directives().notify("error", @"Could not record the answer: $(e.message)"); } if (!accepted) { return directives().notify("error", "That question has already been answered"); } var answered = yield chat_service.get_chat(chat_id, viewer_id); if (answered == null) { return directives().navigate("/chats"); } var resumed = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, answered); return directives().set(resumed); } if (message.strip().length == 0) { var empty = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, chat); return directives().set(empty); } yield chat_service.record_user_message(chat_id, message.strip()); advisor_service.run_turn.begin(chat_id, viewer_id); var updated = yield chat_service.get_chat(chat_id, viewer_id); var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, updated); return directives().set(state); } } /** * Records the human's answer to the chat's pending question (the * question card's option buttons and "Other" box submit here via * the JSON transport) and resumes the turn; answers with the * rebuilt `chat` slot. The chat id is sealed. */ public class AnswerAdvisorQuestionAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } int64 viewer_id = ((!)me).user_id; int64 chat_id = ((!)ctx).chat_id; var chat = yield chat_service.get_chat(chat_id, viewer_id); if (chat == null) { return directives().notify("error", "That chat no longer exists") .navigate("/chats"); } string answer = Forms.field_raw(request, "answer"); // answer_question records the reply and runs the next // turn in its own scope; the answer arrives as this // action's reply. bool accepted = false; try { accepted = yield advisor_service.answer_question(chat_id, viewer_id, answer); } catch (Error e) { return directives().notify("error", @"Could not record the answer: $(e.message)"); } if (!accepted) { return directives().notify("error", "That question has already been answered").navigate( @"/chats/$(chat_id)"); } var updated = yield chat_service.get_chat(chat_id, viewer_id); if (updated == null) { return directives().navigate("/chats"); } var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, updated); return directives().set(state); } } /** * Deletes a chat (its messages and suggestions cascade) and sends * the browser to /chats. The chat id is sealed; deleting someone * else's id is a silent no-op (chats are private per user). */ public class DeleteAdvisorChatAction : TypedStatumAction { private ChatService chat_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } try { yield chat_service.delete_chat(((!)ctx).chat_id, ((!)me).user_id); return directives().notify("success", "Chat deleted") .navigate("/chats"); } catch (Error e) { return directives().notify("error", @"Could not delete the chat: $(e.message)") .navigate("/chats"); } } } /** * The chat page's pulse-driven refresh (goal 27): rebuilds the * `chat` slot from current rows so a running turn's appends — * tool calls, the assistant reply, a parked question, a * suggestion — stream in without a reload. Navigates nowhere. * Like the dashboard's refresh it is authored unsealed (GET): the * page it serves is already the viewer's own. */ public class RefreshAdvisorChatAction : StatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = 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); if (me == null) { return directives().navigate("/chats"); } int64 chat_id = Forms.integer(request, "chat_id", 0); if (chat_id <= 0) { return directives().navigate("/chats"); } var chat = yield chat_service.get_chat(chat_id, ((!)me).user_id); if (chat == null) { // Deleted mid-turn (or never the viewer's): nothing to // refresh — the page's own navigation takes over. return directives().navigate("/chats"); } var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, ((!)me).user_id, chat); return directives().set(state); } } /** * Decides a pending suggestion (goal 27): 'approve' creates the * draft, 'approve_enqueue' creates the draft AND queues it, * 'decline' dismisses it. THE HANDLER IS TASK 3'S (goal 27 task * 176): until it lands this base raises a plain "not yet * available" error so the chat UI can ship wired against the * final action name. The suggestion and chat ids are sealed; the * decision rides the `decision` field. */ public class DecideAdvisorSuggestionAction : TypedStatumAction { private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorSuggestionActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } // Goal 27 task 3 (approvals + completion callback) owns // the body: approve -> draft task/goal, approve_enqueue -> // draft + enqueue, decline -> dismissed row. return directives().notify("error", "Suggestion decisions are not available yet").navigate("/chats"); } } }