Procházet zdrojové kódy

Merge pull request #2324 from visuddhinanda/development

Development
visuddhinanda před 9 měsíci
rodič
revize
511cee2765
27 změnil soubory, kde provedl 2042 přidání a 439 odebrání
  1. 37 10
      api-v8/app/Console/Commands/TestMdRender.php
  2. 1 1
      api-v8/app/Http/Controllers/ProjectTreeController.php
  3. 28 0
      api-v8/app/Providers/TemplateServiceProvider.php
  4. 13 0
      api-v8/app/Services/Template/ContentNode.php
  5. 15 0
      api-v8/app/Services/Template/Contracts/ParserInterface.php
  6. 25 0
      api-v8/app/Services/Template/MarkdownNode.php
  7. 51 0
      api-v8/app/Services/Template/ParameterResolver.php
  8. 21 0
      api-v8/app/Services/Template/ParsedDocument.php
  9. 141 0
      api-v8/app/Services/Template/Renderers/HtmlRenderer.php
  10. 36 0
      api-v8/app/Services/Template/Renderers/JsonRenderer.php
  11. 70 0
      api-v8/app/Services/Template/Renderers/MarkdownRenderer.php
  12. 32 0
      api-v8/app/Services/Template/Renderers/RendererFactory.php
  13. 150 0
      api-v8/app/Services/Template/Renderers/TextRenderer.php
  14. 34 0
      api-v8/app/Services/Template/TemplateNode.php
  15. 159 0
      api-v8/app/Services/Template/TemplateParser.php
  16. 83 0
      api-v8/app/Services/Template/TemplateRegistry.php
  17. 209 0
      api-v8/app/Services/Template/TemplateService.php
  18. 114 0
      api-v8/app/Services/Template/TemplateTokenizer.php
  19. 25 0
      api-v8/app/Services/Template/TextNode.php
  20. 1 0
      api-v8/app/Services/TemplateRender.php
  21. 115 0
      api-v8/app/Services/Templates/NoteTemplate.php
  22. 8 8
      api-v8/app/Services/Templates/TermTemplate.php
  23. 49 0
      api-v8/config/template.php
  24. 163 133
      dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx
  25. 285 282
      dashboard-v4/dashboard/src/components/template/SentEdit/SentTab.tsx
  26. 175 0
      open-ai-server/error.md
  27. 2 5
      open-ai-server/server.js

+ 37 - 10
api-v8/app/Console/Commands/TestMdRender.php

@@ -41,10 +41,37 @@ class TestMdRender extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-        Log::info('md render start item='.$this->argument('item'));
+
+        $content = <<<md
+        # 测试
+        ## 测试
+
+        {{note|text=这是一个普通信息提示|type=info}}
+
+        下面是一个警告信息:
+        {{warning|message=请注意这个重要提示|title=重要}}
+
+        你也可以使用位置参数:
+        {{note|
+        成功完成操作|
+        success}}
+
+        支持嵌套模板:
+        {{info|
+        content=外层信息 {{note|内嵌提示|warning}} 继续外层|
+        title=嵌套示例}}
+
+        **粗体文本** 和 *斜体文本* 也被支持。
+        md;
+        $parser = new \App\Services\Template\TemplateService(false);
+        $render = $parser->parseAndRender($content, 'json');
+        var_dump($render);
+        return 0;
+
+        Log::info('md render start item=' . $this->argument('item'));
         $data = array();
         $data['bold'] = <<<md
         **三十位** 经在[中间]六处为**[licchavi]**,在极果为**慧解脱**
@@ -147,24 +174,24 @@ class TestMdRender extends Command
         Markdown::driver($this->option('driver'));
 
         $format = $this->option('format');
