2
0
Эх сурвалжийг харах

Merge pull request #2247 from visuddhinanda/development

添加 ai 助手
visuddhinanda 1 жил өмнө
parent
commit
b236e0b27c
48 өөрчлөгдсөн 2450 нэмэгдсэн , 844 устгасан
  1. 168 0
      api-v8/app/Console/Commands/AiTranslate.php
  2. 13 15
      api-v8/app/Console/Commands/CacheDictPreference.php
  3. 98 0
      api-v8/app/Console/Commands/MqAiTranslate.php
  4. 47 0
      api-v8/app/Console/Commands/TestAiTask.php
  5. 75 70
      api-v8/app/Console/Commands/UpgradeCommunityTerm.php
  6. 130 0
      api-v8/app/Http/Api/AiTaskPrepare.php
  7. 23 36
      api-v8/app/Http/Api/AuthApi.php
  8. 234 217
      api-v8/app/Http/Api/MdRender.php
  9. 48 0
      api-v8/app/Http/Api/TemplateRender.php
  10. 50 54
      api-v8/app/Http/Api/UserApi.php
  11. 158 0
      api-v8/app/Http/Controllers/AiModelController.php
  12. 1 1
      api-v8/app/Http/Controllers/AiTranslateController.php
  13. 48 33
      api-v8/app/Http/Controllers/AuthController.php
  14. 177 180
      api-v8/app/Http/Controllers/DiscussionController.php
  15. 240 200
      api-v8/app/Http/Controllers/SentenceController.php
  16. 0 2
      api-v8/app/Http/Controllers/TaskController.php
  17. 30 0
      api-v8/app/Http/Requests/StoreAiModelRequest.php
  18. 30 0
      api-v8/app/Http/Requests/UpdateAiModelRequest.php
  19. 19 0
      api-v8/app/Http/Resources/AiModelResource.php
  20. 15 0
      api-v8/app/Models/AiModel.php
  21. 12 4
      api-v8/config/mint.php
  22. 14 15
      api-v8/database/migrations/2024_10_24_124140_create_tasks_table.php
  23. 1 0
      api-v8/database/migrations/2024_10_25_015946_create_projects_table.php
  24. 40 0
      api-v8/database/migrations/2025_01_27_152548_create_ai_models_table.php
  25. 2 0
      api-v8/routes/api.php
  26. 10 0
      dashboard-v4/dashboard/src/Router.tsx
  27. 69 0
      dashboard-v4/dashboard/src/components/ai/AiModelCreate.tsx
  28. 110 0
      dashboard-v4/dashboard/src/components/ai/AiModelEdit.tsx
  29. 279 0
      dashboard-v4/dashboard/src/components/ai/AiModelList.tsx
  30. 41 0
      dashboard-v4/dashboard/src/components/api/ai.ts
  31. 8 1
      dashboard-v4/dashboard/src/components/api/task.ts
  32. 43 0
      dashboard-v4/dashboard/src/components/article/TypePali.tsx
  33. 20 5
      dashboard-v4/dashboard/src/components/studio/LeftSider.tsx
  34. 41 0
      dashboard-v4/dashboard/src/components/studio/Publicity.tsx
  35. 18 0
      dashboard-v4/dashboard/src/components/studio/PublicityIcon.tsx
  36. 15 3
      dashboard-v4/dashboard/src/components/studio/PublicitySelect.tsx
  37. 1 6
      dashboard-v4/dashboard/src/components/task/Category.tsx
  38. 7 1
      dashboard-v4/dashboard/src/components/task/TaskReader.tsx
  39. 51 0
      dashboard-v4/dashboard/src/components/template/Ai.tsx
  40. 3 0
      dashboard-v4/dashboard/src/components/template/MdTpl.tsx
  41. 2 0
      dashboard-v4/dashboard/src/locales/en-US/forms.ts
  42. 2 0
      dashboard-v4/dashboard/src/locales/en-US/label.ts
  43. 2 0
      dashboard-v4/dashboard/src/locales/zh-Hans/forms.ts
  44. 2 0
      dashboard-v4/dashboard/src/locales/zh-Hans/label.ts
  45. 22 0
      dashboard-v4/dashboard/src/pages/studio/ai/index.tsx
  46. 22 0
      dashboard-v4/dashboard/src/pages/studio/ai/model_edit.tsx
  47. 8 0
      dashboard-v4/dashboard/src/pages/studio/ai/models.tsx
  48. 1 1
      dashboard-v4/dashboard/src/pages/studio/task/index.tsx

+ 168 - 0
api-v8/app/Console/Commands/AiTranslate.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Models\PaliText;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+class AiTranslate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan ai:sentence.translate --type=chapter --api=deepseek --model=deepseek-chat --sid=107-2357
+     * php artisan ai:sentence.translate --type=sentence --api=kimi --model=moonshot-v1-8k --sid=107-2357-9-47
+     * @var string
+     */
+    protected $signature = <<<command
+    ai:sentence.translate 
+    {--type=sentence  : sentence|paragraph|chapter} 
+    {--api=  : ai engin url} 
+    {--model=  : ai model } 
+    {--sid=  : 句子编号 } 
+    {--nissaya=  : nissaya channel } 
+    {--result=  : result channel } 
+    command;
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '使用LLM 和nissaya数据翻译句子';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        //句子号列表
+        $sentences = array();
+        $totalLen = 0;
+        switch ($this->option('type')) {
+            case 'sentence':
+                $sentences[] = explode('-', $this->option('sid'));
+                break;
+            case 'paragraph':
+                $para = explode('-', $this->option('sid'));
+                $sent = PaliSentence::where('book', $para[0])
+                    ->where('paragraph', $para[1])->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [$para[0], $para[1], $value->word_begin, $value->word_end];
+                }
+                break;
+            case 'chapter':
+                $para = explode('-', $this->option('sid'));
+                $chapterLen = PaliText::where('book', $para[0])
+                    ->where('paragraph', $para[1])->value('chapter_len');
+                $sent = PaliSentence::where('book', $para[0])
+                    ->whereBetween('paragraph', [$para[1], $para[1] + $chapterLen - 1])
+                    ->orderBy('paragraph')
+                    ->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [$para[0], $para[1], $value->word_begin, $value->word_end];
+                }
+                break;
+            default:
+                return 1;
+                break;
+        }
+        //获取句子总长度
+
+        foreach ($sentences as $key => $sentence) {
+            $totalLen += $this->sentLen($sentence);
+        }
+        //
+        foreach ($sentences as $key => $sentence) {
+            # 获取巴利句子
+            $pali = PaliSentence::where('book', $sentence[0])
+                ->where('paragraph', $sentence[1])
+                ->where('word_begin', $sentence[2])
+                ->where('word_end', $sentence[3])
+                ->value('text');
+            //获取nissaya
+            $nissaya = Sentence::where('channel_uid', $this->option('nissaya'))
+                ->where('book_id', $sentence[0])
+                ->where('paragraph', $sentence[1])
+                ->where('word_start', $sentence[2])
+                ->where('word_end', $sentence[3])
+                ->value('content');
+            //获取ai结果
+            $api = $this->getEngin($this->option('api'));
+            if (!$api) {
+                $this->error('ai translate no api');
+                return 1;
+            }
+            $json = $this->fetch($api, $this->option('model'), $pali, $nissaya);
+            Log::info('ai translate', ['json' => $json]);
+            $this->info($json['choices'][0]['message']['content']);
+            //写入
+        }
+        return 0;
+    }
+
+    private function sentLen($id)
+    {
+        return PaliSentence::where('book', $id[0])
+            ->where('paragraph', $id[1])
+            ->where('word_begin', $id[2])
+            ->where('word_end', $id[3])
+            ->value('length');
+    }
+    private function getEngin($engin)
+    {
+        $api = config('mint.ai.accounts');
+        $selected = array_filter($api, function ($value) use ($engin) {
+            return $value['name'] === $engin;
+        });
+        if (!is_array($selected) || count($selected) === 0) {
+            return null;
+        }
+        return $selected[0];
+    }
+
+    private function fetch($api, $model, $origin,  $nissaya = null)
+    {
+        $prompt = '翻译上面的巴利文为中文';
+        if ($nissaya) {
+            $prompt = '根据下面的解释,' . $prompt;
+        }
+        $message = "{$origin}\n\n{$prompt}\n\n{$nissaya}";
+
+        $url = $api['api_url'];
+        $param = [
+            "model" => $model,
+            "messages" => [
+                ["role" => "system", "content" => "你是翻译人工智能助手.bhikkhu 为专有名词,不可翻译成其他语言。"],
+                ["role" => "user", "content" => $message],
+            ],
+            "temperature" => 0.3,
+            "stream" => false
+        ];
+        $response = Http::withToken($api['token'])
+            ->post($url, $param);
+        if ($response->failed()) {
+            $this->error('http request error' . $response->json('message'));
+            Log::error('http request error', ['data' => $response->json()]);
+            return null;
+        } else {
+            return $response->json();
+        }
+    }
+}

+ 13 - 15
api-v8/app/Console/Commands/CacheDictPreference.php

@@ -3,8 +3,6 @@
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Cache;
 use App\Models\UserDict;
 use Illuminate\Support\Facades\DB;
 use App\Tools\RedisClusters;
@@ -42,32 +40,32 @@ class CacheDictPreference extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
         $prefix = 'dict-preference';
-        $words = UserDict::select(['word','language'])
-                       ->groupBy(['word','language'])
-                       ->cursor();
+        $words = UserDict::select(['word', 'language'])
+            ->groupBy(['word', 'language'])
+            ->cursor();
         $wordCount = DB::select('SELECT count(*) from (
                      SELECT word,language from user_dicts group by word,language) T');
         $bar = $this->output->createProgressBar($wordCount[0]->count);
         $count = 0;
         foreach ($words as $key => $word) {
-            $meaning = UserDict::where('word',$word->word)
-                ->where('language',$word->language)
-                ->where('source','_PAPER_RICH_')
+            $meaning = UserDict::where('word', $word->word)
+                ->where('language', $word->language)
+                ->where('source', '_PAPER_RICH_')
                 ->whereNotNull('mean')
                 ->value('mean');
-            $meaning = trim($meaning," $");
-            if(!empty($meaning)){
-                $m = explode('$',$meaning);
-                RedisClusters::put("{$prefix}/{$word->word}/{$word->language}",$m[0]);
+            $meaning = trim($meaning, " $");
+            if (!empty($meaning)) {
+                $m = explode('$', $meaning);
+                RedisClusters::put("{$prefix}/{$word->word}/{$word->language}", $m[0]);
             }
             $bar->advance();
             $count++;
-            if($count%1000 === 0){
-                if(\App\Tools\Tools::isStop()){
+            if ($count % 1000 === 0) {
+                if (\App\Tools\Tools::isStop()) {
                     return 0;
                 }
             }

+ 98 - 0
api-v8/app/Console/Commands/MqAiTranslate.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+use App\Http\Api\UserApi;
+use App\Http\Controllers\AuthController;
+
+
+class MqAiTranslate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:ai.translate
+     * @var string
+     */
+    protected $signature = 'mq:ai.translate';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $exchange = 'router';
+        $queue = 'ai_translate';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:progress start.");
+        Mq::worker($exchange, $queue, function ($message) {
+
+            $param = [
+                "model" => $message->model->model,
+                "messages" => [
+                    ["role" => "system", "content" => "你是翻译人工智能助手.bhikkhu 为专有名词,不可翻译成其他语言。"],
+                    ["role" => "user", "content" => $message->content],
+                ],
+                "temperature" => 0.3,
+                "stream" => false
+            ];
+            $response = Http::withToken($message->model->token)
+                ->retry(2, 1000)
+                ->post($message->model->url, $param);
+            if ($response->failed()) {
+                $this->error('http response error' . $response->json('message'));
+                Log::error('http response error', ['data' => $response->json()]);
+                return 1;
+            }
+            $aiData = $response->json();
+            Log::debug('http response', ['data' => $response->json()]);
+
+            //获取ai帐号的用户token
+            $user = UserApi::getByName(config('mint.ai.assistant'));
+            $token = AuthController::getUserToken($user['id']);
+            Log::debug('ai assistant token', [
+                'user' => $user,
+                'token' => $token
+            ]);
+
+            //写入句子库
+            $url = '/v2/sentence';
+            $sentData = [];
+            $sentData[] = $message->sentence;
+            $response = Http::withToken($token)->post($url, [
+                'sentences' => $sentData,
+            ]);
+            Log::debug('sentence update http response', ['data' => $response->json()]);
+            //写入task log
+            //修改task 完成度
+
+            return 0;
+        });
+        return 0;
+    }
+}

+ 47 - 0
api-v8/app/Console/Commands/TestAiTask.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\AiTaskPrepare;
+
+class TestAiTask extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:ai.task c77af42f-ffb5-48ae-af71-4c32e1c30dab
+     * php artisan test:ai.task 81bd0b28-c7ea-4fc5-902d-0b188ba79d35
+     * @var string
+     */
+    protected $signature = 'test:ai.task {id}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'test ai task';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $taskId = $this->argument('id');
+        $params = AiTaskPrepare::translate($taskId);
+        var_dump($params);
+        return 0;
+    }
+}

+ 75 - 70
api-v8/app/Console/Commands/UpgradeCommunityTerm.php

@@ -44,27 +44,27 @@ class UpgradeCommunityTerm extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
         $lang = strtolower($this->argument('lang'));
-        $langFamily = explode('-',$lang)[0];
+        $langFamily = explode('-', $lang)[0];
         $localTerm = ChannelApi::getSysChannel("_community_term_{$lang}_");
