Browse Source

Merge pull request #2286 from visuddhinanda/development

Development
visuddhinanda 11 months ago
parent
commit
25b13e9975
24 changed files with 518 additions and 260 deletions
  1. 5 6
      api-v8/app/Console/Commands/ExportAiPaliWordToken.php
  2. 11 0
      api-v8/app/Console/Commands/ExportAiTrainingData.php
  3. 1 2
      api-v8/app/Console/Commands/ExportZip.php
  4. 3 1
      api-v8/app/Console/Commands/MqAiTranslate.php
  5. 68 63
      api-v8/app/Console/Commands/MqPr.php
  6. 0 17
      api-v8/app/Http/Api/Mq.php
  7. 2 1
      api-v8/app/Http/Controllers/ProjectController.php
  8. 114 107
      api-v8/app/Http/Controllers/SentPrController.php
  9. 1 0
      api-v8/app/Http/Controllers/TaskStatusController.php
  10. 183 0
      api-v8/app/Services/AiChatService.php
  11. 2 18
      dashboard-v4/dashboard/src/components/ai/AiModelList.tsx
  12. 1 2
      dashboard-v4/dashboard/src/components/nut/users/ResetPassword.tsx
  13. 1 0
      dashboard-v4/dashboard/src/components/share/Share.tsx
  14. 20 6
      dashboard-v4/dashboard/src/components/share/ShareModal.tsx
  15. 28 20
      dashboard-v4/dashboard/src/components/studio/LeftSider.tsx
  16. 34 12
      dashboard-v4/dashboard/src/components/task/ProjectTable.tsx
  17. 31 1
      dashboard-v4/dashboard/src/components/task/ProjectTask.tsx
  18. 1 0
      dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx
  19. 5 1
      dashboard-v4/dashboard/src/components/task/TaskStatus.tsx
  20. 2 0
      dashboard-v4/dashboard/src/locales/en-US/buttons.ts
  21. 2 0
      dashboard-v4/dashboard/src/locales/zh-Hans/buttons.ts
  22. 1 1
      dashboard-v4/dashboard/src/pages/studio/ai/index.tsx
  23. 1 1
      dashboard-v4/dashboard/src/pages/studio/setting/index.tsx
  24. 1 1
      dashboard-v4/dashboard/src/pages/studio/task/projects.tsx

+ 5 - 6
api-v8/app/Console/Commands/ExportAiPaliWordToken.php

@@ -40,7 +40,6 @@ class ExportAiPaliWordToken extends Command
      */
     public function handle()
     {
-        $this->info('export ai pali word token');
         Log::debug('export ai pali word token');
 
         if (\App\Tools\Tools::isStop()) {
@@ -50,16 +49,16 @@ class ExportAiPaliWordToken extends Command
         if (!is_dir($exportDir)) {
             $res = mkdir($exportDir, 0755, true);
             if (!$res) {
-                $this->error('mkdir fail path=' . $exportDir);
+                Log::error('mkdir fail path=' . $exportDir);
                 return 1;
             } else {
-                $this->info('make dir successful ' . $exportDir);
+                Log::info('make dir successful ' . $exportDir);
             }
         }
 
         $dict_id = DictApi::getSysDict('system_preference');
         if (!$dict_id) {
-            $this->error('没有找到 system_preference 字典');
+            Log::error('没有找到 system_preference 字典');
             return 1;
         }
 
@@ -67,7 +66,7 @@ class ExportAiPaliWordToken extends Command
         $exportFile = $exportDir . '/' . $filename;
         $fp = fopen($exportFile, 'w');
         if ($fp === false) {
-            $this->error('无法创建文件');
+            Log::error('无法创建文件');
             return 1;
         }
 
@@ -87,7 +86,7 @@ class ExportAiPaliWordToken extends Command
             }
         }
         fclose($fp);
