diff --git a/app/Console/Commands/TestLLM.php b/app/Console/Commands/TestLLM.php new file mode 100644 index 0000000..8fc5ef2 --- /dev/null +++ b/app/Console/Commands/TestLLM.php @@ -0,0 +1,76 @@ +setTools($tool); + + $llm->setHistory($history); + + while (true) { +// var_dump($history->getMessages()); + + $q = $this->ask('请输入问题'); + + if (empty($q)) { + $q = "北京天气"; + } + + $history->addMessage(new HumanMessage($q)); + + $s = $llm->streamResponse(); + // 循环输出 + foreach ($s as $item) { + if ($item->role == ChatEnum::Tool) { + if ($item->processing) { + $this->info("正在执行: " . $item->content); + echo "\n"; + } else { + $this->info("执行结果: " . $item->content); + } + } else if ($item->role == ChatEnum::AssistantChunk) { + echo $item->getLastAppend(); + } + } + + echo "\n"; + } + } +} diff --git a/app/Http/Controllers/Api/ChatController.php b/app/Http/Controllers/Api/ChatController.php index 2735ff1..175bf3d 100644 --- a/app/Http/Controllers/Api/ChatController.php +++ b/app/Http/Controllers/Api/ChatController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Assistant; +use App\Models\Chat; use Illuminate\Http\Request; class ChatController extends Controller @@ -10,9 +12,11 @@ class ChatController extends Controller /** * Display a listing of the resource. */ - public function index() + public function index(Request $request) { - // + return $this->success( + Chat::whereUserId($request->user('api')->id)->get() + ); } /** @@ -20,7 +24,26 @@ public function index() */ public function store(Request $request) { - // + $request->validate([ + 'name' => 'string|required', + 'assistant_id' => 'exists:assistants,id|required', + ]); + + $assistant = Assistant::find($request->input('assistant_id')); + + if ($assistant->user_id !== $request->user('api')->id) { + return $this->forbidden(); + } + + $chatModel = new Chat(); + + $chat = $chatModel->create([ + 'name' => $request->input('name'), + 'assistant_id' => $assistant->id, + 'user_id' => $request->user('api')->id, + ]); + + return $this->created($chat); } /** @@ -46,4 +69,11 @@ public function destroy(string $id) { // } + + public function histories(Request $request, Chat $chat) + { + return $this->success( + $chat->histories()->get() + ); + } } diff --git a/app/Http/Controllers/Api/ChatHistoryController.php b/app/Http/Controllers/Api/ChatHistoryController.php new file mode 100644 index 0000000..c98f322 --- /dev/null +++ b/app/Http/Controllers/Api/ChatHistoryController.php @@ -0,0 +1,73 @@ +success( + $chat->histories()->get() + ); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request, Chat $chat) + { + // 获取上一条记录 + $last_history = $chat->histories()->orderBy('id', 'desc')->first(); + + // 如果存在 + if ($last_history) { + // 如果上一条是 user + if ($last_history->role == 'user') { + // 不允许发送消息 + return $this->badRequest('你已经回复过了,请等待 AI 响应。'); + } + + // 检查缓存是否存在 + $last_stream_key = 'chat_history_id:'.$last_history->id; + + // 如果存在 + if (Cache::has($last_stream_key)) { + return $this->conflict('上一个流信息还没有获取,请等待一分钟后重试。'); + } + } + + $request->validate([ + 'message' => 'required', + ]); + + $chat->histories()->create([ + 'content' => $request->input('message'), + 'role' => 'user', + ]); + + $random_id = Str::random(20); + + $last_stream_key = 'chat_history_id:'.$last_history->id; + // 设置缓存 + Cache::put($last_stream_key, $random_id, 60); + Cache::put("chat_history_stream_id:$random_id", $last_history->id, 60); + + return $this->success([ + 'stream_url' => route('chat-stream', $random_id), + ]); + } + + public function stream(string $stream_id) + { + return $stream_id; + } +} diff --git a/app/Http/Middleware/JSONRequest.php b/app/Http/Middleware/JSONRequest.php index f773872..961fd26 100644 --- a/app/Http/Middleware/JSONRequest.php +++ b/app/Http/Middleware/JSONRequest.php @@ -16,6 +16,7 @@ class JSONRequest public function handle(Request $request, Closure $next): Response { $request->headers->set('Accept', 'application/json'); + return $next($request); } } diff --git a/app/LLM/Base.php b/app/LLM/Base.php deleted file mode 100644 index 9407b18..0000000 --- a/app/LLM/Base.php +++ /dev/null @@ -1,5 +0,0 @@ -clearToolInfo(); } - public function getResponse(): void + private function clearToolInfo(): void + { + $this->tool_info = [ + 'function' => [ + 'name' => '', + 'arguments' => '', + ], + 'index' => 0, + 'id' => '', + 'type' => 'function', + ]; + } + + public function setHistory(History $history): void + { + $this->history = $history; + } + + public function setTools(Collection $tools): void + { + $this->tools = $tools; + + foreach ($tools as $tool) { + $this->tool_functions = array_merge($this->tool_functions, $tool->data['tool_functions']); + } + } + + /** + * @throws GuzzleException + */ + public function streamResponse() { $client = new Client([ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Cache-Control' => 'no-cache', - 'Authorization' => 'Bearer '.config('services.dashscope.api_key'), + 'Authorization' => 'Bearer ' . config('services.dashscope.api_key'), ], ]); - $url = config('services.dashscope.api_base').'/compatible-mode/v1/chat/completions'; + $url = config('services.dashscope.api_base') . '/compatible-mode/v1/chat/completions'; - $messages = [ - [ - 'role' => 'user', - 'content' => '你好', - ], - ]; - $jsonBody = json_encode([ - 'model' => $this->model, - 'messages' => $messages, - 'stream' => true, - ]); + $this->request_again = true; + while ($this->request_again) { - $response = $client->request('POST', $url, [ - 'body' => $jsonBody, - 'stream' => true, - ]); + $ai_chunk_message = new AIChunkMessage(""); + + $request_body = [ + 'model' => $this->model, + 'messages' => $this->history->getForApi(), + 'stream' => true, + 'tools' => $this->tool_functions, + ]; + + $response = $client->request('POST', $url, [ + 'body' => json_encode($request_body), + 'stream' => true, + ]); + + $body = $response->getBody(); + + $buffer = ''; + + while (!$body->eof()) { + $data = $body->read(1); + + $buffer .= $data; + + // 读取到 \n\n 则一条消息结束 + if (preg_match(self::END_OF_MESSAGE, $buffer)) { + // 去掉 data: + $d = substr($buffer, 5); + // 去掉第一个空格(如果是) + $d = ltrim($d, ' '); + + // 去除末尾的 END_OF_MESSAGE + $d = rtrim($d, self::END_OF_MESSAGE); + + // 如果是 [DONE] + if ($d == '[DONE]') { + break; + } + + + if (empty($d)) { + return; + } + $event = json_decode($d, true); + + if (isset($event['choices'][0])) { + // 检测是不是 stop + if ($event['choices'][0]['finish_reason'] == 'stop') { + $ai_chunk_message->processing = false; + $this->history->addMessage($ai_chunk_message->toAIMessage()); + break; +// yield $ai_chunk_message; + } + + $delta = $event['choices'][0]['delta']; + + if ($event['choices'][0]['finish_reason'] == 'tool_calls') { + $info = json_decode($this->tool_info['function']['arguments'], true); + + if (isset($info['properties'])) { + $info = $info['properties']; + } + + yield new ToolRequestMessage( + content: $this->tool_info['function']['name'], + ); + + $r = $this->callTool($this->tool_info['function']['name'], $info); + + +// $tool_response = [ +// 'name' => $this->tool_info['function']['name'], +// 'role' => 'tool', +// 'content' => $r, +// ]; + + $tool_call_message = new AIToolCallMessage(content: ""); + $tool_call_message->tool_calls = [ + $this->tool_info, + ]; + + $tool_response_message = new ToolResponseMessage(content: $r); + $tool_response_message->name = $this->tool_info['function']['name']; + + $this->history->addMessage($tool_call_message); + $this->history->addMessage($tool_response_message); + + yield $tool_call_message; + yield $tool_response_message; +// +// +// +// $this->history[] = $tool_response; + +// yield new ToolResponseMessage( +// content: $tool_response_message->content, +// ); + + + // 清空参数,以备二次调用 + $this->clearToolInfo(); + + // 再次请求以获取结果 + $this->request_again = true; + } elseif (isset($delta['tool_calls'])) { + // 更新 tool_info + $call_info = $delta['tool_calls'][0]; + + if (isset($call_info['id'])) { + $this->tool_info['id'] = $call_info['id']; + } + + if (isset($call_info['index'])) { + $this->tool_info['index'] = $call_info['index']; + } + + if (isset($call_info['function']['name'])) { + $this->tool_info['function']['name'] = $call_info['function']['name']; + } + + if (isset($call_info['function']['arguments'])) { + $this->tool_info['function']['arguments'] .= $call_info['function']['arguments']; + } + } elseif (empty($delta['choices'][0]['finish_reason'])) { + if (!empty($delta['content'])) { + $ai_chunk_message->append($delta['content']); + $ai_chunk_message->processing = true; + + yield $ai_chunk_message; + } + + $this->request_again = false; + } + + } + + + $buffer = ''; + } + } - $body = $response->getBody(); - // while (!$body->eof()) { - // echo $body->read(1024); - // } - while (! $body->eof()) { - // $body->read(1); - echo $body->read(1); } } + + private function callTool($tool_name, $args): string + { + // 遍历 tools, 找到 + foreach ($this->tools as $tool) { + foreach ($tool->data['tool_functions'] as $f) { + if ($f['function']['name'] == $tool_name) { + $c = new LLMTool(); + $c->setCallbackUrl($tool->data['callback_url']); + $r = $c->callTool($tool_name, $args); + + return $r->result; + } + } + + } + + return "[Hint] 没有找到对应的工具 " . $tool_name . ',你确定是这个吗?'; + } + } diff --git a/app/Logic/LLM.php b/app/Logic/LLM.php new file mode 100644 index 0000000..29b2b9d --- /dev/null +++ b/app/Logic/LLM.php @@ -0,0 +1,5 @@ +callback_url = $callback_url; + } + + public function callTool(string $function_name, $parameters = []): FunctionCall + { + // 使用 _ 分割 + $names = explode('_', $function_name)[0]; + $prefix_length = strlen($names) + 1; + + $function_name = substr($function_name, $prefix_length); + + $http = Http::post($this->callback_url, [ + 'function_name' => $function_name, + 'parameters' => $parameters, + ]); + + $r = new FunctionCall(); + $r->name = $function_name; + $r->parameters = $parameters; + + if (! $http->ok()) { + $r->success = false; + $r->result = "[Error] 我们的服务器与工具 $function_name 通讯失败"; + } + + $d = $http->json(); + + // 必须有 success 和 message 两个 + if (! isset($d['success']) || ! isset($d['message'])) { + $r->success = false; + $r->result = "[Error] 和 工具 $function_name 通讯失败,返回数据格式错误。"; + return $r; + } + + $r->success= $d['success']; + $r->result = $d['message']; + + return $r; + } +} diff --git a/app/Models/Chat.php b/app/Models/Chat.php index 69dabbc..95d0211 100644 --- a/app/Models/Chat.php +++ b/app/Models/Chat.php @@ -8,6 +8,14 @@ class Chat extends Model { + public const ROLE_USER = 'user'; + + public const ROLE_ASSISTANT = 'assistant'; + + public const ROLE_SYSTEM = 'system'; + + public const ROLE_META = 'meta'; + protected $fillable = [ 'name', 'assistant_id', diff --git a/app/Models/Tool.php b/app/Models/Tool.php index e191ce7..939f829 100644 --- a/app/Models/Tool.php +++ b/app/Models/Tool.php @@ -30,5 +30,4 @@ public function functions(): HasMany { return $this->hasMany(ToolFunction::class); } - } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3104d74..a7065c2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -41,7 +41,7 @@ private function setJWTGuard(): void if (empty($jwt)) { return null; -// return response()->json(['error' => 'No token provided'], 401); + // return response()->json(['error' => 'No token provided'], 401); } $headers = new stdClass(); @@ -50,18 +50,17 @@ private function setJWTGuard(): void $decoded = JWT::decode($jwt, $keys, $headers); // $request->attributes->add(['token_type' => $headers->typ]); } catch (Exception $e) { -// dd($e); + // dd($e); return null; - -// return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401); + // return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401); } // must id_token if ($headers->typ !== 'id_token') { return null; -// return response()->json(['error' => 'The token not id_token'], 401); + // return response()->json(['error' => 'The token not id_token'], 401); } // 检查是否有 字段 @@ -70,22 +69,22 @@ private function setJWTGuard(): void ]; foreach ($required_fields as $field) { - if (!isset($decoded->$field)) { + if (! isset($decoded->$field)) { return null; -// return response()->json(['error' => 'The token not contain the field '.$field], 401); + // return response()->json(['error' => 'The token not contain the field '.$field], 401); } } if (config('oauth.force_aud')) { - if (!in_array($decoded->aud, config('oauth.trusted_aud'))) { - throw new Exception('The application rejected the token, token aud is ' . $decoded->aud . ', app aud is ' . config('oauth.client_id')); -// return response()->json(['error' => 'The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401); + if (! in_array($decoded->aud, config('oauth.trusted_aud'))) { + throw new Exception('The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')); + // return response()->json(['error' => 'The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401); } // throw - throw new Exception('The token not match the application, ' . ' token aud is ' . $decoded->aud . ', app aud is ' . config('oauth.client_id')); -// return response()->json(['error' => 'The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401); + throw new Exception('The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')); + // return response()->json(['error' => 'The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401); } return User::where('external_id', $decoded->sub)->firstOrCreate([ diff --git a/app/Repositories/LLM/AIChunkMessage.php b/app/Repositories/LLM/AIChunkMessage.php new file mode 100644 index 0000000..227a701 --- /dev/null +++ b/app/Repositories/LLM/AIChunkMessage.php @@ -0,0 +1,30 @@ +content, + ); + $a->processing = false; + + return $a; + } + + public function append(string $content): void + { + $this->content .= $content; + $this->last_append = $content; + } + + public function getLastAppend(): string + { + return $this->last_append; + } +} diff --git a/app/Repositories/LLM/AIMessage.php b/app/Repositories/LLM/AIMessage.php new file mode 100644 index 0000000..7a9ce78 --- /dev/null +++ b/app/Repositories/LLM/AIMessage.php @@ -0,0 +1,8 @@ +content = $content; + } + + + public function append(string $content): void + { + $this->content .= $content; + } + + public function clear(): void + { + $this->content = ''; + } + + public function __toString(): string + { + return $this->content; + } + +} diff --git a/app/Repositories/LLM/ChatEnum.php b/app/Repositories/LLM/ChatEnum.php new file mode 100644 index 0000000..9a2c77b --- /dev/null +++ b/app/Repositories/LLM/ChatEnum.php @@ -0,0 +1,11 @@ +history[] = $message; + } + + public function getMessages(): array + { + return $this->history; + } + + public function setHistory(array $history): void + { + $this->history = $history; + } + + public function clearHistory(): void + { + $this->history = []; + } + + public function getForApi(): array + { + $history = []; + + + foreach ($this->history as $h) { + + $a = [ + 'role' => $h->role->value, + 'content' => $h->content, + ]; + + if (isset($h->tool_calls)) { + $a['tool_calls'] = $h->tool_calls; + } + + $history[] = $a; + } + + return $history; + } + + +} diff --git a/app/Repositories/LLM/HumanMessage.php b/app/Repositories/LLM/HumanMessage.php new file mode 100644 index 0000000..d2dd1c0 --- /dev/null +++ b/app/Repositories/LLM/HumanMessage.php @@ -0,0 +1,11 @@ +data['functions'] as $f) { - $this->toolFunctions[] = new ToolFunction($f); + $a = [ + 'type' => 'function', + 'function' => new ToolFunction($f), + ]; + + $this->tool_functions[] = $a; } } } diff --git a/app/Repositories/Tool/ToolFunction.php b/app/Repositories/Tool/ToolFunction.php index 24d8b86..a952ae3 100644 --- a/app/Repositories/Tool/ToolFunction.php +++ b/app/Repositories/Tool/ToolFunction.php @@ -3,6 +3,7 @@ namespace App\Repositories\Tool; use Exception; +use Illuminate\Support\Str; class ToolFunction { @@ -15,7 +16,6 @@ class ToolFunction protected array $data; public array $required; - /** * Create a new class instance. * @@ -32,7 +32,8 @@ public function __construct(array $data) */ private function parse(): void { - $this->name = $this->data['name']; + $random_str = Str::random(config('settings.function_call.random_prefix_length')); + $this->name = $random_str . '_' . $this->data['name']; $this->description = $this->data['description']; // 如果 parameters 不为空,则验证 diff --git a/bootstrap/app.php b/bootstrap/app.php index 5113808..c414e00 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,7 +19,7 @@ // ]); // add for api $middleware->api([ - JSONRequest::class + JSONRequest::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/config/auth.php b/config/auth.php index 286228d..906df9c 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,7 +42,7 @@ ], 'api' => [ 'driver' => 'jwt', -// 'provider' => 'users', + // 'provider' => 'users', ], ], diff --git a/config/services.php b/config/services.php index ae09afa..6d397bc 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,5 @@ 'api_base' => env('DASHSCOPE_API_BASE', 'https://dashscope.aliyuncs.com'), ], + ]; diff --git a/config/settings.php b/config/settings.php new file mode 100644 index 0000000..146bdde --- /dev/null +++ b/config/settings.php @@ -0,0 +1,8 @@ + [ + // 防止命名冲突的随机长度,如果你更改了它,则需要更新全部函数的 name + 'random_prefix_length' => 8 + ] +]; diff --git a/database/migrations/2024_07_23_151217_create_chat_histories_table.php b/database/migrations/2024_07_23_151217_create_chat_histories_table.php index addfff4..6249624 100644 --- a/database/migrations/2024_07_23_151217_create_chat_histories_table.php +++ b/database/migrations/2024_07_23_151217_create_chat_histories_table.php @@ -21,12 +21,12 @@ public function up(): void // $table->string('model'); // input tokens - $table->integer('input_tokens')->default(null); + $table->integer('input_tokens')->nullable(); // output tokens - $table->integer('output_tokens')->default(null); + $table->integer('output_tokens')->nullable(); - $table->integer('total_tokens')->default(null); + $table->integer('total_tokens')->nullable(); // index $table->index(['chat_id', 'role']); diff --git a/routes/api.php b/routes/api.php index cd58e8e..cc610ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,8 +3,8 @@ use App\Http\Controllers\Api\AssistantController; use App\Http\Controllers\Api\AssistantToolController; use App\Http\Controllers\Api\ChatController; +use App\Http\Controllers\Api\ChatHistoryController; use App\Http\Controllers\Api\ToolController; -use App\Http\Middleware\JSONRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -16,8 +16,11 @@ Route::apiResource('tools', ToolController::class); Route::apiResource('assistants', AssistantController::class); Route::apiResource('chats', ChatController::class); + Route::apiResource('chats.histories', ChatHistoryController::class)->only(['index', 'store']); Route::get('assistants/{assistant}/tools', [AssistantToolController::class, 'index']); Route::post('assistants/{assistant}/tools', [AssistantToolController::class, 'store']); Route::delete('assistants/{assistant}/tools/{tool}', [AssistantToolController::class, 'destroy']); }); + +Route::get('chat_stream/{stream_id}', [ChatHistoryController::class, 'stream'])->name('chat-stream');