AdvisorActions.vala 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. using Inversion;
  2. using Spry;
  3. using Spry.Authentication;
  4. using Statum;
  5. using ClankerSaddle.Services;
  6. namespace ClankerSaddle {
  7. /** Sealed chat id carried by the advisor chat actions. */
  8. public class AdvisorChatActionPrivate : Object {
  9. public int64 chat_id { get; set; }
  10. }
  11. /**
  12. * Sealed suggestion + chat ids carried by the suggestion decision
  13. * action (goal 27 task 3 owns its handler).
  14. */
  15. public class AdvisorSuggestionActionPrivate : Object {
  16. public int64 suggestion_id { get; set; }
  17. public int64 chat_id { get; set; }
  18. }
  19. /**
  20. * Starts a new advisor chat (goal 27) from the dashboard's "Ask
  21. * the advisor" composer: creates the chat titled from the first
  22. * line of the message (clamped ~80 chars), records the message as
  23. * the opening user row and kicks the background
  24. * {@link AdvisorService} turn. The POST returns before the turn
  25. * finishes — the chat page's pulse-driven refresh streams the
  26. * appends in — and the browser navigates to /chats/{id}.
  27. */
  28. public class StartAdvisorChatAction : StatumAction {
  29. private ChatService chat_service = inject<ChatService>();
  30. private AdvisorService advisor_service = inject<AdvisorService>();
  31. private UserService users = inject<UserService>();
  32. public override async DirectiveBuilder handle() throws GLib.Error {
  33. var guard = yield SpryAuth.require_login(directives(), request, users);
  34. if (guard != null) {
  35. return (!)guard;
  36. }
  37. var me = SpryAuth.current(request);
  38. if (me == null) {
  39. return directives().navigate("/");
  40. }
  41. int64 viewer_id = ((!)me).user_id;
  42. string message = Forms.field_raw(request, "message");
  43. if (message.strip().length == 0) {
  44. return directives().notify("error",
  45. "Type a question for the advisor first").navigate("/");
  46. }
  47. var chat = yield chat_service.create_chat(viewer_id,
  48. chat_title(message));
  49. yield chat_service.record_user_message(chat.id, message.strip());
  50. // The turn runs in the service's own scope; this request
  51. // finishes (and navigates) immediately.
  52. advisor_service.run_turn.begin(chat.id, viewer_id);
  53. return directives().navigate(@"/chats/$(chat.id)");
  54. }
  55. /**
  56. * The chat's title: the message's first non-empty line,
  57. * clamped to ~80 characters on a word boundary.
  58. */
  59. private static string chat_title(string message) {
  60. string first = "";
  61. foreach (string line in message.strip().split("\n")) {
  62. if (line.strip().length > 0) {
  63. first = line.strip();
  64. break;
  65. }
  66. }
  67. if (first.length <= 80) {
  68. return first;
  69. }
  70. string cut = first.substring(0, 80);
  71. int last_space = cut.last_index_of(" ");
  72. if (last_space > 40) {
  73. cut = cut.substring(0, last_space);
  74. }
  75. return cut.strip() + "…";
  76. }
  77. }
  78. /**
  79. * Appends the composer's message to a chat and kicks the next
  80. * background advisor turn, answering with the rebuilt `chat` slot
  81. * (the user's own row appears at once; the turn's appends stream
  82. * in through the pulse refresh). The chat id is sealed.
  83. */
  84. public class SendAdvisorMessageAction : TypedStatumAction<AdvisorChatActionPrivate> {
  85. private ChatService chat_service = inject<ChatService>();
  86. private AdvisorService advisor_service = inject<AdvisorService>();
  87. private UserService users = inject<UserService>();
  88. protected override Type private_type {
  89. get { return typeof(AdvisorChatActionPrivate); }
  90. }
  91. public override async DirectiveBuilder handle() throws GLib.Error {
  92. var guard = yield SpryAuth.require_login(directives(), request, users);
  93. if (guard != null) {
  94. return (!)guard;
  95. }
  96. var ctx = request_private;
  97. if (ctx == null) {
  98. return directives().navigate("/chats");
  99. }
  100. var me = SpryAuth.current(request);
  101. if (me == null) {
  102. return directives().navigate("/chats");
  103. }
  104. int64 viewer_id = ((!)me).user_id;
  105. int64 chat_id = ((!)ctx).chat_id;
  106. var chat = yield chat_service.get_chat(chat_id, viewer_id);
  107. if (chat == null) {
  108. return directives().notify("error", "That chat no longer exists")
  109. .navigate("/chats");
  110. }
  111. string message = Forms.field_raw(request, "message");
  112. // The question card answers through this action too (the
  113. // page's one sealed send DTO serves the composer, the
  114. // option buttons and the "Other" box): an answer resumes
  115. // a PARKED question, a message starts a normal turn.
  116. string answer = Forms.field_raw(request, "answer");
  117. if (answer.strip().length > 0) {
  118. bool accepted = false;
  119. try {
  120. accepted = yield advisor_service.answer_question(chat_id,
  121. viewer_id, answer);
  122. } catch (Error e) {
  123. return directives().notify("error",
  124. @"Could not record the answer: $(e.message)");
  125. }
  126. if (!accepted) {
  127. return directives().notify("error",
  128. "That question has already been answered");
  129. }
  130. var answered = yield chat_service.get_chat(chat_id, viewer_id);
  131. if (answered == null) {
  132. return directives().navigate("/chats");
  133. }
  134. var resumed = yield ChatState.build_from(state_service,
  135. chat_service, advisor_service, action_registry, viewer_id,
  136. answered);
  137. return directives().set(resumed);
  138. }
  139. if (message.strip().length == 0) {
  140. var empty = yield ChatState.build_from(state_service,
  141. chat_service, advisor_service, action_registry, viewer_id,
  142. chat);
  143. return directives().set(empty);
  144. }
  145. yield chat_service.record_user_message(chat_id, message.strip());
  146. advisor_service.run_turn.begin(chat_id, viewer_id);
  147. var updated = yield chat_service.get_chat(chat_id, viewer_id);
  148. var state = yield ChatState.build_from(state_service, chat_service,
  149. advisor_service, action_registry, viewer_id, updated);
  150. return directives().set(state);
  151. }
  152. }
  153. /**
  154. * Records the human's answer to the chat's pending question (the
  155. * question card's option buttons and "Other" box submit here via
  156. * the JSON transport) and resumes the turn; answers with the
  157. * rebuilt `chat` slot. The chat id is sealed.
  158. */
  159. public class AnswerAdvisorQuestionAction : TypedStatumAction<AdvisorChatActionPrivate> {
  160. private ChatService chat_service = inject<ChatService>();
  161. private AdvisorService advisor_service = inject<AdvisorService>();
  162. private UserService users = inject<UserService>();
  163. protected override Type private_type {
  164. get { return typeof(AdvisorChatActionPrivate); }
  165. }
  166. public override async DirectiveBuilder handle() throws GLib.Error {
  167. var guard = yield SpryAuth.require_login(directives(), request, users);
  168. if (guard != null) {
  169. return (!)guard;
  170. }
  171. var ctx = request_private;
  172. if (ctx == null) {
  173. return directives().navigate("/chats");
  174. }
  175. var me = SpryAuth.current(request);
  176. if (me == null) {
  177. return directives().navigate("/chats");
  178. }
  179. int64 viewer_id = ((!)me).user_id;
  180. int64 chat_id = ((!)ctx).chat_id;
  181. var chat = yield chat_service.get_chat(chat_id, viewer_id);
  182. if (chat == null) {
  183. return directives().notify("error", "That chat no longer exists")
  184. .navigate("/chats");
  185. }
  186. string answer = Forms.field_raw(request, "answer");
  187. // answer_question records the reply and runs the next
  188. // turn in its own scope; the answer arrives as this
  189. // action's reply.
  190. bool accepted = false;
  191. try {
  192. accepted = yield advisor_service.answer_question(chat_id,
  193. viewer_id, answer);
  194. } catch (Error e) {
  195. return directives().notify("error",
  196. @"Could not record the answer: $(e.message)");
  197. }
  198. if (!accepted) {
  199. return directives().notify("error",
  200. "That question has already been answered").navigate(
  201. @"/chats/$(chat_id)");
  202. }
  203. var updated = yield chat_service.get_chat(chat_id, viewer_id);
  204. if (updated == null) {
  205. return directives().navigate("/chats");
  206. }
  207. var state = yield ChatState.build_from(state_service, chat_service,
  208. advisor_service, action_registry, viewer_id, updated);
  209. return directives().set(state);
  210. }
  211. }
  212. /**
  213. * Deletes a chat (its messages and suggestions cascade) and sends
  214. * the browser to /chats. The chat id is sealed; deleting someone
  215. * else's id is a silent no-op (chats are private per user).
  216. */
  217. public class DeleteAdvisorChatAction : TypedStatumAction<AdvisorChatActionPrivate> {
  218. private ChatService chat_service = inject<ChatService>();
  219. private UserService users = inject<UserService>();
  220. protected override Type private_type {
  221. get { return typeof(AdvisorChatActionPrivate); }
  222. }
  223. public override async DirectiveBuilder handle() throws GLib.Error {
  224. var guard = yield SpryAuth.require_login(directives(), request, users);
  225. if (guard != null) {
  226. return (!)guard;
  227. }
  228. var ctx = request_private;
  229. if (ctx == null) {
  230. return directives().navigate("/chats");
  231. }
  232. var me = SpryAuth.current(request);
  233. if (me == null) {
  234. return directives().navigate("/chats");
  235. }
  236. try {
  237. yield chat_service.delete_chat(((!)ctx).chat_id,
  238. ((!)me).user_id);
  239. return directives().notify("success", "Chat deleted")
  240. .navigate("/chats");
  241. } catch (Error e) {
  242. return directives().notify("error",
  243. @"Could not delete the chat: $(e.message)")
  244. .navigate("/chats");
  245. }
  246. }
  247. }
  248. /**
  249. * The chat page's pulse-driven refresh (goal 27): rebuilds the
  250. * `chat` slot from current rows so a running turn's appends —
  251. * tool calls, the assistant reply, a parked question, a
  252. * suggestion — stream in without a reload. Navigates nowhere.
  253. * Like the dashboard's refresh it is authored unsealed (GET): the
  254. * page it serves is already the viewer's own.
  255. */
  256. public class RefreshAdvisorChatAction : StatumAction {
  257. private ChatService chat_service = inject<ChatService>();
  258. private AdvisorService advisor_service = inject<AdvisorService>();
  259. private UserService users = inject<UserService>();
  260. public override async DirectiveBuilder handle() throws GLib.Error {
  261. var guard = yield SpryAuth.require_login(directives(), request, users);
  262. if (guard != null) {
  263. return (!)guard;
  264. }
  265. var me = SpryAuth.current(request);
  266. if (me == null) {
  267. return directives().navigate("/chats");
  268. }
  269. int64 chat_id = Forms.integer(request, "chat_id", 0);
  270. if (chat_id <= 0) {
  271. return directives().navigate("/chats");
  272. }
  273. var chat = yield chat_service.get_chat(chat_id, ((!)me).user_id);
  274. if (chat == null) {
  275. // Deleted mid-turn (or never the viewer's): nothing to
  276. // refresh — the page's own navigation takes over.
  277. return directives().navigate("/chats");
  278. }
  279. var state = yield ChatState.build_from(state_service, chat_service,
  280. advisor_service, action_registry, ((!)me).user_id, chat);
  281. return directives().set(state);
  282. }
  283. }
  284. /**
  285. * Decides a pending suggestion (goal 27): 'approve' creates the
  286. * draft, 'approve_enqueue' creates the draft AND queues it,
  287. * 'decline' dismisses it. THE HANDLER IS TASK 3'S (goal 27 task
  288. * 176): until it lands this base raises a plain "not yet
  289. * available" error so the chat UI can ship wired against the
  290. * final action name. The suggestion and chat ids are sealed; the
  291. * decision rides the `decision` field.
  292. */
  293. public class DecideAdvisorSuggestionAction : TypedStatumAction<AdvisorSuggestionActionPrivate> {
  294. private UserService users = inject<UserService>();
  295. protected override Type private_type {
  296. get { return typeof(AdvisorSuggestionActionPrivate); }
  297. }
  298. public override async DirectiveBuilder handle() throws GLib.Error {
  299. var guard = yield SpryAuth.require_login(directives(), request, users);
  300. if (guard != null) {
  301. return (!)guard;
  302. }
  303. // Goal 27 task 3 (approvals + completion callback) owns
  304. // the body: approve -> draft task/goal, approve_enqueue ->
  305. // draft + enqueue, decline -> dismissed row.
  306. return directives().notify("error",
  307. "Suggestion decisions are not available yet").navigate("/chats");
  308. }
  309. }
  310. }