chat-slice.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { createSlice, PayloadAction } from "@reduxjs/toolkit";
  2. import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
  3. import {
  4. OpenHandsObservation,
  5. CommandObservation,
  6. IPythonObservation,
  7. } from "#/types/core/observations";
  8. import { OpenHandsAction } from "#/types/core/actions";
  9. import { OpenHandsEventType } from "#/types/core/base";
  10. type SliceState = { messages: Message[] };
  11. const MAX_CONTENT_LENGTH = 1000;
  12. const HANDLED_ACTIONS: OpenHandsEventType[] = [
  13. "run",
  14. "run_ipython",
  15. "write",
  16. "read",
  17. "browse",
  18. ];
  19. function getRiskText(risk: ActionSecurityRisk) {
  20. switch (risk) {
  21. case ActionSecurityRisk.LOW:
  22. return "Low Risk";
  23. case ActionSecurityRisk.MEDIUM:
  24. return "Medium Risk";
  25. case ActionSecurityRisk.HIGH:
  26. return "High Risk";
  27. case ActionSecurityRisk.UNKNOWN:
  28. default:
  29. return "Unknown Risk";
  30. }
  31. }
  32. const initialState: SliceState = {
  33. messages: [],
  34. };
  35. export const chatSlice = createSlice({
  36. name: "chat",
  37. initialState,
  38. reducers: {
  39. addUserMessage(
  40. state,
  41. action: PayloadAction<{
  42. content: string;
  43. imageUrls: string[];
  44. timestamp: string;
  45. pending?: boolean;
  46. }>,
  47. ) {
  48. const message: Message = {
  49. type: "thought",
  50. sender: "user",
  51. content: action.payload.content,
  52. imageUrls: action.payload.imageUrls,
  53. timestamp: action.payload.timestamp || new Date().toISOString(),
  54. pending: !!action.payload.pending,
  55. };
  56. // Remove any pending messages
  57. let i = state.messages.length;
  58. while (i) {
  59. i -= 1;
  60. const m = state.messages[i] as Message;
  61. if (m.pending) {
  62. state.messages.splice(i, 1);
  63. }
  64. }
  65. state.messages.push(message);
  66. },
  67. addAssistantMessage(state, action: PayloadAction<string>) {
  68. const message: Message = {
  69. type: "thought",
  70. sender: "assistant",
  71. content: action.payload,
  72. imageUrls: [],
  73. timestamp: new Date().toISOString(),
  74. pending: false,
  75. };
  76. state.messages.push(message);
  77. },
  78. addAssistantAction(state, action: PayloadAction<OpenHandsAction>) {
  79. const actionID = action.payload.action;
  80. if (!HANDLED_ACTIONS.includes(actionID)) {
  81. return;
  82. }
  83. const translationID = `ACTION_MESSAGE$${actionID.toUpperCase()}`;
  84. let text = "";
  85. if (actionID === "run") {
  86. text = `\`${action.payload.args.command}\``;
  87. } else if (actionID === "run_ipython") {
  88. text = `\`\`\`\n${action.payload.args.code}\n\`\`\``;
  89. } else if (actionID === "write") {
  90. let { content } = action.payload.args;
  91. if (content.length > MAX_CONTENT_LENGTH) {
  92. content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
  93. }
  94. text = `${action.payload.args.path}\n${content}`;
  95. } else if (actionID === "read") {
  96. text = action.payload.args.path;
  97. } else if (actionID === "browse") {
  98. text = `Browsing ${action.payload.args.url}`;
  99. }
  100. if (actionID === "run" || actionID === "run_ipython") {
  101. if (
  102. action.payload.args.confirmation_state === "awaiting_confirmation"
  103. ) {
  104. text += `\n\n${getRiskText(action.payload.args.security_risk as unknown as ActionSecurityRisk)}`;
  105. }
  106. }
  107. const message: Message = {
  108. type: "action",
  109. sender: "assistant",
  110. translationID,
  111. eventID: action.payload.id,
  112. content: text,
  113. imageUrls: [],
  114. timestamp: new Date().toISOString(),
  115. };
  116. state.messages.push(message);
  117. },
  118. addAssistantObservation(
  119. state,
  120. observation: PayloadAction<OpenHandsObservation>,
  121. ) {
  122. const observationID = observation.payload.observation;
  123. if (!HANDLED_ACTIONS.includes(observationID)) {
  124. return;
  125. }
  126. const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
  127. const causeID = observation.payload.cause;
  128. const causeMessage = state.messages.find(
  129. (message) => message.eventID === causeID,
  130. );
  131. if (!causeMessage) {
  132. return;
  133. }
  134. causeMessage.translationID = translationID;
  135. // Set success property based on observation type
  136. if (observationID === "run") {
  137. const commandObs = observation.payload as CommandObservation;
  138. causeMessage.success = commandObs.extras.exit_code === 0;
  139. } else if (observationID === "run_ipython") {
  140. // For IPython, we consider it successful if there's no error message
  141. const ipythonObs = observation.payload as IPythonObservation;
  142. causeMessage.success = !ipythonObs.message
  143. .toLowerCase()
  144. .includes("error");
  145. }
  146. if (observationID === "run" || observationID === "run_ipython") {
  147. let { content } = observation.payload;
  148. if (content.length > MAX_CONTENT_LENGTH) {
  149. content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
  150. }
  151. content = `\`\`\`\n${content}\n\`\`\``;
  152. causeMessage.content = content; // Observation content includes the action
  153. } else if (observationID === "browse") {
  154. let content = `**URL:** ${observation.payload.extras.url}\n`;
  155. if (observation.payload.extras.error) {
  156. content += `**Error:**\n${observation.payload.extras.error}\n`;
  157. }
  158. content += `**Output:**\n${observation.payload.content}`;
  159. if (content.length > MAX_CONTENT_LENGTH) {
  160. content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
  161. }
  162. causeMessage.content = content;
  163. }
  164. },
  165. addErrorMessage(
  166. state,
  167. action: PayloadAction<{ id?: string; message: string }>,
  168. ) {
  169. const { id, message } = action.payload;
  170. state.messages.push({
  171. translationID: id,
  172. content: message,
  173. type: "error",
  174. sender: "assistant",
  175. timestamp: new Date().toISOString(),
  176. });
  177. },
  178. clearMessages(state) {
  179. state.messages = [];
  180. },
  181. },
  182. });
  183. export const {
  184. addUserMessage,
  185. addAssistantMessage,
  186. addAssistantAction,
  187. addAssistantObservation,
  188. addErrorMessage,
  189. clearMessages,
  190. } = chatSlice.actions;
  191. export default chatSlice.reducer;