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

Merge pull request #2354 from visuddhinanda/development

Development
visuddhinanda 3 месяцев назад
Родитель
Сommit
1cf3165fea

+ 9 - 0
api-v8/app/Console/Commands/InitSystemChannel.php

@@ -92,6 +92,12 @@ class InitSystemChannel extends Command
             'type' => 'translation',
             'lang' => 'zh-Hans',
         ],
+        [
+            "name" => '_System_commentary_',
+            'type' => 'commentary',
+            'lang' => 'en',
+            'status' => 30,
+        ],
     ];
 
     /**
@@ -131,6 +137,9 @@ class InitSystemChannel extends Command
             $channel->create_time = time() * 1000;
             $channel->modify_time = time() * 1000;
             $channel->is_system = true;
+            if (isset($value['status'])) {
+                $channel->status = $value['status'];
+            }
             $channel->save();
             $this->info("created" . $value['name']);
         }

+ 258 - 0
api-v8/app/Console/Commands/UpgradeAITranslation.php

@@ -0,0 +1,258 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+use App\Services\OpenAIService;
+use App\Services\AIModelService;
+use App\Services\SentenceService;
+use App\Services\SearchPaliDataService;
+use App\Http\Controllers\AuthController;
+
+use App\Models\PaliText;
+use App\Models\PaliSentence;
+use App\Models\Sentence;
+
+use App\Helpers\LlmResponseParser;
+
+use App\Http\Api\ChannelApi;
+use App\Tools\Tools;
+
+class UpgradeAITranslation extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:ai.translation translation --book=141 --para=535
+     * @var string
+     */
+    protected $signature = 'upgrade:ai.translation {type} {--book=} {--para=} {--resume} {--model=} ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+    protected $sentenceService;
+    protected $modelService;
+    protected $openAIService;
+    protected $model;
+    protected $modelToken;
+    protected $workChannel;
+    protected $accessToken;
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        AIModelService $model,
+        SentenceService $sent,
+        OpenAIService $openAI
+    ) {
+        $this->modelService = $model;
+        $this->sentenceService = $sent;
+        $this->openAIService = $openAI;
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if ($this->option('model')) {
+            $this->model = $this->modelService->getModelById($this->option('model'));
+            $this->info("model:{$this->model['model']}");
+            $this->modelToken = AuthController::getUserToken($this->model['uid']);
+        }
+        $this->workChannel = ChannelApi::getById($this->ask('请输入结果channel'));
+
+        $books = [];
+        if ($this->option('book')) {
+            $books = [$this->option('book')];
+        } else {
+            $books = range(1, 217);
+        }
+        foreach ($books as $key => $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) {
+                $this->info($this->argument('type') . " {$book}-{$paragraph}");
+                $data = [];
+                switch ($this->argument('type')) {
+                    case 'translation':
+                        $data = $this->aiPaliTranslate($book, $paragraph);
+                        break;
+                    case 'nissaya':
+                        $data = $this->aiNissayaTranslate($book, $paragraph);
+                    default:
+                        # code...
+                        break;
+                }
+                $this->save($data);
+            }
+        }
+        return 0;
+    }
+
+    private function getPaliContent($book, $para)
+    {
+        $sentenceService = app(SearchPaliDataService::class);
+        $sentences = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_begin')
+            ->get();
+        if (!$sentences) {
+            return null;
+        }
+        $json = [];
+        foreach ($sentences as $key => $sentence) {
+            $content = $sentenceService->getSentenceText($book, $para, $sentence->word_begin, $sentence->word_end);
+            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
+            $json[] = ['id' => $id, 'content' => $content['markdown']];
+        }
+        return $json;
+    }
+
+    private function aiPaliTranslate($book, $para)
+    {
+        $prompt = <<<md
+        你是一个巴利语翻译助手。
+        pali 是巴利原文的一个段落,json格式, 每条记录是一个句子。包括id 和 content 两个字段
+        请翻译这个段落为简体中文。
+
+        翻译要求
+        1. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白。
+        2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
+        3. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
+
+        输出格式jsonl
+        输出id 和 content 两个字段,
+        id 使用巴利原文句子的id ,
+        content 为中文译文
+
+        直接输出jsonl数据,无需解释
+
+
+    **输出范例**
+    {"id":"1-2-3-4","content":"译文"}
+    {"id":"2-3-4-5","content":"译文"}
+    md;
+
+        $pali = $this->getPaliContent($book, $para);
+        $originalText = "```json\n" . json_encode($pali, JSON_UNESCAPED_UNICODE) . "\n```";
+        Log::debug($originalText);
+        if (!$this->model) {
+            Log::error('model is invalid');
+            return [];
+        }
+        $startAt = time();
+        $response = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($prompt)
+            ->setTemperature(0.0)
+            ->setStream(false)
+            ->send("# pali\n\n{$originalText}\n\n");
+        $completeAt = time();
+        $translationText = $response['choices'][0]['message']['content'] ?? '[]';
+        Log::debug($translationText);
+        $json = [];
+        if (is_string($translationText)) {
+            $json = LlmResponseParser::jsonl($translationText);
+        }
+        return $json;
+    }
+    private function aiWBW($book, $para) {}
+    private function aiNissayaTranslate($book, $para)
+    {
+        $sysPrompt = <<<md
+        你是一个佛教翻译专家,精通巴利文和缅文
+        ## 翻译要求:
+        - 请将nissaya单词表中的巴利文和缅文分别翻译为中文
+        - 输入格式为 巴利文:缅文
+        - 一行是一条记录,翻译的时候,请不要拆分一行中的巴利文单词或缅文单词,一行中出现多个单词的,一起翻译
+        - 输出csv格式内容,分隔符为"$",
+        - 字段如下:巴利文\$巴利文的中文译文\$缅文\$缅文的中文译文 #两个译文的语义相似度(%)
+
+        **范例**:
+
+        pana\$然而\$ဝါဒန္တရကား\$教义之说 #60%
+
+        直接输出csv, 无需其他内容
+        用```包裹的行为注释内容,也需要翻译和解释。放在最后面。如果没有```,无需处理
+        md;
+
+        $sentences = Sentence::nissaya()
+            ->language('my') // 过滤缅文
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->orderBy('strlen')
+            ->get();
+        $result = [];
+        foreach ($sentences as $key => $sentence) {
+            $nissaya = [];
+            $rows = explode("\n", $sentence->content);
+            foreach ($rows as $key => $row) {
+                if (strpos('=', $row) >= 0) {
+                    $factors = explode("=", $row);
+                    $nissaya[] = Tools::MyToRm($factors[0]) . ':' . end($factors);
+                } else {
+                    $nissaya[] = $row;
+                }
+            }
+            $nissayaText = json_encode(implode("\n", $nissaya), JSON_UNESCAPED_UNICODE);
+            Log::debug($nissayaText);
+            $startAt = time();
+            $response = $this->openAIService->setApiUrl($this->model['url'])
+                ->setModel($this->model['model'])
+                ->setApiKey($this->model['key'])
+                ->setSystemPrompt($sysPrompt)
+                ->setTemperature(0.7)
+                ->setStream(false)
+                ->send("# nissaya\n\n{$nissayaText}\n\n");
+            $complete = time() - $startAt;
+            $content = $response['choices'][0]['message']['content'] ?? '';
+            Log::debug("ai response in {$complete}s content=" . $content);
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            $result[] = [
+                'id' => $id,
+                'content' => $content,
+            ];
+        }
+        return $result;
+    }
+
+    private function save($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->save($value);
+        }
+    }
+}

