Browse Source

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

visuddhinanda 1 month ago
parent
commit
8266904752
27 changed files with 467 additions and 282 deletions
  1. 32 20
      api-v13/app/Console/Commands/ExportChannel.php
  2. 43 34
      api-v13/app/Console/Commands/ExportSentence.php
  3. 6 6
      api-v13/app/Console/Commands/ExportTag.php
  4. 9 9
      api-v13/app/Console/Commands/ExportTagmap.php
  5. 41 28
      api-v13/app/Console/Commands/ExportTerm.php
  6. 69 36
      api-v13/app/Console/Commands/UpgradeAITranslation.php
  7. 71 73
      api-v13/app/Http/Controllers/Library/TipitakaController.php
  8. 16 8
      api-v13/app/Http/Controllers/SentenceController.php
  9. 1 4
      api-v13/app/Http/Resources/SentSimResource.php
  10. 1 0
      api-v13/app/Models/ProgressChapter.php
  11. 7 0
      api-v13/app/Models/Sentence.php
  12. 2 2
      api-v13/app/Services/AIAssistant/NissayaTranslateService.php
  13. 50 29
      api-v13/app/Services/AIAssistant/PaliTranslateService.php
  14. 23 20
      api-v13/documents/translation-evaluate.md
  15. 13 0
      api-v13/resources/css/modules/reader-content.css
  16. 1 0
      api-v13/resources/lang/en/library.php
  17. 1 0
      api-v13/resources/lang/my/library.php
  18. 1 0
      api-v13/resources/lang/si/library.php
  19. 1 0
      api-v13/resources/lang/th/library.php
  20. 1 0
      api-v13/resources/lang/zh-Hans/library.php
  21. 1 0
      api-v13/resources/lang/zh-Hant/library.php
  22. 17 6
      api-v13/resources/views/library/tipitaka/category.blade.php
  23. 2 1
      dashboard-v4/dashboard/src/components/article/TypePali.tsx
  24. 13 0
      dashboard-v4/dashboard/src/components/article/article.css
  25. 15 0
      dashboard-v6/src/components/article/TypePali.tsx
  26. 13 0
      dashboard-v6/src/components/channel/ChannelMy.tsx
  27. 17 6
      dashboard-v6/src/components/token/Token.tsx

+ 32 - 20
api-v13/app/Console/Commands/ExportChannel.php

@@ -1,7 +1,9 @@
 <?php
+
 /**
  * 导出离线用的channel数据
  */
+
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
@@ -42,40 +44,50 @@ class ExportChannel extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
         Log::debug('task export offline channel-table start');
-        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
-        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $exportFile = storage_path('app/public/export/offline/' . $this->argument('db') . '-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
         $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
         $dbh->beginTransaction();
 
         $query = "INSERT INTO channel ( id , name , type , language ,
                                     summary , owner_id , setting,created_at )
                                     VALUES ( ? , ? , ? , ? , ? , ? , ? , ?  )";
