diff --git a/backend/src/main/java/com/zl/mjga/config/ai/DeepSeekChatAssistant.java b/backend/src/main/java/com/zl/mjga/config/ai/DeepSeekChatAssistant.java index 75d34cf..8cfe8cd 100644 --- a/backend/src/main/java/com/zl/mjga/config/ai/DeepSeekChatAssistant.java +++ b/backend/src/main/java/com/zl/mjga/config/ai/DeepSeekChatAssistant.java @@ -7,6 +7,5 @@ import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.memory.ChatMemoryAccess; public interface DeepSeekChatAssistant extends ChatMemoryAccess { - @SystemMessage("You are a good friend of mine. Answer using slang.") TokenStream chat(@MemoryId String memoryId, @UserMessage String userMessage); } diff --git a/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java b/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java index 2ac8d7d..50e96ed 100644 --- a/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java +++ b/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java @@ -39,7 +39,6 @@ public class WebSecurityConfig { return new OrRequestMatcher( new AntPathRequestMatcher("/auth/sign-in", HttpMethod.POST.name()), new AntPathRequestMatcher("/auth/sign-up", HttpMethod.POST.name()), - new AntPathRequestMatcher("/ai/**", HttpMethod.POST.name()), new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()), new AntPathRequestMatcher("/swagger-ui/**", HttpMethod.GET.name()), new AntPathRequestMatcher("/swagger-ui.html", HttpMethod.GET.name()), @@ -63,6 +62,9 @@ public class WebSecurityConfig { .permitAll() .anyRequest() .authenticated()) + .securityContext(securityContext -> securityContext + .requireExplicitSave(false) + ) .exceptionHandling( (exceptionHandling) -> exceptionHandling diff --git a/backend/src/main/java/com/zl/mjga/controller/AiController.java b/backend/src/main/java/com/zl/mjga/controller/AiController.java index 4dc2d63..e137ce1 100644 --- a/backend/src/main/java/com/zl/mjga/controller/AiController.java +++ b/backend/src/main/java/com/zl/mjga/controller/AiController.java @@ -2,14 +2,14 @@ package com.zl.mjga.controller; import com.zl.mjga.service.DeepSeekAiService; import dev.langchain4j.service.TokenStream; + +import java.security.Principal; import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; @@ -22,20 +22,20 @@ public class AiController { private final DeepSeekAiService deepSeekAiService; @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public Flux chat(@RequestBody String userMessage) { + public Flux chat(Principal principal, @RequestBody String userMessage) { Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - TokenStream chat = deepSeekAiService.chat("123", userMessage); + TokenStream chat = deepSeekAiService.chat(principal.getName(), userMessage); chat.onPartialResponse(sink::tryEmitNext) .onCompleteResponse( r -> { - sink.tryEmitNext("[DONE]"); sink.tryEmitComplete(); + sink.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST); }) .onError(sink::tryEmitError) .start(); - - return sink.asFlux() - .timeout(Duration.ofSeconds(60)) - .onErrorResume(e -> Flux.just("Timeout occurred")); + return sink.asFlux() + .timeout(Duration.ofSeconds(120)) + .doOnCancel(SecurityContextHolder::clearContext) + .doOnTerminate(SecurityContextHolder::clearContext); } } diff --git a/frontend/.env b/frontend/.env index cbc4b2d..bd687de 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,11 +1,11 @@ VITE_APP_PORT=5173 VITE_SOURCE_MAP=true # mock -VITE_ENABLE_MOCK=true -VITE_BASE_URL=http://localhost:5173 +#VITE_ENABLE_MOCK=true +#VITE_BASE_URL=http://localhost:5173 # local -#VITE_ENABLE_MOCK=false -#VITE_BASE_URL=http://localhost:8080 +VITE_ENABLE_MOCK=false +VITE_BASE_URL=http://localhost:8080 # dev #VITE_ENABLE_MOCK=false #VITE_BASE_URL=https://localhost/api diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 95a221b..9328407 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "1.0", "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.0.14", "@vueuse/core": "^13.0.0", "apexcharts": "^3.46.0", @@ -1318,6 +1319,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "http://mirrors.tencent.com/npm/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37cc923..bcbf0ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "type-check": "vue-tsc --build" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.0.14", "@vueuse/core": "^13.0.0", "apexcharts": "^3.46.0", @@ -51,6 +52,8 @@ "vue-tsc": "^2.2.8" }, "msw": { - "workerDirectory": ["public"] + "workerDirectory": [ + "public" + ] } } diff --git a/frontend/src/api/mocks/aiHandlers.ts b/frontend/src/api/mocks/aiHandlers.ts index d5b0ae9..72814b2 100644 --- a/frontend/src/api/mocks/aiHandlers.ts +++ b/frontend/src/api/mocks/aiHandlers.ts @@ -4,7 +4,7 @@ import { http, HttpResponse } from "msw"; export default [ http.post("/ai/chat", () => { const response = HttpResponse.json({ - message: faker.lorem.sentence(1000), + message: faker.lorem.sentence({ min: 100, max: 300 }), }); return response; }), diff --git a/frontend/src/composables/ai/useAiChat.ts b/frontend/src/composables/ai/useAiChat.ts index b085a18..0853c22 100644 --- a/frontend/src/composables/ai/useAiChat.ts +++ b/frontend/src/composables/ai/useAiChat.ts @@ -1,31 +1,54 @@ +import { fetchEventSource } from "@microsoft/fetch-event-source"; import { ref } from "vue"; -import client from "../../api/client"; +import useAuthStore from "../store/useAuthStore"; + +const authStore = useAuthStore(); export const useAiChat = () => { const messages = ref([]); const isLoading = ref(false); + let currentController: AbortController | null = null; + const chat = async (message: string) => { isLoading.value = true; + messages.value.push(message); + messages.value.push(""); + const ctrl = new AbortController(); + currentController = ctrl; + try { - const { response } = await client.POST("/ai/chat", { + const baseUrl = `${import.meta.env.VITE_BASE_URL}`; + + await fetchEventSource(`${baseUrl}/ai/chat`, { + method: "POST", + headers: { + Authorization: authStore.get(), + "Content-Type": "application/json", + }, body: message, - parseAs: "stream", + signal: ctrl.signal, + onmessage(ev) { + messages.value[messages.value.length - 1] += ev.data; + }, + onclose() { + console.log("onclose"); + }, + onerror(err) { + throw err; + }, }); - const reader = response.body?.getReader(); - if (reader) { - const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - messages.value.push(decoder.decode(value, { stream: true })); - console.log(decoder.decode(value)); - } - return; - } } finally { isLoading.value = false; } }; - return { messages, chat, isLoading }; + + const cancel = () => { + if (currentController) { + currentController.abort(); + currentController = null; + } + }; + + return { messages, chat, isLoading, cancel }; }; diff --git a/frontend/src/views/AiChatView.vue b/frontend/src/views/AiChatView.vue index aa62ac0..38b2203 100644 --- a/frontend/src/views/AiChatView.vue +++ b/frontend/src/views/AiChatView.vue @@ -5,9 +5,9 @@ class="flex items-start gap-2.5"> Jese image
+ :class="['flex flex-col leading-1.5 p-4 border-gray-200 rounded-e-xl rounded-es-xl dark:bg-gray-700', chatElement.isUser ? 'bg-gray-100' : 'bg-blue-100']">
- {{ user.username }} + {{ chatElement.username }}

{{ chatElement.content }}

@@ -61,12 +61,12 @@