This commit is contained in:
luke 2025-06-23 16:12:24 +08:00
parent a222e64d24
commit 45537ab339
13 changed files with 136 additions and 64 deletions

View File

@ -33,7 +33,7 @@ public class AuthFilter implements Filter {
String path = request.getRequestURI(); String path = request.getRequestURI();
// 需要鉴权的路径前缀 // 需要鉴权的路径前缀
if (path.startsWith("/api/v1/doc")) { if (path.startsWith("/api/v1/doc") || path.startsWith("/api/v1/llm")) {
String token = request.getHeader("Authorization"); String token = request.getHeader("Authorization");
if (StrUtil.isBlank(token)) { if (StrUtil.isBlank(token)) {

View File

@ -1,12 +1,12 @@
package com.knowledge.base.application.service; package com.knowledge.base.application.service;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map; import java.util.Map;
public interface LLMAppService { public interface LLMAppService {
String getToken(String password) throws Exception; String getToken(String password) throws Exception;
ResponseBodyEmitter ask(String userId, String question, Map<String, Object> params) throws Exception; SseEmitter ask(String llmToken, String question, Map<String, Object> params) throws Exception;
} }

View File

@ -1,17 +1,18 @@
package com.knowledge.base.application.service; package com.knowledge.base.application.service;
import com.knowledge.base.infrastructure.south.llm.LLMService;
import com.knowledge.base.infrastructure.south.llm.LLMServiceFactory; import com.knowledge.base.infrastructure.south.llm.LLMServiceFactory;
import com.knowledge.base.infrastructure.util.http.SseOutputAdapter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.PipedInputStream; import java.io.*;
import java.io.PipedOutputStream;
import java.util.Map; import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class LLMAppServiceImpl implements LLMAppService { public class LLMAppServiceImpl implements LLMAppService {
private final LLMServiceFactory llmServiceFactory; private final LLMServiceFactory llmServiceFactory;
@ -22,33 +23,19 @@ public class LLMAppServiceImpl implements LLMAppService {
} }
@Override @Override
public ResponseBodyEmitter ask(String userId, String question, Map<String, Object> params) throws Exception { public SseEmitter ask(String llmToken, String question, Map<String, Object> params) throws Exception {
LLMService service = llmServiceFactory.current(); SseEmitter emitter = new SseEmitter(0L); // 不超时
ResponseBodyEmitter emitter = new ResponseBodyEmitter();
PipedOutputStream out = new PipedOutputStream();
PipedInputStream in = new PipedInputStream(out);
new Thread(() -> { new Thread(() -> {
try { try {
service.streamAnswer(userId, question, params, out); llmServiceFactory.current().streamAnswer(llmToken, question, params, new SseOutputAdapter(emitter));
} catch (Exception e) {
try {
emitter.send("data: {\"error\": \"LLM内部异常\"}\n\n");
} catch (Exception ignore) {}
emitter.completeWithError(e);
}
}).start();
new Thread(() -> {
try (in) {
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) != -1) {
emitter.send(new String(buf, 0, len));
}
emitter.complete(); emitter.complete();
} catch (Exception e) { } catch (Exception e) {
try {
emitter.send(SseEmitter.event().data("{\"error\": \"LLM异常\"}"));
} catch (IOException ignored) {
log.error("error", e);
}
emitter.completeWithError(e); emitter.completeWithError(e);
} }
}).start(); }).start();

View File

@ -22,4 +22,9 @@ public interface UserCacheService {
* @param userJsonStr * @param userJsonStr
*/ */
void cacheUserJsonByToken(String token, String userJsonStr); void cacheUserJsonByToken(String token, String userJsonStr);
default void cacheAnythingLLMSlugId(String wsName, String slugId) {}
default Optional<String> getAnythingLLMSlugIdByWorkspaceName(String wsName) {return Optional.empty();}
} }

View File

@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -39,4 +40,14 @@ public class RedisUserCacheServiceImpl implements UserCacheService {
public void cacheUserJsonByToken(String token, String userJsonStr) { public void cacheUserJsonByToken(String token, String userJsonStr) {
redisTemplate.opsForValue().set(key("token", token), userJsonStr, ConstantConfig.USER_CACHE_EXPIRED_MINUTES, TimeUnit.MINUTES); redisTemplate.opsForValue().set(key("token", token), userJsonStr, ConstantConfig.USER_CACHE_EXPIRED_MINUTES, TimeUnit.MINUTES);
} }
@Override
public void cacheAnythingLLMSlugId(String wsName, String slugId) {
redisTemplate.opsForValue().set(key("ws-name", wsName), slugId, Duration.ofDays(10).toMinutes(), TimeUnit.MINUTES);
}
@Override
public Optional<String> getAnythingLLMSlugIdByWorkspaceName(String wsName) {
return Optional.ofNullable(redisTemplate.opsForValue().get(key("ws-name", wsName)));
}
} }

View File

@ -28,4 +28,12 @@ public class ConstantConfig {
public static final String DEFAULT_UPLOADER = "ADMIN"; public static final String DEFAULT_UPLOADER = "ADMIN";
public static final String UNKNOWN_LOCAL_RELA_PATH = "UNKNOWN"; public static final String UNKNOWN_LOCAL_RELA_PATH = "UNKNOWN";
/**
* 以下是AnythingLLM相关
*/
public static final String DEFAULT_SLUG_ID = "87e14982-a821-48d8-9c6b-3557d0bb2f96";
} }

View File

@ -4,7 +4,7 @@ import com.knowledge.base.application.service.LLMAppService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map; import java.util.Map;
@ -24,11 +24,11 @@ public class LLMController {
} }
@PostMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseBodyEmitter ask(@RequestHeader("Authorization") String token, public SseEmitter ask(@RequestHeader("Authorization") String token,
@RequestBody Map<String, Object> body) throws Exception { @RequestBody Map<String, Object> body) throws Exception {
String question = String.valueOf(body.get("question")); String question = String.valueOf(body.get("question"));
String userId = String.valueOf(body.getOrDefault("userId", "anonymous")); String llmToken = String.valueOf(body.getOrDefault("llmToken", "anonymous"));
return llmAppService.ask(userId, question, body); return llmAppService.ask(llmToken, question, body);
} }
} }

