Просмотр исходного кода

Merge pull request #2327 from visuddhinanda/development

Development
visuddhinanda 9 месяцев назад
Родитель
Сommit
60e9380e8d

+ 7 - 5
api-v8/app/Console/Commands/RabbitMQWorker.php

@@ -11,6 +11,7 @@ use PhpAmqpLib\Exception\AMQPTimeoutException;
 use PhpAmqpLib\Wire\AMQPTable;
 use App\Services\RabbitMQService;
 use App\Exceptions\SectionTimeoutException;
+use App\Exceptions\TaskFailException;
 
 class RabbitMQWorker extends Command
 {
@@ -146,11 +147,9 @@ class RabbitMQWorker extends Command
 
             try {
                 // 执行业务逻辑
-                $successful = $this->job->handle();
-                if ($successful) {
-                    // 成功处理,确认消息
-                    $msg->ack();
-                }
+                $this->job->handle();
+                // 成功处理,确认消息
+                $msg->ack();
 
                 $this->processedCount++;
 
@@ -158,7 +157,10 @@ class RabbitMQWorker extends Command
             } catch (SectionTimeoutException $e) {
                 $msg->nack(true, false);
                 Log::warning('attempt to requeue the message message_id:' . $msg->get('message_id'));
+            } catch (TaskFailException $e) {
+                $msg->nack(false, false);
             } catch (\Exception $e) {
+                //requeue
                 $this->handleJobException($msg, $data, $retryCount, $e);
             }
         } catch (\Exception $e) {

+ 10 - 0
api-v8/app/Exceptions/TaskFailException.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Exceptions;
+
+use Exception;
+
+class TaskFailException extends Exception
+{
+    //
+}

+ 3 - 0
api-v8/app/Http/Controllers/AiModelController.php

@@ -40,6 +40,9 @@ class AiModelController extends Controller
                 $table = AiModel::where('owner_id', $request->get('user_id'))
                     ->orWhere('privacy', 'public');
                 break;
+            case 'chat':
+                $table = AiModel::where('owner_id', config("mint.admin.root_uuid"));
+                break;
         }
         if ($request->has('keyword')) {
             $table = $table->where('name', 'like', '%' . $request->get('keyword') . '%');

+ 4 - 0
api-v8/app/Jobs/BaseRabbitMQJob.php

@@ -9,6 +9,7 @@ use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Config;
+use App\Exceptions\TaskFailException;
 
 abstract class BaseRabbitMQJob implements ShouldQueue
 {
@@ -54,6 +55,9 @@ abstract class BaseRabbitMQJob implements ShouldQueue
             ]);
 
             return $result;