-        if(empty($format)){
-            $formats = ['react','unity','text','tex','html','simple'];
-        }else{
+        if (empty($format)) {
+            $formats = ['react', 'unity', 'text', 'tex', 'html', 'simple'];
+        } else {
             $formats = [$format];
         }
         foreach ($formats as $format) {
             $this->info("format:{$format}");
             foreach ($data as $key => $value) {
                 $_item = $this->argument('item');
-                if(!empty($_item) && $key !==$_item){
+                if (!empty($_item) && $key !== $_item) {
                     continue;
                 }
                 $mdRender = new MdRender([
-                    'format'=>$format,
-                    'footnote'=>true,
-                    'paragraph'=>true,
+                    'format' => $format,
+                    'footnote' => true,
+                    'paragraph' => true,
                 ]);
-                $output = $mdRender->convert($value,['00ae2c48-c204-4082-ae79-79ba2740d506']);
+                $output = $mdRender->convert($value, ['00ae2c48-c204-4082-ae79-79ba2740d506']);
                 echo $output;
             }
         }

+ 1 - 1
api-v8/app/Http/Controllers/ProjectTreeController.php

@@ -61,7 +61,7 @@ class ProjectTreeController extends Controller
         }
         foreach ($newData as $key => $value) {
             if ($value['parent_id']) {
-                $parent = array_find($newData, function ($element) use ($value) {
+                $parent = \array_find($newData, function ($element) use ($value) {
                     return $element['old_id'] == $value['parent_id'];
                 });
                 if ($parent) {

+ 28 - 0
api-v8/app/Providers/TemplateServiceProvider.php

@@ -0,0 +1,28 @@
+<?php
+
+// ================== 服务提供者 ==================
+
+namespace App\Providers;
+
+use Illuminate\Support\ServiceProvider;
+use App\Services\Template\TemplateService;
+
+class TemplateServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->app->singleton(TemplateService::class, function ($app) {
+            return new TemplateService(
+                config('template.cache_enabled', true),
+                config('template.cache_ttl', 3600)
+            );
+        });
+    }
+
+    public function boot(): void
+    {
+        $this->publishes([
+            __DIR__ . '/../config/template.php' => config_path('template.php'),
+        ], 'template-config');
+    }
+}

+ 13 - 0
api-v8/app/Services/Template/ContentNode.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Services\Template;
+
+
+abstract class ContentNode
+{
+    public string $type;
+    public string $content;
+    public array $position = [];
+
+    abstract public function toArray(): array;
+}

+ 15 - 0
api-v8/app/Services/Template/Contracts/ParserInterface.php

@@ -0,0 +1,15 @@
+<?php
+
+// ================== 契约接口 ==================
+
+namespace App\Services\Template\Contracts;
+
+interface ParserInterface
+{
+    public function parse(string $content): \App\Services\Template\ParsedDocument;
+}
+
+interface RendererInterface
+{
+    public function render(\App\Services\Template\ParsedDocument $document): string;
+}

+ 25 - 0
api-v8/app/Services/Template/MarkdownNode.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Services\Template;
+
+
+class MarkdownNode extends ContentNode
+{
+    public string $content;
+
+    public function __construct(string $content, array $position = [])
+    {
+        $this->type = 'markdown';
+        $this->content = $content;
+        $this->position = $position;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'type' => $this->type,
+            'content' => $this->content,
+            'position' => $this->position
+        ];
+    }
+}

+ 51 - 0
api-v8/app/Services/Template/ParameterResolver.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Services\Template;
+
+
+// ================== 参数解析器 ==================
+
+class ParameterResolver
+{
+    private TemplateRegistry $registry;
+
+    public function __construct(TemplateRegistry $registry)
+    {
+        $this->registry = $registry;
+    }
+
+    public function resolveParameters(string $templateName, array $rawParams): array
+    {
+        $template = $this->registry->getTemplate($templateName);
+        if (!$template) {
+            return $rawParams;
+        }
+
+        $resolved = [];
+        $paramMapping = $template['paramMapping'] ?? [];
+        $defaultValues = $template['defaultValues'] ?? [];
+
+        // 处理位置参数
+        $positionIndex = 0;
+        foreach ($rawParams as $key => $value) {
+            if (is_numeric($key)) {
+                // 位置参数
+                $paramName = $paramMapping[$positionIndex] ?? $positionIndex;
+                $resolved[$paramName] = $value;
+                $positionIndex++;
+            } else {
+                // 命名参数
+                $resolved[$key] = $value;
+            }
+        }
+
+        // 应用默认值
+        foreach ($defaultValues as $key => $value) {
+            if (!isset($resolved[$key])) {
+                $resolved[$key] = $value;
+            }
+        }
+
+        return $resolved;
+    }
+}

+ 21 - 0
api-v8/app/Services/Template/ParsedDocument.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services\Template;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\Contracts\ParserInterface;
+
+// ================== 数据结构 ==================
+
+class ParsedDocument
+{
+    public string $type = 'document';
+    public array $content = [];
+    public array $meta = [];
+
+    public function __construct(array $content = [], array $meta = [])
+    {
+        $this->content = $content;
+        $this->meta = $meta;
+    }
+}

+ 141 - 0
api-v8/app/Services/Template/Renderers/HtmlRenderer.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Services\Template\Renderers;
+
+use Illuminate\Support\Str;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\ParsedDocument;
+use App\Services\Template\ContentNode;
+use App\Services\Template\TextNode;
+use App\Services\Template\MarkdownNode;
+use App\Services\Template\TemplateNode;
+
+
+
+// ================== HTML 渲染器 ==================
+
+class HtmlRenderer implements RendererInterface
+{
+    private array $templateMappings = [];
+
+    public function __construct()
+    {
+        $this->initializeTemplateMappings();
+    }
+
+    private function initializeTemplateMappings(): void
+    {
+        $this->templateMappings = [
+            'note' => function ($params) {
+                $type = $params['type'] ?? 'info';
+                $text = $params['text'] ?? '';
+                $title = $params['title'] ?? '';
+
+                $typeClass = match ($type) {
+                    'warning' => 'alert-warning',
+                    'error' => 'alert-danger',
+                    'success' => 'alert-success',
+                    default => 'alert-info'
+                };
+
+                $titleHtml = $title ? "<h6 class='alert-heading'>$title</h6>" : '';
+                return "<div class='alert $typeClass' role='alert'>$titleHtml$text</div>";
+            },
+
+            'info' => function ($params) {
+                $content = $params['content'] ?? '';
+                $title = $params['title'] ?? '';
+
+                $titleHtml = $title ? "<h6 class='alert-heading'>$title</h6>" : '';
+                return "<div class='alert alert-info' role='alert'>$titleHtml$content</div>";
+            },
+
+            'warning' => function ($params) {
+                $message = $params['message'] ?? '';
+                $title = $params['title'] ?? '';
+
+                $titleHtml = $title ? "<h6 class='alert-heading'>$title</h6>" : '';
+                return "<div class='alert alert-warning' role='alert'>$titleHtml$message</div>";
+            }
+        ];
+    }
+
+    public function render(ParsedDocument $document): string
+    {
+        $html = '';
+
+        foreach ($document->content as $node) {
+            $html .= $this->renderNode($node);
+        }
+
+        return Str::markdown($html);
+    }
+
+    private function renderNode(ContentNode $node): string
+    {
+        switch ($node->type) {
+            case 'text':
+                return $this->renderText($node);
+            case 'markdown':
+                return $this->renderMarkdown($node);
+            case 'template':
+                return $this->renderTemplate($node);
+            default:
+                return '';
+        }
+    }
+
+    public function renderText(TextNode $text): string
+    {
+        return htmlspecialchars($text->content, ENT_QUOTES, 'UTF-8');
+    }
+
+    private function renderMarkdown(MarkdownNode $markdown): string
+    {
+        return Str::markdown($markdown->content);
+        // 简单的 Markdown 渲染,实际项目中可以使用 CommonMark 等库
+        $html = $markdown->content;
+
+        // 处理粗体
+        $html = preg_replace('/\*\*(.*?)\*\*/', '<strong>$1</strong>', $html);
+        $html = preg_replace('/\*(.*?)\*/', '<em>$1</em>', $html);
+
+        // 处理链接
+        $html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $html);
+
+        return $html;
+    }
+
+    public function renderTemplate(TemplateNode $template): string
+    {
+        if (!isset($this->templateMappings[$template->name])) {
+            // 未知模板,返回原始内容
+            return htmlspecialchars($template->raw, ENT_QUOTES, 'UTF-8');
+        }
+
+        $renderer = $this->templateMappings[$template->name];
+
+        // 处理参数中的嵌套内容
+        $processedParams = [];
+        foreach ($template->parameters as $key => $value) {
+            if (is_array($value)) {
+                // 嵌套内容,递归渲染
+                $nestedHtml = '';
+                foreach ($value as $childNode) {
+                    $nestedHtml .= $this->renderNode($childNode);
+                }
+                $processedParams[$key] = $nestedHtml;
+            } else {
+                $processedParams[$key] = $value;
+            }
+        }
+
+        return $renderer($processedParams);
+    }
+
+    public function registerTemplate(string $name, callable $renderer): void
+    {
+        $this->templateMappings[$name] = $renderer;
+    }
+}

+ 36 - 0
api-v8/app/Services/Template/Renderers/JsonRenderer.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Services\Template\Renderers;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\ParsedDocument;
+use App\Services\Template\ContentNode;
+use App\Services\Template\TextNode;
+use App\Services\Template\MarkdownNode;
+use App\Services\Template\TemplateNode;
+
+// ================== JSON 渲染器 ==================
+
+class JsonRenderer implements RendererInterface
+{
+    public function render(ParsedDocument $document): string
+    {
+        $data = [
+            'type' => $document->type,
+            'content' => array_map(fn($node) => $node->toArray(), $document->content),
+            'meta' => $document->meta
+        ];
+
+        return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
+    }
+
+    public function renderTemplate(TemplateNode $template): string
+    {
+        return json_encode($template->toArray(), JSON_UNESCAPED_UNICODE);
+    }
+
+    public function renderText(TextNode $text): string
+    {
+        return json_encode($text->toArray(), JSON_UNESCAPED_UNICODE);
+    }
+}

+ 70 - 0
api-v8/app/Services/Template/Renderers/MarkdownRenderer.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Services\Template\Renderers;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\ParsedDocument;
+use App\Services\Template\ContentNode;
+use App\Services\Template\TextNode;
+use App\Services\Template\MarkdownNode;
+use App\Services\Template\TemplateNode;
+
+
+
+
+// ================== Markdown 渲染器 ==================
+
+class MarkdownRenderer implements RendererInterface
+{
+    public function render(ParsedDocument $document): string
+    {
+        $markdown = '';
+
+        foreach ($document->content as $node) {
+            $markdown .= $this->renderNode($node);
+        }
+
+        return $markdown;
+    }
+
+    private function renderNode(ContentNode $node): string
+    {
+        switch ($node->type) {
+            case 'text':
+                return $this->renderText($node);
+            case 'markdown':
+                return $node->content;
+            case 'template':
+                return $this->renderTemplate($node);
+            default:
+                return '';
+        }
+    }
+
+    public function renderText(TextNode $text): string
+    {
+        return $text->content;
+    }
+
+    public function renderTemplate(TemplateNode $template): string
+    {
+        // 将模板转换回 Markdown 格式
+        $params = [];
+
+        foreach ($template->parameters as $key => $value) {
+            if (is_array($value)) {
+                // 嵌套内容,递归渲染
+                $nestedMarkdown = '';
+                foreach ($value as $childNode) {
+                    $nestedMarkdown .= $this->renderNode($childNode);
+                }
+                $params[] = "$key=$nestedMarkdown";
+            } else {
+                $params[] = is_numeric($key) ? $value : "$key=$value";
+            }
+        }
+
+        $paramString = implode('|', $params);
+        return "{{" . $template->name . ($paramString ? "|$paramString" : "") . "}}";
+    }
+}

+ 32 - 0
api-v8/app/Services/Template/Renderers/RendererFactory.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Services\Template\Renderers;
+
+use App\Services\Template\Contracts\RendererInterface;
+
+// ================== 渲染器工厂 ==================
+
+class RendererFactory
+{
+    private static array $renderers = [];
+
+    public static function create(string $format): RendererInterface
+    {
+        if (!isset(self::$renderers[$format])) {
+            self::$renderers[$format] = match ($format) {
+                'json' => new JsonRenderer(),
+                'html' => new HtmlRenderer(),
+                'markdown' => new MarkdownRenderer(),
+                'text' => new TextRenderer(),
+                default => throw new \InvalidArgumentException("Unsupported format: $format")
+            };
+        }
+
+        return self::$renderers[$format];
+    }
+
+    public static function getSupportedFormats(): array
+    {
+        return ['json', 'html', 'markdown', 'text'];
+    }
+}

+ 150 - 0
api-v8/app/Services/Template/Renderers/TextRenderer.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace App\Services\Template\Renderers;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\ParsedDocument;
+use App\Services\Template\ContentNode;
+use App\Services\Template\TextNode;
+use App\Services\Template\MarkdownNode;
+use App\Services\Template\TemplateNode;
+
+
+
+// ================== 纯文本渲染器 ==================
+
+class TextRenderer implements RendererInterface
+{
+    private array $templateTexts = [];
+
+    public function __construct()
+    {
+        $this->initializeTemplateTexts();
+    }
+
+    private function initializeTemplateTexts(): void
+    {
+        $this->templateTexts = [
+            'note' => function ($params) {
+                $type = $params['type'] ?? 'info';
+                $text = $params['text'] ?? '';
+                $title = $params['title'] ?? '';
+
+                $prefix = match ($type) {
+                    'warning' => '[警告]',
+                    'error' => '[错误]',
+                    'success' => '[成功]',
+                    default => '[信息]'
+                };
+
+                return $prefix . ($title ? " $title: " : ' ') . $text;
+            },
+
+            'info' => function ($params) {
+                $content = $params['content'] ?? '';
+                $title = $params['title'] ?? '';
+
+                return '[信息]' . ($title ? " $title: " : ' ') . $content;
+            },
+
+            'warning' => function ($params) {
+                $message = $params['message'] ?? '';
+                $title = $params['title'] ?? '';
+
+                return '[警告]' . ($title ? " $title: " : ' ') . $message;
+            }
+        ];
+    }
+
+    public function render(ParsedDocument $document): string
+    {
+        $text = '';
+
+        foreach ($document->content as $node) {
+            $text .= $this->renderNode($node);
+        }
+
+        return $text;
+    }
+
+    private function renderNode(ContentNode $node): string
+    {
+        switch ($node->type) {
+            case 'text':
+                return $this->renderText($node);
+            case 'markdown':
+                return $this->renderMarkdownAsText($node);
+            case 'template':
+                return $this->renderTemplate($node);
+            default:
+                return '';
+        }
+    }
+
+    public function renderText(TextNode $text): string
+    {
+        return $text->content;
+    }
+
+    private function renderMarkdownAsText(MarkdownNode $markdown): string
+    {
+        // 移除 Markdown 标记,返回纯文本
+        $text = $markdown->content;
+
+        // 移除粗体和斜体标记
+        $text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
+        $text = preg_replace('/\*(.*?)\*/', '$1', $text);
+
+        // 移除链接标记,保留链接文本
+        $text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $text);
+
+        return $text;
+    }
+
+    public function renderTemplate(TemplateNode $template): string
+    {
+        if (!isset($this->templateTexts[$template->name])) {
+            // 未知模板,返回简化的文本表示
+            $text = $template->name;
+            if (!empty($template->parameters)) {
+                $paramTexts = [];
+                foreach ($template->parameters as $key => $value) {
+                    if (is_array($value)) {
+                        $nestedText = '';
+                        foreach ($value as $childNode) {
+                            $nestedText .= $this->renderNode($childNode);
+                        }
+                        $paramTexts[] = is_numeric($key) ? $nestedText : "$key: $nestedText";
+                    } else {
+                        $paramTexts[] = is_numeric($key) ? $value : "$key: $value";
+                    }
+                }
+                $text .= '(' . implode(', ', $paramTexts) . ')';
+            }
+            return $text;
+        }
+
+        $renderer = $this->templateTexts[$template->name];
+
+        // 处理参数中的嵌套内容
+        $processedParams = [];
+        foreach ($template->parameters as $key => $value) {
+            if (is_array($value)) {
+                $nestedText = '';
+                foreach ($value as $childNode) {
+                    $nestedText .= $this->renderNode($childNode);
+                }
+                $processedParams[$key] = $nestedText;
+            } else {
+                $processedParams[$key] = $value;
+            }
+        }
+
+        return $renderer($processedParams);
+    }
+
+    public function registerTemplate(string $name, callable $renderer): void
+    {
+        $this->templateTexts[$name] = $renderer;
+    }
+}

+ 34 - 0
api-v8/app/Services/Template/TemplateNode.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Services\Template;
+
+
+class TemplateNode extends ContentNode
+{
+    public string $name;
+    public array $parameters = [];
+    public array $children = [];
+    public string $raw = '';
+
+    public function __construct(string $name, array $parameters = [], array $children = [], string $raw = '', array $position = [])
+    {
+        $this->type = 'template';
+        $this->name = $name;
+        $this->parameters = $parameters;
+        $this->children = $children;
+        $this->raw = $raw;
+        $this->position = $position;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'type' => $this->type,
+            'name' => $this->name,
+            'parameters' => $this->parameters,
+            'children' => array_map(fn($child) => $child->toArray(), $this->children),
+            'raw' => $this->raw,
+            'position' => $this->position
+        ];
+    }
+}