View File

@ -1,8 +1,13 @@
package com.knowledge.base.infrastructure.south.llm; package com.knowledge.base.infrastructure.south.llm;
import com.fasterxml.jackson.databind.ObjectMapper; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.knowledge.base.infrastructure.cache.iface.UserCacheService;
import com.knowledge.base.infrastructure.config.ConstantConfig;
import com.knowledge.base.infrastructure.util.HttpHelper; import com.knowledge.base.infrastructure.util.HttpHelper;
import com.knowledge.base.infrastructure.util.http.WriterAdapter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import okhttp3.Response; import okhttp3.Response;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -12,8 +17,10 @@ import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@ -24,11 +31,7 @@ public class AnythingLLMServiceImpl implements LLMService {
private String baseUrl; private String baseUrl;
private final HttpHelper httpHelper; private final HttpHelper httpHelper;
private final ObjectMapper objectMapper; private final UserCacheService userCacheService;
private String cachedToken;
private long cachedAt;
private final long cacheExpireMs = 60 * 60 * 1000;
@Override @Override
public boolean supports(String type) { public boolean supports(String type) {
@ -37,24 +40,18 @@ public class AnythingLLMServiceImpl implements LLMService {
@Override @Override
public String fetchToken(String password) throws Exception { public String fetchToken(String password) throws Exception {
String url = baseUrl + "/api/token"; String url = baseUrl + "/api/request-token";
Map<String, Object> result = httpHelper.postJson(url, Map.of("password", password), null); Map<String, Object> result = httpHelper.postJson(url, Map.of("password", password), null);
String token = String.valueOf(result.get("token")); return String.valueOf(result.get("token"));
cachedToken = token;
cachedAt = System.currentTimeMillis();
return token;
}
private String getValidToken() throws Exception {
if (cachedToken != null && (System.currentTimeMillis() - cachedAt) < cacheExpireMs) {
return cachedToken;
}
return fetchToken("wisdom2025");
} }
@Override @Override
public void streamAnswer(String userId, String question, Map<String, Object> params, OutputStream output) throws Exception { public void streamAnswer(String llmToken, String question, Map<String, Object> params, WriterAdapter writer) throws Exception {
String slug = String.valueOf(params.getOrDefault("slug", "kb")); String slug = (String) params.get("slug");
if (StrUtil.isBlank(slug)) {
slug = fetchSlugByWsName(llmToken, (String) params.getOrDefault("wsName", "部门知识库"));
}
String url = baseUrl + "/api/workspace/" + slug + "/stream-chat"; String url = baseUrl + "/api/workspace/" + slug + "/stream-chat";
Map<String, Object> body = Map.of( Map<String, Object> body = Map.of(
@ -62,14 +59,36 @@ public class AnythingLLMServiceImpl implements LLMService {
"attachments", params.getOrDefault("attachments", List.of()) "attachments", params.getOrDefault("attachments", List.of())
); );
try (Response response = httpHelper.postStream(url, body, "Bearer " + getValidToken()); try (Response response = httpHelper.postStream(url, body, "Bearer " + llmToken);
BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) { BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
output.write((line + "\n").getBytes(StandardCharsets.UTF_8)); writer.writeLine(line); // 已是 SSE 格式
output.flush();
} }
} }
} }
@SneakyThrows
private String fetchSlugByWsName(String llmToken, String wsName) {
Optional<String> slugId = userCacheService.getAnythingLLMSlugIdByWorkspaceName(wsName);
if(slugId.isPresent()) {
return slugId.get();
}
String url = baseUrl + "/api/workspaces";
Map<String, Object> resMap = httpHelper.get(url, llmToken);
List<HashMap> workspaces = (List<HashMap>)resMap.get("workspaces");
if(CollectionUtil.isEmpty(workspaces)) {
return ConstantConfig.DEFAULT_SLUG_ID;
}
Optional<HashMap> targetWorkspace = workspaces.stream().filter(workspace -> workspace.get("name").equals(wsName)).findFirst();
if(targetWorkspace.isEmpty()) {
return ConstantConfig.DEFAULT_SLUG_ID;
}
String slug = (String) targetWorkspace.get().get("slug");
// slug数据进缓存
userCacheService.cacheAnythingLLMSlugId(wsName, slug);
return StrUtil.isBlank(slug) ? ConstantConfig.DEFAULT_SLUG_ID : slug;
}
} }

View File

@ -1,12 +1,13 @@
package com.knowledge.base.infrastructure.south.llm; package com.knowledge.base.infrastructure.south.llm;
import java.io.OutputStream; import com.knowledge.base.infrastructure.util.http.WriterAdapter;
import java.util.Map; import java.util.Map;
public interface LLMService { public interface LLMService {
String fetchToken(String password) throws Exception; String fetchToken(String password) throws Exception;
void streamAnswer(String userId, String question, Map<String, Object> params, OutputStream output) throws Exception; void streamAnswer(String llmToken, String question, Map<String, Object> params, WriterAdapter writer) throws Exception;
boolean supports(String type); boolean supports(String type);
} }

View File

@ -22,8 +22,8 @@ public class HttpHelper {
public HttpHelper() { public HttpHelper() {
this.client = new OkHttpClient.Builder() this.client = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS)
.build(); .build();
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
} }
@ -48,8 +48,18 @@ public class HttpHelper {
* 流式 POST 请求返回 Response 实例调用方负责关闭 response.body().close() * 流式 POST 请求返回 Response 实例调用方负责关闭 response.body().close()
*/ */
public Response postStream(String url, Map<String, Object> body, String token) throws IOException { public Response postStream(String url, Map<String, Object> body, String token) throws IOException {
Request request = buildJsonRequest(url, body, token, "POST"); String json = objectMapper.writeValueAsString(body);
return client.newCall(request).execute(); // 返回后调用方处理 body() RequestBody requestBody = RequestBody.create(json, MediaType.get("application/json; charset=utf-8"));
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", token)
.addHeader("Accept", "text/event-stream")
.addHeader("Connection", "keep-alive")
.post(requestBody)
.build();
return client.newCall(request).execute();
} }
/** /**

View File

@ -0,0 +1,22 @@
package com.knowledge.base.infrastructure.util.http;
import cn.hutool.core.util.StrUtil;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
public class SseOutputAdapter implements WriterAdapter {
private final SseEmitter emitter;
public SseOutputAdapter(SseEmitter emitter) {
this.emitter = emitter;
}
@Override
public void writeLine(String line) throws IOException {
if (StrUtil.isNotBlank(line)) {
emitter.send(SseEmitter.event().data(line));
}
}
}

View File

@ -0,0 +1,8 @@
package com.knowledge.base.infrastructure.util.http;
import java.io.IOException;
public interface WriterAdapter {
void writeLine(String line) throws IOException;
}

View File

@ -58,4 +58,5 @@ knowledge.base.redis.password= # 可留空
knowledge.base.redis.default-expire-minutes=1440 knowledge.base.redis.default-expire-minutes=1440
# 大模型相关 # 大模型相关
llm.remote.base-url=http://localhost:3001 llm.remote.base-url=http://localhost:3001
#llm.remote.base-url=http://llm.wisdompulse.cn