+ 416 - 0
api-v8/app/Console/Commands/UpgradeSystemCommentary.php

@@ -0,0 +1,416 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+
+use App\Models\RelatedParagraph;
+use App\Models\BookTitle;
+use App\Models\PaliText;
+use App\Models\TagMap;
+use App\Models\Tag;
+use App\Models\PaliSentence;
+
+use App\Services\SearchPaliDataService;
+use App\Services\OpenAIService;
+use App\Services\AIModelService;
+use App\Services\SentenceService;
+
+use App\Helpers\LlmResponseParser;
+use App\Http\Api\ChannelApi;
+
+class UpgradeSystemCommentary extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:sys.commentary
+     * @var string
+     */
+    protected $signature = 'upgrade:sys.commentary {--book=} {--para=} {--list} {--model=}';
+    protected $prompt = <<<md
+    你是一个注释对照阅读助手。
+    pali 是巴利原文,jsonl格式, 每条记录是一个句子。包括id 和 content 两个字段
+    commentary 是pali的注释,jsonl 格式,每条记录是一个句子。包括id 和 content 两个字段
+    commentary里面的内容是对pali内容的注释
+    commentary里面的黑体字,说明该句子是注释pali中的对应的巴利文。
+    你需要按照顺序将commentary中的句子与pali原文对照,。
+    输出格式jsonl
+    只输出pali数据
+    在pali句子数据里面增加一个字段“commentary” 里面放这个句子对应的commentary句子的id
+    不要输出content字段,只输出id,commentary字段
+    直接输出jsonl数据,无需解释
+
+**关键规则:**
+1. 根据commentary中的句子的意思找到与pali对应的句子
+1. 如果commentary中的某个句子**有黑体字**,它应该放在pali中对应巴利词汇出现的句子之后
+2. 如果commentary中的某个句子**没有黑体字**,请将其与**上面最近的有黑体字的commentary句子**合并在一起(保持在同一个引用块内),不要单独成行
+3. 有些pali原文句子可能没有对应的注释
+4. 请不要遗漏任何commentary中的句子,也不要打乱顺序
+5. 同时保持pali的句子数量不变,不要增删
+6. 应该将全部commentary中的句子都与pali句子对应,不要有遗漏
+
+**输出范例**
+{"id":0,"commentary":[0,1]}
+{"id":1,"commentary":[2]}
+md;
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+    protected $sentenceService;
+    protected $modelService;
+    protected $openAIService;
+    protected $model;
+    protected $tokensPerSentence = 0;
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        AIModelService $model,
+        SentenceService $sent,
+        OpenAIService $openAI
+    ) {
+        $this->modelService = $model;
+        $this->sentenceService = $sent;
+        $this->openAIService = $openAI;
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if ($this->option('list')) {
+            $result = RelatedParagraph::whereNotNull('book_name')
+                ->groupBy('book_name')
+                ->selectRaw('book_name,count(*)')
+                ->get();
+            foreach ($result as $key => $value) {
+                $this->info($value['book_name'] . "[" . $value['count'] . "]");
+            }
+            return 0;
+        }
+        if ($this->option('model')) {
+            $this->model = $this->modelService->getModelById($this->option('model'));
+            $this->info("model:{$this->model['model']}");
+        }
+
+
+        $channel = ChannelApi::getChannelByName('_System_commentary_');
+
+        $books = [];
+        if ($this->option('book')) {
+            $books[] = ['book_name' => $this->option('book')];
+        } else {
+            $books = RelatedParagraph::whereNotNull('book_name')
+                ->where('cs_para', '>', 0)
+                ->groupBy('book_name')
+                ->select('book_name')
+                ->get()->toArray();
+        }
+        foreach ($books as $key => $currBook) {
+            $paragraphs = [];
+            if ($this->option('para')) {
+                $paragraphs[] = ['cs_para' => $this->option('para')];
+            } else {
+                $paragraphs = RelatedParagraph::where('book_name', $currBook['book_name'])
+                    ->where('cs_para', '>', 0)
+                    ->groupBy('cs_para')
+                    ->select('cs_para')
+                    ->get()->toArray();
+            }
+            foreach ($paragraphs as $key => $paragraph) {
+                $message = 'ai commentary ' . $currBook['book_name'] . '-' . $paragraph['cs_para'];
+                $this->info($message);
+                Log::info($message);
+                $result = RelatedParagraph::where('book_name', $currBook['book_name'])
+                    ->where('cs_para', $paragraph['cs_para'])
+                    ->where('book_id', '>', 0)
+                    ->orderBy('book_id')
+                    ->orderBy('para')
+                    ->get();
+                $pcdBooks = [];
+                $type = [];
+                foreach ($result as $rBook) {
+                    # 把段落整合成书。有几本书就有几条输出纪录
+                    if (!isset($pcdBooks[$rBook->book_id])) {
+                        $bookType = $this->getBookType($rBook->book_id);
+                        $pcdBooks[$rBook->book_id] = $bookType;
+                        if (!isset($type[$bookType])) {
+                            $type[$bookType] = [];
+                        }
+                        $type[$bookType][$rBook->book_id] = [];
+                    }
+                    $currType = $pcdBooks[$rBook->book_id];
+                    $type[$currType][$rBook->book_id][] = ['book' => $rBook->book, 'para' => $rBook->para];
+                }
+                foreach ($type as $keyType => $info) {
+                    Log::debug($keyType);
+                    foreach ($info as $bookId => $paragraphs) {
+                        Log::debug($bookId);
+                        foreach ($paragraphs as  $paragraph) {
+                            Log::debug($paragraph['book'] . '-' . $paragraph['para']);
+                        }
+                    }
+                }
+
+                //处理pali
+                if (
+                    $this->hasData($type, 'pāḷi') &&
+                    $this->hasData($type, 'aṭṭhakathā')
+                ) {
+                    $paliJson = [];
+                    foreach ($type['pāḷi'] as $keyBook => $paragraphs) {
+                        foreach ($paragraphs as  $paraData) {
+                            $sentData = $this->getParaContent($paraData['book'], $paraData['para']);
+                            $paliJson = array_merge($paliJson, $sentData);
+                        }
+                    }
+
+                    $attaJson = [];
+                    foreach ($type['aṭṭhakathā'] as $keyBook => $paragraphs) {
+                        foreach ($paragraphs as  $paraData) {
+                            $sentData = $this->getParaContent($paraData['book'], $paraData['para']);
+                            $attaJson = array_merge($attaJson, $sentData);
+                        }
+                    }
+
+                    //llm 对齐
+                    $result = $this->textAlign($paliJson, $attaJson);
+                    //写入db
+                    $this->save($result, $channel);
+                }
+
+                //处理义注
+                if (
+                    $this->hasData($type, 'aṭṭhakathā') &&
+                    $this->hasData($type, 'ṭīkā')
+                ) {
+                    $tikaResult = array();
+                    foreach ($type['ṭīkā'] as $keyBook => $paragraphs) {
+                        $tikaJson = [];
+                        foreach ($paragraphs as $key => $paraData) {
+                            $sentData = $this->getParaContent($paraData['book'], $paraData['para']);
+                            $tikaJson = array_merge($tikaJson, $sentData);
+                        }
+
+                        //llm 对齐
+                        $result = $this->textAlign($attaJson, $tikaJson);
+                        //将新旧数据合并 如果原来没有,就添加,有,就合并数据
+                        foreach ($result as $new) {
+                            $found = false;
+                            foreach ($tikaResult as $key => $old) {
+                                if ($old['id'] === $new['id']) {
+                                    $found = true;
+                                    if (isset($new['commentary']) && is_array($new['commentary'])) {
+                                        $tikaResult[$key]['commentary'] = array_merge($tikaResult[$key]['commentary'], $new['commentary']);
+                                    }
+                                    break;
+                                }
+                            }
+                            if (!$found) {
+                                array_push($tikaResult, $new);
+                            }
+                        }
+                    }
+                    //写入db
+                    $this->save($tikaResult, $channel);
+                }
+            }
+        }
+
+        return 0;
+    }
+    private function hasData($typeData, $typeName)
+    {
+        if (
+            !isset($typeData[$typeName]) ||
+            $this->getParagraphNumber($typeData[$typeName]) === 0
+        ) {
+            Log::warning($typeName . ' data is missing');
+            return false;
+        }
+        return true;
+    }
+    private function getParagraphNumber($type)
+    {
+        if (!isset($type) || !is_array($type)) {
+            return 0;
+        }
+        $count = 0;
+        foreach ($type as $bookId => $paragraphs) {
+            $count += count($paragraphs);
+        }
+        return $count;
+    }
+    private function getBookType($bookId)
+    {
+        $bookTitle = BookTitle::where('sn', $bookId)->first();
+        $paliTextUuid = PaliText::where('book', $bookTitle->book)->where('paragraph', $bookTitle->paragraph)->value('uid');
+        $tagIds = TagMap::where('anchor_id', $paliTextUuid)->select('tag_id')->get();
+        $tags = Tag::whereIn('id', $tagIds)->select('name')->get();
+        foreach ($tags as $key => $tag) {
+            if (in_array($tag->name, ['pāḷi', 'aṭṭhakathā', 'ṭīkā'])) {
+                return $tag->name;
+            }
+        }
+        return null;
+    }
+
+    private function getParaContent($book, $para)
+    {
+        $sentenceService = app(SearchPaliDataService::class);
+        $sentences = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_begin')
+            ->get();
+        if (!$sentences) {
+            return null;
+        }
+        $json = [];
+        foreach ($sentences as $key => $sentence) {
+            $content = $sentenceService->getSentenceText($book, $para, $sentence->word_begin, $sentence->word_end);
+            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
+            $json[] = ['id' => $id, 'content' => $content['markdown']];
+        }
+        return $json;
+    }
+
+    private function arrayIndexed(array $input): array
+    {
+        $output  = [];
+        foreach ($input as $key => $value) {
+            $value['id'] = $key;
+            $output[] = $value;
+        }
+        return $output;
+    }
+    private function arrayUnIndexed(array $input, array $original, array $commentary): array
+    {
+        $output  = [];
+        foreach ($input as $key => $value) {
+            $value['id'] = $original[$key]['id'];
+            if (isset($value['commentary'])) {
+                $newCommentary = array_map(function ($n) use ($commentary) {
+                    if (isset($commentary[$n])) {
+                        return $commentary[$n]['id'];
+                    }
+                    return '';
+                }, $value['commentary']);
+                $value['commentary'] = $newCommentary;
+            }
+            $output[] = $value;
+        }
+        return $output;
+    }
+    private function textAlign(array $original, array $commentary)
+    {
+        if (!$this->model) {
+            Log::error('model is invalid');
+            return [];
+        }
+        $originalSn  = $this->arrayIndexed($original);
+        $commentarySn  = $this->arrayIndexed($commentary);
+
+        $originalText = "```jsonl\n" . LlmResponseParser::jsonl_encode($originalSn) . "\n```";
+        $commentaryText = "```jsonl\n" . LlmResponseParser::jsonl_encode($commentarySn) . "\n```";
+
+        Log::debug('ai request', [
+            'original' => $originalText,
+            'commentary' => $commentaryText
+        ]);
+
+        $totalSentences = count($original) + count($commentary);
+        $maxTokens = (int)($this->tokensPerSentence * $totalSentences * 1.5);
+        $this->info("requesting…… {$totalSentences} sentences {$this->tokensPerSentence}tokens/sentence set {$maxTokens} max_tokens");
+        Log::debug('requesting…… ' . $this->model['model']);
+        $startAt = time();
+        $response = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($this->prompt)
+            ->setTemperature(0.0)
+            ->setStream(false)
+            ->setMaxToken($maxTokens)
+            ->send("# pali\n\n{$originalText}\n\n# commentary\n\n{$commentaryText}");
+        $completeAt = time();
+        $answer = $response['choices'][0]['message']['content'] ?? '[]';
+        Log::debug('ai response', ['data' => $answer]);
+        $message = ($completeAt - $startAt) . 's';
+
+        if (isset($response['usage']['completion_tokens'])) {
+            Log::debug('usage', $response['usage']);
+            $message .= " completion_tokens:" . $response['usage']['completion_tokens'];
+            $curr = (int)($response['usage']['completion_tokens'] / $totalSentences);
+            if ($curr > $this->tokensPerSentence) {
+                $this->tokensPerSentence = $curr;
+            }
+        }
+        $this->info($message);
+        $json = [];
+        if (is_string($answer)) {
+            $json = LlmResponseParser::jsonl($answer);
+            $json = $this->arrayUnIndexed($json, $original, $commentary);
+            Log::debug(json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+        }
+        if (count($json) === 0) {
+            Log::error("jsonl is empty");
+        }
+
+        return $json;
+    }
+
+
+
+    private function save($json, $channel)
+    {
+        if (!is_array($json)) {
+            Log::warning('llm return null');
+            return false;
+        }
+        foreach ($json as $key => $sentence) {
+            if (!isset($sentence['commentary'])) {
+                continue;
+            }
+            $sentId = explode('-', $sentence['id']);
+            $arrCommentary = $sentence['commentary'];
+            if (
+                isset($arrCommentary) &&
+                is_array($arrCommentary) &&
+                count($arrCommentary) > 0
+            ) {
+                $content =  array_map(function ($n) {
+                    if (is_string($n)) {
+                        return '{{' . $n . '}}';
+                    } else if (is_array($n) && isset($n['id']) && is_string($n['id'])) {
+                        return '{{' . $n['id'] . '}}';
+                    } else {
+                        return '';
+                    }
+                }, $arrCommentary);
+                $this->sentenceService->save(
+                    [
+                        'book_id' => $sentId[0],
+                        'paragraph' => $sentId[1],
+                        'word_start' => $sentId[2],
+                        'word_end' => $sentId[3],
+                        'channel_uid' => $channel->uid,
+                        'content' => implode("\n", $content),
+                        'lang' => $channel->lang,
+                        'status' => $channel->status,
+                        'editor_uid' => $this->model['uid'],
+                    ]
+                );
+                $this->info($sentence['id'] . ' saved');
+            }
+        }
+    }
+}