+ 159 - 0
api-v8/app/Services/Template/TemplateParser.php

@@ -0,0 +1,159 @@
+<?php
+
+// ================== 主解析器 ==================
+
+namespace App\Services\Template;
+
+use App\Services\Template\Contracts\RendererInterface;
+use App\Services\Template\Contracts\ParserInterface;
+
+class TemplateParser implements ParserInterface
+{
+    private TemplateRegistry $registry;
+    private ParameterResolver $parameterResolver;
+
+    public function __construct()
+    {
+        $this->registry = new TemplateRegistry();
+        $this->parameterResolver = new ParameterResolver($this->registry);
+    }
+
+    public function parse(string $content): ParsedDocument
+    {
+        $tokenizer = new TemplateTokenizer($content);
+        $tokens = $tokenizer->tokenize();
+
+        $nodes = [];
+        $templatesUsed = [];
+
+        foreach ($tokens as $token) {
+            if ($token['type'] === 'text') {
+                $nodes[] = new TextNode($token['content'], $token['position']);
+            } elseif ($token['type'] === 'template') {
+                $templateNode = $this->parseTemplateContent($token);
+                if ($templateNode) {
+                    $nodes[] = $templateNode;
+                    $templatesUsed[] = $templateNode->name;
+                } else {
+                    // 解析失败,当作文本处理
+                    $nodes[] = new TextNode($token['raw'], $token['position']);
+                }
+            }
+        }
+
+        return new ParsedDocument($nodes, [
+            'templates_used' => array_unique($templatesUsed),
+            'total_templates' => count($templatesUsed)
+        ]);
+    }
+
+    private function parseTemplateContent(array $token): ?TemplateNode
+    {
+        $content = $token['content'];
+        $parts = $this->splitTemplateParts($content);
+
+        if (empty($parts)) {
+            return null;
+        }
+
+        $templateName = array_shift($parts);
+        $rawParams = $this->parseParameters($parts);
+
+        // 递归解析参数中的嵌套模板
+        $processedParams = [];
+        foreach ($rawParams as $key => $value) {
+            if (strpos($value, '{{') !== false) {
+                // 参数值包含模板,递归解析
+                $subDocument = $this->parse($value);
+                $processedParams[$key] = $subDocument->content;
+            } else {
+                $processedParams[$key] = $value;
+            }
+        }
+
+        $resolvedParams = $this->parameterResolver->resolveParameters($templateName, $processedParams);
+
+        return new TemplateNode(
+            $templateName,
+            $resolvedParams,
+            [],
+            $token['raw'],
+            $token['position']
+        );
+    }
+
+    private function splitTemplateParts(string $content): array
+    {
+        $parts = [];
+        $current = '';
+        $braceLevel = 0;
+        $inQuotes = false;
+        $quoteChar = '';
+
+        for ($i = 0; $i < strlen($content); $i++) {
+            $char = $content[$i];
+            $nextChar = $i + 1 < strlen($content) ? $content[$i + 1] : '';
+
+            if (!$inQuotes && ($char === '"' || $char === "'")) {
+                $inQuotes = true;
+                $quoteChar = $char;
+                $current .= $char;
+            } elseif ($inQuotes && $char === $quoteChar) {
+                $inQuotes = false;
+                $quoteChar = '';
+                $current .= $char;
+            } elseif (!$inQuotes && $char === '{' && $nextChar === '{') {
+                $braceLevel++;
+                $current .= '{{';
+                $i++; // 跳过下一个字符
+            } elseif (!$inQuotes && $char === '}' && $nextChar === '}') {
+                $braceLevel--;
+                $current .= '}}';
+                $i++; // 跳过下一个字符
+            } elseif (!$inQuotes && $char === '|' && $braceLevel === 0) {
+                $parts[] = trim($current);
+                $current = '';
+            } else {
+                $current .= $char;
+            }
+        }
+
+        if ($current !== '') {
+            $parts[] = trim($current);
+        }
+
+        return $parts;
+    }
+
+    private function parseParameters(array $parts): array
+    {
+        $params = [];
+        $positionalIndex = 0;
+
+        foreach ($parts as $part) {
+            if (strpos($part, '=') !== false && !$this->isInNestedTemplate($part)) {
+                // 命名参数
+                [$key, $value] = explode('=', $part, 2);
+                $params[trim($key)] = trim($value);
+            } else {
+                // 位置参数
+                $params[$positionalIndex] = trim($part);
+                $positionalIndex++;
+            }
+        }
+
+        return $params;
+    }
+
+    private function isInNestedTemplate(string $content): bool
+    {
+        $openBraces = substr_count($content, '{{');
+        $closeBraces = substr_count($content, '}}');
+        return $openBraces > 0 && $openBraces >= $closeBraces;
+    }
+
+    public function getRegistry(): TemplateRegistry
+    {
+        return $this->registry;
+    }
+}

