ソースを参照

Merge branch 'development' of https://github.com/visuddhinanda/mint into development

visuddhinanda 9 ヶ月 前
コミット
93a9423d77
36 ファイル変更1335 行追加512 行削除
  1. 14 12
      ai-translate/ai_translate/__init__.py
  2. 1 1
      ai-translate/ai_translate/__main__.py
  3. 28 17
      ai-translate/ai_translate/service.py
  4. 21 22
      ai-translate/ai_translate/worker.py
  5. 2 0
      ai-translate/docker/.gitignore
  6. 44 0
      ai-translate/docker/Dockerfile
  7. 21 0
      ai-translate/docker/build.sh
  8. 8 0
      ai-translate/docker/run.sh
  9. 4 1
      ai-translate/pyproject.toml
  10. 8 0
      ai-translate/run.sh
  11. 2 0
      api-v12/docker/.gitignore
  12. 31 0
      api-v12/docker/Dockerfile
  13. 21 0
      api-v12/docker/build.sh
  14. 8 0
      api-v12/docker/run.sh
  15. 5 1
      api-v8/app/Http/Api/MdRender.php
  16. 5 6
      api-v8/app/Http/Controllers/ProjectTreeController.php
  17. 19 6
      dashboard-v4/dashboard/src/components/discussion/DiscussionListCard.tsx
  18. 163 133
      dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx
  19. 3 7
      dashboard-v4/dashboard/src/components/template/Nissaya.tsx
  20. 285 282
      dashboard-v4/dashboard/src/components/template/SentEdit/SentTab.tsx
  21. 0 1
      deploy/.gitignore
  22. 1 0
      deploy/group_vars/all.yml
  23. 3 4
      deploy/mint.yml
  24. 28 0
      deploy/roles/docker/tasks/kubernetes.yml
  25. 3 0
      deploy/roles/docker/tasks/main.yml
  26. 30 0
      deploy/roles/mint-v2.1/tasks/clove.yml
  27. 4 1
      deploy/roles/mint-v2.1/tasks/laravel.yml
  28. 53 18
      deploy/roles/mint-v2.1/tasks/main.yml
  29. 2 0
      deploy/roles/mint-v2.1/templates/version.txt.j2
  30. 20 0
      deploy/roles/python3/tasks/main.yml
  31. 1 0
      docker/mint/Dockerfile
  32. 2 0
      open-ai-server/.gitignore
  33. 192 0
      open-ai-server/README.md
  34. 175 0
      open-ai-server/error.md
  35. 32 0
      open-ai-server/package.json
  36. 96 0
      open-ai-server/server.js

+ 14 - 12
ai-translate/ai_translate/__init__.py

@@ -13,31 +13,34 @@ logger = logging.getLogger(__name__)
 
 
 def open_redis_cluster(config):
+    logger.debug("open redis cluster tcp://%s:%s/%s",
+                 config['host'], config['port'], config['namespace'])
     cli = RedisCluster(host=config['host'], port=config['port'])
     logger.debug("%s", cli.get_nodes())
     return (cli, config['namespace'])
 
 
-def start_consumer(context, name, queue, config):
-    mq_config = config['rabbitmq']
+def start_consumer(context, name, config, queue, callback):
+    logger.debug("open rabbitmq %s@%s:%d/%s with timeout %ds",
+                 config['user'], config['host'], config['port'], config['virtual-host'], config['customer-timeout'])
     connection = pika.BlockingConnection(
         pika.ConnectionParameters(
-            host=mq_config['host'], port=mq_config['port'],
+            host=config['host'], port=config['port'],
             credentials=pika.PlainCredentials(
-                mq_config['user'], mq_config['password']),
-            virtual_host=mq_config['virtual-host']))
+                config['user'], config['password']),
+            virtual_host=config['virtual-host']))
     channel = connection.channel()
 
-    def callback(ch, method, properties, body):
+    def _callback(ch, method, properties, body):
         logger.info("received message(%s,%s)",
                     properties.message_id, properties.content_type)
         handle_message(context, ch, method, properties.message_id,
                        properties.content_type, json.loads(
                            body, object_hook=SimpleNamespace),
-                       config['app']['api-url'], config['rabbitmq']['customer-timeout'])
+                       callback, config['customer-timeout'])
 
     channel.basic_consume(
-        queue=queue, on_message_callback=callback, auto_ack=False)
+        queue=queue, on_message_callback=_callback, auto_ack=False)
 
     logger.info('start a consumer(%s) for queue(%s)', name, queue)
     channel.start_consuming()
@@ -47,8 +50,7 @@ def launch(name, queue, config_file):
     logger.debug('load configuration from %s', config_file)
     with open(config_file, "rb") as config_fd:
         config = tomllib.load(config_fd)
+        logger.debug('api-url:(%s)', config['app']['api-url'])
         redis_cli = open_redis_cluster(config['redis'])
-        logger.info('api-url:(%s)', config['app']['api-url'])
-        logger.info('customer-timeout:(%s)',
-                    config['rabbitmq']['customer-timeout'])
-        start_consumer(redis_cli, name, queue, config)
+        start_consumer(redis_cli, name,
+                       config['rabbitmq'], queue, config['app']['api-url'])

+ 1 - 1
ai-translate/ai_translate/__main__.py

@@ -23,7 +23,7 @@ def main():
     parser.add_argument('-d', '--debug',
                         action='store_true', help='run on debug mode')
     parser.add_argument('-v', '--version',
-                        action='version', version='%(prog)s v2025.6.11')
+                        action='version', version='%(prog)s v2025.6.27')
     args = parser.parse_args()
 
     if args.debug:

+ 28 - 17
ai-translate/ai_translate/service.py

@@ -25,6 +25,18 @@ class SectionTimeout(Exception):
         super().__init__(self.message)
 
 
+class TaskFailException(Exception):
+    def __init__(self, message="task fail"):
+        self.message = message
+        super().__init__(self.message)
+
+
+class LLMFailException(Exception):
+    def __init__(self, message="LLM request fail"):
+        self.message = message
+        super().__init__(self.message)
+
+
 @dataclass
 class TaskProgress:
     """任务进度"""
@@ -143,7 +155,7 @@ class AiTranslateService:
                 self.task.id,
                 'task',
                 self.task.title,
-                self.task.category,
+                f'id:{message_id}',
                 None
             )
         times = [self.maxProcessTime]
@@ -175,14 +187,12 @@ class AiTranslateService:
 
             # 写入句子 discussion
             topic_children = []
-            # 提示词
-            topic_children.append(message.prompt)
             # 任务结果
             topic_children.append(response_llm['content'])
             # 推理过程写入discussion
             if response_llm.get('reasoningContent'):
                 topic_children.append(response_llm['reasoningContent'])