+ 130 - 0
api-v8/app/Helpers/LlmResponseParser.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Helpers;
+
+use Illuminate\Support\Facades\Log;
+
+/**
+ * Class LlmResponseParser
+ * @package App\Helpers
+ */
+class LlmResponseParser
+{
+    /**
+     * 解析LLM返回的可能包含Markdown格式(如```json...```)或额外文字说明的JSON字符串。
+     *
+     * @param string $input LLM返回的原始字符串。
+     * @return array|null 解析成功的PHP数组,如果解析失败则返回空数组
+     */
+    public static function json(string $input): array
+    {
+        // 1. 预处理:查找并提取被 Markdown 代码块包裹的JSON字符串。
+        // 匹配 ```json ... ``` 或 ``` ... ``` 格式的代码块。
+        // S: dotall 模式,允许 . 匹配换行符。
+        // ?: 非贪婪模式,匹配尽可能少的字符直到遇到下一个 ```。
+        $pattern = '/```(?:json)?\s*(.*?)\s*```/s';
+
+        if (preg_match($pattern, $input, $matches)) {
+            // 情况 2 和 3:找到代码块,提取其内容。
+            // $matches[1] 包含代码块内部的内容。
+            $jsonString = trim($matches[1]);
+        } else {
+            // 情况 1:没有代码块包裹,认为整个输入就是纯 JSON 字符串。
+            // 这种情况也包含了用户可能提供的、未被代码块包裹但带有文字说明的JSON。
+            // 这种情况下,我们需要在后续尝试解析时,先尝试 trim() 整个输入。
+            $jsonString = trim($input);
+        }
+
+        // 2. 尝试解析提取或预处理后的字符串。
+        // json_decode 的第二个参数设为 true,将其解码为关联数组。
+        $data = json_decode($jsonString, true);
+
+        // 3. 检查解析结果。
+        // json_last_error() 返回 JSON 解析的最后一个错误代码。
+        if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
+            // 解析成功且结果为数组。
+            return $data;
+        }
+
+        // 4. 如果第一次尝试失败 (通常是针对情况 1 或包含文字说明的情况),
+        // 并且提取的字符串与原始输入相同(即没有匹配到代码块),
+        // 并且原始输入中包含可能干扰解析的文字,
+        // 则可以考虑更复杂的清理步骤,但考虑到 LLM 返回的 JSON 通常比较规范,
+        // 推荐的做法是如果第一次没有成功,就返回 null,以保持函数的健壮性。
+        // 纯粹的 JSON 字符串(情况 1)在第一次尝试时应该已经成功。
+
+        // 如果解析失败,则返回 空数据
+        Log::error('解析失败' . $input);
+        return [];
+    }
+
+    /**
+     * 解析LLM返回的JSONL(JSON Lines)格式字符串。
+     * 支持Markdown代码块包裹、额外说明文字,以及处理截断的JSONL数据。
+     * 每一行应为独立的JSON对象,解析时会跳过无效行并返回所有成功解析的数据。
+     *
+     * @param string $input LLM返回的原始JSONL字符串。
+     * @return array 解析成功的PHP数组,每个元素对应一行有效的JSON对象。如果完全解析失败则返回空数组。
+     */
+    public static function jsonl(string $input): array
+    {
+        // 1. 预处理:查找并提取被 Markdown 代码块包裹的 JSONL 字符串。
+        // 匹配 ```jsonl ... ``` 或 ``` ... ``` 格式的代码块。
+        $pattern = '/```(?:jsonl?)?\s*(.*?)\s*```/s';
+
+        if (preg_match($pattern, $input, $matches)) {
+            // 情况 2 和 3:找到代码块,提取其内容。
+            $jsonlString = trim($matches[1]);
+        } else {
+            // 情况 1:没有代码块包裹,认为整个输入就是纯 JSONL 字符串。
+            $jsonlString = trim($input);
+        }
+
+        // 2. 按行分割 JSONL 字符串。
+        // 使用 PHP_EOL 或 \n 作为分隔符,同时处理 Windows (\r\n) 和 Unix (\n) 换行符。
+        $lines = preg_split('/\r\n|\r|\n/', $jsonlString);
+
+        // 3. 逐行解析 JSON 对象。
+        $result = [];
+        foreach ($lines as $lineNumber => $line) {
+            // 去除每行的首尾空白字符。
+            $line = trim($line);
+
+            // 跳过空行。
+            if (empty($line)) {
+                continue;
+            }
+
+            // 尝试解析当前行为 JSON。
+            $decoded = json_decode($line, true);
+
+            // 检查解析是否成功。
+            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+                // 解析成功,添加到结果数组。
+                $result[] = $decoded;
+            } else {
+                // 解析失败,记录错误日志(可选)。
+                // 这里处理了截断的情况:如果某行不是有效 JSON,则跳过它。
+                // 通常最后一行可能因截断而无效,前面的有效行仍会被返回。
+                Log::warning("JSONL解析失败 - 行 " . ($lineNumber + 1) . ": " . $line);
+            }
+        }
+
+        // 4. 返回解析结果。
+        // 即使没有成功解析任何行,也返回空数组而非 null,保持返回类型一致。
+        if (empty($result)) {
+            Log::error('JSONL解析失败,未能提取任何有效数据: ' . $input);
+        }
+
+        return $result;
+    }
+
+    public static function jsonl_encode(array $input): string
+    {
+        $rows = [];
+        foreach ($input as $key => $value) {
+            $rows[] = json_encode($value, JSON_UNESCAPED_UNICODE);
+        }
+        return implode("\n", $rows);
+    }
+}