-        try{
+        try {
             $stmt = $dbh->prepare($query);
-        }catch(PDOException $e){
+        } catch (\PDOException $e) {
             Log::error($e->getMessage(), ['exception' => $e]);
             return 1;
         }
 
-        $bar = $this->output->createProgressBar(Channel::where('status',30)->count());
-        foreach (Channel::where('status',30)
-                ->select(['uid','name','type','lang',
-                          'summary','owner_uid','setting','created_at'])
-                          ->cursor() as $row) {
-                $currData = array(
-                            $row->uid,
-                            $row->name,
-                            $row->type,
-                            $row->lang,
-                            $row->summary,
-                            $row->owner_uid,
-                            $row->setting,
-                            $row->created_at,
-                            );
+        $bar = $this->output->createProgressBar(Channel::where('status', 30)->count());
+        foreach (
+            Channel::where('status', 30)
+                ->select([
+                    'uid',
+                    'name',
+                    'type',
+                    'lang',
+                    'summary',
+                    'owner_uid',
+                    'setting',
+                    'created_at'
+                ])
+                ->cursor() as $row
+        ) {
+            $currData = array(
+                $row->uid,
+                $row->name,
+                $row->type,
+                $row->lang,
+                $row->summary,
+                $row->owner_uid,
+                $row->setting,
+                $row->created_at,
+            );
             $stmt->execute($currData);
             $bar->advance();
         }

+ 43 - 34
api-v13/app/Console/Commands/ExportSentence.php

@@ -45,26 +45,26 @@ class ExportSentence extends Command
     public function handle()
     {
         Log::debug('task export offline sentence-table start');
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
         \App\Tools\Markdown::driver($this->option('driver'));
         $channels = [];
         $channel_id = $this->option('channel');
-        if($channel_id){
+        if ($channel_id) {
             $file_suf = $channel_id;
             $channels[] = $channel_id;
-        }else{
+        } else {
             $channel_type = $this->option('type');
             $file_suf = $channel_type;
-            if($channel_type === "original"){
+            if ($channel_type === "original") {
                 $pali_channel = ChannelApi::getSysChannel("_System_Pali_VRI_");
-                if($pali_channel === false){
+                if ($pali_channel === false) {
                     return 0;
                 }
                 $channels[] = $pali_channel;
-            }else{
-                $nissaya_channel = Channel::where('type',$channel_type)->where('status',30)->select('uid')->get();
+            } else {
+                $nissaya_channel = Channel::where('type', $channel_type)->where('status', 30)->select('uid')->get();
                 foreach ($nissaya_channel as $key => $value) {
                     # code...
                     $channels[] = $value->uid;
@@ -73,53 +73,62 @@ class ExportSentence extends Command
         }
 
 
-        $exportFile = storage_path('app/public/export/offline/wikipali-offline-'.date("Y-m-d").'.db3');
-        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $exportFile = storage_path('app/public/export/offline/wikipali-offline-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
         $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
         $dbh->beginTransaction();
 
-        if($channel_type === "original"){
+        if ($channel_type === "original") {
             $table = 'sentence';
-        }else{
+        } else {
             $table = 'sentence_translation';
         }
 
         $query = "INSERT INTO {$table} ( book , paragraph ,
                                     word_start , word_end , content , channel_id  )
                                     VALUES ( ? , ? , ? , ? , ? , ? )";
-        try{
+        try {
             $stmt = $dbh->prepare($query);
-        }catch(PDOException $e){
+        } catch (\PDOException $e) {
             Log::error($e->getMessage(), ['exception' => $e]);
             return 1;
         }
 
-        $db = Sentence::whereIn('channel_uid',$channels);
+        $db = Sentence::whereIn('channel_uid', $channels);
         $bar = $this->output->createProgressBar($db->count());
-        $srcDb = $db->select(['uid','book_id','paragraph',
-                                'word_start','word_end',
-                                'content','content_type','channel_uid',
-                                'editor_uid','language','updated_at'])->cursor();
+        $srcDb = $db->select([
+            'uid',
+            'book_id',
+            'paragraph',
+            'word_start',
+            'word_end',
+            'content',
+            'content_type',
+            'channel_uid',
+            'editor_uid',
+            'language',
+            'updated_at'
+        ])->cursor();
         foreach ($srcDb as $sent) {
-            if(Str::isUuid($sent->channel_uid)){
+            if (Str::isUuid($sent->channel_uid)) {
                 $channel = ChannelApi::getById($sent->channel_uid);
                 $currData = array(
-                        $sent->book_id,
-                        $sent->paragraph,
-                        $sent->word_start,
-                        $sent->word_end,
-                        MdRender::render($sent->content,
-                                        [$sent->channel_uid],
-                                        null,
-                                        'read',
-                                        $channel['type'],
-                                        $sent->content_type,
-                                        'unity',
-                                        ),
-                        $sent->channel_uid,
-                    );
+                    $sent->book_id,
+                    $sent->paragraph,
+                    $sent->word_start,
+                    $sent->word_end,
+                    MdRender::render(
+                        $sent->content,
+                        [$sent->channel_uid],
+                        null,
+                        'read',
+                        $channel['type'],
+                        $sent->content_type,
+                        'unity',
+                    ),
+                    $sent->channel_uid,
+                );
                 $stmt->execute($currData);
-
             }
             $bar->advance();
         }

+ 6 - 6
api-v13/app/Console/Commands/ExportTag.php

@@ -41,26 +41,26 @@ class ExportTag extends Command
     public function handle()
     {
         Log::debug('task: export offline data tag-table start');
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
-        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $exportFile = storage_path('app/public/export/offline/' . $this->argument('db') . '-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
         $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
         $dbh->beginTransaction();
 
         $query = "INSERT INTO tag ( id , name ,
                                     description , color , owner_id  )
                                     VALUES ( ? , ? , ? , ? , ?  )";
-        try{
+        try {
             $stmt = $dbh->prepare($query);
-        }catch(PDOException $e){
+        } catch (\PDOException $e) {
             Log::error($e->getMessage(), ['exception' => $e]);
             return 1;
         }
 
         $bar = $this->output->createProgressBar(Tag::count());
-        foreach (Tag::select(['id','name','description','color','owner_id'])->cursor() as $row) {
+        foreach (Tag::select(['id', 'name', 'description', 'color', 'owner_id'])->cursor() as $row) {
             $currData = array(
                 $row->id,
                 $row->name,

+ 9 - 9
api-v13/app/Console/Commands/ExportTagmap.php

@@ -41,29 +41,29 @@ class ExportTagmap extends Command
     public function handle()
     {
         Log::debug('task: export offline tagmap-table start');
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
-        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $exportFile = storage_path('app/public/export/offline/' . $this->argument('db') . '-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
         $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
         $dbh->beginTransaction();
 
         $query = "INSERT INTO tag_map ( anchor_id , tag_id )
                                     VALUES ( ? , ? )";
-        try{
+        try {
             $stmt = $dbh->prepare($query);
-        }catch(PDOException $e){
+        } catch (\PDOException $e) {
             Log::error($e->getMessage(), ['exception' => $e]);
             return 1;
         }
 
         $bar = $this->output->createProgressBar(TagMap::count());
-        foreach (TagMap::select(['id','table_name','anchor_id','tag_id'])->cursor() as $row) {
+        foreach (TagMap::select(['id', 'table_name', 'anchor_id', 'tag_id'])->cursor() as $row) {
             $currData = array(
-                            $row->anchor_id,
-                            $row->tag_id,
-                            );
+                $row->anchor_id,
+                $row->tag_id,
+            );
             $stmt->execute($currData);
             $bar->advance();
         }

+ 41 - 28
api-v13/app/Console/Commands/ExportTerm.php

@@ -42,11 +42,11 @@ class ExportTerm extends Command
     {
         Log::info('task export offline term-table start');
         $startAt = time();
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-        $exportFile = storage_path('app/public/export/offline/wikipali-offline-'.date("Y-m-d").'.db3');
-        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $exportFile = storage_path('app/public/export/offline/wikipali-offline-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
         $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
         $dbh->beginTransaction();
 
@@ -58,42 +58,55 @@ class ExportTerm extends Command
                                             ? , ? , ? , ? ,
                                             ?, ?, ?,
                                             ?, ?, ? )";
-        try{
+        try {
             $stmt = $dbh->prepare($query);
-        }catch(PDOException $e){
+        } catch (\PDOException $e) {
             Log::error($e->getMessage(), ['exception' => $e]);
             return 1;
         }
 
         $bar = $this->output->createProgressBar(DhammaTerm::count());
-        foreach (DhammaTerm::select(['guid','word','word_en','meaning',
-                          'other_meaning','note','tag','channal',
-                          'language',"owner","editor_id",
-                          "created_at","updated_at","deleted_at"
-                          ])
-                          ->cursor() as $row) {
-                $currData = array(
-                            $row->guid,
-                            $row->word,
-                            $row->word_en,
-                            $row->meaning,
-                            $row->other_meaning,
-                            $row->note,
-                            $row->tag,
-                            $row->channal,
-                            $row->language,
-                            $row->owner,
-                            $row->editor_id,
-                            $row->created_at,
-                            $row->updated_at,
-                            $row->deleted_at,
-                            );
+        foreach (
+            DhammaTerm::select([
+                'guid',
+                'word',
+                'word_en',
+                'meaning',
+                'other_meaning',
+                'note',
+                'tag',
+                'channal',
+                'language',
+                "owner",
+                "editor_id",
+                "created_at",
+                "updated_at",
+                "deleted_at"
+            ])
+                ->cursor() as $row
+        ) {
+            $currData = array(
+                $row->guid,
+                $row->word,
+                $row->word_en,
+                $row->meaning,
+                $row->other_meaning,
+                $row->note,
+                $row->tag,
+                $row->channal,
+                $row->language,
+                $row->owner,
+                $row->editor_id,
+                $row->created_at,
+                $row->updated_at,
+                $row->deleted_at,
+            );
             $stmt->execute($currData);
             $bar->advance();
         }
         $dbh->commit();
         $bar->finish();
-        $this->info(' time='.(time()-$startAt).'s');
+        $this->info(' time=' . (time() - $startAt) . 's');
         Log::info('task export offline term-table finished');
         return 0;
     }

+ 69 - 36
api-v13/app/Console/Commands/UpgradeAITranslation.php

@@ -2,6 +2,10 @@
 
 namespace App\Console\Commands;
 
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
@@ -13,9 +17,8 @@ use App\Services\AIModelService;
 use App\Services\AuthService;
 use App\Services\OpenAIService;
 use App\Services\SentenceService;
-use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Log;
+
+use function PHPUnit\Framework\isEmpty;
 
 class UpgradeAITranslation extends Command
 {
@@ -95,10 +98,13 @@ class UpgradeAITranslation extends Command
          */
         if (! $this->option('model')) {
             $this->error('model is request');
-
             return 1;
         }
         $this->model = $this->modelService->getModelById($this->option('model'));
+        if (empty($this->model)) {
+            $this->error('invalid model id ');
+            return 1;
+        }
         $this->info("model:{$this->model['model']}");
         $this->modelToken = AuthService::getUserToken($this->model['uid']);
 
@@ -106,21 +112,21 @@ class UpgradeAITranslation extends Command
         $this->workChannel = ChannelApi::getById($this->argument('channel'));
         // 需要判断输入channel 与翻译类型是否一致 nissaya -> nissaya channel
         if ($this->workChannel['type'] !== $this->argument('type')) {
-            $this->error('channel type not match request '.$this->argument('type').' input is '.$this->workChannel['type']);
+            $this->error('channel type not match request ' . $this->argument('type') . ' input is ' . $this->workChannel['type']);
 
             return 1;
         }
 
         if ($this->option('thinking')) {
             $this->thinking = $this->option('thinking') === 'true';
-            $this->line('thinking is '.$this->option('thinking'));
+            $this->line('thinking is ' . $this->option('thinking'));
         }
 
         // translation 工作流步骤校验
         $steps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('steps')))));
         $invalid = array_diff($steps, PaliTranslateService::STEPS);
         if (! empty($invalid)) {
-            $this->error('invalid steps: '.implode(',', $invalid).'. allowed: '.implode(',', PaliTranslateService::STEPS));
+            $this->error('invalid steps: ' . implode(',', $invalid) . '. allowed: ' . implode(',', PaliTranslateService::STEPS));
 
             return 1;
         }
@@ -129,7 +135,7 @@ class UpgradeAITranslation extends Command
         $nissayaSteps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('nissaya')))));
         $invalidNissaya = array_diff($nissayaSteps, PaliTranslateService::NISSAYA_STEPS);
         if (! empty($invalidNissaya)) {
-            $this->error('invalid nissaya steps: '.implode(',', $invalidNissaya).'. allowed: '.implode(',', PaliTranslateService::NISSAYA_STEPS));
+            $this->error('invalid nissaya steps: ' . implode(',', $invalidNissaya) . '. allowed: ' . implode(',', PaliTranslateService::NISSAYA_STEPS));
 
             return 1;
         }
@@ -138,7 +144,7 @@ class UpgradeAITranslation extends Command
         $channelId = $this->workChannel['id'] ?? '';
 
         // 缓存键:按 type、channel 区分不同任务的断点
-        $cacheKey = self::CACHE_KEY_PREFIX.':'.$type.':'.$channelId;
+        $cacheKey = self::CACHE_KEY_PREFIX . ':' . $type . ':' . $channelId;
 
         if ($this->option('fresh')) {
             Cache::forget($cacheKey);
@@ -158,21 +164,21 @@ class UpgradeAITranslation extends Command
             // 未指定 book 时,若已有断点缓存,从上次处理到的 book 继续,无需从 1 开始
             $startBook = 1;
             if (! empty($done)) {
-                $doneBooks = array_map(fn ($cursor) => (int) explode('|', $cursor)[0], array_keys($done));
+                $doneBooks = array_map(fn($cursor) => (int) explode('|', $cursor)[0], array_keys($done));
                 $startBook = max($doneBooks);
                 $this->info("resume from book {$startBook}");
             }
             $books = range($startBook, 217);
         }
-        foreach ($books as $key => $book) {
+        foreach ($books as $book) {
             $maxParagraph = PaliText::where('book', $book)->max('paragraph');
             $paragraphs = range(1, $maxParagraph);
             if ($this->option('para')) {
                 $paragraphs = [$this->option('para')];
             }
-            foreach ($paragraphs as $key => $paragraph) {
+            foreach ($paragraphs as  $paragraph) {
                 // 稳定游标:缓存键已含 type、channel,此处仅以 book|para 标识处理单元
-                $cursor = $book.'|'.$paragraph;
+                $cursor = $book . '|' . $paragraph;
                 if (isset($done[$cursor])) {
                     $this->info("skip {$cursor}");
 
@@ -201,11 +207,32 @@ class UpgradeAITranslation extends Command
                 }
                 $this->save($data);
                 $time = time() - $start;
-                $this->info($this->argument('type')." {$book}-{$paragraph} ".count($data).' sentences time='.$time);
+                $this->info($this->argument('type') . " {$book}-{$paragraph} " . count($data) . ' sentences time=' . $time);
                 // 该处理单元全部写库完成后再标记游标,确保中途中断不会误跳过
                 $done[$cursor] = true;
                 Cache::put($cacheKey, $done, now()->addHours(24));
             }
+
+            $param = [
+                '--book' => $book,
+                '--channel' => $this->workChannel['id'],
+            ];
+            if ($this->option('para')) {
+                $param['--para'] = $this->option('para');
+            }
+            $this->call('upgrade:progress.para', $param);
+            $this->call('upgrade:progress.chapter', $param);
+
+            $param = [
+                'book' => $book,
+                '--channel' => $this->workChannel['id'],
+                '--summary' => 'off',
+                '--granularity' => 'chapter'
+            ];
+            if ($this->option('para')) {
+                $param['--para'] = $this->option('para');
+            }
+            $this->call('opensearch:index-tipitaka', $param);
         }
 
         // 完整遍历正常结束,清空断点缓存
@@ -275,7 +302,7 @@ class UpgradeAITranslation extends Command
             $response = $llm->send("```json\n{$tplText}\n```");
             $complete = time() - $startAt;
             $content = $response['choices'][0]['message']['content'] ?? '[]';
-            Log::debug("ai response in {$complete}s content=".$content);
+            Log::debug("ai response in {$complete}s content=" . $content);
 
             $json = LlmResponseParser::jsonl($content);
 
@@ -289,7 +316,7 @@ class UpgradeAITranslation extends Command
         return $result;
     }
 
-    private function aiNissayaTranslate($book, $para)
+    private function aiNissayaTranslate(int $book, int $para)
     {
         $sentences = Sentence::nissaya()
             ->language('my') // 过滤缅文
@@ -304,6 +331,7 @@ class UpgradeAITranslation extends Command
 
                 $aiNissaya = $this->nissayaTranslateService
                     ->setModel($this->model)
+                    ->setThinking($this->thinking ?? null)
                     ->translate($sentence->content, false);
                 Log::debug('ai response ', ['content' => $aiNissaya['data']]);
                 $result[] = [
@@ -317,28 +345,33 @@ class UpgradeAITranslation extends Command
         return $result;
     }
 
-    private function save($data)
+    private function save(array $data)
     {
         // 写入句子库
-        $sentData = [];
-        $sentData = array_map(function ($n) {
-            $sId = explode('-', $n['id']);
-
-            return [
-                'book_id' => $sId[0],
-                'paragraph' => $sId[1],
-                'word_start' => $sId[2],
-                'word_end' => $sId[3],
-                'channel_uid' => $this->workChannel['id'],
-                'content' => $n['content'],
-                'content_type' => $n['content_type'] ?? 'markdown',
-                'lang' => $this->workChannel['lang'],
-                'status' => $this->workChannel['status'],
-                'editor_uid' => $this->model['uid'],
-            ];
-        }, $data);
-        foreach ($sentData as $key => $value) {
-            $this->sentenceService->saveWithHistory($value);
+        try {
+            $sentData = [];
+            $sentData = array_map(function ($n) {
+                $sId = explode('-', $n['id']);
+
+                return [
+                    'book_id' => $sId[0],
+                    'paragraph' => $sId[1],
+                    'word_start' => $sId[2],
+                    'word_end' => $sId[3],
+                    'channel_uid' => $this->workChannel['id'],
+                    'content' => $n['content'],
+                    'content_type' => $n['content_type'] ?? 'markdown',
+                    'lang' => $this->workChannel['lang'],
+                    'status' => $this->workChannel['status'],
+                    'editor_uid' => $this->model['uid'],
+                ];
+            }, $data);
+            foreach ($sentData as $key => $value) {
+                $this->sentenceService->saveWithHistory($value);
+            }
+        } catch (\Exception $e) {
+            $this->error($e->getMessage());
+            throw $e;
         }
     }
 }

+ 71 - 73
api-v13/app/Http/Controllers/Library/TipitakaController.php

@@ -2,19 +2,17 @@
 
 namespace App\Http\Controllers\Library;
 
+use App\Http\Api\ChannelApi;
 use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cookie;
-use Illuminate\Support\Facades\File;
-use Illuminate\Support\Facades\Log;
-
-
 use App\Models\PaliText;
 use App\Models\ProgressChapter;
-use App\Services\TermService;
 use App\Models\Tag;
 use App\Models\TagMap;
-
+use App\Services\TermService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cookie;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Str;
 
 class TipitakaController extends Controller
 {
@@ -30,8 +28,6 @@ class TipitakaController extends Controller
 
     /**
      * 构造函数,注入 TermService
-     *
-     * @param  \App\Services\TermService  $termService
      */
     public function __construct(
         protected TermService $termService,
@@ -44,8 +40,8 @@ class TipitakaController extends Controller
     {
         return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
     }
-    protected static int $nextId = 1;
 
+    protected static int $nextId = 1;
 
     // app/Http/Controllers/Library/CategoryController.php
     // category() 方法修改版
@@ -66,14 +62,14 @@ class TipitakaController extends Controller
         // ── 当前分类 ──────────────────────────────────────────
         if ($id) {
             $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (!$currentCategory) {
+            if (! $currentCategory) {
                 abort(404);
             }
             $breadcrumbs = $this->getBreadcrumbs($currentCategory, $categories);
         } else {
             // 首页:虚拟顶级分类
             $currentCategory = ['id' => null, 'name' => '三藏'];
-            $breadcrumbs     = [];
+            $breadcrumbs = [];
         }
 
         // ── 子分类 ─────────────────────────────────────────────
@@ -81,13 +77,13 @@ class TipitakaController extends Controller
             $categories,
             fn($cat) => $cat['parent_id'] == $id
         ));
-        if (count($subCategories) === 0 && !$request->has('book')) {
+        if (count($subCategories) === 0 && ! $request->has('book')) {
             $paliBooks = $this->getPaliBooks($categories, $id);
-            foreach ($paliBooks as  $value) {
+            foreach ($paliBooks as $value) {
                 $subCategories[] = [
                     'id' => $id,
                     'name' => $value->text,
-                    'book' => "{$value->book}-{$value->paragraph}"
+                    'book' => "{$value->book}-{$value->paragraph}",
                 ];
             }
         }
@@ -111,24 +107,29 @@ class TipitakaController extends Controller
             $subCategories[$key]['name'] = $termMap[$name] ?? $name;
         }
 
-
-
         // ── 过滤参数 ────────────────────────────────────────────
-        $selectedType   = request('type',   'all');
-        $selectedLang   = request('lang',   'all');
+        $selectedType = request('type', 'all');
+        $selectedLang = request('lang', 'all');
         $selectedAuthor = request('author', 'all');
-        $selectedSort   = request('sort',   'new');
+        $selectedSort = request('sort', 'new');
+        $selectedChannel = request('channel', 'all');
+
+        // ── 当前频道(提供 channel 参数时) ──────────────────────
+        $currentChannel = $selectedChannel !== 'all'
+            ? (ChannelApi::getById($selectedChannel) ?: null)
+            : null;
 
         $sortList = [
-            ['key' => 'new',         'label' => __('library.badge_updated'),],
-            ['key' => 'progress',    'label' => '完成度',],
+            ['key' => 'new',         'label' => __('library.badge_updated')],
+            ['key' => 'progress',    'label' => '完成度'],
         ];
 
         $selected = [
-            'type'   => $selectedType,
-            'lang'   => $selectedLang,
+            'type' => $selectedType,
+            'lang' => $selectedLang,
             'author' => $selectedAuthor,
-            'sort'   => $selectedSort,
+            'sort' => $selectedSort,
+            'channel' => $selectedChannel,
         ];
         if ($request->has('book')) {
             $selected['book'] = $request->input('book');
@@ -165,7 +166,8 @@ class TipitakaController extends Controller
             'totalCount',
             'recommended',
             'activeAuthors',
-            'sortList'
+            'sortList',
+            'currentChannel'
         ));
     }
 
@@ -200,36 +202,23 @@ class TipitakaController extends Controller
             ['id' => 5, 'title' => '长部·梵网经',    'category' => '经藏'],
         ];
     }
+
     private function mockActiveAuthors()
     {
         return [
             [
-                'name'    => 'Bhikkhu Bodhi',
-                'avatar'  => null,
-                'color'   => '#2d5a8e',
+                'name' => 'Bhikkhu Bodhi',
+                'avatar' => null,
+                'color' => '#2d5a8e',
                 'initials' => 'BB',
-                'count'   => 24,
+                'count' => 24,
             ],
             [
-                'name'    => 'Bhikkhu Sujato',
-                'avatar'  => null,
-                'color'   => '#5a2d8e',
+                'name' => 'Bhikkhu Sujato',
+                'avatar' => null,
+                'color' => '#5a2d8e',
                 'initials' => 'BS',
-                'count'   => 18,
-            ],
-            [
-                'name'    => 'Buddhaghosa',
-                'avatar'  => null,
-                'color'   => '#8e5a2d',
-                'initials' => 'BG',
-                'count'   => 12,
-            ],
-            [
-                'name'    => 'Bhikkhu Brahmali',
-                'avatar'  => null,
-                'color'   => '#2d8e5a',
-                'initials' => 'BR',
-                'count'   => 9,
+                'count' => 18,
             ],
         ];
     }
@@ -253,12 +242,10 @@ class TipitakaController extends Controller
             ['id' => '1', 'name' => 'sutta'],
             ['id' => '48', 'name' => 'vinaya'],
             ['id' => '66', 'name' => 'abhidhamma'],
-            ['id' => '82', 'name' => 'añña']
+            ['id' => '82', 'name' => 'añña'],
         ];
     }
 
-
-
     private function subCategories($categories, int $id)
     {
         return array_filter($categories, function ($cat) use ($id) {
@@ -270,7 +257,7 @@ class TipitakaController extends Controller
     {
         if ($id) {
             $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (!$currentCategory) {
+            if (! $currentCategory) {
                 abort(404);
             }
             // 标签查章节
@@ -287,18 +274,22 @@ class TipitakaController extends Controller
         foreach ($booksChapter as $key => $value) {
             $chapters[] = [$value->book, $value->paragraph];
         }
+
         return $chapters;
     }
+
     private function getPaliBooks(array $categories, string $id)
     {
         $chapters = $this->getBooksIdInCat($categories, $id);
 
         $books = PaliText::whereIns(['book', 'paragraph'], $chapters)->get();
+
         return $books;
     }
+
     private function getBooks(array $categories, ?string $id, array $filters)
     {
-        //根据分类获取书号
+        // 根据分类获取书号
         if (isset($filters['book'])) {
             $chapters = [explode('-', $filters['book'])];
         } else {
@@ -307,24 +298,29 @@ class TipitakaController extends Controller
 
         $table = ProgressChapter::with('channel.owner')
             ->whereHas('channel', function ($query) use ($filters) {
-                $query->where('status', 30);
-
                 if ($filters['type'] !== 'all') {
-                    $query->where('type', $filters['type']);
+                    $query->where('type', $filters['type'])
+                        ->where('status', 30);
                 }
 
                 if ($filters['lang'] !== 'all') {
-                    $query->where('lang', $filters['lang']);
+                    $query->where('lang', $filters['lang'])
+                        ->where('status', 30);
+                }
+
+                if ($filters['channel'] !== 'all' && Str::isUuid($filters['channel'])) {
+                    $query->where('uid', $filters['channel']);
                 }
             })
             ->whereNotNull('last_chapter_completed_at')
             ->whereIns(['book', 'para'], $chapters);
         if ($filters['sort'] === 'new') {
             $table = $table->orderBy('last_chapter_completed_at', 'desc');
-        } else if ($filters['sort'] === 'progress') {
+        } elseif ($filters['sort'] === 'progress') {
             $table = $table->orderBy('progress', 'desc');
         }
         $books = $table->take(100)->get();
+
         return $this->getBooksInfo($books);
     }
 
@@ -333,7 +329,7 @@ class TipitakaController extends Controller
         $pali = PaliText::where('level', 1)->get();
         // 获取该分类下的书籍
         $categoryBooks = [];
-        $books->each(function ($book) use (&$categoryBooks,  $pali) {
+        $books->each(function ($book) use (&$categoryBooks, $pali) {
             $title = $book->title;
             if (empty($title)) {
                 $title = $pali->firstWhere('book', $book->book)->toc;
@@ -354,23 +350,23 @@ class TipitakaController extends Controller
             $subTitle = $this->getBookType($book->book, $book->para);
 
             $categoryBooks[] = [
-                "id" => $book->uid,
-                "title" => $title,
-                "author" => $book->channel->name,
+                'id' => $book->uid,
+                'title' => $title,
+                'author' => $book->channel->name,
                 'subTitle' => $subTitle,
-                "publisher" => $book->channel->owner,
+                'publisher' => $book->channel->owner,
                 'completed_chapters' => $book->completed_chapters,
-                "type" => __('labels.' . $book->channel->type),
-                "cover" => $coverUrl,
+                'type' => __('labels.' . $book->channel->type),
+                'cover' => $coverUrl,
                 'cover_gradient' => $this->coverGradients[$colorIdx % count($this->coverGradients)],
-                "description" => $book->summary ?? "比库戒律的详细说明",
-                "language" => __('language.' . $book->channel->lang),
+                'description' => $book->summary ?? '比库戒律的详细说明',
+                'language' => __('language.' . $book->channel->lang),
             ];
         });
+
         return $categoryBooks;
     }
 
-
     private function getBookType(int $book, int $para)
     {
 
@@ -385,15 +381,17 @@ class TipitakaController extends Controller
 
         return null;
     }
+
     private function loadCategories()
     {
-        $json = file_get_contents(public_path("data/category/default.json"));
+        $json = file_get_contents(public_path('data/category/default.json'));
         $tree = json_decode($json, true);
         $flat = self::flattenWithIds($tree);
+
         return $flat;
     }
 
-    public static function flattenWithIds(array $tree,  int $parentId = 0, int $level = 1): array
+    public static function flattenWithIds(array $tree, int $parentId = 0, int $level = 1): array
     {
 
         $flat = [];
@@ -406,7 +404,7 @@ class TipitakaController extends Controller
                 'parent_id' => $parentId,
                 'name' => $node['name'] ?? null,
                 'tag' => $node['tag'] ?? [],
-                "description" => "佛教戒律经典",
+                'description' => '佛教戒律经典',
                 'level' => $level,
             ];
 
@@ -414,7 +412,7 @@ class TipitakaController extends Controller
 
             if (isset($node['children']) && is_array($node['children'])) {
                 $childrenLevel = $level + 1;
-                $flat = array_merge($flat, self::flattenWithIds($node['children'],  $currentId, $childrenLevel));
+                $flat = array_merge($flat, self::flattenWithIds($node['children'], $currentId, $childrenLevel));
             }
         }
 

+ 16 - 8
api-v13/app/Http/Controllers/SentenceController.php

@@ -85,7 +85,7 @@ class SentenceController extends Controller
                     return $this->error('没有关键词');
                 }
                 $table = Sentence::select($indexCol)
-                    ->where('content', 'like', '%'.$key.'%')
+                    ->where('content', 'like', '%' . $key . '%')
                     ->where('editor_uid', $userUid);
 
                 break;
@@ -179,8 +179,16 @@ class SentenceController extends Controller
                 $table = Sentence::where('ver', '>', 1)
                     ->where('book_id', $request->input('book'))
                     ->whereIn('paragraph', explode(',', $request->input('para')))
-                    ->whereIn('channel_uid', explode(',', $request->input('channels')))
                     ->orderBy('book_id')->orderBy('paragraph')->orderBy('word_start');
+                if ($request->has('channels')) {
+                    $table = $table->whereIn('channel_uid', explode(',', $request->input('channels')));
+                }
+                if ($request->has('channel_type')) {
+                    $table = $table->type($request->input('channel_type'));
+                }
+                if ($request->has('lang')) {
+                    $table = $table->language($request->input('lang'));
+                }
                 break;
             case 'my-edit':
                 // 我编辑的
@@ -195,7 +203,7 @@ class SentenceController extends Controller
                 break;
         }
         if (! empty($request->input('key'))) {
-            $table = $table->where('content', 'like', '%'.$request->input('key').'%');
+            $table = $table->where('content', 'like', '%' . $request->input('key') . '%');
         }
 
         $count = $table->count();
@@ -261,7 +269,7 @@ class SentenceController extends Controller
         }
     }
 
-    private function UserCanEdit($userId, $channelId, $book, $access_token = null)
+    private function UserCanEdit(string $userId, string $channelId, int  $book, $access_token = null)
     {
         $channel = Channel::where('uid', $channelId)->first();
         if (! $channel) {
@@ -276,8 +284,8 @@ class SentenceController extends Controller
                     return false;
                 }
                 $key = AccessToken::where('res_id', $channelId)->value('token');
-                $jwt = JWT::decode($access_token, new Key($key.$key, 'HS512'));
-                if ($jwt->book && $jwt->book !== $book) {
+                $jwt = JWT::decode($access_token, new Key($key . $key, 'HS512'));
+                if (isset($jwt->book) && $jwt->book !== 0 &&  $jwt->book !== $book) {
                     return false;
                 }
             }
@@ -308,7 +316,7 @@ class SentenceController extends Controller
             if ($this->UserCanEdit(
                 $user['user_uid'],
                 $request->input('channel'),
-                $request->input('book', 0),
+                (int)$request->input('book', 0),
                 $request->input('access_token', null)
             )) {
                 $destChannel = Channel::where('uid', $request->input('channel'))->first();
@@ -325,7 +333,7 @@ class SentenceController extends Controller
                 if ($this->UserCanEdit(
                     $user['user_uid'],
                     $sent['channel_uid'],
-                    $sent['book_id'],
+                    (int)$sent['book_id'],
                     isset($sent['access_token']) ? $sent['access_token'] : null
                 )) {
                     $destChannel = Channel::where('uid', $sent['channel_uid'])->first();

+ 1 - 4
api-v13/app/Http/Resources/SentSimResource.php

@@ -4,11 +4,8 @@ namespace App\Http\Resources;
 
 use Illuminate\Http\Resources\Json\JsonResource;
 use App\Models\PaliSentence;
-use App\Models\PaliText;
-use App\Http\Api\UserApi;
-use App\Http\Api\ChannelApi;
 use App\Http\Controllers\CorpusController;
-use App\Services\AuthService;
+
 
 class SentSimResource extends JsonResource
 {

+ 1 - 0
api-v13/app/Models/ProgressChapter.php

@@ -11,6 +11,7 @@ class ProgressChapter extends Model
     use HasFactory;
     protected $fillable = [
         'book',
+        'para',
         'channel_id',
         'lang',
         'all_trans',

+ 7 - 0
api-v13/app/Models/Sentence.php

@@ -48,6 +48,13 @@ class Sentence extends Model
         return $this->belongsTo(Channel::class, 'channel_uid', 'uid');
     }
 
+    public function scopeType($query, $type)
+    {
+        return $query->whereHas('channel', function ($q) use ($type) {
+            $q->where('type', $type);
+        });
+    }
+
     public function scopeNissaya($query)
     {
         return $query->whereHas('channel', function ($q) {

+ 2 - 2
api-v13/app/Services/AIAssistant/NissayaTranslateService.php

@@ -14,7 +14,7 @@ class NissayaTranslateService
 
     protected bool $romanize;
 
-    protected bool $thinking;
+    protected ?bool $thinking = null;
 
     /**
      * 翻译提示词模板
@@ -266,7 +266,7 @@ PROMPT;
             ];
 
             foreach ($batches as $index => $batch) {
-                Log::debug('NissayaTranslate: 处理批次 '.($index + 1).'/'.count($batches));
+                Log::debug('NissayaTranslate: 处理批次 ' . ($index + 1) . '/' . count($batches));
 
                 $jsonlInput = $this->arrayToJsonl($batch);
                 $response = $this->openAIService

+ 50 - 29
api-v13/app/Services/AIAssistant/PaliTranslateService.php

@@ -67,7 +67,7 @@ class PaliTranslateService
         若用户额外提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 一一对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,翻译时应优先参照 nissaya 确定原意,遇到歧义时以 nissaya 为准。
 
         翻译要求
-        1. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白。
+        1. 语言风格为现代汉语,**绝对不要**使用古汉语或者半文半白。**不要参考**阿含经和元亨寺语言风格。
         2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
         3. 经名、人名、地名等专有名词:有约定俗成的标准译名时优先使用标准译名;没有标准译名的,尽量按词义意译;意译确有困难的再使用音译。同一专有名词在全文中译名须前后一致
         4. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
@@ -150,28 +150,49 @@ class PaliTranslateService
 
         你的任务:逐句对照原文审查译文,找出其中**确实存在**的翻译问题,按严重程度分级,并把问题**就地标注在译文上**,最后输出标注后的译文。
 
-        # 问题分级(严重程度由高到低)
-        - fatal(严重错误):会让读者产生邪见或重大误解。例如主语、谓语、宾语等句子核心成分判断错误,或句意违背基本教理。
-        - error(错误,必须修改):漏译;衍译(多出原文没有的内容);词义或修饰关系译错;造成误解的表达;义理或用词与注释书不符;代词指代错误。
-        - warning(待提升):关键词有歧义却未加注释;代词指代不清;不致误解的汉语语病;标点误用;术语标记使用不当;整句逻辑表达不规范。
-        - suggestion(可提升):语言不够流畅;风格不统一;该用术语标记而未用;嵌套长句对读者不友好等仅影响阅读体验的问题。
-
-        若同一片段存在多重问题,只按其中最严重的一级标注。
+        # 问题分级与类型
+        问题分为四个**级别**(severity),每个级别下又分若干**类型**(type)。请先判断片段属于哪个级别,再从该级别下选出最贴切的类型名。**级别从高到低,标注时只取最严重的一级。**
+
+        - fatal(严重错误):会让读者对经文意思有严重误解。类型:
+            - 严重失真:主、谓(含非谓语动词)、宾中有一项判断错误,导致句子意思严重失真
+            - 教理违背:句子意思违背基本教理原则
+        - error(错误,有举必究,必须修改):类型:
+            - 漏译:原文中有的内容在译文中缺失
+            - 多译:译文中增加了原文没有的内容
+            - 词义误译:词语的意思翻译错误
+            - 修饰错误:修饰关系判断错误
+            - 误解表达:表达方式会导致读者误解
+            - 义理不符:义理与注释书不符
+            - 用词不符:用词与注释书不符
+            - 指代错误:代词指代的对象错误
+        - warning(待提升):类型:
+            - 语意不明:关键词语意不明确
+            - 缺少注释:二意场合没有添加注释
+            - 指代不明:代词指代不够明确
+            - 汉语语病:不导致误解的汉语语病
+            - 标点错误:标点符号使用错误
+            - 误用标记:不该使用术语标记时使用了术语标记
+            - 逻辑不规范:整句逻辑表达不规范
+        - suggestion(可提升,仅影响阅读体验):类型:
+            - 表达晦涩:语言表达不够流畅
+            - 代词指代:代词指代不够明确
+            - 风格统一:语言风格不统一
+            - 术语标记:该使用而没有使用术语标记
+            - 缺少注释:不常用术语需要编写注释或者百科
+            - 句式复杂:复杂的嵌套句整句语言逻辑理解困难(对读者不友好)
+
+        若同一句子存在多重问题,只按其中最严重的一级标注,并选用该级别下最贴切的类型名。
 
         # 标注方法
         只对译文中**有问题的最小片段**,用如下 span 原地包裹(不改动译文本身的文字与黑体等格式,仅在外层套标签):
 
-        <span class='evaluate-级别' style='background:颜色' title='类别·级别:问题简述|建议:修改建议'>有问题的译文片段</span>
+        <span class='evaluate evaluate-级别'  title='类型·级别:问题简述|建议:修改建议'>有问题的译文片段</span>
 
-        **span 的属性一律用单引号**(class='...' style='...' title='...'),不要用双引号——因为 content 整体是 JSON 字符串、本身由双引号包裹,属性再用双引号极易因转义出错导致整行 JSON 解析失败、整句被丢弃。title 等属性值内若要引用文字,请使用中文全角引号「」或‘’,**严禁**出现 ASCII 双引号(")或单引号(')。
+        其中 class 里的「级别」与 title 里的「级别」都必须是 fatal / error / warning / suggestion 之一;title 里的「类型」必须是上面对应级别下列出的类型名(例如 语意不明、漏译、严重失真)。**级别在前判定,类型在该级别内挑选**,不要张冠李戴(例如 语意不明 只能配 warning)
 
-        级别与背景颜色对应(越暖代表越严重):
-        - fatal      颜色 #ffcdd2
-        - error      颜色 #ffe0b2
-        - warning    颜色 #fff9c4
-        - suggestion 颜色 #c8e6c9
+        **span 的属性一律用单引号**(class='...'  title='...'),不要用双引号——因为 content 整体是 JSON 字符串、本身由双引号包裹,属性再用双引号极易因转义出错导致整行 JSON 解析失败、整句被丢弃。title 等属性值内若要引用文字,请使用中文全角引号「」或‘’,**严禁**出现 ASCII 双引号(")或单引号(')。
 
-        title 写法:先写问题类别与级别,再用一句话说清问题是什么,最后给出具体可操作的修改建议,用「|建议:」分隔。
+        title 写法:先写「类型·级别」,再用一句话说清问题是什么,最后用「|建议:」给出具体可操作的修改建议。
 
         # 必须遵守的原则
         1. 只标注你**确有把握**的真实问题;拿不准就不标。
@@ -187,7 +208,7 @@ class PaliTranslateService
         直接输出 jsonl 数据,无需解释。
 
         **输出范例**(注意 span 属性用单引号,整行是合法 JSON)
-        {"id":"1-2-3-4","content":"他于<span class='evaluate-error' style='background:#ffe0b2' title='漏译·error:原文 bhagavā 未译出|建议:补译为‘世尊’'>那时</span>住在王舍城。"}
+        {"id":"1-2-3-4","content":"他于<span class='evaluate evaluate-error'  title='漏译·error:原文 bhagavā 未译出|建议:补译为‘世尊’'>那时</span>住在王舍城。"}
         {"id":"2-3-4-5","content":"完全正确的译文原样返回。"}
         md;
 
@@ -378,7 +399,7 @@ class PaliTranslateService
      */
     public function translate(array $pali, array $nissaya = []): array
     {
-        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n".$this->nissayaSection($nissaya);
+        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n" . $this->nissayaSection($nissaya);
         Log::debug('PaliTranslate: translate', ['input' => $userText]);
 
         $content = $this->send($this->translatePrompt, $userText);
@@ -396,9 +417,9 @@ class PaliTranslateService
      */
     public function review(array $pali, array $translation, array $nissaya = []): array
     {
-        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
-            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
-            .$this->nissayaSection($nissaya);
+        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
+            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
+            . $this->nissayaSection($nissaya);
         Log::debug('PaliTranslate: review', ['input' => $userText]);
 
         $content = $this->send($this->reviewPrompt, $userText);
@@ -417,9 +438,9 @@ class PaliTranslateService
      */
     public function revise(array $pali, array $translation, array $review): array
     {
-        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
-            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
-            ."# review\n\n".$this->jsonBlock($review)."\n\n";
+        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
+            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
+            . "# review\n\n" . $this->jsonBlock($review) . "\n\n";
         Log::debug('PaliTranslate: revise', ['input' => $userText]);
 
         $content = $this->send($this->revisePrompt, $userText);
@@ -439,9 +460,9 @@ class PaliTranslateService
      */
     public function evaluate(array $pali, array $translation, array $nissaya = []): array
     {
-        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
-            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
-            .$this->nissayaSection($nissaya);
+        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
+            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
+            . $this->nissayaSection($nissaya);
         Log::debug('PaliTranslate: evaluate', ['input' => $userText]);
 
         $content = $this->send($this->evaluatePrompt, $userText);
@@ -525,7 +546,7 @@ class PaliTranslateService
             return '';
         }
 
-        return "# nissaya\n\n".$this->jsonBlock($nissaya)."\n\n";
+        return "# nissaya\n\n" . $this->jsonBlock($nissaya) . "\n\n";
     }
 
     /**
@@ -535,6 +556,6 @@ class PaliTranslateService
      */
     protected function jsonBlock(array $data): string
     {
-        return "```json\n".json_encode($data, JSON_UNESCAPED_UNICODE)."\n```";
+        return "```json\n" . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n```";
     }
 }

+ 23 - 20
api-v13/documents/translation-evaluate.md

@@ -1,31 +1,34 @@
 # 问题分级
 
 - 第一类 严重错误 fatal(零容忍;增长普通用户邪见、降低普通用户对译文的评价)
-    1. 主谓(含非谓语动词)宾有一项判断错误
-    2. 句子意思违背基本教理原则
+    1. 严重失真:主谓(含非谓语动词)宾有一项判断错误导致句子意思严重失真)
+    2. 教理违背:句子意思违背基本教理原则
 
 - 第二类 错误 error(专家有举必究;只要发现,一定要改、只有巴利专家能发现)
-    1. 漏译(例句:32**两**糖块的体积)
-    2. 错误多译
-    3. 错译(词义,修饰关系)
-    4. 导致误解的表达
-    5. 义理或用词与注释书不符
-    6. 代词指代错误
+    1. 漏译:原文中有的内容在译文中缺失
+    2. 多译:译文中增加了原文没有的内容
+    3. 词义误译:词语的意思翻译错误
+    4. 修饰错误:修饰关系判断错误
+    5. 误解表达:表达方式会导致读者误解
+    6. 义理不符:义理与注释书不符
+    7. 用词不符:用词与注释书不符
+    8. 指代错误:代词指代的对象错误
 
 - 第三类 待提升 warning
-    1. 关键词语意不明确或二意场合没有注释(稣息、转起)
-    2. 代词指代不明确
-    3. 不导致误解的汉语语病
-    4. 标点符号使用错误
-    5. 不该使用术语标记时使用了术语标记
-    6. 整句逻辑表达不规范
+    1. 语意不明:关键词语意不明确
+    2. 缺少注释:二意场合没有添加注释
+    3. 指代不明:代词指代不够明确
+    4. 汉语语病:不导致误解的汉语语病
+    5. 标点错误:标点符号使用错误
+    6. 误用标记:不该使用术语标记时使用了术语标记
+    7. 逻辑不规范:整句逻辑表达不规范
 
 - 第四类 可提升 suggestion
-    1. 语言表达不够流畅
-    2. 代词指代可能不够明确
-    3. 语言风格不统一
-    4. 该使用而没有使用术语标记
-    5. 不常用术语编写注释或者百科
-    6. 复杂的嵌套句整句语言逻辑理解困难(对读者不友好)
+    1. 表达晦涩:语言表达不够流畅
+    2. 代词指代:代词指代不够明确
+    3. 风格统一:语言风格不统一
+    4. 术语标记:该使用而没有使用术语标记
+    5. 缺少注释:不常用术语需要编写注释或者百科
+    6. 句式复杂:复杂的嵌套句整句语言逻辑理解困难(对读者不友好)
 
 第三类是出版社编辑提出的,第四类是由有佛教背景的读者提出的

+ 13 - 0
api-v13/resources/css/modules/reader-content.css

@@ -261,3 +261,16 @@ article.reader-body div.epigraph > blockquote > footer {
     text-align: right;
     font-size: 0.9rem;
 }
+
+article.reader-body.evaluate-fatal {
+    background-color: rgb(185 34 255 / 50%);
+}
+article.reader-body .evaluate-error {
+    background-color: #ff232393;
+}
+article.reader-body .evaluate-warning {
+    background-color: #e97a3086;
+}
+article.reader-body .evaluate-suggestion {
+    background-color: #c4f8c6;
+}

+ 1 - 0
api-v13/resources/lang/en/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => 'About This Anthology',
     'anthology_info' => 'Anthology Info',
     'language' => 'Language',
+    'current_channel' => 'Current Channel',
     'created' => 'Created',
     'updated' => 'Updated',
     'related_anthology' => 'Related Anthologies',

+ 1 - 0
api-v13/resources/lang/my/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => 'ဤလက်ရွေးစင်စာစုအကြောင်း',
     'anthology_info' => 'လက်ရွေးစင်စာစု သတင်းအချက်အလက်',
     'language' => 'ဘာသာစကား',
+    'current_channel' => 'လက်ရှိချန်နယ်',
     'created' => 'ဖန်တီးသည်',
     'updated' => 'အပ်ဒိတ်လုပ်သည်',
     'related_anthology' => 'ဆက်စပ်သော လက်ရွေးစင်စာစုများ',

+ 1 - 0
api-v13/resources/lang/si/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => 'මෙම සංග්‍රහය ගැන',
     'anthology_info' => 'සංග්‍රහ තොරතුරු',
     'language' => 'භාෂාව',
+    'current_channel' => 'වත්මන් නාලිකාව',
     'created' => 'නිර්මිත',
     'updated' => 'යාවත්කාලීන',
     'related_anthology' => 'සම්බන්ධිත සංග්‍රහ',

+ 1 - 0
api-v13/resources/lang/th/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => 'เกี่ยวกับรวมบทความนี้',
     'anthology_info' => 'ข้อมูลรวมบทความ',
     'language' => 'ภาษา',
+    'current_channel' => 'ช่องปัจจุบัน',
     'created' => 'วันที่สร้าง',
     'updated' => 'อัปเดต',
     'related_anthology' => 'รวมบทความที่เกี่ยวข้อง',

+ 1 - 0
api-v13/resources/lang/zh-Hans/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => '关于本文集',
     'anthology_info' => '文集信息',
     'language' => '语言',
+    'current_channel' => '当前频道',
     'created' => '创建',
     'updated' => '更新',
     'related_anthology' => '相关文集',

+ 1 - 0
api-v13/resources/lang/zh-Hant/library.php

@@ -89,6 +89,7 @@ return [
     'about_anthology' => '關於此文集',
     'anthology_info' => '文集資訊',
     'language' => '語言',
+    'current_channel' => '目前頻道',
     'created' => '建立時間',
     'updated' => '更新時間',
     'related_anthology' => '相關文集',

+ 17 - 6
api-v13/resources/views/library/tipitaka/category.blade.php

@@ -23,7 +23,7 @@
 <li class="breadcrumb-item active">{{ $breadcrumb['name'] }}</li>
 @else
 <li class="breadcrumb-item">
-    <a href="{{ route('library.tipitaka.category', ['id' => $breadcrumb['id']]) }}">
+    <a href="{{ route('library.tipitaka.category', array_filter(['id' => $breadcrumb['id'], 'channel' => request('channel')])) }}">
         {{ $breadcrumb['name'] }}
     </a>
 </li>
@@ -48,14 +48,14 @@
                 <div class="wiki-sidebar-title">{{ __('library.categories') }}</div>
                 <ul class="wiki-cat-list">
                     <li>
-                        <a href="{{ route('library.tipitaka.index') }}"
+                        <a href="{{ route('library.tipitaka.index', array_filter(['channel' => request('channel')])) }}"
                             class="{{ !$currentCategory['id'] ? 'active' : '' }}">
                             {{ __('library.all') }}
                         </a>
                     </li>
                     @foreach($types as $type)
                     <li>
-                        <a href="{{ route('library.tipitaka.category', ['id' => $type['id']]) }}"
+                        <a href="{{ route('library.tipitaka.category', array_filter(['id' => $type['id'], 'channel' => request('channel')])) }}"
                             class="{{ ($currentCategory['id'] ?? null) == $type['id'] ? 'active' : '' }}">
                             {{ $type['name'] }}
                         </a>
@@ -82,6 +82,15 @@
             {{-- 2. 过滤器区 --}}
             <div class="wiki-card tipitaka-filters">
 
+                @if($currentChannel)
+                {{-- 当前频道 --}}
+                <div class="tipitaka-filter-row">
+                    <span class="tipitaka-filter-label">{{ __('library.current_channel') }}</span>
+                    <div class="tipitaka-filter-pills">
+                        <span class="tipitaka-pill tipitaka-pill--active">{{ $currentChannel['name'] }}</span>
+                    </div>
+                </div>
+                @else
                 {{-- 类型 --}}
                 <div class="tipitaka-filter-row">
                     <span class="tipitaka-filter-label">{{ __('library.type') }}</span>
@@ -113,6 +122,7 @@
                         @endforeach
                     </div>
                 </div>
+                @endif
 
                 {{-- 作者 --}}
                 <div class="tipitaka-filter-row" style="display:none">
@@ -132,11 +142,12 @@
                 @php
                 $hasFilter = $selected['type'] !== 'all'
                 || $selected['lang'] !== 'all'
-                || $selected['author'] !== 'all';
+                || $selected['author'] !== 'all'
+                || $selected['channel'] !== 'all';
                 @endphp
                 @if($hasFilter)
                 <div class="tipitaka-filter-clear">
-                    <a href="{{ request()->fullUrlWithQuery(['type' => null, 'lang' => null, 'author' => null, 'page' => null]) }}"
+                    <a href="{{ rtrim(request()->fullUrlWithQuery(['type' => null, 'lang' => null, 'author' => null, 'channel' => null, 'page' => null]), '?') }}"
                         class="tipitaka-clear-btn">
                         <i class="ti ti-x"></i> {{ __('library.clear_filters') }}
                     </a>
@@ -150,7 +161,7 @@
             <div class="wiki-card tipitaka-subcategories">
                 <div class="tipitaka-subcategory-grid">
                     @foreach($subCategories as $sub)
-                    <a href="{{ route('library.tipitaka.category', array_filter(['id' => $sub['id'], 'book' => $sub['book'] ?? null])) }}"
+                    <a href="{{ route('library.tipitaka.category', array_filter(['id' => $sub['id'], 'book' => $sub['book'] ?? null, 'channel' => request('channel')])) }}"
                         class="tipitaka-subcategory-item">
                         <i class="ti {{ !empty($sub['book']) ? 'ti-book' : 'ti-folder' }} tipitaka-subcategory-icon" aria-hidden="true"></i>
                         <span class="tipitaka-subcategory-name">{{ $sub['name'] }}</span>

+ 2 - 1
dashboard-v4/dashboard/src/components/article/TypePali.tsx

@@ -25,6 +25,7 @@ import { TaskBuilderChapterModal } from "../task/TaskBuilderChapter";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { ArticleTplModal } from "../template/Builder/ArticleTpl";
+import "./article.css";
 
 interface IWidget {
   type?: ArticleType;
@@ -239,7 +240,7 @@ const TypePaliWidget = ({
   }
 
   return (
-    <div>
+    <div className="pcd_article">
       {loading ? (
         <ArticleSkeleton />
       ) : errorCode ? (

+ 13 - 0
dashboard-v4/dashboard/src/components/article/article.css

@@ -171,3 +171,16 @@
 .video-js video {
   max-width: 100%;
 }
+
+.pcd_article .evaluate-fatal {
+  background-color: #ffcdd2;
+}
+.pcd_article .evaluate-error {
+  background-color: #ffe0b2;
+}
+.pcd_article .evaluate-warning {
+  background-color: #fff9c4;
+}
+.pcd_article .evaluate-suggestion {
+  background-color: #c8e6c9;
+}

+ 15 - 0
dashboard-v6/src/components/article/TypePali.tsx

@@ -29,6 +29,8 @@ import TocPath from "../tipitaka/TocPath";
 import ParagraphNode from "../tipitaka/ParagraphNode";
 import "./article.css";
 
+import { useIntl } from "react-intl";
+
 export interface ISearchParams {
   key: string;
   value: string;
@@ -68,6 +70,8 @@ const TypePali = ({
   hideNav = false,
   onArticleChange,
 }: IWidget) => {
+  const intl = useIntl();
+
   const user = useAppSelector(currentUser);
   const channels = channelId?.split("_");
 
@@ -177,10 +181,21 @@ const TypePali = ({
               items: [
                 { key: "tpl", label: "获取模板" },
                 { key: "task", label: "生成任务" },
+                {
+                  key: "library",
+                  label: intl.formatMessage({ id: "buttons.open.in.library" }),
+                },
               ],
               onClick: ({ key }) => {
                 if (key === "task") setTaskBuilderModalOpen(true);
                 if (key === "tpl") setTplOpen(true);
+                if (key === "library" && channels) {
+                  window.open(
+                    import.meta.env.VITE_APP_API_SERVER +
+                      `/library/tipitaka/${id}/read?channel=${channels[0]}`,
+                    "_blank"
+                  );
+                }
               },
             }}
             placement="bottomRight"

+ 13 - 0
dashboard-v6/src/components/channel/ChannelMy.tsx

@@ -300,6 +300,12 @@ const ChannelMy = ({
                       }),
                       icon: <InfoCircleOutlined />,
                     },
+                    {
+                      key: "library",
+                      label: intl.formatMessage({
+                        id: "buttons.open.in.library",
+                      }),
+                    },
                   ],
                   onClick: (e) => {
                     const ch: IChannel = {
@@ -328,6 +334,13 @@ const ChannelMy = ({
                         });
                         setTokenOpen(true);
                         break;
+                      case "library":
+                        window.open(
+                          import.meta.env.VITE_APP_API_SERVER +
+                            `/library/tipitaka?channel=${ch.id}`,
+                          "_blank"
+                        );
+                        break;
                     }
                   },
                 }}

+ 17 - 6
dashboard-v6/src/components/token/Token.tsx

@@ -23,6 +23,7 @@ interface IWidget {
 const Token = ({ channelId, articleId, type }: IWidget) => {
   const [text, setText] = useState("");
   const [power, setPower] = useState<TPower>("readonly");
+  const [curr, setCurr] = useState(false);
   const intl = useIntl();
 
   useEffect(() => {
@@ -35,13 +36,13 @@ const Token = ({ channelId, articleId, type }: IWidget) => {
       console.error("channels or book or para is undefined", channelId, id);
       return;
     }
-    const _book = id[0];
+    const _book = parseInt(id[0] ?? "0");
     const _para = id[1];
     const payload: IPayload[] = [];
     payload.push({
       res_id: channelId,
       res_type: "channel",
-      book: parseInt(_book ?? 0),
+      book: curr ? _book : 0,
       para_start: parseInt(_para),
       para_end: parseInt(_para) + 100,
       power: power,
@@ -55,14 +56,24 @@ const Token = ({ channelId, articleId, type }: IWidget) => {
         setText(json.data.rows[0].token);
       }
     });
-  }, [articleId, channelId, power, type]);
+  }, [articleId, channelId, curr, power, type]);
   return (
     <div>
       <div style={{ textAlign: "center", padding: 20 }}>
+        <div>
+          <Segmented
+            key="power"
+            options={["readonly", "edit"]}
+            onChange={(value: SegmentedValue) => {
+              setPower(value as TPower);
+            }}
+          />
+        </div>
         <Segmented
-          options={["readonly", "edit"]}
-          onChange={(value: SegmentedValue) => {
-            setPower(value as TPower);
+          key="curr"
+          options={["current", "all"]}
+          onChange={(value) => {
+            setCurr(value === "current");
           }}
         />
       </div>