-        $this->info((time() - $start) . ' seconds');
+        Log::info((time() - $start) . ' seconds');
 
         $this->call('export:zip', [
             'id' => 'ai-pali-word-token',

+ 11 - 0
api-v8/app/Console/Commands/ExportAiTrainingData.php

@@ -43,6 +43,17 @@ class ExportAiTrainingData extends Command
     public function handle()
     {
         Log::debug('task export offline sentence-table start');
+        //创建文件夹
+        $exportDir = storage_path('app/tmp/export/offline');
+        if (!is_dir($exportDir)) {
+            $res = mkdir($exportDir, 0755, true);
+            if (!$res) {
+                $this->error('mkdir fail path=' . $exportDir);
+                return 1;
+            } else {
+                $this->info('make dir successful ' . $exportDir);
+            }
+        }
         $filename = 'wikipali-offline-ai-training-' . date("Y-m-d") . '.tsv';
         $exportFile = storage_path('app/tmp/export/offline/' . $filename);
         $fp = fopen($exportFile, 'w');

+ 1 - 2
api-v8/app/Console/Commands/ExportZip.php

@@ -45,7 +45,6 @@ class ExportZip extends Command
     public function handle()
     {
         Log::debug('export offline: 开始压缩');
-        $this->info('export offline: 开始压缩');
         $defaultExportPath = storage_path('app/public/export/offline');
         $exportFile = $this->argument('filename');
         $filename = basename($exportFile);
@@ -116,7 +115,7 @@ class ExportZip extends Command
         $this->info(implode(' ', $command));
         Log::debug('export offline zip start', ['command' => $command, 'format' => $this->argument('format')]);
         $process = new Process($command);
-	$process->setTimeout(60*60*6);
+        $process->setTimeout(60 * 60 * 6);
         $process->run();
         $this->info($process->getOutput());
         $this->info('压缩完成');

+ 3 - 1
api-v8/app/Console/Commands/MqAiTranslate.php

@@ -194,7 +194,9 @@ class MqAiTranslate extends Command
                         'begin' => $message->sentence->word_start,
                         'end' => $message->sentence->word_end,
                         'channel' => $message->sentence->channel_uid,
-                        'text' => $responseContent
+                        'text' => $responseContent,
+                        'notification' => false,
+                        'webhook' => false,
                     ]);
                     if ($response->failed()) {
                         Log::error($queue . ' sentence update failed', [

+ 68 - 63
api-v8/app/Console/Commands/MqPr.php

@@ -89,79 +89,84 @@ class MqPr extends Command
 
             $result = 0;
             //发送站内信
-            try {
-                $sendTo = array();
-                if ($prData->editor->id !== $prData->channel->studio_id) {
-                    $sendTo[] = $prData->channel->studio_id;
-                }
-                if ($orgText) {
-                    //原文作者
-                    if (
-                        !in_array($orgText->editor_uid, $sendTo) &&
-                        $orgText->editor_uid !== $prData->editor->id
-                    ) {
-                        $sendTo[] = $orgText->editor_uid;
+            if ($message->webhook) {
+
+                try {
+                    $sendTo = array();
+                    if ($prData->editor->id !== $prData->channel->studio_id) {
+                        $sendTo[] = $prData->channel->studio_id;
                     }
-                    //原文采纳者
-                    if (
-                        !empty($orgText->acceptor_uid) &&
-                        !in_array($orgText->acceptor_uid, $sendTo) &&
-                        $orgText->acceptor_uid !== $prData->editor->id
-                    ) {
-                        $sendTo[] = $orgText->acceptor_uid;
+                    if ($orgText) {
+                        //原文作者
+                        if (
+                            !in_array($orgText->editor_uid, $sendTo) &&
+                            $orgText->editor_uid !== $prData->editor->id
+                        ) {
+                            $sendTo[] = $orgText->editor_uid;
+                        }
+                        //原文采纳者
+                        if (
+                            !empty($orgText->acceptor_uid) &&
+                            !in_array($orgText->acceptor_uid, $sendTo) &&
+                            $orgText->acceptor_uid !== $prData->editor->id
+                        ) {
+                            $sendTo[] = $orgText->acceptor_uid;
+                        }
+                    }
+                    if (count($sendTo) > 0) {
+                        $sendCount = NotificationController::insert(
+                            from: $prData->editor->id,
+                            to: $sendTo,
+                            res_type: 'suggestion',
+                            res_id: $prData->uid,
+                            channel: $prData->channel->id
+                        );
                     }
-                }
-                if (count($sendTo) > 0) {
-                    $sendCount = NotificationController::insert(
-                        from: $prData->editor->id,
-                        to: $sendTo,
-                        res_type: 'suggestion',
-                        res_id: $prData->uid,
-                        channel: $prData->channel->id
-                    );
-                }
 
-                $this->info("send notification success to [" . count($sendTo) . '] users');
-            } catch (\Exception $e) {
-                $this->error('send notification failed');
-                Log::error('send notification failed', ['exception' => $e]);
+                    $this->info("send notification success to [" . count($sendTo) . '] users');
+                } catch (\Exception $e) {
+                    $this->error('send notification failed');
+                    Log::error('send notification failed', ['exception' => $e]);
+                }
             }
 
             //发送webhook
-
-            $webhooks = WebHook::where('res_id', $prData->channel->id)
-                ->where('status', 'active')
-                ->get();
+            if ($message->webhook) {
+                $webhooks = WebHook::where('res_id', $prData->channel->id)
+                    ->where('status', 'active')
+                    ->get();
 
 
-            foreach ($webhooks as $key => $hook) {
-                $event = json_decode($hook->event);
-                if (!in_array('pr', $event)) {
-                    continue;
-                }
-                $command = '';
-                $whSend = new WebHookSend;
-                switch ($hook->receiver) {
-                    case 'dingtalk':
-                        $ok = $whSend->dingtalk($hook->url, $msgTitle, $msgContent);
-                        break;
-                    case 'wechat':
-                        $ok = $whSend->wechat($hook->url, null, $msgContent);
-                        break;
-                    default:
-                        $ok = 2;
-                        break;
-                }
-                $this->info("{$command}  ok={$ok}");
-                $result += $ok;
-                if ($ok === 0) {
-                    Log::debug('mq:pr: send success {url}', ['url' => $hook->url]);
-                    WebHook::where('id', $hook->id)->increment('success');
-                } else {
-                    Log::error('mq:pr: send fail {url}', ['url' => $hook->url]);
-                    WebHook::where('id', $hook->id)->increment('fail');
+                foreach ($webhooks as $key => $hook) {
+                    $event = json_decode($hook->event);
+                    if (!in_array('pr', $event)) {
+                        continue;
+                    }
+                    $command = '';
+                    $whSend = new WebHookSend;
+                    switch ($hook->receiver) {
+                        case 'dingtalk':
+                            $ok = $whSend->dingtalk($hook->url, $msgTitle, $msgContent);
+                            break;
+                        case 'wechat':
+                            $ok = $whSend->wechat($hook->url, null, $msgContent);
+                            break;
+                        default:
+                            $ok = 2;
+                            break;
+                    }
+                    $this->info("{$command}  ok={$ok}");
+                    $result += $ok;
+                    if ($ok === 0) {
+                        Log::debug('mq:pr: send success {url}', ['url' => $hook->url]);
+                        WebHook::where('id', $hook->id)->increment('success');
+                    } else {
+                        Log::error('mq:pr: send fail {url}', ['url' => $hook->url]);
+                        WebHook::where('id', $hook->id)->increment('fail');
+                    }
                 }
             }
+
             return $result;
         });
         return 0;

+ 0 - 17
api-v8/app/Http/Api/Mq.php

@@ -138,23 +138,6 @@ class Mq
                         'message_id' => $message->get('message_id'),
                         'exception' => $e
                     ]);
-
-                    // push to issues
-                    /*
-                    $channelName = 'issues';
-                    $channelIssues = $connection->channel();
-                    $channelIssues->queue_declare($channelName, false, true, false, false);
-
-                    $msg = new AMQPMessage(json_encode([
-                        'exchange' => $exchange,
-                        'channel' => $queue,
-                        'message' => json_decode($message->body),
-                        'result' => 0,
-                        'error' => $e,
-                    ], JSON_UNESCAPED_UNICODE));
-                    $channelIssues->basic_publish($msg, '', $channelName);
-                    $channelIssues->close();
-                    */
                 }
 
                 if (\App\Tools\Tools::isStop()) {

+ 2 - 1
api-v8/app/Http/Controllers/ProjectController.php

@@ -43,7 +43,8 @@ class ProjectController extends Controller
                     ->orWhereJsonContains('path', $request->get('project_id'));
                 break;
             case 'shared':
-                $resList = ShareApi::getResList($studioId, 6);
+                $type = $request->get('type', 'instance');
+                $resList = ShareApi::getResList($studioId, $type === 'instance' ? 7 : 6);
                 $resId = [];
                 foreach ($resList as $res) {
                     $resId[] = $res['res_id'];

+ 114 - 107
api-v8/app/Http/Controllers/SentPrController.php

@@ -29,59 +29,60 @@ class SentPrController extends Controller
         //
         switch ($request->get('view')) {
             case 'sent-info':
-                $table = SentPr::where('book_id',$request->get('book'))
-                                ->where('paragraph',$request->get('para'))
-                                ->where('word_start',$request->get('start'))
-                                ->where('word_end',$request->get('end'))
-                                ->where('channel_uid',$request->get('channel'));
+                $table = SentPr::where('book_id', $request->get('book'))
+                    ->where('paragraph', $request->get('para'))
+                    ->where('word_start', $request->get('start'))
+                    ->where('word_end', $request->get('end'))
+                    ->where('channel_uid', $request->get('channel'));
                 $all_count = $table->count();
-                $result = $table->orderBy('created_at','desc')->get();
+                $result = $table->orderBy('created_at', 'desc')->get();
 
                 break;
         }
-        if($result){
+        if ($result) {
             //修改notification 已读状态
             $user = AuthApi::current($request);
-            if($user){
-                $id=array();
+            if ($user) {
+                $id = array();
                 foreach ($result as $key => $row) {
                     $id[] = $row->uid;
                 }
-                Notification::whereIn('res_id',$id)
-                            ->where('to',$user['user_uid'])
-                            ->update(['status'=>'read']);
+                Notification::whereIn('res_id', $id)
+                    ->where('to', $user['user_uid'])
+                    ->update(['status' => 'read']);
             }
             return $this->ok([
-                    "rows"=>SentPrResource::collection($result),
-                    "count"=>$all_count
-                ]);
-        }else{
+                "rows" => SentPrResource::collection($result),
+                "count" => $all_count
+            ]);
+        } else {
             return $this->error("no data");
         }
     }
 
-    public function pr_tree(Request $request){
+    public function pr_tree(Request $request)
+    {
         $output = [];
         $sentences = $request->get("data");
         foreach ($sentences as $key => $sentence) {
             # 先查句子信息
-            $sentInfo = Sentence::where('book_id',$sentence['book'])
-                                ->where('paragraph',$sentence['paragraph'])
-                                ->where('word_start',$sentence['word_start'])
-                                ->where('word_end',$sentence['word_end'])
-                                ->where('channel_uid',$sentence['channel_id'])
-                                ->first();
-            $sentPr = SentPr::where('book_id',$sentence['book'])
-                            ->where('paragraph',$sentence['paragraph'])
-                            ->where('word_start',$sentence['word_start'])
-                            ->where('word_end',$sentence['word_end'])
-                            ->where('channel_uid',$sentence['channel_id'])
-                            ->select('content','editor_uid')
-                            ->orderBy('created_at','desc')->get();
-            if(count($sentPr)>0){
-                if($sentInfo){
+            $sentInfo = Sentence::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->first();
+            $sentPr = SentPr::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->select('content', 'editor_uid')
+                ->orderBy('created_at', 'desc')->get();
+            if (count($sentPr) > 0) {
+                if ($sentInfo) {
                     $content = $sentInfo->content;
-                }else{
+                } else {
                     $content = "null";
                 }
                 $output[] = [
@@ -97,9 +98,8 @@ class SentPrController extends Controller
                     'pr' => $sentPr,
                 ];
             }
-
         }
-        return $this->ok(['rows'=>$output,'count'=>count($output)]);
+        return $this->ok(['rows' => $output, 'count' => count($output)]);
     }
     /**
      * Store a newly created resource in storage.
@@ -111,25 +111,25 @@ class SentPrController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),401,401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
         }
         $user_uid = $user['user_uid'];
 
         $data = $request->all();
 
 
-		#查询是否存在
-		#同样的内容只能提交一次
-		$exists = SentPr::where('book_id',$data['book'])
-						->where('paragraph',$data['para'])
-						->where('word_start',$data['begin'])
-						->where('word_end',$data['end'])
-						->where('content',$data['text'])
-						->where('channel_uid',$data['channel'])
-						->exists();
-        if($exists){
-            return $this->error("已经存在同样的修改建议",200,200);
+        #查询是否存在
+        #同样的内容只能提交一次
+        $exists = SentPr::where('book_id', $data['book'])
+            ->where('paragraph', $data['para'])
+            ->where('word_start', $data['begin'])
+            ->where('word_end', $data['end'])
+            ->where('content', $data['text'])
+            ->where('channel_uid', $data['channel'])
+            ->exists();
+        if ($exists) {
+            return $this->error("已经存在同样的修改建议", 200, 200);
         }
 
         #不存在,新建
@@ -143,33 +143,41 @@ class SentPrController extends Controller
         $new->channel_uid = $data['channel'];
         $new->editor_uid = $user_uid;
         $new->content = $data['text'];
-        $new->language = Channel::where('uid',$data['channel'])->value('lang');
-        $new->status = 1;//未处理状态
-        $new->strlen = mb_strlen($data['text'],"UTF-8");
-        $new->create_time = time()*1000;
-        $new->modify_time = time()*1000;
+        $new->language = Channel::where('uid', $data['channel'])->value('lang');
+        $new->status = 1; //未处理状态
+        $new->strlen = mb_strlen($data['text'], "UTF-8");
+        $new->create_time = time() * 1000;
+        $new->modify_time = time() * 1000;
         $new->save();
-        Mq::publish('suggestion',['data'=>new SentPrResource($new),
-                                  'token'=>AuthApi::getToken($request)]);
 
-		$robotMessageOk=true;
-		$webHookMessage="";
+        $suggestionData =  [
+            'data' => new SentPrResource($new),
+            'token' => AuthApi::getToken($request),
+            'notification' => $request->get('notification', true),
+            'webhook' => $request->get('webhook', true),
+        ];
+        Mq::publish(
+            'suggestion',
+            $suggestionData
+        );
 
-		#同时返回此句子pr数量
-		$info['book_id'] = $data['book'];
-		$info['paragraph'] = $data['para'];
-		$info['word_start'] = $data['begin'];
-		$info['word_end'] = $data['end'];
-		$info['channel_uid'] = $data['channel'];
-		$count = SentPr::where('book_id' , $data['book'])
-						->where('paragraph' , $data['para'])
-						->where('word_start' , $data['begin'])
-						->where('word_end' , $data['end'])
-						->where('channel_uid' , $data['channel'])
-						->count();
+        $robotMessageOk = true;
+        $webHookMessage = "";
 
-		return $this->ok(["new"=>$info,"count"=>$count,"webhook"=>["message"=>$webHookMessage,"ok"=>$robotMessageOk]]);
+        #同时返回此句子pr数量
+        $info['book_id'] = $data['book'];
+        $info['paragraph'] = $data['para'];
+        $info['word_start'] = $data['begin'];
+        $info['word_end'] = $data['end'];
+        $info['channel_uid'] = $data['channel'];
+        $count = SentPr::where('book_id', $data['book'])
+            ->where('paragraph', $data['para'])
+            ->where('word_start', $data['begin'])
+            ->where('word_end', $data['end'])
+            ->where('channel_uid', $data['channel'])
+            ->count();
 
+        return $this->ok(["new" => $info, "count" => $count, "webhook" => ["message" => $webHookMessage, "ok" => $robotMessageOk]]);
     }
 
     /**
@@ -178,20 +186,20 @@ class SentPrController extends Controller
      * @param  string  $uid
      * @return \Illuminate\Http\Response
      */
-    public function show(Request $request,string $uid)
+    public function show(Request $request, string $uid)
     {
         //
 
-        $pr = SentPr::where('uid',$uid)->first();
-        if(!$pr){
-            return $this->error('no data',404,404);
+        $pr = SentPr::where('uid', $uid)->first();
+        if (!$pr) {
+            return $this->error('no data', 404, 404);
         }
         //修改notification 已读状态
         $user = AuthApi::current($request);
-        if($user){
-            Notification::where('res_id',$uid)
-                        ->where('to',$user['user_uid'])
-                        ->update(['status'=>'read']);
+        if ($user) {
+            Notification::where('res_id', $uid)
+                ->where('to', $user['user_uid'])
+                ->update(['status' => 'read']);
         }
 
         return $this->ok(new SentPrResource($pr));
@@ -207,22 +215,21 @@ class SentPrController extends Controller
     public function update(Request $request, string $id)
     {
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),401,401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
         }
 
-		$sentPr = SentPr::find($id);
-        if(!$sentPr){
+        $sentPr = SentPr::find($id);
+        if (!$sentPr) {
             return $this->error('no res');
         }
-		if($sentPr->editor_uid !== $user['user_uid']){
-            return $this->error('not power',403,403);
+        if ($sentPr->editor_uid !== $user['user_uid']) {
+            return $this->error('not power', 403, 403);
         }
         $sentPr->content = $request->get('text');
-        $sentPr->modify_time = time()*1000;
+        $sentPr->modify_time = time() * 1000;
         $sentPr->save();
         return $this->ok($sentPr);
-
     }
 
     /**
@@ -235,31 +242,31 @@ class SentPrController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),401,401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
         }
-		$old = SentPr::where('id', $id)->first();
-        if(!$old){
+        $old = SentPr::where('id', $id)->first();
+        if (!$old) {
             return $this->error('no res');
         }
         //鉴权
-        if($old->editor_uid !== $user["user_uid"]){
-            return $this->error(__('auth.failed'),403,403);
+        if ($old->editor_uid !== $user["user_uid"]) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $result = SentPr::where('id', $id)
+            ->where('editor_uid', $user["user_uid"])
+            ->delete();
+        if ($result > 0) {
+            #同时返回此句子pr数量
+            $count = SentPr::where('book_id', $old->book_id)
+                ->where('paragraph', $old->paragraph)
+                ->where('word_start', $old->word_start)
+                ->where('word_end', $old->word_end)
+                ->where('channel_uid', $old->channel_uid)
+                ->count();
+            return $this->ok($count);
+        } else {
+            return $this->error('not power', 403, 403);
         }
-		$result = SentPr::where('id', $id)
-						->where('editor_uid', $user["user_uid"])
-						->delete();
-		if($result>0){
-					#同时返回此句子pr数量
-		    $count = SentPr::where('book_id' , $old->book_id)
-						->where('paragraph' , $old->paragraph)
-						->where('word_start' , $old->word_start)
-						->where('word_end' , $old->word_end)
-						->where('channel_uid' , $old->channel_uid)
-						->count();
-			return $this->ok($count);
-		}else{
-			return $this->error('not power',403,403);
-		}
     }
 }

+ 1 - 0
api-v8/app/Http/Controllers/TaskStatusController.php

@@ -119,6 +119,7 @@ class TaskStatusController extends Controller
             case 'running':
                 $task->started_at = now();
                 $task->executor_id = $user['user_uid'];
+                $task->save();
                 $this->pushChange('running', $task->id);
                 break;
             case 'restarted':

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

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

+ 2 - 18
dashboard-v4/dashboard/src/components/ai/AiModelList.tsx

@@ -1,25 +1,11 @@
 import { Link } from "react-router-dom";
 import { useIntl } from "react-intl";
-import {
-  Button,
-  Popover,
-  Typography,
-  Dropdown,
-  Modal,
-  message,
-  Tag,
-  Space,
-} from "antd";
+import { Button, Popover, Modal, message, Tag, Space } from "antd";
 import { ActionType, ProList } from "@ant-design/pro-components";
-import {
-  PlusOutlined,
-  DeleteOutlined,
-  ExclamationCircleOutlined,
-} from "@ant-design/icons";
+import { PlusOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
 
 import { delete_, get } from "../../request";
 
-import { RoleValueEnum } from "../../components/studio/table";
 import { IDeleteResponse } from "../../components/api/Article";
 import { useRef, useState } from "react";
 
@@ -30,8 +16,6 @@ import PublicityIcon from "../studio/PublicityIcon";
 import ShareModal from "../share/ShareModal";
 import { EResType } from "../share/Share";
 
-const { Text } = Typography;
-
 interface IWidget {
   studioName?: string;
 }

+ 1 - 2
dashboard-v4/dashboard/src/components/nut/users/ResetPassword.tsx

@@ -132,7 +132,7 @@ const Widget = ({ token }: IWidget) => {
             label={intl.formatMessage({
               id: "forms.fields.username.label",
             })}
-            rules={[{ required: true, max: 255, min: 6 }]}
+            rules={[{ required: true, max: 255, min: 2 }]}
           />
         </ProForm.Group>
         <ProForm.Group>
@@ -141,7 +141,6 @@ const Widget = ({ token }: IWidget) => {
             name="password"
             fieldProps={{
               type: "password",
-
               iconRender: (visible) =>
                 visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
             }}

+ 1 - 0
dashboard-v4/dashboard/src/components/share/Share.tsx

@@ -17,6 +17,7 @@ export enum EResType {
   article = 3,
   collection = 4,
   workflow = 6,
+  project = 7,
   modal = 8,
 }
 interface IWidget {

+ 20 - 6
dashboard-v4/dashboard/src/components/share/ShareModal.tsx

@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { Modal } from "antd";
 import Share, { EResType } from "./Share";
 import { useIntl } from "react-intl";
@@ -7,20 +7,34 @@ interface IWidget {
   resId: string;
   resType: EResType;
   trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: () => void;
 }
-const ShareModalWidget = ({ resId, resType, trigger }: IWidget) => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
+const ShareModalWidget = ({
+  resId,
+  resType,
+  trigger,
+  open,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
   const intl = useIntl();
+
+  useEffect(() => setIsModalOpen(open), [open]);
   const showModal = () => {
     setIsModalOpen(true);
   };
 
   const handleOk = () => {
-    setIsModalOpen(false);
+    if (onClose) {
+      onClose();
+    } else {
+      setIsModalOpen(false);
+    }
   };
 
   const handleCancel = () => {
-    setIsModalOpen(false);
+    handleOk();
   };
 
   return (
@@ -33,7 +47,7 @@ const ShareModalWidget = ({ resId, resType, trigger }: IWidget) => {
         open={isModalOpen}
         onOk={handleOk}
         onCancel={handleCancel}
-        footer={undefined}
+        footer={false}
       >
         <Share resId={resId} resType={resType} />
       </Modal>

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

@@ -89,6 +89,34 @@ const LeftSiderWidget = ({ selectedKeys = "", openKeys }: IWidgetHeadBar) => {
           ),
           key: "analysis",
         },
+        {
+          label: intl.formatMessage({
+            id: "columns.studio.setting.title",
+          }),
+          key: "setting",
+          children: [
+            {
+              label: (
+                <Link to={linkSetting}>
+                  {intl.formatMessage({
+                    id: "buttons.general",
+                  })}
+                </Link>
+              ),
+              key: "general",
+            },
+            {
+              label: (
+                <Link to={`${urlBase}/ai/models/list`}>
+                  {intl.formatMessage({
+                    id: "buttons.ai-models",
+                  })}
+                </Link>
+              ),
+              key: "models",
+            },
+          ],
+        },
       ],
     },
     {
@@ -146,16 +174,6 @@ const LeftSiderWidget = ({ selectedKeys = "", openKeys }: IWidgetHeadBar) => {
             },
           ],
         },
-        {
-          label: "AI",
-          key: "ai",
-          children: [
-            {
-              label: <Link to={`${urlBase}/ai/models/list`}>{"models"}</Link>,
-              key: "models",
-            },
-          ],
-        },
         {
           label: (
             <Link to={linkCourse}>
@@ -227,16 +245,6 @@ const LeftSiderWidget = ({ selectedKeys = "", openKeys }: IWidgetHeadBar) => {
           ),
           key: "tag",
         },
-        {
-          label: (
-            <Link to={linkSetting}>
-              {intl.formatMessage({
-                id: "columns.studio.setting.title",
-              })}
-            </Link>
-          ),
-          key: "setting",
-        },
       ].filter((value) => value.disabled !== true),
     },
     {

+ 34 - 12
dashboard-v4/dashboard/src/components/task/TaskProjects.tsx → dashboard-v4/dashboard/src/components/task/ProjectTable.tsx

@@ -19,6 +19,8 @@ import { getSorterUrl } from "../../utils";
 import { TransferOutLinedIcon } from "../../assets/icon";
 import { IProjectData, IProjectListResponse } from "../api/task";
 import ProjectCreate from "./ProjectCreate";
+import ShareModal from "../share/ShareModal";
+import { EResType } from "../share/Share";
 
 export interface IResNumberResponse {
   ok: boolean;
@@ -47,20 +49,20 @@ interface IWidget {
   studioName?: string;
   type?: string;
   disableChannels?: string[];
-  channelType?: TChannelType;
   onSelect?: Function;
 }
 
-const ProjectListWidget = ({
+const ProjectTableWidget = ({
   studioName,
   disableChannels,
-  channelType,
   type,
   onSelect,
 }: IWidget) => {
   const intl = useIntl();
-  const [activeKey, setActiveKey] = useState<React.Key | undefined>("instance");
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("studio");
   const [openCreate, setOpenCreate] = useState(false);
+  const [shareId, setShareId] = useState<string>();
+  const [shareOpen, setShareOpen] = useState(false);
 
   useEffect(() => {
     ref.current?.reload();
@@ -107,6 +109,17 @@ const ProjectListWidget = ({
 
   return (
     <>
+      {shareId ? (
+        <ShareModal
+          open={shareOpen}
+          onClose={() => setShareOpen(false)}
+          resId={shareId}
+          resType={EResType.project}
+        />
+      ) : (
+        <></>
+      )}
+
       <ProTable<IProjectData>
         actionRef={ref}
         columns={[
@@ -186,6 +199,12 @@ const ProjectListWidget = ({
                         }),
                         icon: <TransferOutLinedIcon />,
                       },
+                      {
+                        key: "share",
+                        label: intl.formatMessage({
+                          id: "buttons.share",
+                        }),
+                      },
                       {
                         key: "remove",
                         label: intl.formatMessage({
@@ -200,6 +219,10 @@ const ProjectListWidget = ({
                         case "remove":
                           showDeleteConfirm(row.id, row.title);
                           break;
+                        case "share":
+                          setShareId(row.id);
+                          setShareOpen(true);
+                          break;
                         default:
                           break;
                       }
@@ -218,7 +241,7 @@ const ProjectListWidget = ({
         ]}
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
-          let url = `/v2/project?view=studio&type=${activeKey}`;
+          let url = `/v2/project?view=${activeKey}&type=instance`;
           url += `&studio=${studioName}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
@@ -226,7 +249,6 @@ const ProjectListWidget = ({
           url += `&limit=${params.pageSize}&offset=${offset}`;
 
           url += params.keyword ? "&keyword=" + params.keyword : "";
-          url += channelType ? "&type=" + channelType : "";
           url += getSorterUrl(sorter);
           console.log("project list api request", url);
           const res = await get<IProjectListResponse>(url);
@@ -252,7 +274,7 @@ const ProjectListWidget = ({
             content={
               <ProjectCreate
                 studio={studioName}
-                type={activeKey === "workflow" ? "workflow" : "instance"}
+                type={"instance"}
                 onCreate={() => {
                   setOpenCreate(false);
                   ref.current?.reload();
@@ -276,12 +298,12 @@ const ProjectListWidget = ({
             activeKey,
             items: [
               {
-                key: "instance",
-                label: "项目",
+                key: "studio",
+                label: "我的项目",
               },
               {
-                key: "workflow",
-                label: intl.formatMessage({ id: "labels.workflow" }),
+                key: "shared",
+                label: intl.formatMessage({ id: "labels.shared" }),
               },
             ],
             onChange(key) {
@@ -296,4 +318,4 @@ const ProjectListWidget = ({
   );
 };
 
-export default ProjectListWidget;
+export default ProjectTableWidget;

+ 31 - 1
dashboard-v4/dashboard/src/components/task/ProjectTask.tsx

@@ -7,6 +7,24 @@ import { useState } from "react";
 import { ITaskData } from "../../components/api/task";
 import { useIntl } from "react-intl";
 
+// 更新 ITaskData[] 中的函数
+export function update(input: ITaskData[], target: ITaskData[]): void {
+  for (const newItem of input) {
+    const match = target.findIndex((item) => item.id === newItem.id);
+    if (match >= 0) {
+      // 更新当前项的属性
+      target[match] = newItem;
+    } else {
+      // 如果没有找到,递归检查子项
+      for (const item of target) {
+        if (item.children) {
+          update([newItem], item.children);
+        }
+      }
+    }
+  }
+}
+
 interface IWidget {
   studioName?: string;
   projectId?: string;
@@ -29,6 +47,7 @@ const ProjectTask = ({
     setTasks(listData);
     onChange && onChange(listData);
   };
+
   return (
     <>
       <Tabs
@@ -50,7 +69,18 @@ const ProjectTask = ({
           {
             label: intl.formatMessage({ id: "labels.table" }),
             key: "table",
-            children: <TaskTable tasks={tasks} onChange={onDataChange} />,
+            children: (
+              <TaskTable
+                tasks={tasks}
+                onChange={(data: ITaskData[]) => {
+                  if (origin) {
+                    let origin = JSON.parse(JSON.stringify(taskTree));
+                    update(data, origin);
+                    onDataChange(origin);
+                  }
+                }}
+              />
+            ),
           },
           {
             label: intl.formatMessage({ id: "labels.flowchart" }),

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

@@ -55,6 +55,7 @@ export const TaskBuilderChapterModal = ({
     <>
       <Modal
         destroyOnClose={true}
+        maskClosable={false}
         width={1400}
         style={{ top: 10 }}
         title={""}

+ 5 - 1
dashboard-v4/dashboard/src/components/task/TaskStatus.tsx

@@ -15,6 +15,9 @@ const TaskStatus = ({ task }: IWidget) => {
     if (!task?.id) {
       return;
     }
+    if (task.status !== "running") {
+      return;
+    }
     const query = () => {
       const url = `/v2/task/${task?.id}`;
       console.info("api request", url);
@@ -25,11 +28,12 @@ const TaskStatus = ({ task }: IWidget) => {
         }
       });
     };
+
     let timer = setInterval(query, 1000 * (60 + Math.random() * 10));
     return () => {
       clearInterval(timer);
     };
-  }, [task?.id]);
+  }, [task]);
 
   let color = "";
   switch (task?.status) {

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

@@ -108,6 +108,8 @@ const items = {
   "buttons.next": "Next",
   "buttons.previous": "Previous",
   "buttons.clone": "Clone",
+  "buttons.general": "General",
+  "buttons.ai-models": "AI Models",
 };
 
 export default items;

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

@@ -109,6 +109,8 @@ const items = {
   "buttons.next": "下一步",
   "buttons.previous": "上一步",
   "buttons.clone": "克隆",
+  "buttons.general": "常规",
+  "buttons.ai-models": "AI模型",
 };
 
 export default items;

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

@@ -10,7 +10,7 @@ const Widget = () => {
   return (
     <Layout>
       <Layout>
-        <LeftSider openKeys={["ai"]} />
+        <LeftSider openKeys={["setting"]} />
         <Content style={styleStudioContent}>
           <Outlet />
         </Content>

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

@@ -10,7 +10,7 @@ const Widget = () => {
   return (
     <Layout>
       <Layout>
-        <LeftSider selectedKeys="setting" />
+        <LeftSider selectedKeys="setting" openKeys={["setting"]} />
         <Content style={styleStudioContent}>
           <SettingArticle />
         </Content>

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

@@ -1,6 +1,6 @@
 import { useParams } from "react-router-dom";
 
-import TaskProjects from "../../../components/task/TaskProjects";
+import TaskProjects from "../../../components/task/ProjectTable";
 
 const Widget = () => {
   const { studioname } = useParams();