+ 11 - 0
api-v8/app/Http/Api/ChannelApi.php

@@ -21,6 +21,7 @@ class ChannelApi
                 'type' => $channel['type'],
                 'lang' => $channel['lang'],
                 'studio_id' => $channel['owner_uid'],
+                'status' => $channel['status'],
             ];
         } else {
             return false;
@@ -124,6 +125,16 @@ class ChannelApi
         }
     }
 
+    public static function getChannelByName($name)
+    {
+        $channel = Channel::where('name', $name)
+            ->first();
+        if (!$channel) {
+            throw new \Exception('channel is invalid');
+        }
+        return $channel;
+    }
+
     /**
      * 获取某个studio 的某个语言的自定义书的channel
      * 如果没有,建立

+ 7 - 2
api-v8/app/Http/Controllers/CategoryController.php

@@ -93,7 +93,12 @@ class CategoryController extends Controller
             $chaptersParam[] = [$chapter->book, $chapter->paragraph];
         }
         // 获取该分类下的章节
-        $books = ProgressChapter::with('channel.owner')->whereIns(['book', 'para'], $chaptersParam)
+        $books = ProgressChapter::with('channel.owner')
+            ->leftJoin('pali_texts', function ($join) {
+                $join->on('progress_chapters.book', '=', 'pali_texts.book')
+                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+            })
+            ->whereIns(['progress_chapters.book', 'progress_chapters.para'], $chaptersParam)
             ->whereHas('channel', function ($query) {
                 $query->where('status', 30);
             })
@@ -115,7 +120,7 @@ class CategoryController extends Controller
                 "publisher" => $book->channel->owner,
                 "type" => __('labels.' . $book->channel->type),
                 "category_id" => $id,
-                "cover" => "/assets/images/cover/1/214.jpg",
+                "cover" => "/assets/images/cover/zh-hans/1/{$book->pcd_book_id}.png",
                 "description" => $book->summary ?? "比库戒律的详细说明",
                 "language" => __('language.' . $book->channel->lang),
             ];

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

@@ -329,7 +329,7 @@ class SentenceController extends Controller
                     $sent['book_id'],
                     isset($sent['access_token']) ? $sent['access_token'] : null
                 )) {
-                    $destChannel = Channel::where('uid', $sent['channel_uid'])->first();;
+                    $destChannel = Channel::where('uid', $sent['channel_uid'])->first();
                 } else {
                     continue;
                 }

+ 40 - 5
api-v8/app/Models/Sentence.php

@@ -10,13 +10,27 @@ class Sentence extends Model
 {
     use HasFactory;
     use SoftDeletes;
-	protected $fillable = ['id','uid','book_id',
-                          'paragraph','word_start','word_end',
-                          'channel_uid','editor_uid','content','content_type',
-                          'strlen','status','create_time','modify_time','language'];
+    protected $fillable = [
+        'id',
+        'uid',
+        'book_id',
+        'paragraph',
+        'word_start',
+        'word_end',
+        'channel_uid',
+        'editor_uid',
+        'content',
+        'content_type',
+        'strlen',
+        'status',
+        'create_time',
+        'modify_time',
+        'language'
+    ];
     protected $primaryKey = 'uid';
     protected $casts = [
-        'uid' => 'string'
+        'uid' => 'string',
+        'channel_uid' => 'string',
     ];
 
     protected $dates = [
@@ -26,4 +40,25 @@ class Sentence extends Model
         'fork_at'
     ];
 
+    /**
+     * 获取句子所属的频道
+     */
+    public function channel()
+    {
+        return $this->belongsTo(Channel::class, 'channel_uid', 'uid');
+    }
+
+    public function scopeNissaya($query)
+    {
+        return $query->whereHas('channel', function ($q) {
+            $q->where('type', 'nissaya');
+        });
+    }
+
+    public function scopeLanguage($query, $language)
+    {
+        return $query->whereHas('channel', function ($q) use ($language) {
+            $q->where('lang', $language);
+        });
+    }
 }

