| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- 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<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = 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);
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private UserService users = inject<UserService>();
- 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<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = 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);
- 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<AdvisorSuggestionActionPrivate> {
- private UserService users = inject<UserService>();
- 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");
- }
- }
- }
|