+ 83 - 0
api-v8/app/Services/Template/TemplateRegistry.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Services\Template;
+
+
+// ================== 模板注册表 ==================
+
+class TemplateRegistry
+{
+    private array $templates = [];
+
+    public function __construct()
+    {
+        $this->loadDefaultTemplates();
+    }
+
+    private function loadDefaultTemplates(): void
+    {
+        $this->templates = [
+            'note' => [
+                'defaultParams' => ['text', 'type'],
+                'paramMapping' => [
+                    '0' => 'text',
+                    '1' => 'type'
+                ],
+                'defaultValues' => [
+                    'type' => 'info'
+                ],
+                'validation' => [
+                    'required' => ['text'],
+                    'optional' => ['type', 'title']
+                ]
+            ],
+            'info' => [
+                'defaultParams' => ['content'],
+                'paramMapping' => [
+                    '0' => 'content'
+                ],
+                'defaultValues' => [],
+                'validation' => [
+                    'required' => ['content'],
+                    'optional' => ['title']
+                ]
+            ],
+            'warning' => [
+                'defaultParams' => ['message'],
+                'paramMapping' => [
+                    '0' => 'message'
+                ],
+                'defaultValues' => [],
+                'validation' => [
+                    'required' => ['message'],
+                    'optional' => ['title']
+                ]
+            ]
+        ];
+    }
+
+    public function registerTemplate(string $name, array $config): void
+    {
+        $this->templates[$name] = $config;
+    }
+
+    public function getTemplate(string $name): ?array
+    {
+        return $this->templates[$name] ?? null;
+    }
+
+    public function hasTemplate(string $name): bool
+    {
+        return isset($this->templates[$name]);
+    }
+
+    public function getParamMapping(string $templateName): array
+    {
+        return $this->templates[$templateName]['paramMapping'] ?? [];
+    }
+
+    public function getDefaultValues(string $templateName): array
+    {
+        return $this->templates[$templateName]['defaultValues'] ?? [];
+    }
+}