+ 6 - 0
api-v8/app/Services/AIModelService.php

@@ -15,6 +15,12 @@ class AIModelService
         $result = $table->get();
         return AiModelResource::collection(resource: $result);
     }
+    public function getModelById($id)
+    {
+        $result = AiModel::where('uid', $id)
+            ->first();
+        return new AiModelResource(resource: $result);
+    }
 
     public function getSysModels($type = null)
     {

+ 0 - 183
api-v8/app/Services/AiChatService.php

@@ -1,183 +0,0 @@
-<?php
-
-namespace App\Services;
-
-use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
-use Illuminate\Http\Client\ConnectionException;
-
-class ChatGPTService
-{
-    protected int $retries = 3;
-    protected int $delayMs = 2000;
-    protected string $model = 'gpt-4-1106-preview';
-    protected string $apiUrl = 'https://api.openai.com/v1/chat/completions';
-    protected string $apiKey;
-    protected string $systemPrompt = '你是一个有帮助的助手。';
-    protected float $temperature = 0.7;
-
-    public static function withRetry(int $retries = 3, int $delayMs = 2000): static
-    {
-        return (new static())->setRetry($retries, $delayMs);
-    }
-
-    public function setRetry(int $retries, int $delayMs): static
-    {
-        $this->retries = $retries;
-        $this->delayMs = $delayMs;
-        return $this;
-    }
-
-    public function setModel(string $model): static
-    {
-        $this->model = $model;
-        return $this;
-    }
-
-    public function setApiUrl(string $url): static
-    {
-        $this->apiUrl = $url;
-        return $this;
-    }
-
-    public function setApiKey(string $key): static
-    {
-        $this->apiKey = $key;
-        return $this;
-    }
-
-    public function setSystemPrompt(string $prompt): static
-    {
-        $this->systemPrompt = $prompt;
-        return $this;
-    }
-
-    public function setTemperature(float $temperature): static
-    {
-        $this->temperature = $temperature;
-        return $this;
-    }
-
-    public function ask(string $question): string|array
-    {
-        for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
-            try {
-                $response = Http::withToken($this->apiKey)
-                    ->timeout(300)
-                    ->retry(3, 2000, function ($exception, $request) {
-                        // 仅当是连接/响应超时才重试
-                        return $exception instanceof ConnectionException;
-                    })
-                    ->post($this->apiUrl, [
-                        'model' => $this->model,
-                        'messages' => [
-                            ['role' => 'system', 'content' => $this->systemPrompt],
-                            ['role' => 'user', 'content' => $question],
-                        ],
-                        'temperature' => $this->temperature,
-                    ]);
-
-                $status = $response->status();
-                $body = $response->json();
-
-                // ✅ 判断 429 限流重试
-                if ($status === 429) {
-                    $retryAfter = $response->header('Retry-After') ?? 10;
-                    Log::warning("第 {$attempt} 次请求被限流(429),等待 {$retryAfter} 秒后重试...");
-                    sleep((int) $retryAfter);
-                    continue;
-                }
-
-                // ✅ 判断是否 GPT 返回 timeout 错误
-                $isTimeout = in_array($status, [408, 504]) ||
-                    (isset($body['error']['message']) && Str::contains(strtolower($body['error']['message']), 'time'));
-
-                if ($isTimeout) {
-                    Log::warning("第 {$attempt} 次 GPT 响应超时,准备重试...");
-                    usleep($this->delayMs * 1000);
-                    continue;
-                }
-
-                if ($response->successful()) {
-                    return $body['choices'][0]['message']['content'] ?? '无内容返回';
-                }
-
-                return [
-                    'error' => $body['error']['message'] ?? '请求失败',
-                    'status' => $status
-                ];
-            } catch (ConnectionException $e) {
-                Log::warning("第 {$attempt} 次连接超时:{$e->getMessage()},准备重试...");
-                usleep($this->delayMs * 1000);
-                continue;
-            } catch (\Exception $e) {
-                Log::error("GPT 请求异常:" . $e->getMessage());
-                return [
-                    'error' => $e->getMessage(),
-                    'status' => 500
-                ];
-            }
-        }
-
-        return [
-            'error' => '请求多次失败或超时,请稍后再试。',
-            'status' => 504
-        ];
-    }
-}
-
-/**
- namespace App\Http\Controllers;
-
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Http\Client\ConnectionException;
-use Illuminate\Http\Client\RequestException;
-
-class ChatGPTController extends Controller
-{
-    public function ask(Request $request)
-    {
-        $question = $request->input('question', 'Hello, who are you?');
-
-        try {
-            $response = Http::withToken(env('OPENAI_API_KEY'))
-                ->timeout(10) // 请求超时时间(秒)
-                ->retry(3, 2000, function ($exception, $request) {
-                    // 仅当是连接/响应超时才重试
-                    return $exception instanceof ConnectionException;
-                })
-                ->post('https://api.openai.com/v1/chat/completions', [
-                    'model' => 'gpt-4-1106-preview',
-                    'messages' => [
-                        ['role' => 'system', 'content' => '你是一个有帮助的助手。'],
-                        ['role' => 'user', 'content' => $question],
-                    ],
-                    'temperature' => 0.7,
-                ]);
-
-            $data = $response->json();
-
-            return response()->json([
-                'reply' => $data['choices'][0]['message']['content'] ?? '没有返回内容。',
-            ]);
-
-        } catch (ConnectionException $e) {
-            // 所有重试都失败
-            Log::error('请求超时:' . $e->getMessage());
-            return response()->json(['error' => '请求超时,请稍后再试。'], 504);
-        } catch (RequestException $e) {
-            // 非超时类的请求异常(如 400/500)
-            Log::error('请求失败:' . $e->getMessage());
-            return response()->json(['error' => '请求失败:' . $e->getMessage()], 500);
-        } catch (\Exception $e) {
-            // 其他异常
-            Log::error('未知错误:' . $e->getMessage());
-            return response()->json(['error' => '发生未知错误。'], 500);
-        }
-    }
-}
-
- */

