ChatView.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. <script setup lang="ts">
  2. import { ref, onMounted, watch } from 'vue';
  3. import { Message, Conversation, Theme, Plugin, ContentType } from '../../api/dto';
  4. import { getMessagesByConversation } from '../../api/chat';
  5. import ChatHeader from './ChatHeader.vue';
  6. import ChatMessages from './ChatMessages.vue';
  7. import ChatInput from './ChatInput.vue';
  8. import { useGlobSetting } from '@jeesite/core/hooks/setting';
  9. const { ctxAdminPath } = useGlobSetting();
  10. interface Props {
  11. currentConversation: Conversation;
  12. plugins: Plugin[];
  13. }
  14. interface Emits {
  15. (e: 'toggle-theme'): void;
  16. }
  17. const props = defineProps<Props>();
  18. const emit = defineEmits<Emits>();
  19. const currentTheme = ref<Theme>('dark');
  20. const messages = ref<Message[]>([]);
  21. const isLoading = ref(false);
  22. const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
  23. function toggleTheme() {
  24. currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark';
  25. document.documentElement.setAttribute('data-theme', currentTheme.value);
  26. localStorage.setItem('soloncode-theme', currentTheme.value);
  27. emit('toggle-theme');
  28. }
  29. function loadTheme() {
  30. const savedTheme = localStorage.getItem('soloncode-theme') as Theme | null;
  31. if (savedTheme) {
  32. currentTheme.value = savedTheme;
  33. } else {
  34. const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  35. currentTheme.value = prefersDark ? 'dark' : 'light';
  36. }
  37. document.documentElement.setAttribute('data-theme', currentTheme.value);
  38. }
  39. async function sendMessage(messageText: string) {
  40. const userMessage: Message = {
  41. id: Date.now(),
  42. role: 'user',
  43. timestamps: new Date().toLocaleTimeString(),
  44. contents: [{ type: 'text', text: messageText }],
  45. };
  46. messages.value.push(userMessage);
  47. // 保存用户消息到数据库
  48. // await saveMessage({
  49. // conversationId: props.currentConversation.id,
  50. // role: 'user',
  51. // timestamps: userMessage.timestamps,
  52. // contents: userMessage.contents,
  53. // });
  54. isLoading.value = true;
  55. await chatMessagesRef.value?.scrollToBottom();
  56. try {
  57. const sessionId = props.currentConversation.id.toString();
  58. const url = `${ctxAdminPath}/ai/call?prompt=${encodeURIComponent(messageText)}&m=stream`;
  59. const response = await window.fetch(url, {
  60. method: 'GET',
  61. headers: {
  62. 'X-Session-Id': sessionId,
  63. },
  64. });
  65. if (!response.ok) {
  66. throw new Error(`HTTP error! status: ${response.status}`);
  67. }
  68. const reader = response.body?.getReader();
  69. const decoder = new TextDecoder();
  70. const currentType = ref<ContentType>();
  71. if (reader) {
  72. while (true) {
  73. const { done, value } = await reader.read();
  74. if (done) return;
  75. const chunk = decoder.decode(value, { stream: true });
  76. const lines = chunk.split('\n');
  77. for (const line of lines) {
  78. if (line.trim()) {
  79. try {
  80. let jsonStr = line.trim();
  81. if (jsonStr.startsWith('data:')) {
  82. jsonStr = jsonStr.substring(5).trim();
  83. }
  84. if (jsonStr === '[DONE]') {
  85. // 保存助手消息到数据库
  86. const assistantMsg = messages.value[messages.value.length - 1];
  87. if (assistantMsg && assistantMsg.role === 'assistant') {
  88. // await saveMessage({
  89. // conversationId: props.currentConversation.id,
  90. // role: 'assistant',
  91. // timestamps: assistantMsg.timestamps,
  92. // contents: assistantMsg.contents,
  93. // });
  94. }
  95. isLoading.value = false;
  96. await chatMessagesRef.value?.scrollToBottom();
  97. return;
  98. }
  99. const data = JSON.parse(jsonStr);
  100. const type = data.type as ContentType;
  101. let text = data.text || '';
  102. if (text === '') {
  103. continue;
  104. }
  105. if (currentType.value != type) {
  106. let isAddText = false;
  107. const addText = '\n```\n\n';
  108. if (currentType.value && (currentType.value == 'reason' || currentType.value == 'action')) {
  109. isAddText = true;
  110. }
  111. currentType.value = type;
  112. if (type === 'action') {
  113. text =
  114. '```md\n> ⚡ ' + data?.toolName ||
  115. '工具' + '\n' + JSON.stringify(data.args) + '\n' + text.substring(0, 5) + '...\n```\n\n';
  116. } else if (type === 'reason') {
  117. text = '```md\n> 🧠\n' + text;
  118. } else {
  119. text = '\n\n' + text;
  120. }
  121. if (isAddText) {
  122. text = addText + text;
  123. }
  124. }
  125. console.log(text);
  126. if (text || data.toolName || data.args) {
  127. // 获取或创建助手消息
  128. const lastMsg = messages.value[messages.value.length - 1];
  129. if (!lastMsg || lastMsg.role !== 'assistant') {
  130. const newMsg: Message = {
  131. id: Date.now() + Math.floor(Math.random() * 1000),
  132. role: 'assistant',
  133. timestamps: new Date().toLocaleTimeString(),
  134. contents: [],
  135. };
  136. messages.value.push(newMsg);
  137. }
  138. const assistantMsg = messages.value[messages.value.length - 1];
  139. const lastContent = assistantMsg.contents[assistantMsg.contents.length - 1];
  140. if (lastContent) {
  141. lastContent.text += text;
  142. } else {
  143. assistantMsg.contents.push({ type: 'text', text });
  144. }
  145. await chatMessagesRef.value?.scrollToBottom();
  146. }
  147. } catch (e) {
  148. console.warn('Failed to parse chunk:', line, e);
  149. }
  150. }
  151. }
  152. }
  153. }
  154. } catch (error) {
  155. console.error('Failed to send message:', error);
  156. const errorMessage: Message = {
  157. id: Date.now() + 1,
  158. role: 'error',
  159. timestamps: new Date().toLocaleTimeString(),
  160. contents: [{ type: 'error', text: `请求失败: ${error instanceof Error ? error.message : '未知错误'}` }],
  161. };
  162. messages.value.push(errorMessage);
  163. // 保存错误消息到数据库
  164. // await saveMessage({
  165. // conversationId: props.currentConversation.id,
  166. // role: 'error',
  167. // timestamps: errorMessage.timestamps,
  168. // contents: errorMessage.contents,
  169. // });
  170. } finally {
  171. isLoading.value = false;
  172. await chatMessagesRef.value?.scrollToBottom();
  173. }
  174. }
  175. async function loadSolonClawMessages() {
  176. // 优先从数据库加载历史消息
  177. const storedMessages = await getMessagesByConversation('SolonClaw');
  178. if (storedMessages.length > 0) {
  179. messages.value = storedMessages.map((msg) => ({
  180. ...msg,
  181. contents: msg.contents,
  182. }));
  183. } else {
  184. messages.value = [
  185. {
  186. id: 1,
  187. role: 'assistant',
  188. timestamps: new Date().toLocaleTimeString(),
  189. contents: [
  190. {
  191. type: 'text',
  192. text: '🦊 猎影 已启动\n\n我是AI数据研判专家。我可以帮助你:\n\n处理和分析银行账单、第三方支付流水、通讯话单(CDR)、基站轨迹等多源异构数据。\n\n擅长利用图算法、时序分析和异常检测模型从杂乱无章的数据中挖掘出隐蔽的贿赂线索、利益输送链条和攻守同盟关系。\n\n请告诉我你需要什么帮助?',
  193. },
  194. ],
  195. },
  196. ];
  197. }
  198. }
  199. async function loadConversationMessages(convId: string | number) {
  200. const storedMessages = await getMessagesByConversation(convId);
  201. if (storedMessages.length > 0) {
  202. messages.value = storedMessages.map((msg) => ({
  203. ...msg,
  204. contents: msg.contents,
  205. }));
  206. } else {
  207. messages.value = [
  208. {
  209. id: 1,
  210. role: 'assistant',
  211. timestamps: new Date().toLocaleTimeString(),
  212. contents: [
  213. {
  214. type: 'text',
  215. text: '你好!我是 猎影 助手。有什么我可以帮助你的吗?',
  216. },
  217. ],
  218. },
  219. ];
  220. }
  221. }
  222. watch(
  223. () => props.currentConversation.id,
  224. (newId) => {
  225. if (newId === 'SolonClaw' && props.currentConversation.isPermanent) {
  226. loadSolonClawMessages();
  227. } else {
  228. loadConversationMessages(newId);
  229. }
  230. },
  231. { immediate: true },
  232. );
  233. onMounted(() => {
  234. loadTheme();
  235. });
  236. </script>
  237. <template>
  238. <main class="main-content">
  239. <ChatHeader
  240. :title="props.currentConversation.title"
  241. :status="props.currentConversation.status"
  242. :theme="currentTheme"
  243. @toggle-theme="toggleTheme"
  244. />
  245. <ChatMessages ref="chatMessagesRef" :messages="messages" :is-loading="isLoading" :theme="currentTheme" />
  246. <ChatInput @send="sendMessage" />
  247. </main>
  248. </template>
  249. <style scoped>
  250. .main-content {
  251. flex: 1;
  252. display: flex;
  253. flex-direction: column;
  254. overflow: hidden;
  255. height: 100%;
  256. }
  257. </style>