-            self._sentence_discussion(s_uid, topic_children)
+            self._sentence_discussion(s_uid, message.prompt, topic_children)
 
             # 修改task 完成度
             progress = self._set_task_progress(
@@ -222,12 +232,12 @@ class AiTranslateService:
         logger.info('ai translate task complete')
         return True
 
-    def _sentence_discussion(self, id, discussions):
+    def _sentence_discussion(self, id, prompt, discussions):
         topic_id = self._task_discussion(
             id,
             'sentence',
             self.task.title,
-            self.task.category,
+            prompt,
             None
         )
 
@@ -255,10 +265,11 @@ class AiTranslateService:
         response = requests.patch(
             url, json=data, headers=headers, timeout=self.api_timeout)
 
-        if not response.ok:
-            logger.error(f'ai_translate task status error: {response.json()}')
-        else:
+        if response.ok:
             logger.info(f'ai_translate task status successful ({status})')
+        else:
+            logger.error(
+                f'ai_translate task status update fail. response: {response.text}')
 
     def _save_model_log(self, token: str, data: Dict[str, Any]) -> bool:
         """保存模型日志"""
@@ -292,8 +303,7 @@ class AiTranslateService:
         else:
             task_discussion_data['title'] = title
 
-        logger.debug(
-            f'{self.queue} discussion create: {url}, data: {json.dumps(task_discussion_data)}')
+        logger.info(f'{self.queue} discussion create: {url},')
 
         headers = {'Authorization': f'Bearer {self.model_token}'}
         response = requests.post(
@@ -304,8 +314,8 @@ class AiTranslateService:
                 f'{self.queue} discussion create error: {response.json()}')
             return False
 
-        logger.debug(
-            f'{self.queue} discussion create: {json.dumps(response.json())}')
+        # logger.debug(
+        #    f'{self.queue} discussion create: {json.dumps(response.json())}')
 
         response_data = response.json()
         if response_data.get('data', {}).get('id'):
@@ -326,8 +336,9 @@ class AiTranslateService:
 
         logger.info(
             f'{self.queue} LLM request {message.model.url} model: {param["model"]}')
-        logger.debug(
-            f'{self.queue} LLM api request: {message.model.url}, data: {json.dumps(param)}')
+
+        # logger.debug(
+        #     f'{self.queue} LLM api request: {message.model.url}, data: {json.dumps(param)}')
 
         # 写入 model log
         model_log_data = {
@@ -377,7 +388,7 @@ class AiTranslateService:
                 # 某些错误不需要重试
                 if status in [400, 401, 403, 404, 422]:
                     logger.warning(f"客户端错误,不重试: {status}")
-                    raise e
+                    raise LLMFailException
 
                 # 服务器错误或网络错误可以重试
                 if attempt < max_retries:
@@ -397,7 +408,7 @@ class AiTranslateService:
                     logger.error(e)
 
         ai_data = response.json()
-        logger.debug(f'{self.queue} LLM http response: {response.json()}')
+        # logger.debug(f'{self.queue} LLM http response: {response.json()}')
 
         response_content = ai_data['choices'][0]['message']['content']
         reasoning_content = ai_data['choices'][0]['message'].get(

+ 21 - 22
ai-translate/ai_translate/worker.py

@@ -1,20 +1,14 @@
 import logging
 
-from .service import AiTranslateService, SectionTimeout, Message
+from .service import AiTranslateService, SectionTimeout, LLMFailException, Message
 from .decode_dataclass import ns_to_dataclass
 from .utils import is_stopped
 
 logger = logging.getLogger(__name__)
 
 
-class TaskFailException(Exception):
-    def __init__(self, message="task fail"):
-        self.message = message
-        super().__init__(self.message)
-
-
-def handle_message(redis, ch, method, id, content_type, body, api_url, customer_timeout):
-    MaxRetry = 3
+def handle_message(redis, ch, method, id, content_type, body, api_url: str, customer_timeout: int):
+    MaxRetry: int = 3
     try:
         logger.info("process message start (%s) messages", len(body.payload))
         consumer = AiTranslateService(
@@ -22,28 +16,33 @@ def handle_message(redis, ch, method, id, content_type, body, api_url, customer_
         messages = ns_to_dataclass([body], Message)
         consumer.process_translate(id, messages[0])
         ch.basic_ack(delivery_tag=method.delivery_tag)  # 确认消息
+        logger.info(f'message {id} ack')
     except SectionTimeout as e:
         # 时间到了,活还没干完 NACK 并重新入队
+        logger.info(
+            f'time is not enough for complete current message id={id}. requeued')
         ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
+    except LLMFailException as e:
+        ch.basic_nack(delivery_tag=method.delivery_tag,
+                      requeue=False)
+        logger.warning(f'message {id} LLMFailException')
     except Exception as e:
         # retry
         retryKey = f'{redis[1]}/message/retry/{id}'
-        retry = 0
-        if redis[0].exists(retryKey):
-            retry = redis[0].get(retryKey)
+        retry = int(redis[0].get(retryKey)
+                    or 0) if redis[0].exists(retryKey) else 0
         if retry > MaxRetry:
-            logger.error(f'超过最大重试次数[{MaxRetry}],任务失败')
+            logger.warning(f'超过最大重试次数[{MaxRetry}],任务失败 id={id}')
             # NACK 丢弃或者进入死信队列
             ch.basic_nack(delivery_tag=method.delivery_tag,
                           requeue=False)
-            raise TaskFailException
-        retry = retry+1
-        redis[0].set(retryKey, retry)
-        # NACK 并重新入队
-        logger.warning(f'消息处理错误,重新压入队列 [{retry}/{MaxRetry}]')
-        ch.basic_nack(delivery_tag=method.delivery_tag,
-                      requeue=True)
-        logger.error(f"error: {e}")
-        logger.exception("发生异常")
+        else:
+            retry = retry+1
+            redis[0].set(retryKey, retry)
+            # NACK 并重新入队
+            logger.warning(f'消息处理错误,重新压入队列 [{retry}/{MaxRetry}]')
+            ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
+            logger.error(f"error: {e}")
+            logger.exception("发生异常")
     finally:
         is_stopped()

+ 2 - 0
ai-translate/docker/.gitignore

@@ -0,0 +1,2 @@
+*.tar
+*.md5

+ 44 - 0
ai-translate/docker/Dockerfile

@@ -0,0 +1,44 @@
+FROM ubuntu:latest
+LABEL maintainer="Kassapa"
+
+ENV DEBIAN_FRONTEND noninteractive
+# https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa
+ARG PYTHON_VERSION=3.13
+
+RUN apt update
+RUN apt -y install lsb-release apt-utils \
+    debian-keyring debian-archive-keyring apt-transport-https software-properties-common curl wget gnupg
+RUN add-apt-repository -y ppa:deadsnakes/ppa
+RUN apt -y upgrade
+RUN apt -y install git vim sudo locales locales-all tzdata build-essential \
+    python${PYTHON_VERSION}-dev python${PYTHON_VERSION}-venv
+RUN apt -y autoremove
+RUN apt -y clean
+
+RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
+RUN locale-gen
+RUN update-locale LANG=en_US.UTF-8
+RUN update-alternatives --set editor /usr/bin/vim.basic
+
+RUN useradd -s /bin/bash -m deploy
+RUN passwd -l deploy
+RUN echo 'deploy ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/101-deploy
+USER deploy
+
+RUN python${PYTHON_VERSION} -m venv $HOME/python3
+RUN echo 'source $HOME/python3/bin/activate' >> $HOME/.bashrc
+
+# https://pip.pypa.io/en/stable/installation/#get-pip-py
+ADD --chown=deploy https://bootstrap.pypa.io/get-pip.py /opt/
+RUN bash -c "source $HOME/python3/bin/activate && python3 /opt/get-pip.py"
+
+# COPY --chown=deploy README.md pyproject.toml /opt/ai-translate/
+# COPY --chown=deploy ai_translate /opt/ai-translate/ai_translate
+# RUN bash -c "source $HOME/python3/bin/activate && python3 -m pip install -e /opt/ai-translate"
+
+RUN echo "$(date -u +%4Y%m%d%H%M%S)" | sudo tee /VERSION
+
+VOLUME /srv
+WORKDIR /srv
+
+CMD ["/bin/bash", "-l"]

+ 21 - 0
ai-translate/docker/build.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -e
+
+if [ "$#" -ne 1 ]; then
+    echo "USAGE: $0 PYTHON_VERSION"
+    exit 1
+fi
+
+export VERSION=$(date "+%4Y%m%d%H%M%S")
+export CODE="mint-python$1"
+export TAR="$CODE-$VERSION-$(uname -m)"
+
+podman pull ubuntu:latest
+podman build --build-arg PYTHON_VERSION=$1 -t $CODE .
+podman save --format=oci-archive -o $TAR.tar $CODE
+md5sum $TAR.tar >$TAR.md5
+
+echo "done($TAR.tar)."
+
+exit 0

+ 8 - 0
ai-translate/docker/run.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+if [ "$#" -ne 1 ]; then
+    echo "USAGE: $0 PYTHON_VERSION"
+    exit 1
+fi
+
+podman run --rm -it --events-backend=file --hostname=palm --network host -v $PWD:/srv:z "mint-python$1"

+ 4 - 1
ai-translate/pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "ai_translate"
-version = "2025.6.11"
+version = "2025.6.27"
 requires-python = ">= 3.13"
 description = "An OpenAI consumer process"
 readme = "README.md"
@@ -14,3 +14,6 @@ dependencies = ["pika", "requests", "redis[hiredis]", "openai"]
 
 [project.scripts]
 mint-ai-translate-consumer = "ai_translate.__main__:main"
+
+[tool.setuptools]
+py-modules = []

+ 8 - 0
ai-translate/run.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+if [ "$#" -lt 2 ]; then
+    echo "USAGE: $0 PYTHON_VERSION ARGS"
+    exit 1
+fi
+
+podman run --rm -it --events-backend=file --hostname=palm --network host -v $PWD:/srv:z "mint-python$1" bash -c "source ~/python3/bin/activate && mint-ai-translate-consumer ${@:2}"

+ 2 - 0
api-v12/docker/.gitignore

@@ -0,0 +1,2 @@
+*.tar
+*.md5

+ 31 - 0
api-v12/docker/Dockerfile

@@ -0,0 +1,31 @@
+FROM ubuntu:latest
+LABEL maintainer="Kassapa"
+
+ENV DEBIAN_FRONTEND noninteractive
+# https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa
+ARG _VERSION=3.13
+
+RUN apt update
+RUN apt -y install lsb-release apt-utils \
+    debian-keyring debian-archive-keyring apt-transport-https software-properties-common curl wget gnupg
+RUN add-apt-repository -y ppa:ondrej/php
+RUN apt -y upgrade
+RUN apt -y install git vim locales locales-all tzdata build-essential \
+    php${PHP_VERSION}-cli php${PHP_VERSION}-fpm \
+    php${PHP_VERSION}-xml php${PHP_VERSION}-imap php${PHP_VERSION}-intl php${PHP_VERSION}-mbstring php${PHP_VERSION}-bcmath \
+    php${PHP_VERSION}-bz2 php${PHP_VERSION}-zip php${PHP_VERSION}-curl php${PHP_VERSION}-gd php${PHP_VERSION}-imagick \
+    php${PHP_VERSION}-pgsql php${PHP_VERSION}-mysql php${PHP_VERSION}-sqlite3 php${PHP_VERSION}-redis php${PHP_VERSION}-amqp
+RUN apt -y autoremove
+RUN apt -y clean
+
+RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
+RUN locale-gen
+RUN update-locale LANG=en_US.UTF-8
+RUN update-alternatives --set editor /usr/bin/vim.basic
+
+RUN echo "$(date -u +%4Y%m%d%H%M%S)" | tee /VERSION
+
+VOLUME /srv
+WORKDIR /srv
+
+CMD ["/bin/bash", "-l"]

+ 21 - 0
api-v12/docker/build.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -e
+
+if [ "$#" -ne 1 ]; then
+    echo "USAGE: $0 PYTHON_VERSION"
+    exit 1
+fi
+
+export VERSION=$(date "+%4Y%m%d%H%M%S")
+export CODE="mint-php$1"
+export TAR="$CODE-$VERSION-$(uname -m)"
+
+podman pull ubuntu:latest
+podman build --build-arg PHP_VERSION=$1 -t $CODE .
+podman save --format=oci-archive -o $TAR.tar $CODE
+md5sum $TAR.tar >$TAR.md5
+
+echo "done($TAR.tar)."
+
+exit 0

+ 8 - 0
api-v12/docker/run.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+if [ "$#" -ne 1 ]; then
+    echo "USAGE: $0 PHP_VERSION"
+    exit 1
+fi
+
+podman run --rm -it --events-backend=file --hostname=palm --network host -v $PWD:/srv:z "mint-php$1"

+ 5 - 1
api-v8/app/Http/Api/MdRender.php

@@ -399,9 +399,13 @@ class MdRender
                 $lines = explode("\n", $markdown);
                 $newLines = array();
                 foreach ($lines as  $line) {
-                    if (strstr($line, '=') === FALSE) {
+                    if (
+                        strstr($line, '=') === FALSE &&
+                        strstr($line, '$') === FALSE
+                    ) {
                         $newLines[] = $line;
                     } else {
+                        $line = str_replace('$', '=', $line);
                         $nissaya = explode('=', $line);
                         $meaning = array_slice($nissaya, 1);
                         $meaning = implode('=', $meaning);

+ 5 - 6
api-v8/app/Http/Controllers/ProjectTreeController.php

@@ -46,7 +46,6 @@ class ProjectTreeController extends Controller
                 'old_id' => $value['id'],
                 'title' => $value['title'],
                 'type' => $value['type'],
-
                 'res_id' => $value['res_id'],
                 'parent_id' => $value['parent_id'],
                 'path' => null,
@@ -62,13 +61,13 @@ class ProjectTreeController extends Controller
         }
         foreach ($newData as $key => $value) {
             if ($value['parent_id']) {
-                $found = array_filter($newData, function ($element) use ($value) {
+                $parent = array_find($newData, function ($element) use ($value) {
                     return $element['old_id'] == $value['parent_id'];
                 });
-                if (count($found) > 0) {
-                    $newData[$key]['parent_id'] = $found[0]['uid'];
-                    $parentPath = $found[0]['path'] ? json_decode($found[0]['path']) : [];
-                    $newData[$key]['path'] = json_encode([...$parentPath, $found[0]['uid']], JSON_UNESCAPED_UNICODE);
+                if ($parent) {
+                    $newData[$key]['parent_id'] = $parent['uid'];
+                    $parentPath = $parent['path'] ? json_decode($parent['path']) : [];
+                    $newData[$key]['path'] = json_encode([...$parentPath, $parent['uid']], JSON_UNESCAPED_UNICODE);
                 } else {
                     $newData[$key]['parent_id'] = null;
                 }

+ 19 - 6
dashboard-v4/dashboard/src/components/discussion/DiscussionListCard.tsx

@@ -20,6 +20,8 @@ import { courseInfo } from "../../reducers/current-course";
 import { courseUser } from "../../reducers/course-user";
 import TimeShow from "../general/TimeShow";
 
+const { Paragraph } = Typography;
+
 export type TResType =
   | "article"
   | "channel"
@@ -126,14 +128,14 @@ const DiscussionListCardWidget = ({
                       {entity.title}
                     </Button>
                   </div>
-                  <Space>
+                  <div>
                     <TimeShow
                       type="secondary"
                       showIcon={false}
                       createdAt={entity.createdAt}
                       updatedAt={entity.updatedAt}
                     />
-                  </Space>
+                  </div>
                 </>
               );
             },
@@ -142,11 +144,22 @@ const DiscussionListCardWidget = ({
             dataIndex: "content",
             search: false,
             render(dom, entity, index, action, schema) {
+              const content = entity.summary ?? entity.content;
               return (
-                <div>
-                  <div key={index}>
-                    {entity.summary ?? entity.content?.substring(0, 100)}
-                  </div>
+                <div key={index}>
+                  <Paragraph
+                    type="secondary"
+                    ellipsis={{
+                      rows: 2,
+                      expandable: true,
+                      onEllipsis: (ellipsis) => {
+                        console.log("Ellipsis changed:", ellipsis);
+                      },
+                    }}
+                    title={content}
+                  >
+                    {content}
+                  </Paragraph>
                 </div>
               );
             },

+ 163 - 133
dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx

@@ -4,12 +4,13 @@ import {
   Input,
   message,
   Modal,
+  notification,
   Space,
   Steps,
   Typography,
 } from "antd";
 
-import { useState } from "react";
+import { useMemo, useState } from "react";
 import Workflow from "./Workflow";
 import {
   IProjectTreeData,
@@ -33,8 +34,12 @@ import {
 } from "../api/token";
 import ProjectWithTasks from "./ProjectWithTasks";
 import { useIntl } from "react-intl";
+import { NotificationPlacement } from "antd/lib/notification";
+import React from "react";
 const { Text, Paragraph } = Typography;
 
+const Context = React.createContext({ name: "Default" });
+
 interface IModal {
   studioName?: string;
   channels?: string[];
@@ -75,7 +80,7 @@ export const TaskBuilderChapterModal = ({
     </>
   );
 };
-
+type NotificationType = "success" | "info" | "warning" | "error";
 interface IWidget {
   studioName?: string;
   channels?: string[];
@@ -257,147 +262,172 @@ const TaskBuilderChapter = ({
   };
   const items = steps.map((item) => ({ key: item.title, title: item.title }));
 
-  const DoButton = () => (
-    <Button
-      loading={loading}
-      disabled={loading}
-      type="primary"
-      onClick={async () => {
-        if (!studioName || !chapter) {
-          console.error("缺少参数", studioName, chapter);
-          return;
-        }
-        setLoading(true);
-        //生成projects
-        setMessages((origin) => [...origin, "正在生成任务组……"]);
-        const url = "/v2/project-tree";
-        const values: IProjectTreeInsertRequest = {
-          studio_name: studioName,
-          data: chapter.map((item, id) => {
-            return {
-              id: item.paragraph.toString(),
-              title: id === 0 && title ? title : item.text ?? "",
-              type: "instance",
-              weight: item.chapter_strlen,
-              parent_id: item.parent.toString(),
-              res_id: `${item.book}-${item.paragraph}`,
-            };
-          }),
-        };
-        console.info("api request", url, values);
-        const res = await post<IProjectTreeInsertRequest, IProjectTreeResponse>(
-          url,
-          values
-        );
-        console.info("api response", res);
-        if (!res.ok) {
-          setMessages((origin) => [...origin, "正在生成任务组失败"]);
-          return;
-        } else {
-          setProjects(res.data.rows);
-          setMessages((origin) => [...origin, "生成任务组成功"]);
-        }
-        //生成tasks
-        setMessages((origin) => [...origin, "正在生成任务……"]);
-        const taskUrl = "/v2/task-group";
-        if (!workflow) {
-          return;
-        }
+  const [api, contextHolder] = notification.useNotification();
 
-        let taskData: ITaskGroupInsertData[] = res.data.rows
-          .filter((value) => value.isLeaf)
-          .map((project, pId) => {
-            return {
-              project_id: project.id,
-              tasks: workflow.map((task, tId) => {
-                let newContent = task.description;
-                prop
-                  ?.find((pValue) => pValue.taskId === task.id)
-                  ?.param?.forEach((value: IParam) => {
-                    //替换数字参数
-                    if (value.type === "number") {
-                      const searchValue = `${value.key}=${value.value}`;
-                      const replaceValue =
-                        `${value.key}=` +
-                        (value.initValue + value.step * pId).toString();
-                      newContent = newContent?.replace(
-                        searchValue,
-                        replaceValue
-                      );
-                    } else {
-                      //替换book
-                      if (project.resId) {
-                        const [book, paragraph] = project.resId.split("-");
-                        newContent = newContent?.replace(
-                          "book=#",
-                          `book=${book}`
-                        );
-                        newContent = newContent?.replace(
-                          "paragraphs=#",
-                          `paragraphs=${paragraph}`
-                        );
-                        //替换channel
-                        //查找toke
-
-                        const [channel, power] = value.value.split("@");
-                        const mToken = tokens?.find(
-                          (token) =>
-                            token.payload.book?.toString() === book &&
-                            token.payload.para_start?.toString() ===
-                              paragraph &&
-                            token.payload.res_id === channel &&
-                            (power && power.length > 0
-                              ? token.payload.power === power
-                              : true)
-                        );
-                        newContent = newContent?.replace(
-                          value.key,
-                          channel + (mToken ? "@" + mToken?.token : "")
-                        );
-                      }
-                    }
-                  });
+  const openNotification = (
+    type: NotificationType,
+    title: string,
+    description?: string
+  ) => {
+    api[type]({
+      message: title,
+      description: description,
+    });
+  };
 
-                console.debug("description", newContent);
+  const DoButton = () => {
+    return (
+      <>
+        <Button
+          loading={loading}
+          disabled={loading}
+          type="primary"
+          onClick={async () => {
+            if (!studioName || !chapter) {
+              console.error("缺少参数", studioName, chapter);
+              return;
+            }
+            setLoading(true);
+            //生成projects
+            setMessages((origin) => [...origin, "正在生成任务组……"]);
+            const url = "/v2/project-tree";
+            const values: IProjectTreeInsertRequest = {
+              studio_name: studioName,
+              data: chapter.map((item, id) => {
                 return {
-                  ...task,
+                  id: item.paragraph.toString(),
+                  title: id === 0 && title ? title : item.text ?? "",
                   type: "instance",
-                  description: newContent,
+                  weight: item.chapter_strlen,
+                  parent_id: item.parent.toString(),
+                  res_id: `${item.book}-${item.paragraph}`,
                 };
               }),
             };
-          });
+            let res;
+            try {
+              console.info("api request", url, values);
+              res = await post<IProjectTreeInsertRequest, IProjectTreeResponse>(
+                url,
+                values
+              );
+              console.info("api response", res);
+              // 检查响应状态
+              if (!res.ok) {
+                throw new Error(`HTTP error! status: `);
+              }
+              setProjects(res.data.rows);
+              setMessages((origin) => [...origin, "生成任务组成功"]);
+            } catch (error) {
+              console.error("Fetch error:", error);
+              openNotification("error", "生成任务组失败");
+              throw error;
+            }
 
-        console.info("api request", taskUrl, taskData);
-        const taskRes = await post<ITaskGroupInsertRequest, ITaskGroupResponse>(
-          taskUrl,
-          { data: taskData }
-        );
-        if (taskRes.ok) {
-          message.success("ok");
-          setMessages((origin) => [...origin, "生成任务成功"]);
-          setMessages((origin) => [
-            ...origin,
-            "生成任务" + taskRes.data.taskCount,
-          ]);
-          setMessages((origin) => [
-            ...origin,
-            "生成任务关联" + taskRes.data.taskRelationCount,
-          ]);
-          setMessages((origin) => [
-            ...origin,
-            "打开译经楼-我的任务查看已经生成的任务",
-          ]);
-          setDone(true);
-        }
-        setLoading(false);
-      }}
-    >
-      Done
-    </Button>
-  );
+            //生成tasks
+            setMessages((origin) => [...origin, "正在生成任务……"]);
+            const taskUrl = "/v2/task-group";
+            if (!workflow) {
+              return;
+            }
+
+            let taskData: ITaskGroupInsertData[] = res.data.rows
+              .filter((value) => value.isLeaf)
+              .map((project, pId) => {
+                return {
+                  project_id: project.id,
+                  tasks: workflow.map((task, tId) => {
+                    let newContent = task.description;
+                    prop
+                      ?.find((pValue) => pValue.taskId === task.id)
+                      ?.param?.forEach((value: IParam) => {
+                        //替换数字参数
+                        if (value.type === "number") {
+                          const searchValue = `${value.key}=${value.value}`;
+                          const replaceValue =
+                            `${value.key}=` +
+                            (value.initValue + value.step * pId).toString();
+                          newContent = newContent?.replace(
+                            searchValue,
+                            replaceValue
+                          );
+                        } else {
+                          //替换book
+                          if (project.resId) {
+                            const [book, paragraph] = project.resId.split("-");
+                            newContent = newContent?.replace(
+                              "book=#",
+                              `book=${book}`
+                            );
+                            newContent = newContent?.replace(
+                              "paragraphs=#",
+                              `paragraphs=${paragraph}`
+                            );
+                            //替换channel
+                            //查找toke
+
+                            const [channel, power] = value.value.split("@");
+                            const mToken = tokens?.find(
+                              (token) =>
+                                token.payload.book?.toString() === book &&
+                                token.payload.para_start?.toString() ===
+                                  paragraph &&
+                                token.payload.res_id === channel &&
+                                (power && power.length > 0
+                                  ? token.payload.power === power
+                                  : true)
+                            );
+                            newContent = newContent?.replace(
+                              value.key,
+                              channel + (mToken ? "@" + mToken?.token : "")
+                            );
+                          }
+                        }
+                      });
+
+                    console.debug("description", newContent);
+                    return {
+                      ...task,
+                      type: "instance",
+                      description: newContent,
+                    };
+                  }),
+                };
+              });
+
+            console.info("api request", taskUrl, taskData);
+            const taskRes = await post<
+              ITaskGroupInsertRequest,
+              ITaskGroupResponse
+            >(taskUrl, { data: taskData });
+            if (taskRes.ok) {
+              setMessages((origin) => [...origin, "生成任务成功"]);
+              setMessages((origin) => [
+                ...origin,
+                "生成任务" + taskRes.data.taskCount,
+              ]);
+              setMessages((origin) => [
+                ...origin,
+                "生成任务关联" + taskRes.data.taskRelationCount,
+              ]);
+              setMessages((origin) => [
+                ...origin,
+                "打开译经楼-我的任务查看已经生成的任务",
+              ]);
+              openNotification("success", "生成任务成功");
+              setDone(true);
+            }
+            setLoading(false);
+          }}
+        >
+          Done
+        </Button>
+      </>
+    );
+  };
   return (
     <div style={style}>
+      {contextHolder}
       <Steps current={current} items={items} />
       <div className="steps-content" style={{ minHeight: 400 }}>
         {steps[current].content}

+ 3 - 7
dashboard-v4/dashboard/src/components/template/Nissaya.tsx

@@ -43,13 +43,9 @@ const NissayaCtl = ({ pali, meaning, lang, children }: IWidgetNissayaCtl) => {
       ) : (
         <></>
       )}
-      {lang === "my" ? (
-        meaning
-          ?.slice(-1)
-          .map((item, id) => <NissayaMeaning key={id} text={item} />)
-      ) : (
-        <>{meaning2}</>
-      )}
+      {meaning?.slice(-1).map((item, id) => (
+        <NissayaMeaning key={id} text={item} />
+      ))}
     </span>
   );
 };

+ 285 - 282
dashboard-v4/dashboard/src/components/template/SentEdit/SentTab.tsx

@@ -101,295 +101,298 @@ const SentTabWidget = ({
     : undefined;
 
   return (
-    <Tabs
-      onMouseEnter={() => setHover(true)}
-      onMouseLeave={() => setHover(false)}
-      activeKey={currKey}
-      onChange={(activeKey: string) => {
-        setCurrKey(activeKey);
-      }}
-      style={
-        isCompact
-          ? {
-              position: currKey === "close" ? "absolute" : "unset",
-              marginTop: -32,
-              width: "100%",
-              marginRight: 10,
-              backgroundColor:
-                hover || currKey !== "close"
-                  ? "rgba(128, 128, 128, 0.1)"
-                  : "unset",
-            }
-          : {
-              padding: "0 8px",
-              backgroundColor: "rgba(128, 128, 128, 0.1)",
-            }
-      }
-      tabBarStyle={{ marginBottom: 0 }}
-      size="small"
-      tabBarGutter={0}
-      tabBarExtraContent={
-        <Space>
-          <TocPath
-            link="none"
-            data={mPath}
-            channels={channelsId}
-            trigger={path ? path.length > 0 ? path[0].title : <></> : <></>}
-          />
-          <Text>{sentId[0]}</Text>
-          <SentTabCopy wbwData={wbwData} text={`{{${sentId[0]}}}`} />
-          <SentMenu
-            book={book}
-            para={para}
-            loading={magicDictLoading}
-            mode={mode}
-            onMagicDict={(type: string) => {
-              if (typeof onMagicDict !== "undefined") {
-                onMagicDict(type);
+    <div className="sent_block">
+      <Tabs
+        onMouseEnter={() => setHover(true)}
+        onMouseLeave={() => setHover(false)}
+        activeKey={currKey}
+        type="card"
+        onChange={(activeKey: string) => {
+          setCurrKey(activeKey);
+        }}
+        style={
+          isCompact
+            ? {
+                position: currKey === "close" ? "absolute" : "unset",
+                marginTop: -32,
+                width: "100%",
+                marginRight: 10,
+                backgroundColor:
+                  hover || currKey !== "close"
+                    ? "rgba(128, 128, 128, 0.1)"
+                    : "unset",
               }
-            }}
-            onMenuClick={(key: string) => {
-              switch (key) {
-                case "compact":
-                  if (typeof onCompact !== "undefined") {
-                    setIsCompact(true);
-                    onCompact(true);
-                  }
-                  break;
-                case "normal":
-                  if (typeof onCompact !== "undefined") {
-                    setIsCompact(false);
-                    onCompact(false);
-                  }
-                  break;
-                case "origin-edit":
-                  if (typeof onModeChange !== "undefined") {
-                    onModeChange("edit");
-                  }
-                  break;
-                case "origin-wbw":
-                  if (typeof onModeChange !== "undefined") {
-                    onModeChange("wbw");
-                  }
-                  break;
-                case "copy-id":
-                  const id = `{{${book}-${para}-${wordStart}-${wordEnd}}}`;
-                  navigator.clipboard.writeText(id).then(() => {
-                    message.success("编号已经拷贝到剪贴板");
-                  });
-                  break;
-                case "copy-link":
-                  let link = `/article/para/${book}-${para}?mode=edit`;
-                  link += `&book=${book}&par=${para}`;
-                  if (channelsId) {
-                    link += `&channel=` + channelsId?.join("_");
-                  }
-                  link += `&focus=${book}-${para}-${wordStart}-${wordEnd}`;
-                  navigator.clipboard.writeText(fullUrl(link)).then(() => {
-                    message.success("链接地址已经拷贝到剪贴板");
-                  });
-                  break;
-                case "affix":
-                  if (typeof onAffix !== "undefined") {
-                    onAffix();
-                  }
-                  break;
-                default:
-                  break;
-              }
-            }}
-          />
-        </Space>
-      }
-      items={[
-        {
-          label: (
-            <span style={tabButtonStyle}>
-              <Badge size="small" count={0}>
-                <CloseOutlined />
-              </Badge>
-            </span>
-          ),
-          key: "close",
-          children: <></>,
-        },
-        {
-          label: (
-            <SentTabButton
-              style={tabButtonStyle}
-              icon={<TranslationOutlined />}
-              type="translation"
-              sentId={id}
-              count={
-                currTranNum
-                  ? currTranNum -
-                    (loadedRes?.translation ? loadedRes.translation : 0)
-                  : undefined
-              }
-              title={intl.formatMessage({
-                id: "channel.type.translation.label",
-              })}
-            />
-          ),
-          key: "translation",
-          children: (
-            <SentCanRead
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              type="translation"
-              channelsId={channelsId}
-              onCreate={() => setCurrTranNum((origin) => origin + 1)}
-            />
-          ),
-        },
-        {
-          label: (
-            <SentTabButton
-              style={tabButtonStyle}
-              icon={<CloseOutlined />}
-              type="nissaya"
-              sentId={id}
-              count={
-                currNissayaNum
-                  ? currNissayaNum -
-                    (loadedRes?.nissaya ? loadedRes.nissaya : 0)
-                  : undefined
-              }
-              title={intl.formatMessage({
-                id: "channel.type.nissaya.label",
-              })}
-            />
-          ),
-          key: "nissaya",
-          children: (
-            <SentCanRead
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              type="nissaya"
-              channelsId={channelsId}
-              onCreate={() => setCurrNissayaNum((origin) => origin + 1)}
-            />
-          ),
-        },
-        {
-          label: (
-            <SentTabButton
-              style={tabButtonStyle}
-              icon={<TranslationOutlined />}
-              type="commentary"
-              sentId={id}
-              count={
-                currCommNum
-                  ? currCommNum -
-                    (loadedRes?.commentary ? loadedRes.commentary : 0)
-                  : undefined
+            : {
+                padding: "0 8px",
+                backgroundColor: "rgba(128, 128, 128, 0.1)",
               }
-              title={intl.formatMessage({
-                id: "channel.type.commentary.label",
-              })}
-            />
-          ),
-          key: "commentary",
-          children: (
-            <SentCanRead
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              type="commentary"
-              channelsId={channelsId}
-              onCreate={() => setCurrCommNum((origin) => origin + 1)}
-            />
-          ),
-        },
-        {
-          label: (
-            <SentTabButton
-              icon={<BlockOutlined />}
-              type="original"
-              sentId={id}
-              count={originNum}
-              title={intl.formatMessage({
-                id: "channel.type.original.label",
-              })}
-            />
-          ),
-          key: "original",
-          children: (
-            <SentCanRead
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              type="original"
-              origin={origin}
+        }
+        tabBarStyle={{ marginBottom: 0 }}
+        size="small"
+        tabBarGutter={0}
+        tabBarExtraContent={
+          <Space>
+            <TocPath
+              link="none"
+              data={mPath}
+              channels={channelsId}
+              trigger={path ? path.length > 0 ? path[0].title : <></> : <></>}
             />
-          ),
-        },
-        {
-          label: (
-            <SentTabButton
-              style={tabButtonStyle}
-              icon={<BlockOutlined />}
-              type="original"
-              sentId={id}
-              count={currSimilarNum}
-              title={intl.formatMessage({
-                id: "buttons.sim",
-              })}
-            />
-          ),
-          key: "sim",
-          children: (
-            <SentSim
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              channelsId={channelsId}
-              limit={5}
-              onCreate={() => setCurrSimilarNum((origin) => origin + 1)}
-            />
-          ),
-        },
-        {
-          label: (
-            <SentTabButtonWbw
-              style={tabButtonStyle}
-              sentId={id}
-              count={0}
-              onMenuClick={(keyPath: string[]) => {
-                switch (keyPath.join("-")) {
-                  case "show-progress":
-                    setShowWbwProgress((origin) => !origin);
+            <Text>{sentId[0]}</Text>
+            <SentTabCopy wbwData={wbwData} text={`{{${sentId[0]}}}`} />
+            <SentMenu
+              book={book}
+              para={para}
+              loading={magicDictLoading}
+              mode={mode}
+              onMagicDict={(type: string) => {
+                if (typeof onMagicDict !== "undefined") {
+                  onMagicDict(type);
+                }
+              }}
+              onMenuClick={(key: string) => {
+                switch (key) {
+                  case "compact":
+                    if (typeof onCompact !== "undefined") {
+                      setIsCompact(true);
+                      onCompact(true);
+                    }
+                    break;
+                  case "normal":
+                    if (typeof onCompact !== "undefined") {
+                      setIsCompact(false);
+                      onCompact(false);
+                    }
+                    break;
+                  case "origin-edit":
+                    if (typeof onModeChange !== "undefined") {
+                      onModeChange("edit");
+                    }
+                    break;
+                  case "origin-wbw":
+                    if (typeof onModeChange !== "undefined") {
+                      onModeChange("wbw");
+                    }
+                    break;
+                  case "copy-id":
+                    const id = `{{${book}-${para}-${wordStart}-${wordEnd}}}`;
+                    navigator.clipboard.writeText(id).then(() => {
+                      message.success("编号已经拷贝到剪贴板");
+                    });
+                    break;
+                  case "copy-link":
+                    let link = `/article/para/${book}-${para}?mode=edit`;
+                    link += `&book=${book}&par=${para}`;
+                    if (channelsId) {
+                      link += `&channel=` + channelsId?.join("_");
+                    }
+                    link += `&focus=${book}-${para}-${wordStart}-${wordEnd}`;
+                    navigator.clipboard.writeText(fullUrl(link)).then(() => {
+                      message.success("链接地址已经拷贝到剪贴板");
+                    });
+                    break;
+                  case "affix":
+                    if (typeof onAffix !== "undefined") {
+                      onAffix();
+                    }
+                    break;
+                  default:
                     break;
                 }
               }}
             />
-          ),
-          key: "wbw",
-          children: (
-            <SentWbw
-              book={parseInt(sId[0])}
-              para={parseInt(sId[1])}
-              wordStart={parseInt(sId[2])}
-              wordEnd={parseInt(sId[3])}
-              channelsId={channelsId}
-              wbwProgress={showWbwProgress}
-            />
-          ),
-        },
-        {
-          label: <span style={tabButtonStyle}>{"关系图"}</span>,
-          key: "relation-graphic",
-          children: <RelaGraphic wbwData={wbwData} />,
-        },
-      ]}
-    />
+          </Space>
+        }
+        items={[
+          {
+            label: (
+              <span style={tabButtonStyle}>
+                <Badge size="small" count={0}>
+                  <CloseOutlined />
+                </Badge>
+              </span>
+            ),
+            key: "close",
+            children: <></>,
+          },
+          {
+            label: (
+              <SentTabButton
+                style={tabButtonStyle}
+                icon={<TranslationOutlined />}
+                type="translation"
+                sentId={id}
+                count={
+                  currTranNum
+                    ? currTranNum -
+                      (loadedRes?.translation ? loadedRes.translation : 0)
+                    : undefined
+                }
+                title={intl.formatMessage({
+                  id: "channel.type.translation.label",
+                })}
+              />
+            ),
+            key: "translation",
+            children: (
+              <SentCanRead
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                type="translation"
+                channelsId={channelsId}
+                onCreate={() => setCurrTranNum((origin) => origin + 1)}
+              />
+            ),
+          },
+          {
+            label: (
+              <SentTabButton
+                style={tabButtonStyle}
+                icon={<CloseOutlined />}
+                type="nissaya"
+                sentId={id}
+                count={
+                  currNissayaNum
+                    ? currNissayaNum -
+                      (loadedRes?.nissaya ? loadedRes.nissaya : 0)
+                    : undefined
+                }
+                title={intl.formatMessage({
+                  id: "channel.type.nissaya.label",
+                })}
+              />
+            ),
+            key: "nissaya",
+            children: (
+              <SentCanRead
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                type="nissaya"
+                channelsId={channelsId}
+                onCreate={() => setCurrNissayaNum((origin) => origin + 1)}
+              />
+            ),
+          },
+          {
+            label: (
+              <SentTabButton
+                style={tabButtonStyle}
+                icon={<TranslationOutlined />}
+                type="commentary"
+                sentId={id}
+                count={
+                  currCommNum
+                    ? currCommNum -
+                      (loadedRes?.commentary ? loadedRes.commentary : 0)
+                    : undefined
+                }
+                title={intl.formatMessage({
+                  id: "channel.type.commentary.label",
+                })}
+              />
+            ),
+            key: "commentary",
+            children: (
+              <SentCanRead
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                type="commentary"
+                channelsId={channelsId}
+                onCreate={() => setCurrCommNum((origin) => origin + 1)}
+              />
+            ),
+          },
+          {
+            label: (
+              <SentTabButton
+                icon={<BlockOutlined />}
+                type="original"
+                sentId={id}
+                count={originNum}
+                title={intl.formatMessage({
+                  id: "channel.type.original.label",
+                })}
+              />
+            ),
+            key: "original",
+            children: (
+              <SentCanRead
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                type="original"
+                origin={origin}
+              />
+            ),
+          },
+          {
+            label: (
+              <SentTabButton
+                style={tabButtonStyle}
+                icon={<BlockOutlined />}
+                type="original"
+                sentId={id}
+                count={currSimilarNum}
+                title={intl.formatMessage({
+                  id: "buttons.sim",
+                })}
+              />
+            ),
+            key: "sim",
+            children: (
+              <SentSim
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                channelsId={channelsId}
+                limit={5}
+                onCreate={() => setCurrSimilarNum((origin) => origin + 1)}
+              />
+            ),
+          },
+          {
+            label: (
+              <SentTabButtonWbw
+                style={tabButtonStyle}
+                sentId={id}
+                count={0}
+                onMenuClick={(keyPath: string[]) => {
+                  switch (keyPath.join("-")) {
+                    case "show-progress":
+                      setShowWbwProgress((origin) => !origin);
+                      break;
+                  }
+                }}
+              />
+            ),
+            key: "wbw",
+            children: (
+              <SentWbw
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                channelsId={channelsId}
+                wbwProgress={showWbwProgress}
+              />
+            ),
+          },
+          {
+            label: <span style={tabButtonStyle}>{"关系图"}</span>,
+            key: "relation-graphic",
+            children: <RelaGraphic wbwData={wbwData} />,
+          },
+        ]}
+      />
+    </div>
   );
 };
 

+ 0 - 1
deploy/.gitignore

@@ -1,5 +1,4 @@
 /clients/
-/python/
 /shared/
 /tmp/
 *.log

+ 1 - 0
deploy/group_vars/all.yml

@@ -9,6 +9,7 @@ app_debug: false
 app_dashboard_base_path: "/pcd"
 app_postgresql_version: "16"
 app_open_search_version: "2.19.1"
+app_python_version: "3.13"
 app_php_version: "8.1"
 app_php_memory_limit: "128M"
 app_container_prefix: "mint"

+ 3 - 4
deploy/mint.yml

@@ -56,7 +56,6 @@
         - wbw.analyses
         - export.pali.chapter
         - export.article
-        - ai.translate
 
 - name: Start mint php-fpm
   hosts:
@@ -73,11 +72,11 @@
   hosts:
     - ai_translate
   tasks:
-    - name: Start ai-translate service
+    - name: Disable php ai-translate service
       ansible.builtin.systemd_service:
         name: "{{ app_container_prefix }}-{{ app_domain }}-worker-mq-ai.translate"
-        enabled: true
-        state: restarted
+        enabled: false
+        state: stopped
         scope: user
 
 - name: Setup nginx

+ 28 - 0
deploy/roles/docker/tasks/kubernetes.yml

@@ -0,0 +1,28 @@
+# https://minikube.sigs.k8s.io/docs/start/
+- name: Install minikube
+  become: true
+  ansible.builtin.get_url:
+    url: https://github.com/kubernetes/minikube/releases/latest/download/minikube-linux-amd64
+    dest: /usr/local/bin/minikube
+    mode: "0755"
+  when: ansible_architecture == "x86_64"
+
+- name: Install minikube
+  become: true
+  ansible.builtin.get_url:
+    url: https://github.com/kubernetes/minikube/releases/latest/download/minikube-linux-arm64
+    dest: /usr/local/bin/minikube
+    mode: "0755"
+  when: ansible_architecture == "aarch64"
+
+# https://minikube.sigs.k8s.io/docs/handbook/kubectl/
+- name: Install Kubectl
+  become: true
+  ansible.builtin.file:
+    src: /usr/local/bin/minikube
+    dest: /usr/local/bin/kubectl
+    state: link
+
+- name: Setup Kubectl
+  ansible.builtin.shell:
+    cmd: kubectl help

+ 3 - 0
deploy/roles/docker/tasks/main.yml

@@ -67,3 +67,6 @@
     line: "net.ipv4.ip_forward = 1"
     create: true
     mode: "0644"
+
+- name: Setup kubernetes
+  ansible.builtin.import_tasks: kubernetes.yml

+ 30 - 0
deploy/roles/mint-v2.1/tasks/clove.yml

@@ -0,0 +1,30 @@
+- name: Clone source codes
+  ansible.builtin.git:
+    repo: "https://github.com/iapt-platform/clove.git"
+    dest: "{{ app_deploy_target | dirname }}/clove"
+
+# - name: Clean resources for v8
+#   ansible.builtin.file:
+#     path: "{{ app_deploy_target }}/api-v8/storage/resources"
+#     state: absent
+
+- name: Setup resources for v8
+  become: true
+  ansible.builtin.file:
+    src: "{{ app_deploy_target | dirname }}/clove"
+    dest: "{{ app_deploy_target }}/api-v8/storage/resources"
+    state: link
+    force: true
+
+# - name: Clean resources for v12
+#   ansible.builtin.file:
+#     path: "{{ app_deploy_target }}/api-v12/storage/resources"
+#     state: absent
+
+- name: Setup resources for v12
+  become: true
+  ansible.builtin.file:
+    src: "{{ app_deploy_target | dirname }}/clove"
+    dest: "{{ app_deploy_target }}/api-v12/storage/resources"
+    state: link
+    force: true

+ 4 - 1
deploy/roles/mint-v2.1/tasks/laravel.yml

@@ -43,10 +43,13 @@
     - wbw.analyses
     - export.pali.chapter
     - export.article
-    - ai.translate
+    # - ai.translate
   loop_control:
     loop_var: worker_name
 
+- name: Setup clove resources
+  ansible.builtin.import_tasks: clove.yml
+
 - name: Setup schedule run
   ansible.builtin.import_tasks: schedule-run.yml
 

+ 53 - 18
deploy/roles/mint-v2.1/tasks/main.yml

@@ -6,24 +6,41 @@
     owner: "{{ ansible_user }}"
     mode: "0755"
 
-# - name: Download source code
-#   ansible.builtin.unarchive:
-#     src: https://github.com/iapt-platform/mint/archive/{{ mint_version }}.zip
-#     dest: "{{ app_deploy_target | dirname }}"
-#     remote_src: true
-#     creates: "{{ app_deploy_target }}"
-
-- name: Download source codes
-  ansible.builtin.git:
-    repo: "https://github.com/iapt-platform/mint.git"
-    dest: "{{ app_deploy_target | dirname }}/repo"
-
-- name: Clone to spec version
-  ansible.builtin.git:
-    repo: "{{ app_deploy_target | dirname }}/repo"
-    dest: "{{ app_deploy_target }}"
-    depth: 1
-    single_branch: true
+# ---------------------------------------------------------
+
+- name: Download source code
+  ansible.builtin.unarchive:
+    src: https://github.com/iapt-platform/mint/archive/{{ mint_version }}.zip
+    dest: "{{ app_deploy_target | dirname }}"
+    remote_src: true
+    creates: "{{ app_deploy_target }}"
+
+# ---------------------------------------------------------
+
+# - name: Clone source codes directly
+#   ansible.builtin.git:
+#     repo: "https://github.com/iapt-platform/mint.git"
+#     dest: "{{ app_deploy_target }}"
+#     version: "{{ mint_version }}"
+
+# ---------------------------------------------------------
+
+# - name: Clone source codes to repo
+#   ansible.builtin.git:
+#     repo: "https://github.com/iapt-platform/mint.git"
+#     dest: "{{ app_deploy_target | dirname }}/repo"
+#     update: true
+#     version: "development"
+
+# - name: Clone to from local repo
+#   ansible.builtin.git:
+#     repo: "{{ app_deploy_target | dirname }}/repo"
+#     dest: "{{ app_deploy_target }}"
+#     version: "{{ mint_version }}"
+#     # depth: 1
+#     # single_branch: true
+
+# ---------------------------------------------------------
 
 - name: Upload dashboard-v4 dist
   ansible.posix.synchronize:
@@ -48,3 +65,21 @@
   ansible.builtin.systemd:
     daemon_reload: true
     scope: user
+
+- name: Upload version.txt(api-v8)
+  ansible.builtin.template:
+    src: version.txt.j2
+    dest: "{{ app_deploy_target }}/api-v8/public/version.txt"
+    mode: "0555"
+
+- name: Upload version.txt(api-v12)
+  ansible.builtin.template:
+    src: version.txt.j2
+    dest: "{{ app_deploy_target }}/api-v12/public/version.txt"
+    mode: "0555"
+
+- name: Upload version.txt(dashboard-v4)
+  ansible.builtin.template:
+    src: version.txt.j2
+    dest: "{{ app_deploy_target }}/dashboard-v4/dashboard/dist/version.txt"
+    mode: "0555"

+ 2 - 0
deploy/roles/mint-v2.1/templates/version.txt.j2

@@ -0,0 +1,2 @@
+git version: {{ mint_version }}
+deployed at: {{ ansible_date_time.iso8601 }}

+ 20 - 0
deploy/roles/python3/tasks/main.yml

@@ -0,0 +1,20 @@
+# https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa
+
+- name: Add python3 stable repository from PPA and install its signing key on Ubuntu target
+  become: true
+  ansible.builtin.apt_repository:
+    repo: ppa:deadsnakes/ppa
+
+- name: Update apt cache
+  become: true
+  ansible.builtin.apt:
+    update_cache: true
+    # cache_valid_time: 3600
+
+- name: Install python3 packages
+  become: true
+  ansible.builtin.apt:
+    pkg:
+      - python{{ app_python_version }}-dev
+      - python{{ app_python_version }}-venv
+      - python{{ app_python_version }}-distutils

+ 1 - 0
docker/mint/Dockerfile

@@ -2,6 +2,7 @@ FROM ubuntu:latest
 LABEL maintainer="Jeremy Zheng"
 
 ENV DEBIAN_FRONTEND noninteractive
+# https://launchpad.net/~ondrej/+archive/ubuntu/php
 ARG PHP_VERSION=8.4
 
 RUN apt update

+ 2 - 0
open-ai-server/.gitignore

@@ -0,0 +1,2 @@
+/node_modules
+package-lock.json

+ 192 - 0
open-ai-server/README.md

@@ -0,0 +1,192 @@
+# open ai server
+
+这个 API 将接收 POST 请求,使用 OpenAI SDK 调用 API,并支持流式响应。现在我来创建 package.json 文件,包含所需的依赖:让我再创建一个使用示例文件,展示如何调用这个 API:
+
+## 安装和运行步骤
+
+1. **安装依赖**:
+
+```bash
+npm install
+```
+
+1. **启动服务器**:
+
+```bash
+# 开发模式(自动重启)
+npm run dev
+
+# 生产模式
+npm start
+```
+
+## 主要功能特点
+
+**✅ RESTful API**:使用 Express.js 创建 POST 端点 `/api/openai`
+
+**✅ 支持流式响应**:当`payload.stream = true`时启用 Server-Sent Events
+
+**✅ 错误处理**:完整的错误处理和状态码响应
+
+**✅ CORS 支持**:支持跨域请求
+
+**✅ 参数验证**:验证必需的参数
+
+**✅ 健康检查**:提供`/health`端点用于服务监控
+
+## API 使用方法
+
+**请求格式**:
+
+```json
+{
+  "open_ai_url": "https://api.openai.com/v1",
+  "api_key": "your-api-key",
+  "payload": {
+    "model": "gpt-4",
+    "messages": [{ "role": "user", "content": "your message" }],
+    "stream": true // 可选,启用流式响应
+  }
+}
+```
+
+**非流式响应**:返回完整的 JSON 结果
+**流式响应**:返回 Server-Sent Events 格式的实时数据流
+
+服务器会在端口 3000 上运行,你可以通过环境变量`PORT`来修改端口号。
+
+```bash
+# 启动服务器(端口4000)
+PORT=4000 npm run dev
+```
+
+## 测试
+
+```bash
+# 非流式请求
+curl -X POST http://localhost:3000/api/openai \
+  -H "Content-Type: application/json" \
+  -d '{
+    "open_ai_url": "https://api.openai.com/v1",
+    "api_key": "your-api-key-here",
+    "payload": {
+      "model": "gpt-4",
+      "messages": [
+        {
+          "role": "user",
+          "content": "Tell me a three sentence bedtime story about a unicorn."
+        }
+      ],
+      "max_tokens": 150,
+      "temperature": 0.7
+    }
+  }'
+
+# 流式请求
+curl -X POST http://localhost:3000/api/openai \
+  -H "Content-Type: application/json" \
+  -d '{
+    "open_ai_url": "https://api.openai.com/v1",
+    "api_key": "your-api-key-here",
+    "payload": {
+      "model": "gpt-4",
+      "messages": [
+        {
+          "role": "user",
+          "content": "Tell me a three sentence bedtime story about a unicorn."
+        }
+      ],
+      "max_tokens": 150,
+      "temperature": 0.7,
+      "stream": true
+    }
+  }'
+```
+
+```javascript
+// 1. 非流式请求示例
+async function callOpenAIAPI() {
+  const response = await fetch("http://localhost:3000/api/openai", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({
+      open_ai_url: "https://api.openai.com/v1",
+      api_key: "your-api-key-here",
+      payload: {
+        model: "gpt-4",
+        messages: [
+          {
+            role: "user",
+            content: "Tell me a three sentence bedtime story about a unicorn.",
+          },
+        ],
+        max_tokens: 150,
+        temperature: 0.7,
+      },
+    }),
+  });
+
+  const data = await response.json();
+  console.log("Non-streaming response:", data);
+}
+
+// 2. 流式请求示例
+async function callOpenAIAPIStreaming() {
+  const response = await fetch("http://localhost:3000/api/openai", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({
+      open_ai_url: "https://api.openai.com/v1",
+      api_key: "your-api-key-here",
+      payload: {
+        model: "gpt-4",
+        messages: [
+          {
+            role: "user",
+            content: "Tell me a three sentence bedtime story about a unicorn.",
+          },
+        ],
+        max_tokens: 150,
+        temperature: 0.7,
+        stream: true, // 开启流式响应
+      },
+    }),
+  });
+
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder();
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      const chunk = decoder.decode(value);
+      const lines = chunk.split("\n");
+
+      for (const line of lines) {
+        if (line.startsWith("data: ")) {
+          const data = line.slice(6);
+          if (data === "[DONE]") {
+            console.log("Stream finished");
+            return;
+          }
+
+          try {
+            const parsed = JSON.parse(data);
+            console.log("Streaming chunk:", parsed);
+          } catch (e) {
+            // 忽略解析错误
+          }
+        }
+      }
+    }
+  } finally {
+    reader.releaseLock();
+  }
+}
+```

+ 175 - 0
open-ai-server/error.md

@@ -0,0 +1,175 @@
+# 错误处理指南
+
+## 错误响应格式
+
+### API 错误响应(来自 OpenAI SDK)
+
+```json
+{
+  "error": "Error message",
+  "type": "api_error",
+  "code": "invalid_request_error",
+  "param": "model",
+  "details": {
+    "status": 400,
+    "headers": {},
+    "request_id": "req_123",
+    "response": {
+      // 原始API响应
+    }
+  },
+  "raw_error": {
+    "name": "OpenAIError",
+    "message": "Detailed error message",
+    "stack": "Error stack trace (development only)"
+  }
+}
+```
+
+### 服务器内部错误响应
+
+```json
+{
+  "error": "Internal server error",
+  "message": "Specific error message",
+  "type": "NetworkError",
+  "details": {
+    "name": "NetworkError",
+    "message": "Connection failed",
+    "stack": "Error stack trace (development only)",
+    "cause": null,
+    "errno": -3008,
+    "code": "ENOTFOUND",
+    "syscall": "getaddrinfo",
+    "hostname": "api.example.com"
+  }
+}
+```
+
+### 流式响应错误
+
+```json
+{
+  "error": "Streaming failed",
+  "type": "streaming_error",
+  "status": 429,
+  "code": "rate_limit_exceeded",
+  "details": {
+    "name": "RateLimitError",
+    "message": "Rate limit exceeded",
+    "stack": "Error stack trace (development only)",
+    "headers": {},
+    "request_id": "req_456"
+  }
+}
+```
+
+## 常见错误类型
+
+### 1. 认证错误 (401)
+
+```bash
+curl -X POST http://localhost:4000/api/openai \
+  -H "Content-Type: application/json" \
+  -d '{
+    "open_ai_url": "https://generativelanguage.googleapis.com/v1beta/openai",
+    "api_key": "invalid_key",
+    "payload": {
+      "model": "gemini-2.0-flash-exp",
+      "messages": [{"role": "user", "content": "Hello"}]
+    }
+  }'
+```
+
+### 2. 无效模型错误 (400)
+
+```bash
+curl -X POST http://localhost:4000/api/openai \
+  -H "Content-Type: application/json" \
+  -d '{
+    "open_ai_url": "https://generativelanguage.googleapis.com/v1beta/openai",
+    "api_key": "valid_key",
+    "payload": {
+      "model": "invalid-model-name",
+      "messages": [{"role": "user", "content": "Hello"}]
+    }
+  }'
+```
+
+### 3. 速率限制错误 (429)
+
+当请求过于频繁时会返回速率限制错误。
+
+### 4. 网络连接错误 (500)
+
+当无法连接到 API 服务时返回网络错误。
+
+## 调试技巧
+
+### 1. 使用调试端点
+
+```bash
+curl -X POST http://localhost:4000/api/debug \
+  -H "Content-Type: application/json" \
+  -d '{
+    "test": "debug request"
+  }'
+```
+
+### 2. 设置开发环境
+
+```bash
+NODE_ENV=development PORT=4000 npm start
+```
+
+在开发环境下会显示完整的错误堆栈信息。
+
+### 3. 检查服务器日志
+
+所有错误都会在服务器端打印详细日志:
+
+```text
+API Error: OpenAIError: Invalid API key provided
+    at new OpenAIError (/path/to/error)
+    ...
+```
+
+## 错误排查步骤
+
+1. **检查 API 密钥**:确保 API 密钥有效且有相应权限
+2. **验证 URL**:确保使用正确的 baseURL
+3. **检查模型名称**:确认模型名称正确且可用
+4. **查看速率限制**:检查是否超过 API 调用限制
+5. **网络连接**:确认能够访问目标 API 服务
+6. **参数验证**:检查请求参数格式是否正确
+
+## 示例:完整错误响应
+
+```json
+{
+  "error": "The model `invalid-model` does not exist",
+  "type": "invalid_request_error",
+  "code": "model_not_found",
+  "param": "model",
+  "details": {
+    "status": 404,
+    "headers": {
+      "content-type": "application/json",
+      "x-request-id": "req_123abc"
+    },
+    "request_id": "req_123abc",
+    "response": {
+      "error": {
+        "message": "The model `invalid-model` does not exist",
+        "type": "invalid_request_error",
+        "param": "model",
+        "code": "model_not_found"
+      }
+    }
+  },
+  "raw_error": {
+    "name": "NotFoundError",
+    "message": "The model `invalid-model` does not exist"
+  }
+}
+```

+ 32 - 0
open-ai-server/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "openai-proxy-api",
+  "version": "1.0.0",
+  "description": "RESTful API proxy for OpenAI with streaming support",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "nodemon server.js",
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [
+    "openai",
+    "api",
+    "proxy",
+    "streaming",
+    "nodejs",
+    "express"
+  ],
+  "author": "",
+  "license": "MIT",
+  "dependencies": {
+    "express": "^4.18.2",
+    "openai": "^4.20.1",
+    "cors": "^2.8.5"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.1"
+  },
+  "engines": {
+    "node": ">=16.0.0"
+  }
+}

+ 96 - 0
open-ai-server/server.js

@@ -0,0 +1,96 @@
+const express = require("express");
+const OpenAI = require("openai");
+const cors = require("cors");
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+// 中间件
+app.use(cors());
+app.use(express.json());
+
+// POST 路由处理OpenAI请求
+app.post("/api/openai", async (req, res) => {
+  try {
+    const { open_ai_url, api_key, payload } = req.body;
+    console.debug("request", open_ai_url);
+    // 验证必需的参数
+    if (!open_ai_url || !api_key || !payload) {
+      return res.status(400).json({
+        error: "Missing required parameters: open_ai_url, api_key, or payload",
+      });
+    }
+
+    // 初始化OpenAI客户端
+    const openai = new OpenAI({
+      apiKey: api_key,
+      baseURL: open_ai_url,
+    });
+
+    // 检查是否需要流式响应
+    const isStreaming = payload.stream === true;
+
+    if (isStreaming) {
+      // 流式响应
+      res.setHeader("Content-Type", "text/event-stream");
+      res.setHeader("Cache-Control", "no-cache");
+      res.setHeader("Connection", "keep-alive");
+      res.setHeader("Access-Control-Allow-Origin", "*");
+
+      try {
+        const stream = await openai.chat.completions.create({
+          ...payload,
+          stream: true,
+        });
+        console.info("waiting response");
+        for await (const chunk of stream) {
+          const data = JSON.stringify(chunk);
+          res.write(`data: ${data}\n\n`);
+        }
+
+        res.write("data: [DONE]\n\n");
+        res.end();
+      } catch (streamError) {
+        console.error("Streaming error:", streamError);
+        res.write(
+          `data: ${JSON.stringify({ error: streamError.message })}\n\n`
+        );
+        res.end();
+      }
+    } else {
+      // 非流式响应
+      const completion = await openai.chat.completions.create(payload);
+
+      res.json(completion);
+    }
+  } catch (error) {
+    console.error("API Error:", error);
+
+    // 处理不同类型的错误
+    if (error.status) {
+      return res.status(error.status).json({
+        error: error.message,
+        type: error.type || "api_error",
+      });
+    }
+
+    res.status(500).json({
+      error: "Internal server error",
+      message: error.message,
+    });
+  }
+});
+
+// 健康检查端点
+app.get("/health", (req, res) => {
+  res.json({ status: "OK", timestamp: new Date().toISOString() });
+});
+
+// 启动服务器
+app.listen(PORT, () => {
+  console.log(`Server is running on port ${PORT}`);
+  console.log(`Health check: http://localhost:${PORT}/health`);
+  console.log(`API endpoint: http://localhost:${PORT}/api/openai`);
+});
+
+module.exports = app;