+ 238 - 0
api-v8/app/Services/OpenAIService.php

@@ -0,0 +1,238 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\Client\ConnectionException;
+
+class OpenAIService
+{
+    protected int $retries = 3;
+    protected int $delayMs = 2000;
+    protected string $model = 'gpt-4-1106-preview';
+    protected string $apiUrl = 'https://api.openai.com/v1/chat/completions';
+    protected string $apiKey;
+    protected string $systemPrompt = '你是一个有帮助的助手。';
+    protected float $temperature = 0.7;
+    protected bool $stream = false;
+    protected int $timeout = 600;
+    protected int $maxTokens = 0;
+
+    public static function withRetry(int $retries = 3, int $delayMs = 2000): static
+    {
+        return (new static())->setRetry($retries, $delayMs);
+    }
+
+    public function setRetry(int $retries, int $delayMs): static
+    {
+        $this->retries = $retries;
+        $this->delayMs = $delayMs;
+        return $this;
+    }
+
+    public function setModel(string $model): static
+    {
+        $this->model = $model;
+        return $this;
+    }
+
+    public function setApiUrl(string $url): static
+    {
+        $this->apiUrl = $url;
+        return $this;
+    }
+
+    public function setApiKey(string $key): static
+    {
+        $this->apiKey = $key;
+        return $this;
+    }
+
+    public function setSystemPrompt(string $prompt): static
+    {
+        $this->systemPrompt = $prompt;
+        return $this;
+    }
+
+    public function setTemperature(float $temperature): static
+    {
+        $this->temperature = $temperature;
+        return $this;
+    }
+
+    public function setStream(bool $stream): static
+    {
+        $this->stream = $stream;
+
+        // 流式时需要无限超时
+        if ($stream) {
+            $this->timeout = 0;
+        }
+
+        return $this;
+    }
+    public function setMaxToken(int $maxTokens): static
+    {
+        $this->maxTokens = $maxTokens;
+        return $this;
+    }
+    /**
+     * 发送 GPT 请求(支持流式与非流式)
+     */
+    public function send(string $question): string|array
+    {
+        for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
+
+            try {
+                if ($this->stream === false) {
+                    // ⬇ 非流式原逻辑
+                    return $this->sendNormal($question);
+                }
+
+                // ⬇ 流式逻辑
+                return $this->sendStreaming($question);
+            } catch (ConnectionException $e) {
+                Log::warning("第 {$attempt} 次连接超时:{$e->getMessage()},准备重试...");
+                usleep($this->delayMs * 1000 * pow(2, $attempt));
+                continue;
+            } catch (\Exception $e) {
+                Log::error("GPT 请求异常:" . $e->getMessage());
+                return [
+                    'error' => $e->getMessage(),
+                    'status' => 500
+                ];
+            }
+        }
+
+        return [
+            'error' => '请求多次失败或超时,请稍后再试。',
+            'status' => 504
+        ];
+    }
+
+    /**
+     * 普通非流式请求
+     */
+    protected function sendNormal(string $question)
+    {
+        $data = [
+            'model' => $this->model,
+            'messages' => [
+                ['role' => 'system', 'content' => $this->systemPrompt],
+                ['role' => 'user', 'content' => $question],
+            ],
+            'temperature' => $this->temperature,
+            'stream' => false,
+        ];
+        if ($this->maxTokens > 0) {
+            $data['max_tokens'] = $this->maxTokens;
+        }
+        $response = Http::withToken($this->apiKey)
+            ->timeout($this->timeout)
+            ->post($this->apiUrl, $data);
+
+        $status = $response->status();
+        $body = $response->json();
+
+        if ($status === 429) {
+            $retryAfter = $response->header('Retry-After') ?? 20;
+            Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...");
+            sleep((int)$retryAfter);
+            return $this->sendNormal($question);
+        }
+
+        if ($response->successful()) {
+            return $body;
+        }
+        Log::error('llm request error', [
+            'error' => $body['error']['message'] ?? '请求失败',
+            'status' => $status
+        ]);
+        return [
+            'error' => $body['error']['message'] ?? '请求失败',
+            'status' => $status
+        ];
+    }
+
+    /**
+     * =====================================================
+     *  ⭐ 原生 CURL SSE + 完整拼接
+     *  ⭐ 不输出浏览器
+     *  ⭐ 无 IDE 报错(使用 \CurlHandle)
+     * =====================================================
+     */
+    protected function sendStreaming(string $question): string|array
+    {
+        $payload = [
+            'model' => $this->model,
+            'messages' => [
+                ['role' => 'system', 'content' => $this->systemPrompt],
+                ['role' => 'user', 'content' => $question],
+            ],
+            'temperature' => $this->temperature,
+            'stream' => true,
+        ];
+
+        /** @var \CurlHandle $ch */
+        $ch = curl_init($this->apiUrl);
+
+        $fullContent = '';
+
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_HTTPHEADER => [
+                "Authorization: Bearer {$this->apiKey}",
+                "Content-Type: application/json",
+                "Accept: text/event-stream",
+            ],
+            CURLOPT_POSTFIELDS => json_encode($payload),
+            CURLOPT_RETURNTRANSFER => false,
+            CURLOPT_TIMEOUT => 0,      // SSE 必须无限
+            CURLOPT_HEADER => false,
+            CURLOPT_FOLLOWLOCATION => true,
+
+            CURLOPT_WRITEFUNCTION =>
+            function (\CurlHandle $curl, string $data) use (&$fullContent): int {
+
+                $lines = explode("\n", $data);
+
+                foreach ($lines as $line) {
+                    $line = trim($line);
+
+                    if (!str_starts_with($line, 'data: '))
+                        continue;
+
+                    $json = substr($line, 6);
+
+                    if ($json === '[DONE]')
+                        continue;
+
+                    $obj = json_decode($json, true);
+                    if (!is_array($obj)) continue;
+
+                    $delta = $obj['choices'][0]['delta']['content'] ?? '';
+                    if ($delta !== '') {
+                        $fullContent .= $delta;
+                    }
+                }
+
+                return strlen($data);
+            },
+        ]);
+
+        curl_exec($ch);
+
+        if ($error = curl_error($ch)) {
+            curl_close($ch);
+            return [
+                'error' => $error,
+                'status' => 500
+            ];
+        }
+
+        curl_close($ch);
+
+        return $fullContent;
+    }
+}