+ 209 - 0
api-v8/app/Services/Template/TemplateService.php

@@ -0,0 +1,209 @@
+<?php
+
+namespace App\Services\Template;
+
+use App\Services\Template\TemplateParser;
+use App\Services\Template\Renderers\RendererFactory;
+use App\Services\Template\TemplateRegistry;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+
+class TemplateService
+{
+    private TemplateParser $parser;
+    private bool $cacheEnabled;
+    private int $cacheTtl;
+
+    public function __construct(bool $cacheEnabled = true, int $cacheTtl = 3600)
+    {
+        $this->parser = new TemplateParser();
+        $this->cacheEnabled = $cacheEnabled;
+        $this->cacheTtl = $cacheTtl;
+    }
+
+    /**
+     * 解析并渲染内容
+     */
+    public function parseAndRender(string $content, string $format = 'json'): array
+    {
+        try {
+            // 生成缓存键
+            $cacheKey = $this->generateCacheKey($content, $format);
+
+            if ($this->cacheEnabled && Cache::has($cacheKey)) {
+                return Cache::get($cacheKey);
+            }
+
+            // 解析内容
+            $document = $this->parser->parse($content);
+
+            // 渲染内容
+            $renderer = RendererFactory::create($format);
+            $renderedContent = $renderer->render($document);
+
+            $result = [
+                'data' => $format === 'json' ? json_decode($renderedContent, true) : $renderedContent,
+                'meta' => $document->meta
+            ];
+
+            // 缓存结果
+            if ($this->cacheEnabled) {
+                Cache::put($cacheKey, $result, $this->cacheTtl);
+            }
+
+            return $result;
+        } catch (\Exception $e) {
+            Log::error('Template parsing failed', [
+                'content' => substr($content, 0, 200),
+                'format' => $format,
+                'error' => $e->getMessage()
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 仅解析,不渲染
+     */
+    public function parse(string $content): ParsedDocument
+    {
+        return $this->parser->parse($content);
+    }
+
+    /**
+     * 仅渲染已解析的文档
+     */
+    public function render(ParsedDocument $document, string $format): string
+    {
+        $renderer = RendererFactory::create($format);
+        return $renderer->render($document);
+    }
+
+    /**
+     * 注册新模板
+     */
+    public function registerTemplate(string $name, array $config): void
+    {
+        $registry = $this->parser->getRegistry();
+        $registry->registerTemplate($name, $config);
+
+        // 清除相关缓存
+        if ($this->cacheEnabled) {
+            $this->clearTemplateCache($name);
+        }
+    }
+
+    /**
+     * 获取可用模板列表
+     */
+    public function getAvailableTemplates(): array
+    {
+        $registry = $this->parser->getRegistry();
+        $templates = [];
+
+        // 这里需要添加一个方法来获取所有模板
+        // 为了示例,我们返回一些基本信息
+        $basicTemplates = ['note', 'info', 'warning'];
+
+        foreach ($basicTemplates as $templateName) {
+            $template = $registry->getTemplate($templateName);
+            if ($template) {
+                $templates[$templateName] = [
+                    'name' => $templateName,
+                    'config' => $template,
+                    'example' => $this->generateTemplateExample($templateName, $template)
+                ];
+            }
+        }
+
+        return $templates;
+    }
+
+    /**
+     * 生成模板使用示例
+     */
+    private function generateTemplateExample(string $name, array $config): string
+    {
+        $params = [];
+        $defaultParams = $config['defaultParams'] ?? [];
+
+        foreach ($defaultParams as $index => $paramName) {
+            $params[] = $paramName . '=示例值' . ($index + 1);
+        }
+
+        return '{{' . $name . '|' . implode('|', $params) . '}}';
+    }
+
+    /**
+     * 生成缓存键
+     */
+    private function generateCacheKey(string $content, string $format): string
+    {
+        return 'template_' . $format . '_' . md5($content);
+    }
+
+    /**
+     * 清除模板相关缓存
+     */
+    private function clearTemplateCache(string $templateName): void
+    {
+        // 这里可以实现更精确的缓存清除逻辑
+        Cache::flush(); // 简单起见,清除所有缓存
+    }
+
+    /**
+     * 批量处理内容
+     */
+    public function batchProcess(array $contents, string $format = 'json'): array
+    {
+        $results = [];
+
+        foreach ($contents as $index => $content) {
+            try {
+                $results[$index] = $this->parseAndRender($content, $format);
+            } catch (\Exception $e) {
+                $results[$index] = [
+                    'success' => false,
+                    'error' => $e->getMessage()
+                ];
+            }
+        }
+
+        return $results;
+    }
+
+    /**
+     * 验证模板语法
+     */
+    public function validateSyntax(string $content): array
+    {
+        $errors = [];
+
+        try {
+            $document = $this->parser->parse($content);
+
+            // 检查是否有未知模板
+            foreach ($document->content as $node) {
+                if ($node instanceof TemplateNode) {
+                    $registry = $this->parser->getRegistry();
+                    if (!$registry->hasTemplate($node->name)) {
+                        $errors[] = [
+                            'type' => 'unknown_template',
+                            'template' => $node->name,
+                            'position' => $node->position,
+                            'message' => "Unknown template: {$node->name}"
+                        ];
+                    }
+                }
+            }
+        } catch (\Exception $e) {
+            $errors[] = [
+                'type' => 'parse_error',
+                'message' => $e->getMessage()
+            ];
+        }
+
+        return $errors;
+    }
+}

+ 114 - 0
api-v8/app/Services/Template/TemplateTokenizer.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Services\Template;
+
+
+// ================== 词法分析器 ==================
+
+class TemplateTokenizer
+{
+    private string $content;
+    private int $position = 0;
+    private int $length;
+
+    public function __construct(string $content)
+    {
+        $this->content = $content;
+        $this->length = strlen($content);
+    }
+
+    public function tokenize(): array
+    {
+        $tokens = [];
+        $this->position = 0;
+
+        while ($this->position < $this->length) {
+            $token = $this->nextToken();
+            if ($token) {
+                $tokens[] = $token;
+            }
+        }
+
+        return $tokens;
+    }
+
+    private function nextToken(): ?array
+    {
+        // 寻找模板开始标记
+        $templateStart = strpos($this->content, '{{', $this->position);
+
+        if ($templateStart === false) {
+            // 没有更多模板,返回剩余文本
+            if ($this->position < $this->length) {
+                $text = substr($this->content, $this->position);
+                $this->position = $this->length;
+                return [
+                    'type' => 'text',
+                    'content' => $text,
+                    'position' => ['start' => $this->position - strlen($text), 'end' => $this->position]
+                ];
+            }
+            return null;
+        }
+
+        // 如果模板前有文本
+        if ($templateStart > $this->position) {
+            $text = substr($this->content, $this->position, $templateStart - $this->position);
+            $this->position = $templateStart;
+            return [
+                'type' => 'text',
+                'content' => $text,
+                'position' => ['start' => $this->position - strlen($text), 'end' => $this->position]
+            ];
+        }
+
+        // 解析模板
+        return $this->parseTemplate();
+    }
+
+    private function parseTemplate(): ?array
+    {
+        $start = $this->position;
+        $this->position += 2; // 跳过 '{{'
+
+        $braceCount = 1;
+        $templateContent = '';
+
+        while ($this->position < $this->length && $braceCount > 0) {
+            $char = $this->content[$this->position];
+            $nextChar = $this->position + 1 < $this->length ? $this->content[$this->position + 1] : '';
+
+            if ($char === '{' && $nextChar === '{') {
+                $braceCount++;
+                $templateContent .= '{{';
+                $this->position += 2;
+            } elseif ($char === '}' && $nextChar === '}') {
+                $braceCount--;
+                if ($braceCount > 0) {
+                    $templateContent .= '}}';
+                }
+                $this->position += 2;
+            } else {
+                $templateContent .= $char;
+                $this->position++;
+            }
+        }
+
+        if ($braceCount > 0) {
+            // 未闭合的模板,当作普通文本处理
+            $this->position = $start + 2;
+            return [
+                'type' => 'text',
+                'content' => '{{',
+                'position' => ['start' => $start, 'end' => $start + 2]
+            ];
+        }
+
+        return [
+            'type' => 'template',
+            'content' => trim($templateContent),
+            'raw' => '{{' . $templateContent . '}}',
+            'position' => ['start' => $start, 'end' => $this->position]
+        ];
+    }
+}

+ 25 - 0
api-v8/app/Services/Template/TextNode.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Services\Template;
+
+
+class TextNode extends ContentNode
+{
+    public string $content;
+
+    public function __construct(string $content, array $position = [])
+    {
+        $this->type = 'text';
+        $this->content = $content;
+        $this->position = $position;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'type' => $this->type,
+            'content' => $this->content,
+            'position' => $this->position
+        ];
+    }
+}

+ 1 - 0
api-v8/app/Services/TemplateRender.php

@@ -54,6 +54,7 @@ class TemplateRender
             'cf' => \App\Services\Templates\ConfidenceTemplate::class,
             'nissaya' => \App\Services\Templates\NissayaTemplate::class,
             'term' => \App\Services\Templates\TermTemplate::class,
+            'note' => \App\Services\Templates\NoteTemplate::class,
         ];
 
         if (!isset($templateMap[$this->templateName])) {

+ 115 - 0
api-v8/app/Services/Templates/NoteTemplate.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Services\Templates;
+
+use App\Http\Api\MdRender;
+
+class NoteTemplate extends AbstractTemplate
+{
+    public function __construct() {}
+
+    public function render(): array
+    {
+        $note = $this->getParam("text", 1);
+        $trigger = $this->getParam("trigger", 2);
+        $props = ["note" => $note];
+        $innerString = "";
+        if (!empty($trigger)) {
+            $props["trigger"] = $trigger;
+            $innerString = $props["trigger"];
+        }
+        if ($this->options['format'] === 'unity') {
+            $props["note"] = MdRender::render(
+                $props["note"],
+                $this->options['channel_id'],
+                null,
+                'read',
+                'translation',
+                'markdown',
+                'unity'
+            );
+        }
+        $output = $note;
+        switch ($this->options['format']) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => $innerString,
+                    'tag' => 'span',
+                    'tpl' => 'note',
+                ];
+                break;
+            case 'unity':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'tpl' => 'note',
+                ];
+                break;
+            case 'html':
+                if (isset($GLOBALS['note_sn'])) {
+                    $GLOBALS['note_sn']++;
+                } else {
+                    $GLOBALS['note_sn'] = 1;
+                    $GLOBALS['note'] = array();
+                }
+                $GLOBALS['note'][] = [
+                    'sn' => $GLOBALS['note_sn'],
+                    'trigger' => $trigger,
+                    'content' => MdRender::render(
+                        $props["note"],
+                        $this->options['channel_id'],
+                        null,
+                        'read',
+                        'translation',
+                        'markdown',
+                        'html'
+                    ),
+                ];
+
+                $link = "<a href='#footnote-" . $GLOBALS['note_sn'] . "' name='note-" . $GLOBALS['note_sn'] . "'>";
+                if (empty($trigger)) {
+                    $output =  $link . "<sup>[" . $GLOBALS['note_sn'] . "]</sup></a>";
+                } else {
+                    $output = $link . $trigger . "</a>";
+                }
+                break;
+            case 'text':
+                $output = $trigger;
+                break;
+            case 'tex':
+                $output = $trigger;
+                break;
+            case 'simple':
+                $output = '';
+                break;
+            case 'markdown':
+                if (isset($GLOBALS['note_sn'])) {
+                    $GLOBALS['note_sn']++;
+                } else {
+                    $GLOBALS['note_sn'] = 1;
+                    $GLOBALS['note'] = array();
+                }
+                $content = MdRender::render(
+                    $props["note"],
+                    $this->options['channel_id'],
+                    null,
+                    'read',
+                    'translation',
+                    'markdown',
+                    'markdown'
+                );
+                $output = '[^' . $GLOBALS['note_sn'] . ']';
+                $GLOBALS['note'][] = [
+                    'sn' => $GLOBALS['note_sn'],
+                    'trigger' => $trigger,
+                    'content' => $content,
+                ];
+                //$output = '<footnote id="'.$GLOBALS['note_sn'].'">'.$content.'</footnote>';
+                break;
+            default:
+                $output = '';
+                break;
+        }
+        return $output;
+    }
+}

