Explorar el Código

Merge pull request #2248 from visuddhinanda/development

注册用邮箱验证码
visuddhinanda hace 1 año
padre
commit
c9da7020fb
Se han modificado 55 ficheros con 1853 adiciones y 469 borrados
  1. 144 23
      api-v8/app/Console/Commands/MqAiTranslate.php
  2. 119 105
      api-v8/app/Console/Commands/MqDiscussion.php
  3. 47 0
      api-v8/app/Http/Api/AiAssistantApi.php
  4. 85 34
      api-v8/app/Http/Api/AiTaskPrepare.php
  5. 17 4
      api-v8/app/Http/Api/TemplateRender.php
  6. 12 0
      api-v8/app/Http/Api/UserApi.php
  7. 87 0
      api-v8/app/Http/Controllers/AiAssistantController.php
  8. 6 4
      api-v8/app/Http/Controllers/AiModelController.php
  9. 6 0
      api-v8/app/Http/Controllers/AuthController.php
  10. 100 0
      api-v8/app/Http/Controllers/EmailCertificationController.php
  11. 41 30
      api-v8/app/Http/Controllers/InviteController.php
  12. 94 0
      api-v8/app/Http/Controllers/ModelLogController.php
  13. 2 2
      api-v8/app/Http/Controllers/SentenceController.php
  14. 2 19
      api-v8/app/Http/Controllers/TaskAssigneeController.php
  15. 29 1
      api-v8/app/Http/Controllers/TaskController.php
  16. 95 82
      api-v8/app/Http/Controllers/TaskStatusController.php
  17. 19 0
      api-v8/app/Http/Resources/AiAssistant.php
  18. 24 0
      api-v8/app/Http/Resources/AiAssistantResource.php
  19. 19 0
      api-v8/app/Http/Resources/ModelLogResource.php
  20. 1 0
      api-v8/app/Http/Resources/TaskResource.php
  21. 46 0
      api-v8/app/Mail/EmailCertif.php
  22. 7 9
      api-v8/app/Mail/InviteMail.php
  23. 5 0
      api-v8/app/Models/Invite.php
  24. 18 0
      api-v8/app/Models/ModelLog.php
  25. 44 26
      api-v8/app/Tools/Tools.php
  26. 3 0
      api-v8/database/migrations/2025_01_27_152548_create_ai_models_table.php
  27. 40 0
      api-v8/database/migrations/2025_02_04_081924_create_model_logs_table.php
  28. 15 0
      api-v8/resources/views/emails/certification/en-US.blade.php
  29. 15 0
      api-v8/resources/views/emails/certification/en.blade.php
  30. 18 0
      api-v8/resources/views/emails/certification/zh-Hans.blade.php
  31. 15 0
      api-v8/resources/views/emails/certification/zh-Hant.blade.php
  32. 6 0
      api-v8/routes/api.php
  33. 2 0
      dashboard-v4/dashboard/src/Router.tsx
  34. 77 0
      dashboard-v4/dashboard/src/components/ai/AiAssistantSelect.tsx
  35. 7 0
      dashboard-v4/dashboard/src/components/ai/AiModelEdit.tsx
  36. 9 0
      dashboard-v4/dashboard/src/components/ai/AiModelList.tsx
  37. 153 0
      dashboard-v4/dashboard/src/components/ai/AiModelLogList.tsx
  38. 6 0
      dashboard-v4/dashboard/src/components/api/Auth.ts
  39. 22 0
      dashboard-v4/dashboard/src/components/api/ai.ts
  40. 1 0
      dashboard-v4/dashboard/src/components/api/task.ts
  41. 1 1
      dashboard-v4/dashboard/src/components/discussion/DiscussionCreate.tsx
  42. 7 3
      dashboard-v4/dashboard/src/components/like/EditableAvatarGroup.tsx
  43. 36 6
      dashboard-v4/dashboard/src/components/like/WatchAdd.tsx
  44. 22 2
      dashboard-v4/dashboard/src/components/like/WatchList.tsx
  45. 113 81
      dashboard-v4/dashboard/src/components/nut/users/SignUp.tsx
  46. 28 0
      dashboard-v4/dashboard/src/components/task/Assignees.tsx
  47. 0 1
      dashboard-v4/dashboard/src/components/task/TaskReader.tsx
  48. 40 8
      dashboard-v4/dashboard/src/components/task/TaskStatus.tsx
  49. 135 27
      dashboard-v4/dashboard/src/components/users/SignUp.tsx
  50. 1 0
      dashboard-v4/dashboard/src/locales/en-US/auth/index.ts
  51. 1 0
      dashboard-v4/dashboard/src/locales/en-US/label.ts
  52. 1 0
      dashboard-v4/dashboard/src/locales/zh-Hans/auth/index.ts
  53. 1 0
      dashboard-v4/dashboard/src/locales/zh-Hans/label.ts
  54. 1 1
      dashboard-v4/dashboard/src/pages/nut/users/sign-in.tsx
  55. 8 0
      dashboard-v4/dashboard/src/pages/studio/ai/model_logs.tsx

+ 144 - 23
api-v8/app/Console/Commands/MqAiTranslate.php

@@ -6,9 +6,11 @@ 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;
+use Illuminate\Support\Str;
 
