ソースを参照

完善app模块、修复一些错误

tycoding 1 年間 前
コミット
5f351afc15
20 ファイル変更647 行追加80 行削除
  1. 5 7
      langchat-app/src/main/java/cn/tycoding/langchat/app/controller/AigcAppApiController.java
  2. 13 0
      langchat-app/src/main/java/cn/tycoding/langchat/app/controller/AigcAppWebController.java
  3. 58 22
      langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/AppApiChatEndpoint.java
  4. 1 1
      langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/CompletionReq.java
  5. 82 0
      langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/CompletionRes.java
  6. 10 3
      langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/OpenapiAuthAspect.java
  7. 2 4
      langchat-app/src/main/java/cn/tycoding/langchat/app/entity/AigcAppApi.java
  8. 6 0
      langchat-app/src/main/java/cn/tycoding/langchat/app/entity/AigcAppWeb.java
  9. 101 0
      langchat-app/src/main/java/cn/tycoding/langchat/app/store/AppChannelStore.java
  10. 1 1
      langchat-common/src/main/java/cn/tycoding/langchat/common/utils/ServletUtil.java
  11. 12 2
      langchat-core/src/main/java/cn/tycoding/langchat/core/service/impl/LangChatServiceImpl.java
  12. 1 1
      langchat-ui/src/views/app/KnowledgeSelect.vue
  13. 2 2
      langchat-ui/src/views/app/ModelSelect.vue
  14. 1 1
      langchat-ui/src/views/app/PromptSelect.vue
  15. 22 19
      langchat-ui/src/views/app/api/components/docs.vue
  16. 63 13
      langchat-ui/src/views/app/api/components/edit.vue
  17. 14 3
      langchat-ui/src/views/app/index.vue
  18. 220 0
      langchat-ui/src/views/app/web/components/edit.vue
  19. 25 0
      langchat-ui/src/views/app/web/components/preview.vue
  20. 8 1
      langchat-ui/src/views/app/web/index.vue

+ 5 - 7
langchat-app/src/main/java/cn/tycoding/langchat/app/controller/AigcAppApiController.java

@@ -18,10 +18,10 @@ package cn.tycoding.langchat.app.controller;
 
 import cn.hutool.core.lang.Dict;
 import cn.hutool.core.util.IdUtil;
-import cn.hutool.core.util.StrUtil;
 import cn.tycoding.langchat.app.consts.AppConst;
 import cn.tycoding.langchat.app.entity.AigcAppApi;
 import cn.tycoding.langchat.app.service.AigcAppApiService;
+import cn.tycoding.langchat.app.store.AppChannelStore;
 import cn.tycoding.langchat.common.annotation.ApiLog;
 import cn.tycoding.langchat.common.utils.MybatisUtil;
 import cn.tycoding.langchat.common.utils.QueryPage;