+ 2 - 2
api-v8/app/Services/SearchPaliDataService.php

@@ -173,7 +173,7 @@ class SearchPaliDataService
      * @param int $para
      * @return array $sentence
      */
-    private function getSentenceText($book, $para, $start, $end)
+    public function getSentenceText($book, $para, $start, $end)
     {
         $words = WbwTemplate::where('book', $book)
             ->where('paragraph', $para)
@@ -200,7 +200,7 @@ class SearchPaliDataService
                 $markdown .= $word->word . ' ';
             }
         }
-        $markdown = str_replace([' ti', ' ,', ' .', ' ?'], ['ti', ',', '.', '?'], $markdown);
+        $markdown = str_replace([' ti', ' ,', ' .', ' ?', '‘ ‘ ', ' ’ ’'], ['ti', ',', '.', '?', '‘‘', '’’'], $markdown);
         $markdown = str_replace(['~~  ~~', '** **'], [' ', ' '], $markdown);
         $text = implode(' ', $arrText);
         $text = str_replace([' ti', ' ,', ' .', ' ?'], ['ti', ',', '.', '?'], $text);

+ 47 - 0
api-v8/app/Services/SentenceService.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Sentence;
+use App\Models\SentHistory;
+use Illuminate\Support\Str;
+
+class SentenceService
+{
+    public function save($data)
+    {
+        $row = Sentence::firstOrNew([
+            "book_id" => $data['book_id'],
+            "paragraph" => $data['paragraph'],
+            "word_start" => $data['word_start'],
+            "word_end" => $data['word_end'],
+            "channel_uid" => $data['channel_uid'],
+        ], [
+            "id" => app('snowflake')->id(),
+            "uid" => Str::uuid(),
+        ]);
+        $row->content = $data['content'];
+        if (isset($data['content_type']) && !empty($data['content_type'])) {
+            $row->content_type = $data['content_type'];
+        }
+        $row->strlen = mb_strlen($data['content'], "UTF-8");
+        $row->language = $data['lang'];
+        $row->status = $data['status'];
+        if (isset($data['copy'])) {
+            //复制句子,保留原作者信息
+            $row->editor_uid = $data["editor_uid"];
+            $row->acceptor_uid = $data["acceptor_uid"];
+            $row->pr_edit_at = $data["updated_at"];
+            if (isset($data['fork_from'])) {
+                $row->fork_at = now();
+            }
+        } else {
+            $row->editor_uid = $data["editor_uid"];
+            $row->acceptor_uid = null;
+            $row->pr_edit_at = null;
+        }
+        $row->create_time = time() * 1000;
+        $row->modify_time = time() * 1000;
+        $row->save();
+    }
+}