+use App\Http\Controllers\AuthController;
+use App\Models\Sentence;
+use App\Models\ModelLog;
 
 class MqAiTranslate extends Command
 {
@@ -51,46 +53,165 @@ class MqAiTranslate extends Command
         $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
         Log::debug("mq:progress start.");
         Mq::worker($exchange, $queue, function ($message) {
+            Log::debug('start', ['message' => $message]);
+            //写入 model log
+            $modelLog = new ModelLog();
+            $modelLog->uid = Str::uuid();
 
             $param = [
                 "model" => $message->model->model,
                 "messages" => [
-                    ["role" => "system", "content" => "你是翻译人工智能助手.bhikkhu 为专有名词,不可翻译成其他语言。"],
-                    ["role" => "user", "content" => $message->content],
+                    ["role" => "system", "content" => $message->model->system_prompt],
+                    ["role" => "user", "content" => $message->prompt],
                 ],
-                "temperature" => 0.3,
+                'prompt' => $message->prompt,
+                "temperature" => 0.7,
                 "stream" => false
             ];
-            $response = Http::withToken($message->model->token)
-                ->retry(2, 1000)
+            $this->info('ai request' . $message->model->url);
+            $this->info('model:' . $param['model']);
+            Log::debug('ai api request', [
+                'url' => $message->model->url,
+                'data' => $param
+            ]);
+            $modelLog->model_id = $message->model->uid;
+            $modelLog->request_at = now();
+            $modelLog->request_data = json_encode($param, JSON_UNESCAPED_UNICODE);
+
+            $response = Http::withToken($message->model->key)
+                ->retry(2, 120000)
                 ->post($message->model->url, $param);
+            $modelLog->request_headers = json_encode($response->handlerStats(), JSON_UNESCAPED_UNICODE);
+            $modelLog->response_headers = json_encode($response->headers(), JSON_UNESCAPED_UNICODE);
+            $modelLog->status = $response->status();
+            $modelLog->response_data = json_encode($response->json(), JSON_UNESCAPED_UNICODE);
             if ($response->failed()) {
+                $modelLog->success = false;
+                $modelLog->save();
                 $this->error('http response error' . $response->json('message'));
                 Log::error('http response error', ['data' => $response->json()]);
                 return 1;
             }
+            $modelLog->save();
+            $this->info('log saved');
             $aiData = $response->json();
             Log::debug('http response', ['data' => $response->json()]);
+            $responseContent = $aiData['choices'][0]['message']['content'];
+            if (isset($aiData['choices'][0]['message']['reasoning_content'])) {
+                $reasoningContent = $aiData['choices'][0]['message']['reasoning_content'];
+            }
 
-            //获取ai帐号的用户token
-            $user = UserApi::getByName(config('mint.ai.assistant'));
-            $token = AuthController::getUserToken($user['id']);
-            Log::debug('ai assistant token', [
-                'user' => $user,
-                'token' => $token
-            ]);
+            $this->info('ai content=' . $responseContent);
+            if (empty($reasoningContent)) {
+                $this->info('no reasoningContent');
+            } else {
+                $this->info('reasoning=' . $reasoningContent);
+            }
+
+            //获取model token
+            Log::debug('ai assistant token', ['user' => $message->model->uid]);
+            $token = AuthController::getUserToken($message->model->uid);
+            Log::debug('ai assistant token', ['token' => $token]);
+
+            if ($message->task->info->category === 'translate') {
+                //写入句子库
+                $url = config('app.url') . '/api/v2/sentence';
+                $sentData = [];
+                $message->sentence->content = $responseContent;
+                $sentData[] = $message->sentence;
+                $this->info("upload to {$url}");
+                Log::debug('sentence update http request', ['data' => $sentData]);
+                $response = Http::withToken($token)->post($url, [
+                    'sentences' => $sentData,
+                ]);
+                Log::debug('sentence update http response', ['data' => $response->json()]);
+                if ($response->failed()) {
+                    $this->error('upload error' . $response->json('message'));
+                    Log::error('upload error', ['data' => $response->json()]);
+                } else {
+                    $this->info('upload successful');
+                }
+            }
+
+            //写入discussion
+            #获取句子id
+            $sUid = Sentence::where('book_id', $message->sentence->book_id)
+                ->where('paragraph', $message->sentence->paragraph)
+                ->where('word_start', $message->sentence->word_start)
+                ->where('word_end', $message->sentence->word_end)
+                ->where('channel_uid', $message->sentence->channel_uid)
+                ->value('uid');
+            $url = config('app.url') . '/api/v2/discussion';
+            $data = [
+                'res_id' => $sUid,
+                'res_type' => 'sentence',
+                'title' => 'AI ' . $message->task->info->title,
+                'content' => 'AI ' . $message->task->info->category,
+                'content_type' => 'markdown',
+                'type' => 'discussion',
+            ];
+            $response = Http::withToken($token)->post($url, $data);
+            if ($response->failed()) {
+                $this->error('discussion error' . $response->json('message'));
+                Log::error('discussion error', ['data' => $response->json()]);
+            } else {
+                $this->info('discussion topic successful');
+            }
+            $data['parent'] = $response->json()['data']['id'];
+            unset($data['title']);
+            $topicChildren = [];
+            //提示词
+            $topicChildren[] = $message->prompt;
+            //任务结果
+            $topicChildren[] = $responseContent;
+            //推理过程写入discussion
+            if (isset($reasoningContent) && !empty($reasoningContent)) {
+                $topicChildren[] = $reasoningContent;
+            }
+            foreach ($topicChildren as  $content) {
+                $data['content'] = $content;
+                Log::debug('discussion child request', ['url' => $url, 'data' => $data]);
+                $response = Http::withToken($token)->post($url, $data);
+                if ($response->failed()) {
+                    $this->error('discussion error' . $response->json('message'));
+                    Log::error('discussion error', ['data' => $response->json()]);
+                } else {
+                    $this->info('discussion child successful');
+                }
+            }
 
-            //写入句子库
-            $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 完成度
+            $taskProgress = $message->task->progress;
+            $progress = (int)($taskProgress->current * 100 / $taskProgress->total);
+            $url = config('app.url') . '/api/v2/task/' . $message->task->info->id;
+            $data = [
+                'progress' => $progress,
+            ];
+            Log::debug('task progress request', ['url' => $url, 'data' => $data]);
+            $response = Http::withToken($token)->patch($url, $data);
+            if ($response->failed()) {
+                $this->error('task progress error' . $response->json('message'));
+                Log::error('task progress error', ['data' => $response->json()]);
+            } else {
+                $this->info('task progress successful progress=' . $response->json()['data']['progress']);
+            }
 
+            //完成 修改状态
+            if ($taskProgress->current === $taskProgress->total) {
+                $url = config('app.url') . '/api/v2/task-status/' . $message->task->info->id;
+                $data = [
+                    'status' => 'done',
+                ];
+                Log::debug('task status request', ['url' => $url, 'data' => $data]);
+                $response = Http::withToken($token)->patch($url, $data);
+                //判断状态码
+                if ($response->failed()) {
+                    $this->error('task status error' . $response->json('message'));
+                    Log::error('task status error', ['data' => $response->json()]);
+                } else {
+                    $this->info('task status successful ');
+                }
+            }
             return 0;
         });
         return 0;

+ 119 - 105
api-v8/app/Console/Commands/MqDiscussion.php

@@ -53,161 +53,169 @@ class MqDiscussion extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
         $exchange = 'router';
         $queue = 'discussion';
         $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
         Log::info("discussion worker start .");
-        Mq::worker($exchange,$queue,function ($message){
-            Log::info('mq discussion receive {message}',['message'=>json_encode($message,JSON_UNESCAPED_UNICODE)]);
+        Mq::worker($exchange, $queue, function ($message) {
+            Log::info('mq discussion receive {message}', ['message' => json_encode($message, JSON_UNESCAPED_UNICODE)]);
             $result = 0;
             $msgParam = array();
             $msgParam['nickname'] = $message->editor->nickName;
-            $link = config('app.url')."/pcd/discussion/topic/";
-            if($message->parent){
-                $msgParam['topic-title'] = Discussion::where('id',$message->parent)->value('title');
+            $link = config('app.url') . "/pcd/discussion/topic/";
+            if ($message->parent) {
+                $msgParam['topic-title'] = Discussion::where('id', $message->parent)->value('title');
                 $id = $message->id;
-                $msgParam['link'] = $link . $message->parent.'#'.$id;
+                $msgParam['link'] = $link . $message->parent . '#' . $id;
                 $msgParam['card_title'] = "回复讨论";
                 $type = 'reply';
-            }else{
+            } else {
                 $msgParam['title'] = $message->title;
                 $msgParam['link'] = $link . $message->id;
                 $msgParam['card_title'] = "创建讨论";
                 $type = 'create';
             }
-            if($message->content){
+            if ($message->content) {
                 $msgParam['content'] = $message->content;
             }
 
             switch ($message->res_type) {
                 case 'sentence':
-                    $sentence = Sentence::where('uid',$message->res_id)->first();
-                    if(!$sentence){
-                        Log::error('invalid sentence id '.$message->res_id);
+                    $sentence = Sentence::where('uid', $message->res_id)->first();
+                    if (!$sentence) {
+                        Log::error('invalid sentence id ' . $message->res_id);
                         $result = 1;
                         break;
                     }
 
                     //站内信
-                    try{
+                    try {
                         $sendTo = array();
                         //句子的channel拥有者
                         //$sendTo[] = $prData->channel->studio_id;
                         //句子的作者
-                        if(!in_array($sentence->editor_uid,$sendTo)){
+                        if (!in_array($sentence->editor_uid, $sendTo)) {
                             $sendTo[] = $sentence->editor_uid;
                         }
                         //句子的采纳者
-                        if(!empty($sentence->acceptor_uid) && !in_array($sentence->acceptor_uid,$sendTo)){
+                        if (!empty($sentence->acceptor_uid) && !in_array($sentence->acceptor_uid, $sendTo)) {
                             $sendTo[] = $sentence->acceptor_uid;
                         }
-                        $this->notification($message->editor->uid,
-                                            $sendTo,
-                                            'discussion',
-                                            $message->id,
-                                            $sentence->channel_uid);
-                    }catch(\Exception $e){
-                        Log::error('send notification failed',['exception'=>$e]);
+                        $this->notification(
+                            $message->editor->uid,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $sentence->channel_uid
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
                     }
 
                     //webhook
-                    $contentHtml = MdRender::render($sentence->content,
-                                             [$sentence->channel_uid],
-                                             null,
-                                             'read',
-                                             'translation',
-                                             $sentence->content_type);
+                    $contentHtml = MdRender::render(
+                        $sentence->content,
+                        [$sentence->channel_uid],
+                        null,
+                        'read',
+                        'translation',
+                        $sentence->content_type
+                    );
                     $contentTxt = strip_tags($contentHtml);
                     /**生成消息内容 */
 
                     $msgParam['anchor-content'] = $contentTxt;
                     $WebHookResId = $sentence->channel_uid;
 
-                    $this->WebHook($msgParam,$type,$WebHookResId);
+                    $this->WebHook($msgParam, $type, $WebHookResId);
                     break;
                 case 'wbw':
-                    $wbw = Wbw::where('uid',$message->res_id)->first();
-                    if(!$wbw){
-                        Log::error('invalid wbw id '.$message->res_id);
+                    $wbw = Wbw::where('uid', $message->res_id)->first();
+                    if (!$wbw) {
+                        Log::error('invalid wbw id ' . $message->res_id);
                         $result = 1;
                         break;
                     }
-                    $wbwBlock = WbwBlock::where('uid',$wbw->block_uid)->first();
-                    if(!$wbwBlock){
-                        Log::error('invalid wbw-block id '.$message->res_id);
+                    $wbwBlock = WbwBlock::where('uid', $wbw->block_uid)->first();
+                    if (!$wbwBlock) {
+                        Log::error('invalid wbw-block id ' . $message->res_id);
                         $result = 1;
                         break;
                     }
 
                     //站内信
-                    try{
+                    try {
                         $sendTo = array();
                         //channel拥有者
                         //$sendTo[] = $prData->channel->studio_id;
                         //作者
-                        if(!in_array($wbw->creator_uid,$sendTo)){
+                        if (!in_array($wbw->creator_uid, $sendTo)) {
                             $sendTo[] = $wbw->creator_uid;
                         }
                         //提问者
-                        if(!empty($message->parent)){
-                            $topicEditor = Discussion::where('id',$message->parent)
-                                                ->value('editor_uid');
-                            if(!empty($topicEditor) && !in_array($topicEditor,$sendTo)){
+                        if (!empty($message->parent)) {
+                            $topicEditor = Discussion::where('id', $message->parent)
+                                ->value('editor_uid');
+                            if (!empty($topicEditor) && !in_array($topicEditor, $sendTo)) {
                                 $sendTo[] = $topicEditor;
-                                Log::debug('发送给提问者',['data'=>$topicEditor]);
+                                Log::debug('发送给提问者', ['data' => $topicEditor]);
                             }
                         }
 
-                        $this->notification($message->editor->id,
-                                            $sendTo,
-                                            'discussion',
-                                            $message->id,
-                                            $wbwBlock->channel_uid);
-                    }catch(\Exception $e){
-                        Log::error('send notification failed',['exception'=>$e]);
+                        $this->notification(
+                            $message->editor->id,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $wbwBlock->channel_uid
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
                     }
 
                     $msgParam['anchor-content'] = $wbw->word;
                     $WebHookResId = $wbwBlock->channel_uid;
-                    $this->WebHook($msgParam,$type,$WebHookResId);
+                    $this->WebHook($msgParam, $type, $WebHookResId);
                     break;
                 case 'term':
-                    $term = DhammaTerm::where('guid',$message->res_id)->first();
-                    if(!$term){
-                        Log::error('invalid term id '.$message->res_id);
+                    $term = DhammaTerm::where('guid', $message->res_id)->first();
+                    if (!$term) {
+                        Log::error('invalid term id ' . $message->res_id);
                         $result = 1;
                         break;
                     }
-                    if(empty($term->channal) || !Str::isUuid($term->channal)){
+                    if (empty($term->channal) || !Str::isUuid($term->channal)) {
                         break;
                     }
 
 
                     //站内信
-                    try{
+                    try {
                         $sendTo = array();
                         //拥有者
                         $sendTo[] = $term->term;
                         //作者
-                        $editor = App\Http\Api\UserApi::getById($term->editor_id);
-                        if($editor['id'] !== 0 && !in_array($editor['uid'],$sendTo)){
+                        $editor = UserApi::getById($term->editor_id);
+                        if ($editor['id'] !== 0 && !in_array($editor['uid'], $sendTo)) {
                             $sendTo[] = $editor['uid'];
                         }
-                        $this->notification($message->editor->uid,
-                                            $sendTo,
-                                            'discussion',
-                                            $message->id,
-                                            $term->channal);
-                    }catch(\Exception $e){
-                        Log::error('send notification failed',['exception'=>$e]);
+                        $this->notification(
+                            $message->editor->uid,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $term->channal
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
                     }
                     //webhook
                     $msgParam['anchor-content'] = $term->meaning . '(' . $term->word . ')';
                     $WebHookResId = $term->channal;
-                    $this->WebHook($msgParam,$WebHookResId);
+                    $this->WebHook($msgParam, 'term', $WebHookResId);
 
                     break;
                 default:
@@ -221,31 +229,35 @@ class MqDiscussion extends Command
         return 0;
     }
 
-    private function WebHook($msgParam,$type,$resId){
+    private function WebHook($msgParam, $type, $resId)
+    {
         $rootId = UserApi::getById(0)['uid'];
         $articleTitle = "webhook://discussion/{$type}/zh-hans";
-        $tpl = Article::where('owner',$rootId)
-                      ->where('title',$articleTitle)
-                      ->value('content');
-        if(empty($tpl)){
-            Log::error('mq:discussion 模版不能为空',['tpl_title'=>$articleTitle]);
+        $tpl = Article::where('owner', $rootId)
+            ->where('title', $articleTitle)
+            ->value('content');
+        if (empty($tpl)) {
+            Log::error('mq:discussion 模版不能为空', ['tpl_title' => $articleTitle]);
             return 1;
         }
-        $m = new \Mustache_Engine(array('entity_flags'=>ENT_QUOTES,
-                                    'delimiters' => '{% %}',));
-        $msgContent = $m->render($tpl,$msgParam);
+        $m = new \Mustache_Engine(array(
+            'entity_flags' => ENT_QUOTES,
+            'delimiters' => '{% %}',
+        ));
+        $msgContent = $m->render($tpl, $msgParam);
 
-        $webhooks = WebHook::where('res_id',$resId)
-                        ->where('status','active')
-                        ->get();
+        $webhooks = WebHook::where('res_id', $resId)
+            ->where('status', 'active')
+            ->get();
+        $result = 0;
         foreach ($webhooks as $key => $hook) {
             $event = json_decode($hook->event);
 
-            if(is_array($event)){
-                if(!in_array('discussion',$event)){
+            if (is_array($event)) {
+                if (!in_array('discussion', $event)) {
                     continue;
                 }
-            }else{
+            } else {
                 continue;
             }
             $command = '';
@@ -253,47 +265,49 @@ class MqDiscussion extends Command
             $ok = 0;
             switch ($hook->receiver) {
                 case 'dingtalk':
-                    $ok = $whSend->dingtalk($hook->url,$msgParam['card_title'],$msgContent);
+                    $ok = $whSend->dingtalk($hook->url, $msgParam['card_title'], $msgContent);
                     break;
                 case 'wechat':
-                    $ok = $whSend->wechat($hook->url,null,$msgContent);
+                    $ok = $whSend->wechat($hook->url, null, $msgContent);
                     break;
                 default:
-                    $ok=2;
+                    $ok = 2;
                     break;
             }
             $result += $ok;
             $logMsg = "{$command}  ok={$ok}";
-            if($ok === 0){
+            if ($ok === 0) {
                 $this->info($logMsg);
-            }else{
+            } else {
                 $this->error($logMsg);
             }
 
-            if($ok === 0){
-                Log::debug('mq:discussion: send success {url}',['url'=>$hook->url]);
-                WebHook::where('id',$hook->id)->increment('success');
-            }else{
-                Log::error('mq:discussion: send fail {url}',['url'=>$hook->url]);
-                WebHook::where('id',$hook->id)->increment('fail');
+            if ($ok === 0) {
+                Log::debug('mq:discussion: send success {url}', ['url' => $hook->url]);
+                WebHook::where('id', $hook->id)->increment('success');
+            } else {
+                Log::error('mq:discussion: send fail {url}', ['url' => $hook->url]);
+                WebHook::where('id', $hook->id)->increment('fail');
             }
         }
     }
 
-    private function notification($from,$to,$resType,$resId,$channel){
-            //发送站内信
-            try{
+    private function notification($from, $to, $resType, $resId, $channel)
+    {
+        //发送站内信
+        try {
 
-                $sendCount = NotificationController::insert(
-                                    $from,
-                                    $to,
-                                    $resType,
-                                    $resId,
-                                    $channel);
-                $this->info("send notification success to [".$sendCount.'] users');
-            }catch(\Exception $e){
-                Log::error('send notification failed',['exception'=>$e]);
-            }
-            return;
+            $sendCount = NotificationController::insert(
+                $from,
+                $to,
+                $resType,
+                $resId,
+                $channel
+            );
+            $this->info("send notification success to [" . $sendCount . '] users');
+        } catch (\Exception $e) {
+            Log::error('send notification failed', ['exception' => $e]);
+        }
+        return;
     }
 }

+ 47 - 0
api-v8/app/Http/Api/AiAssistantApi.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Http\Api;
+
+use App\Models\AiModel;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\App;
+
+class AiAssistantApi
+{
+    public static function getByUuid($id)
+    {
+        $user = AiModel::where('uid', $id)->first();
+        return self::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->uid,
+            'nickName' => $user->name,
+            'userName' => $user->real_name,
+            'realName' => $user->real_name,
+            'sn' => 0,
+        ];
+
+        if ($user->avatar) {
+            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
+            if (App::environment('local')) {
+                $data['avatar'] = Storage::url($img);
+            } else {
+                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
+            }
+        }
+        return $data;
+    }
+}

+ 85 - 34
api-v8/app/Http/Api/AiTaskPrepare.php

@@ -6,13 +6,22 @@ use App\Models\Task;
 use App\Models\PaliText;
 use App\Models\PaliSentence;
 use App\Models\AiModel;
+use App\Models\Sentence;
+
 use App\Http\Api\Mq;
+use App\Http\Api\ChannelApi;
 
 use Illuminate\Support\Facades\Log;
 
 class AiTaskPrepare
 {
-    public static function translate(string $taskId)
+    /**
+     * 读取task信息,将任务拆解为单句小任务
+     *
+     * @param  string  $taskId 任务uuid
+     * @return array 拆解后的提示词数组
+     */
+    public static function translate(string $taskId, bool $send = true)
     {
         $task = Task::findOrFail($taskId);
         $description = $task->description;
@@ -24,7 +33,7 @@ class AiTaskPrepare
                 $params[$param[0]] = $param[1];
             }
         }
-        if (!isset($params['type']) || !isset($params['book']) || !isset($params['para'])) {
+        if (!isset($params['type'])) {
             return false;
         }
 
@@ -33,23 +42,34 @@ class AiTaskPrepare
         $totalLen = 0;
         switch ($params['type']) {
             case 'sentence':
+                if (!isset($params['id'])) {
+                    return false;
+                }
                 $sentences[] = explode('-', $params['id']);
                 break;
-            case 'paragraph':
+            case 'para':
+                if (!isset($params['book']) || !isset($params['paragraphs'])) {
+                    return false;
+                }
                 $sent = PaliSentence::where('book', $params['book'])
-                    ->where('paragraph', $params['para'])->orderBy('word_begin')->get();
+                    ->where('paragraph', $params['paragraphs'])->orderBy('word_begin')->get();
                 foreach ($sent as $key => $value) {
                     $sentences[] = [
-                        $value->book,
-                        $value->paragraph,
-                        $value->word_begin,
-                        $value->word_end,
-                        $value->length
+                        'id' => [
+                            $value->book,
+                            $value->paragraph,
+                            $value->word_begin,
+                            $value->word_end,
+                        ],
+                        'strlen' => $value->length
                     ];
                     $totalLen += $value->length;
                 }
                 break;
             case 'chapter':
+                if (!isset($params['book']) || !isset($params['para'])) {
+                    return false;
+                }
                 $chapterLen = PaliText::where('book', $params['book'])
                     ->where('paragraph', $params['para'])->value('chapter_len');
                 $sent = PaliSentence::where('book', $params['book'])
@@ -58,11 +78,13 @@ class AiTaskPrepare
                     ->orderBy('word_begin')->get();
                 foreach ($sent as $key => $value) {
                     $sentences[] = [
-                        $value->book,
-                        $value->paragraph,
-                        $value->word_begin,
-                        $value->word_end,
-                        $value->length
+                        'id' => [
+                            $value->book,
+                            $value->paragraph,
+                            $value->word_begin,
+                            $value->word_end,
+                        ],
+                        'strlen' => $value->length
                     ];
                     $totalLen += $value->length;
                 }
@@ -86,45 +108,74 @@ class AiTaskPrepare
         ));
 
         # ai model
-        if (!isset($params['{{ai|model'])) {
-            return false;
-        }
-        $modelId = trim($params['{{ai|model'], '}');
-        $aiModel = AiModel::findOne($modelId);
-        $aiPrompts = [];
+        $aiModel = AiModel::findOrFail($task->executor_id);
         $sumLen = 0;
+        $mqData = [];
         foreach ($sentences as $key => $sentence) {
-            $sumLen += $sentence[4];
-            $sid = implode('-', $sentence);
+            $sumLen += $sentence['strlen'];
+            $sid = implode('-', $sentence['id']);
             Log::debug($sid);
-            $data['pali'] = '{{' . $sid . '}}';
+            $data['origin'] = '{{' . $sid . '}}';
+            $data['translation'] = '{{sent|id=' . $sid;
+            $data['translation'] .= '|channel=' . $params['channel'];
+            $data['translation'] .= '|text=translation}}';
             if (isset($params['nissaya'])) {
-                $data['nissaya'] = '{{' . $sid . '@' . $params['nissaya'] . '}}';
+                $data['nissaya'] = [];
+                $nissayaChannels = explode(',', $params['nissaya']);
+                foreach ($nissayaChannels as $key => $channel) {
+                    $channelInfo = ChannelApi::getById($channel);
+                    if (!$channelInfo) {
+                        continue;
+                    }
+                    //查看句子是否存在
+                    $nissayaSent = Sentence::where('book_id', $sentence['id'][0])
+                        ->where('paragraph', $sentence['id'][1])
+                        ->where('word_start', $sentence['id'][2])
+                        ->where('word_end', $sentence['id'][3])
+                        ->where('channel_uid', $channel)->first();
+                    if (!$nissayaSent) {
+                        continue;
+                    }
+                    if (empty($nissayaSent->content)) {
+                        continue;
+                    }
+                    $nissayaData = [];
+                    $nissayaData['channel'] = $channelInfo;
+                    $nissayaData['data'] = '{{sent|id=' . $sid;
+                    $nissayaData['data'] .= '|channel=' . $channel;
+                    $nissayaData['data'] .= '|text=translation}}';
+                    $data['nissaya'][] = $nissayaData;
+                }
             }
             $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),
+                    'info' => $task,
+                    'progress' => [
+                        'current' => $sumLen,
+                        'total' => $totalLen
+                    ],
                 ],
+                'prompt' => $prompt,
                 'sentence' => [
-                    'book_id' => $sentence[0],
-                    'paragraph' => $sentence[1],
-                    'word_start' => $sentence[2],
-                    'word_end' => $sentence[3],
+                    'book_id' => $sentence['id'][0],
+                    'paragraph' => $sentence['id'][1],
+                    'word_start' => $sentence['id'][2],
+                    'word_end' => $sentence['id'][3],
                     'channel_uid' => $params['channel'],
                     'content' => $prompt,
                     'content_type' => 'markdown',
                     'access_token' => $params['token'],
                 ],
             ];
-            Mq::publish('ai_translate', $aiMqData);
+            array_push($mqData, $aiMqData);
+            if ($send) {
+                Mq::publish('ai_translate', $aiMqData);
+            }
         }
-
-        return $aiPrompts;
+        return $mqData;
     }
 }

+ 17 - 4
api-v8/app/Http/Api/TemplateRender.php

@@ -21,6 +21,8 @@ use App\Http\Api\ChannelApi;
 use App\Http\Api\MdRender;
 use App\Http\Api\PaliTextApi;
 
+use App\Tools\Tools;
+
 class TemplateRender
 {
     protected $param = [];
@@ -560,6 +562,9 @@ class TemplateRender
             case 'simple':
                 $output = $pali . '၊' . $meaning;
                 break;
+            case 'prompt':
+                $output = Tools::MyToRm($pali) . ':' . $meaning;
+                break;
             default:
                 $output = $pali . '၊' . $meaning;
                 break;
@@ -608,7 +613,6 @@ class TemplateRender
     }
     private  function render_article()
     {
-
         $type = $this->get_param($this->param, "type", 1);
         $id = $this->get_param($this->param, "id", 2);
         $title = $this->get_param($this->param, "title", 3);
@@ -1006,9 +1010,18 @@ class TemplateRender
                 break;
             case 'prompt':
                 $output = '';
-                if (isset($props['origin']) && is_array($props['origin'])) {
-                    foreach ($props['origin'] as $key => $value) {
-                        $output .= $value['html'];
+                if ($text === 'both' || $text === 'origin') {
+                    if (isset($props['origin']) && is_array($props['origin'])) {
+                        foreach ($props['origin'] as $key => $value) {
+                            $output .= $value['html'];
+                        }
+                    }
+                }
+                if ($text === 'both' || $text === 'translation') {
+                    if (isset($props['translation']) && is_array($props['translation'])) {
+                        foreach ($props['translation'] as $key => $value) {
+                            $output .= $value['html'];
+                        }
                     }
                 }
                 break;

+ 12 - 0
api-v8/app/Http/Api/UserApi.php

@@ -2,6 +2,8 @@
 
 namespace App\Http\Api;
 
+use App\Http\Resources\AiAssistant;
+use App\Models\AiModel;
 use App\Models\UserInfo;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -35,6 +37,9 @@ class UserApi
     public static function getByUuid($id)
     {
         $user = UserInfo::where('userid', $id)->first();
+        if (!$user) {
+            return AiAssistantApi::getByUuid($id);
+        }
         return UserApi::userInfo($user);
     }
     public static function getListByUuid($uuid)
@@ -43,6 +48,7 @@ class UserApi
             return null;
         };
         $users = UserInfo::whereIn('userid', $uuid)->get();
+        $assistants = AiModel::whereIn('uid', $uuid)->get();
         $output = array();
         foreach ($uuid as $key => $id) {
             foreach ($users as $user) {
@@ -51,6 +57,12 @@ class UserApi
                     continue;
                 };
             }
+            foreach ($assistants as $assistant) {
+                if ($assistant->uid === $id) {
+                    $output[] = AiAssistantApi::userInfo($assistant);
+                    continue;
+                };
+            }
         }
         return $output;
     }

+ 87 - 0
api-v8/app/Http/Controllers/AiAssistantController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AiModel;
+use Illuminate\Http\Request;
+use App\Http\Resources\AiAssistantResource;
+
+class AiAssistantController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $table = AiModel::whereNotNull('owner_id');
+        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));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => AiAssistantResource::collection(resource: $result),
+                "count" => $count,
+            ]
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AiModel $aiModel)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, AiModel $aiModel)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(AiModel $aiModel)
+    {
+        //
+    }
+}

+ 6 - 4
api-v8/app/Http/Controllers/AiModelController.php

@@ -34,9 +34,11 @@ class AiModelController extends Controller
                 break;
             case 'studio':
                 $studioId = StudioApi::getIdByName($request->get('name'));
-
                 $table = AiModel::where('owner_id', $studioId);
-
+                break;
+            case 'usable':
+                $table = AiModel::where('owner_id', $request->get('user_id'))
+                    ->orWhere('privacy', 'public');
                 break;
         }
         if ($request->has('keyword')) {
@@ -52,8 +54,6 @@ class AiModelController extends Controller
         $table = $table->skip($request->get("offset", 0))
             ->take($request->get('limit', 1000));
 
-        Log::debug('sql', ['sql' => $table->toSql()]);
-
         $result = $table->get();
 
         return $this->ok(
@@ -85,6 +85,7 @@ class AiModelController extends Controller
         $new = new AiModel();
         $new->name = $request->get('name');
         $new->uid = Str::uuid();
+        $new->real_name = Str::uuid();
         $new->owner_id = $studioId;
         $new->editor_id = $user['user_uid'];
         $new->save();
@@ -122,6 +123,7 @@ class AiModelController extends Controller
         }
         $aiModel->name = $request->get('name');
         $aiModel->description = $request->get('description');
+        $aiModel->system_prompt = $request->get('system_prompt');
         $aiModel->url = $request->get('url');
         $aiModel->model = $request->get('model');
         $aiModel->key = $request->get('key');

+ 6 - 0
api-v8/app/Http/Controllers/AuthController.php

@@ -9,6 +9,7 @@ use App\Http\Api\AuthApi;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\App;
 use App\Http\Api\UserApi;
+use App\Http\Api\AiAssistantApi;
 
 class AuthController extends Controller
 {
@@ -98,6 +99,9 @@ class AuthController extends Controller
     public static function getUserToken($userUid)
     {
         $user = UserApi::getByUuid($userUid);
+        if (!$user) {
+            $user = AiAssistantApi::getByUuid($userUid);
+        }
         if ($user) {
             $ExpTime = time() + 60 * 60 * 24 * 365;
             $key = config('app.key');
@@ -108,7 +112,9 @@ class AuthController extends Controller
                 'id' => $user['sn'],
             ];
             $jwt = JWT::encode($payload, $key, 'HS512');
+            return $jwt;
         }
+        return null;
     }
 
     public function getUserInfoByToken(Request $request)

+ 100 - 0
api-v8/app/Http/Controllers/EmailCertificationController.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\Invite;
+use App\Http\Resources\InviteResource;
+use Illuminate\Support\Str;
+use App\Mail\EmailCertif;
+use Illuminate\Support\Facades\Mail;
+use App\Tools\RedisClusters;
+use App\Models\UserInfo;
+
+class EmailCertificationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //查询是否重复
+        if (UserInfo::where('email', $request->get('email'))->exists()) {
+            return $this->error('email.exists', 'err.email.exists', 200);
+        }
+        $sender = config("mint.admin.root_uuid");
+
+        $uuid = Str::uuid();
+        $invite = Invite::firstOrNew(
+            ['email' => $request->get('email')],
+            ['id' => $uuid]
+        );
+        $invite->user_uid = $sender;
+        $invite->status = 'invited';
+        $invite->save();
+
+        Mail::to($request->get('email'))
+            ->send(new EmailCertif(
+                $invite->id,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        }
+
+        return $this->ok(new InviteResource($invite));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //
+        $code = RedisClusters::get("/email/certification/" . $id);
+        if (empty($code)) {
+            return $this->error('Certification is avalide', 200, 200);
+        }
+        return $this->ok($code);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 41 - 30
api-v8/app/Http/Controllers/InviteController.php

@@ -10,8 +10,8 @@ use App\Http\Api\UserApi;
 use App\Http\Api\StudioApi;
 use App\Http\Resources\InviteResource;
 use Illuminate\Support\Str;
-use Mail;
 use App\Mail\InviteMail;
+use Illuminate\Support\Facades\Mail;
 
 class InviteController extends Controller
 {
@@ -24,41 +24,49 @@ class InviteController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
+        if (!$user) {
             return $this->error(__('auth.failed'));
         }
-        $table = Invite::select(['id','user_uid','email',
-                                 'status','created_at','updated_at']);
+        $table = Invite::select([
+            'id',
+            'user_uid',
+            'email',
+            'status',
+            'created_at',
+            'updated_at'
+        ]);
         switch ($request->get('view')) {
             case 'studio':
-                if(empty($request->get('studio'))){
+                if (empty($request->get('studio'))) {
                     return $this->error(__('auth.failed'));
                 }
                 //判断当前用户是否有指定的studio的权限
-                if($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))){
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))) {
                     return $this->error(__('auth.failed'));
                 }
                 $table = $table->where('user_uid', $user["user_uid"]);
                 break;
             case 'all':
                 $user = UserApi::getByUuid($user['user_uid']);
-                if(!$user || !isset($user['roles']) || !in_array('administrator',$user['roles']) ){
+                if (!$user || !isset($user['roles']) || !in_array('administrator', $user['roles'])) {
                     return $this->error(__('auth.failed'));
                 }
                 break;
         }
-        if($request->has('search')){
-            $table = $table->where('email', 'like', '%'.$request->get('search')."%");
+        if ($request->has('search')) {
+            $table = $table->where('email', 'like', '%' . $request->get('search') . "%");
         }
         $count = $table->count();
-        $table = $table->orderBy($request->get('order','updated_at'),
-                                 $request->get('dir','desc'));
+        $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();
-        return $this->ok(["rows"=>InviteResource::collection($result),"count"=>$count]);
+        return $this->ok(["rows" => InviteResource::collection($result), "count" => $count]);
     }
 
     /**
@@ -71,36 +79,40 @@ class InviteController extends Controller
     {
         //
         $sender = '';
-        if(!empty($request->get('studio'))){
+        if (!empty($request->get('studio'))) {
             $user = AuthApi::current($request);
-            if(!$user){
-                return $this->error(__('auth.failed'),401,401);
+            if (!$user) {
+                return $this->error(__('auth.failed'), 401, 401);
             }
             //判断当前用户是否有指定的studio的权限
             $studio_id = StudioApi::getIdByName($request->get('studio'));
-            if($user['user_uid'] !== $studio_id){
+            if ($user['user_uid'] !== $studio_id) {
                 return $this->error(__('auth.failed'));
             }
             $sender = $studio_id;
-        }else{
+        } else {
             $sender = config("mint.admin.root_uuid");
         }
 
         //查询是否重复
-        if(Invite::where('email',$request->get('email'))->exists() ||
-            UserInfo::where('email',$request->get('email'))->exists()){
-            return $this->error('email.exists',__('validation.exists',['email']),200);
+        if (
+            Invite::where('email', $request->get('email'))->exists() ||
+            UserInfo::where('email', $request->get('email'))->exists()
+        ) {
+            return $this->error('email.exists', __('validation.exists', ['email']), 200);
         }
 
         $uuid = Str::uuid();
         Mail::to($request->get('email'))
-            ->send(new InviteMail($uuid,
-                                $request->get('subject','sign up wikipali'),
-                                $request->get('lang'),
-                                $request->get('dashboard')));
-        if(Mail::failures()){
-            return $this->error('send email fail', '',200);
-        }else{
+            ->send(new InviteMail(
+                $uuid,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+                $request->get('dashboard')
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        } else {
             $invite = new Invite;
             $invite->id = $uuid;
             $invite->email = $request->get('email');
@@ -121,7 +133,6 @@ class InviteController extends Controller
     {
         //
         return $this->ok(new InviteResource($invite));
-
     }
 
     /**

+ 94 - 0
api-v8/app/Http/Controllers/ModelLogController.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ModelLog;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Resources\ModelLogResource;
+
+class ModelLogController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        switch ($request->get('view')) {
+            case 'model':
+                # code..
+                $table = ModelLog::where('model_id', $request->get('id'));
+                break;
+
+            default:
+                # code...
+                break;
+        }
+        if ($request->has('search')) {
+            $table = $table->where('email', 'like', '%' . $request->get('search') . "%");
+        }
+        $count = $table->count();
+        $table = $table->orderBy(
+            $request->get('order', 'updated_at'),
+            $request->get('dir', 'desc')
+        );
+
+        $table = $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        return $this->ok(["rows" => ModelLogResource::collection($result), "count" => $count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ModelLog $modelLog)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ModelLog $modelLog)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ModelLog $modelLog)
+    {
+        //
+    }
+}

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

@@ -302,9 +302,9 @@ class SentenceController extends Controller
                 $power = ShareApi::getResPower($user["user_uid"], $channel->uid, 2);
                 if ($power < 20) {
                     //判断token
-                    if (isset($sent['token'])) {
+                    if (isset($sent['access_token'])) {
                         $key = AccessToken::where('res_id', $channelId)->value('token');
-                        $jwt = JWT::decode($sent['token'], new Key($key, 'HS512'));
+                        $jwt = JWT::decode($sent['access_token'], new Key($key, 'HS512'));
                         if ($jwt->book !== $sent['book_id']) {
                             continue;
                         }

+ 2 - 19
api-v8/app/Http/Controllers/TaskAssigneeController.php

@@ -17,15 +17,6 @@ class TaskAssigneeController extends Controller
         //
     }
 
-    /**
-     * Show the form for creating a new resource.
-     *
-     * @return \Illuminate\Http\Response
-     */
-    public function create()
-    {
-        //
-    }
 
     /**
      * Store a newly created resource in storage.
@@ -49,16 +40,6 @@ class TaskAssigneeController extends Controller
         //
     }
 
-    /**
-     * Show the form for editing the specified resource.
-     *
-     * @param  \App\Models\TaskAssignee  $taskAssignee
-     * @return \Illuminate\Http\Response
-     */
-    public function edit(TaskAssignee $taskAssignee)
-    {
-        //
-    }
 
     /**
      * Update the specified resource in storage.
@@ -81,5 +62,7 @@ class TaskAssigneeController extends Controller
     public function destroy(TaskAssignee $taskAssignee)
     {
         //
+        $del = $taskAssignee->delete();
+        return $this->ok($del);
     }
 }

+ 29 - 1
api-v8/app/Http/Controllers/TaskController.php

@@ -211,7 +211,7 @@ class TaskController extends Controller
         if (!$user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!self::canEdit($user['user_uid'], $task->owner_id)) {
+        if (!self::canUpdate($user['user_uid'], $task)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
         if ($request->has('title')) {
@@ -223,6 +223,9 @@ class TaskController extends Controller
         if ($request->has('category')) {
             $task->category = $request->get('category');
         }
+        if ($request->has('progress')) {
+            $task->progress = $request->get('progress');
+        }
         if ($request->has('assignees_id')) {
             $delete = TaskAssignee::where('task_id', $task->id)->delete();
             $assigneesData = [];
@@ -305,4 +308,29 @@ class TaskController extends Controller
     {
         return $user_uid === $owner_uid;
     }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  string  $user_uid
+     * @param  Task $task
+     * @return boolean
+     */
+    public static function canUpdate($user_uid, $task)
+    {
+        if ($user_uid === $task->owner_id) {
+            return true;
+        }
+        if ($user_uid === $task->executor_id) {
+            return true;
+        }
+        if (TaskAssignee::where('task_id', $task->id)
+            ->where('assignee_id', $user_uid)
+            ->exists()
+        ) {
+            return true;
+        }
+
+        return false;
+    }
 }

+ 95 - 82
api-v8/app/Http/Controllers/TaskStatusController.php

@@ -12,9 +12,12 @@ use Illuminate\Support\Facades\Log;
 use App\Http\Resources\TaskResource;
 use App\Http\Api\AuthApi;
 use App\Http\Api\WatchApi;
+use App\Models\AiModel;
+use App\Http\Api\AiTaskPrepare;
 
 class TaskStatusController extends Controller
 {
+    protected $changeTasks = array();
     /**
      * Display a listing of the resource.
      *
@@ -47,6 +50,27 @@ class TaskStatusController extends Controller
         //
     }
 
+    /**
+     *
+     *
+     * @param  string  $status
+     * @param  string  $id
+     * @return void
+     */
+    private function pushChange(string $status, string $id)
+    {
+        if (!isset($this->changeTasks[$status])) {
+            $this->changeTasks[$status] = array();
+        }
+        $this->changeTasks[$status][] = $id;
+    }
+    private function getChange(string $status)
+    {
+        if (!isset($this->changeTasks[$status])) {
+            $this->changeTasks[$status] = array();
+        }
+        return $this->changeTasks[$status];
+    }
     /**
      * Update the specified resource in storage.
      *
@@ -62,40 +86,32 @@ class TaskStatusController extends Controller
         if (!$user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!$this->canEdit($user['user_uid'], $task)) {
+        if (!TaskController::canUpdate($user['user_uid'], $task)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
 
         if (!$request->has('status')) {
             return $this->error('no status', 400, 400);
         }
-        $doneTask = [];
-        $publishTask = [];
-        $restartTask = [];
-        $runningTask = [];
+
         switch ($request->get('status')) {
-            case 'publish':
-                $publishTask[] = $id;
+            case 'published':
+                $this->pushChange('published', $id);
                 # 开启子任务
-                $children = Task::whereIn('parent_id', $id)
+                $children = Task::where('parent_id', $id)
                     ->where('status', 'pending')
                     ->select('id')->get();
                 foreach ($children as $key => $child) {
-                    $publishTask[] = $child->id;
+                    $this->pushChange('published', $child->id);
                 }
-                Task::whereIn('id', $publishTask)->update([
-                    'status' => 'published',
-                    'editor_id' => $user['user_uid'],
-                    'updated_at' => now()
-                ]);
                 break;
             case 'running':
                 $task->started_at = now();
                 $task->executor_id = $user['user_uid'];
-                $runningTask[] = $task->id;
+                $this->pushChange('running', $task->id);
                 break;
             case 'done':
-                $doneTask[] = $task->id;
+                $this->pushChange('done', $task->id);
                 $task->finished_at = now();
                 $preTask = [$task->id];
                 //开启父任务
@@ -106,103 +122,100 @@ class TaskStatusController extends Controller
                         ->count();
                     if ($notCompleted === 0) {
                         //父任务已经完成
-                        Task::where('id', $task->parent_id)
-                            ->update([
-                                'status' => 'done',
-                                'editor_id' => $user['user_uid'],
-                                'updated_at' => now(),
-                                'finished_at' => now()
-                            ]);
-                        $doneTask[] = $task->parent_id;
                         $preTask[] = $task->parent_id;
+                        $this->pushChange('done', $task->parent_id);
                     }
                 }
                 //开启后置任务
                 $nextTasks = TaskRelation::whereIn('task_id', $preTask)
-                    ->where('status', 'pending')
                     ->select('next_task_id')->get();
                 foreach ($nextTasks as $key => $value) {
-                    $publishTask[] = $value->next_task_id;
+                    $nextTask = Task::find($value->next_task_id);
+                    if ($nextTask->status === 'pending') {
+                        $this->pushChange('published', $value->next_task_id);
+                    }
                 }
                 //开启后置任务的子任务
-                $nextTasksChildren = Task::whereIn('parent_id', $publishTask)
+                $nextTasksChildren = Task::whereIn('parent_id', $this->getChange('published'))
                     ->where('status', 'pending')
                     ->select('id')->get();
                 foreach ($nextTasksChildren as $child) {
-                    $publishTask[] = $child->id;
+                    $this->pushChange('published', $child->id);
                 }
-                Task::whereIn('id', $publishTask)
-                    ->update([
-                        'status' => 'published',
-                        'editor_id' => $user['user_uid'],
-                        'updated_at' => now()
-                    ]);
 
                 $nextTasks = TaskRelation::whereIn('task_id', $preTask)
-                    ->where('status', 'requested_restart')
                     ->select('next_task_id')->get();
                 foreach ($nextTasks as $key => $value) {
-                    $runningTask[] = $value->next_task_id;
+                    $nextTask = Task::find($value->next_task_id);
+                    if ($nextTask->status === 'requested_restart') {
+                        //$runningTask[] = $value->next_task_id;
+                        $this->pushChange('running', $value->next_task_id);
+                    }
                 }
-                Task::whereIn('id', $runningTask)
-                    ->update([
-                        'status' => 'running',
-                        'editor_id' => $user['user_uid'],
-                        'updated_at' => now()
-                    ]);
                 break;
             case 'requested_restart':
+                $this->pushChange('requested_restart', $task->id);
                 //从新开启前置任务
                 $preTasks = TaskRelation::where('next_task_id', $task->id)
                     ->select('task_id')->get();
                 foreach ($preTasks as $key => $value) {
-                    $restartTask[] = $value->task_id;
+                    //$restartTask[] = $value->task_id;
+                    $this->pushChange('restart', $value->task_id);
                 }
-                Task::whereIn('id', $restartTask)
-                    ->update([
-                        'status' => 'restarted',
-                        'editor_id' => $user['user_uid'],
-                        'updated_at' => now()
-                    ]);
                 break;
         }
         $task->status = $request->get('status');
-        //发送站内信
-        $doneTask[] = $task->id;
-        $doneSend = WatchApi::change(
-            resId: $doneTask,
-            from: $user['user_uid'],
-            message: "任务状态变为 已经完成",
-        );
-
-        $pubSend = WatchApi::change(
-            resId: $publishTask,
-            from: $user['user_uid'],
-            message: "任务状态变为 已经发布",
-        );
-
-        $restartSend = WatchApi::change(
-            resId: $restartTask,
-            from: $user['user_uid'],
-            message: "任务状态变为 已经重启",
-        );
-        $runningSend = WatchApi::change(
-            resId: $runningTask,
-            from: $user['user_uid'],
-            message: "任务状态变为 运行中",
-        );
-
-        Log::debug('watch message', [
-            'done' => $doneSend,
-            'published' => $pubSend,
-            'restarted' => $restartSend,
-            'running' => $runningSend,
-        ]);
-
         $task->editor_id = $user['user_uid'];
         $task->save();
+        # auto start with ai assistant
+        foreach ($this->getChange('published') as $taskId) {
+            $taskAssignee = TaskAssignee::where('task_id', $taskId)
+                ->select('assignee_id')->get();
+            $aiAssistant = AiModel::whereIn('uid', $taskAssignee)->first();
+            if ($aiAssistant) {
+                $aiTask = Task::find($taskId);
+                $aiTask->executor_id = $aiAssistant->uid;
+                $aiTask->status = 'running';
+                $aiTask->save();
+                $this->pushChange('running', $taskId);
+                $params = AiTaskPrepare::translate($taskId);
+                Log::debug('ai running', ['params' => $params]);
+            }
+        }
+
+        $allChanged = [];
+        foreach ($this->changeTasks as $key => $tasksId) {
+            $allChanged = array_merge($allChanged, $tasksId);
+            #change status in related
+            $data = [
+                'status' => $key,
+                'editor_id' => $user['user_uid'],
+                'updated_at' => now(),
+            ];
+            if ($key === 'done') {
+                $data['finished_at'] = now();
+            }
+            if ($key === 'running') {
+                $data['started_at'] = now();
+            }
+            if ($key === 'restart') {
+                $data['finished_at'] = null;
+            }
+            Task::whereIn('id', $tasksId)
+                ->update($data);
+            //发送站内信
+            $send = WatchApi::change(
+                resId: $tasksId,
+                from: $user['user_uid'],
+                message: "任务状态变为 {$key}",
+            );
+            Log::debug('watch message', [
+                'send-to' => $send,
+            ]);
+        }
 
-        $result = Task::whereIn('id', array_merge($doneTask, $publishTask, $restartTask, $runningTask))
+        //changed tasks
+        $result = Task::whereIn('id', $allChanged)
             ->get();
         return $this->ok(
             [

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

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class AiAssistant 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);
+    }
+}

+ 24 - 0
api-v8/app/Http/Resources/AiAssistantResource.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class AiAssistantResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        $data = [
+            'id' => $this->uid,
+            'userName' => $this->real_name,
+            'nickName' => $this->name,
+        ];
+        return $data;
+    }
+}

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

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class ModelLogResource 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);
+    }
+}

+ 1 - 0
api-v8/app/Http/Resources/TaskResource.php

@@ -36,6 +36,7 @@ class TaskResource extends JsonResource
             'description' => $this->description,
             'type' => $this->type,
             'category' => $this->category,
+            'progress' => $this->progress,
             'parent_id' => $this->parent_id,
             'parent' => TaskApi::getById($this->parent_id),
             'roles' => $this->roles,

+ 46 - 0
api-v8/app/Mail/EmailCertif.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Log;
+
+class EmailCertif extends Mailable
+{
+    use Queueable, SerializesModels;
+    protected $uuid;
+    protected $lang;
+    /**
+     * Create a new message instance.
+     *
+     * @return void
+     */
+    public function __construct(string $uuid, string $subject = 'wikipali email certification', string $lang = 'en-US')
+    {
+        //
+        $this->uuid = $uuid;
+        $this->lang = $lang;
+        $this->subject($subject);
+    }
+
+    /**
+     * Build the message.
+     *
+     * @return $this
+     */
+    public function build()
+    {
+        // 生成一个介于 1000 到 9999 之间的随机整数
+        $randomNumber = random_int(1000, 9999);
+        $key = "/email/certification/" . $this->uuid;
+        Log::debug('email certification', ['key' => $key, 'value' => $randomNumber]);
+        RedisClusters::put($key, $randomNumber,  30 * 60);
+        return $this->view('emails.certification.' . $this->lang)
+            ->with([
+                'code' => $randomNumber,
+            ]);
+    }
+}

+ 7 - 9
api-v8/app/Mail/InviteMail.php

@@ -2,9 +2,7 @@
 
 namespace App\Mail;
 
-use App\Models\Invite;
 use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Mail\Mailable;
 use Illuminate\Queue\SerializesModels;
 
@@ -20,15 +18,15 @@ class InviteMail extends Mailable
      *
      * @return void
      */
-    public function __construct(string $uuid,string $subject='wikipali invite email',string $lang='en-US',string $dashboard=null)
+    public function __construct(string $uuid, string $subject = 'wikipali invite email', string $lang = 'en-US', string $dashboard = null)
     {
         //
         $this->uuid = $uuid;
         $this->lang = $lang;
         $this->subject($subject);
-        if($dashboard && !empty($dashboard)){
+        if ($dashboard && !empty($dashboard)) {
             $this->dashboard_url = $dashboard;
-        }else{
+        } else {
             $this->dashboard_url = config('mint.server.dashboard_base_path');
         }
     }
@@ -41,9 +39,9 @@ class InviteMail extends Mailable
     public function build()
     {
 
-        return $this->view('emails.invite.'.$this->lang)
-                    ->with([
-                        'url' => $this->dashboard_url.'/anonymous/users/sign-up/'.$this->uuid,
-                    ]);
+        return $this->view('emails.invite.' . $this->lang)
+            ->with([
+                'url' => $this->dashboard_url . '/anonymous/users/sign-up/' . $this->uuid,
+            ]);
     }
 }

+ 5 - 0
api-v8/app/Models/Invite.php

@@ -8,4 +8,9 @@ use Illuminate\Database\Eloquent\Model;
 class Invite extends Model
 {
     use HasFactory;
+    protected $primaryKey = 'id';
+    protected $casts = [
+        'id' => 'string'
+    ];
+    protected $fillable = ['email', 'id'];
 }

+ 18 - 0
api-v8/app/Models/ModelLog.php

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

+ 44 - 26
api-v8/app/Tools/Tools.php

@@ -1,9 +1,13 @@
 <?php
+
 namespace App\Tools;
+
 use Illuminate\Support\Facades\Log;
 
-class Tools{
-    public static function zip($zipFile,$files){
+class Tools
+{
+    public static function zip($zipFile, $files)
+    {
         $zip = new \ZipArchive;
         $res = $zip->open($zipFile, \ZipArchive::CREATE);
         if ($res === TRUE) {
@@ -16,61 +20,75 @@ class Tools{
             return false;
         }
     }
-    public static function isStop(){
-        if(file_exists(base_path('.stop'))){
+    public static function isStop()
+    {
+        if (file_exists(base_path('.stop'))) {
             Log::debug('.stop exists');
             return true;
-        }else{
+        } else {
             return false;
         }
     }
 
     public static function getWordEn($strIn)
-        {
-            $out = str_replace(["ā","ī","ū","ṅ","ñ","ṭ","ḍ","ṇ","ḷ","ṃ"],
-                            ["a","i","u","n","n","t","d","n","l","m"], $strIn);
-            return ($out);
-        }
-    public static function PaliReal($inStr): string {
+    {
+        $out = str_replace(
+            ["ā", "ī", "ū", "ṅ", "ñ", "ṭ", "ḍ", "ṇ", "ḷ", "ṃ"],
+            ["a", "i", "u", "n", "n", "t", "d", "n", "l", "m"],
+            $strIn
+        );
+        return ($out);
+    }
+    public static function PaliReal($inStr): string
+    {
         if (!is_string($inStr)) {
             return "";
         }
         $paliLetter = "abcdefghijklmnoprstuvyāīūṅñṭḍṇḷṃ";
         $output = [];
         $inStr = strtolower($inStr);
-        for ($i=0; $i < mb_strlen($inStr,"UTF-8"); $i++) {
+        for ($i = 0; $i < mb_strlen($inStr, "UTF-8"); $i++) {
             # code...
-            if(strstr($paliLetter,$inStr[$i]) !==FALSE){
+            if (strstr($paliLetter, $inStr[$i]) !== FALSE) {
                 $output[] = $inStr[$i];
             }
         }
-        return implode('',$output);
+        return implode('', $output);
     }
-    private static function convert($array,$xml){
+    private static function convert($array, $xml)
+    {
         foreach ($array as $key => $line) {
             # code...
-            if(!is_array($line)){
-                $data = $xml->addChild($key,$line);
-            }else{
-                if(isset($line['value'])){
+            if (!is_array($line)) {
+                $data = $xml->addChild($key, $line);
+            } else {
+                if (isset($line['value'])) {
                     $value = $line['value'];
                     unset($line['value']);
-                }else{
+                } else {
                     $value = "";
                 }
-                $obj = $xml->addChild($key,$value);
-                if(isset($line['status'])){
-                    $obj->addAttribute('status',$line['status']);
+                $obj = $xml->addChild($key, $value);
+                if (isset($line['status'])) {
+                    $obj->addAttribute('status', $line['status']);
                     unset($line['status']);
                 }
-                Tools::convert($line,$obj);
+                Tools::convert($line, $obj);
             }
         }
         return $xml;
     }
-    public static function JsonToXml($inArray){
+    public static function JsonToXml($inArray)
+    {
         $xmlObj = simplexml_load_string("<word></word>");
-        $xmlDoc = Tools::convert($inArray,$xmlObj);
+        $xmlDoc = Tools::convert($inArray, $xmlObj);
         return $xmlDoc->asXml();
     }
+
+    public static function MyToRm($input)
+    {
+        $my = ["ႁႏၵ", "ခ္", "ဃ္", "ဆ္", "ဈ္", "ည္", "ဌ္", "ဎ္", "ထ္", "ဓ္", "ဖ္", "ဘ္", "က္", "ဂ္", "စ္", "ဇ္", "ဉ္", "ဠ္", "ဋ္", "ဍ္", "ဏ္", "တ္", "ဒ္", "န္", "ဟ္", "ပ္", "ဗ္", "မ္", "ယ္", "ရ္", "လ္", "ဝ္", "သ္", "င္", "င်္", "ဿ", "ခ", "ဃ", "ဆ", "ဈ", "စျ", "ည", "ဌ", "ဎ", "ထ", "ဓ", "ဖ", "ဘ", "က", "ဂ", "စ", "ဇ", "ဉ", "ဠ", "ဋ", "ဍ", "ဏ", "တ", "ဒ", "န", "ဟ", "ပ", "ဗ", "မ", "ယ", "ရ", "႐", "လ", "ဝ", "သ", "aျ္", "aွ္", "aြ္", "aြ", "ၱ", "ၳ", "ၵ", "ၶ", "ၬ", "ၭ", "ၠ", "ၡ", "ၢ", "ၣ", "ၸ", "ၹ", "ၺ", "႓", "ၥ", "ၧ", "ၨ", "ၩ", "်", "ျ", "ႅ", "ၼ", "ွ", "ႇ", "ႆ", "ၷ", "ၲ", "႒", "႗", "ၯ", "ၮ", "႑", "kaၤ", "gaၤ", "khaၤ", "ghaၤ", "aှ", "aိံ", "aုံ", "aော", "aေါ", "aအံ", "aဣံ", "aဥံ", "aံ", "aာ", "aါ", "aိ", "aီ", "aု", "aဳ", "aူ", "aေ", "အါ", "အာ", "အ", "ဣ", "ဤ", "ဥ", "ဦ", "ဧ", "ဩ", "ႏ", "ၪ", "a္", "္", "aံ", "ေss", "ေkh", "ေgh", "ေch", "ေjh", "ေññ", "ေṭh", "ေḍh", "ေth", "ေdh", "ေph", "ေbh", "ေk", "ေg", "ေc", "ေj", "ေñ", "ေḷ", "ေṭ", "ေḍ", "ေṇ", "ေt", "ေd", "ေn", "ေh", "ေp", "ေb", "ေm", "ေy", "ေr", "ေl", "ေv", "ေs", "ေy", "ေv", "ေr", "ea", "eā", "၁", "၂", "၃", "၄", "၅", "၆", "၇", "၈", "၉", "၀", "း", "့", "။", "၊"];
+        $en = ["ndra", "kh", "gh", "ch", "jh", "ññ", "ṭh", "ḍh", "th", "dh", "ph", "bh", "k", "g", "c", "j", "ñ", "ḷ", "ṭ", "ḍ", "ṇ", "t", "d", "n", "h", "p", "b", "m", "y", "r", "l", "v", "s", "ṅ", "ṅ", "ssa", "kha", "gha", "cha", "jha", "jha", "ñña", "ṭha", "ḍha", "tha", "dha", "pha", "bha", "ka", "ga", "ca", "ja", "ña", "ḷa", "ṭa", "ḍa", "ṇa", "ta", "da", "na", "ha", "pa", "ba", "ma", "ya", "ra", "ra", "la", "va", "sa", "ya", "va", "ra", "ra", "္ta", "္tha", "္da", "္dha", "္ṭa", "္ṭha", "္ka", "္kha", "္ga", "္gha", "္pa", "္pha", "္ba", "္bha", "္ca", "္cha", "္ja", "္jha", "္a", "္ya", "္la", "္ma", "္va", "္ha", "ssa", "na", "ta", "ṭṭha", "ṭṭa", "ḍḍha", "ḍḍa", "ṇḍa", "ṅka", "ṅga", "ṅkha", "ṅgha", "ha", "iṃ", "uṃ", "o", "o", "aṃ", "iṃ", "uṃ", "aṃ", "ā", "ā", "i", "ī", "u", "u", "ū", "e", "ā", "ā", "a", "i", "ī", "u", "ū", "e", "o", "n", "ñ", "", "", "aṃ", "sse", "khe", "ghe", "che", "jhe", "ññe", "ṭhe", "ḍhe", "the", "dhe", "phe", "bhe", "ke", "ge", "ce", "je", "ñe", "ḷe", "ṭe", "ḍe", "ṇe", "te", "de", "ne", "he", "pe", "be", "me", "ye", "re", "le", "ve", "se", "ye", "ve", "re", "e", "o", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "”", "’", ".", ","];
+        return str_replace($my, $en, $input);
+    }
 }

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

@@ -17,10 +17,13 @@ class CreateAiModelsTable extends Migration
             $table->id();
             $table->uuid('uid')->unique();
             $table->string('name', 64)->index();
+            $table->string('real_name', 64)->unique();
+            $table->uuid('avatar',)->nullable()->index();
             $table->text('description')->nullable();
             $table->string('url', 1024)->nullable()->index();
             $table->string('model', 1024)->nullable()->index();
             $table->string('key', 1024)->nullable();
+            $table->text('system_prompt')->nullable();
             $table->string('privacy', 32)->index()->default('private')->comment('隐私性:private|public');
             $table->uuid('owner_id')->index()->comment('任务拥有者:用户或者team-space');
             $table->uuid('editor_id')->index();

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

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateModelLogsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('model_logs', function (Blueprint $table) {
+            $table->id();
+            $table->uuid('uid')->unique();
+            $table->uuid('model_id')->index();
+            $table->json('request_headers');
+            $table->json('request_data');
+            $table->json('response_headers')->nullable();
+            $table->json('response_data')->nullable();
+            $table->integer('status')->index();
+            $table->boolean('success')->default(true);
+            $table->timestamp('request_at')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('model_logs');
+    }
+}

+ 15 - 0
api-v8/resources/views/emails/certification/en-US.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>email certification</title>
+</head>
+
+<body>
+    <div>wikipali email certification</div>
+    <div>wikipali sign up email certification.</div>
+    <div><b>{{ $code }}</b></div>
+    <div>This email is sent automatically by system, please don't reply.</div>
+</body>
+
+</html>

+ 15 - 0
api-v8/resources/views/emails/certification/en.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>email certification</title>
+</head>
+
+<body>
+    <div>wikipali email certification</div>
+    <div>wikipali sign up email certification.</div>
+    <div><b>{{ $code }}</b></div>
+    <div>This email is sent automatically by system, please don't reply.</div>
+</body>
+
+</html>

+ 18 - 0
api-v8/resources/views/emails/certification/zh-Hans.blade.php

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>invite</title>
+</head>
+
+<body>
+    <h2>验证你的电子邮件地址</h2>
+    <div>你好:</div>
+    <div>已收到用此邮箱注册wikipali账号的请求。要完成此流程,请在验证页面输入以下代码:</div>
+    <h3>{{ $code }}</h3>
+    <div>该验证码三十分钟内有效</div>
+    <div>如果你未曾注册wikipali账号,请忽略此邮件。如果你反复收到此邮件,请联系wikipali管理员</div>
+    <div>此邮件为系统自动发送,请勿回复。</div>
+</body>
+
+</html>

+ 15 - 0
api-v8/resources/views/emails/certification/zh-Hant.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>invite</title>
+</head>
+
+<body>
+    <div>wikipali 注册邮箱验证码</div>
+    <div>使用下面的邮箱验证码。</div>
+    <div><b>{{ $code }}</b></div>
+    <div>此郵件為係統自動發送,請勿回複。</div>
+</body>
+
+</html>

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

@@ -112,6 +112,9 @@ use App\Http\Controllers\PaliBookCategoryController;
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\SearchWordSliceController;
 use App\Http\Controllers\AiModelController;
+use App\Http\Controllers\AiAssistantController;
+use App\Http\Controllers\ModelLogController;
+use App\Http\Controllers\EmailCertificationController;
 
 
 
@@ -281,4 +284,7 @@ Route::group(['prefix' => 'v2'], function () {
     Route::apiResource('access-token', AccessTokenController::class);
     Route::apiResource('search-word-slice', SearchWordSliceController::class);
     Route::apiResource('ai-model', AiModelController::class);
+    Route::apiResource('ai-assistant', AiAssistantController::class);
+    Route::apiResource('model-log', ModelLogController::class);
+    Route::apiResource('email-certification', EmailCertificationController::class);
 });

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

@@ -154,6 +154,7 @@ 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 StudioAiModeLog from "./pages/studio/ai/model_logs";
 
 import { ConfigProvider } from "antd";
 import { useAppSelector } from "./hooks";
@@ -376,6 +377,7 @@ const Widget = () => {
             <Route path="models">
               <Route path="list" element={<StudioAiModes />} />
               <Route path=":modelId/edit" element={<StudioAiModeEdit />} />
+              <Route path=":modelId/logs" element={<StudioAiModeLog />} />
             </Route>
           </Route>
           <Route path="setting" element={<StudioSetting />} />

+ 77 - 0
dashboard-v4/dashboard/src/components/ai/AiAssistantSelect.tsx

@@ -0,0 +1,77 @@
+import { ProFormSelect, RequestOptionsType } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import { IUserListResponse } from "../api/Auth";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  multiple?: boolean;
+  hidden?: boolean;
+  hiddenTitle?: boolean;
+  required?: boolean;
+  initialValue?: string | string[] | null;
+  options?: RequestOptionsType[];
+}
+const UserSelectWidget = ({
+  name = "user",
+  multiple = false,
+  width = "md",
+  hidden = false,
+  hiddenTitle = false,
+  required = true,
+  options = [],
+  initialValue,
+}: IWidget) => {
+  const intl = useIntl();
+  console.log("UserSelect options", options);
+  return (
+    <ProFormSelect
+      name={name}
+      label={
+        hiddenTitle
+          ? undefined
+          : intl.formatMessage({ id: "labels.ai-assistant" })
+      }
+      hidden={hidden}
+      width={width}
+      initialValue={initialValue}
+      showSearch
+      debounceTime={300}
+      fieldProps={{
+        mode: multiple ? "tags" : undefined,
+      }}
+      request={async ({ keyWords }) => {
+        console.log("keyWord", keyWords);
+
+        if (typeof keyWords === "string") {
+          const json = await get<IUserListResponse>(
+            `/v2/ai-assistant?keyword=${keyWords}`
+          );
+          console.info("api response user select", json);
+          const userList: RequestOptionsType[] = json.data.rows.map((item) => {
+            return {
+              value: item.id,
+              label: `${item.nickName}`,
+            };
+          });
+          console.log("json", userList);
+          return userList;
+        } else {
+          const defaultOptions: RequestOptionsType[] = options.map((item) => {
+            return { label: item.label, value: item.value?.toString() };
+          });
+          return defaultOptions;
+        }
+      }}
+      rules={[
+        {
+          required: required,
+        },
+      ]}
+    />
+  );
+};
+
+export default UserSelectWidget;

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

@@ -103,6 +103,13 @@ const AiModelEdit = ({ studioName, modelId, onChange }: IWidget) => {
           label={intl.formatMessage({ id: "forms.fields.description.label" })}
         />
       </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="system_prompt"
+          label={"system_prompt"}
+        />
+      </ProForm.Group>
     </ProForm>
   );
 };

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

@@ -220,6 +220,15 @@ const AiModelList = ({ studioName }: IWidget) => {
               return <PublicityIcon value={entity.privacy} />;
             },
           },
+          actions: {
+            render(dom, entity, index, action, schema) {
+              return (
+                <Link to={`/studio/${studioName}/ai/models/${entity.uid}/logs`}>
+                  logs
+                </Link>
+              );
+            },
+          },
         }}
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);

+ 153 - 0
dashboard-v4/dashboard/src/components/ai/AiModelLogList.tsx

@@ -0,0 +1,153 @@
+import { ProList } from "@ant-design/pro-components";
+import { Button, Space, Tabs, Tag, Typography } from "antd";
+import type { Key } from "react";
+import { useState } from "react";
+
+import { CheckOutlined, WarningOutlined } from "@ant-design/icons";
+
+import { IAiModelLogData, IAiModelLogListResponse } from "../api/ai";
+import { get } from "../../request";
+import moment from "moment";
+
+const { Text } = Typography;
+
+interface IWidget {
+  modelId?: string;
+}
+const AiModelLogList = ({ modelId }: IWidget) => {
+  const [expandedRowKeys, setExpandedRowKeys] = useState<readonly Key[]>([]);
+
+  return (
+    <ProList<IAiModelLogData>
+      rowKey="title"
+      headerTitle="logs"
+      expandable={{ expandedRowKeys, onExpandedRowsChange: setExpandedRowKeys }}
+      metas={{
+        title: {},
+        subTitle: {
+          render: (dom, entity, index, action, schema) => {
+            return (
+              <Space size={0}>
+                <Tag color="blue">{entity.status}</Tag>
+              </Space>
+            );
+          },
+        },
+        description: {
+          render: (dom, entity, index, action, schema) => {
+            const jsonView = (text?: string | null) => {
+              return (
+                <div>
+                  <pre>
+                    {text ? JSON.stringify(JSON.parse(text), null, 2) : ""}
+                  </pre>
+                </div>
+              );
+            };
+            const info = (headers: string, data: string) => {
+              return (
+                <div>
+                  <Text strong>Headers</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(headers)}
+                  </div>
+                  <Text strong>Payload</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(data)}
+                  </div>
+                </div>
+              );
+            };
+            return (
+              <>
+                <Tabs
+                  items={[
+                    {
+                      label: "request",
+                      key: "request",
+                      children: (
+                        <div>
+                          {info(entity.request_headers, entity.request_data)}
+                        </div>
+                      ),
+                    },
+                    {
+                      label: "response",
+                      key: "response",
+                      children: (
+                        <div>
+                          {info(
+                            entity.response_headers ?? "",
+                            entity.response_data ?? ""
+                          )}
+                        </div>
+                      ),
+                    },
+                  ]}
+                />
+              </>
+            );
+          },
+        },
+        avatar: {
+          render(dom, entity, index, action, schema) {
+            return (
+              <>
+                {entity.success ? (
+                  <CheckOutlined style={{ color: "green" }} />
+                ) : (
+                  <WarningOutlined color="error" />
+                )}
+              </>
+            );
+          },
+        },
+        actions: {
+          render: (dom, entity, index, action, schema) => {
+            const date = moment(entity.created_at).toLocaleString();
+            return <Text type="secondary">{date}</Text>;
+          },
+        },
+      }}
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/model-log?view=model&id=${modelId}`;
+        const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        console.info("ai model log api request", url);
+        const res = await get<IAiModelLogListResponse>(url);
+        console.info("ai model log api response", res);
+        return {
+          total: res.data.total,
+          succcess: res.ok,
+          data: res.data.rows,
+        };
+      }}
+    />
+  );
+};
+
+export default AiModelLogList;

+ 6 - 0
dashboard-v4/dashboard/src/components/api/Auth.ts

@@ -115,4 +115,10 @@ export interface IInviteResponse {
   data: IInviteData;
 }
 
+export interface IEmailCertificationResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+
 export type TSoftwareEdition = "basic" | "pro";

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

@@ -57,6 +57,7 @@ export interface IAiModel {
 export interface IAiModelRequest {
   name: string;
   description?: string | null;
+  system_prompt?: string | null;
   url?: string | null;
   model?: string;
   key?: string;
@@ -75,3 +76,24 @@ export interface IAiModelResponse {
   message: string;
   data: IAiModel;
 }
+
+export interface IAiModelLogData {
+  id: string;
+  uid: string;
+  model_id: string;
+  request_headers: string;
+  request_data: string;
+  response_headers?: string | null;
+  response_data?: string | null;
+  status: number;
+  success: boolean;
+  request_at: string;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IAiModelLogListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAiModelLogData[]; total: number };
+}

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

@@ -51,6 +51,7 @@ export interface ITaskData {
   title: string;
   description?: string | null;
   category?: TTaskCategory | null;
+  progress?: number;
   html?: string | null;
   type: TTaskType;
   order?: number;

+ 1 - 1
dashboard-v4/dashboard/src/components/discussion/DiscussionCreate.tsx

@@ -42,7 +42,7 @@ export const toIComment = (value: ICommentApiData): IComment => {
 };
 interface IWidget {
   resId?: string;
-  resType?: string;
+  resType?: string; //TODO change
   parent?: string;
   topicId?: string;
   type?: TDiscussionType;

+ 7 - 3
dashboard-v4/dashboard/src/components/like/EditableAvatarGroup.tsx

@@ -6,11 +6,15 @@ import { IDataType, WatchAddButton } from "./WatchAdd";
 interface IWidget {
   users?: IUser[];
   onFinish?: ((formData: IDataType) => Promise<boolean | void>) | undefined;
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
 }
-const EditableAvatarGroup = ({ users, onFinish }: IWidget) => {
+const EditableAvatarGroup = ({ users, onFinish, onDelete }: IWidget) => {
   return (
     <Space>
-      <Popover trigger={"click"} content={<WatchList data={users} />}>
+      <Popover
+        trigger={"click"}
+        content={<WatchList data={users} onDelete={onDelete} />}
+      >
         <div>
           {users?.map((item, id) => {
             return (
@@ -24,7 +28,7 @@ const EditableAvatarGroup = ({ users, onFinish }: IWidget) => {
           })}
         </div>
       </Popover>
-      <WatchAddButton data={users} onFinish={onFinish} />
+      <WatchAddButton data={users} onFinish={onFinish} onDelete={onDelete} />
     </Space>
   );
 };

+ 36 - 6
dashboard-v4/dashboard/src/components/like/WatchAdd.tsx

@@ -1,42 +1,72 @@
 import { useRef } from "react";
-import { ProForm, ProFormInstance } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormDependency,
+  ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
 import { PlusOutlined } from "@ant-design/icons";
 
 import UserSelect from "../template/UserSelect";
 import { Button, Divider, Popover } from "antd";
 import WatchList from "./WatchList";
 import { IUser } from "../auth/User";
+import AiAssistantSelect from "../ai/AiAssistantSelect";
+import { useIntl } from "react-intl";
 
 export interface IDataType {
+  user_type?: "user" | "ai-assistant";
   user_id?: string;
 }
 
 interface IWidget {
   data?: IUser[];
   onFinish?: ((formData: IDataType) => Promise<boolean | void>) | undefined;
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
 }
 
-export const WatchAddButton = ({ data, onFinish }: IWidget) => {
+export const WatchAddButton = ({ data, onFinish, onDelete }: IWidget) => {
   return (
     <Popover
       trigger={"click"}
-      content={<WatchAdd data={data} onFinish={onFinish} />}
+      content={<WatchAdd data={data} onFinish={onFinish} onDelete={onDelete} />}
     >
       <Button type="text" icon={<PlusOutlined />} />
     </Popover>
   );
 };
-const WatchAdd = ({ data, onFinish }: IWidget) => {
+const WatchAdd = ({ data, onFinish, onDelete }: IWidget) => {
+  const intl = useIntl();
   const formRef = useRef<ProFormInstance>();
   return (
     <div>
       <ProForm<IDataType> formRef={formRef} onFinish={onFinish}>
         <ProForm.Group>
-          <UserSelect name="user_id" multiple={false} />
+          <ProFormSelect
+            options={[
+              { label: "用户", value: "user" },
+              {
+                label: intl.formatMessage({ id: "labels.ai-assistant" }),
+                value: "ai-assistant",
+              },
+            ]}
+            width="xs"
+            name="userType"
+            label={"用户类型"}
+          />
+          <ProFormDependency name={["userType"]}>
+            {({ userType }) => {
+              if (userType === "user") {
+                return <UserSelect name="user_id" multiple={false} />;
+              } else {
+                return <AiAssistantSelect name="user_id" multiple={false} />;
+              }
+            }}
+          </ProFormDependency>
         </ProForm.Group>
       </ProForm>
       <Divider />
-      <WatchList data={data} />
+      <WatchList data={data} onDelete={onDelete} />
     </div>
   );
 };

+ 22 - 2
dashboard-v4/dashboard/src/components/like/WatchList.tsx

@@ -2,17 +2,37 @@ import { Button, List } from "antd";
 import { DeleteOutlined } from "@ant-design/icons";
 
 import User, { IUser } from "../auth/User";
+import { useState } from "react";
 
 interface IWidget {
   data?: IUser[];
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
 }
-const WatchList = ({ data }: IWidget) => {
+const WatchList = ({ data, onDelete }: IWidget) => {
+  const [del, setDel] = useState<string>();
   return (
     <List
       dataSource={data}
       renderItem={(item) => (
         <List.Item
-          extra={[<Button type="text" danger icon={<DeleteOutlined />} />]}
+          extra={[
+            <Button
+              type="text"
+              danger
+              loading={item.id === del}
+              icon={<DeleteOutlined />}
+              onClick={() => {
+                console.debug("delete", item);
+                if (typeof onDelete !== "undefined") {
+                  console.debug("delete", item);
+                  setDel(item.id);
+                  onDelete(item).finally(() => {
+                    setDel(undefined);
+                  });
+                }
+              }}
+            />,
+          ]}
         >
           <User {...item} />
         </List.Item>

+ 113 - 81
dashboard-v4/dashboard/src/components/nut/users/SignUp.tsx

@@ -19,7 +19,7 @@ import {
   ISignUpRequest,
 } from "../../api/Auth";
 
-interface IFormData {
+export interface IAccountForm {
   email: string;
   username: string;
   nickname: string;
@@ -27,88 +27,31 @@ interface IFormData {
   password2: string;
   lang: string;
 }
-
-interface IWidget {
-  token?: string;
+interface IAccountInfo {
+  email?: boolean;
 }
-const SignUpWidget = ({ token }: IWidget) => {
+export const AccountInfo = ({ email = true }: IAccountInfo) => {
   const intl = useIntl();
-  const navigate = useNavigate();
-  const [success, setSuccess] = useState(false);
   const [nickname, setNickname] = useState<string>();
-  const formRef = useRef<ProFormInstance>();
-  return success ? (
-    <Result
-      status="success"
-      title="注册成功"
-      subTitle={
-        <Button
-          type="primary"
-          onClick={() => navigate("/anonymous/users/sign-in")}
-        >
-          {intl.formatMessage({
-            id: "nut.users.sign-up.title",
-          })}
-        </Button>
-      }
-    />
-  ) : (
-    <ProForm<IFormData>
-      formRef={formRef}
-      onFinish={async (values: IFormData) => {
-        if (typeof token === "undefined") {
-          return;
-        }
-        if (values.password !== values.password2) {
-          Modal.error({ title: "两次密码不同" });
-          return;
-        }
-        const user = {
-          token: token,
-          username: values.username,
-          nickname: values.nickname ? values.nickname : values.username,
-          email: values.email,
-          password: values.password,
-          lang: values.lang,
-        };
-        const signUp = await post<ISignUpRequest, ISignInResponse>(
-          "/v2/sign-up",
-          user
-        );
-        if (signUp.ok) {
-          setSuccess(true);
-        } else {
-          message.error(signUp.message);
-        }
-      }}
-      request={async () => {
-        const url = `/v2/invite/${token}`;
-        console.info("api request", url);
-        const res = await get<IInviteResponse>(url);
-        console.debug("api response", res.data);
-        return {
-          id: res.data.id,
-          username: "",
-          nickname: "",
-          password: "",
-          password2: "",
-          email: res.data.email,
-          lang: "zh-Hans",
-        };
-      }}
-    >
-      <ProForm.Group>
-        <ProFormText
-          width="md"
-          name="email"
-          required
-          label={intl.formatMessage({
-            id: "forms.fields.email.label",
-          })}
-          rules={[{ required: true, max: 255, min: 4 }]}
-          disabled
-        />
-      </ProForm.Group>
+
+  return (
+    <>
+      {email ? (
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="email"
+            required
+            label={intl.formatMessage({
+              id: "forms.fields.email.label",
+            })}
+            rules={[{ required: true, max: 255, min: 4 }]}
+            disabled
+          />
+        </ProForm.Group>
+      ) : (
+        <></>
+      )}
       <ProForm.Group>
         <ProFormText
           width="md"
@@ -188,10 +131,99 @@ const SignUpWidget = ({ token }: IWidget) => {
           }}
         </ProFormDependency>
       </ProForm.Group>
-
       <ProForm.Group>
         <LangSelect label="常用的译文语言" />
       </ProForm.Group>
+    </>
+  );
+};
+
+export const SignUpSuccess = () => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  return (
+    <Result
+      status="success"
+      title="注册成功"
+      subTitle={
+        <Button
+          type="primary"
+          onClick={() => navigate("/anonymous/users/sign-in")}
+        >
+          {intl.formatMessage({
+            id: "nut.users.sign-in.title",
+          })}
+        </Button>
+      }
+    />
+  );
+};
+export const onSignIn = async (token: string, values: IAccountForm) => {
+  if (values.password !== values.password2) {
+    Modal.error({ title: "两次密码不同" });
+    return false;
+  }
+  const url = "/v2/sign-up";
+  const data = {
+    token: token,
+    username: values.username,
+    nickname:
+      values.nickname && values.nickname.trim() !== ""
+        ? values.nickname
+        : values.username,
+    email: values.email,
+    password: values.password,
+    lang: values.lang,
+  };
+  console.info("api request", url, data);
+  const signUp = await post<ISignUpRequest, ISignInResponse>(
+    "/v2/sign-up",
+    data
+  );
+  console.info("api response", signUp);
+  return signUp;
+};
+interface IWidget {
+  token?: string;
+}
+const SignUpWidget = ({ token }: IWidget) => {
+  const [success, setSuccess] = useState(false);
+  const formRef = useRef<ProFormInstance>();
+  return success ? (
+    <SignUpSuccess />
+  ) : (
+    <ProForm<IAccountForm>
+      formRef={formRef}
+      onFinish={async (values: IAccountForm) => {
+        if (typeof token === "undefined") {
+          return;
+        }
+        const signUp = await onSignIn(token, values);
+        if (signUp) {
+          if (signUp.ok) {
+            setSuccess(true);
+          } else {
+            message.error(signUp.message);
+          }
+        }
+      }}
+      request={async () => {
+        const url = `/v2/invite/${token}`;
+        console.info("api request", url);
+        const res = await get<IInviteResponse>(url);
+        console.debug("api response", res.data);
+        return {
+          id: res.data.id,
+          username: "",
+          nickname: "",
+          password: "",
+          password2: "",
+          email: res.data.email,
+          lang: "zh-Hans",
+        };
+      }}
+    >
+      <AccountInfo />
     </ProForm>
   );
 };

+ 28 - 0
dashboard-v4/dashboard/src/components/task/Assignees.tsx

@@ -18,6 +18,34 @@ const Assignees = ({ task, onChange }: IWidget) => {
     <>
       <EditableAvatarGroup
         users={data ?? undefined}
+        onDelete={async (user: IUser) => {
+          if (!task) {
+            console.error("no task");
+            return;
+          }
+          let users: string[] = [];
+          if (task.assignees_id) {
+            users = task.assignees_id.filter((value) => value !== user.id);
+          }
+          let setting: ITaskUpdateRequest = {
+            id: task.id,
+            studio_name: "",
+            assignees_id: users,
+          };
+          const url = `/v2/task/${setting.id}`;
+          console.info("api request", url, setting);
+          patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then(
+            (json) => {
+              console.info("api response", json);
+              if (json.ok) {
+                message.success("Success");
+                onChange && onChange([json.data]);
+              } else {
+                message.error(json.message);
+              }
+            }
+          );
+        }}
         onFinish={async (values: IDataType) => {
           if (!task) {
             console.error("no task");

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

@@ -100,7 +100,6 @@ const TaskReader = ({ taskId, onChange, onEdit }: IWidget) => {
     <div>
       <div style={{ display: "flex", justifyContent: "space-between" }}>
         <Space>
-          <Category task={task} />
           <TaskStatus task={task} />
           <Milestone task={task} />
           <PreTask

+ 40 - 8
dashboard-v4/dashboard/src/components/task/TaskStatus.tsx

@@ -1,9 +1,32 @@
-import { Tag } from "antd";
-import { ITaskData } from "../api/task";
+import { Progress, Tag } from "antd";
+import { ITaskData, ITaskResponse } from "../api/task";
 import { useIntl } from "react-intl";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
 
 const TaskStatus = ({ task }: { task?: ITaskData }) => {
   const intl = useIntl();
+  const [progress, setProgress] = useState(task?.progress);
+
+  useEffect(() => {
+    if (!task?.id) {
+      return;
+    }
+    const query = () => {
+      const url = `/v2/task/${task?.id}`;
+      console.info("api request", url);
+      get<ITaskResponse>(url).then((json) => {
+        console.log("api response", json);
+        if (json.ok) {
+          setProgress(json.data.progress);
+        }
+      });
+    };
+    let timer = setInterval(query, 1000 * (60 + Math.random() * 10));
+    return () => {
+      clearInterval(timer);
+    };
+  }, [task?.id]);
 
   let color = "";
   switch (task?.status) {
@@ -27,12 +50,21 @@ const TaskStatus = ({ task }: { task?: ITaskData }) => {
       break;
   }
   return (
-    <Tag color={color}>
-      {intl.formatMessage({
-        id: `labels.task.status.${task?.status}`,
-        defaultMessage: "unknown",
-      })}
-    </Tag>
+    <>
+      <Tag color={color}>
+        {intl.formatMessage({
+          id: `labels.task.status.${task?.status}`,
+          defaultMessage: "unknown",
+        })}
+      </Tag>
+      {task?.status === "running" ? (
+        <div style={{ display: "inline-block", width: 80 }}>
+          <Progress percent={progress} size="small" showInfo={false} />
+        </div>
+      ) : (
+        <></>
+      )}
+    </>
   );
 };
 

+ 135 - 27
dashboard-v4/dashboard/src/components/users/SignUp.tsx

@@ -1,19 +1,33 @@
 import { useRef, useState } from "react";
 import { useIntl } from "react-intl";
-import { Alert, Button, Result, message } from "antd";
+import { Alert, Button, message } from "antd";
 import type { ProFormInstance } from "@ant-design/pro-components";
 import {
   CheckCard,
   ProForm,
+  ProFormCaptcha,
   ProFormCheckbox,
   ProFormText,
   StepsForm,
 } from "@ant-design/pro-components";
 
-import { post } from "../../request";
-import { IInviteRequest, IInviteResponse } from "../api/Auth";
+import { MailOutlined, LockOutlined } from "@ant-design/icons";
+
+import { get, post } from "../../request";
+import {
+  IEmailCertificationResponse,
+  IInviteData,
+  IInviteRequest,
+  IInviteResponse,
+} from "../api/Auth";
 import { dashboardBasePath } from "../../utils";
 import { get as getUiLang } from "../../locales";
+import {
+  AccountInfo,
+  IAccountForm,
+  onSignIn,
+  SignUpSuccess,
+} from "../nut/users/SignUp";
 
 interface IFormData {
   email: string;
@@ -25,6 +39,7 @@ const SingUpWidget = () => {
   const formRef = useRef<ProFormInstance>();
   const [error, setError] = useState<string>();
   const [agree, setAgree] = useState(false);
+  const [invite, setInvite] = useState<IInviteData>();
   return (
     <StepsForm<IFormData>
       formRef={formRef}
@@ -46,7 +61,7 @@ const SingUpWidget = () => {
                 {"下一步"}
               </Button>
             );
-          } else if (props.step === 2) {
+          } else if (props.step === 3) {
             return <></>;
           } else {
             return dom;
@@ -85,8 +100,8 @@ const SingUpWidget = () => {
                 <div>✅经文阅读</div>
                 <div>✅字典</div>
                 <div>✅经文搜索</div>
-                <div>❌课程</div>
                 <div>❌翻译</div>
+                <div>❌参加课程</div>
               </div>
             }
             value="B"
@@ -142,29 +157,31 @@ const SingUpWidget = () => {
       </StepsForm.StepForm>
 
       <StepsForm.StepForm<{
-        checkbox: string;
+        email: string;
+        captcha: number;
       }>
         name="checkbox"
         title={intl.formatMessage({ id: "auth.sign-up.email-certification" })}
         stepProps={{
           description: " ",
         }}
-        onFinish={async () => {
-          const values = formRef.current?.getFieldsValue();
-          const url = `/v2/invite`;
-          const data: IInviteRequest = {
-            email: values.email,
-            lang: getUiLang(),
-            subject: intl.formatMessage({ id: "labels.email.sign-up.subject" }),
-            studio: "",
-            dashboard: dashboardBasePath(),
-          };
-          console.info("api request", values);
+        onFinish={async (value) => {
+          if (!invite) {
+            message.error("无效的id");
+            return false;
+          }
+          const url = `/v2/email-certification/${invite?.id}`;
+          console.info("api request email-certification", url);
           try {
-            const res = await post<IInviteRequest, IInviteResponse>(url, data);
+            const res = await get<IEmailCertificationResponse>(url);
             console.debug("api response", res);
             if (res.ok) {
-              message.success(intl.formatMessage({ id: "flashes.success" }));
+              if (res.data === value.captcha) {
+                message.success(intl.formatMessage({ id: "flashes.success" }));
+              } else {
+                setError("验证码不正确");
+              }
+              //建立账号
             } else {
               setError(intl.formatMessage({ id: `error.${res.message}` }));
             }
@@ -178,10 +195,13 @@ const SingUpWidget = () => {
         {error ? <Alert type="error" message={error} /> : undefined}
         <ProForm.Group>
           <ProFormText
-            width="md"
+            fieldProps={{
+              size: "large",
+              prefix: <MailOutlined />,
+            }}
             name="email"
             required
-            label={intl.formatMessage({ id: "forms.fields.email.label" })}
+            placeholder={intl.formatMessage({ id: "forms.fields.email.label" })}
             rules={[
               {
                 required: true,
@@ -190,17 +210,105 @@ const SingUpWidget = () => {
             ]}
           />
         </ProForm.Group>
+        <ProForm.Group>
+          <ProFormCaptcha
+            fieldProps={{
+              size: "large",
+              prefix: <LockOutlined />,
+            }}
+            captchaProps={{
+              size: "large",
+            }}
+            placeholder={"请输入验证码"}
+            captchaTextRender={(timing, count) => {
+              if (timing) {
+                return `${count} ${"获取验证码"}`;
+              }
+              return "获取验证码";
+            }}
+            name="captcha"
+            rules={[
+              {
+                required: true,
+                message: "请输入验证码!",
+              },
+            ]}
+            onGetCaptcha={async () => {
+              const values = formRef.current?.getFieldsValue();
+              const url = `/v2/email-certification`;
+              const data: IInviteRequest = {
+                email: values.email,
+                lang: getUiLang(),
+                subject: intl.formatMessage({
+                  id: "labels.email.sign-up.subject",
+                }),
+                studio: "",
+                dashboard: dashboardBasePath(),
+              };
+              console.info("api request", values);
+              try {
+                const res = await post<IInviteRequest, IInviteResponse>(
+                  url,
+                  data
+                );
+                console.debug("api response", res);
+                if (res.ok) {
+                  setInvite(res.data);
+                  message.success(
+                    "邮件发送成功,请登录此邮箱查收邮件,并将邮件中的验证码填入。"
+                  );
+                } else {
+                  setError(intl.formatMessage({ id: `error.${res.message}` }));
+                  message.error("邮件发送失败");
+                }
+              } catch (error) {
+                setError(error as string);
+                message.error("邮件发送失败");
+              }
+            }}
+          />
+        </ProForm.Group>
+      </StepsForm.StepForm>
+      <StepsForm.StepForm<IAccountForm>
+        name="info"
+        title={intl.formatMessage({ id: "auth.sign-up.info" })}
+        onFinish={async (values: IAccountForm) => {
+          if (typeof invite === "undefined") {
+            return false;
+          }
+          values.email = invite.email;
+          const signUp = await onSignIn(invite.id, values);
+          if (signUp) {
+            if (signUp.ok) {
+              return true;
+            } else {
+              message.error(signUp.message);
+              return false;
+            }
+          } else {
+            return false;
+          }
+        }}
+        request={async () => {
+          console.debug("account info", invite);
+          return {
+            id: invite ? invite.id : "",
+            username: "",
+            nickname: "",
+            password: "",
+            password2: "",
+            email: invite ? invite.email : "",
+            lang: "zh-Hant",
+          };
+        }}
+      >
+        <AccountInfo email={false} />
       </StepsForm.StepForm>
-
       <StepsForm.StepForm
         name="finish"
         title={intl.formatMessage({ id: "labels.done" })}
       >
-        <Result
-          status="success"
-          title="注册邮件已经成功发送"
-          subTitle="请查收邮件,根据提示完成注册。"
-        />
+        <SignUpSuccess />
       </StepsForm.StepForm>
     </StepsForm>
   );

+ 1 - 0
dashboard-v4/dashboard/src/locales/en-US/auth/index.ts

@@ -13,6 +13,7 @@ const items = {
   "auth.type.user": "user",
   "auth.type.group": "group",
   "auth.sign-up.email-certification": "E-Mail certification",
+  "auth.sign-up.info": "完善个人信息",
 };
 
 export default items;

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

@@ -75,6 +75,7 @@ const items = {
   "labels.task.category.team": "team",
   "labels.task.category.review": "review",
   "labels.task.category.proofread": "proofread",
+  "labels.ai-assistant": "AI Assistant",
 };
 
 export default items;

+ 1 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/auth/index.ts

@@ -13,6 +13,7 @@ const items = {
   "auth.type.user": "用户",
   "auth.type.group": "群组",
   "auth.sign-up.email-certification": "邮箱验证",
+  "auth.sign-up.info": "完善个人信息",
 };
 
 export default items;

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

@@ -83,6 +83,7 @@ const items = {
   "labels.task.category.team": "术语",
   "labels.task.category.review": "审稿",
   "labels.task.category.proofread": "proofread",
+  "labels.ai-assistant": "人工智能助手",
 };
 
 export default items;

+ 1 - 1
dashboard-v4/dashboard/src/pages/nut/users/sign-in.tsx

@@ -9,7 +9,7 @@ const Widget = () => {
     <div>
       <Card
         title={intl.formatMessage({
-          id: "nut.users.sign-up.title",
+          id: "nut.users.sign-in.title",
         })}
       >
         <Space direction="vertical">

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

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