-        if(!$localTerm){
+        if (!$localTerm) {
             return 1;
         }
 
         $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
-        if($channelId === false){
+        if ($channelId === false) {
             $this->error('no channel');
             return 1;
         }
-        $table = DhammaTerm::select(['word','tag'])
-                            ->whereIn('language',[$this->argument('lang'),$lang,$langFamily])
-                            ->groupBy(['word','tag']);
+        $table = DhammaTerm::select(['word', 'tag'])
+            ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+            ->groupBy(['word', 'tag']);
 
-        if($this->argument('word')){
-            $table = $table->where('word',$this->argument('word'));
+        if ($this->argument('word')) {
+            $table = $table->where('word', $this->argument('word'));
         }
         $words = $table->get();
         $bar = $this->output->createProgressBar(count($words));
@@ -74,49 +74,50 @@ class UpgradeCommunityTerm extends Command
              * 1. 找到最常见的意思
              * 2. 找到分数最高的
              */
-            $bestNote = "" ;
-            $allTerm = DhammaTerm::where('word',$word->word)
-                                ->where('tag',$word->tag)
-                                ->whereIn('language',[$this->argument('lang'),$lang,$langFamily])
-                                ->get();
+            $bestNote = "";
+            $allTerm = DhammaTerm::where('word', $word->word)
+                ->where('tag', $word->tag)
+                ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+                ->get();
             $score = [];
+            //$term_exp = [];
             foreach ($allTerm as $key => $term) {
-                //经验值
-                $exp = UserOperationDaily::where('user_id',$term->editor_id)
-                                        ->where('date_int','<=',date_timestamp_get(date_create($term->updated_at))*1000)
-                                        ->sum('duration');
-                $iExp = (int)($exp/1000);
-                $noteStrLen = $term->note? mb_strlen($term->note,'UTF-8'):0;
+                //获取经验值
+                $exp = UserOperationDaily::where('user_id', $term->editor_id)
+                    ->where('date_int', '<=', date_timestamp_get(date_create($term->updated_at)) * 1000)
+                    ->sum('duration');
+                $iExp = (int)($exp / 1000);
+                $noteStrLen = $term->note ? mb_strlen($term->note, 'UTF-8') : 0;
                 $paliStrLen = 0;
                 $tranStrLen = 0;
                 $noteWithoutPali = "";
-                if($term->note && !empty(trim($term->note))){
+                if ($term->note && !empty(trim($term->note))) {
                     //计算note得分
                     //查找句子模版
                     $pattern = "/\{\{[0-9].+?\}\}/";
                     //获取去掉句子模版的剩余部分
-                    $noteWithoutPali = preg_replace($pattern,"",$term->note);
+                    $noteWithoutPali = preg_replace($pattern, "", $term->note);
                     $sentences = [];
-                    $iSent = preg_match_all($pattern,$term->note,$sentences);
-                    if($iSent>0){
+                    $iSent = preg_match_all($pattern, $term->note, $sentences);
+                    if ($iSent > 0) {
                         foreach ($sentences[0] as  $sentence) {
-                            $sentId = explode("-",trim($sentence,"{}"));
-                            if(count($sentId) === 4){
-                                $hasTran = Sentence::where('book_id',$sentId[0])
-                                                    ->where('paragraph',$sentId[1])
-                                                    ->where('word_start',$sentId[2])
-                                                    ->where('word_end',$sentId[3])
-                                                    ->exists();
+                            $sentId = explode("-", trim($sentence, "{}"));
+                            if (count($sentId) === 4) {
+                                $hasTran = Sentence::where('book_id', $sentId[0])
+                                    ->where('paragraph', $sentId[1])
+                                    ->where('word_start', $sentId[2])
+                                    ->where('word_end', $sentId[3])
+                                    ->exists();
 
-                                $sentLen = Sentence::where('book_id',$sentId[0])
-                                                    ->where('paragraph',$sentId[1])
-                                                    ->where('word_start',$sentId[2])
-                                                    ->where('word_end',$sentId[3])
-                                                    ->where("channel_uid", $channelId)
-                                                    ->value('strlen');
-                                if($sentLen){
+                                $sentLen = Sentence::where('book_id', $sentId[0])
+                                    ->where('paragraph', $sentId[1])
+                                    ->where('word_start', $sentId[2])
+                                    ->where('word_end', $sentId[3])
+                                    ->where("channel_uid", $channelId)
+                                    ->value('strlen');
+                                if ($sentLen) {
                                     $paliStrLen += $sentLen;
-                                    if($hasTran){
+                                    if ($hasTran) {
                                         $tranStrLen += $sentLen;
                                     }
                                 }
@@ -124,45 +125,49 @@ class UpgradeCommunityTerm extends Command
                         }
                     }
                 }
-                //计算该术语总得分
-                $score["{$key}"] = $iExp*$noteStrLen;
+                //计算该术语note的总得分
+                $score["{$key}"] = $iExp * $noteStrLen;
+                //$term_exp["{$key}"] = $iExp;
+                //$updated_time["{$key}"] = $term->updated_at 先提取,具体如何使用待定
+
             }
 
+            //需要过滤掉system_term的数量,把count(*)替换为经验值加合作为基础得分,(基础经验得分之和转化为标准活动周,再加上最近的更新时间,为最终得分)
             $hotMeaning = DhammaTerm::selectRaw('meaning,count(*) as co')
-                        ->where('word',$word->word)
-                        ->whereIn('language',[$this->argument('lang'),$lang,$langFamily])
-                        ->groupBy('meaning')
-                        ->orderBy('co','desc')
-                        ->first();
-            if($hotMeaning){
+                ->where('word', $word->word)
+                ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+                ->groupBy('meaning')
+                ->orderBy('co', 'desc')
+                ->first();
+            if ($hotMeaning) {
                 $bestNote = "";
-                if(count($score)>0){
+                if (count($score) > 0) {
                     arsort($score);
                     $bestNote = $allTerm[(int)key($score)]->note;
                 }
 
-                $term = DhammaTerm::where('channal',$localTerm)->firstOrNew(
-                        [
-                            "word" => $word->word,
-                            "tag" => $word->tag,
-                            "channal" => $localTerm,
-                        ],
-                        [
-                            'id' =>app('snowflake')->id(),
-                            'guid' =>Str::uuid(),
-                            'word_en' =>Tools::getWordEn($word->word),
-                            'meaning' => '',
-                            'language' => $this->argument('lang'),
-                            'owner' => config("mint.admin.root_uuid"),
-                            'editor_id' => 0,
-                            'create_time' => time()*1000,
-                        ]
-                    );
-                    $term->tag = $word->tag;
-                    $term->meaning = $hotMeaning->meaning;
-                    $term->note = $bestNote;
-                    $term->modify_time = time()*1000;
-                    $term->save();
+                $term = DhammaTerm::where('channal', $localTerm)->firstOrNew(
+                    [
+                        "word" => $word->word,
+                        "tag" => $word->tag,
+                        "channal" => $localTerm,
+                    ],
+                    [
+                        'id' => app('snowflake')->id(),
+                        'guid' => Str::uuid(),
+                        'word_en' => Tools::getWordEn($word->word),
+                        'meaning' => '',
+                        'language' => $this->argument('lang'),
+                        'owner' => config("mint.admin.root_uuid"),
+                        'editor_id' => 0,
+                        'create_time' => time() * 1000,
+                    ]
+                );
+                $term->tag = $word->tag;
+                $term->meaning = $hotMeaning->meaning;
+                $term->note = $bestNote;
+                $term->modify_time = time() * 1000;
+                $term->save();
             }
             $bar->advance();
         }

+ 130 - 0
api-v8/app/Http/Api/AiTaskPrepare.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Http\Api;
+
+use App\Models\Task;
+use App\Models\PaliText;
+use App\Models\PaliSentence;
+use App\Models\AiModel;
+use App\Http\Api\Mq;
+
+use Illuminate\Support\Facades\Log;
+
+class AiTaskPrepare
+{
+    public static function translate(string $taskId)
+    {
+        $task = Task::findOrFail($taskId);
+        $description = $task->description;
+        $rows = explode("\n", $description);
+        $params = [];
+        foreach ($rows as $key => $row) {
+            if (strpos($row, '=') !== false) {
+                $param = explode('=', trim($row, '|'));
+                $params[$param[0]] = $param[1];
+            }
+        }
+        if (!isset($params['type']) || !isset($params['book']) || !isset($params['para'])) {
+            return false;
+        }
+
+        //get sentences in article
+        $sentences = array();
+        $totalLen = 0;
+        switch ($params['type']) {
+            case 'sentence':
+                $sentences[] = explode('-', $params['id']);
+                break;
+            case 'paragraph':
+                $sent = PaliSentence::where('book', $params['book'])
+                    ->where('paragraph', $params['para'])->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [
+                        $value->book,
+                        $value->paragraph,
+                        $value->word_begin,
+                        $value->word_end,
+                        $value->length
+                    ];
+                    $totalLen += $value->length;
+                }
+                break;
+            case 'chapter':
+                $chapterLen = PaliText::where('book', $params['book'])
+                    ->where('paragraph', $params['para'])->value('chapter_len');
+                $sent = PaliSentence::where('book', $params['book'])
+                    ->whereBetween('paragraph', [$params['para'], $params['para'] + $chapterLen - 1])
+                    ->orderBy('paragraph')
+                    ->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [
+                        $value->book,
+                        $value->paragraph,
+                        $value->word_begin,
+                        $value->word_end,
+                        $value->length
+                    ];
+                    $totalLen += $value->length;
+                }
+                break;
+            default:
+                return false;
+                break;
+        }
+
+        //render prompt
+        $mdRender = new MdRender([
+            'format' => 'prompt',
+            'footnote' => false,
+            'paragraph' => false,
+        ]);
+        $m = new \Mustache_Engine(array(
+            'entity_flags' => ENT_QUOTES,
+            'escape' => function ($value) {
+                return $value;
+            }
+        ));
+
+        # ai model
+        if (!isset($params['{{ai|model'])) {
+            return false;
+        }
+        $modelId = trim($params['{{ai|model'], '}');
+        $aiModel = AiModel::findOne($modelId);
+        $aiPrompts = [];
+        $sumLen = 0;
+        foreach ($sentences as $key => $sentence) {
+            $sumLen += $sentence[4];
+            $sid = implode('-', $sentence);
+            Log::debug($sid);
+            $data['pali'] = '{{' . $sid . '}}';
+            if (isset($params['nissaya'])) {
+                $data['nissaya'] = '{{' . $sid . '@' . $params['nissaya'] . '}}';
+            }
+            $content = $m->render($description, $data);
+            $prompt = $mdRender->convert($content, []);
+            $aiPrompts[] = $prompt;
+            //gen mq
+            $aiMqData = [
+                'model' => $aiModel,
+                'task' => [
+                    'task_id' => $taskId,
+                    'progress' => (int)($sumLen * 100 / $totalLen),
+                ],
+                'sentence' => [
+                    'book_id' => $sentence[0],
+                    'paragraph' => $sentence[1],
+                    'word_start' => $sentence[2],
+                    'word_end' => $sentence[3],
+                    'channel_uid' => $params['channel'],
+                    'content' => $prompt,
+                    'content_type' => 'markdown',
+                    'access_token' => $params['token'],
+                ],
+            ];
+            Mq::publish('ai_translate', $aiMqData);
+        }
+
+        return $aiPrompts;
+    }
+}

+ 23 - 36
api-v8/app/Http/Api/AuthApi.php

@@ -1,4 +1,5 @@
 <?php
+
 namespace App\Http\Api;
 
 use Illuminate\Support\Facades\Log;
@@ -6,46 +7,32 @@ use Illuminate\Http\Request;
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
 
-class AuthApi{
-    public static function getToken(Request $request){
-        $token = false;
-        if($request->hasHeader('Authorization')){
-            $token = $request->header('Authorization');
-            if(\substr($token,0,6) === 'Bearer'){
-                $token = trim(substr($token,6));
-                if($token === "null"){
-                    return false;
-                }
-            }
-        }
+class AuthApi
+{
+    public static function getToken(Request $request)
+    {
+        $token = $request->bearerToken();
         return $token;
     }
-    public static function current(Request $request){
-        if($request->hasHeader('Authorization')){
-            $token = $request->header('Authorization');
-            if(\substr($token,0,6) === 'Bearer'){
-                $token = trim(substr($token,6));
-                if($token === "null"){
-                    return false;
-                }
-                try{
-                    $jwt = JWT::decode($token,new Key(config('app.key'),'HS512'));
-                }catch(\Exception $e){
-                    return false;
-                }
-                if($jwt->exp < time()){
-                    //过期
-                    return false;
-                }else{
-                    //有效的token
-                    return ['user_uid'=>$jwt->uid,'user_id'=>$jwt->id];
-                }
-            }else{
+    public static function current(Request $request)
+    {
+        $token = $request->bearerToken();
+        if ($token) {
+            try {
+                $jwt = JWT::decode($token, new Key(config('app.key'), 'HS512'));
+            } catch (\Exception $e) {
+                return false;
+            }
+            if ($jwt->exp < time()) {
+                //过期
                 return false;
+            } else {
+                //有效的token
+                return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
             }
-        }else if(isset($_COOKIE['user_uid'])){
-            return ['user_uid'=>$_COOKIE['user_uid'],'user_id'=>$_COOKIE['user_id']];
-        }else{
+        } else if (isset($_COOKIE['user_uid'])) {
+            return ['user_uid' => $_COOKIE['user_uid'], 'user_id' => $_COOKIE['user_id']];
+        } else {
             return false;
         }
     }

+ 234 - 217
api-v8/app/Http/Api/MdRender.php

@@ -1,4 +1,5 @@
 <?php
+
 namespace App\Http\Api;
 
 use Illuminate\Support\Str;
@@ -12,9 +13,10 @@ use App\Tools\RedisClusters;
 use Illuminate\Support\Facades\Log;
 use App\Tools\Markdown;
 
-define("STACK_DEEP",8);
+define("STACK_DEEP", 8);
 
-class MdRender{
+class MdRender
+{
     /**
      * 文字渲染模式
      * read 阅读模式
@@ -22,17 +24,17 @@ class MdRender{
      */
     protected $options = [
         'mode' => 'read',
-        'channelType'=>'translation',
-        'contentType'=>"markdown",
-        'format'=>'react',
-        'debug'=>[],
-        'studioId'=>null,
-        'lang'=>'zh-Hans',
-        'footnote'=>false,
-        'paragraph'=>false,
-        ];
-
-    public function __construct($options=[])
+        'channelType' => 'translation',
+        'contentType' => "markdown",
+        'format' => 'react',
+        'debug' => [],
+        'studioId' => null,
+        'lang' => 'zh-Hans',
+        'footnote' => false,
+        'paragraph' => false,
+    ];
+
+    public function __construct($options = [])
     {
         foreach ($options as $key => $value) {
             $this->options[$key] = $value;
@@ -42,90 +44,94 @@ class MdRender{
     /**
      * 将句子模版组成的段落复制一份,为了实现巴汉逐段对读
      */
-    private function preprocessingForParagraph($input){
-        if(!$this->options['paragraph']){
+    private function preprocessingForParagraph($input)
+    {
+        if (!$this->options['paragraph']) {
             return $input;
         }
-        $paragraphs = explode("\n\n",$input);
+        $paragraphs = explode("\n\n", $input);
         $output = [];
         foreach ($paragraphs as $key => $paragraph) {
             # 判断是否是纯粹的句子模版
             $pattern = "/\{\{sent\|id=([0-9].+?)\}\}/";
             $replacement = '';
-            $space = preg_replace($pattern,$replacement,$paragraph);
-            $space = str_replace('>','',$space);
-            if(empty(trim($space))){
-                $output[] = str_replace('}}','|text=origin}}',$paragraph);
-                $output[] = str_replace('}}','|text=translation}}',$paragraph);
-            }else{
+            $space = preg_replace($pattern, $replacement, $paragraph);
+            $space = str_replace('>', '', $space);
+            if (empty(trim($space))) {
+                $output[] = str_replace('}}', '|text=origin}}', $paragraph);
+                $output[] = str_replace('}}', '|text=translation}}', $paragraph);
+            } else {
                 $output[] = $paragraph;
             }
         }
 
-        return implode("\n\n",$output);
+        return implode("\n\n", $output);
     }
 
     /**
      * 按照{{}}把字符串切分成三个部分。模版之前的,模版,和模版之后的
      */
-    private function tplSplit($tpl){
-        $before = strpos($tpl,'{{');
-        if($before === FALSE){
+    private function tplSplit($tpl)
+    {
+        $before = strpos($tpl, '{{');
+        if ($before === FALSE) {
             //未找到
-            return ['data'=>[$tpl,'',''],'error'=>0];
-        }else{
+            return ['data' => [$tpl, '', ''], 'error' => 0];
+        } else {
             $pointer = $before;
             $stack = array();
             $stack[] = $pointer;
-            $after = substr($tpl,$pointer+2) ;
-            while (!empty($after) && count($stack)>0 && count($stack)<STACK_DEEP) {
-                $nextBegin = strpos($after,"{{");
-                $nextEnd = strpos($after,"}}");
-                if($nextBegin !== FALSE){
-                    if($nextBegin < $nextEnd){
+            $after = substr($tpl, $pointer + 2);
+            while (!empty($after) && count($stack) > 0 && count($stack) < STACK_DEEP) {
+                $nextBegin = strpos($after, "{{");
+                $nextEnd = strpos($after, "}}");
+                if ($nextBegin !== FALSE) {
+                    if ($nextBegin < $nextEnd) {
                         //有嵌套找到最后一个}}
                         $pointer = $pointer + 2 + $nextBegin;
                         $stack[] = $pointer;
-                        $after = substr($tpl,$pointer+2);
-                    }else if($nextEnd !== FALSE){
+                        $after = substr($tpl, $pointer + 2);
+                    } else if ($nextEnd !== FALSE) {
                         //无嵌套有结束
                         $pointer = $pointer + 2 + $nextEnd;
                         array_pop($stack);
-                        $after = substr($tpl,$pointer+2);
-                    }else{
+                        $after = substr($tpl, $pointer + 2);
+                    } else {
                         //无结束符 没找到
                         break;
                     }
-                }else if($nextEnd !== FALSE){
+                } else if ($nextEnd !== FALSE) {
                     $pointer = $pointer + 2 + $nextEnd;
                     array_pop($stack);
-                    $after = substr($tpl,$pointer+2);
-                }else{
+                    $after = substr($tpl, $pointer + 2);
+                } else {
                     //没找到
                     break;
                 }
             }
-            if(count($stack)>0){
-                if(count($stack) === STACK_DEEP){
-                    return ['data'=>[$tpl,'',''],'error'=>2];
-                }else{
+            if (count($stack) > 0) {
+                if (count($stack) === STACK_DEEP) {
+                    return ['data' => [$tpl, '', ''], 'error' => 2];
+                } else {
                     //未关闭
-                    return ['data'=>[$tpl,'',''],'error'=>1];
+                    return ['data' => [$tpl, '', ''], 'error' => 1];
                 }
-            }else{
-                return ['data'=>
-                        [
-                            substr($tpl,0,$before),
-                            substr($tpl,$before,$pointer-$before+2),
-                            substr($tpl,$pointer+2)
-                        ],
-                        'error'=>0
+            } else {
+                return [
+                    'data' =>
+                    [
+                        substr($tpl, 0, $before),
+                        substr($tpl, $before, $pointer - $before + 2),
+                        substr($tpl, $pointer + 2)
+                    ],
+                    'error' => 0
                 ];
             }
         }
     }
 
-    private function wiki2xml(string $wiki,$channelId=[]):string{
+    private function wiki2xml(string $wiki, $channelId = []): string
+    {
         /**
          * 渲染markdown里面的模版
          */
@@ -135,39 +141,40 @@ class MdRender{
             $arrWiki = $this->tplSplit($remain);
             $buffer[] = $arrWiki['data'][0];
             $tpl = $arrWiki['data'][1];
-            if(!empty($tpl)){
+            if (!empty($tpl)) {
                 /**
                  * 处理模版 提取参数
                  */
-                $tpl = str_replace("|\n","|",$tpl);
+                $tpl = str_replace("|\n", "|", $tpl);
                 $pattern = "/\{\{(.+?)\|/";
                 $replacement = '<MdTpl class="tpl" name="$1"><param>';
-                $tpl = preg_replace($pattern,$replacement,$tpl);
-                $tpl = str_replace("}}","</param></MdTpl>",$tpl);
-                $tpl = str_replace("|","</param><param>",$tpl);
+                $tpl = preg_replace($pattern, $replacement, $tpl);
+                $tpl = str_replace("}}", "</param></MdTpl>", $tpl);
+                $tpl = str_replace("|", "</param><param>", $tpl);
                 /**
                  * 替换变量名
                  */
                 $pattern = "/<param>([a-z]+?)=/";
                 $replacement = '<param name="$1">';
-                $tpl = preg_replace($pattern,$replacement,$tpl);
+                $tpl = preg_replace($pattern, $replacement, $tpl);
                 //tpl to react
-                $tpl = str_replace('<param','<span class="param"',$tpl);
-                $tpl = str_replace('</param>','</span>',$tpl);
-                $tpl = $this->xml2tpl($tpl,$channelId);
+                $tpl = str_replace('<param', '<span class="param"', $tpl);
+                $tpl = str_replace('</param>', '</span>', $tpl);
+                $tpl = $this->xml2tpl($tpl, $channelId);
                 $buffer[] = $tpl;
             }
             $remain = $arrWiki['data'][2];
         } while (!empty($remain));
 
-        $html = implode('' , $buffer);
+        $html = implode('', $buffer);
 
         return $html;
     }
-    private function xmlQueryId(string $xml, string $id):string{
-        try{
+    private function xmlQueryId(string $xml, string $id): string
+    {
+        try {
             $dom = simplexml_load_string($xml);
-        }catch(\Exception $e){
+        } catch (\Exception $e) {
             Log::error($e);
             return "<div></div>";
         }
@@ -175,11 +182,11 @@ class MdRender{
         foreach ($tpl_list as $key => $tpl) {
             foreach ($tpl->children() as  $param) {
                 # 处理每个参数
-                if($param->getName() === "param"){
-                    foreach($param->attributes() as $pa => $pa_value){
+                if ($param->getName() === "param") {
+                    foreach ($param->attributes() as $pa => $pa_value) {
                         $pValue = $pa_value->__toString();
-                        if($pa === "name" && $pValue === "id"){
-                            if($param->__toString() === $id){
+                        if ($pa === "name" && $pValue === "id") {
+                            if ($param->__toString() === $id) {
                                 return $tpl->asXML();
                             }
                         }
@@ -189,24 +196,25 @@ class MdRender{
         }
         return "<div></div>";
     }
-    public static function take_sentence(string $xml):array{
+    public static function take_sentence(string $xml): array
+    {
         $output = [];
-        try{
+        try {
             $dom = simplexml_load_string($xml);
-        }catch(\Exception $e){
+        } catch (\Exception $e) {
             Log::error($e);
             return $output;
         }
         $tpl_list = $dom->xpath('//MdTpl');
         foreach ($tpl_list as $key => $tpl) {
-            foreach($tpl->attributes() as $a => $a_value){
-                if($a==="name"){
-                    if($a_value->__toString() ==="sent"){
+            foreach ($tpl->attributes() as $a => $a_value) {
+                if ($a === "name") {
+                    if ($a_value->__toString() === "sent") {
                         foreach ($tpl->children() as  $param) {
                             # 处理每个参数
-                            if($param->getName() === "param"){
+                            if ($param->getName() === "param") {
                                 $sent = $param->__toString();
-                                if(!empty($sent)){
+                                if (!empty($sent)) {
                                     $output[] = $sent;
                                     break;
                                 }
@@ -218,25 +226,26 @@ class MdRender{
         }
         return $output;
     }
-    private function xml2tpl(string $xml, $channelId=[]):string{
+    private function xml2tpl(string $xml, $channelId = []): string
+    {
         /**
          * 解析xml
          * 获取模版参数
          * 生成react 组件参数
          */
-        try{
+        try {
             //$dom = simplexml_load_string($xml);
             $doc = new \DOMDocument();
-            $xml = str_replace('MdTpl','dfn',$xml);
+            $xml = str_replace('MdTpl', 'dfn', $xml);
             $xml = mb_convert_encoding($xml, 'HTML-ENTITIES', "UTF-8");
-            $ok = $doc->loadHTML($xml,LIBXML_NOERROR  | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
-        }catch(\Exception $e){
+            $ok = $doc->loadHTML($xml, LIBXML_NOERROR  | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+        } catch (\Exception $e) {
             Log::error($e);
             Log::error($xml);
             return "<span>xml解析错误{$e}</span>";
         }
 
-        if(!$ok){
+        if (!$ok) {
             return "<span>xml解析错误</span>";
         }
         /*
@@ -254,8 +263,8 @@ class MdRender{
              */
             $props = [];
             $tpl_name = '';
-            foreach($tpl->attributes as $a => $a_value){
-                if($a_value->nodeName==="name"){
+            foreach ($tpl->attributes as $a => $a_value) {
+                if ($a_value->nodeName === "name") {
                     $tpl_name = $a_value->nodeValue;
                     break;
                 }
@@ -264,24 +273,23 @@ class MdRender{
             $child = $tpl->firstChild;
             while ($child) {
                 # 处理每个参数
-                if($child->nodeName === "span"){
+                if ($child->nodeName === "span") {
                     $param_id++;
                     $paramName = "";
-                    foreach($child->attributes as $pa => $pa_value){
-                        if($pa_value->nodeName === "name"){
+                    foreach ($child->attributes as $pa => $pa_value) {
+                        if ($pa_value->nodeName === "name") {
                             $nodeText = $pa_value->nodeValue;
                             $props["{$nodeText}"] = $child->nodeValue;
                             $paramName = $pa_value;
                         }
                     }
-                    if(empty($paramName)){
+                    if (empty($paramName)) {
                         foreach ($child->childNodes as $param_child) {
                             # code...
-                            if($param_child->nodeType ===3){
+                            if ($param_child->nodeType === 3) {
                                 $props["{$param_id}"] = $param_child->nodeValue;
                             }
                         }
-
                     }
                 }
                 $child = $child->nextSibling;
@@ -293,19 +301,20 @@ class MdRender{
             //TODO 判断$channelId里面的是否都是uuid
             $channelInfo = [];
             foreach ($channelId as $key => $id) {
-                $channelInfo[] = Channel::where('uid',$id)->first();
+                $channelInfo[] = Channel::where('uid', $id)->first();
             }
-            $tplRender = new TemplateRender($props,
-                                        $channelInfo,
-                                        $this->options['mode'],
-                                        $this->options['format'],
-                                        $this->options['studioId'],
-                                        $this->options['debug'],
-                                        $this->options['lang'],
-                                    );
+            $tplRender = new TemplateRender(
+                $props,
+                $channelInfo,
+                $this->options['mode'],
+                $this->options['format'],
+                $this->options['studioId'],
+                $this->options['debug'],
+                $this->options['lang'],
+            );
             $tplRender->options($this->options);
             $tplProps = $tplRender->render($tpl_name);
-            if($this->options['format']==='react' && $tplProps){
+            if ($this->options['format'] === 'react' && $tplProps) {
                 $props = $doc->createAttribute("props");
                 $props->nodeValue = $tplProps['props'];
                 $tpl->appendChild($props);
@@ -313,55 +322,56 @@ class MdRender{
                 $attTpl->nodeValue = $tplProps['tpl'];
                 $tpl->appendChild($attTpl);
                 $htmlElement = $doc->createElement($tplProps['tag']);
-                $htmlElement->nodeValue=$tplProps['html'];
+                $htmlElement->nodeValue = $tplProps['html'];
                 $tpl->appendChild($htmlElement);
             }
         }
         $html = $doc->saveHTML();
-        $html = str_replace(['<dfn','</dfn>'],['<MdTpl','</MdTpl>'],$html);
+        $html = str_replace(['<dfn', '</dfn>'], ['<MdTpl', '</MdTpl>'], $html);
         switch ($this->options['format']) {
             case 'react':
                 return trim($html);
                 break;
             case 'unity':
-                if($tplProps){
-                    return "{{"."{$tplProps['tpl']}|{$tplProps['props']}"."}}";
-                }else{
+                if ($tplProps) {
+                    return "{{" . "{$tplProps['tpl']}|{$tplProps['props']}" . "}}";
+                } else {
                     return '';
                 }
                 break;
             case 'html':
-                if(isset($tplProps)){
-                    if(is_array($tplProps)){
+                if (isset($tplProps)) {
+                    if (is_array($tplProps)) {
                         return '';
-                    }else{
+                    } else {
                         return $tplProps;
                     }
-                }else{
+                } else {
                     Log::error('tplProps undefine');
                     return '';
                 }
                 break;
             case 'tex':
-                if(isset($tplProps)){
-                    if(is_array($tplProps)){
+                if (isset($tplProps)) {
+                    if (is_array($tplProps)) {
                         return '';
-                    }else{
+                    } else {
                         return $tplProps;
                     }
-                }else{
+                } else {
                     Log::error('tplProps undefine');
                     return '';
                 }
                 break;
-            default: /**text simple markdown */
-                if(isset($tplProps)){
-                    if(is_array($tplProps)){
+            default:
+                /**text simple markdown */
+                if (isset($tplProps)) {
+                    if (is_array($tplProps)) {
                         return '';
-                    }else{
+                    } else {
                         return $tplProps;
                     }
-                }else{
+                } else {
                     Log::error('tplProps undefine');
                     return '';
                 }
@@ -372,53 +382,54 @@ class MdRender{
     /**
      * 将markdown文件中的模版转换为标准的wiki模版
      */
-    private function markdown2wiki(string $markdown): string{
+    private function markdown2wiki(string $markdown): string
+    {
         //$markdown = mb_convert_encoding($markdown,'UTF-8','UTF-8');
-        $markdown = iconv('UTF-8','UTF-8//IGNORE',$markdown);
+        $markdown = iconv('UTF-8', 'UTF-8//IGNORE', $markdown);
         /**
          * nissaya
          * aaa=bbb\n
          * {{nissaya|aaa|bbb}}
          */
-        if($this->options['channelType']==='nissaya'){
-            if($this->options['contentType'] === "json"){
+        if ($this->options['channelType'] === 'nissaya') {
+            if ($this->options['contentType'] === "json") {
                 $json = json_decode($markdown);
                 $nissayaWord = [];
-                if(is_array($json)){
+                if (is_array($json)) {
                     foreach ($json as $word) {
-                        if(count($word->sn) === 1){
+                        if (count($word->sn) === 1) {
                             //只输出第一层级
                             $str = "{{nissaya|";
-                            if(isset($word->word->value)){
+                            if (isset($word->word->value)) {
                                 $str .= $word->word->value;
                             }
                             $str .= "|";
-                            if(isset($word->meaning->value)){
+                            if (isset($word->meaning->value)) {
                                 $str .= $word->meaning->value;
                             }
                             $str .= "}}";
                             $nissayaWord[] = $str;
                         }
                     }
-                }else{
-                    Log::error('json data is not array',['data'=>$markdown]);
+                } else {
+                    Log::error('json data is not array', ['data' => $markdown]);
                 }
 
-                $markdown = implode('',$nissayaWord);
-            }else if($this->options['contentType'] === "markdown"){
-                $lines = explode("\n",$markdown);
+                $markdown = implode('', $nissayaWord);
+            } else if ($this->options['contentType'] === "markdown") {
+                $lines = explode("\n", $markdown);
                 $newLines = array();
                 foreach ($lines as  $line) {
-                    if(strstr($line,'=') === FALSE){
+                    if (strstr($line, '=') === FALSE) {
                         $newLines[] = $line;
-                    }else{
-                        $nissaya = explode('=',$line);
-                        $meaning = array_slice($nissaya,1);
-                        $meaning = implode('=',$meaning);
+                    } else {
+                        $nissaya = explode('=', $line);
+                        $meaning = array_slice($nissaya, 1);
+                        $meaning = implode('=', $meaning);
                         $newLines[] = "{{nissaya|{$nissaya[0]}|{$meaning}}}";
                     }
                 }
-                $markdown = implode("\n",$newLines);
+                $markdown = implode("\n", $newLines);
             }
         }
         //$markdown = preg_replace("/\n\n/","<div></div>",$markdown);
@@ -426,29 +437,29 @@ class MdRender{
         /**
          * 处理 mermaid
          */
-        if(strpos($markdown,"```mermaid") !== false){
-            $lines = explode("\n",$markdown);
+        if (strpos($markdown, "```mermaid") !== false) {
+            $lines = explode("\n", $markdown);
             $newLines = array();
             $mermaidBegin = false;
             $mermaidString = array();
             foreach ($lines as  $line) {
-                if($line === "```mermaid"){
+                if ($line === "```mermaid") {
                     $mermaidBegin = true;
                     $mermaidString = [];
                     continue;
                 }
-                if($mermaidBegin){
-                    if($line === "```"){
-                        $newLines[] = "{{mermaid|".base64_encode(\json_encode($mermaidString))."}}";
+                if ($mermaidBegin) {
+                    if ($line === "```") {
+                        $newLines[] = "{{mermaid|" . base64_encode(\json_encode($mermaidString)) . "}}";
                         $mermaidBegin = false;
-                    }else{
+                    } else {
                         $mermaidString[] = $line;
                     }
-                }else{
+                } else {
                     $newLines[] = $line;
                 }
             }
-            $markdown = implode("\n",$newLines);
+            $markdown = implode("\n", $newLines);
         }
 
         /**
@@ -468,12 +479,12 @@ class MdRender{
         #替换术语
         $pattern = "/\[\[(.+?)\]\]/";
         $replacement = '{{term|$1}}';
-        $markdown = preg_replace($pattern,$replacement,$markdown);
+        $markdown = preg_replace($pattern, $replacement, $markdown);
 
         #替换句子模版
         $pattern = "/\{\{([0-9].+?)\}\}/";
         $replacement = '{{sent|id=$1}}';
-        $markdown = preg_replace($pattern,$replacement,$markdown);
+        $markdown = preg_replace($pattern, $replacement, $markdown);
 
         /**
          * 替换多行注释
@@ -486,34 +497,34 @@ class MdRender{
          * bla
          * }}
          */
-        if(strpos($markdown,"```\n") !== false){
-            $lines = explode("\n",$markdown);
+        if (strpos($markdown, "```\n") !== false) {
+            $lines = explode("\n", $markdown);
             $newLines = array();
             $noteBegin = false;
             $noteString = array();
             foreach ($lines as  $line) {
 
-                if($noteBegin){
-                    if($line === "```"){
+                if ($noteBegin) {
+                    if ($line === "```") {
                         $newLines[] = "}}";
                         $noteBegin = false;
-                    }else{
+                    } else {
                         $newLines[] = $line;
                     }
-                }else{
-                    if($line === "```"){
+                } else {
+                    if ($line === "```") {
                         $noteBegin = true;
                         $newLines[] = "{{note|";
                         continue;
-                    }else{
-                       $newLines[] = $line;
+                    } else {
+                        $newLines[] = $line;
                     }
                 }
             }
-            if($noteBegin){
+            if ($noteBegin) {
                 $newLines[] = "}}";
             }
-            $markdown = implode("\n",$newLines);
+            $markdown = implode("\n", $newLines);
         }
 
         /**
@@ -523,68 +534,73 @@ class MdRender{
          */
         $pattern = "/`(.+?)`/";
         $replacement = '{{note|$1}}';
-        $markdown = preg_replace($pattern,$replacement,$markdown);
+        $markdown = preg_replace($pattern, $replacement, $markdown);
 
         return $markdown;
     }
 
-    private function markdownToHtml($markdown){
-        $markdown = str_replace('MdTpl','mdtpl',$markdown);
-        $markdown = str_replace(['<param','</param>'],['<span','</span>'],$markdown);
+    private function markdownToHtml($markdown)
+    {
+        $markdown = str_replace('MdTpl', 'mdtpl', $markdown);
+        $markdown = str_replace(['<param', '</param>'], ['<span', '</span>'], $markdown);
 
         $html = Markdown::render($markdown);
-        if($this->options['format']==='react'){
+        if ($this->options['format'] === 'react') {
             $html = $this->fixHtml($html);
         }
-        $html = str_replace('<hr>','<hr />',$html);
+        $html = str_replace('<hr>', '<hr />', $html);
         //给H1-6 添加uuid
-        for ($i=1; $i<7 ; $i++) {
-            if(strpos($html,"<h{$i}>")===false){
+        for ($i = 1; $i < 7; $i++) {
+            if (strpos($html, "<h{$i}>") === false) {
                 continue;
             }
             $output = array();
             $input = $html;
-            $hPos = strpos($input,"<h{$i}>");
+            $hPos = strpos($input, "<h{$i}>");
             while ($hPos !== false) {
-                $output[] = substr($input,0,$hPos);
-                $output[] = "<h{$i} id='".Str::uuid()."'>";
-                $input = substr($input,$hPos+4);
-                $hPos = strpos($input,"<h{$i}>");
+                $output[] = substr($input, 0, $hPos);
+                $output[] = "<h{$i} id='" . Str::uuid() . "'>";
+                $input = substr($input, $hPos + 4);
+                $hPos = strpos($input, "<h{$i}>");
             }
             $output[] = $input;
-            $html = implode('',$output);
+            $html = implode('', $output);
         }
-        $html = str_replace('mdtpl','MdTpl',$html);
+        $html = str_replace('mdtpl', 'MdTpl', $html);
         return $html;
     }
-    private function  fixHtml($html) {
+    private function  fixHtml($html)
+    {
         $doc = new \DOMDocument();
         libxml_use_internal_errors(true);
         $html = mb_convert_encoding($html, 'HTML-ENTITIES', "UTF-8");
-        $doc->loadHTML('<span>'.$html.'</span>',LIBXML_NOERROR  | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+        $doc->loadHTML('<span>' . $html . '</span>', LIBXML_NOERROR  | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
         $fixed = $doc->saveHTML();
         $fixed = mb_convert_encoding($fixed, "UTF-8", 'HTML-ENTITIES');
         return $fixed;
     }
-    public static function init(){
+    public static function init()
+    {
         $GLOBALS["MdRenderStack"] = 0;
     }
-    public function convert($markdown,$channelId=[],$queryId=null){
-        if(isset($GLOBALS["MdRenderStack"]) && is_numeric($GLOBALS["MdRenderStack"])){
+    public function convert($markdown, $channelId = [], $queryId = null)
+    {
+        if (isset($GLOBALS["MdRenderStack"]) && is_numeric($GLOBALS["MdRenderStack"])) {
             $GLOBALS["MdRenderStack"]++;
-        }else{
+        } else {
             $GLOBALS["MdRenderStack"] = 1;
         }
-        if($GLOBALS["MdRenderStack"]<3){
-            $output  =  $this->_convert($markdown,$channelId,$queryId);
-        }else{
+        if ($GLOBALS["MdRenderStack"] < 3) {
+            $output  =  $this->_convert($markdown, $channelId, $queryId);
+        } else {
             $output  = $markdown;
         }
         $GLOBALS["MdRenderStack"]--;
         return $output;
     }
-    private function _convert($markdown,$channelId=[],$queryId=null){
-        if(empty($markdown)){
+    private function _convert($markdown, $channelId = [], $queryId = null)
+    {
+        if (empty($markdown)) {
             switch ($this->options['format']) {
                 case 'react':
                     return "<span></span>";
@@ -596,8 +612,8 @@ class MdRender{
         }
         $wiki = $this->markdown2wiki($markdown);
         $wiki = $this->preprocessingForParagraph($wiki);
-        $markdownWithTpl = $this->wiki2xml($wiki,$channelId);
-        if(!is_null($queryId)){
+        $markdownWithTpl = $this->wiki2xml($wiki, $channelId);
+        if (!is_null($queryId)) {
             $html = $this->xmlQueryId($markdownWithTpl, $queryId);
         }
         $html = $this->markdownToHtml($markdownWithTpl);
@@ -607,54 +623,55 @@ class MdRender{
         switch ($this->options['format']) {
             case 'react':
                 //生成可展开组件
-                $html = str_replace("<div/>","<div></div>",$html);
+                $html = str_replace("<div/>", "<div></div>", $html);
                 $pattern = '/<li><div>(.+?)<\/div><\/li>/';
                 $replacement = '<li><MdTpl name="toggle" tpl="toggle" props=""><div>$1</div></MdTpl></li>';
-                $output = preg_replace($pattern,$replacement,$html);
+                $output = preg_replace($pattern, $replacement, $html);
                 break;
             case 'text':
             case 'simple':
+            case 'prompt':
                 $html = strip_tags($html);
-                $output = htmlspecialchars_decode($html,ENT_QUOTES);
+                $output = htmlspecialchars_decode($html, ENT_QUOTES);
                 //$output = html_entity_decode($html);
                 break;
             case 'tex':
                 $html = strip_tags($html);
-                $output = htmlspecialchars_decode($html,ENT_QUOTES);
+                $output = htmlspecialchars_decode($html, ENT_QUOTES);
                 //$output = html_entity_decode($html);
                 break;
             case 'unity':
-                $html = str_replace(['<strong>','</strong>','<em>','</em>'],['[%b%]','[%/b%]','[%i%]','[%/i%]'],$html);
+                $html = str_replace(['<strong>', '</strong>', '<em>', '</em>'], ['[%b%]', '[%/b%]', '[%i%]', '[%/i%]'], $html);
                 $html = strip_tags($html);
-                $html = str_replace(['[%b%]','[%/b%]','[%i%]','[%/i%]'],['<b>','</b>','<i>','</i>'],$html);
-                $output = htmlspecialchars_decode($html,ENT_QUOTES);
+                $html = str_replace(['[%b%]', '[%/b%]', '[%i%]', '[%/i%]'], ['<b>', '</b>', '<i>', '</i>'], $html);
+                $output = htmlspecialchars_decode($html, ENT_QUOTES);
                 break;
             case 'html':
-                $output = htmlspecialchars_decode($html,ENT_QUOTES);
+                $output = htmlspecialchars_decode($html, ENT_QUOTES);
                 //处理脚注
-                if($this->options['footnote'] && isset($GLOBALS['note']) && count($GLOBALS['note'])>0){
+                if ($this->options['footnote'] && isset($GLOBALS['note']) && count($GLOBALS['note']) > 0) {
                     $output .= '<div><h1>endnote</h1>';
                     foreach ($GLOBALS['note'] as $footnote) {
-                        $output .= '<p><a name="footnote-'.$footnote['sn'].'">['.$footnote['sn'].']</a> '.$footnote['content'].'</p>';
+                        $output .= '<p><a name="footnote-' . $footnote['sn'] . '">[' . $footnote['sn'] . ']</a> ' . $footnote['content'] . '</p>';
                     }
                     $output .= '</div>';
                     unset($GLOBALS['note']);
                 }
                 //处理图片链接
-                $output = str_replace('<img src="','<img src="'.config('app.url'),$output);
+                $output = str_replace('<img src="', '<img src="' . config('app.url'), $output);
                 break;
             case 'markdown':
                 //处理脚注
                 $footnotes = array();
-                if($this->options['footnote'] && isset($GLOBALS['note']) && count($GLOBALS['note'])>0){
+                if ($this->options['footnote'] && isset($GLOBALS['note']) && count($GLOBALS['note']) > 0) {
                     foreach ($GLOBALS['note'] as $footnote) {
-                        $footnotes[] = '[^'.$footnote['sn'].']: ' . $footnote['content'];
+                        $footnotes[] = '[^' . $footnote['sn'] . ']: ' . $footnote['content'];
                     }
                     unset($GLOBALS['note']);
                 }
                 //处理图片链接
-                $output = str_replace('/attachments/',config('app.url')."/attachments/",$markdownWithTpl);
-                $output = $output . "\n\n" . implode("\n\n",$footnotes);
+                $output = str_replace('/attachments/', config('app.url') . "/attachments/", $markdownWithTpl);
+                $output = $output . "\n\n" . implode("\n\n", $footnotes);
                 break;
         }
         return $output;
@@ -664,20 +681,20 @@ class MdRender{
     /**
      * string[] $channelId
      */
-    public static function render($markdown,$channelId,$queryId=null,$mode='read',$channelType='translation',$contentType="markdown",$format='react'){
+    public static function render($markdown, $channelId, $queryId = null, $mode = 'read', $channelType = 'translation', $contentType = "markdown", $format = 'react')
+    {
 
-            $mdRender = new MdRender(
-                            [
-                                'mode'=>$mode,
-                                'channelType'=>$channelType,
-                                'contentType'=>$contentType,
-                                'format'=>$format
-                            ]);
+        $mdRender = new MdRender(
+            [
+                'mode' => $mode,
+                'channelType' => $channelType,
+                'contentType' => $contentType,
+                'format' => $format
+            ]
+        );
 
-            $output  = $mdRender->convert($markdown,$channelId,$queryId);
+        $output  = $mdRender->convert($markdown, $channelId, $queryId);
 
         return $output;
     }
-
-
 }

+ 48 - 0
api-v8/app/Http/Api/TemplateRender.php

@@ -154,6 +154,9 @@ class TemplateRender
             case 'dict-pref':
                 $result = $this->render_dict_pref();
                 break;
+            case 'ai':
+                $result = $this->render_ai();
+                break;
             default:
                 # code...
                 $result = [
@@ -1001,6 +1004,14 @@ class TemplateRender
                     }
                 }
                 break;
+            case 'prompt':
+                $output = '';
+                if (isset($props['origin']) && is_array($props['origin'])) {
+                    foreach ($props['origin'] as $key => $value) {
+                        $output .= $value['html'];
+                    }
+                }
+                break;
             case 'html':
                 $output = '';
                 $output .= '<span class="sentence">';
@@ -1359,6 +1370,43 @@ class TemplateRender
         }
         return $output;
     }
+
+    private function render_ai()
+    {
+        $model = $this->get_param($this->param, "model", 1, 1);
+
+        $props = [
+            "model" => $model,
+        ];
+
+        switch ($this->format) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => "",
+                    'text' => '',
+                    'tag' => 'div',
+                    'tpl' => 'ai',
+                ];
+                break;
+            case 'unity':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'tpl' => 'ai',
+                ];
+                break;
+            case 'text':
+                $output = 'ai';
+                break;
+            case 'prompt':
+                $output = '';
+                break;
+            default:
+                $output = 'ai';
+                break;
+        }
+        return $output;
+    }
     private  function get_param(array $param, string $name, int $id, string $default = '')
     {
         if (isset($param[$name])) {

+ 50 - 54
api-v8/app/Http/Api/UserApi.php

@@ -1,4 +1,5 @@
 <?php
+
 namespace App\Http\Api;
 
 use App\Models\UserInfo;
@@ -6,63 +7,46 @@ use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\App;
 
-class UserApi{
-    public static function getIdByName($name){
-        return UserInfo::where('username',$name)->value('userid');
+class UserApi
+{
+    public static function getIdByName($name)
+    {
+        return UserInfo::where('username', $name)->value('userid');
     }
-    public static function getIdByUuid($uuid){
-        return UserInfo::where('userid',$uuid)->value('id');
+    public static function getIdByUuid($uuid)
+    {
+        return UserInfo::where('userid', $uuid)->value('id');
     }
-    public static function getIntIdByName($name){
-        return UserInfo::where('username',$name)->value('id');
+    public static function getIntIdByName($name)
+    {
+        return UserInfo::where('username', $name)->value('id');
+    }
+    public static function getById($id)
+    {
+        $user = UserInfo::where('id', $id)->first();
+        return UserApi::userInfo($user);
     }
-    public static function getById($id){
-        $user = UserInfo::where('id',$id)->first();
-        if($user){
-            return [
-                'id'=>$id,
-                'uid'=>$user->userid,
-                'nickName'=>$user['nickname'],
-                'userName'=>$user['username'],
-                'realName'=>$user['username'],
-                'avatar'=>'',
-            ];
-        }else{
-            Log::error('$user=null;$id='.$id);
-            return [
-                'id'=>0,
-                'nickName'=>'unknown',
-                'userName'=>'unknown',
-                'realName'=>'unknown',
-                'avatar'=>'',
-            ];
-        }
 
+    public static function getByName($name)
+    {
+        $user = UserInfo::where('username', $name)->first();
+        return UserApi::userInfo($user);
     }
-    public static function getByUuid($id){
-        $user = UserInfo::where('userid',$id)->first();
-        if($user){
-            return UserApi::userInfo($user);
-        }else{
-            Log::error('$user=null;$id='.$id);
-            return [
-                'id'=>0,
-                'nickName'=>'unknown',
-                'userName'=>'unknown',
-                'realName'=>'unknown',
-                'avatar'=>'',
-            ];
-        }
+    public static function getByUuid($id)
+    {
+        $user = UserInfo::where('userid', $id)->first();
+        return UserApi::userInfo($user);
     }
-    public static function getListByUuid($uuid){
-        if(!$uuid || !is_array($uuid)){
+    public static function getListByUuid($uuid)
+    {
+        if (!$uuid || !is_array($uuid)) {
             return null;
         };
-        $users = UserInfo::whereIn('userid',$uuid)->get();
+        $users = UserInfo::whereIn('userid', $uuid)->get();
         $output = array();
         foreach ($uuid as $key => $id) {
             foreach ($users as $user) {
-                if($user->userid === $id){
+                if ($user->userid === $id) {
                     $output[] = UserApi::userInfo($user);
                     continue;
                 };
@@ -70,21 +54,33 @@ class UserApi{
         }
         return $output;
     }
-    public static function userInfo($user){
+    public static function userInfo($user)
+    {
+        if (!$user) {
+            Log::error('$user=null;');
+            return [
+                'id' => 0,
+                'nickName' => 'unknown',
+                'userName' => 'unknown',
+                'realName' => 'unknown',
+                'avatar' => '',
+            ];
+        }
         $data = [
             'id' => $user->userid,
-            'nickName'=>$user->nickname,
-            'userName'=>$user->username,
-            'realName'=>$user->username,
+            'nickName' => $user->nickname,
+            'userName' => $user->username,
+            'realName' => $user->username,
+            'sn' => $user->id,
         ];
-        if(!empty($user->role)){
+        if (!empty($user->role)) {
             $data['roles'] = json_decode($user->role);
         }
-        if($user->avatar){
-            $img = str_replace('.jpg','_s.jpg',$user->avatar);
+        if ($user->avatar) {
+            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
             if (App::environment('local')) {
                 $data['avatar'] = Storage::url($img);
-            }else{
+            } else {
                 $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
             }
         }

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

@@ -0,0 +1,158 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreAiModelRequest;
+use App\Http\Requests\UpdateAiModelRequest;
+use App\Models\AiModel;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\AiModelResource;
+
+
+class AiModelController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        switch ($request->get('view')) {
+            case 'all':
+                $table = AiModel::whereNotNull('owner_id');
+                break;
+            case 'studio':
+                $studioId = StudioApi::getIdByName($request->get('name'));
+
+                $table = AiModel::where('owner_id', $studioId);
+
+                break;
+        }
+        if ($request->has('keyword')) {
+            $table = $table->where('name', 'like', '%' . $request->get('keyword') . '%');
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy(
+            $request->get('order', 'created_at'),
+            $request->get('dir', 'asc')
+        );
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+
+        Log::debug('sql', ['sql' => $table->toSql()]);
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => AiModelResource::collection(resource: $result),
+                "count" => $count,
+            ]
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreAiModelRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreAiModelRequest $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        Log::debug('store', ['studioId' => $studioId, 'user' => $user]);
+        if (!self::canEdit($user['user_uid'], $studioId)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $new = new AiModel();
+        $new->name = $request->get('name');
+        $new->uid = Str::uuid();
+        $new->owner_id = $studioId;
+        $new->editor_id = $user['user_uid'];
+        $new->save();
+        return $this->ok(new AiModelResource($new));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AiModel $aiModel)
+    {
+        //
+        return $this->ok(new AiModelResource($aiModel));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateAiModelRequest  $request
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateAiModelRequest $request, AiModel $aiModel)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $aiModel->name = $request->get('name');
+        $aiModel->description = $request->get('description');
+        $aiModel->url = $request->get('url');
+        $aiModel->model = $request->get('model');
+        $aiModel->key = $request->get('key');
+        $aiModel->privacy = $request->get('privacy');
+        $aiModel->editor_id = $user['user_uid'];
+        $aiModel->save();
+        return $this->ok(new AiModelResource($aiModel));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, AiModel $aiModel)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $del = $aiModel->delete();
+        return $this->ok($del);
+    }
+
+    public static function canEdit($user_uid, $owner_uid)
+    {
+        return $user_uid === $owner_uid;
+    }
+}

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

@@ -47,7 +47,7 @@ class AiTranslateController extends Controller
             ],
             "temperature" => 0.3,
         ];
-        $response = Http::withToken('sk-kwjHIMh3PoWwUwQyKdT3KHvNe8Es19SUiujGrxtH09uDQCui')
+        $response = Http::withToken($selected[0]['token'])
             ->post($url, $param);
         if ($response->failed()) {
             $this->error('http request error' . $response->json('message'));

+ 48 - 33
api-v8/app/Http/Controllers/AuthController.php

@@ -5,11 +5,10 @@ namespace App\Http\Controllers;
 use Illuminate\Http\Request;
 use App\Models\UserInfo;
 use Firebase\JWT\JWT;
-use Firebase\JWT\Key;
 use App\Http\Api\AuthApi;
-use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\App;
+use App\Http\Api\UserApi;
 
 class AuthController extends Controller
 {
@@ -67,19 +66,20 @@ class AuthController extends Controller
     {
         //
     }
-    public function signIn(Request $request){
+    public function signIn(Request $request)
+    {
 
-        $query = UserInfo::where(function ($query) use($request) {
-                            $query->where('username',$request->get('username'))
-                                  ->where('password',md5($request->get('password')));
-                        })
-                        ->orWhere(function ($query) use($request) {
-                            $query->where('email',$request->get('username'))
-                                  ->where('password',md5($request->get('password')));
-                        });
+        $query = UserInfo::where(function ($query) use ($request) {
+            $query->where('username', $request->get('username'))
+                ->where('password', md5($request->get('password')));
+        })
+            ->orWhere(function ($query) use ($request) {
+                $query->where('email', $request->get('username'))
+                    ->where('password', md5($request->get('password')));
+            });
         //Log::info($query->toSql());
         $user = $query->first();
-        if($user){
+        if ($user) {
             $ExpTime = time() + 60 * 60 * 24 * 365;
             $key = config('app.key');
             $payload = [
@@ -88,50 +88,65 @@ class AuthController extends Controller
                 'uid' => $user->userid,
                 'id' => $user->id,
             ];
-            $jwt = JWT::encode($payload,$key,'HS512');
+            $jwt = JWT::encode($payload, $key, 'HS512');
             return $this->ok($jwt);
-        }else{
+        } else {
             return $this->error('invalid token');
         }
     }
-    public function getUserInfoByToken(Request $request){
+
+    public static function getUserToken($userUid)
+    {
+        $user = UserApi::getByUuid($userUid);
+        if ($user) {
+            $ExpTime = time() + 60 * 60 * 24 * 365;
+            $key = config('app.key');
+            $payload = [
+                'nbf' => time(),
+                'exp' => $ExpTime,
+                'uid' => $user['id'],
+                'id' => $user['sn'],
+            ];
+            $jwt = JWT::encode($payload, $key, 'HS512');
+        }
+    }
+
+    public function getUserInfoByToken(Request $request)
+    {
         $curr = AuthApi::current($request);
-        if(!$curr){
-            return $this->error('invalid token',401,401);
+        if (!$curr) {
+            return $this->error('invalid token', 401, 401);
         }
-        $userInfo = UserInfo::where('userid',$curr['user_uid'])
-                        ->first();
+        $userInfo = UserInfo::where('userid', $curr['user_uid'])
+            ->first();
         $user = [
-            "id"=>$curr['user_uid'],
-            "nickName"=> $userInfo->nickname,
-            "realName"=> $userInfo->username,
-            "avatar"=> "",
-            "token"=>\substr($request->header('Authorization'),7) ,
+            "id" => $curr['user_uid'],
+            "nickName" => $userInfo->nickname,
+            "realName" => $userInfo->username,
+            "avatar" => "",
+            "token" => \substr($request->header('Authorization'), 7),
         ];
 
         //role为空 返回[]
         $user['roles'] = [];
-        if(!empty($userInfo->role)){
+        if (!empty($userInfo->role)) {
             $roles = json_decode($userInfo->role);
-            if(is_array($roles)){
+            if (is_array($roles)) {
                 $user['roles'] = $roles;
             }
         }
 
-        if($curr['user_uid'] === config('mint.admin.root_uuid')){
+        if ($curr['user_uid'] === config('mint.admin.root_uuid')) {
             $user['roles'] = ['root'];
         }
-        if($userInfo->avatar){
-            $img = str_replace('.jpg','_s.jpg',$userInfo->avatar);
+        if ($userInfo->avatar) {
+            $img = str_replace('.jpg', '_s.jpg', $userInfo->avatar);
             if (App::environment('local')) {
                 $user['avatar'] = Storage::url($img);
-            }else{
+            } else {
                 $user['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
             }
         }
         return $this->ok($user);
     }
-
 }
-
-

+ 177 - 180
api-v8/app/Http/Controllers/DiscussionController.php

@@ -32,24 +32,24 @@ class DiscussionController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if($user){
+        if ($user) {
             $userInfo = UserApi::getByUuid($user['user_uid']);
         }
-		switch ($request->get('view')) {
+        switch ($request->get('view')) {
             case 'question-by-topic':
-                $topic = Discussion::where('id',$request->get('id'));
-                $topic->where('status',$request->get('status','active'))
-                      ->select('res_id')->first();
-                if(!$topic){
-			        return $this->error("无效的id");
+                $topic = Discussion::where('id', $request->get('id'));
+                $topic->where('status', $request->get('status', 'active'))
+                    ->select('res_id')->first();
+                if (!$topic) {
+                    return $this->error("无效的id");
                 }
-                $table = Discussion::where('res_id',$topic->res_id);
-                $activeNumber = Discussion::where('res_id',$topic->res_id)
-                                            ->where('status','active')->count();
-                $closeNumber = Discussion::where('res_id',$topic->res_id)
-                                            ->where('status','close')->count();
-                $table->where('status',$request->get('status','active'))
-                                    ->where('parent',null);
+                $table = Discussion::where('res_id', $topic->res_id);
+                $activeNumber = Discussion::where('res_id', $topic->res_id)
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('res_id', $topic->res_id)
+                    ->where('status', 'close')->count();
+                $table->where('status', $request->get('status', 'active'))
+                    ->where('parent', null);
                 break;
             case 'question':
                 /**
@@ -58,7 +58,7 @@ class DiscussionController extends Controller
                  * basic用户看到别人在别人channel发表的discussion
                  *
                  */
-                if(!$user && $request->get('type')==='discussion'){
+                if (!$user && $request->get('type') === 'discussion') {
                     return $this->ok([
                         "rows" => [],
                         "count" => 0,
@@ -66,20 +66,20 @@ class DiscussionController extends Controller
                         'close' => 0,
                         'can_create' => false,
                         'can_reply' => false,
-                        ]);
+                    ]);
                 }
                 $resType = $request->get('res_type');
-                if($user){
+                if ($user) {
                     switch ($resType) {
                         case 'sentence':
                             # code...
                             break;
                         case 'wbw':
-                            $block_uid = Wbw::where('uid',$request->get('id'))->value('block_uid');
-                            if($block_uid){
-                                $channelId = WbwBlock::where('uid',$block_uid)->value('channel_uid');
-                                if($channelId){
-                                    $canEdit = ChannelApi::userCanEdit($user['user_uid'],$channelId);
+                            $block_uid = Wbw::where('uid', $request->get('id'))->value('block_uid');
+                            if ($block_uid) {
+                                $channelId = WbwBlock::where('uid', $block_uid)->value('channel_uid');
+                                if ($channelId) {
+                                    $canEdit = ChannelApi::userCanEdit($user['user_uid'], $channelId);
                                 }
                             }
                             break;
@@ -91,7 +91,7 @@ class DiscussionController extends Controller
 
 
                 $resId = [$request->get('id')];
-                if(!empty($request->get('course'))){
+                if (!empty($request->get('course'))) {
                     //
                     /**
                      * 如果res id 是答案,获取学员提问
@@ -99,116 +99,118 @@ class DiscussionController extends Controller
                      */
                     //获取学员提问
                     //获取学员channel
-                    if($request->get('show_student') === 'true'){
+                    if ($request->get('show_student') === 'true') {
                         $channelsId = CourseApi::getStudentChannels($request->get('course'));
                         switch ($resType) {
                             case 'wbw':
                                 //获取答案单词编号
-                                $wbwWord = Wbw::where('uid',$request->get('id'))
-                                            ->first();
+                                $wbwWord = Wbw::where('uid', $request->get('id'))
+                                    ->first();
                                 $wbwId = WbwSentenceController::getWbwIdByChannels(
-                                                $channelsId,
-                                                $wbwWord->book_id,
-                                                $wbwWord->paragraph,
-                                                $wbwWord->wid);
-                                $resId = array_merge($resId,$wbwId);
+                                    $channelsId,
+                                    $wbwWord->book_id,
+                                    $wbwWord->paragraph,
+                                    $wbwWord->wid
+                                );
+                                $resId = array_merge($resId, $wbwId);
                                 break;
                             case 'sentence':
                                 break;
                         }
                     }
                 }
-                $table = Discussion::whereIn('res_id',$resId)
-                                    ->where('type', $request->get('type','discussion'))
-                                    ->where('status',$request->get('status','active'))
-                                    ->where('parent',null);
-                if($request->get('type')==='discussion'){
-                    if(isset($userInfo) &&
+                $table = Discussion::whereIn('res_id', $resId)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', $request->get('status', 'active'))
+                    ->where('parent', null);
+                if ($request->get('type') === 'discussion') {
+                    if (
+                        isset($userInfo) &&
                         isset($userInfo['roles']) &&
-                        in_array('basic',$userInfo['roles'])){
-                            if(isset($canEdit) && $canEdit===true){
-
-                            }else{
-                               $table = $table->where('editor_uid',$userInfo['id']);
-                            }
+                        in_array('basic', $userInfo['roles'])
+                    ) {
+                        if (isset($canEdit) && $canEdit === true) {
+                        } else {
+                            $table = $table->where('editor_uid', $userInfo['id']);
+                        }
                     }
                 }
-                $activeNumber = Discussion::whereIn('res_id',$resId)
-                                            ->where('parent',null)
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','active')->count();
-                $closeNumber = Discussion::whereIn('res_id',$resId)
-                                            ->where('parent',null)
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','close')->count();
+                $activeNumber = Discussion::whereIn('res_id', $resId)
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::whereIn('res_id', $resId)
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
                 break;
             case 'answer':
-                $table = Discussion::where('parent',$request->get('id'));
-                $activeNumber = Discussion::where('parent',$request->get('id'))
-                                        ->where('status','active')->count();
-                $closeNumber = Discussion::where('parent',$request->get('id'))
-                                        ->where('status','close')->count();
+                $table = Discussion::where('parent', $request->get('id'));
+                $activeNumber = Discussion::where('parent', $request->get('id'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('parent', $request->get('id'))
+                    ->where('status', 'close')->count();
                 break;
             case 'res_id':
                 /**
                  * 先获取顶级节点
                  * 需要确定用户身份,manager查看全部topic 普通用户只显示自己提交的topic
                  */
-                $roots = Discussion::where('res_id',$request->get('id'))
-                                    ->where('type', $request->get('type','discussion'))
-                                    ->whereIn('status',explode(',',$request->get('status','active')) )
-                                    ->where('parent',null)
-                                    ->select('id')
-                                    ->get();
+                $roots = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->whereIn('status', explode(',', $request->get('status', 'active')))
+                    ->where('parent', null)
+                    ->select('id')
+                    ->get();
 
                 $table = Discussion::where(function ($query) use ($roots) {
-                                        $query->whereIn('id'  , $roots)
-                                            ->orWhereIn('parent', $roots);
-                                        });
-                $activeNumber = Discussion::where('res_id',$request->get('id'))
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','active')->count();
-                $closeNumber = Discussion::where('res_id',$request->get('id'))
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','close')->count();
+                    $query->whereIn('id', $roots)
+                        ->orWhereIn('parent', $roots);
+                });
+                $activeNumber = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
                 break;
             case 'topic-by-user':
                 /**
                  * 某用户发表的全部topic
                  *
                  */
-                if(!$user){
-                    return $this->error('',403,403);
+                if (!$user) {
+                    return $this->error('', 403, 403);
                 }
-                $table = Discussion::where('editor_uid',$user['user_uid'])
-                                    ->where('type', $request->get('type','discussion'))
-                                    ->whereIn('status',explode(',',$request->get('status','active')) )
-                                    ->where('parent',null);
-                $activeNumber = Discussion::where('editor_uid',$user['user_uid'])
-                                            ->where('parent',null)
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','active')->count();
-                $closeNumber = Discussion::where('editor_uid',$user['user_uid'])
-                                            ->where('parent',null)
-                                            ->where('type', $request->get('type','discussion'))
-                                            ->where('status','close')->count();
+                $table = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->whereIn('status', explode(',', $request->get('status', 'active')))
+                    ->where('parent', null);
+                $activeNumber = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
                 break;
             case 'all':
-                $table = Discussion::where('parent',null);
-                $activeNumber = Discussion::where('parent',null)
-                                        ->where('status','active')->count();
-                $closeNumber = Discussion::where('parent',null)
-                                        ->where('status','close')->count();
+                $table = Discussion::where('parent', null);
+                $activeNumber = Discussion::where('parent', null)
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('parent', null)
+                    ->where('status', 'close')->count();
                 break;
         }
-        if(!empty($search)){
-            $table = $table->where('title', 'like', $search."%");
+        if (!empty($search)) {
+            $table = $table->where('title', 'like', $search . "%");
         }
         $count = $table->count();
 
-        $table = $table->orderBy($request->get('order','created_at'),$request->get('dir','desc'));
-        $table = $table->skip($request->get("offset",0))
-              ->take($request->get('limit',1000));
+        $table = $table->orderBy($request->get('order', 'created_at'), $request->get('dir', 'desc'));
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
 
         $result = $table->get();
 
@@ -216,11 +218,11 @@ class DiscussionController extends Controller
         $can_reply = false;
         $user = AuthApi::current($request);
 
-        switch ($request->get('type','discussion')) {
+        switch ($request->get('type', 'discussion')) {
             case 'qa':
                 switch ($request->get('res_type')) {
                     case 'article':
-                        if($user && ArticleController::userCanEditId($user['user_uid'],$request->get('id'))){
+                        if ($user && ArticleController::userCanEditId($user['user_uid'], $request->get('id'))) {
                             $can_create = true;
                             $can_reply = true;
                         }
@@ -230,9 +232,9 @@ class DiscussionController extends Controller
             case 'help':
                 switch ($request->get('res_type')) {
                     case 'article':
-                        if($user){
+                        if ($user) {
                             $can_reply = true;
-                            if(ArticleController::userCanEditId($user['user_uid'],$request->get('id'))){
+                            if (ArticleController::userCanEditId($user['user_uid'], $request->get('id'))) {
                                 $can_create = true;
                             }
                         }
@@ -240,7 +242,7 @@ class DiscussionController extends Controller
                 }
                 break;
             case 'discussion':
-                if($user){
+                if ($user) {
                     $can_create = true;
                     $can_reply = true;
                 }
@@ -254,27 +256,27 @@ class DiscussionController extends Controller
             'close' => $closeNumber,
             'can_create' => $can_create,
             'can_reply' => $can_reply,
-            ]);
-
+        ]);
     }
 
-    public function discussion_tree(Request $request){
+    public function discussion_tree(Request $request)
+    {
         $output = [];
         $sentences = $request->get("data");
         foreach ($sentences as $key => $sentence) {
             # 先查句子信息
-            $sentInfo = Sentence::where('book_id',$sentence['book'])
-                                ->where('paragraph',$sentence['paragraph'])
-                                ->where('word_start',$sentence['word_start'])
-                                ->where('word_end',$sentence['word_end'])
-                                ->where('channel_uid',$sentence['channel_id'])
-                                ->first();
-            if($sentInfo){
-                $sentPr = Discussion::where('res_id',$sentInfo['uid'])
-                                ->whereNull('parent')
-                                ->select('title','children_count','editor_uid')
-                                ->orderBy('created_at','desc')->get();
-                if(count($sentPr)>0){
+            $sentInfo = Sentence::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->first();
+            if ($sentInfo) {
+                $sentPr = Discussion::where('res_id', $sentInfo['uid'])
+                    ->whereNull('parent')
+                    ->select('title', 'children_count', 'editor_uid')
+                    ->orderBy('created_at', 'desc')->get();
+                if (count($sentPr) > 0) {
                     $output[] = [
                         'sentence' => [
                             'book' => $sentInfo->book_id,
@@ -288,11 +290,9 @@ class DiscussionController extends Controller
                         'pr' => $sentPr,
                     ];
                 }
-
             }
-
         }
-        return $this->ok(['rows'=>$output,'count'=>count($output)]);
+        return $this->ok(['rows' => $output, 'count' => count($output)]);
     }
     /**
      * Store a newly created resource in storage.
@@ -303,52 +303,52 @@ class DiscussionController extends Controller
     public function store(Request $request)
     {
         $user = AuthApi::current($request);
-        if(!$user){
-            Log::error('discussion store auth failed {request}',['request'=>$request]);
-            return $this->error(__('auth.failed'),[401],401);
+        if (!$user) {
+            Log::error('discussion store auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), [401], 401);
         }
         //
         // validate
         // read more on validation at http://laravel.com/docs/validation
 
-        if($request->has('parent')){
+        if ($request->has('parent')) {
             $rules = [];
             $parentInfo = Discussion::find($request->get('parent'));
-            if(!$parentInfo){
+            if (!$parentInfo) {
                 return $this->error('no record');
             }
-        }else{
+        } else {
             $rules = array(
-            'res_id' => 'required',
-            'res_type' => 'required',
-            'title' => 'required',
-        );
+                'res_id' => 'required',
+                'res_type' => 'required',
+                'title' => 'required',
+            );
         }
 
         $validated = $request->validate($rules);
 
         $discussion = new Discussion;
-        if($request->has('parent')){
+        if ($request->has('parent')) {
             $discussion->res_id = $parentInfo->res_id;
             $discussion->res_type = $parentInfo->res_type;
-        }else{
+        } else {
             $discussion->res_id = $request->get('res_id');
             $discussion->res_type = $request->get('res_type');
         }
-        $discussion->type = $request->get('type','discussion');
+        $discussion->type = $request->get('type', 'discussion');
         $discussion->tpl_id = $request->get('tpl_id');
-        $discussion->title = $request->get('title',null);
-        $discussion->content = $request->get('content',null);
-        $discussion->content_type = $request->get('content_type',"markdown");
-        $discussion->parent = $request->get('parent',null);
+        $discussion->title = $request->get('title', null);
+        $discussion->content = $request->get('content', null);
+        $discussion->content_type = $request->get('content_type', "markdown");
+        $discussion->parent = $request->get('parent', null);
         $discussion->editor_uid = $user['user_uid'];
         $discussion->save();
         //更新parent children_count
-        if($request->has('parent')){
-            $parentInfo->increment('children_count',1);
+        if ($request->has('parent')) {
+            $parentInfo->increment('children_count', 1);
             $parentInfo->save();
         }
-        Mq::publish('discussion',new DiscussionResource($discussion));
+        Mq::publish('discussion', new DiscussionResource($discussion));
 
         return $this->ok(new DiscussionResource($discussion));
     }
@@ -363,10 +363,9 @@ class DiscussionController extends Controller
     {
         //
         return $this->ok(new DiscussionResource($discussion));
-
     }
 
-        /**
+    /**
      * 获取discussion 锚点的数据。以句子为最小单位,逐词解析也要显示单词所在的句子
      *
      * @param  string  $id
@@ -376,37 +375,36 @@ class DiscussionController extends Controller
     {
         //
         $discussion = Discussion::find($id);
+        $content = '';
         switch ($discussion->res_type) {
             case 'wbw':
                 # 从逐词解析表获取逐词解析数据
-                $wbw = Wbw::where('uid',$discussion->res_id)->first();
-                if(!$wbw){
+                $wbw = Wbw::where('uid', $discussion->res_id)->first();
+                if (!$wbw) {
                     return $this->error('no wbw data');
                 }
-                $wbwBlock = WbwBlock::where('uid',$wbw->block_uid)->first();
-                if(!$wbwBlock){
+                $wbwBlock = WbwBlock::where('uid', $wbw->block_uid)->first();
+                if (!$wbwBlock) {
                     return $this->error('no wbwBlock data');
                 }
-                $sent = PaliSentence::where('book',$wbw->book_id)
-                                    ->where('paragraph',$wbw->paragraph)
-                                    ->where('word_begin','<=',$wbw->wid)
-                                    ->where('word_end','>=',$wbw->wid)
-                                    ->first();
-                if(!$sent){
+                $sent = PaliSentence::where('book', $wbw->book_id)
+                    ->where('paragraph', $wbw->paragraph)
+                    ->where('word_begin', '<=', $wbw->wid)
+                    ->where('word_end', '>=', $wbw->wid)
+                    ->first();
+                if (!$sent) {
                     return $this->error('no sent data');
                 }
                 $sentId = "{$sent['book']}-{$sent['paragraph']}-{$sent['word_begin']}-{$sent['word_end']}";
                 $channel = $wbwBlock->channel_uid;
-                $content = MdRender::render("{{".$sentId."}}",[$channel]);
-                return $this->ok($content);
+                $content = MdRender::render("{{" . $sentId . "}}", [$channel]);
                 break;
 
             default:
                 # code...
                 break;
         }
-        return $this->ok();
-
+        return $this->ok($content);
     }
     /**
      * Update the specified resource in storage.
@@ -419,51 +417,50 @@ class DiscussionController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),[403],403);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [403], 403);
         }
         //
         $isManager = false;
         $isResManager = false;
-        if($discussion->editor_uid === $user['user_uid']){
+        if ($discussion->editor_uid === $user['user_uid']) {
             $isManager = true;
-        }else{
+        } else {
             //查看是否是资源拥有者
-            if($discussion->res_type === 'sentence'){
+            if ($discussion->res_type === 'sentence') {
                 $res = Sentence::find($discussion->res_id);
-                if($res){
+                if ($res) {
                     $channelId = $res->channel_uid;
                 }
-            }else if($discussion->res_type === 'wbw'){
-                $res = Wbw::where('uid',$discussion->res_id)->first();
-                if($res){
-                    $block = WbwBlock::where('uid',$res->block_uid)->first();
-                    if($block){
+            } else if ($discussion->res_type === 'wbw') {
+                $res = Wbw::where('uid', $discussion->res_id)->first();
+                if ($res) {
+                    $block = WbwBlock::where('uid', $res->block_uid)->first();
+                    if ($block) {
                         $channelId = $block->channel_uid;
                     }
                 }
             }
-            if(isset($channelId)){
+            if (isset($channelId)) {
                 $channel = Channel::find($channelId);
-                if($channel){
-                    $isResManager = ChannelApi::userCanEdit($user['user_uid'],$channelId);
+                if ($channel) {
+                    $isResManager = ChannelApi::userCanEdit($user['user_uid'], $channelId);
                 }
             }
         }
-        if(!$isManager && !$isResManager){
-            return $this->error(__('auth.failed'),[403],403);
+        if (!$isManager && !$isResManager) {
+            return $this->error(__('auth.failed'), [403], 403);
         }
 
-        $discussion->title = $request->get('title',null);
-        $discussion->content = $request->get('content',null);
-        $discussion->status = $request->get('status','active');
-        if($request->has('type')){
+        $discussion->title = $request->get('title', null);
+        $discussion->content = $request->get('content', null);
+        $discussion->status = $request->get('status', 'active');
+        if ($request->has('type')) {
             $discussion->type = $request->get('type');
         }
         //$discussion->editor_uid = $user['user_uid'];
         $discussion->save();
         return $this->ok(new DiscussionResource($discussion));
-
     }
 
     /**
@@ -472,16 +469,16 @@ class DiscussionController extends Controller
      * @param  \App\Models\Discussion  $discussion
      * @return \Illuminate\Http\Response
      */
-    public function destroy(Request $request,Discussion $discussion)
+    public function destroy(Request $request, Discussion $discussion)
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),[401],401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [401], 401);
         }
         //TODO 其他有权限的人也可以删除
-        if($discussion->editor_uid !== $user['user_uid']){
-            return $this->error(__('auth.failed'),[403],403);
+        if ($discussion->editor_uid !== $user['user_uid']) {
+            return $this->error(__('auth.failed'), [403], 403);
         }
         $delete = $discussion->delete();
         return $this->ok($delete);

+ 240 - 200
api-v8/app/Http/Controllers/SentenceController.php

@@ -19,10 +19,12 @@ use App\Http\Api\ShareApi;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\PaliTextApi;
 use App\Http\Api\Mq;
-
+use App\Models\AccessToken;
 use App\Tools\RedisClusters;
 use App\Tools\OpsLog;
 
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
 
 class SentenceController extends Controller
 {
@@ -34,60 +36,73 @@ class SentenceController extends Controller
     public function index(Request $request)
     {
         $user = AuthApi::current($request);
-        $result=false;
-		$indexCol = ['id','uid','book_id','paragraph',
-                    'word_start','word_end','content','content_type',
-                    'channel_uid','editor_uid','fork_at','acceptor_uid','pr_edit_at','updated_at'];
+        $result = false;
+        $indexCol = [
+            'id',
+            'uid',
+            'book_id',
+            'paragraph',
+            'word_start',
+            'word_end',
+            'content',
+            'content_type',
+            'channel_uid',
+            'editor_uid',
+            'fork_at',
+            'acceptor_uid',
+            'pr_edit_at',
+            'updated_at'
+        ];
 
-		switch ($request->get('view')) {
+        switch ($request->get('view')) {
             case 'public':
                 //获取全部公开的译文
                 //首先获取某个类型的 channel 列表
                 $channels = [];
-                $channel_type = $request->get('channel_type','translation');
-                if($channel_type === "original"){
+                $channel_type = $request->get('channel_type', 'translation');
+                if ($channel_type === "original") {
                     $pali_channel = ChannelApi::getSysChannel("_System_Pali_VRI_");
-                    if($pali_channel !== false){
+                    if ($pali_channel !== false) {
                         $channels[] = $pali_channel;
                     }
-                }else{
-                    $channelList = Channel::where('type',$channel_type)
-                                              ->where('status',30)
-                                              ->select('uid')->get();
+                } else {
+                    $channelList = Channel::where('type', $channel_type)
+                        ->where('status', 30)
+                        ->select('uid')->get();
                     foreach ($channelList as $channel) {
                         # code...
                         $channels[] = $channel->uid;
                     }
                 }
                 $table = Sentence::select($indexCol)
-                                  ->whereIn('channel_uid',$channels)
-                                  ->where('updated_at','>',$request->get('updated_after','1970-1-1'));
+                    ->whereIn('channel_uid', $channels)
+                    ->where('updated_at', '>', $request->get('updated_after', '1970-1-1'));
                 break;
             case 'fulltext':
-                if(isset($_COOKIE['user_uid'])){
+                if (isset($_COOKIE['user_uid'])) {
                     $userUid = $_COOKIE['user_uid'];
                 }
                 $key = $request->get('key');
-                if(empty($key)){
-			        return $this->error("没有关键词");
+                if (empty($key)) {
+                    return $this->error("没有关键词");
                 }
                 $table = Sentence::select($indexCol)
-								  ->where('content','like', '%'.$key.'%')
-                                  ->where('editor_uid',$userUid);
+                    ->where('content', 'like', '%' . $key . '%')
+                    ->where('editor_uid', $userUid);
 
                 break;
             case 'channel':
                 //句子编号列表在某个channel下的全部内容
-                $sent = explode(',',$request->get('sentence')) ;
+                $sent = explode(',', $request->get('sentence'));
                 $query = [];
                 foreach ($sent as $value) {
                     # code...
-                    $ids = explode('-',$value);
+                    $ids = explode('-', $value);
                     $query[] = $ids;
                 }
                 $table = Sentence::select($indexCol)
-                                ->where('channel_uid', $request->get('channel'))
-                                ->whereIns(['book_id','paragraph','word_start','word_end'],$query);
+                    ->where('channel_uid', $request->get('channel'))
+                    ->whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query);
                 break;
             case 'sent-can-read':
                 /**
@@ -95,20 +110,20 @@ class SentenceController extends Controller
                  */
                 //获取用户有阅读权限的所有channel
                 //全网公开
-                $type = $request->get('type','translation');
-                $channelTable = Channel::where("type",$type)->select(['uid','name']);
-                $channelPub = $channelTable->where('status',30)->get();
+                $type = $request->get('type', 'translation');
+                $channelTable = Channel::where("type", $type)->select(['uid', 'name']);
+                $channelPub = $channelTable->where('status', 30)->get();
 
                 $user = AuthApi::current($request);
-                $channelShare=array();
-                $channelMy=array();
-                if($user){
+                $channelShare = array();
+                $channelMy = array();
+                if ($user) {
                     //自己的
-                    $channelMy = Channel::where('owner_uid',$user['user_uid'])
-                                        ->where('type',$type)
-                                        ->get();
+                    $channelMy = Channel::where('owner_uid', $user['user_uid'])
+                        ->where('type', $type)
+                        ->get();
                     //协作
-                    $channelShare = ShareApi::getResList($user['user_uid'],2);
+                    $channelShare = ShareApi::getResList($user['user_uid'], 2);
                 }
                 $channelCanRead = [];
                 foreach ($channelPub as $key => $value) {
@@ -119,13 +134,13 @@ class SentenceController extends Controller
                     ];
                 }
                 foreach ($channelShare as $key => $value) {
-                    if($value['type'] === $type){
+                    if ($value['type'] === $type) {
                         $channelCanRead[$value['res_id']] = [
                             'id' => $value['res_id'],
                             'role' => 'member',
                             'name' => $value['res_title'],
                         ];
-                        if($value['power']>=20){
+                        if ($value['power'] >= 20) {
                             $channelCanRead[$value['res_id']]['role'] = "editor";
                         }
                     }
@@ -138,107 +153,110 @@ class SentenceController extends Controller
                     ];
                 }
                 $channels = [];
-                $excludeChannels = explode(',',$request->get('excludes')) ;
+                $excludeChannels = explode(',', $request->get('excludes'));
 
                 foreach ($channelCanRead as $key => $value) {
                     # code...
-                    if(!in_array($key,$excludeChannels)){
+                    if (!in_array($key, $excludeChannels)) {
                         $channels[] = $key;
                     }
                 }
-                $sent = explode('-',$request->get('sentence')) ;
+                $sent = explode('-', $request->get('sentence'));
                 $table = Sentence::select($indexCol)
-                                ->whereIn('channel_uid', $channels)
-                                ->where('ver','>',1)
-                                ->where('book_id',$sent[0])
-                                ->where('paragraph',$sent[1])
-                                ->where('word_start',$sent[2])
-                                ->where('word_end',$sent[3]);
+                    ->whereIn('channel_uid', $channels)
+                    ->where('ver', '>', 1)
+                    ->where('book_id', $sent[0])
+                    ->where('paragraph', $sent[1])
+                    ->where('word_start', $sent[2])
+                    ->where('word_end', $sent[3]);
                 break;
             case 'chapter':
-                $chapter =  PaliTextApi::getChapterStartEnd($request->get('book'),$request->get('para'));
-                $table = Sentence::where('ver','>',1)
-                                    ->where('book_id',$request->get('book'))
-                                    ->whereBetween('paragraph',$chapter)
-                                    ->whereIn('channel_uid',explode(',',$request->get('channels')));
+                $chapter =  PaliTextApi::getChapterStartEnd($request->get('book'), $request->get('para'));
+                $table = Sentence::where('ver', '>', 1)
+                    ->where('book_id', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->whereIn('channel_uid', explode(',', $request->get('channels')));
                 break;
             case 'paragraph':
-                $table = Sentence::where('ver','>',1)
-                                    ->where('book_id',$request->get('book'))
-                                    ->whereIn('paragraph',explode(',',$request->get('para')))
-                                    ->whereIn('channel_uid',explode(',',$request->get('channels')))
-                                    ->orderBy('book_id')->orderBy('paragraph')->orderBy('word_start');
+                $table = Sentence::where('ver', '>', 1)
+                    ->where('book_id', $request->get('book'))
+                    ->whereIn('paragraph', explode(',', $request->get('para')))
+                    ->whereIn('channel_uid', explode(',', $request->get('channels')))
+                    ->orderBy('book_id')->orderBy('paragraph')->orderBy('word_start');
                 break;
             case 'my-edit':
                 //我编辑的
-                if(!$user){
-                    return $this->error(__('auth.failed'),401,401);
+                if (!$user) {
+                    return $this->error(__('auth.failed'), 401, 401);
                 }
-                $table = Sentence::where('editor_uid',$user['user_uid'])
-                                ->where('ver','>',1);
+                $table = Sentence::where('editor_uid', $user['user_uid'])
+                    ->where('ver', '>', 1);
+                break;
+            default:
+                # code...
                 break;
-			default:
-				# code...
-				break;
-		}
-        if(!empty($request->get("key"))){
-            $table = $table->where('content','like', '%'.$request->get("key").'%');
+        }
+        if (!empty($request->get("key"))) {
+            $table = $table->where('content', 'like', '%' . $request->get("key") . '%');
         }
 
         $count = $table->count();
-        if($request->get('strlen',false)){
+        if ($request->get('strlen', false)) {
             $totalStrLen = $table->sum('strlen');
         }
-        if($request->get('view') !== 'paragraph'){
-            $table = $table->orderBy($request->get('order','updated_at'),
-                                    $request->get('dir','desc'));
+        if ($request->get('view') !== 'paragraph') {
+            $table = $table->orderBy(
+                $request->get('order', 'updated_at'),
+                $request->get('dir', 'desc')
+            );
         }
 
-        $table = $table->skip($request->get("offset",0))
-                       ->take($request->get('limit',1000));
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
         $result = $table->get();
 
-		if($result){
-            $output = ["count"=>$count];
-            if($request->get('view') === 'sent-can-read' ||
+        if ($result) {
+            $output = ["count" => $count];
+            if (
+                $request->get('view') === 'sent-can-read' ||
                 $request->get('view') === 'channel' ||
                 $request->get('view') === 'chapter' ||
                 $request->get('view') === 'paragraph' ||
                 $request->get('view') === 'my-edit'
-                ){
+            ) {
                 $output["rows"] = SentResource::collection($result);
-            }else{
+            } else {
                 $output["rows"] = $result;
             }
-            if(isset($totalStrLen)){
+            if (isset($totalStrLen)) {
                 $output['total_strlen'] = $totalStrLen;
             }
             return $this->ok($output);
-
-		}else{
-			return $this->error("没有查询到数据");
-		}
+        } else {
+            return $this->error("没有查询到数据");
+        }
     }
     /**
      * 用channel 和句子编号列表查询句子
      */
-    public function sent_in_channel(Request $request){
-        $sent = $request->get('sentences') ;
+    public function sent_in_channel(Request $request)
+    {
+        $sent = $request->get('sentences');
         $query = [];
         foreach ($sent as $value) {
             # code...
-            $ids = explode('-',$value);
-            if(count($ids)===4){
+            $ids = explode('-', $value);
+            if (count($ids) === 4) {
                 $query[] = $ids;
             }
         }
-        $table = Sentence::select(['id','book_id','paragraph','word_start','word_end','content','channel_uid','updated_at'])
-                        ->where('channel_uid', $request->get('channel'))
-                        ->whereIns(['book_id','paragraph','word_start','word_end'],$query);
+        $table = Sentence::select(['id', 'book_id', 'paragraph', 'word_start', 'word_end', 'content', 'channel_uid', 'updated_at'])
+            ->where('channel_uid', $request->get('channel'))
+            ->whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query);
         $result = $table->get();
-        if($result){
-            return $this->ok(["rows"=>$result,"count"=>count($result)]);
-        }else{
+        if ($result) {
+            return $this->ok(["rows" => $result, "count" => count($result)]);
+        } else {
             return $this->error("没有查询到数据");
         }
     }
@@ -262,113 +280,132 @@ class SentenceController extends Controller
     {
         //鉴权
         $user = AuthApi::current($request);
-        if(!$user ){
+        if (!$user) {
             //未登录用户
-            return $this->error(__('auth.failed'),401,401);
-        }
-        $channel = Channel::where('uid',$request->get('channel'))->first();
-        if(!$channel){
-            return $this->error(__('auth.failed'),403,403);
+            return $this->error(__('auth.failed'), 401, 401);
         }
-        if($channel->owner_uid !== $user["user_uid"]){
-            //判断是否为协作
-            $power = ShareApi::getResPower($user["user_uid"],$channel->uid,2);
-            if($power < 20){
-                return $this->error(__('auth.failed'),403,403);
-            }
+        if (!$request->has('sentences')) {
+            return $this->error('no date', 200, 200);
         }
-        $sentFirst=null;
+
+        $sentFirst = null;
         $changedSent = [];
         foreach ($request->get('sentences') as $key => $sent) {
-            # code...
-            if($sentFirst === null){
+            # 权限
+            $channelId = $sent['channel_uid'];
+            $channel = Channel::where('uid', $channelId)->first();
+            if (!$channel) {
+                continue;
+            }
+            if ($channel->owner_uid !== $user["user_uid"]) {
+                //判断是否为协作
+                $power = ShareApi::getResPower($user["user_uid"], $channel->uid, 2);
+                if ($power < 20) {
+                    //判断token
+                    if (isset($sent['token'])) {
+                        $key = AccessToken::where('res_id', $channelId)->value('token');
+                        $jwt = JWT::decode($sent['token'], new Key($key, 'HS512'));
+                        if ($jwt->book !== $sent['book_id']) {
+                            continue;
+                        }
+                    } else {
+                        continue;
+                    }
+                }
+            }
+
+            if ($sentFirst === null) {
                 $sentFirst = $sent;
             }
             $row = Sentence::firstOrNew([
-                "book_id"=>$sent['book_id'],
-                "paragraph"=>$sent['paragraph'],
-                "word_start"=>$sent['word_start'],
-                "word_end"=>$sent['word_end'],
-                "channel_uid"=>$channel->uid,
-            ],[
-                "id"=>app('snowflake')->id(),
-                "uid"=>Str::uuid(),
+                "book_id" => $sent['book_id'],
+                "paragraph" => $sent['paragraph'],
+                "word_start" => $sent['word_start'],
+                "word_end" => $sent['word_end'],
+                "channel_uid" => $channel->uid,
+            ], [
+                "id" => app('snowflake')->id(),
+                "uid" => Str::uuid(),
             ]);
             $row->content = $sent['content'];
-            if(isset($sent['content_type']) && !empty($sent['content_type'])){
+            if (isset($sent['content_type']) && !empty($sent['content_type'])) {
                 $row->content_type = $sent['content_type'];
             }
-            $row->strlen = mb_strlen($sent['content'],"UTF-8");
+            $row->strlen = mb_strlen($sent['content'], "UTF-8");
             $row->language = $channel->lang;
             $row->status = $channel->status;
-            if($request->has('copy')){
+            if ($request->has('copy')) {
                 //复制句子,保留原作者信息
                 $row->editor_uid = $sent["editor_uid"];
                 $row->acceptor_uid = $user["user_uid"];
                 $row->pr_edit_at = $sent["updated_at"];
-                if($request->has('fork_from')){
+                if ($request->has('fork_from')) {
                     $row->fork_at = now();
                 }
-            }else{
+            } else {
                 $row->editor_uid = $user["user_uid"];
                 $row->acceptor_uid = null;
                 $row->pr_edit_at = null;
             }
-            $row->create_time = time()*1000;
-            $row->modify_time = time()*1000;
+            $row->create_time = time() * 1000;
+            $row->modify_time = time() * 1000;
             $row->save();
 
             $changedSent[] = $row->uid;
 
             //保存历史记录
-            if($request->has('copy')){
-                $fork_from = $request->get('fork_from',null);
-                $this->saveHistory($row->uid,
-                                $sent["editor_uid"],
-                                $sent['content'],
-                                $user["user_uid"],
-                                $fork_from);
-            }else{
-                $this->saveHistory($row->uid,$user["user_uid"],$sent['content'],$user["user_uid"]);
+            if ($request->has('copy')) {
+                $fork_from = $request->get('fork_from', null);
+                $this->saveHistory(
+                    $row->uid,
+                    $sent["editor_uid"],
+                    $sent['content'],
+                    $user["user_uid"],
+                    $fork_from
+                );
+            } else {
+                $this->saveHistory($row->uid, $user["user_uid"], $sent['content'], $user["user_uid"]);
             }
             //清除缓存
             $sentId = "{$sent['book_id']}-{$sent['paragraph']}-{$sent['word_start']}-{$sent['word_end']}";
             $hKey = "/sentence/res-count/{$sentId}/";
             Redis::del($hKey);
         }
-        if($sentFirst !== null){
-            Mq::publish('progress',['book'=>$sentFirst['book_id'],
-                                'para'=>$sentFirst['paragraph'],
-                                'channel'=>$channel->uid,
-                                ]);
+        if ($sentFirst !== null) {
+            Mq::publish('progress', [
+                'book' => $sentFirst['book_id'],
+                'para' => $sentFirst['paragraph'],
+                'channel' => $channel->uid,
+            ]);
         }
 
         $result = Sentence::whereIn('uid', $changedSent)->get();
         return $this->ok([
-            'rows'=>SentResource::collection($result),
-            'count'=>count($result)
+            'rows' => SentResource::collection($result),
+            'count' => count($result)
         ]);
     }
 
-    private function saveHistory($uid,$editor,$content,$user_uid=null,$fork_from=null,$pr_from=null){
+    private function saveHistory($uid, $editor, $content, $user_uid = null, $fork_from = null, $pr_from = null)
+    {
         $newHis = new SentHistory();
         $newHis->id = app('snowflake')->id();
         $newHis->sent_uid = $uid;
         $newHis->user_uid = $editor;
-        if(empty($content)){
+        if (empty($content)) {
             $newHis->content = "";
-        }else{
+        } else {
             $newHis->content = $content;
         }
-        if($fork_from){
+        if ($fork_from) {
             $newHis->fork_from = $fork_from;
             $newHis->accepter_uid = $user_uid;
         }
-        if($pr_from){
+        if ($pr_from) {
             $newHis->pr_from = $pr_from;
             $newHis->accepter_uid = $user_uid;
         }
-        $newHis->create_time = time()*1000;
+        $newHis->create_time = time() * 1000;
         $newHis->save();
     }
     /**
@@ -394,51 +431,51 @@ class SentenceController extends Controller
     public function update(Request $request,  $id)
     {
         //
-        $param = \explode('_',$id);
+        $param = \explode('_', $id);
 
         //鉴权
         $user = AuthApi::current($request);
-        if(!$user){
+        if (!$user) {
             //未登录鉴权失败
-            return $this->error(__('auth.failed'),403,403);
+            return $this->error(__('auth.failed'), 403, 403);
         }
-        $channel = Channel::where('uid',$param[4])->first();
-        if(!$channel){
+        $channel = Channel::where('uid', $param[4])->first();
+        if (!$channel) {
             return $this->error("not found channel");
         }
-        if($channel->owner_uid !== $user["user_uid"]){
+        if ($channel->owner_uid !== $user["user_uid"]) {
             // 判断是否为协作
-            $power = ShareApi::getResPower($user["user_uid"],$channel->uid,2);
-            if($power < 20){
-                return $this->error(__('auth.failed'),403,403);
+            $power = ShareApi::getResPower($user["user_uid"], $channel->uid, 2);
+            if ($power < 20) {
+                return $this->error(__('auth.failed'), 403, 403);
             }
         }
 
         $sent = Sentence::firstOrNew([
-            "book_id"=>$param[0],
-            "paragraph"=>$param[1],
-            "word_start"=>$param[2],
-            "word_end"=>$param[3],
-            "channel_uid"=>$param[4],
-        ],[
-            "id"=>app('snowflake')->id(),
-            "uid"=>Str::orderedUuid(),
-            "create_time"=>time()*1000,
+            "book_id" => $param[0],
+            "paragraph" => $param[1],
+            "word_start" => $param[2],
+            "word_end" => $param[3],
+            "channel_uid" => $param[4],
+        ], [
+            "id" => app('snowflake')->id(),
+            "uid" => Str::orderedUuid(),
+            "create_time" => time() * 1000,
         ]);
         $sent->content = $request->get('content');
-        if($request->has('contentType')){
+        if ($request->has('contentType')) {
             $sent->content_type = $request->get('contentType');
         }
         $sent->language = $channel->lang;
         $sent->status = $channel->status;
-        $sent->strlen = mb_strlen($request->get('content'),"UTF-8");
-        $sent->modify_time = time()*1000;
-        if($request->has('prEditor')){
+        $sent->strlen = mb_strlen($request->get('content'), "UTF-8");
+        $sent->modify_time = time() * 1000;
+        if ($request->has('prEditor')) {
             $realEditor = $request->get('prEditor');
             $sent->acceptor_uid = $user["user_uid"];
             $sent->pr_edit_at = $request->get('prEditAt');
             $sent->pr_id = $request->get('prId');
-        }else{
+        } else {
             $realEditor = $user["user_uid"];
             $sent->acceptor_uid = null;
             $sent->pr_edit_at = null;
@@ -451,33 +488,35 @@ class SentenceController extends Controller
         $sentId = "{$sent['book_id']}-{$sent['paragraph']}-{$sent['word_start']}-{$sent['word_end']}";
         $hKey = "/sentence/res-count/{$sentId}/";
         Redis::del($hKey);
-        OpsLog::debug($user["user_uid"],$sent);
+        OpsLog::debug($user["user_uid"], $sent);
 
         //清除cache
         $channelId = $param[4];
         $currSentId = "{$param[0]}-{$param[1]}-{$param[2]}-{$param[3]}";
         RedisClusters::forget("/sent/{$channelId}/{$currSentId}");
         //保存历史记录
-        if($request->has('prEditor')){
-            $this->saveHistory($sent->uid,
-                            $realEditor,
-                            $request->get('content'),
-                            $user["user_uid"],
-                            null,
-                            $request->get('prUuid'),
-                        );
-        }else{
-            $this->saveHistory($sent->uid,$realEditor,$request->get('content'));
+        if ($request->has('prEditor')) {
+            $this->saveHistory(
+                $sent->uid,
+                $realEditor,
+                $request->get('content'),
+                $user["user_uid"],
+                null,
+                $request->get('prUuid'),
+            );
+        } else {
+            $this->saveHistory($sent->uid, $realEditor, $request->get('content'));
         }
 
-        Mq::publish('progress',['book'=>$param[0],
-                            'para'=>$param[1],
-                            'channel'=>$channelId,
-                            ]);
-        Mq::publish('content',new SentResource($sent));
+        Mq::publish('progress', [
+            'book' => $param[0],
+            'para' => $param[1],
+            'channel' => $channelId,
+        ]);
+        Mq::publish('content', new SentResource($sent));
 
-        if($channel->type === 'nissaya' && $sent->content_type === 'json'){
-            $this->updateWbwAnalyses($sent->content,$channel->lang,$user["user_id"]);
+        if ($channel->type === 'nissaya' && $sent->content_type === 'json') {
+            $this->updateWbwAnalyses($sent->content, $channel->lang, $user["user_id"]);
         }
 
         return $this->ok(new SentResource($sent));
@@ -494,15 +533,16 @@ class SentenceController extends Controller
         //
     }
 
-    private function updateWbwAnalyses($data,$lang,$editorId){
+    private function updateWbwAnalyses($data, $lang, $editorId)
+    {
         $wbwData = json_decode($data);
         $currWbwId = 0;
         $prefix = 'wbw-preference';
         foreach ($wbwData as $key => $word) {
             # code...
-            if(count($word->sn) === 1 ){
+            if (count($word->sn) === 1) {
                 $currWbwId = $word->uid;
-                WbwAnalysis::where('wbw_id',$word->uid)->delete();
+                WbwAnalysis::where('wbw_id', $word->uid)->delete();
             }
             $newData = [
                 'wbw_id' => $currWbwId,
@@ -514,29 +554,29 @@ class SentenceController extends Controller
                 'data' => '',
                 'confidence' => 100,
                 'lang' => $lang,
-                'editor_id'=>$editorId,
-                'created_at'=>now(),
-                'updated_at'=>now()
+                'editor_id' => $editorId,
+                'created_at' => now(),
+                'updated_at' => now()
             ];
             $newData['type'] = 3;
-            if(!empty($word->meaning->value)){
+            if (!empty($word->meaning->value)) {
                 $newData['data'] = $word->meaning->value;
                 WbwAnalysis::insert($newData);
-                RedisClusters::put("{$prefix}/{$word->real->value}/3/{$editorId}",$word->meaning->value);
-                RedisClusters::put("{$prefix}/{$word->real->value}/3/0",$word->meaning->value);
+                RedisClusters::put("{$prefix}/{$word->real->value}/3/{$editorId}", $word->meaning->value);
+                RedisClusters::put("{$prefix}/{$word->real->value}/3/0", $word->meaning->value);
             }
-            if(isset($word->factors) && isset($word->factorMeaning)){
-                $factors = explode('+',str_replace('-','+',$word->factors->value));
-                $factorMeaning = explode('+',str_replace('-','+',$word->factorMeaning->value));
+            if (isset($word->factors) && isset($word->factorMeaning)) {
+                $factors = explode('+', str_replace('-', '+', $word->factors->value));
+                $factorMeaning = explode('+', str_replace('-', '+', $word->factorMeaning->value));
                 foreach ($factors as $key => $factor) {
-                    if(isset($factorMeaning[$key])){
-                        if(!empty($factorMeaning[$key])){
+                    if (isset($factorMeaning[$key])) {
+                        if (!empty($factorMeaning[$key])) {
                             $newData['wbw_word'] = $factor;
                             $newData['data'] = $factorMeaning[$key];
                             $newData['type'] = 5;
                             WbwAnalysis::insert($newData);
-                            RedisClusters::put("{$prefix}/{$factor}/5/{$editorId}",$factorMeaning[$key]);
-                            RedisClusters::put("{$prefix}/{$factor}/5/0",$factorMeaning[$key]);
+                            RedisClusters::put("{$prefix}/{$factor}/5/{$editorId}", $factorMeaning[$key]);
+                            RedisClusters::put("{$prefix}/{$factor}/5/0", $factorMeaning[$key]);
                         }
                     }
                 }

+ 0 - 2
api-v8/app/Http/Controllers/TaskController.php

@@ -14,7 +14,6 @@ use App\Http\Resources\TaskResource;
 use App\Http\Api\AuthApi;
 use App\Http\Api\StudioApi;
 use App\Http\Api\TaskApi;
-use App\Http\Api\WatchApi;
 
 
 class TaskController extends Controller
@@ -27,7 +26,6 @@ class TaskController extends Controller
     public function index(Request $request)
     {
         //
-        Log::debug('task controller start');
         $user = AuthApi::current($request);
         if (!$user) {
             Log::error('notification auth failed {request}', ['request' => $request]);

+ 30 - 0
api-v8/app/Http/Requests/StoreAiModelRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreAiModelRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 30 - 0
api-v8/app/Http/Requests/UpdateAiModelRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UpdateAiModelRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 19 - 0
api-v8/app/Http/Resources/AiModelResource.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class AiModelResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        return parent::toArray($request);
+    }
+}

+ 15 - 0
api-v8/app/Models/AiModel.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class AiModel extends Model
+{
+    use HasFactory;
+    protected $primaryKey = 'uid';
+    protected $casts = [
+        'uid' => 'string'
+    ];
+}

+ 12 - 4
api-v8/config/mint.php

@@ -100,21 +100,29 @@ return [
     ],
 
     'ai' => [
+        'assistant' => 'test161',
         'default' => 'kimi',
         'accounts' => [
             [
                 'name' => 'kimi',
-                'summary' => 'Moonshot AI 月之暗面',
+                'description' => 'Moonshot AI 月之暗面',
                 'api_url' => 'https://api.moonshot.cn/v1/chat/completions',
                 'model' => ['moonshot-v1-8k'],
-                'token' => 'sk-kwjHIMh3PoWwUwQyKdT3KHvNe8Es19SUiujGrxtH09uDQCui',
+                'token' => env('AI_API_TOKEN_MOONSHOT'),
             ],
             [
                 'name' => 'volcengine',
-                'summary' => '字节跳动AI引擎',
+                'description' => '字节跳动AI引擎',
                 'api_url' => 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
                 'model' => ['Doubao-lite-4k', 'Doubao-pro-4k'],
-                'token' => '647a23fe-60cd-4c07-b2a4-b11b57109f79',
+                'token' => env('AI_API_TOKEN_VOLCENGIN'),
+            ],
+            [
+                'name' => 'deepseek',
+                'description' => 'deepseek',
+                'api_url' => 'https://api.deepseek.com/chat/completions',
+                'model' => ['deepseek-chat'],
+                'token' => env('AI_API_TOKEN_DEEPSEEK'),
             ],
         ],
     ]

+ 14 - 15
api-v8/database/migrations/2024_10_24_124140_create_tasks_table.php

@@ -33,32 +33,31 @@ class CreateTasksTable extends Migration
             $table->uuid('id')->primary()->default(DB::raw('uuid_generate_v1mc()'));
             $table->string('title', 512)->index();
             $table->string('type', 32)->index()->default('project');
-            $table->string('category', 256)->nullable()->index()->comment('类别,分类 审稿,百科 等');
+            $table->string('category', 256)->nullable()->index()->comment('类别: 翻译,审稿,百科 等');
             $table->text('summary')->nullable();
             $table->text('description')->nullable();
             $table->integer('weight')->index()->default(0)->comment('权重');
+            $table->integer('progress')->index()->default(0)->comment('进度0-100');
             $table->uuid('parent_id')->index()->nullable();
             $table->jsonb('assignees_id')->index()->nullable()->comment('责任人');
-            $table->jsonb('roles')->index()->nullable();
-            $table->uuid('executor_id')->index()->nullable();
+            $table->jsonb('roles')->index()->nullable()->comment('对于领取任务的人的角色要求');
+            $table->uuid('executor_id')->index()->nullable()->comment('实际执行人');
             $table->uuid('executor_relation_task_id')->index()->nullable();
             $table->uuid('project_id')->index();
-            $table->uuid('pre_task_id')->index()->nullable();
-            $table->uuid('next_task_id')->index()->nullable();
-            $table->boolean('is_milestone')->index()->default(false);
-            $table->uuid('owner_id')->index();
+            $table->boolean('is_milestone')->index()->default(false)->comment('是否是里程碑');
+            $table->uuid('owner_id')->index()->comment('任务拥有者:用户或者team-space');
             $table->uuid('creator_id')->index();
             $table->uuid('editor_id')->index();
-            $table->integer('order')->index()->default(1);
+            $table->integer('order')->index()->default(1)->comment('拖拽排序顺序');
             $table->string('status', 32)->index()->default('pending');
             $table->boolean('closed_by_subtask')->default(true);
-            $table->timestamp('started_at')->nullable()->index();
-            $table->timestamp('finished_at')->nullable()->index();
-            $table->timestamp('begin_at')->nullable()->index();
-            $table->timestamp('end_at')->nullable()->index();
-            $table->boolean('plan_with_time')->index()->default(false);
-            $table->boolean('hide_description_before_begin')->index()->default(false); //在开始时间之前隐藏描述
-            $table->text('script')->nullable();
+            $table->timestamp('started_at')->nullable()->index()->comment('实际开始时间');
+            $table->timestamp('finished_at')->nullable()->index()->comment('实际结束时间');
+            $table->timestamp('begin_at')->nullable()->index()->comment('计划开始时间');
+            $table->timestamp('end_at')->nullable()->index()->comment('计划结束时间');
+            $table->boolean('plan_with_time')->index()->default(false)->comment('计划时间包含时间');
+            $table->boolean('hide_description_before_begin')->index()->default(false)->comment('在开始时间之前隐藏描述');
+            $table->text('script')->nullable()->comment('lua脚本');
             $table->timestamps();
         });
     }

+ 1 - 0
api-v8/database/migrations/2024_10_25_015946_create_projects_table.php

@@ -20,6 +20,7 @@ class CreateProjectsTable extends Migration
             $table->string('type', 32)->index()->default('normal');
             $table->text('description')->nullable();
             $table->integer('weight')->index()->default(0)->comment('权重');
+            $table->integer('progress')->index()->default(0)->comment('进度0-100');
             $table->jsonb('executors_id')->index()->nullable();
             $table->uuid('parent_id')->index()->nullable();
             $table->jsonb('path')->index()->nullable();

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

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateAiModelsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('ai_models', function (Blueprint $table) {
+            $table->id();
+            $table->uuid('uid')->unique();
+            $table->string('name', 64)->index();
+            $table->text('description')->nullable();
+            $table->string('url', 1024)->nullable()->index();
+            $table->string('model', 1024)->nullable()->index();
+            $table->string('key', 1024)->nullable();
+            $table->string('privacy', 32)->index()->default('private')->comment('隐私性:private|public');
+            $table->uuid('owner_id')->index()->comment('任务拥有者:用户或者team-space');
+            $table->uuid('editor_id')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('ai_models');
+    }
+}

+ 2 - 0
api-v8/routes/api.php

@@ -111,6 +111,7 @@ use App\Http\Controllers\SiteInfoController;
 use App\Http\Controllers\PaliBookCategoryController;
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\SearchWordSliceController;
+use App\Http\Controllers\AiModelController;
 
 
 
@@ -279,4 +280,5 @@ Route::group(['prefix' => 'v2'], function () {
     Route::apiResource('pali-book-category', PaliBookCategoryController::class);
     Route::apiResource('access-token', AccessTokenController::class);
     Route::apiResource('search-word-slice', SearchWordSliceController::class);
+    Route::apiResource('ai-model', AiModelController::class);
 });

+ 10 - 0
dashboard-v4/dashboard/src/Router.tsx

@@ -151,6 +151,10 @@ import StudioTaskList from "./pages/studio/task/tasks";
 import StudioTaskProjects from "./pages/studio/task/projects";
 import StudioTaskProject from "./pages/studio/task/project";
 
+import StudioAi from "./pages/studio/ai";
+import StudioAiModes from "./pages/studio/ai/models";
+import StudioAiModeEdit from "./pages/studio/ai/model_edit";
+
 import { ConfigProvider } from "antd";
 import { useAppSelector } from "./hooks";
 import { currTheme } from "./reducers/theme";
@@ -368,6 +372,12 @@ const Widget = () => {
               element={<StudioAnthologyEdit />}
             />
           </Route>
+          <Route path="ai" element={<StudioAi />}>
+            <Route path="models">
+              <Route path="list" element={<StudioAiModes />} />
+              <Route path=":modelId/edit" element={<StudioAiModeEdit />} />
+            </Route>
+          </Route>
           <Route path="setting" element={<StudioSetting />} />
 
           <Route path="exp" element={<StudioAnalysis />}>

+ 69 - 0
dashboard-v4/dashboard/src/components/ai/AiModelCreate.tsx

@@ -0,0 +1,69 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { post } from "../../request";
+import { useRef } from "react";
+import { IAiModelRequest, IAiModelResponse } from "../api/ai";
+
+interface IWidget {
+  studioName?: string;
+  onCreate?: Function;
+}
+const AiModelCreate = ({ studioName, onCreate }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/v2/ai-model`;
+        console.info("api request", url, values);
+        const res = await post<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+            formRef.current?.resetFields();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelCreate;

+ 110 - 0
dashboard-v4/dashboard/src/components/ai/AiModelEdit.tsx

@@ -0,0 +1,110 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { get, put } from "../../request";
+import { useRef } from "react";
+import { IAiModelRequest, IAiModelResponse } from "../api/ai";
+import Publicity from "../studio/Publicity";
+
+interface IWidget {
+  studioName?: string;
+  modelId?: string;
+  onChange?: Function;
+}
+const AiModelEdit = ({ studioName, modelId, onChange }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/v2/ai-model/${modelId}`;
+        console.info("api request", url, values);
+        const res = await put<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          onChange && onChange();
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const url = `/v2/ai-model/${modelId}`;
+        console.info("api request", url);
+        const res = await get<IAiModelResponse>(url);
+        console.info("api response", res);
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="url"
+          label={intl.formatMessage({ id: "forms.fields.url.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="model"
+          label={intl.formatMessage({ id: "forms.fields.model.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="key"
+          label={intl.formatMessage({ id: "forms.fields.key.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Publicity
+          name="privacy"
+          disable={["disable", "public_no_list", "blocked"]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({ id: "forms.fields.description.label" })}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelEdit;

+ 279 - 0
dashboard-v4/dashboard/src/components/ai/AiModelList.tsx

@@ -0,0 +1,279 @@
+import { Link } from "react-router-dom";
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Popover,
+  Typography,
+  Dropdown,
+  Modal,
+  message,
+  Tag,
+} from "antd";
+import { ActionType, ProList } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../request";
+
+import { RoleValueEnum } from "../../components/studio/table";
+import { IDeleteResponse } from "../../components/api/Article";
+import { useRef, useState } from "react";
+
+import { getSorterUrl } from "../../utils";
+import { IAiModel, IAiModelListResponse } from "../api/ai";
+import AiModelCreate from "./AiModelCreate";
+import PublicityIcon from "../studio/PublicityIcon";
+
+const { Text } = Typography;
+
+interface IWidget {
+  studioName?: string;
+}
+const AiModelList = ({ studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/group/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType>();
+
+  return (
+    <>
+      <ProList<IAiModel>
+        actionRef={ref}
+        onRow={(record) => ({
+          onClick: () => {},
+        })}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.name.label",
+            }),
+            dataIndex: "name",
+            key: "name",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (text, row, index, action) => {
+              return (
+                <div key={index}>
+                  <div>
+                    <Link to={`/studio/${studioName}/group/${row.uid}/show`}>
+                      {row.name}
+                    </Link>
+                  </div>
+                  <Text type="secondary"></Text>
+                </div>
+              );
+            },
+          },
+
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.description.label",
+            }),
+            dataIndex: "description",
+            key: "description",
+            search: false,
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.role.label",
+            }),
+            dataIndex: "role",
+            key: "role",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: RoleValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created_at",
+            width: 100,
+            search: false,
+            dataIndex: "created_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                menu={{
+                  items: [
+                    {
+                      key: "remove",
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "share":
+                        break;
+                      case "remove":
+                        showDeleteConfirm(row.uid, row.name);
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link
+                  to={`/studio/${studioName}/group/${row.uid}/edit`}
+                  target="_blank"
+                >
+                  {intl.formatMessage({
+                    id: "buttons.edit",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        metas={{
+          title: {
+            dataIndex: "name",
+            render(dom, entity, index, action, schema) {
+              return (
+                <Link to={`/studio/${studioName}/ai/models/${entity.uid}/edit`}>
+                  {entity.name}
+                </Link>
+              );
+            },
+          },
+          description: {
+            dataIndex: "url",
+          },
+          subTitle: {
+            render(dom, entity, index, action, schema) {
+              return <Tag>{entity.model}</Tag>;
+            },
+          },
+          content: {
+            render(dom, entity, index, action, schema) {
+              return entity.description;
+            },
+          },
+          avatar: {
+            render(dom, entity, index, action, schema) {
+              return <PublicityIcon value={entity.privacy} />;
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/ai-model?view=studio&name=${studioName}`;
+          const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += getSorterUrl(sorter);
+
+          console.info("api request", url);
+          const res = await get<IAiModelListResponse>(url);
+          console.info("api response", res);
+          return {
+            total: res.data.total,
+            succcess: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <AiModelCreate
+                studioName={studioName}
+                onCreate={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+      />
+    </>
+  );
+};
+
+export default AiModelList;

+ 41 - 0
dashboard-v4/dashboard/src/components/api/ai.ts

@@ -1,3 +1,8 @@
+import { IStudio } from "../auth/Studio";
+import { IUser } from "../auth/User";
+
+export type TPrivacy = "private" | "public";
+
 export interface IKimiResponse {
   id: string;
   object: string;
@@ -34,3 +39,39 @@ export interface IAiTranslateResponse {
   message: string;
   data: IKimiResponse;
 }
+
+export interface IAiModel {
+  uid: string;
+  name: string;
+  description?: string | null;
+  url?: string | null;
+  model?: string;
+  key?: string;
+  privacy: TPrivacy;
+  owner: IStudio;
+  editor: IUser;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IAiModelRequest {
+  name: string;
+  description?: string | null;
+  url?: string | null;
+  model?: string;
+  key?: string;
+  privacy: TPrivacy;
+  studio_name?: string;
+}
+
+export interface IAiModelListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAiModel[]; total: number };
+}
+
+export interface IAiModelResponse {
+  ok: boolean;
+  message: string;
+  data: IAiModel;
+}

+ 8 - 1
dashboard-v4/dashboard/src/components/api/task.ts

@@ -33,11 +33,18 @@ export interface IProject {
   description: string | null;
 }
 
-export type TTaskCategory = "translate" | "vocabulary" | "team";
+export type TTaskCategory =
+  | "translate"
+  | "vocabulary"
+  | "team"
+  | "review"
+  | "proofread";
 export const ATaskCategory: TTaskCategory[] = [
   "translate",
   "vocabulary",
   "team",
+  "review",
+  "proofread",
 ];
 export interface ITaskData {
   id: string;

+ 43 - 0
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 { IPayload, ITokenCreate, ITokenCreateResponse } from "../api/token";
 
 interface IWidget {
   type?: ArticleType;
@@ -71,6 +72,7 @@ const TypePaliWidget = ({
   const [tplOpen, setTplOpen] = useState(false);
   const user = useAppSelector(currentUser);
   const channels = channelId?.split("_");
+  const _id = articleId?.split("-");
 
   const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
 
@@ -236,6 +238,37 @@ const TypePaliWidget = ({
     }
   }
 
+  const getAccessToken = async () => {
+    if (!channels || !_id || _id.length < 2) {
+      console.error(
+        "channels or book or para is undefined",
+        channels,
+        book,
+        para
+      );
+      return null;
+    }
+    const _book = _id[0];
+    const _para = _id[1];
+    let payload: IPayload[] = [];
+    payload.push({
+      res_id: channels[0],
+      res_type: "channel",
+      book: parseInt(_book),
+      para_start: parseInt(_para),
+      para_end: parseInt(_para) + 100,
+    });
+    const url = "/v2/access-token";
+    const values = { payload: payload };
+    console.info("token api request", url, values);
+    const res = await post<ITokenCreate, ITokenCreateResponse>(url, values);
+    console.info("token api response", res);
+    if (res.ok) {
+      return res.data.rows[0].token;
+    } else {
+      return null;
+    }
+  };
   return (
     <div>
       {loading ? (
@@ -272,6 +305,10 @@ const TypePaliWidget = ({
                     key: "task",
                     label: "生成任务",
                   },
+                  {
+                    key: "token",
+                    label: "获取访问密钥",
+                  },
                 ],
                 onClick: ({ key }) => {
                   switch (key) {
@@ -281,6 +318,12 @@ const TypePaliWidget = ({
                     case "tpl":
                       setTplOpen(true);
                       break;
+                    case "token":
+                      const token = getAccessToken();
+                      if (typeof token === "string") {
+                        alert(token);
+                      }
+                      break;
                   }
                 },
               }}

+ 20 - 5
dashboard-v4/dashboard/src/components/studio/LeftSider.tsx

@@ -20,8 +20,9 @@ const onClick: MenuProps["onClick"] = (e) => {
 
 type IWidgetHeadBar = {
   selectedKeys?: string;
+  openKeys?: string[];
 };
-const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
+const LeftSiderWidget = ({ selectedKeys = "", openKeys }: IWidgetHeadBar) => {
   //Library head bar
   const user = useAppSelector(currentUser);
 
@@ -37,6 +38,8 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
   const linkCourse = "/studio/" + studioname + "/course/list";
   const linkSetting = "/studio/" + studioname + "/setting";
 
+  const urlBase = `/studio/${studioname}`;
+
   const items: MenuProps["items"] = [
     {
       label: intl.formatMessage({
@@ -103,7 +106,7 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           children: [
             {
               label: (
-                <Link to={`/studio/${studioname}/task/hall`}>
+                <Link to={`${urlBase}/task/hall`}>
                   {intl.formatMessage({
                     id: "labels.task.hall",
                   })}
@@ -113,7 +116,7 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
             },
             {
               label: (
-                <Link to={`/studio/${studioname}/task/list`}>
+                <Link to={`${urlBase}/task/list`}>
                   {intl.formatMessage({
                     id: "labels.task.mine",
                   })}
@@ -123,7 +126,7 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
             },
             {
               label: (
-                <Link to={`/studio/${studioname}/task/projects`}>
+                <Link to={`${urlBase}/task/projects`}>
                   {intl.formatMessage({
                     id: "labels.task.my.project",
                   })}
@@ -133,6 +136,16 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
             },
           ],
         },
+        {
+          label: "AI",
+          key: "ai",
+          children: [
+            {
+              label: <Link to={`${urlBase}/ai/models/list`}>{"models"}</Link>,
+              key: "models",
+            },
+          ],
+        },
         {
           label: (
             <Link to={linkCourse}>
@@ -270,7 +283,9 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
             theme="light"
             onClick={onClick}
             defaultSelectedKeys={[selectedKeys]}
-            defaultOpenKeys={["basic", "advance", "collaboration"]}
+            defaultOpenKeys={["basic", "advance", "collaboration"].concat(
+              openKeys ?? ""
+            )}
             mode="inline"
             items={items}
           />

+ 41 - 0
dashboard-v4/dashboard/src/components/studio/Publicity.tsx

@@ -0,0 +1,41 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { publicityList, TPublicity } from "./PublicitySelect";
+
+interface IWidget {
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  disable?: TPublicity[];
+  name?: string;
+  readonly?: boolean;
+}
+const Publicity = ({
+  width,
+  disable = [],
+  name = "status",
+  readonly,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const options = publicityList.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `forms.fields.publicity.${item}.label`,
+      }),
+      disable: disable.includes(item),
+    };
+  });
+
+  return (
+    <ProFormSelect
+      options={options.filter((value) => value.disable === false)}
+      readonly={readonly}
+      width={width}
+      name={name}
+      allowClear={false}
+      label={intl.formatMessage({ id: "forms.fields.publicity.label" })}
+    />
+  );
+};
+
+export default Publicity;

+ 18 - 0
dashboard-v4/dashboard/src/components/studio/PublicityIcon.tsx

@@ -0,0 +1,18 @@
+import { GlobalOutlined } from "@ant-design/icons";
+import { TPublicity } from "./PublicitySelect";
+import { LockIcon } from "../../assets/icon";
+
+interface IWidget {
+  value?: TPublicity;
+}
+const PublicityIcon = ({ value }: IWidget) => {
+  return value === "public" ? (
+    <GlobalOutlined />
+  ) : value === "private" ? (
+    <LockIcon />
+  ) : (
+    <></>
+  );
+};
+
+export default PublicityIcon;

+ 15 - 3
dashboard-v4/dashboard/src/components/studio/PublicitySelect.tsx

@@ -7,13 +7,25 @@ export type TPublicity =
   | "private"
   | "public_no_list"
   | "public";
-
+export const publicityList: TPublicity[] = [
+  "disable",
+  "blocked",
+  "private",
+  "public_no_list",
+  "public",
+];
 interface IWidget {
   width?: number | "md" | "sm" | "xl" | "xs" | "lg";
   disable?: TPublicity[];
+  name?: string;
   readonly?: boolean;
 }
-const PublicitySelectWidget = ({ width, disable = [], readonly }: IWidget) => {
+const PublicitySelectWidget = ({
+  width,
+  disable = [],
+  name = "status",
+  readonly,
+}: IWidget) => {
   const intl = useIntl();
 
   const options = [
@@ -60,7 +72,7 @@ const PublicitySelectWidget = ({ width, disable = [], readonly }: IWidget) => {
       options={options.filter((value) => value.disable === false)}
       readonly={readonly}
       width={width}
-      name="status"
+      name={name}
       allowClear={false}
       label={intl.formatMessage({ id: "forms.fields.publicity.label" })}
     />

+ 1 - 6
dashboard-v4/dashboard/src/components/task/Category.tsx

@@ -36,15 +36,10 @@ const Category = ({ task, onChange }: IWidget) => {
 
   const onClick: MenuProps["onClick"] = (e) => {
     if (!task) {
+      console.error("no task");
       return;
     }
-    switch (e.key) {
-      case "json":
-        break;
 
-      default:
-        break;
-    }
     let setting: ITaskUpdateRequest = {
       id: task.id,
       studio_name: "",

+ 7 - 1
dashboard-v4/dashboard/src/components/task/TaskReader.tsx

@@ -184,7 +184,13 @@ const TaskReader = ({ taskId, onChange, onEdit }: IWidget) => {
         </Space>
         <Space>
           <Text type="secondary">类别</Text>
-          <Category />
+          <Category
+            task={task}
+            onChange={(data) => {
+              setTask(data[0]);
+              onChange && onChange(data);
+            }}
+          />
         </Space>
       </div>
       <Divider />

+ 51 - 0
dashboard-v4/dashboard/src/components/template/Ai.tsx

@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+import { IAiModel, IAiModelResponse } from "../api/ai";
+import { get } from "../../request";
+import { Alert, Tag, Typography } from "antd";
+const { Text } = Typography;
+
+interface IAiCtl {
+  model?: string;
+}
+const AiCtl = ({ model }: IAiCtl) => {
+  const [curr, setCurr] = useState<IAiModel>();
+  useEffect(() => {
+    const url = `/v2/ai-model/${model}`;
+    console.info("api request", url);
+    get<IAiModelResponse>(url).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        setCurr(json.data);
+      }
+    });
+  }, [model]);
+  return (
+    <Alert
+      message={
+        <div>
+          <Text strong style={{ display: "block" }}>
+            {curr?.name}
+          </Text>
+          <Tag>{curr?.model}</Tag>
+          <Text>{curr?.url}</Text>
+        </div>
+      }
+      type="info"
+    />
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IAiCtl;
+  console.log(prop);
+  return (
+    <>
+      <AiCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 3 - 0
dashboard-v4/dashboard/src/components/template/MdTpl.tsx

@@ -1,4 +1,5 @@
 import { GrammarPopShell } from "../dict/GrammarPop";
+import Ai from "./Ai";
 import Article from "./Article";
 import DictPreferenceEditor from "./DictPreferenceEditor";
 import Exercise from "./Exercise";
@@ -69,6 +70,8 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <Reference props={props ? props : ""} />;
     case "dict-pref":
       return props ? <DictPreferenceEditor props={props} /> : <>无效的参数</>;
+    case "ai":
+      return <Ai props={props ? props : ""} />;
     default:
       return <>未定义模版({tpl})</>;
   }

+ 2 - 0
dashboard-v4/dashboard/src/locales/en-US/forms.ts

@@ -96,6 +96,8 @@ const items = {
   "forms.fields.started-at.label": "started at",
   "forms.fields.finished-at.label": "finished at",
   "forms.fields.assignees.label": "assignees",
+  "forms.fields.model.label": "model",
+  "forms.fields.key.label": "key",
 };
 
 export default items;

+ 2 - 0
dashboard-v4/dashboard/src/locales/en-US/label.ts

@@ -73,6 +73,8 @@ const items = {
   "labels.task.category.translate": "translate",
   "labels.task.category.vocabulary": "vocabulary",
   "labels.task.category.team": "team",
+  "labels.task.category.review": "review",
+  "labels.task.category.proofread": "proofread",
 };
 
 export default items;

+ 2 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/forms.ts

@@ -96,6 +96,8 @@ const items = {
   "forms.fields.started-at.label": "开始时间",
   "forms.fields.finished-at.label": "完成时间",
   "forms.fields.assignees.label": "指派给",
+  "forms.fields.model.label": "模型",
+  "forms.fields.key.label": "密钥",
 };
 
 export default items;

+ 2 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/label.ts

@@ -81,6 +81,8 @@ const items = {
   "labels.task.category.translate": "翻译",
   "labels.task.category.vocabulary": "词汇表",
   "labels.task.category.team": "术语",
+  "labels.task.category.review": "审稿",
+  "labels.task.category.proofread": "proofread",
 };
 
 export default items;

+ 22 - 0
dashboard-v4/dashboard/src/pages/studio/ai/index.tsx

@@ -0,0 +1,22 @@
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
+
+import LeftSider from "../../../components/studio/LeftSider";
+import { styleStudioContent } from "../style";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  return (
+    <Layout>
+      <Layout>
+        <LeftSider openKeys={["ai"]} />
+        <Content style={styleStudioContent}>
+          <Outlet />
+        </Content>
+      </Layout>
+    </Layout>
+  );
+};
+
+export default Widget;

+ 22 - 0
dashboard-v4/dashboard/src/pages/studio/ai/model_edit.tsx

@@ -0,0 +1,22 @@
+import { useParams } from "react-router-dom";
+import { Card } from "antd";
+
+import GoBack from "../../../components/studio/GoBack";
+import AiModelEdit from "../../../components/ai/AiModelEdit";
+
+const Widget = () => {
+  const { studioname } = useParams();
+  const { modelId } = useParams(); //url 参数
+
+  return (
+    <Card
+      title={
+        <GoBack to={`/studio/${studioname}/ai/models/list`} title={"返回"} />
+      }
+    >
+      <AiModelEdit studioName={studioname} modelId={modelId} />
+    </Card>
+  );
+};
+
+export default Widget;

+ 8 - 0
dashboard-v4/dashboard/src/pages/studio/ai/models.tsx

@@ -0,0 +1,8 @@
+import { useParams } from "react-router-dom";
+import AiModelList from "../../../components/ai/AiModelList";
+
+const Widget = () => {
+  const { studioname } = useParams(); //url 参数
+  return <AiModelList studioName={studioname} />;
+};
+export default Widget;

+ 1 - 1
dashboard-v4/dashboard/src/pages/studio/task/index.tsx

@@ -9,7 +9,7 @@ const { Content } = Layout;
 const Widget = () => {
   return (
     <Layout>
-      <LeftSider selectedKeys="task" />
+      <LeftSider openKeys={["task"]} />
       <Content style={{ ...styleStudioContent }}>
         <Outlet />
       </Content>