+ 40 - 0
api-v8/database/migrations/2025_12_17_165147_change_channel_uid_type_in_sentences.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+class ChangeChannelUidTypeInSentences extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('sentences', function (Blueprint $table) {
+            DB::statement("
+            ALTER TABLE sentences
+            ALTER COLUMN channel_uid TYPE uuid
+            USING CASE
+                WHEN channel_uid = '' THEN NULL
+                ELSE channel_uid::uuid
+            END
+        ");
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('sentences', function (Blueprint $table) {
+            DB::statement('ALTER TABLE sentences ALTER COLUMN channel_uid TYPE varchar(255) USING channel_uid::varchar');
+        });
+    }
+}

BIN
api-v8/public/assets/images/cover/zh-hans/1/156.png


BIN
api-v8/public/assets/images/cover/zh-hans/1/276.png


BIN
api-v8/public/assets/images/cover/zh-hans/1/277.png


BIN
api-v8/public/assets/images/cover/zh-hans/1/279.png


+ 1 - 1
dashboard-v4/dashboard/src/components/channel/ChannelMy.tsx

@@ -225,7 +225,7 @@ const ChannelMy = ({
       .then((res) => {
         console.debug("progress data api response", res);
         const items: IItem[] = res.data.rows
-          .filter((value) => value.name.substring(0, 4) !== "_Sys")
+          .filter((value) => value.name.substring(0, 4) !== "_sys")
           .map((item, id) => {
             const date = new Date(item.created_at);
             let all: number = 0;

+ 6 - 3
dashboard-v4/dashboard/src/components/template/style.css

@@ -38,7 +38,7 @@
 }
 .sent_tabs {
   padding: 0 8px;
-  background-color: rgba(128, 128, 128, 0.1);
+  background-color: rgba(92, 164, 247, 0.1);
 }
 
 .sent_tabs.compact {
@@ -65,10 +65,11 @@
   background: #c6c5c5 !important;
 }
 .sent_tabs .ant-tabs-tab-active {
-  background: rgba(128, 128, 128, 0.1) !important;
+  background: rgba(92, 164, 247, 0.1) !important;
 }
 
 /** 2 级 组件 */
+/*
 .sent-edit-inner .sent-edit-inner .sent_tabs {
   background-color: rgba(128, 128, 128, 0.9);
 }
@@ -79,8 +80,9 @@
 .sent-edit-inner .sent-edit-inner .sent_tabs .ant-tabs-tab-active {
   background: rgba(128, 128, 128, 0.1) !important;
 }
-
+*/
 /** 3 级 组件 */
+/*
 .sent-edit-inner .sent-edit-inner .sent-edit-inner .sent_tabs {
   background-color: rgb(200, 200, 200);
 }
@@ -95,6 +97,7 @@
   .ant-tabs-tab-active {
   background: rgba(128, 128, 128, 0.9) !important;
 }
+  */
 .pcd_sent_commentary {
   border: 2px dotted darkred;
   border-radius: 8px;