terminalStore.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { defineStore } from 'pinia';
  2. import type { CardInstance, ChatMessage, TaskState } from '../api/types';
  3. import { connectChatStream, type ParsedSseEvent } from '../api/sse';
  4. import { fetchCard, submitCardAction } from '../api/client';
  5. export type ContextPanelMode = 'idle' | 'loading' | 'task' | 'error' | 'completed';
  6. export type ConnectionStatus = 'online' | 'disconnected';
  7. export interface CardPreview {
  8. id: string;
  9. cardKey: string;
  10. title: string;
  11. subtitle: string;
  12. }
  13. const CARD_TITLE_MAP: Record<string, string> = {
  14. 'department-selection': '科室推荐',
  15. 'doctor-selection': '医生选择',
  16. 'time-slot-selection': '就诊时间',
  17. 'confirm-appointment': '确认预约',
  18. 'payment-qrcode': '支付',
  19. 'appointment-success': '挂号成功',
  20. };
  21. export const useTerminalStore = defineStore('terminal', {
  22. state: () => ({
  23. deviceId: 'EMOON-WEB-DEMO-001',
  24. deviceType: 'self_service_kiosk',
  25. conversationId: '' as string,
  26. currentTraceId: '' as string,
  27. messages: [] as ChatMessage[],
  28. streaming: false,
  29. task: null as TaskState | null,
  30. activeCard: null as CardInstance | null,
  31. cardHistory: [] as CardInstance[],
  32. viewingCardId: null as string | null,
  33. cardPreviews: [] as CardPreview[],
  34. errorMessage: '' as string,
  35. connectionStatus: 'online' as ConnectionStatus,
  36. inputText: '',
  37. pendingActionKey: '',
  38. demoMode: import.meta.env.VITE_DEMO_MODE === 'true',
  39. sending: false,
  40. selection: {} as Record<string, string>,
  41. }),
  42. getters: {
  43. contextPanelMode(state): ContextPanelMode {
  44. if (state.errorMessage) return 'error';
  45. if (state.sending && !state.activeCard && !state.viewingCardId) return 'loading';
  46. if (this.displayedCard?.cardKey === 'appointment-success') return 'completed';
  47. if (this.displayedCard) return 'task';
  48. return 'idle';
  49. },
  50. displayedCard(state): CardInstance | null {
  51. if (state.viewingCardId) {
  52. return state.cardHistory.find((c) => c.cardInstanceId === state.viewingCardId) ?? state.activeCard;
  53. }
  54. return state.activeCard;
  55. },
  56. isDemo(): boolean {
  57. return this.demoMode;
  58. },
  59. },
  60. actions: {
  61. addMessage(message: ChatMessage) {
  62. this.messages.push(message);
  63. },
  64. appendAssistantDelta(text: string) {
  65. const last = this.messages[this.messages.length - 1];
  66. if (last?.role === 'assistant' && last.streaming) {
  67. last.content += text;
  68. return;
  69. }
  70. this.messages.push({
  71. id: crypto.randomUUID(),
  72. role: 'assistant',
  73. content: text,
  74. streaming: true,
  75. });
  76. },
  77. completeAssistantMessage() {
  78. const last = this.messages[this.messages.length - 1];
  79. if (last?.role === 'assistant') last.streaming = false;
  80. },
  81. setTask(task: TaskState) {
  82. this.task = task;
  83. },
  84. setActiveCard(card: CardInstance) {
  85. this.activeCard = card;
  86. this.viewingCardId = null;
  87. this.cardHistory.push(card);
  88. this.errorMessage = '';
  89. // Push mini card preview for chat feed
  90. const cardKey = card.cardKey;
  91. const title = CARD_TITLE_MAP[cardKey] || cardKey;
  92. const data = card.cardData || {};
  93. let subtitle = '';
  94. if (cardKey === 'department-selection') {
  95. const depts = data.departments as Array<{ name: string }> | undefined;
  96. subtitle = depts?.[0]?.name ? `${depts[0].name}${depts.length > 1 ? '等' + depts.length + '个科室' : ''}` : '';
  97. } else if (cardKey === 'doctor-selection') {
  98. const docs = data.doctors as Array<{ doctorName: string }> | undefined;
  99. subtitle = docs?.[0]?.doctorName || '';
  100. } else if (cardKey === 'time-slot-selection') {
  101. const slots = data.slots as Array<{ timePeriod: string }> | undefined;
  102. subtitle = slots?.[0]?.timePeriod || '';
  103. } else if (cardKey === 'confirm-appointment') {
  104. subtitle = '请确认预约信息';
  105. } else if (cardKey === 'payment-qrcode') {
  106. const amount = data.amountYuan || '0.00';
  107. subtitle = `Mock支付 ¥${amount}`;
  108. } else if (cardKey === 'appointment-success') {
  109. subtitle = '预约已确认';
  110. }
  111. this.cardPreviews.push({ id: card.cardInstanceId, cardKey, title, subtitle });
  112. },
  113. setError(message: string, traceId = '') {
  114. this.errorMessage = message;
  115. this.currentTraceId = traceId;
  116. },
  117. viewCard(cardInstanceId: string | null) {
  118. this.viewingCardId = cardInstanceId;
  119. },
  120. resetToIdle() {
  121. this.task = null;
  122. this.activeCard = null;
  123. this.viewingCardId = null;
  124. this.cardHistory = [];
  125. this.cardPreviews = [];
  126. this.errorMessage = '';
  127. this.pendingActionKey = '';
  128. this.selection = {};
  129. },
  130. saveSelection(payload: Record<string, unknown>) {
  131. for (const [key, value] of Object.entries(payload)) {
  132. if (typeof value === 'string') {
  133. this.selection[key] = value;
  134. }
  135. }
  136. },
  137. handleSseEvent(event: ParsedSseEvent) {
  138. switch (event.type) {
  139. case 'task_updated': {
  140. const data = event.data as unknown as TaskState;
  141. this.setTask(data);
  142. break;
  143. }
  144. case 'message_delta': {
  145. const text = event.data.text as string;
  146. if (text) this.appendAssistantDelta(text);
  147. this.streaming = true;
  148. break;
  149. }
  150. case 'message_completed': {
  151. this.completeAssistantMessage();
  152. this.streaming = false;
  153. if (event.data.conversationId) {
  154. this.conversationId = event.data.conversationId as string;
  155. }
  156. break;
  157. }
  158. case 'card_created': {
  159. const cardInstanceId = event.data.cardInstanceId as string;
  160. if (cardInstanceId) {
  161. fetchCard(cardInstanceId)
  162. .then((card) => this.setActiveCard(card))
  163. .catch((err) => this.setError(err.message));
  164. }
  165. break;
  166. }
  167. case 'error': {
  168. this.setError(
  169. (event.data.message as string) ?? '未知错误',
  170. (event.data.traceId as string) ?? '',
  171. );
  172. this.streaming = false;
  173. break;
  174. }
  175. case 'completed': {
  176. this.streaming = false;
  177. break;
  178. }
  179. }
  180. },
  181. async sendMessage(text: string): Promise<void> {
  182. this.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
  183. this.errorMessage = '';
  184. this.sending = true;
  185. this.streaming = true;
  186. return new Promise((resolve, reject) => {
  187. connectChatStream({
  188. deviceId: this.deviceId,
  189. deviceType: this.deviceType,
  190. text,
  191. conversationId: this.conversationId || undefined,
  192. onEvent: (event) => this.handleSseEvent(event),
  193. onError: (error) => {
  194. this.setError(error.message);
  195. this.streaming = false;
  196. this.sending = false;
  197. reject(error);
  198. },
  199. onComplete: () => {
  200. this.streaming = false;
  201. this.sending = false;
  202. resolve();
  203. },
  204. });
  205. });
  206. },
  207. async submitCard(payload: Record<string, unknown>): Promise<void> {
  208. if (!this.activeCard) return;
  209. const actionName = this.activeCard.actions[0]?.actionName;
  210. if (!actionName) return;
  211. this.pendingActionKey = `${this.activeCard.cardInstanceId}-${actionName}`;
  212. this.errorMessage = '';
  213. try {
  214. const result = await submitCardAction(
  215. this.activeCard.cardInstanceId,
  216. actionName,
  217. payload,
  218. );
  219. if (result.nextCard?.cardInstanceId) {
  220. const fullCard = await fetchCard(result.nextCard.cardInstanceId);
  221. this.setActiveCard(fullCard);
  222. }
  223. } catch (err) {
  224. this.setError((err as Error).message);
  225. throw err;
  226. }
  227. },
  228. },
  229. });