@@ -41,6 +41,7 @@ import java.util.List;
 public class AigcAppApiController {
 
     private final AigcAppApiService appApiService;
+    private final AppChannelStore appChannelStore;
 
     @GetMapping("/generate/key")
     public R generateKey() {
@@ -48,14 +49,9 @@ public class AigcAppApiController {
         return R.ok(Dict.create().set("apiKey", AppConst.PREFIX + uuid));
     }
 
-    private void hide(AigcAppApi data) {
-        data.setApiKey(StrUtil.hide(data.getApiKey(), 13, data.getApiKey().length() - 4));
-    }
-
     @GetMapping("/list")
     public R<List<AigcAppApi>> list(AigcAppApi data) {
         List<AigcAppApi> list = appApiService.list(new LambdaQueryWrapper<AigcAppApi>());
-        list.forEach(this::hide);
         return R.ok(list);
     }
 
@@ -64,7 +60,6 @@ public class AigcAppApiController {
         IPage<AigcAppApi> iPage = appApiService.page(MybatisUtil.wrap(data, queryPage),
                 Wrappers.<AigcAppApi>lambdaQuery()
                         .like(StringUtils.isNotEmpty(data.getName()), AigcAppApi::getName, data.getName()));
-        iPage.getRecords().forEach(this::hide);
         return R.ok(MybatisUtil.getData(iPage));
     }
 
@@ -79,6 +74,7 @@ public class AigcAppApiController {
 //    @SaCheckPermission("aigc:app:iframe:add")
     public R add(@RequestBody AigcAppApi data) {
         appApiService.save(data);
+        appChannelStore.init();
         return R.ok();
     }
 
@@ -87,6 +83,7 @@ public class AigcAppApiController {
 //    @SaCheckPermission("aigc:app:iframe:update")
     public R update(@RequestBody AigcAppApi data) {
         appApiService.updateById(data);
+        appChannelStore.init();
         return R.ok();
     }
 
@@ -95,6 +92,7 @@ public class AigcAppApiController {
 //    @SaCheckPermission("aigc:app:iframe:delete")
     public R delete(@PathVariable String id) {
         appApiService.removeById(id);
+        appChannelStore.init();
         return R.ok();
     }
 }

+ 13 - 0
langchat-app/src/main/java/cn/tycoding/langchat/app/controller/AigcAppWebController.java

@@ -17,8 +17,11 @@
 package cn.tycoding.langchat.app.controller;
 
 import cn.hutool.core.lang.Dict;
+import cn.hutool.core.util.IdUtil;
+import cn.tycoding.langchat.app.consts.AppConst;
 import cn.tycoding.langchat.app.entity.AigcAppWeb;
 import cn.tycoding.langchat.app.service.AigcAppWebService;
+import cn.tycoding.langchat.app.store.AppChannelStore;
 import cn.tycoding.langchat.common.annotation.ApiLog;
 import cn.tycoding.langchat.common.utils.MybatisUtil;
 import cn.tycoding.langchat.common.utils.QueryPage;
@@ -37,6 +40,13 @@ import java.util.List;
 public class AigcAppWebController {
 
     private final AigcAppWebService aigcAppService;
+    private final AppChannelStore appChannelStore;
+
+    @GetMapping("/generate/key")
+    public R generateKey() {
+        String uuid = IdUtil.simpleUUID();
+        return R.ok(Dict.create().set("apiKey", AppConst.PREFIX + uuid));
+    }
 
     @GetMapping("/list")
     public R<List<AigcAppWeb>> list(AigcAppWeb data) {
@@ -60,6 +70,7 @@ public class AigcAppWebController {
 //    @SaCheckPermission("aigc:app:iframe:add")
     public R add(@RequestBody AigcAppWeb data) {
         aigcAppService.save(data);
+        appChannelStore.init();
         return R.ok();
     }
 
@@ -68,6 +79,7 @@ public class AigcAppWebController {
 //    @SaCheckPermission("aigc:app:iframe:update")
     public R update(@RequestBody AigcAppWeb data) {
         aigcAppService.updateById(data);
+        appChannelStore.init();
         return R.ok();
     }
 
@@ -76,6 +88,7 @@ public class AigcAppWebController {
 //    @SaCheckPermission("aigc:app:iframe:delete")
     public R delete(@PathVariable String id) {
         aigcAppService.removeById(id);
+        appChannelStore.init();
         return R.ok();
     }
 }

+ 58 - 22
langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/AppApiChatEndpoint.java

@@ -16,17 +16,28 @@
 
 package cn.tycoding.langchat.app.endpoint;
 
+import cn.hutool.core.util.StrUtil;
+import cn.tycoding.langchat.app.consts.AppConst;
 import cn.tycoding.langchat.app.endpoint.auth.CompletionReq;
+import cn.tycoding.langchat.app.endpoint.auth.CompletionRes;
 import cn.tycoding.langchat.app.endpoint.auth.OpenapiAuth;
+import cn.tycoding.langchat.app.entity.AigcAppApi;
+import cn.tycoding.langchat.app.entity.AigcAppWeb;
+import cn.tycoding.langchat.app.store.AppChannelStore;
+import cn.tycoding.langchat.common.dto.ChatReq;
+import cn.tycoding.langchat.common.utils.PromptUtil;
+import cn.tycoding.langchat.common.utils.StreamEmitter;
+import cn.tycoding.langchat.core.service.LangChatService;
+import dev.langchain4j.model.input.Prompt;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 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.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
-import java.io.IOException;
+import java.util.List;
 
 /**
  * @author tycoding
@@ -38,25 +49,50 @@ import java.io.IOException;
 @RequestMapping("/v1")
 public class AppApiChatEndpoint {
 
-    @OpenapiAuth
-    @PostMapping("/chat/completions")
-    public Object test2(@RequestBody CompletionReq req) throws InterruptedException, IOException {
-        log.info("x: {}", req);
-        ResponseBodyEmitter emitter = new ResponseBodyEmitter();
-
-        new Thread(() -> {
-            try {
-                for (int i = 0; i < 5; i++) {
-//                    new ChatReq().set
-//                    emitter.send(JSON.toJSONString(res));
-                    Thread.sleep(1000);
-                }
-                emitter.complete();
-            } catch (Exception e) {
-                emitter.completeWithError(e);
-            }
-        }).start();
-
-        return emitter;
+    private final LangChatService langChatService;
+
+    @OpenapiAuth(AppConst.CHANNEL_API)
+    @PostMapping(value = "/chat/completions")
+    public SseEmitter completions(@RequestBody CompletionReq req) {
+        StreamEmitter emitter = new StreamEmitter();
+        AigcAppApi appApi = AppChannelStore.getApiChannel();
+
+        return handler(emitter, appApi.getModelId(), req.getMessages());
+    }
+
+    private SseEmitter handler(StreamEmitter emitter, String modelId, List<CompletionReq.Message> messages) {
+        if (messages == null || messages.isEmpty() || StrUtil.isBlank(modelId)) {
+            throw new RuntimeException("Message is undefined. Or check the model configuration");
+        }
+        CompletionReq.Message message = messages.get(0);
+
+        Prompt prompt = PromptUtil.build(message.getContent());
+        langChatService
+                .singleChat(new ChatReq()
+                        .setPrompt(prompt)
+                        .setMessage(message.getContent())
+                        .setRole(message.getRole())
+                        .setModelId(modelId))
+                .onNext(token -> {
+                    CompletionRes res = CompletionRes.process(token);
+                    emitter.send(res);
+                }).onComplete(c -> {
+                    CompletionRes res = CompletionRes.end(c);
+                    emitter.send(res);
+                    emitter.complete();
+                }).onError(e -> {
+                    emitter.error(e.getMessage());
+                }).start();
+
+        return emitter.get();
+    }
+
+    @OpenapiAuth(AppConst.CHANNEL_WEB)
+    @PostMapping(value = "/chat/completions/channel/web")
+    public SseEmitter webChat(@RequestBody CompletionReq req) {
+        StreamEmitter emitter = new StreamEmitter();
+        AigcAppWeb appWeb = AppChannelStore.getWebChannel();
+
+        return handler(emitter, appWeb.getModelId(), req.getMessages());
     }
 }

+ 1 - 1
langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/CompletionReq.java

@@ -44,7 +44,7 @@ public class CompletionReq {
 
     @Data
     @Builder
-    static class Message {
+    public static class Message {
         String role;
         String content;
     }

+ 82 - 0
langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/CompletionRes.java

@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2024 LangChat. TyCoding All Rights Reserved.
+ *
+ * Licensed under the GNU Affero General Public License, Version 3 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.gnu.org/licenses/agpl-3.0.html
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cn.tycoding.langchat.app.endpoint.auth;
+
+import dev.langchain4j.model.output.Response;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author tycoding
+ * @since 2024/7/30
+ */
+@Data
+@Builder
+public class CompletionRes {
+
+    private final String id;
+    private final Integer created;
+    private final String model;
+    private final List<ChatCompletionChoice> choices;
+    private final Usage usage;
+
+    public static CompletionRes process(String token) {
+        return CompletionRes.builder()
+                .choices(List.of(ChatCompletionChoice
+                        .builder()
+                        .delta(Delta.builder().content(token).build())
+                        .build()))
+                .build();
+    }
+
+    public static CompletionRes end(Response res) {
+        return CompletionRes.builder()
+                .usage(Usage.builder()
+                        .completionTokens(res.tokenUsage().outputTokenCount())
+                        .promptTokens(res.tokenUsage().inputTokenCount())
+                        .totalTokens(res.tokenUsage().totalTokenCount())
+                        .build())
+                .choices(List.of(ChatCompletionChoice
+                        .builder()
+                        .finishReason(res.finishReason().toString())
+                        .build()))
+                .build();
+    }
+
+    @Data
+    @Builder
+    static class Usage {
+        private final Integer promptTokens;
+        private final Integer completionTokens;
+        private final Integer totalTokens;
+    }
+
+    @Data
+    @Builder
+    static class ChatCompletionChoice {
+        private final Delta delta;
+        private final String finishReason;
+    }
+
+    @Data
+    @Builder
+    static class Delta {
+        private final String content;
+    }
+}

+ 10 - 3
langchat-app/src/main/java/cn/tycoding/langchat/app/endpoint/auth/OpenapiAuthAspect.java

@@ -16,7 +16,9 @@
 
 package cn.tycoding.langchat.app.endpoint.auth;
 
+import cn.tycoding.langchat.app.store.AppChannelStore;
 import cn.tycoding.langchat.common.exception.AuthException;
+import cn.tycoding.langchat.common.exception.ServiceException;
 import cn.tycoding.langchat.common.utils.ServletUtil;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -24,7 +26,6 @@ import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Aspect;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.data.redis.core.StringRedisTemplate;
 
 @Slf4j
 @Aspect
@@ -32,14 +33,20 @@ import org.springframework.data.redis.core.StringRedisTemplate;
 @AllArgsConstructor
 public class OpenapiAuthAspect {
 
-    private StringRedisTemplate redisTemplate;
+    private final AppChannelStore channelStore;
 
     @Around("@annotation(openapiAuth)")
     public Object around(ProceedingJoinPoint point, OpenapiAuth openapiAuth) throws Throwable {
         String authorization = ServletUtil.getAuthorizationToken();
 
         if (authorization == null) {
-            throw new AuthException("Authentication Token invalid");
+            throw new AuthException(401, "Authentication Token invalid");
+        }
+
+        try {
+            channelStore.isExpired(openapiAuth.value());
+        } catch (Exception e) {
+            throw new ServiceException(e.getMessage());
         }
         return point.proceed();
     }

+ 2 - 4
langchat-app/src/main/java/cn/tycoding/langchat/app/entity/AigcAppApi.java

@@ -45,11 +45,9 @@ public class AigcAppApi implements Serializable {
 
     private String channel;
     private String apiKey;
+    private Integer reqLimit = 100;
     private String name;
-    private String title;
-    private String link;
-    private String icon;
-    private String floatIcon;
     private String des;
+    private Date expired = null;
     private Date createTime;
 }

+ 6 - 0
langchat-app/src/main/java/cn/tycoding/langchat/app/entity/AigcAppWeb.java

@@ -39,13 +39,19 @@ public class AigcAppWeb implements Serializable {
      */
     @TableId(type = IdType.ASSIGN_UUID)
     private String id;
+    private String modelId;
+    private String knowledgeId;
+    private String promptId;
 
     private String channel;
+    private String apiKey;
+    private Integer reqLimit = 100;
     private String name;
     private String title;
     private String link;
     private String icon;
     private String floatIcon;
     private String des;
+    private Date expired = null;
     private Date createTime;
 }

+ 101 - 0
langchat-app/src/main/java/cn/tycoding/langchat/app/store/AppChannelStore.java

@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2024 LangChat. TyCoding All Rights Reserved.
+ *
+ * Licensed under the GNU Affero General Public License, Version 3 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.gnu.org/licenses/agpl-3.0.html
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cn.tycoding.langchat.app.store;
+
+import cn.hutool.core.date.DateUtil;
+import cn.tycoding.langchat.app.consts.AppConst;
+import cn.tycoding.langchat.app.entity.AigcAppApi;
+import cn.tycoding.langchat.app.entity.AigcAppWeb;
+import cn.tycoding.langchat.app.service.AigcAppApiService;
+import cn.tycoding.langchat.app.service.AigcAppWebService;
+import cn.tycoding.langchat.common.exception.ServiceException;
+import cn.tycoding.langchat.common.utils.ServletUtil;
+import jakarta.annotation.PostConstruct;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author tycoding
+ * @since 2024/7/30
+ */
+@Slf4j
+@Component
+@AllArgsConstructor
+public class AppChannelStore {
+
+    private static final Map<String, AigcAppApi> API_MAP = new HashMap<>();
+    private static final Map<String, AigcAppWeb> WEB_MAP = new HashMap<>();
+
+    private final AigcAppApiService appApiService;
+    private final AigcAppWebService appWebService;
+
+    public static AigcAppApi getApiChannel() {
+        String token = ServletUtil.getAuthorizationToken();
+        return API_MAP.get(token);
+    }
+
+    public static AigcAppWeb getWebChannel() {
+        String token = ServletUtil.getAuthorizationToken();
+        return WEB_MAP.get(token);
+    }
+
+    @PostConstruct
+    public void init() {
+        log.info("initialize app channel config list...");
+        List<AigcAppApi> apis = appApiService.list();
+        apis.forEach(api -> API_MAP.put(api.getApiKey(), api));
+
+        List<AigcAppWeb> webs = appWebService.list();
+        webs.forEach(web -> WEB_MAP.put(web.getApiKey(), web));
+    }
+
+    public void isExpired(String channel) {
+        String token = ServletUtil.getAuthorizationToken();
+
+        Date expired = null;
+        if (AppConst.CHANNEL_API.equals(channel)) {
+            AigcAppApi data = API_MAP.get(token);
+            if (data == null) {
+                throw new RuntimeException("The ApiKey is empty");
+            }
+            expired = data.getExpired();
+        }
+
+        if (AppConst.CHANNEL_WEB.equals(channel)) {
+            AigcAppWeb data = WEB_MAP.get(token);
+            if (data == null) {
+                throw new RuntimeException("The ApiKey is empty");
+            }
+            expired = data.getExpired();
+        }
+
+
+        if (expired != null) {
+            int is = DateUtil.compare(new Date(), expired);
+            if (is > 0) {
+                // expired
+                throw new ServiceException("The ApiKey is expired");
+            }
+        }
+    }
+}

+ 1 - 1
langchat-common/src/main/java/cn/tycoding/langchat/common/utils/ServletUtil.java

@@ -57,7 +57,7 @@ public class ServletUtil {
     public static String getAuthorizationToken() {
         String token = getRequest().getHeader("Authorization");
         if (token != null && token.toLowerCase().startsWith("bearer")) {
-            return token.replace("bearer", "").trim();
+            return token.toLowerCase().replace("bearer", "").trim();
         }
         return null;
     }

+ 12 - 2
langchat-core/src/main/java/cn/tycoding/langchat/core/service/impl/LangChatServiceImpl.java

@@ -16,7 +16,8 @@
 
 package cn.tycoding.langchat.core.service.impl;
 
-import cn.hutool.core.lang.UUID;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
 import cn.tycoding.langchat.common.dto.ChatReq;
 import cn.tycoding.langchat.common.dto.ImageR;
 import cn.tycoding.langchat.common.exception.ServiceException;
@@ -58,6 +59,9 @@ public class LangChatServiceImpl implements LangChatService {
     @Override
     public TokenStream chat(ChatReq req) {
         StreamingChatLanguageModel model = provider.stream(req.getModelId());
+        if (StrUtil.isBlank(req.getConversationId())) {
+            req.setConversationId(IdUtil.simpleUUID());
+        }
 
         Assistant assistant;
         if (req.getIsGoogleSearch()) {
@@ -89,6 +93,9 @@ public class LangChatServiceImpl implements LangChatService {
     @Override
     public TokenStream singleChat(ChatReq req) {
         StreamingChatLanguageModel model = provider.stream(req.getModelId());
+        if (StrUtil.isBlank(req.getConversationId())) {
+            req.setConversationId(IdUtil.simpleUUID());
+        }
 
         Assistant  assistant = AiServices.builder(Assistant.class)
                 .streamingChatLanguageModel(model)
@@ -99,6 +106,9 @@ public class LangChatServiceImpl implements LangChatService {
     @Override
     public String text(ChatReq req) {
         CompletableFuture<Void> future = new CompletableFuture<>();
+        if (StrUtil.isBlank(req.getConversationId())) {
+            req.setConversationId(IdUtil.simpleUUID());
+        }
 
         try {
             StreamingChatLanguageModel model = provider.stream(req.getModelId());
@@ -108,7 +118,7 @@ public class LangChatServiceImpl implements LangChatService {
                     .build();
 
             StringBuilder text = new StringBuilder();
-            assistant.stream(UUID.randomUUID().toString(), req.getPrompt().text())
+            assistant.stream(req.getConversationId(), req.getPrompt().text())
                     .onNext(text::append)
                     .onComplete((t) -> {
                         future.complete(null);

+ 1 - 1
langchat-ui/src/views/app/KnowledgeSelect.vue

@@ -19,7 +19,7 @@
   import { list } from '@/api/aigc/knowledge';
 
   const props = defineProps<{
-    id: string;
+    id: any;
   }>();
   const emit = defineEmits(['update']);
   const options = ref([]);

+ 2 - 2
langchat-ui/src/views/app/ModelSelect.vue

@@ -20,7 +20,7 @@
   import { LLMProviders } from '@/views/aigc/model/data';
 
   const props = defineProps<{
-    id: string;
+    id: any;
   }>();
   const emit = defineEmits(['update']);
   const options = ref([]);
@@ -48,7 +48,7 @@
   function onUpdate(val: any, opt) {
     const obj = toRaw(opt);
     emit('update', {
-      modelId: obj.id,
+      id: obj.id,
       modelName: obj.model,
       modelProvider: obj.provider,
     });

+ 1 - 1
langchat-ui/src/views/app/PromptSelect.vue

@@ -19,7 +19,7 @@
   import { list } from '@/api/aigc/prompt';
 
   const props = defineProps<{
-    id: string;
+    id: any;
   }>();
   const emit = defineEmits(['update']);
   const options = ref([]);

+ 22 - 19
langchat-ui/src/views/app/api/components/docs.vue

@@ -20,36 +20,43 @@
 
   hljs.registerLanguage('javascript', javascript);
 
+  const url = `http://langchat.cn`;
   const request = `
-POST /api/users HTTP/1.1
-Host: example.com
+POST /v1/chat/completions HTTP/1.1
 Content-Type: application/json
-
+Authorization: 'Bearer YOUR_ACCESS_TOKEN'
+Body:
 {
-  "name": "John Doe",
-  "email": "john@example.com"
+    "messages": [
+        { "role": "user", "content": "你好" }
+    ]
 }
   `;
 
   const response = `
-{
-  "name": "John Doe",
-  "email": "john@example.com"
-}
+data: {"choices": [{"index": 0, "delta": {"content": "你好!"}, "finish_reason": null}], "session_id": null}
+
+data: {"choices": [{"index": 0, "delta": {"content": "我能"}, "finish_reason": null}], "session_id": null}
+
+data: {"choices": [{"index": 0, "delta": {"content": "为你"}, "finish_reason": null}], "session_id": null}
+
+data: {"choices": [{"index": 0, "delta": {"content": "做些什么?"}, "finish_reason": null}], "session_id": null}
+
+data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop", "usage": {"prompt_tokens": 9, "completion_tokens": 6, "total_tokens": 15}}], "session_id": null}
   `;
 
   const demo = `
-const url = 'http://langchat.cn/langchat/openapi/v1/';
+const url = 'http://langchat.cn/v1/chat/completions';
 const data = {
-  message: '你好呀',
+    "messages": [
+        { "role": "user", "content": "你好" }
+    ]
 };
 
 fetch(url, {
   method: 'POST',
   headers: {
     'Content-Type': 'application/json',
-    'Host': 'example.com',
-    'Accept': 'application/json',
     'Authorization': 'Bearer YOUR_ACCESS_TOKEN'
   },
   body: JSON.stringify(data)
@@ -75,11 +82,7 @@ fetch(url, {
       <div>
         <n-alert title="API URL" type="info" />
         <div class="bg-[#18181c] mt-2 py-2 px-4 overflow-x-auto rounded">
-          <n-code
-            class="text-white"
-            code="http://langchat.cn/langchat/openapi/v1/"
-            language="JavaScript"
-          />
+          <n-code :code="url" class="text-white" language="JavaScript" />
         </div>
       </div>
 
@@ -91,7 +94,7 @@ fetch(url, {
       </div>
 
       <div>
-        <n-alert title="Response" type="info" />
+        <n-alert title="Response(Stream)" type="info" />
         <div class="bg-[#18181c] py-2 mt-2 px-4 overflow-x-auto rounded">
           <n-code :code="response" class="text-white" language="JavaScript" />
         </div>

+ 63 - 13
langchat-ui/src/views/app/api/components/edit.vue

@@ -18,6 +18,8 @@
   const dialog = useDialog();
   const router = useRouter();
   const apiKey = ref('');
+  const isExpired = ref(false);
+  const isLimit = ref(false);
   const modelOptions = ref([]);
 
   onMounted(async () => {
@@ -27,6 +29,9 @@
       data.apiKey = await generateKey();
     }
     apiKey.value = data.apiKey;
+    if (data.expired == null) {
+      isExpired.value = true;
+    }
     data.apiKey =
       data.apiKey.slice(0, 13) +
       data.apiKey.slice(13, -4).replace(/./g, '*') +
@@ -41,7 +46,6 @@
     formRef.value?.validate(async (errors) => {
       if (!errors) {
         const data = { ...toRaw(form.value) };
-        data.apiKey = apiKey.value;
         if (isNullOrWhitespace(data.id)) {
           await add(data);
           emit('reload');
@@ -105,6 +109,39 @@
   function onSelectKnowledge(val) {
     form.value.knowledgeId = val.id;
   }
+  function onSelectModel(val) {
+    form.value.modelId = val.id;
+  }
+  function onSelectPrompt(val) {
+    form.value.promptId = val.id;
+  }
+
+  function onCheckExpired(val: boolean) {
+    if (val) {
+      form.value.expired = null;
+    }
+  }
+  function onUpdateExpired(val) {
+    if (val == null) {
+      isExpired.value = true;
+      form.value.expired = null;
+    } else {
+      isExpired.value = false;
+    }
+  }
+  function onCheckLimit(val: boolean) {
+    if (val) {
+      form.value.reqLimit = null;
+    }
+  }
+  function onUpdateLimit(val) {
+    if (val == null) {
+      isLimit.value = true;
+      form.value.reqLimit = null;
+    } else {
+      isLimit.value = false;
+    }
+  }
 </script>
 
 <template>
@@ -126,11 +163,7 @@
       </n-form-item>
 
       <n-form-item label="关联模型" path="modelId">
-        <ModelSelect
-          v-if="form.modelId !== undefined"
-          :id="form.modelId"
-          @update="(modelId:string) => form.value.modelId = modelId"
-        />
+        <ModelSelect v-if="form.modelId !== undefined" :id="form.modelId" @update="onSelectModel" />
       </n-form-item>
       <n-form-item label="关联知识库" path="knowledgeId">
         <KnowledgeSelect
@@ -143,19 +176,36 @@
         <PromptSelect
           v-if="form.promptId !== undefined"
           :id="form.promptId"
-          @update="(id:string) => form.value.promptId = id"
+          @update="onSelectPrompt"
         />
       </n-form-item>
 
-      <n-form-item label="请求限额(天)" path="limit">
-        <n-slider v-model:value="form.key" :step="10" />
+      <n-form-item label="请求限额 / 天" path="limit">
+        <n-slider
+          v-model:value="form.reqLimit"
+          :default-value="100"
+          :max="1000"
+          :min="10"
+          :step="100"
+          @update:value="onUpdateLimit"
+        />
         &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-        <n-input-number v-model:value="form.key" size="small" />
+        <n-input-number v-model:value="form.reqLimit" size="small" @update:value="onUpdateLimit" />
+        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+        <n-checkbox v-model:checked="isLimit" class="w-[200px]" @update:checked="onCheckLimit">
+          不限制
+        </n-checkbox>
       </n-form-item>
-      <n-form-item label="Key有效期(天)" path="expired">
-        <n-slider v-model:value="form.expired" :step="10" />
+      <n-form-item label="Key过期时间" path="expired">
+        <n-date-picker
+          v-model:value="form.expired"
+          clearable
+          format="yyyy-MM-dd"
+          type="date"
+          @update:value="onUpdateExpired"
+        />
         &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-        <n-input-number v-model:value="form.expired" size="small" />
+        <n-checkbox v-model:checked="isExpired" @update:checked="onCheckExpired"> 长期 </n-checkbox>
       </n-form-item>
       <n-form-item label="应用描述" path="des">
         <n-input v-model:value="form.des" placeholder="请输入应用描述" type="textarea" />

+ 14 - 3
langchat-ui/src/views/app/index.vue

@@ -22,6 +22,7 @@
   import SvgIcon from '@/components/SvgIcon/index.vue';
   import router from '@/router';
   import { useDialog, useMessage } from 'naive-ui';
+  import { copyToClip } from '@/utils/copy';
 
   const editRef = ref();
   const dialog = useDialog();
@@ -49,7 +50,6 @@
   }
 
   async function onInfo(item: any) {
-    console.log('点击了', item);
     if (item.channel === 'CHANNEL_API') {
       await router.push('/aigc/app/api/' + item.id);
     }
@@ -112,6 +112,15 @@
       },
     });
   }
+
+  function getKey(apiKey: string) {
+    const key = apiKey;
+    return key.slice(0, 13) + key.slice(13, -4).replace(/./g, '*') + key.slice(-4);
+  }
+  async function onCopy(key: string) {
+    await copyToClip(key);
+    ms.success('Api Key复制成功');
+  }
 </script>
 
 <template>
@@ -172,16 +181,18 @@
 
               <div class="flex items-center justify-between w-full mt-3 gap-x-2">
                 <input
-                  :value="item.link"
+                  v-if="items.key === 'CHANNEL_API'"
+                  :value="getKey(item.apiKey)"
                   class="flex-1 block h-8 px-4 text-sm text-gray-700 bg-white border border-gray-200 rounded-md focus:border-blue-400 focus:ring-blue-300 focus:ring-opacity-40 focus:outline-none focus:ring"
                   type="text"
                 />
-
                 <button
                   class="rounded-md hidden sm:block p-1.5 text-gray-700 bg-white border border-gray-200 focus:border-blue-400 focus:ring-blue-300 focus:ring-opacity-40 focus:outline-none focus:ring transition-colors duration-300 hover:text-blue-500 dark:hover:text-blue-500"
+                  @click="onCopy(item.apiKey)"
                 >
                   <SvgIcon class="text-lg" icon="uil:copy" />
                 </button>
+
                 <button
                   class="rounded-md hidden sm:block p-1.5 text-gray-700 bg-blue-100 focus:ring-opacity-40 focus:outline-none focus:ring transition-colors duration-300 hover:text-blue-500 dark:hover:text-blue-500"
                   @click="onInfo(item)"

+ 220 - 0
langchat-ui/src/views/app/web/components/edit.vue

@@ -0,0 +1,220 @@
+<script lang="ts" setup>
+  import { onMounted, ref, toRaw } from 'vue';
+  import SvgIcon from '@/components/SvgIcon/index.vue';
+  import { isNullOrWhitespace } from '@/utils/is';
+  import { add, generateKey, getById, update } from '@/api/app/appWeb';
+  import { list as getModelList } from '@/api/aigc/model';
+  import { useDialog, useMessage } from 'naive-ui';
+  import { useRouter } from 'vue-router';
+  import { copyToClip } from '@/utils/copy';
+  import ModelSelect from '@/views/app/ModelSelect.vue';
+  import KnowledgeSelect from '@/views/app/KnowledgeSelect.vue';
+  import PromptSelect from '@/views/app/PromptSelect.vue';
+
+  const emit = defineEmits(['reload']);
+  const formRef = ref();
+  const form = ref<any>({});
+  const ms = useMessage();
+  const dialog = useDialog();
+  const router = useRouter();
+  const apiKey = ref('');
+  const isExpired = ref(false);
+  const isLimit = ref(false);
+  const modelOptions = ref([]);
+
+  onMounted(async () => {
+    const id = router.currentRoute.value.params.id;
+    const data = await getById(id);
+    if (data.apiKey === undefined || data.apiKey === null) {
+      data.apiKey = await generateKey();
+    }
+    apiKey.value = data.apiKey;
+    if (data.expired == null) {
+      isExpired.value = true;
+    }
+    data.apiKey =
+      data.apiKey.slice(0, 13) +
+      data.apiKey.slice(13, -4).replace(/./g, '*') +
+      data.apiKey.slice(-4);
+    form.value = { ...data };
+
+    modelOptions.value = await getModelList({});
+  });
+
+  async function onSubmit(e: MouseEvent) {
+    e.preventDefault();
+    formRef.value?.validate(async (errors) => {
+      if (!errors) {
+        const data = { ...toRaw(form.value) };
+        if (isNullOrWhitespace(data.id)) {
+          await add(data);
+          emit('reload');
+          ms.success('新增成功');
+        } else {
+          await update(data);
+          emit('reload');
+          ms.success('修改成功');
+        }
+      } else {
+        ms.error('请完善表单');
+      }
+    });
+  }
+  const rules = {
+    name: {
+      required: true,
+      trigger: ['blur', 'change'],
+      message: '请输入应用名称',
+    },
+    apiKey: {
+      required: true,
+      trigger: ['blur', 'change'],
+      message: '请输入应用Key',
+    },
+    modelId: {
+      required: true,
+      trigger: ['blur', 'change'],
+      message: '请选择关联模型',
+    },
+    knowledgeId: {
+      required: true,
+      trigger: ['blur', 'change'],
+      message: '请选择关联知识库',
+    },
+    promptId: {
+      required: true,
+      trigger: ['blur', 'change'],
+      message: '请选择关联提示词',
+    },
+  };
+
+  function resetKey() {
+    dialog.warning({
+      title: '提示!',
+      content: '你确定重置Key吗?删除后原Key将立即失效是,请谨慎操作',
+      positiveText: '是',
+      negativeText: '否',
+      onPositiveClick: async () => {
+        const data = await generateKey();
+        form.value.apiKey = data.apiKey;
+      },
+    });
+  }
+
+  async function onCopy() {
+    await copyToClip(apiKey.value);
+    ms.success('Api Key复制成功');
+  }
+
+  function onSelectKnowledge(val) {
+    form.value.knowledgeId = val.id;
+  }
+  function onSelectModel(val) {
+    form.value.modelId = val.id;
+  }
+  function onSelectPrompt(val) {
+    form.value.promptId = val.id;
+  }
+
+  function onCheckExpired(val: boolean) {
+    if (val) {
+      form.value.expired = null;
+    }
+  }
+  function onUpdateExpired(val) {
+    if (val == null) {
+      isExpired.value = true;
+      form.value.expired = null;
+    } else {
+      isExpired.value = false;
+    }
+  }
+  function onCheckLimit(val: boolean) {
+    if (val) {
+      form.value.reqLimit = null;
+    }
+  }
+  function onUpdateLimit(val) {
+    if (val == null) {
+      isLimit.value = true;
+      form.value.reqLimit = null;
+    } else {
+      isLimit.value = false;
+    }
+  }
+</script>
+
+<template>
+  <div class="bg-white p-4 rounded">
+    <n-form ref="formRef" :model="form" :rules="rules" label-placement="left" label-width="auto">
+      <n-form-item label="应用名称" path="name">
+        <n-input v-model:value="form.name" placeholder="请输入应用名称" />
+      </n-form-item>
+      <n-form-item label="请求Key" path="apiKey">
+        <n-input v-model:value="form.apiKey" disabled placeholder="请输入Key">
+          <template #suffix>
+            <n-button text type="info" @click="onCopy">
+              <SvgIcon class="text-xl" icon="mingcute:copy-3-fill" />
+            </n-button>
+          </template>
+        </n-input>
+        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+        <n-button secondary size="small" type="primary" @click="resetKey">重置Key</n-button>
+      </n-form-item>
+
+      <n-form-item label="关联模型" path="modelId">
+        <ModelSelect v-if="form.modelId !== undefined" :id="form.modelId" @update="onSelectModel" />
+      </n-form-item>
+      <n-form-item label="关联知识库" path="knowledgeId">
+        <KnowledgeSelect
+          v-if="form.knowledgeId !== undefined"
+          :id="form.knowledgeId"
+          @update="onSelectKnowledge"
+        />
+      </n-form-item>
+      <n-form-item label="关联知提示词" path="promptId">
+        <PromptSelect
+          v-if="form.promptId !== undefined"
+          :id="form.promptId"
+          @update="onSelectPrompt"
+        />
+      </n-form-item>
+
+      <n-form-item label="请求限额 / 天" path="limit">
+        <n-slider
+          v-model:value="form.reqLimit"
+          :default-value="100"
+          :max="1000"
+          :min="10"
+          :step="100"
+          @update:value="onUpdateLimit"
+        />
+        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+        <n-input-number v-model:value="form.reqLimit" size="small" @update:value="onUpdateLimit" />
+        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+        <n-checkbox v-model:checked="isLimit" class="w-[200px]" @update:checked="onCheckLimit">
+          不限制
+        </n-checkbox>
+      </n-form-item>
+      <n-form-item label="Key过期时间" path="expired">
+        <n-date-picker
+          v-model:value="form.expired"
+          clearable
+          format="yyyy-MM-dd"
+          type="date"
+          @update:value="onUpdateExpired"
+        />
+        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+        <n-checkbox v-model:checked="isExpired" @update:checked="onCheckExpired"> 长期 </n-checkbox>
+      </n-form-item>
+      <n-form-item label="应用描述" path="des">
+        <n-input v-model:value="form.des" placeholder="请输入应用描述" type="textarea" />
+      </n-form-item>
+      <n-form-item>
+        <n-button attr-type="button" class="mx-4" type="info" @click="onSubmit">保存配置</n-button>
+      </n-form-item>
+    </n-form>
+  </div>
+</template>
+
+<style lang="less" scoped></style>

+ 25 - 0
langchat-ui/src/views/app/web/components/preview.vue

@@ -0,0 +1,25 @@
+<!--
+  - Copyright (c) 2024 LangChat. TyCoding All Rights Reserved.
+  -
+  - Licensed under the GNU Affero General Public License, Version 3 (the "License");
+  - you may not use this file except in compliance with the License.
+  - You may obtain a copy of the License at
+  -
+  -     https://www.gnu.org/licenses/agpl-3.0.html
+  -
+  - Unless required by applicable law or agreed to in writing, software
+  - distributed under the License is distributed on an "AS IS" BASIS,
+  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  - See the License for the specific language governing permissions and
+  - limitations under the License.
+  -->
+
+<script lang="ts" setup></script>
+
+<template>
+  <div class="h-full overflow-auto rounded">
+    <iframe height="100%" src="http://127.0.0.1:8099/" width="100%"></iframe>
+  </div>
+</template>
+
+<style lang="less" scoped></style>

+ 8 - 1
langchat-ui/src/views/app/web/index.vue

@@ -18,6 +18,8 @@
   import SvgIcon from '@/components/SvgIcon/index.vue';
   import router from '@/router';
   import { ref } from 'vue-demi';
+  import Preview from './components/preview.vue';
+  import Edit from './components/edit.vue';
 
   const active = ref('1');
 </script>
@@ -42,7 +44,12 @@
       <div></div>
     </div>
 
-    <n-card class="flex-1">11</n-card>
+    <div class="flex-1 overflow-y-auto">
+      <div class="flex gap-4 h-full w-full">
+        <Preview class="w-3/5" />
+        <Edit class="w-full" />
+      </div>
+    </div>
   </div>
 </template>