+        } catch (TaskFailException $e) {
+            $this->handleFinalFailure($this->messageData, $e);
+            throw $e;
         } catch (\Exception $e) {
             Log::error("队列消息处理失败", [
                 'queue' => $this->queueName,

+ 3 - 1
api-v8/app/Jobs/ProcessAITranslateJob.php

@@ -5,6 +5,7 @@ namespace App\Jobs;
 use App\Services\AiTranslateService;
 use App\Services\RabbitMQService;
 use Illuminate\Support\Facades\Log;
+use App\Exceptions\TaskFailException;
 
 class ProcessAITranslateJob extends BaseRabbitMQJob
 {
@@ -17,9 +18,10 @@ class ProcessAITranslateJob extends BaseRabbitMQJob
             // Laravel会自动注入
             $this->aiService = app(AiTranslateService::class);
             return $this->aiService->processTranslate($this->messageId, $messageData);
+        } catch (TaskFailException $e) {
+            throw $e;
         } catch (\Exception $e) {
             // 记录失败指标
-
             throw $e;
         } finally {
             // 记录处理时间

+ 37 - 21
api-v8/app/Services/AiTranslateService.php

@@ -19,12 +19,13 @@ use App\Http\Controllers\AuthController;
 
 use App\Http\Api\MdRender;
 use App\Exceptions\SectionTimeoutException;
+use App\Exceptions\TaskFailException;
 
 class DatabaseException extends \Exception {}
 
 class AiTranslateService
 {
-    private $queue = 'ai_translate';
+    private $queue = 'ai_translate_v2';
     private $modelToken = null;
     private $task = null;
     protected $mq;
@@ -34,9 +35,15 @@ class AiTranslateService
     private $stop = false;
     private $maxProcessTime = 15 * 60; //一个句子的最大处理时间
     private $mqTimeout = 60;
+    private $openaiProxy = null;
 
     public function __construct() {}
 
+    public function setProxy(string $proxy): self
+    {
+        $this->openaiProxy = $proxy;
+        return $this;
+    }
     /**
      * @param string $messageId
      * @param array $translateData
@@ -99,13 +106,8 @@ class AiTranslateService
             $taskDiscussionContent = [];
 
             //推理
-            try {
-                $responseLLM = $this->requestLLM($message);
-                $taskDiscussionContent[] = '- LLM request successful';
-            } catch (RequestException $e) {
-                throw $e;
-            }
-
+            $responseLLM = $this->requestLLM($message);
+            $taskDiscussionContent[] = '- LLM request successful';
 
             if ($this->task->category === 'translate') {
                 //写入句子库
@@ -173,7 +175,7 @@ class AiTranslateService
             $taskDiscussionContent[] = "- progress=" . $progress;
             //写入task discussion
             if ($this->taskTopicId) {
-                $content = implode('\n', $taskDiscussionContent);
+                $content = implode("\n", $taskDiscussionContent);
                 $dId = $this->taskDiscussion(
                     $this->task->id,
                     'task',
@@ -274,6 +276,17 @@ class AiTranslateService
             "temperature" => 0.7,
             "stream" => false
         ];
+        if ($this->openaiProxy) {
+            $requestUrl = $this->openaiProxy;
+            $body = [
+                'open_ai_url' => $message->model->url,
+                'api_key' => $message->model->key,
+                'payload' => $param,
+            ];
+        } else {
+            $requestUrl = $message->model->url;
+            $body = $param;
+        }
         Log::info($this->queue . ' LLM request ' . $message->model->url . ' model:' . $param['model']);
         Log::debug($this->queue . ' LLM api request', [
             'url' => $message->model->url,
@@ -294,7 +307,7 @@ class AiTranslateService
                 try {
                     $response = Http::withToken($message->model->key)
                         ->timeout($this->llmTimeout)
-                        ->post($message->model->url, $param);
+                        ->post($requestUrl, $body);
 
                     // 如果状态码是 4xx 或 5xx,会自动抛出 RequestException
                     $response->throw();
@@ -308,13 +321,22 @@ class AiTranslateService
                     self::saveModelLog($this->modelToken, $modelLogData);
                     break; // 跳出 while 循环
                 } catch (RequestException $e) {
+                    Log::error($this->queue . ' LLM request exception: ' . $e->getMessage());
+                    $failResponse = $e->response;
+                    $modelLogData['request_headers'] = json_encode($failResponse->handlerStats(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['response_headers'] = json_encode($failResponse->headers(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['status'] = $failResponse->status();
+                    $modelLogData['response_data'] = $response->body();
+                    $modelLogData['success'] = false;
+                    self::saveModelLog($this->modelToken, $modelLogData);
+
                     $attempt++;
                     $status = $e->response->status();
 
                     // 某些错误不需要重试
                     if (in_array($status, [400, 401, 403, 404, 422])) {
                         Log::warning("客户端错误,不重试: {$status}\n");
-                        throw $e; // 重新抛出异常
+                        throw new TaskFailException; // 重新抛出异常
                     }
                     // 服务器错误或网络错误可以重试
                     if ($attempt < $maxRetries) {
@@ -323,19 +345,13 @@ class AiTranslateService
                         sleep($delay);
                     } else {
                         Log::error("达到最大重试次数,请求最终失败\n");
-                        throw $e;
+                        throw new TaskFailException;
                     }
+                } catch (\Exception $e) {
+                    throw $e;
                 }
             }
-        } catch (RequestException $e) {
-            Log::error($this->queue . ' LLM request exception: ' . $e->getMessage());
-            $failResponse = $e->response;
-            $modelLogData['request_headers'] = json_encode($failResponse->handlerStats(), JSON_UNESCAPED_UNICODE);
-            $modelLogData['response_headers'] = json_encode($failResponse->headers(), JSON_UNESCAPED_UNICODE);
-            $modelLogData['status'] = $failResponse->status();
-            $modelLogData['response_data'] = $response->body();
-            $modelLogData['success'] = false;
-            self::saveModelLog($this->modelToken, $modelLogData);
+        } catch (\Exception $e) {
             throw $e;
         }
 

+ 24 - 25
api-v8/app/Services/RabbitMQService.php

@@ -8,6 +8,7 @@ use PhpAmqpLib\Message\AMQPMessage;
 use PhpAmqpLib\Wire\AMQPTable;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
+use App\Exceptions\TaskFailException;
 
 class RabbitMQService
 {
@@ -188,36 +189,34 @@ class RabbitMQService
                 ]);
 
                 // 执行回调处理消息
-                $result = $callback($data, $retryCount);
-
-                if ($result === true) {
-                    // 处理成功,确认消息
-                    $msg->ack();
-                    Log::info("Message processed successfully", ['delivery_tag' => $msg->getDeliveryTag()]);
-                } else {
-                    // 处理失败,检查重试次数
-                    if ($retryCount < $maxRetries) {
-                        // 重新入队,延迟处理
-                        $this->requeueWithDelay($msg, $queueName, $retryCount + 1);
-                        Log::warning("Message requeued for retry", [
-                            'delivery_tag' => $msg->getDeliveryTag(),
-                            'retry_count' => $retryCount + 1
-                        ]);
-                    } else {
-                        // 超过重试次数,拒绝消息(进入死信队列)
-                        $msg->nack(false, false);
-                        Log::error("Message rejected after max retries", [
-                            'delivery_tag' => $msg->getDeliveryTag(),
-                            'retry_count' => $retryCount
-                        ]);
-                    }
-                }
-            } catch (\Exception $e) {
+                $callback($data, $retryCount);
+                // 处理成功,确认消息
+                $msg->ack();
+                Log::info("Message processed successfully", ['delivery_tag' => $msg->getDeliveryTag()]);
+            } catch (TaskFailException $e) {
+                //no need requeue
                 Log::error("Error processing message", [
                     'error' => $e->getMessage(),
                     'delivery_tag' => $msg->getDeliveryTag()
                 ]);
                 $msg->nack(false, false);
+            } catch (\Exception $e) {
+                // 处理失败,检查重试次数
+                if ($retryCount < $maxRetries) {
+                    // 重新入队,延迟处理
+                    $this->requeueWithDelay($msg, $queueName, $retryCount + 1);
+                    Log::warning("Message requeued for retry", [
+                        'delivery_tag' => $msg->getDeliveryTag(),
+                        'retry_count' => $retryCount + 1
+                    ]);
+                } else {
+                    // 超过重试次数,拒绝消息(进入死信队列)
+                    $msg->nack(false, false);
+                    Log::error("Message rejected after max retries", [
+                        'delivery_tag' => $msg->getDeliveryTag(),
+                        'retry_count' => $retryCount
+                    ]);
+                }
             }
 
             $iteration++;

+ 1 - 0
dashboard-v4/dashboard/.env.orig

@@ -14,3 +14,4 @@ REACT_APP_ASSETS_SERVER=https://assets.wikipali.org
 REACT_APP_API_SERVER=https://www.wikipali.org
 REACT_APP_ICP_CODE=
 REACT_APP_QUESTIONNAIRE_LINK=
+REACT_APP_OPENAI_PROXY=https://jp.wikipali.org/api/openai

+ 640 - 0
dashboard-v4/dashboard/src/components/chat/AiChat.tsx

@@ -0,0 +1,640 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import {
+  Input,
+  Button,
+  Avatar,
+  Dropdown,
+  message,
+  Tooltip,
+  Space,
+  Spin,
+  MenuProps,
+  Card,
+  Affix,
+} from "antd";
+import {
+  SendOutlined,
+  CopyOutlined,
+  EditOutlined,
+  ReloadOutlined,
+  DownOutlined,
+  UserOutlined,
+  RobotOutlined,
+  PaperClipOutlined,
+} from "@ant-design/icons";
+import Marked from "../general/Marked";
+import { IAiModel, IAiModelListResponse } from "../api/ai";
+import { get } from "../../request";
+
+const { TextArea } = Input;
+
+// 类型定义
+interface Message {
+  id: number;
+  type: "user" | "ai";
+  content: string;
+  timestamp: string;
+  model?: string;
+}
+
+interface OpenAIMessage {
+  role: "system" | "user" | "assistant";
+  content: string;
+}
+
+interface AIModel {
+  key: string;
+  label: string;
+}
+
+interface StreamTypeController {
+  addToken: (token: string) => void;
+  complete: () => void;
+}
+
+interface OpenAIStreamResponse {
+  choices?: Array<{
+    delta?: {
+      content?: string;
+    };
+  }>;
+}
+
+interface IWidget {
+  initMessage?: string;
+  systemPrompt?: string;
+  onChat?: () => void;
+}
+const AIChatComponent = ({
+  initMessage,
+  systemPrompt = "你是一个巴利语专家",
+  onChat,
+}: IWidget) => {
+  const [messages, setMessages] = useState<Message[]>([]);
+  const [inputValue, setInputValue] = useState<string>("");
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+  const [selectedModel, setSelectedModel] = useState<string>("");
+
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const [isTyping, setIsTyping] = useState<boolean>(false);
+  const [currentTypingMessage, setCurrentTypingMessage] = useState<string>("");
+  const [models, setModels] = useState<IAiModel[]>(); // 可用的AI模型
+
+  const scrollToBottom = useCallback(() => {
+    messagesEndRef.current?.scrollIntoView({
+      behavior: "smooth",
+      block: "center",
+    });
+  }, []);
+
+  useEffect(() => {
+    const url = `/v2/ai-model?view=chat`;
+    console.info("api request", url);
+    get<IAiModelListResponse>(url).then((json) => {
+      if (json.ok) {
+        setModels(json.data.rows);
+        if (json.data.rows.length > 0) {
+          setSelectedModel(json.data.rows[0].uid);
+        }
+      }
+    });
+  }, []);
+
+  useEffect(() => {
+    scrollToBottom();
+  }, [messages, currentTypingMessage, scrollToBottom]);
+
+  useEffect(() => {
+    if (initMessage) {
+      setMessages([]);
+      setInputValue(initMessage);
+      sendMessage();
+    }
+  }, [initMessage]);
+  // 打字机效果 - 支持流式输入
+  const typeWriter = useCallback(
+    (text: string, callback: () => void): NodeJS.Timeout => {
+      setIsTyping(true);
+      setCurrentTypingMessage("");
+      let index = 0;
+
+      const timer = setInterval(() => {
+        if (index < text.length) {
+          setCurrentTypingMessage((prev) => prev + text.charAt(index));
+          index++;
+        } else {
+          clearInterval(timer);
+          setIsTyping(false);
+          setCurrentTypingMessage("");
+          callback();
+        }
+      }, 30);
+
+      return timer;
+    },
+    []
+  );
+
+  // 流式打字机效果
+  const streamTypeWriter = useCallback(
+    (
+      onToken?: (content: string) => void,
+      onComplete?: (finalContent: string) => void
+    ): StreamTypeController => {
+      setIsTyping(true);
+      setCurrentTypingMessage("");
+
+      return {
+        addToken: (token: string) => {
+          setCurrentTypingMessage((prev) => {
+            const newContent = prev + token;
+            onToken && onToken(newContent);
+            return newContent;
+          });
+        },
+        complete: () => {
+          setIsTyping(false);
+          setCurrentTypingMessage((prev) => {
+            const finalContent = prev;
+            setCurrentTypingMessage("");
+            onComplete && onComplete(finalContent);
+            return "";
+          });
+        },
+      };
+    },
+    []
+  );
+
+  // 调用OpenAI API - 支持流式输出
+  const callOpenAI = useCallback(
+    async (messages: OpenAIMessage[]): Promise<void> => {
+      setIsLoading(false); // 开始流式输出时取消loading状态
+      if (typeof process.env.REACT_APP_OPENAI_PROXY === "undefined") {
+        console.error("no REACT_APP_OPENAI_PROXY");
+        return;
+      }
+      try {
+        const payload = {
+          model: selectedModel,
+          messages: messages,
+          stream: true,
+          temperature: 0.7,
+          max_tokens: 2000,
+        };
+        const response = await fetch(process.env.REACT_APP_OPENAI_PROXY, {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+            Authorization: `Bearer AIzaSyCzr8KqEdaQ3cRCxsFwSHh8c7kF3RZTZWw`, // 或你的API密钥
+          },
+          body: JSON.stringify({
+            model_id: selectedModel,
+            payload: payload,
+          }),
+        });
+
+        if (!response.ok) {
+          throw new Error(`HTTP error! status: ${response.status}`);
+        }
+
+        // 处理流式响应
+        const reader = response.body?.getReader();
+        if (!reader) {
+          throw new Error("无法获取响应流");
+        }
+
+        const decoder = new TextDecoder();
+        let buffer = "";
+
+        // 创建流式打字机效果
+        const typeController = streamTypeWriter(
+          (content: string) => {
+            // 每次添加token时的回调
+          },
+          (finalContent: string) => {
+            // 完成时的回调
+            const aiMessage: Message = {
+              id: Date.now(),
+              type: "ai",
+              content: finalContent,
+              timestamp: new Date().toLocaleTimeString(),
+              model: selectedModel,
+            };
+            setMessages((prev) => [...prev, aiMessage]);
+          }
+        );
+
+        try {
+          while (true) {
+            const { done, value } = await reader.read();
+
+            if (done) {
+              typeController.complete();
+              break;
+            }
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split("\n");
+            buffer = lines.pop() || "";
+
+            for (const line of lines) {
+              if (line.trim() === "") continue;
+              if (line.startsWith("data: ")) {
+                const data = line.slice(6);
+
+                if (data === "[DONE]") {
+                  typeController.complete();
+                  return;
+                }
+
+                try {
+                  const parsed: OpenAIStreamResponse = JSON.parse(data);
+                  const delta = parsed.choices?.[0]?.delta;
+
+                  if (delta?.content) {
+                    typeController.addToken(delta.content);
+                  }
+                } catch (e) {
+                  console.warn("解析SSE数据失败:", e);
+                }
+              }
+            }
+          }
+        } catch (error) {
+          console.error("读取流数据失败:", error);
+          typeController.complete();
+          throw error;
+        }
+      } catch (error) {
+        console.error("API调用失败:", error);
+
+        // 如果真实API失败,回退到模拟响应
+        const mockResponse = await simulateAIResponse(messages);
+        typeWriter(mockResponse, () => {
+          const aiMessage: Message = {
+            id: Date.now(),
+            type: "ai",
+            content: mockResponse,
+            timestamp: new Date().toLocaleTimeString(),
+            model: selectedModel,
+          };
+          setMessages((prev) => [...prev, aiMessage]);
+        });
+      }
+    },
+    [selectedModel, streamTypeWriter, typeWriter]
+  );
+
+  // 模拟AI响应(作为备用方案)
+  const simulateAIResponse = useCallback(
+    async (conversationHistory: OpenAIMessage[]): Promise<string> => {
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          const lastUserMessage =
+            conversationHistory[conversationHistory.length - 1]?.content || "";
+          const responses = [
+            '这是一个很好的问题。让我来为你详细解答这个关于 "' +
+              lastUserMessage +
+              '" 的问题。\n\n首先,我需要说明的是,这个话题涉及多个方面的考虑。从技术层面来看,我们需要考虑实现的可行性和复杂度。从用户体验的角度,我们要确保解决方案既实用又易于理解。\n\n希望这个回答对你有帮助!',
+            '我理解你的意思。根据我的知识,关于 "' +
+              lastUserMessage +
+              '" 这个问题,我可以从以下几个角度来分析:\n\n1. 首先是基本概念的理解\n2. 然后是实际应用场景\n3. 最后是注意事项和建议\n\n这样的分析方法能够帮助我们更全面地理解这个问题。',
+            '感谢你的提问。关于 "' +
+              lastUserMessage +
+              '" 这个话题,我的看法是这样的:\n\n这确实是一个值得深入探讨的问题。在我看来,解决这类问题的关键在于找到平衡点,既要考虑效率,也要考虑可维护性。\n\n让我知道如果你需要更详细的解释!',
+          ];
+          resolve(responses[Math.floor(Math.random() * responses.length)]);
+        }, 1000);
+      });
+    },
+    []
+  );
+
+  // 发送消息到AI
+  const sendMessage = useCallback(
+    async (messageText: string = inputValue): Promise<void> => {
+      if (!messageText.trim()) return;
+
+      const userMessage: Message = {
+        id: Date.now(),
+        type: "user",
+        content: messageText,
+        timestamp: new Date().toLocaleTimeString(),
+      };
+
+      setMessages((prev) => [...prev, userMessage]);
+      setInputValue("");
+      setIsLoading(true);
+      onChat && onChat();
+      try {
+        // 构建对话历史
+        const conversationHistory: OpenAIMessage[] = [
+          { role: "system", content: systemPrompt },
+          ...messages.map((msg) => {
+            const newMsg: OpenAIMessage = {
+              role: msg.type === "user" ? "user" : "assistant",
+              content: msg.content,
+            };
+            return newMsg;
+          }),
+          { role: "user", content: messageText },
+        ];
+
+        // 调用OpenAI API
+        await callOpenAI(conversationHistory);
+      } catch (error) {
+        console.error("发送消息失败:", error);
+        message.error("发送消息失败,请重试");
+        setIsLoading(false);
+        setIsTyping(false);
+      }
+    },
+    [inputValue, messages, systemPrompt, callOpenAI]
+  );
+
+  // 复制消息内容
+  const copyMessage = useCallback(async (content: string): Promise<void> => {
+    try {
+      await navigator.clipboard.writeText(content);
+      message.success("已复制到剪贴板");
+    } catch (error) {
+      console.error("复制失败:", error);
+      message.error("复制失败");
+    }
+  }, []);
+
+  // 刷新AI回答
+  const refreshAIResponse = useCallback(
+    async (messageIndex: number): Promise<void> => {
+      const userMessage = messages[messageIndex - 1];
+      if (userMessage && userMessage.type === "user") {
+        // 重新构建到该消息为止的对话历史
+        const conversationHistory: OpenAIMessage[] = [
+          { role: "system", content: systemPrompt },
+          ...messages.slice(0, messageIndex - 1).map((msg) => {
+            const newMsg: OpenAIMessage = {
+              role: msg.type === "user" ? "user" : "assistant",
+              content: msg.content,
+            };
+            return newMsg;
+          }),
+          { role: "user", content: userMessage.content },
+        ];
+
+        // 移除旧的AI回答
+        setMessages((prev) => prev.slice(0, messageIndex));
+
+        try {
+          await callOpenAI(conversationHistory);
+        } catch (error) {
+          console.error("刷新回答失败:", error);
+          message.error("刷新回答失败,请重试");
+        }
+      }
+    },
+    [messages, systemPrompt, callOpenAI]
+  );
+
+  // 编辑用户消息
+  const editUserMessage = useCallback(
+    (messageIndex: number, newContent: string): void => {
+      const updatedMessages = [...messages];
+      updatedMessages[messageIndex].content = newContent;
+      setMessages(updatedMessages);
+    },
+    [messages]
+  );
+
+  // 处理键盘事件
+  const handleKeyPress = useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        sendMessage();
+      }
+    },
+    [sendMessage]
+  );
+
+  // 模型选择菜单
+  const modelMenu: MenuProps = {
+    selectedKeys: [selectedModel],
+    onClick: ({ key }) => setSelectedModel(key),
+    items: models?.map((model) => ({
+      key: model.uid,
+      label: model.name,
+    })),
+  };
+
+  // 刷新按钮的下拉菜单
+  const refreshMenu = useCallback(
+    (messageIndex: number): MenuProps => ({
+      onClick: ({ key }) => {
+        if (key === "refresh") {
+          refreshAIResponse(messageIndex);
+        }
+      },
+      items: [
+        {
+          key: "refresh",
+          label: "重新生成",
+        },
+        {
+          type: "divider",
+        },
+        {
+          key: "model-submenu",
+          label: "选择模型重新生成",
+          children: models?.map((model) => ({
+            key: model.uid,
+            label: model.name,
+            onClick: () => {
+              setSelectedModel(model.uid);
+              refreshAIResponse(messageIndex);
+            },
+          })),
+        },
+      ],
+    }),
+    [refreshAIResponse, models]
+  );
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-50">
+      {/* 聊天显示窗口 */}
+      <div className="flex-1 overflow-y-auto p-4 space-y-4">
+        {messages.map((msg, index) => (
+          <div
+            key={msg.id}
+            className={`flex ${
+              msg.type === "user" ? "justify-end" : "justify-start"
+            }`}
+          >
+            <div
+              className={`group max-w-[70%] ${
+                msg.type === "user"
+                  ? "bg-blue-500 text-white rounded-l-lg rounded-tr-lg"
+                  : "bg-white border rounded-r-lg rounded-tl-lg shadow-sm"
+              } p-4 relative`}
+            >
+              <div className="flex items-start space-x-3">
+                <Avatar
+                  size={32}
+                  icon={
+                    msg.type === "user" ? <UserOutlined /> : <RobotOutlined />
+                  }
+                  className={
+                    msg.type === "user" ? "bg-blue-600" : "bg-gray-500"
+                  }
+                />
+                <div className="flex-1">
+                  <div className="text-sm font-medium mb-1">
+                    {msg.type === "user"
+                      ? "你"
+                      : msg.model
+                      ? models?.find((m) => m.uid === msg.model)?.name
+                      : "AI助手"}
+                  </div>
+                  <div className="text-sm leading-relaxed whitespace-pre-wrap">
+                    <Marked text={msg.content} />
+                  </div>
+                  <div className="text-xs opacity-60 mt-2">{msg.timestamp}</div>
+                </div>
+              </div>
+
+              {/* 悬浮工具按钮 */}
+              <div
+                className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
+                style={{ textAlign: "right" }}
+              >
+                <Space size="small">
+                  <Tooltip title="复制">
+                    <Button
+                      size="small"
+                      type="text"
+                      icon={<CopyOutlined />}
+                      onClick={() => copyMessage(msg.content)}
+                    />
+                  </Tooltip>
+                  {msg.type === "user" ? (
+                    <Tooltip title="编辑">
+                      <Button
+                        size="small"
+                        type="text"
+                        icon={<EditOutlined />}
+                        onClick={() => {
+                          const newContent = prompt("编辑消息:", msg.content);
+                          if (newContent !== null) {
+                            editUserMessage(index, newContent);
+                          }
+                        }}
+                      />
+                    </Tooltip>
+                  ) : (
+                    <Dropdown menu={refreshMenu(index)} trigger={["hover"]}>
+                      <Button
+                        size="small"
+                        type="text"
+                        icon={<ReloadOutlined />}
+                      />
+                    </Dropdown>
+                  )}
+                </Space>
+              </div>
+            </div>
+          </div>
+        ))}
+
+        {/* 显示AI正在输入的消息 */}
+        {isTyping && (
+          <div className="flex justify-start">
+            <div className="max-w-[70%] bg-white border rounded-r-lg rounded-tl-lg shadow-sm p-4">
+              <div className="flex items-start space-x-3">
+                <Avatar
+                  size={32}
+                  icon={<RobotOutlined />}
+                  className="bg-gray-500"
+                />
+                <div className="flex-1">
+                  <div className="text-sm font-medium mb-1">
+                    {models?.find((m) => m.uid === selectedModel)?.name ||
+                      "AI助手"}
+                  </div>
+                  <Marked text={currentTypingMessage} />
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {isLoading && !isTyping && (
+          <div className="flex justify-start">
+            <div className="max-w-[70%] bg-white border rounded-r-lg rounded-tl-lg shadow-sm p-4">
+              <div className="flex items-center space-x-3">
+                <Avatar
+                  size={32}
+                  icon={<RobotOutlined />}
+                  className="bg-gray-500"
+                />
+                <Spin size="small" />
+                <span className="text-sm text-gray-500">正在思考...</span>
+              </div>
+            </div>
+          </div>
+        )}
+
+        <div ref={messagesEndRef} />
+      </div>
+
+      {/* 用户输入区域 */}
+      <Affix offsetBottom={10}>
+        <Card bordered={true} style={{ borderRadius: 10, borderColor: "gray" }}>
+          <div className="max-w-4xl mx-auto">
+            {/* 输入框 */}
+            <div style={{ display: "flex" }}>
+              <TextArea
+                value={inputValue}
+                onChange={(e) => setInputValue(e.target.value)}
+                onKeyPress={handleKeyPress}
+                placeholder="提出你的问题,如:总结下面的内容..."
+                autoSize={{ minRows: 1, maxRows: 6 }}
+                className="resize-none pr-12"
+              />
+            </div>
+
+            {/* 功能按钮和模型选择 */}
+            <div style={{ display: "flex", justifyContent: "space-between" }}>
+              <Space>
+                <Tooltip title="附加文件">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<PaperClipOutlined />}
+                  />
+                </Tooltip>
+              </Space>
+              <div>
+                <Dropdown menu={modelMenu} trigger={["click"]}>
+                  <Button size="small" type="text">
+                    {models?.find((m) => m.uid === selectedModel)?.name}
+                    <DownOutlined />
+                  </Button>
+                </Dropdown>
+                <Button
+                  type="primary"
+                  icon={<SendOutlined />}
+                  onClick={() => sendMessage()}
+                  disabled={!inputValue.trim() || isLoading}
+                  className="absolute right-2 bottom-2"
+                />
+              </div>
+            </div>
+          </div>
+        </Card>
+      </Affix>
+    </div>
+  );
+};
+
+export default AIChatComponent;

+ 20 - 42
dashboard-v4/dashboard/src/components/fts/FullTextSearchResult.tsx

@@ -12,7 +12,7 @@ import AiTranslate from "../ai/AiTranslate";
 
 const { Title, Text } = Typography;
 
-interface IFtsData {
+export interface IFtsData {
   rank?: number;
   highlight?: string;
   book: number;
@@ -23,7 +23,7 @@ interface IFtsData {
   paliTitle?: string;
   path?: ITocPathNode[];
 }
-interface IFtsResponse {
+export interface IFtsResponse {
   ok: boolean;
   message: string;
   data: {
@@ -31,7 +31,7 @@ interface IFtsResponse {
     count: number;
   };
 }
-interface IFtsItem {
+export interface IFtsItem {
   book: number;
   paragraph: number;
   title?: string;
@@ -43,51 +43,29 @@ interface IFtsItem {
 
 export type ISearchView = "pali" | "title" | "page" | "number";
 interface IWidget {
-  keyWord?: string;
-  keyWords?: string[];
-  engin?: "wbw" | "tulip";
-  tags?: string[];
-  bookId?: string | null;
-  book?: number;
-  para?: number;
-  bold?: string | null;
-  orderBy?: string | null;
-  match?: string | null;
-  keyWord2?: string;
   view?: ISearchView;
-  pageType?: string;
+  ftsData?: IFtsItem[];
+  total?: number;
+  loading: boolean;
+  currPage: number;
+  onChange?: (page: number, pageSize: number) => void;
 }
 const FullTxtSearchResultWidget = ({
-  keyWord,
-  keyWords,
-  engin = "wbw",
-  tags,
-  bookId,
-  book,
-  para,
-  orderBy,
-  match,
-  bold,
-  keyWord2,
-  view = "pali",
-  pageType,
+  view,
+  ftsData,
+  total,
+  loading = false,
+  currPage = 1,
+  onChange,
 }: IWidget) => {
-  const [ftsData, setFtsData] = useState<IFtsItem[]>();
-  const [total, setTotal] = useState<number>();
-  const [loading, setLoading] = useState(false);
-  const [currPage, setCurrPage] = useState<number>(1);
-
+  /*
   useEffect(
     () => setCurrPage(1),
     [view, keyWord, keyWords, tags, bookId, match, pageType, bold]
   );
 
   useEffect(() => {
-    /**
-     * 搜索引擎选择逻辑
-     * 如果 keyWord 包涵空格 使用 tulip
-     * 如果 keyWord 不包涵空格 使用 wbw
-     */
+
     let words;
     let api = "";
     if (keyWord?.trim().includes(" ")) {
@@ -140,6 +118,7 @@ const FullTxtSearchResultWidget = ({
           });
           setFtsData(result);
           setTotal(json.data.count);
+          onFound && onFound(result);
         } else {
           console.error(json.message);
         }
@@ -157,6 +136,7 @@ const FullTxtSearchResultWidget = ({
     view,
     bold,
   ]);
+  */
   return (
     <List
       style={{ width: "100%" }}
@@ -164,12 +144,10 @@ const FullTxtSearchResultWidget = ({
       size="small"
       dataSource={ftsData}
       pagination={{
-        onChange: (page) => {
-          console.log(page);
-          setCurrPage(page);
-        },
+        onChange: onChange,
         showQuickJumper: true,
         showSizeChanger: false,
+        current: currPage,
         pageSize: 10,
         total: total,
         position: "both",

+ 4 - 34
dashboard-v4/dashboard/src/components/nut/Home.tsx

@@ -1,41 +1,11 @@
-import ReactMarkdown from "react-markdown";
-import code_png from "../../assets/nut/code.png";
-
-import MarkdownForm from "./MarkdownForm";
-import MarkdownShow from "./MarkdownShow";
-import FontBox from "./FontBox";
-import TreeTest from "./TreeTest";
-
-import { Layout, Typography } from "antd";
-import CaseFormula from "../template/Wbw/CaseFormula";
-import EditableLabel from "../general/EditableLabel";
-import Tree from "./test/Tree";
-const { Paragraph } = Typography;
+import AIChatComponent from "../chat/AiChat";
 
 const Widget = () => {
   return (
-    <Layout>
+    <div>
       <h1>Home</h1>
-      <Paragraph style={{ width: 200 }}>
-        <EditableLabel value="测试意思" />
-      </Paragraph>
-      <CaseFormula />
-      <h2>TreeTest</h2>
-      <Tree />
-
-      <br />
-      <FontBox />
-      <br />
-      <MarkdownShow body="- Hello, **《mint》**!" />
-      <br />
-      <h3>Form</h3>
-      <MarkdownForm />
-      <br />
-      <img alt="code" src={code_png} />
-      <div>
-        <ReactMarkdown>*This* is text with `quote`</ReactMarkdown>
-      </div>
-    </Layout>
+      <AIChatComponent />
+    </div>
   );
 };
 

+ 218 - 63
dashboard-v4/dashboard/src/pages/library/search/search.tsx

@@ -1,8 +1,10 @@
 import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 import { useEffect, useState } from "react";
-import { Row, Col, Breadcrumb, Space, Tabs, Select } from "antd";
+import { Row, Col, Breadcrumb, Space, Tabs, Select, Button, Affix } from "antd";
 import FullSearchInput from "../../../components/fts/FullSearchInput";
 import FullTextSearchResult, {
+  IFtsItem,
+  IFtsResponse,
   ISearchView,
 } from "../../../components/fts/FullTextSearchResult";
 import FtsBookList from "../../../components/fts/FtsBookList";
@@ -12,16 +14,26 @@ import PageNumberList from "../../../components/fts/PageNumberList";
 import { Key } from "antd/es/table/interface";
 
 import BookTreeWithTags from "../../../components/corpus/BookTreeWithTags";
+import AIChatComponent from "../../../components/chat/AiChat";
+import { get } from "../../../request";
 
 const Widget = () => {
   const { key } = useParams();
   const [searchParams, setSearchParams] = useSearchParams();
-  const [bookRoot, setBookRoot] = useState("default");
   const [bookPath, setBookPath] = useState<string[]>([]);
   const navigate = useNavigate();
   const [pageType, setPageType] = useState("P");
   const [view, setView] = useState<ISearchView | undefined>("pali");
   const [caseWord, setCaseWord] = useState<string[]>();
+  const [prompt, setPrompt] = useState<string>();
+  const [sysPrompt, setSysPrompt] = useState<string>();
+
+  const [ftsData, setFtsData] = useState<IFtsItem[]>();
+  const [total, setTotal] = useState<number>();
+  const [loading, setLoading] = useState(false);
+  const [currPage, setCurrPage] = useState<number>(1);
+
+  const [chat, setChat] = useState(false);
 
   useEffect(() => {
     const v = searchParams.get("view");
@@ -30,19 +42,112 @@ const Widget = () => {
     }
   }, [key, searchParams]);
 
+  const sTags = searchParams.get("tags")?.split(",");
+  const bookId = searchParams.get("book");
+  const orderBy = searchParams.get("orderby");
+  const match = searchParams.get("match");
+  const bold = searchParams.get("bold");
+
+  useEffect(
+    () => setCurrPage(1),
+    [view, key, caseWord, sTags, bookId, match, pageType, bold]
+  );
+
   useEffect(() => {
-    let currRoot: string | null;
-    currRoot = localStorage.getItem("pali_path_root");
-    if (currRoot === null) {
-      currRoot = "default";
+    /**
+     * 搜索引擎选择逻辑
+     * 如果 keyWord 包涵空格 使用 tulip
+     * 如果 keyWord 不包涵空格 使用 wbw
+     */
+    let words;
+    let api = "";
+    if (key?.trim().includes(" ")) {
+      api = "search";
+      words = key;
+    } else {
+      api = "search-pali-wbw";
+      words = caseWord?.join();
+    }
+
+    let url = `/v2/${api}?view=${view}&key=${words}`;
+    if (typeof sTags !== "undefined") {
+      url += `&tags=${sTags}`;
+    }
+    if (bookId) {
+      url += `&book=${bookId}`;
+    }
+    if (orderBy) {
+      url += `&orderby=${orderBy}`;
+    }
+    if (match) {
+      url += `&match=${match}`;
     }
-    setBookRoot(currRoot);
-  }, []);
+    if (pageType) {
+      url += `&type=${pageType}`;
+    }
+    if (bold) {
+      url += `&bold=${bold}`;
+    }
+    const offset = (currPage - 1) * 10;
+    url += `&limit=10&offset=${offset}`;
+    console.log("fetch url", url);
+    setLoading(true);
+    get<IFtsResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.log("data", json.data);
+          const result: IFtsItem[] = json.data.rows.map((item) => {
+            return {
+              book: item.book,
+              paragraph: item.paragraph,
+              title: item.title ? item.title : item.paliTitle,
+              paliTitle: item.paliTitle,
+              content: item.highlight
+                ? item.highlight.replaceAll("** ti ", "**ti ")
+                : item.content,
+              path: item.path,
+              rank: item.rank,
+            };
+          });
+          setFtsData(result);
+          setTotal(json.data.count);
+          if (result && result.length > 0) {
+            const chat = result
+              .map((item) => {
+                return `## ${item.title}-${item.paliTitle} \n\n${item.content}\n\n`;
+              })
+              .join("");
+            setSysPrompt(`${chat}\n\n请根据上述巴利文本内容,回答用户的问题`);
+          }
+        } else {
+          console.error(json.message);
+        }
+      })
+      .finally(() => setLoading(false));
+  }, [
+    bold,
+    bookId,
+    caseWord,
+    currPage,
+    key,
+    match,
+    orderBy,
+    pageType,
+    sTags,
+    view,
+  ]);
+
+  let bookRoot = "default";
+  const currRoot = localStorage.getItem("pali_path_root");
+  if (currRoot) {
+    bookRoot = currRoot;
+  }
+
   return (
     <>
       <Row>
         <Col flex="auto"></Col>
-        <Col flex="1440px">
+        <Col>
           <Row>
             <Col xs={0} sm={6} md={5}>
               <BookTreeWithTags
@@ -65,7 +170,7 @@ const Widget = () => {
                 }}
               />
             </Col>
-            <Col xs={24} sm={18} md={13}>
+            <Col xs={24} sm={18} md={12}>
               <Space direction="vertical" style={{ padding: 10 }}>
                 <Space>
                   <FullSearchInput
@@ -167,65 +272,115 @@ const Widget = () => {
                     },
                   ]}
                 />
-                <FullTextSearchResult
-                  view={view as ISearchView}
-                  pageType={pageType}
-                  keyWord={key}
-                  keyWords={caseWord}
-                  tags={searchParams.get("tags")?.split(",")}
-                  bookId={searchParams.get("book")}
-                  orderBy={searchParams.get("orderby")}
-                  match={searchParams.get("match")}
-                  bold={searchParams.get("bold")}
+                <AIChatComponent
+                  initMessage={prompt}
+                  systemPrompt={sysPrompt}
+                  onChat={() => setChat(true)}
                 />
+                <div>
+                  <Space>
+                    <Button
+                      onClick={() =>
+                        setPrompt(
+                          `写一个关于**${key}**的概要,概要中的观点应该引用上述巴利文经文,并逐条列出每个巴利原文每个段落的摘要`
+                        )
+                      }
+                    >
+                      概要
+                    </Button>
+                    <Button
+                      onClick={() =>
+                        setPrompt(
+                          `写一个介绍**${key}**的百科词条,词条中的观点应该引用巴利文经文,并给出引用的巴利原文和译文`
+                        )
+                      }
+                    >
+                      术语
+                    </Button>
+                  </Space>
+                </div>
+                {chat ? (
+                  <></>
+                ) : (
+                  <FullTextSearchResult
+                    view={view}
+                    ftsData={ftsData}
+                    total={total}
+                    loading={loading}
+                    currPage={currPage}
+                    onChange={(page) => {
+                      console.log(page);
+                      setCurrPage(page);
+                    }}
+                  />
+                )}
               </Space>
             </Col>
-            <Col xs={0} sm={0} md={6}>
-              {key && parseInt(key) ? (
-                <PageNumberList
-                  keyWord={key}
-                  onSelect={(selectedKeys: Key[]) => {
-                    console.log("selectedKeys", selectedKeys);
-                    if (selectedKeys.length > 0) {
-                      if (typeof selectedKeys[0] === "string") {
-                        const queryString = selectedKeys[0].split("-");
-                        if (queryString.length === 3) {
-                          setCaseWord(queryString[1].split(","));
-                          if (parseInt(queryString[2]) === 0) {
-                            searchParams.delete("book");
-                          } else {
-                            searchParams.set("book", queryString[2]);
+            <Col xs={0} sm={0} md={7}>
+              <Affix offsetTop={0}>
+                <div style={{ height: "100vh", overflowY: "auto" }}>
+                  {key && parseInt(key) ? (
+                    <PageNumberList
+                      keyWord={key}
+                      onSelect={(selectedKeys: Key[]) => {
+                        console.log("selectedKeys", selectedKeys);
+                        if (selectedKeys.length > 0) {
+                          if (typeof selectedKeys[0] === "string") {
+                            const queryString = selectedKeys[0].split("-");
+                            if (queryString.length === 3) {
+                              setCaseWord(queryString[1].split(","));
+                              if (parseInt(queryString[2]) === 0) {
+                                searchParams.delete("book");
+                              } else {
+                                searchParams.set("book", queryString[2]);
+                              }
+                              setSearchParams(searchParams);
+                            }
                           }
-                          setSearchParams(searchParams);
                         }
-                      }
-                    }
-                  }}
-                />
-              ) : (
-                <CaseList
-                  word={key}
-                  lines={5}
-                  onChange={(value: string[]) => setCaseWord(value)}
-                />
-              )}
+                      }}
+                    />
+                  ) : (
+                    <CaseList
+                      word={key}
+                      lines={5}
+                      onChange={(value: string[]) => setCaseWord(value)}
+                    />
+                  )}
 
-              <FtsBookList
-                view={view}
-                keyWord={key}
-                keyWords={caseWord}
-                tags={searchParams.get("tags")?.split(",")}
-                match={searchParams.get("match")}
-                bookId={searchParams.get("book")}
-                onSelect={(bookId: number) => {
-                  if (bookId !== 0) {
-                    searchParams.set("book", bookId.toString());
-                  } else {
-                    searchParams.delete("book");
-                  }
-                  setSearchParams(searchParams);
-                }}
-              />
+                  <FtsBookList
+                    view={view}
+                    keyWord={key}
+                    keyWords={caseWord}
+                    tags={searchParams.get("tags")?.split(",")}
+                    match={searchParams.get("match")}
+                    bookId={searchParams.get("book")}
+                    onSelect={(bookId: number) => {
+                      if (bookId !== 0) {
+                        searchParams.set("book", bookId.toString());
+                      } else {
+                        searchParams.delete("book");
+                      }
+                      setSearchParams(searchParams);
+                    }}
+                  />
+                  {chat ? (
+                    <FullTextSearchResult
+                      view={view}
+                      ftsData={ftsData}
+                      total={total}
+                      loading={loading}
+                      currPage={currPage}
+                      onChange={(page) => {
+                        console.log(page);
+                        setCurrPage(page);
+                      }}
+                    />
+                  ) : (
+                    <></>
+                  )}
+                </div>
+              </Affix>
             </Col>
           </Row>
         </Col>

+ 6 - 72
dashboard-v4/dashboard/src/pages/nut/index.tsx

@@ -1,83 +1,17 @@
-import { useState } from "react";
-import { Space, Button } from "antd";
-import lodash from "lodash";
-
 import FooterBar from "../../components/library/FooterBar";
 
 import HeadBar from "../../components/library/HeadBar";
-import Home from "../../components/nut/Home";
-import InnerDrawer from "../../components/nut/InnerDrawer";
-import AI from "../../components/nut/test/AI";
-
-interface IRandomPanel {
-  v1: string;
-  v2: string;
-}
-
-interface IMermaidProps {
-  value: string;
-}
-
-const Mermaid = ({ value }: IMermaidProps) => {
-  return <pre className="mermaid">{value}</pre>;
-};
+import AIChatComponent from "../../components/chat/AiChat";
+import { Content } from "antd/lib/layout/layout";
 
 const Widget = () => {
-  const [rdp, setRdp] = useState<IRandomPanel>({
-    v1: "",
-    v2: "",
-  });
-  var aaa: any = {};
-  aaa["bbb"] = "hi";
-  aaa[123] = 321;
-  aaa.hi = "hello";
-  console.log(aaa);
-
   return (
     <div>
       <HeadBar />
-      <div>Home Page</div>
-      <AI />
-      <InnerDrawer />
-      <div>
-        <h1>Mermaid</h1>
-        <div>
-          <Mermaid
-            value={`graph TD 
-        A[Client] --> B[Load Balancer] 
-        B --> C[Server01] 
-        B --> D[Server02]`}
-          />
-        </div>
-        <h1>random</h1>
-        <div>
-          &nbsp;
-          <Space style={{ color: "blue" }}>{lodash.uniqueId("hi-")}</Space>
-          &nbsp;
-          <Space style={{ color: "red" }}>{rdp.v1}</Space>
-          &nbsp;
-          <Space style={{ color: "green" }}>{rdp.v2}</Space>
-          &nbsp;
-          <Button
-            onClick={() => {
-              setRdp({
-                v1: Array.from(Array(20), () =>
-                  Math.floor(Math.random() * 36).toString(36)
-                ).join(""),
-                v2: lodash
-                  .times(20, () => lodash.random(35).toString(36))
-                  .join(""),
-              });
-            }}
-          >
-            Generate
-          </Button>
-        </div>
-      </div>
-
-      <div>
-        <Home />
-      </div>
+      <Content>
+        <div>Home Page</div>
+        <AIChatComponent />
+      </Content>
       <FooterBar />
     </div>
   );

+ 1 - 1
open-ai-server/config.orig.json

@@ -1 +1 @@
-{ "port": 4000, "debug": true }
+{ "port": 4000, "debug": true, "api_server": "http://staging.wikipali.org/api" }

+ 23 - 5
open-ai-server/src/server.js

@@ -3,23 +3,40 @@ import OpenAI from "openai";
 import cors from "cors";
 
 import logger from "./logger";
+import config from "./config";
 
 const app = express();
 
 // 中间件
 app.use(cors());
 app.use(express.json());
-
+const api_server = config["api_server"];
 // POST 路由处理OpenAI请求
 app.post("/api/openai", async (req, res) => {
   try {
-    const { open_ai_url, api_key, payload } = req.body;
+    const { model_id, open_ai_url, api_key, payload } = req.body;
 
     // 验证必需的参数
-    if (!open_ai_url || !api_key || !payload) {
-      return res.status(400).json({
-        error: "Missing required parameters: open_ai_url, api_key, or payload",
+    if (!model_id) {
+      if (!open_ai_url || !api_key || !payload) {
+        return res.status(400).json({
+          error:
+            "Missing required parameters: open_ai_url, api_key, or payload",
+        });
+      }
+    } else {
+      //get model info from api server
+      const url = api_server + `/v2/ai-model/${model_id}`;
+      const res = await fetch(url, {
+        method: "GET",
+        headers: {
+          "Content-Type": "application/json",
+        },
       });
+      // 获取响应数据
+      const model = await res.json();
+      open_ai_url = model.url;
+      api_key = model.key;
     }
 
     // 检测不同的 AI 服务提供商
@@ -164,3 +181,4 @@ app.get("/health", (req, res) => {
 });
 
 export default app;
+export { setConfig };