+ 8 - 8
api-v8/app/Services/Templates/TermTemplate.php

@@ -132,14 +132,14 @@ class TermTemplate extends AbstractTemplate
         if ($channel && !empty($channel)) {
             $channelId = $channel;
         } else {
-            if (count($this->channel_id) > 0) {
-                $channelId = $this->channel_id[0];
+            if (count($this->options['channel_id']) > 0) {
+                $channelId = $this->options['channel_id'][0];
             } else {
                 $channelId = null;
             }
         }
 
-        if (count($this->channelInfo) === 0) {
+        if (count($this->options['channelInfo']) === 0) {
             if (!empty($channel)) {
                 $channelInfo = Channel::where('uid', $channel)->first();
                 if (!$channelInfo) {
@@ -155,7 +155,7 @@ class TermTemplate extends AbstractTemplate
                 return $output;
             }
         } else {
-            $channelInfo = $this->channelInfo[0];
+            $channelInfo = $this->options['channelInfo'][0];
         }
 
         if (Str::isUuid($channelId)) {
@@ -165,8 +165,8 @@ class TermTemplate extends AbstractTemplate
             } else {
                 $langFamily = 'zh';
             }
-            $this->info("term:{$word} 先查属于这个channel 的", 'term');
-            $this->info('channel id' . $channelId, 'term');
+            Log::info("term:{$word} 先查属于这个channel 的", 'term');
+            Log::info('channel id' . $channelId, 'term');
             $table = DhammaTerm::where("word", $word)
                 ->where('channal', $channelId);
             if ($tag && !empty($tag)) {
@@ -179,7 +179,7 @@ class TermTemplate extends AbstractTemplate
             $tplParam = false;
             $lang = '';
             $langFamily = '';
-            $studioId = $this->studioId;
+            $studioId = $this->options['studioId'];
         }
 
         if (!$tplParam) {
@@ -252,7 +252,7 @@ class TermTemplate extends AbstractTemplate
             $output["channel"] = $tplParam->channal;
             if (!empty($tplParam->note)) {
                 $mdRender = new MdRender(['format' => $this->options['format']]);
-                $output['note'] = $mdRender->convert($tplParam->note, $this->channel_id);
+                $output['note'] = $mdRender->convert($tplParam->note, $this->options['channel_id']);
             }
             if (isset($isCommunity)) {
                 $output["isCommunity"] = true;

+ 49 - 0
api-v8/config/template.php

@@ -0,0 +1,49 @@
+<?php
+
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Template Cache Settings
+    |--------------------------------------------------------------------------
+    */
+    'cache_enabled' => env('TEMPLATE_CACHE_ENABLED', true),
+    'cache_ttl' => env('TEMPLATE_CACHE_TTL', 3600),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Templates
+    |--------------------------------------------------------------------------
+    */
+    'default_templates' => [
+        'note' => [
+            'defaultParams' => ['text', 'type'],
+            'paramMapping' => [
+                '0' => 'text',
+                '1' => 'type'
+            ],
+            'defaultValues' => [
+                'type' => 'info'
+            ],
+            'validation' => [
+                'required' => ['text'],
+                'optional' => ['type', 'title']
+            ]
+        ],
+        // 更多模板定义...
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Renderer Settings
+    |--------------------------------------------------------------------------
+    */
+    'supported_formats' => ['json', 'html', 'markdown', 'text'],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Parser Settings
+    |--------------------------------------------------------------------------
+    */
+    'max_nesting_level' => 10,
+    'max_template_size' => 10000, // characters
+];

+ 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}

+ 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>
   );
 };
 

+ 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"
+  }
+}
+```

+ 2 - 5
open-ai-server/server.js

@@ -13,7 +13,7 @@ app.use(express.json());
 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({
@@ -61,10 +61,7 @@ app.post("/api/openai", async (req, res) => {
       // 非流式响应
       const completion = await openai.chat.completions.create(payload);
 
-      res.json({
-        success: true,
-        data: completion,
-      });
+      res.json(completion);
     }
   } catch (error) {
     console.error("API Error:", error);