Compare commits
18 Commits
e28318b4ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e956df9daa | |||
| 71226c255b | |||
| 78875ec3eb | |||
| 663e15395b | |||
| 59d4831f00 | |||
| dcbd0338e6 | |||
| 8c4ad80dab | |||
| 56523c1f0a | |||
| 977c8ee272 | |||
| 6d934f4e34 | |||
| 2ad101c297 | |||
| fa00da5966 | |||
| ced95c02cb | |||
| c55534ad20 | |||
| dafa8f6b06 | |||
| 318571a6d9 | |||
| 6356baacc0 | |||
| c6d6534b63 |
11
.ai/mcp/mcp.json
Normal file
11
.ai/mcp/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "vendor/bin/sail",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"php-lsp@claude-plugins-official": true,
|
||||||
|
"context7@claude-plugins-official": true,
|
||||||
|
"laravel-boost@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
42
.env.example
42
.env.example
@@ -8,6 +8,7 @@ OCTANE_SERVER=frankenphp
|
|||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
AUTH_GUARD=api
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
# APP_MAINTENANCE_STORE=database
|
||||||
@@ -28,7 +29,7 @@ DB_DATABASE=ars_backend
|
|||||||
DB_USERNAME=ars
|
DB_USERNAME=ars
|
||||||
DB_PASSWORD=secret
|
DB_PASSWORD=secret
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=array
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
@@ -36,9 +37,9 @@ SESSION_DOMAIN=null
|
|||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=redis
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
@@ -47,6 +48,8 @@ REDIS_CLIENT=phpredis
|
|||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
JWT_SECRET=
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
@@ -64,3 +67,36 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Agent Provider HTTP(为空则走 Dummy/OpenAI)
|
||||||
|
AGENT_PROVIDER_ENDPOINT=true
|
||||||
|
AGENT_PROVIDER_TIMEOUT=30 # HTTP 请求超时(秒)
|
||||||
|
AGENT_PROVIDER_CONNECT_TIMEOUT=5 # 连接超时(秒)
|
||||||
|
AGENT_PROVIDER_RETRY_TIMES=1 # 建立流前重试次数(仅连接失败/429/5xx 且未产出事件时)
|
||||||
|
AGENT_PROVIDER_RETRY_BACKOFF_MS=500 # 重试退避毫秒(指数退避)
|
||||||
|
|
||||||
|
# OpenAI-compatible Chat Completions(填充后启用;否则回退 Dummy)
|
||||||
|
AGENT_OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4/
|
||||||
|
AGENT_OPENAI_API_KEY=
|
||||||
|
AGENT_OPENAI_ORGANIZATION= # 可选
|
||||||
|
AGENT_OPENAI_PROJECT= # 可选
|
||||||
|
AGENT_OPENAI_MODEL=gpt-4o-mini
|
||||||
|
AGENT_OPENAI_TEMPERATURE=0.7
|
||||||
|
AGENT_OPENAI_TOP_P=1.0
|
||||||
|
AGENT_OPENAI_INCLUDE_USAGE=false
|
||||||
|
|
||||||
|
# AgentRunJob 队列执行策略
|
||||||
|
AGENT_RUN_JOB_TRIES=1 # 队列重试次数
|
||||||
|
AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数
|
||||||
|
AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
|
||||||
|
|
||||||
|
# Tool 子 Run 调度与超时
|
||||||
|
AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数
|
||||||
|
AGENT_TOOL_WAIT_TIMEOUT_MS=30000 # 等待 tool.result 的超时时间(毫秒)
|
||||||
|
AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒)
|
||||||
|
AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT)
|
||||||
|
AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入)
|
||||||
|
AGENT_TOOL_CHOICE=auto # OpenAI tool_choice 选项(auto/required 等)
|
||||||
|
AGENT_TOOL_JOB_TRIES=1 # ToolRunJob 重试次数
|
||||||
|
AGENT_TOOL_JOB_BACKOFF=3 # ToolRunJob 重试退避秒数
|
||||||
|
AGENT_TOOL_JOB_TIMEOUT=120 # ToolRunJob 超时时间(秒)
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,3 +26,5 @@ Thumbs.db
|
|||||||
**/caddy
|
**/caddy
|
||||||
frankenphp
|
frankenphp
|
||||||
frankenphp-worker.php
|
frankenphp-worker.php
|
||||||
|
rr
|
||||||
|
.rr.yaml
|
||||||
|
|||||||
228
.junie/guidelines.md
Normal file
228
.junie/guidelines.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
|
||||||
|
## Foundational Context
|
||||||
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
|
- php - 8.3.28
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/octane (OCTANE) - v2
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/telescope (TELESCOPE) - v5
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure - don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
|
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||||
|
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
|
||||||
|
=== sail rules ===
|
||||||
|
|
||||||
|
## Laravel Sail
|
||||||
|
|
||||||
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
|
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
|
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||||
|
- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples:
|
||||||
|
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||||
|
- Install Composer packages: `vendor/bin/sail composer install`
|
||||||
|
- Execute node commands: `vendor/bin/sail npm run dev`
|
||||||
|
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||||
|
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## Test Enforcement
|
||||||
|
|
||||||
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter.
|
||||||
|
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### URL Generation
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### Vite Error
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- No middleware files in `app/Http/Middleware/`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
|
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
|
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
## PHPUnit Core
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `vendor/bin/sail artisan test`.
|
||||||
|
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
|
||||||
|
</laravel-boost-guidelines>
|
||||||
11
.junie/mcp/mcp.json
Normal file
11
.junie/mcp/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "vendor/bin/sail",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.qoderignore
Normal file
1
.qoderignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Specify files or folders to ignore during indexing. Use commas to separate entries. Glob patterns like *.log,my-security/ are supported.
|
||||||
225
AGENTS.md
Executable file
225
AGENTS.md
Executable file
@@ -0,0 +1,225 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
注意我们使用中文作为自然语言。
|
||||||
|
## Foundational Context
|
||||||
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
|
- php - 8.3.28
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/octane (OCTANE) - v2
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/telescope (TELESCOPE) - v5
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure - don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `docker compose run --rm app npm run build`, `docker compose run --rm app npm run dev`, or `docker compose run --rm app composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
- Use Chinese for all natural language communication.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
|
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||||
|
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
|
||||||
|
=== docker-compose rules ===
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
- This project runs with Docker Compose (Sail is not used). Always run commands through Docker Compose.
|
||||||
|
- Start services using `docker compose up -d` and stop them with `docker compose down`.
|
||||||
|
- Run one-off commands with `docker compose run --rm app ...` to ensure dependencies are available.
|
||||||
|
- Common commands: `docker compose run --rm app php artisan migrate`, `docker compose run --rm app composer install`, `docker compose run --rm app npm run dev`, `docker compose run --rm app php [script]`.
|
||||||
|
- The application is available at `http://localhost:8000` when the stack is running.
|
||||||
|
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## Test Enforcement
|
||||||
|
|
||||||
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `docker compose run --rm app php artisan test` with a specific filename or filter.
|
||||||
|
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `docker compose run --rm app php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `docker compose run --rm app php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### URL Generation
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### Vite Error
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- No middleware files in `app/Http/Middleware/`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
|
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
|
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `docker compose run --rm app ./vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `docker compose run --rm app ./vendor/bin/pint --test`; just run Pint to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
## PHPUnit Core
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `docker compose exec --rm app php artisan test`.
|
||||||
|
- To run all tests in a file: `docker compose exec --rm app php artisan test tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `docker compose exec --rm app php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||||
|
</laravel-boost-guidelines>
|
||||||
679
CLAUDE.md
Executable file
679
CLAUDE.md
Executable file
@@ -0,0 +1,679 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
自然语言使用中文。
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
这是一个基于 Laravel 12 + Octane + Docker 的 Agent Runtime Server (ARS),用于提供可部署的 Agent 运行时服务。核心特性包括:
|
||||||
|
- 兼容多种 Agent 模型
|
||||||
|
- Web 终端实时交互,支持断线重连
|
||||||
|
- 后台任务持续执行,重连后可续传会话
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **PHP**: 8.2+
|
||||||
|
- **Laravel Framework**: 12.x
|
||||||
|
- **Laravel Octane**: 2.x (FrankenPHP)
|
||||||
|
- **Laravel Horizon**: 5.x (队列监控)
|
||||||
|
- **Laravel Telescope**: 5.x (调试工具)
|
||||||
|
- **数据库**: PostgreSQL 16
|
||||||
|
- **缓存/队列**: Redis 7
|
||||||
|
- **认证**: JWT (php-open-source-saver/jwt-auth)
|
||||||
|
- **容器化**: Docker Compose
|
||||||
|
|
||||||
|
## 核心架构
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
|
||||||
|
#### ChatSession (会话)
|
||||||
|
- `session_id` (UUID, 主键): 会话唯一标识
|
||||||
|
- `session_name`: 会话名称
|
||||||
|
- `status`: 会话状态 (OPEN/LOCKED/CLOSED)
|
||||||
|
- `last_seq`: 最后消息序号
|
||||||
|
- `last_message_id`: 最后消息ID
|
||||||
|
|
||||||
|
#### Message (消息)
|
||||||
|
- `message_id` (UUID, 主键): 消息唯一标识
|
||||||
|
- `session_id`: 所属会话ID
|
||||||
|
- `role`: 消息角色 (USER/AGENT/TOOL/SYSTEM)
|
||||||
|
- `type`: 消息类型 (user.prompt/agent.message/message.delta/tool.call/tool.result/run.status/error等)
|
||||||
|
- `content`: 消息内容 (text)
|
||||||
|
- `payload`: 附加数据 (jsonb),包含 run_id、tool_call_id、error_type 等元数据
|
||||||
|
- `seq`: 会话内序号 (单调递增)
|
||||||
|
- `reply_to`: 回复的消息ID
|
||||||
|
- `dedupe_key`: 幂等去重键
|
||||||
|
- **约束**: `unique(session_id, seq)` 和 `unique(session_id, dedupe_key)`
|
||||||
|
|
||||||
|
#### 消息类型完整列表
|
||||||
|
- `user.prompt` (USER): 用户提示
|
||||||
|
- `agent.message` (AGENT): Agent 完整回复
|
||||||
|
- `message.delta` (AGENT): 流式文本增量
|
||||||
|
- `tool.call` (AGENT): 工具调用请求
|
||||||
|
- `tool.result` (TOOL): 工具执行结果
|
||||||
|
- `run.status` (SYSTEM): Run 状态(RUNNING/DONE/FAILED/CANCELED)
|
||||||
|
- `error` (SYSTEM): 错误信息
|
||||||
|
- `run.cancel.request` (USER): 取消请求
|
||||||
|
|
||||||
|
### 会话状态与门禁规则
|
||||||
|
|
||||||
|
- **OPEN**: 正常追加所有消息
|
||||||
|
- **LOCKED**: 拒绝 `role=USER && type=user.prompt`
|
||||||
|
- **CLOSED**: 拒绝追加,例外允许 `role=SYSTEM && type in [run.status, error]`
|
||||||
|
- **状态变更规则**: CLOSED 状态的会话不能重新打开
|
||||||
|
|
||||||
|
### Agent Run 执行流程
|
||||||
|
|
||||||
|
1. 用户发送 `user.prompt` 消息后自动触发,或通过 API 手动触发
|
||||||
|
2. `RunDispatcher` 检查幂等性(基于 `trigger_message_id`)
|
||||||
|
3. 检查会话是否已有 RUNNING 状态的 run(单会话单任务限制)
|
||||||
|
4. 创建 `run.status=RUNNING` 消息并派发 `AgentRunJob`
|
||||||
|
5. `RunLoop` 执行 Agent 调用流程:
|
||||||
|
- `ContextBuilder` 构建上下文(加载最近 20 条相关消息)
|
||||||
|
- `AgentProviderInterface::stream()` 流式调用 Agent
|
||||||
|
- 消费 `Generator<ProviderEvent>` 流:
|
||||||
|
- `MessageDelta`: 流式文本,写入 `message.delta` 消息
|
||||||
|
- `ToolCall`: 工具调用,累积后写入 `tool.call` 并分发 `ToolRunJob`
|
||||||
|
- `Done`: 流结束,写入最终 `agent.message` + `DONE` 状态
|
||||||
|
- `Error`: 错误,写入 `error` + `FAILED` 状态
|
||||||
|
- `CancelChecker` 定期检查取消信号
|
||||||
|
- 工具调用完成后,等待 `tool.result`,继续下一轮 Provider 调用
|
||||||
|
- `OutputSink` 统一写入消息,保证幂等性
|
||||||
|
6. 完成后写入 `run.status=DONE/FAILED/CANCELED`
|
||||||
|
|
||||||
|
**Provider 选择逻辑**(在 `AppServiceProvider` 中绑定):
|
||||||
|
- `HttpAgentProvider` 会检查 `AGENT_OPENAI_API_KEY` 环境变量
|
||||||
|
- 若配置了 OpenAI Key,则使用 `OpenAiChatCompletionsAdapter`
|
||||||
|
- 否则回退到 `DummyAgentProvider`(返回模拟响应)
|
||||||
|
|
||||||
|
### 工具系统架构
|
||||||
|
|
||||||
|
项目支持 Agent 调用工具(Tools),采用子 Run 模式:
|
||||||
|
|
||||||
|
- **Tool** (`app/Services/Tool/Tool.php`): 工具接口,定义 name、description、parameters、execute 方法
|
||||||
|
- **ToolRegistry** (`app/Services/Tool/ToolRegistry.php`): 管理已注册工具,生成 OpenAI 兼容工具声明
|
||||||
|
- **ToolExecutor** (`app/Services/Tool/ToolExecutor.php`): 执行工具,处理超时和结果截断
|
||||||
|
- **ToolRunDispatcher** (`app/Services/Tool/ToolRunDispatcher.php`): 为每个工具调用创建子 run 并投递 `ToolRunJob`
|
||||||
|
- **ToolRunJob** (`app/Jobs/ToolRunJob.php`): 队列任务,执行工具并写入 `tool.result` 消息
|
||||||
|
|
||||||
|
工具调用流程:
|
||||||
|
1. Agent 返回 ToolCall 事件
|
||||||
|
2. RunLoop 累积工具调用,写入 `tool.call` 消息
|
||||||
|
3. ToolRunDispatcher 为每个工具创建子 run(`run.status=RUNNING`)
|
||||||
|
4. ToolRunJob 执行工具,写入 `tool.result` 消息
|
||||||
|
5. RunLoop 轮询等待所有 `tool.result`(支持超时)
|
||||||
|
6. 收集工具结果后,继续下一轮 Provider 调用
|
||||||
|
|
||||||
|
配置项(`config/agent.php`):
|
||||||
|
- `agent.tools.max_calls_per_run`: 单 run 最多工具调用次数(默认 1)
|
||||||
|
- `agent.tools.wait_timeout_ms`: 等待工具结果超时(默认 15000ms)
|
||||||
|
- `agent.tools.wait_poll_interval_ms`: 轮询间隔(默认 200ms)
|
||||||
|
- `agent.tools.timeout_seconds`: 工具执行超时(默认 15s)
|
||||||
|
- `agent.tools.result_max_bytes`: 结果最大字节数(默认 4096)
|
||||||
|
|
||||||
|
### 实时消息推送 (SSE)
|
||||||
|
|
||||||
|
- **端点**: `GET /api/sessions/{id}/sse?after_seq=123`
|
||||||
|
- **机制**:
|
||||||
|
1. 先从数据库补发历史消息(seq > after_seq)
|
||||||
|
2. 订阅 Redis 频道 `session:{id}:messages` 监听新消息
|
||||||
|
3. 支持 `Last-Event-ID` 自动续传
|
||||||
|
4. 检测 seq gap 自动回补
|
||||||
|
5. 15 秒心跳保活
|
||||||
|
- **事件格式**: SSE event id 为消息 seq
|
||||||
|
|
||||||
|
### 服务层架构
|
||||||
|
|
||||||
|
- **ChatService** (`app/Services/ChatService.php`): 会话和消息的核心业务逻辑
|
||||||
|
- 使用行锁 (`lockForUpdate`) + 事务保证消息 seq 单调递增
|
||||||
|
- 通过 `dedupe_key` 实现幂等性
|
||||||
|
- 消息追加后发布 Redis 事件用于 SSE 推送
|
||||||
|
- 提供 `appendMessage()`、`listMessagesBySeq()`、`updateSession()` 等方法
|
||||||
|
|
||||||
|
- **RunDispatcher** (`app/Services/RunDispatcher.php`): Agent Run 调度器
|
||||||
|
- 检查 trigger_message_id 幂等性
|
||||||
|
- 确保同会话只有一个 RUNNING 状态的 run
|
||||||
|
|
||||||
|
- **RunLoop** (`app/Services/RunLoop.php`): Agent 执行循环
|
||||||
|
- 协调 ContextBuilder、AgentProvider、OutputSink、CancelChecker、ToolRunDispatcher
|
||||||
|
- 处理工具调用上限(`max_calls_per_run`)
|
||||||
|
- 达到上限后强制 `tool_choice=none` 防止再次触发
|
||||||
|
|
||||||
|
- **OutputSink** (`app/Services/OutputSink.php`): 统一的消息写入接口
|
||||||
|
- `appendAgentMessage()`: 写入 agent 回复
|
||||||
|
- `appendAgentDelta()`: 写入流式文本增量
|
||||||
|
- `appendRunStatus()`: 写入 run 状态
|
||||||
|
- `appendError()`: 写入错误信息
|
||||||
|
- `appendToolCall()`: 写入工具调用
|
||||||
|
- `appendToolResult()`: 写入工具结果
|
||||||
|
|
||||||
|
- **ContextBuilder** (`app/Services/ContextBuilder.php`): 构建 Agent 上下文
|
||||||
|
- 加载最近 20 条相关消息(USER/AGENT/TOOL 角色)
|
||||||
|
- 按 seq 排序并转换为 AgentContext
|
||||||
|
|
||||||
|
- **CancelChecker** (`app/Services/CancelChecker.php`): 检查 run 是否被取消
|
||||||
|
- 查询 `type='run.cancel.request'` 消息
|
||||||
|
|
||||||
|
## 常用开发命令
|
||||||
|
|
||||||
|
### Docker 容器操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动所有服务
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d app horizon pgsql redis
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker compose stop
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f app
|
||||||
|
docker compose logs -f horizon
|
||||||
|
|
||||||
|
# 进入容器 shell
|
||||||
|
docker compose exec app bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行迁移
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
|
||||||
|
# 回滚迁移
|
||||||
|
docker compose exec app php artisan migrate:rollback
|
||||||
|
|
||||||
|
# 刷新数据库(危险:删除所有表并重新迁移)
|
||||||
|
docker compose exec app php artisan migrate:fresh
|
||||||
|
|
||||||
|
# 运行 seeder
|
||||||
|
docker compose exec app php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
docker compose exec app php artisan test
|
||||||
|
|
||||||
|
# 运行特定测试套件
|
||||||
|
docker compose exec app php artisan test --testsuite=Feature
|
||||||
|
docker compose exec app php artisan test --testsuite=Unit
|
||||||
|
|
||||||
|
# 运行特定测试文件
|
||||||
|
docker compose exec app php artisan test tests/Feature/ChatSessionTest.php
|
||||||
|
|
||||||
|
# 运行特定测试方法(使用 filter)
|
||||||
|
docker compose exec app php artisan test --filter=testCreateSession
|
||||||
|
docker compose exec app php artisan test --filter=testAppendMessageWithDedupe
|
||||||
|
|
||||||
|
# 显示测试覆盖率
|
||||||
|
docker compose exec app php artisan test --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行 Laravel Pint 格式化代码
|
||||||
|
docker compose exec app vendor/bin/pint
|
||||||
|
|
||||||
|
# 只检查格式问题(不修改)
|
||||||
|
docker compose exec app vendor/bin/pint --test
|
||||||
|
|
||||||
|
# 格式化脏文件(git 变更的文件)
|
||||||
|
docker compose exec app vendor/bin/pint --dirty
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发(不使用 Docker)
|
||||||
|
|
||||||
|
如果你想在本地直接运行(需要 PHP 8.2+、PostgreSQL、Redis):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# 启动 Octane 开发服务器
|
||||||
|
php artisan octane:start --host=0.0.0.0 --port=8000
|
||||||
|
|
||||||
|
# 启动队列 worker
|
||||||
|
php artisan queue:work
|
||||||
|
|
||||||
|
# 或启动 Horizon
|
||||||
|
php artisan horizon
|
||||||
|
|
||||||
|
# 查看实时日志
|
||||||
|
php artisan pail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 队列与任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 Horizon 仪表板
|
||||||
|
# 访问 http://localhost:8000/horizon
|
||||||
|
|
||||||
|
# 手动运行队列 worker(开发调试用)
|
||||||
|
docker compose exec app php artisan queue:work
|
||||||
|
|
||||||
|
# 查看失败的任务
|
||||||
|
docker compose exec app php artisan queue:failed
|
||||||
|
|
||||||
|
# 重试失败的任务
|
||||||
|
docker compose exec app php artisan queue:retry all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Tinker REPL
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
|
||||||
|
# 查看路由列表
|
||||||
|
docker compose exec app php artisan route:list
|
||||||
|
|
||||||
|
# 清除缓存
|
||||||
|
docker compose exec app php artisan cache:clear
|
||||||
|
docker compose exec app php artisan config:clear
|
||||||
|
docker compose exec app php artisan route:clear
|
||||||
|
|
||||||
|
# 查看实时日志(使用 Laravel Pail)
|
||||||
|
docker compose exec app php artisan pail
|
||||||
|
```
|
||||||
|
|
||||||
|
### Artisan 生成器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 Model(推荐带选项一次性生成相关文件)
|
||||||
|
docker compose exec app php artisan make:model ChatSession -mfs
|
||||||
|
# -m: migration, -f: factory, -s: seeder
|
||||||
|
|
||||||
|
# 生成 Controller
|
||||||
|
docker compose exec app php artisan make:controller ChatSessionController
|
||||||
|
|
||||||
|
# 生成 Form Request(用于验证)
|
||||||
|
docker compose exec app php artisan make:request AppendMessageRequest
|
||||||
|
|
||||||
|
# 生成 Resource(API 响应格式化)
|
||||||
|
docker compose exec app php artisan make:resource ChatSessionResource
|
||||||
|
|
||||||
|
# 生成 Job
|
||||||
|
docker compose exec app php artisan make:job AgentRunJob
|
||||||
|
|
||||||
|
# 生成 Service 类
|
||||||
|
docker compose exec app php artisan make:class Services/ChatService
|
||||||
|
|
||||||
|
# 生成测试
|
||||||
|
docker compose exec app php artisan make:test ChatSessionTest --phpunit
|
||||||
|
docker compose exec app php artisan make:test ChatServiceTest --unit --phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 路由结构
|
||||||
|
|
||||||
|
所有 API 路由均在 `/api/*` 下,除登录和健康检查外均需 JWT 认证:
|
||||||
|
|
||||||
|
- `POST /api/login`: 用户登录
|
||||||
|
- `GET /api/health`: 健康检查
|
||||||
|
- `GET /api/me`: 获取当前用户信息
|
||||||
|
|
||||||
|
### 用户管理
|
||||||
|
- `GET /api/users`: 用户列表
|
||||||
|
- `POST /api/users`: 创建用户
|
||||||
|
- `PUT /api/users/{user}`: 更新用户
|
||||||
|
- `POST /api/users/{user}/deactivate`: 停用用户
|
||||||
|
- `POST /api/users/{user}/activate`: 激活用户
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
- `POST /api/sessions`: 创建会话
|
||||||
|
- `GET /api/sessions`: 会话列表(支持分页、状态过滤、关键词搜索)
|
||||||
|
- `GET /api/sessions/{session_id}`: 获取会话详情
|
||||||
|
- `PATCH /api/sessions/{session_id}`: 更新会话(重命名、状态变更)
|
||||||
|
- `POST /api/sessions/{session_id}/archive`: 归档会话(幂等,设为 CLOSED)
|
||||||
|
|
||||||
|
### 消息管理
|
||||||
|
- `POST /api/sessions/{session_id}/messages`: 追加消息(支持幂等 dedupe_key)
|
||||||
|
- `GET /api/sessions/{session_id}/messages`: 获取消息列表(支持 after_seq 增量拉取)
|
||||||
|
- `GET /api/sessions/{session_id}/messages/{message_id}`: 获取单条消息
|
||||||
|
|
||||||
|
### 实时与 Agent Run
|
||||||
|
- `GET /api/sessions/{session_id}/sse`: SSE 实时消息流
|
||||||
|
- `POST /api/sessions/{session_id}/runs`: 手动触发 Agent Run
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Enums/ # 枚举类(ChatSessionStatus 等)
|
||||||
|
├── Exceptions/ # 自定义异常
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/ # API 控制器
|
||||||
|
│ │ ├── ChatSessionController.php # 会话和消息 API
|
||||||
|
│ │ ├── ChatSessionSseController.php # SSE 实时推送
|
||||||
|
│ │ ├── RunController.php # Agent Run 手动触发
|
||||||
|
│ │ ├── AuthController.php # 用户认证
|
||||||
|
│ │ └── UserController.php # 用户管理
|
||||||
|
│ ├── Requests/ # Form Request 验证
|
||||||
|
│ └── Resources/ # API 响应格式化
|
||||||
|
├── Jobs/ # 队列任务
|
||||||
|
│ ├── AgentRunJob.php # Agent Run 队列任务
|
||||||
|
│ └── ToolRunJob.php # 工具执行队列任务
|
||||||
|
├── Models/ # Eloquent 模型
|
||||||
|
│ ├── ChatSession.php
|
||||||
|
│ ├── Message.php
|
||||||
|
│ └── User.php
|
||||||
|
├── Providers/ # 服务提供者
|
||||||
|
│ └── AppServiceProvider.php # 绑定 AgentProviderInterface
|
||||||
|
└── Services/ # 业务逻辑服务
|
||||||
|
├── Agent/ # Agent Provider 实现
|
||||||
|
│ ├── OpenAi/ # OpenAI 适配器
|
||||||
|
│ │ ├── OpenAiChatCompletionsAdapter.php
|
||||||
|
│ │ ├── ChatCompletionsRequestBuilder.php
|
||||||
|
│ │ ├── OpenAiApiClient.php
|
||||||
|
│ │ ├── OpenAiStreamParser.php
|
||||||
|
│ │ └── OpenAiEventNormalizer.php
|
||||||
|
│ ├── AgentProviderInterface.php
|
||||||
|
│ ├── AgentContext.php
|
||||||
|
│ ├── ProviderEvent.php
|
||||||
|
│ ├── ProviderEventType.php
|
||||||
|
│ ├── ProviderException.php
|
||||||
|
│ ├── HttpAgentProvider.php
|
||||||
|
│ └── DummyAgentProvider.php
|
||||||
|
├── Tool/ # 工具系统
|
||||||
|
│ ├── Tool.php # 工具接口
|
||||||
|
│ ├── ToolRegistry.php # 工具注册表
|
||||||
|
│ ├── ToolExecutor.php # 工具执行器
|
||||||
|
│ ├── ToolRunDispatcher.php # 工具 Run 分发器
|
||||||
|
│ ├── ToolCall.php # 工具调用对象
|
||||||
|
│ ├── ToolResult.php # 工具结果对象
|
||||||
|
│ └── Tools/ # 具体工具实现
|
||||||
|
│ └── GetTimeTool.php # 获取时间工具(示例)
|
||||||
|
├── ChatService.php # 会话和消息核心服务
|
||||||
|
├── RunDispatcher.php # Run 调度器
|
||||||
|
├── RunLoop.php # Run 执行循环
|
||||||
|
├── ContextBuilder.php # 上下文构建器
|
||||||
|
├── OutputSink.php # 消息写入器
|
||||||
|
└── CancelChecker.php # 取消检查器
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── migrations/
|
||||||
|
│ └── 2025_02_14_000003_create_chat_tables.php # 核心表结构
|
||||||
|
└── factories/ # 测试数据工厂
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── ChatSessionTest.php # 会话和消息测试
|
||||||
|
│ └── AgentRunTest.php # Agent Run 流程测试
|
||||||
|
└── Unit/
|
||||||
|
└── OpenAiAdapterTest.php # OpenAI 适配器单元测试
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── agent.php # Agent Provider 和工具配置
|
||||||
|
├── auth.php # JWT 认证配置
|
||||||
|
├── queue.php # 队列配置
|
||||||
|
└── horizon.php # Horizon 队列监控配置
|
||||||
|
|
||||||
|
bootstrap/
|
||||||
|
├── app.php # Laravel 12 应用引导(中间件、路由、异常)
|
||||||
|
└── providers.php # 服务提供者注册
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计原则**:
|
||||||
|
- 所有 Agent Provider 实现 `AgentProviderInterface::stream()` 接口
|
||||||
|
- 使用 `Generator` 模式流式返回 `ProviderEvent`
|
||||||
|
- 统一通过 `OutputSink` 写入消息,保证事务性和幂等性
|
||||||
|
- 工具系统采用子 Run 模式,每个工具调用创建独立 run
|
||||||
|
- 所有异步操作通过队列(AgentRunJob、ToolRunJob)执行
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
### Laravel 12 新特性
|
||||||
|
- 无 `app/Console/Kernel.php` 和 `app/Http/Kernel.php`
|
||||||
|
- 中间件、路由、异常处理在 `bootstrap/app.php` 配置
|
||||||
|
- 服务提供者在 `bootstrap/providers.php` 注册
|
||||||
|
- Commands 自动注册(无需手动注册)
|
||||||
|
- JWT 中间件别名在 `bootstrap/app.php` 中配置为 `auth.jwt`
|
||||||
|
|
||||||
|
### 数据库操作规范
|
||||||
|
- **消息追加**:必须使用 `ChatService::appendMessage()`,不要直接操作 Message 模型
|
||||||
|
- 原因:需要行锁 + 事务保证 seq 单调递增,并发布 Redis 事件
|
||||||
|
- 所有 `OutputSink` 方法最终都调用 `ChatService::appendMessage()`
|
||||||
|
- **会话状态变更**:必须通过 `ChatService::updateSession()`
|
||||||
|
- 会自动校验 CLOSED 状态不可重新打开
|
||||||
|
- **所有涉及 seq 递增的操作**:必须在事务 + 行锁中完成
|
||||||
|
|
||||||
|
### Provider 与 Event Stream 开发
|
||||||
|
- 实现自定义 Provider 时必须实现 `AgentProviderInterface::stream()` 接口
|
||||||
|
- 使用 `Generator` 模式 yield `ProviderEvent` 对象
|
||||||
|
- 事件类型:
|
||||||
|
- `ProviderEvent::messageDelta($content)`: 流式文本增量
|
||||||
|
- `ProviderEvent::toolCall($toolCallId, $name, $arguments)`: 工具调用
|
||||||
|
- `ProviderEvent::done($finishReason)`: 流结束
|
||||||
|
- `ProviderEvent::error($errorCode, $message, $retryable)`: 错误
|
||||||
|
- 错误处理:抛出 `ProviderException` 包含 errorCode、retryable、httpStatus
|
||||||
|
- `RunLoop` 会自动处理重试、取消检查、工具调用分发
|
||||||
|
|
||||||
|
### 工具开发规范
|
||||||
|
- 创建新工具:继承 `Tool` 抽象类,实现 `name()`、`description()`、`parameters()`、`execute()` 方法
|
||||||
|
- 注册工具:在 `AppServiceProvider` 中调用 `ToolRegistry::register($tool)`
|
||||||
|
- 工具执行:
|
||||||
|
- `execute()` 方法接收 `array $args`,返回字符串结果
|
||||||
|
- 超时控制:通过 `AGENT_TOOL_TIMEOUT_SECONDS` 配置
|
||||||
|
- 结果截断:超过 `AGENT_TOOL_RESULT_MAX_BYTES` 会自动截断并标记
|
||||||
|
- 工具参数:使用 JSON Schema 格式定义,会自动传递给 OpenAI API
|
||||||
|
|
||||||
|
### 测试规范
|
||||||
|
- **框架**:所有测试使用 PHPUnit(非 Pest)
|
||||||
|
- **Feature 测试**:必须测试完整的 HTTP 请求流程
|
||||||
|
- 使用 `RefreshDatabase` trait 在测试间刷新数据库
|
||||||
|
- 使用 `Queue::fake()` 模拟队列
|
||||||
|
- 使用 `Redis::shouldReceive()` 模拟 Redis 发布
|
||||||
|
- **测试数据**:使用 Factory 创建模型数据
|
||||||
|
- **运行测试**:修改代码后必须运行相关测试确保通过
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
docker compose exec app php artisan test
|
||||||
|
|
||||||
|
# 运行特定测试方法
|
||||||
|
docker compose exec app php artisan test --filter=testAppendMessageWithDedupe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 队列配置
|
||||||
|
- **开发环境**:可使用同步队列,`.env` 中设置 `QUEUE_CONNECTION=sync`
|
||||||
|
- 优点:调试方便,错误堆栈清晰
|
||||||
|
- 缺点:阻塞 HTTP 请求
|
||||||
|
- **生产环境**:使用 Redis 队列 + Horizon 监控
|
||||||
|
- `AgentRunJob` 和 `ToolRunJob` 在队列中异步执行
|
||||||
|
- Horizon 仪表板:http://localhost:8000/horizon
|
||||||
|
- **Job 配置**:通过 `config/agent.php` 控制重试次数、退避时间、超时
|
||||||
|
|
||||||
|
### 幂等性设计
|
||||||
|
- **dedupe_key 机制**:所有可能重复调用的操作都使用 `dedupe_key`
|
||||||
|
- 基于 UNIQUE 约束 `unique(session_id, dedupe_key)` 自动去重
|
||||||
|
- 重复请求返回已有消息(相同 message_id 和 seq)
|
||||||
|
- **Run 幂等**:`RunDispatcher` 通过 `trigger_message_id` 的 dedupe_key 确保不会为同一 prompt 重复创建 run
|
||||||
|
- **SSE 续传**:通过 `Last-Event-ID` / `after_seq` 支持断线续传
|
||||||
|
- **消息幂等模式**:
|
||||||
|
- `run:{runId}:agent:message` - Agent 最终回复
|
||||||
|
- `run:{runId}:agent:delta:{index}` - 流式增量
|
||||||
|
- `run:{runId}:status:{status}` - Run 状态
|
||||||
|
- `run:{runId}:tool:call:{toolCallId}` - 工具调用
|
||||||
|
- `run:{runId}:tool:result:{toolCallId}` - 工具结果
|
||||||
|
|
||||||
|
### 性能优化建议
|
||||||
|
- **上下文加载**:`ContextBuilder` 只加载最近 20 条消息,可通过配置调整
|
||||||
|
- **消息分页**:`listMessagesBySeq()` 使用 `after_seq` + `limit` 增量拉取
|
||||||
|
- **索引优化**:`(session_id, seq)` 和 `(session_id, dedupe_key)` 复合索引加速查询
|
||||||
|
- **Redis 发布**:消息追加后异步发布,使用 `DB::afterCommit()` 保证顺序
|
||||||
|
- **SSE 优化**:backlog 限制 200 条,心跳 15 秒,gap 检测自动回补
|
||||||
|
|
||||||
|
## 环境变量关键配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Octane 服务器
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|
||||||
|
# 数据库(PostgreSQL)
|
||||||
|
DB_CONNECTION=pgsql
|
||||||
|
DB_HOST=pgsql
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=ars_backend
|
||||||
|
DB_USERNAME=ars
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
CACHE_STORE=redis
|
||||||
|
|
||||||
|
# 队列
|
||||||
|
QUEUE_CONNECTION=redis # 或 sync(开发用)
|
||||||
|
|
||||||
|
# JWT 认证
|
||||||
|
JWT_SECRET=<生成的密钥>
|
||||||
|
AUTH_GUARD=api
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# OpenAI 兼容 API 配置
|
||||||
|
AGENT_OPENAI_BASE_URL=https://api.openai.com/v1 # 支持任何 OpenAI 兼容端点
|
||||||
|
AGENT_OPENAI_API_KEY= # 为空时使用 DummyProvider
|
||||||
|
AGENT_OPENAI_ORGANIZATION= # 可选
|
||||||
|
AGENT_OPENAI_PROJECT= # 可选
|
||||||
|
AGENT_OPENAI_MODEL=gpt-4o-mini
|
||||||
|
AGENT_OPENAI_TEMPERATURE=0.7
|
||||||
|
AGENT_OPENAI_TOP_P=1.0
|
||||||
|
AGENT_OPENAI_INCLUDE_USAGE=false
|
||||||
|
|
||||||
|
# Agent Provider HTTP 配置(重试机制)
|
||||||
|
AGENT_PROVIDER_TIMEOUT=30 # HTTP 请求超时(秒)
|
||||||
|
AGENT_PROVIDER_CONNECT_TIMEOUT=5 # 连接超时(秒)
|
||||||
|
AGENT_PROVIDER_RETRY_TIMES=1 # 建立流前重试次数(仅连接失败/429/5xx 且未产出事件时)
|
||||||
|
AGENT_PROVIDER_RETRY_BACKOFF_MS=500 # 重试退避毫秒(指数退避)
|
||||||
|
|
||||||
|
# Agent Run Job 配置
|
||||||
|
AGENT_RUN_JOB_TRIES=1 # 队列重试次数
|
||||||
|
AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数
|
||||||
|
AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
|
||||||
|
|
||||||
|
# 工具系统配置
|
||||||
|
AGENT_TOOL_MAX_CALLS_PER_RUN=1 # 单个父 Run 允许的工具调用次数
|
||||||
|
AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒)
|
||||||
|
AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒)
|
||||||
|
AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT)
|
||||||
|
AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入)
|
||||||
|
AGENT_TOOL_CHOICE=auto # OpenAI tool_choice 选项(auto/required 等)
|
||||||
|
AGENT_TOOL_JOB_TRIES=1 # ToolRunJob 重试次数
|
||||||
|
AGENT_TOOL_JOB_BACKOFF=3 # ToolRunJob 重试退避秒数
|
||||||
|
AGENT_TOOL_JOB_TIMEOUT=120 # ToolRunJob 超时时间(秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 初始化新环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 复制环境配置
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. 生成应用密钥
|
||||||
|
docker compose exec app php artisan key:generate
|
||||||
|
|
||||||
|
# 3. 生成 JWT 密钥
|
||||||
|
docker compose exec app php artisan jwt:secret
|
||||||
|
|
||||||
|
# 4. 运行迁移
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
|
||||||
|
# 5. (可选)创建测试用户
|
||||||
|
docker compose exec app php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- API 详细文档:`docs/ChatSession/chat-session-api.md`
|
||||||
|
- OpenAPI 规范:`docs/ChatSession/chat-session-openapi.yaml`
|
||||||
|
- 用户管理文档:`docs/User/user-api.md`
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
### 队列任务不执行
|
||||||
|
- 检查 Horizon 是否运行:`docker compose ps horizon`
|
||||||
|
- 查看 Horizon 日志:`docker compose logs -f horizon`
|
||||||
|
- 检查 Redis 连接:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
> Redis::ping() # 应返回 "PONG"
|
||||||
|
```
|
||||||
|
- 查看失败的任务:`docker compose exec app php artisan queue:failed`
|
||||||
|
- 重试失败任务:`docker compose exec app php artisan queue:retry all`
|
||||||
|
|
||||||
|
### SSE 连接断开
|
||||||
|
- 检查 Nginx/代理是否支持 SSE(需要禁用缓冲)
|
||||||
|
- 确认客户端正确处理 `Last-Event-ID` 续传
|
||||||
|
- 查看 Redis 发布日志
|
||||||
|
- 测试环境下 SSE 会自动回退到仅返回 backlog(无实时推送)
|
||||||
|
|
||||||
|
### Agent Run 失败
|
||||||
|
- 查看 `messages` 表中 `type=error` 的消息:
|
||||||
|
```sql
|
||||||
|
SELECT message_id, session_id, content, payload
|
||||||
|
FROM messages
|
||||||
|
WHERE type = 'error'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
- 检查 `payload.error_type`、`payload.provider`、`payload.retryable`、`payload.details`
|
||||||
|
- 检查 Provider 配置:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan config:show agent
|
||||||
|
```
|
||||||
|
- 查看实时日志:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan pail
|
||||||
|
# 或查看容器日志
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
- 测试 OpenAI API Key:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
> $provider = app(App\Services\Agent\AgentProviderInterface::class);
|
||||||
|
> $context = new App\Services\Agent\AgentContext('test', []);
|
||||||
|
> foreach ($provider->stream($context) as $event) { dump($event); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工具调用问题
|
||||||
|
- 检查工具是否注册:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
> $registry = app(App\Services\Tool\ToolRegistry::class);
|
||||||
|
> dump($registry->openAiToolsSpec());
|
||||||
|
```
|
||||||
|
- 查看 tool.call 和 tool.result 消息:
|
||||||
|
```sql
|
||||||
|
SELECT message_id, type, content, payload
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = 'xxx' AND type IN ('tool.call', 'tool.result')
|
||||||
|
ORDER BY seq;
|
||||||
|
```
|
||||||
|
- 检查工具调用上限:配置 `AGENT_TOOL_MAX_CALLS_PER_RUN`
|
||||||
|
- 工具执行超时:检查 `payload.status` 是否为 `TIMEOUT`
|
||||||
|
|
||||||
|
### 数据库迁移问题
|
||||||
|
- 确保 PostgreSQL 已启动:`docker compose ps pgsql`
|
||||||
|
- 查看迁移状态:`docker compose exec app php artisan migrate:status`
|
||||||
|
- 检查数据库连接:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
> DB::connection()->getPdo()
|
||||||
|
```
|
||||||
|
- 查看数据库日志:`docker compose logs -f pgsql`
|
||||||
|
|
||||||
|
### 消息 seq 不连续或重复
|
||||||
|
- 检查是否有并发追加消息(应使用行锁 + 事务)
|
||||||
|
- 确认所有消息追加都通过 `ChatService::appendMessage()`
|
||||||
|
- 查看 unique 约束冲突日志
|
||||||
|
|
||||||
|
### 调试技巧
|
||||||
|
- **实时日志**:`docker compose exec app php artisan pail`
|
||||||
|
- **Telescope**:访问 http://localhost:8000/telescope 查看请求、查询、队列
|
||||||
|
- **Tinker REPL**:`docker compose exec app php artisan tinker` 交互式调试
|
||||||
|
- **查看配置**:`php artisan config:show agent`
|
||||||
|
- **查看路由**:`php artisan route:list`
|
||||||
|
- **数据库查询日志**:在 `.env` 中设置 `DB_LOG_QUERIES=true`
|
||||||
149
README.md
Normal file → Executable file
149
README.md
Normal file → Executable file
@@ -1,76 +1,99 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# Agent Runtime Server (ARS) · Laravel + Octane + Docker
|
||||||
|
|
||||||
<p align="center">
|
> 自建可部署的 Agent Runtime Server。愿景:兼容大部分 Agent 模型、随时在 Web 终端输入指令/查看日志、断开后任务继续执行,重新连入能续上会话。
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
>
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
> “输入 prompt 就能离开工位,路上/手机继续 approve 和观察”。
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Local Development (Docker + FrankenPHP Octane)
|
## 🎯 愿景与思路(摘录)
|
||||||
|
- 兼容多数 Agent 模型,提供可观测的运行日志。
|
||||||
|
- Web 终端可随时输入/查看;断线不中断,重连可续。
|
||||||
|
- 面向开源生态,避免被单一厂商闭锁;先做最小实现,再逐步拆分组件。
|
||||||
|
|
||||||
Everything runs in containers; no PHP/Composer is required on the host.
|
## 🏗️ 当前架构概览
|
||||||
|
- **运行**:Docker Compose,FrankenPHP + Laravel Octane。
|
||||||
|
- **存储**:PostgreSQL(主存储),Redis(可选,用于 SSE 推送);messages.payload 提供表达式索引以加速 run/cancel 查询。
|
||||||
|
- **鉴权**:JWT(无状态),API 路由均在 `/api/*`。
|
||||||
|
- **会话/消息模型**:
|
||||||
|
- `chat_sessions(session_id UUID, session_name, status OPEN|LOCKED|CLOSED, last_seq, last_message_id, timestamps)`
|
||||||
|
- `messages(message_id UUID, session_id, role USER|AGENT|TOOL|SYSTEM, type, content, payload jsonb, seq, reply_to, dedupe_key)`
|
||||||
|
- 约束:`unique(session_id, seq)`、`unique(session_id, dedupe_key)`;append 行锁 + 事务,seq 单调递增。
|
||||||
|
- **实时**:SSE `/api/sessions/{id}/sse`,backlog 先补历史(按 seq),再监听 Redis `session:{id}:messages` 渠道;发现 seq gap 会回补并提供心跳。
|
||||||
|
- **Agent Run 编排(MVP-0)**:user.prompt 后自动触发 RunDispatcher → `run.status=RUNNING` → AgentRunJob(HttpProvider,未配置 endpoint 时回退 DummyProvider)→ `agent.message` + `run.status=DONE/FAILED/CANCELED` 落库;同 trigger_message 幂等、同会话只允许一个 RUNNING,终态/消息均去重,取消可保证不写 agent.message。
|
||||||
|
- **队列监控**:Horizon(本地默认开放 `/horizon`,非 local 环境默认拒绝访问)。
|
||||||
|
|
||||||
1. Build the FrankenPHP image (installs PHP extensions, Composer, git): `docker compose build`
|
## 🚀 快速启动
|
||||||
2. Start the stack (app + PostgreSQL + Redis): `docker compose up -d app pgsql redis`
|
```bash
|
||||||
3. Tail app logs: `docker compose logs -f app`
|
# 构建并启动
|
||||||
4. Run framework commands: `docker compose exec app php artisan migrate`
|
docker compose build
|
||||||
5. Stop and clean up: `docker compose down` (add `-v` to drop databases/Redis data)
|
docker compose up -d app horizon pgsql redis
|
||||||
|
|
||||||
Notes:
|
# 首次迁移(仅需一次)
|
||||||
- `docker/app/entrypoint.sh` will run `composer install`, copy `.env` if missing, generate `APP_KEY`, and boot Octane with `--watch`.
|
docker compose exec app php artisan migrate
|
||||||
- Config sets `OCTANE_SERVER=frankenphp` by default (see `.env` and `config/octane.php`).
|
|
||||||
- PostgreSQL service: host `pgsql`, port `5432`, database `ars_backend`, user `ars`, password `secret` (see `.env.example`).
|
|
||||||
- Redis service: host `redis`, port `6379`, default no password, client `phpredis`.
|
|
||||||
- 项目沟通与自然语言默认使用中文。
|
|
||||||
|
|
||||||
## About Laravel
|
# 运行 Feature 测试
|
||||||
|
docker compose exec app php artisan test --testsuite=Feature
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
# 队列(AgentRunJob):开发可用同步队列,或用 Horizon
|
||||||
|
# 同步:.env / phpunit.xml 中 QUEUE_CONNECTION=sync
|
||||||
|
# Horizon:docker compose up -d horizon(需 composer install 安装 laravel/horizon,QUEUE_CONNECTION=redis)
|
||||||
|
```
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
### Agent Provider 配置(可选)
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
`config/agent.php` 读取以下环境变量(默认值已内置),用于控制 HTTP 调用、OpenAI 直连以及队列重试:
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
- `AGENT_PROVIDER_ENDPOINT`:自定义 HTTP Provider 入口(为空时回退 Dummy 或 OpenAI 适配器)
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
- `AGENT_PROVIDER_TIMEOUT`(默认 30):Provider HTTP 请求超时时间(秒)
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
- `AGENT_PROVIDER_CONNECT_TIMEOUT`(默认 5):Provider 连接超时时间(秒)
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
- `AGENT_PROVIDER_RETRY_TIMES`(默认 1):建立流前的重试次数(仅连接失败/429/5xx 且尚未产出事件时重试)
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
- `AGENT_PROVIDER_RETRY_BACKOFF_MS`(默认 500):重试退避(毫秒,指数退避)
|
||||||
|
- `AGENT_OPENAI_BASE_URL`(默认 https://api.openai.com/v1):OpenAI-compatible Chat Completions 基础地址
|
||||||
|
- `AGENT_OPENAI_API_KEY`:OpenAI API Key(为空则使用 DummyProvider)
|
||||||
|
- `AGENT_OPENAI_ORGANIZATION`:OpenAI Organization header,可选
|
||||||
|
- `AGENT_OPENAI_PROJECT`:OpenAI Project header,可选
|
||||||
|
- `AGENT_OPENAI_MODEL`(默认 gpt-4o-mini):模型名称
|
||||||
|
- `AGENT_OPENAI_TEMPERATURE`(默认 0.7):采样温度
|
||||||
|
- `AGENT_OPENAI_TOP_P`(默认 1.0):Top-p 采样
|
||||||
|
- `AGENT_OPENAI_INCLUDE_USAGE`(默认 false):是否请求流式返回 usage 统计
|
||||||
|
- `AGENT_RUN_JOB_TRIES`(默认 1):AgentRunJob 队列重试次数
|
||||||
|
- `AGENT_RUN_JOB_BACKOFF`(默认 3):AgentRunJob 重试退避秒数
|
||||||
|
- `AGENT_RUN_JOB_TIMEOUT`(默认 360):AgentRunJob 超时时间(秒)
|
||||||
|
- 工具调用(子 Run 模式):
|
||||||
|
- `AGENT_TOOL_MAX_CALLS_PER_RUN`(默认 1):单个父 Run 允许的工具调用次数上限(超过直接失败)
|
||||||
|
- `AGENT_TOOL_WAIT_TIMEOUT_MS`(默认 15000):等待子 Run 写入 `tool.result` 的超时时间(毫秒)
|
||||||
|
- `AGENT_TOOL_WAIT_POLL_MS`(默认 200):等待工具结果时的轮询间隔(毫秒)
|
||||||
|
- `AGENT_TOOL_TIMEOUT_SECONDS`(默认 15):单个工具执行的预期超时时间(超过会记为 TIMEOUT)
|
||||||
|
- `AGENT_TOOL_RESULT_MAX_BYTES`(默认 4096):工具结果最大保存字节数(超出会截断)
|
||||||
|
- `AGENT_TOOL_CHOICE`(默认 auto):OpenAI tool_choice 传参策略
|
||||||
|
- `AGENT_TOOL_JOB_TRIES/AGENT_TOOL_JOB_BACKOFF/AGENT_TOOL_JOB_TIMEOUT`:ToolRunJob 队列重试/退避/超时设置
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
## 🔑 API 能力一览(MVP-1.1 + Archive/GetMessage/SSE)
|
||||||
|
- 会话:`POST /api/sessions`,`GET /api/sessions`(分页/状态/关键词),`GET /api/sessions/{id}`,`PATCH /api/sessions/{id}`(重命名/状态,CLOSED 不可重开),`POST /api/sessions/{id}/archive`(幂等归档→CLOSED)。
|
||||||
|
- 消息:`POST /api/sessions/{id}/messages`(幂等 dedupe_key,CLOSED/LOCKED 门禁),`GET /api/sessions/{id}/messages`(after_seq 增量),`GET /api/sessions/{id}/messages/{message_id}`(校验 session_id)。
|
||||||
|
- 实时:`GET /api/sessions/{id}/sse?after_seq=123`,SSE 事件 id 为 seq;`Last-Event-ID` 优先于 query;gap 会回补,定期心跳保活。
|
||||||
|
- Agent Run:user.prompt 自动触发;或 `POST /api/sessions/{id}/runs {trigger_message_id}` 手动触发,写入 `run.status/agent.message`,幂等去重,失败会写 `error`(含 retryable/http_status/provider/latency_ms)。
|
||||||
|
|
||||||
## Learning Laravel
|
详细字段/示例:`docs/ChatSession/chat-session-api.md`,OpenAPI:`docs/ChatSession/chat-session-openapi.yaml`。用户管理/鉴权文档:`docs/User/user-api.md`。
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
## 🔒 状态与门禁规则
|
||||||
|
- `OPEN`:正常追加。
|
||||||
|
- `LOCKED`:拒绝 `role=USER && type=user.prompt`。
|
||||||
|
- `CLOSED`:拒绝追加,例外 `role=SYSTEM && type in [run.status, error]`。
|
||||||
|
- 幂等:同一 session + dedupe_key 返回已有消息(同 message_id/seq);run.status 与 agent.message 均通过 dedupe_key 防重复。
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
## 🔌 开发/验证
|
||||||
|
- 迁移:`docker compose exec app php artisan migrate`
|
||||||
|
- 测试:`docker compose exec app php artisan test --testsuite=Feature`
|
||||||
|
- cURL 示例(需 Bearer TOKEN):
|
||||||
|
```bash
|
||||||
|
# 归档
|
||||||
|
curl -X POST http://localhost:8000/api/sessions/{sid}/archive -H "Authorization: Bearer $TOKEN"
|
||||||
|
# 单条消息
|
||||||
|
curl http://localhost:8000/api/sessions/{sid}/messages/{mid} -H "Authorization: Bearer $TOKEN"
|
||||||
|
# SSE(断线续传:可带 Last-Event-ID)
|
||||||
|
curl -N http://localhost:8000/api/sessions/{sid}/sse?after_seq=0 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Accept: text/event-stream"
|
||||||
|
```
|
||||||
|
|
||||||
## Laravel Sponsors
|
## 📌 后续演进(规划)
|
||||||
|
- Agent Loop/Tools/Policy Engine/Context Store 解耦与插件化。
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
- 更丰富的前端控制台:日志流、工具审批、移动端友好。
|
||||||
|
- 兼容多家模型/工具 SDK,保持开放生态。
|
||||||
### Premium Partners
|
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
15
app/Enums/ChatSessionStatus.php
Normal file
15
app/Enums/ChatSessionStatus.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
class ChatSessionStatus
|
||||||
|
{
|
||||||
|
public const OPEN = 'OPEN';
|
||||||
|
public const LOCKED = 'LOCKED';
|
||||||
|
public const CLOSED = 'CLOSED';
|
||||||
|
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [self::OPEN, self::LOCKED, self::CLOSED];
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Exceptions/ChatSessionStatusException.php
Normal file
9
app/Exceptions/ChatSessionStatusException.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ChatSessionStatusException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
40
app/Http/Controllers/AuthController.php
Normal file
40
app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function login(LoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validated();
|
||||||
|
|
||||||
|
$user = User::whereEmail($credentials['email'])->first();
|
||||||
|
|
||||||
|
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||||
|
return response()->json(['message' => '凭证无效'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
return response()->json(['message' => '用户已停用'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = auth('api')->login($user);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'bearer',
|
||||||
|
'expires_in' => auth('api')->factory()->getTTL() * 60,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'is_active' => $user->is_active,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/Http/Controllers/ChatSessionController.php
Normal file
145
app/Http/Controllers/ChatSessionController.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exceptions\ChatSessionStatusException;
|
||||||
|
use App\Http\Requests\AppendMessageRequest;
|
||||||
|
use App\Http\Requests\CreateSessionRequest;
|
||||||
|
use App\Http\Requests\UpdateSessionRequest;
|
||||||
|
use App\Http\Resources\ChatSessionResource;
|
||||||
|
use App\Http\Resources\MessageResource;
|
||||||
|
use App\Services\ChatService;
|
||||||
|
use App\Services\RunDispatcher;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ChatSessionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatService $service,
|
||||||
|
private readonly RunDispatcher $runDispatcher,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储新的会话。
|
||||||
|
*
|
||||||
|
* @param CreateSessionRequest $request 包含会话信息的请求实例。
|
||||||
|
* @return JsonResponse 返回包含新创建会话信息的 JSON 响应,状态码为 201。
|
||||||
|
*/
|
||||||
|
public function store(CreateSessionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$session = $this->service->createSession($request->input('session_name'));
|
||||||
|
|
||||||
|
return (new ChatSessionResource($session))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加一条消息。
|
||||||
|
*
|
||||||
|
* @param string $sessionId 会话 ID。
|
||||||
|
* @param AppendMessageRequest $request 追加消息的请求实例。
|
||||||
|
* @return JsonResponse 添加消息的响应,包含添加的消息信息。
|
||||||
|
*/
|
||||||
|
public function append(string $sessionId, AppendMessageRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$message = $this->service->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
...$request->validated(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($message->role === 'USER' && $message->type === 'user.prompt') {
|
||||||
|
$this->runDispatcher->dispatchForPrompt($sessionId, $message->message_id);
|
||||||
|
}
|
||||||
|
} catch (ChatSessionStatusException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new MessageResource($message))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定会话的消息列表。
|
||||||
|
*
|
||||||
|
* @param Request $request 包含查询参数的请求实例,其中包括 after_seq 和 limit。
|
||||||
|
* @param string $sessionId 会话的唯一标识符。
|
||||||
|
* @return JsonResponse 返回包含消息列表的 JSON 响应。
|
||||||
|
*/
|
||||||
|
public function listMessages(Request $request, string $sessionId): JsonResponse
|
||||||
|
{
|
||||||
|
$afterSeq = (int) $request->query('after_seq', 0);
|
||||||
|
$limit = (int) $request->query('limit', 50);
|
||||||
|
$limit = $limit > 0 && $limit <= 200 ? $limit : 50;
|
||||||
|
|
||||||
|
$messages = $this->service->listMessagesBySeq($sessionId, $afterSeq, $limit);
|
||||||
|
|
||||||
|
return MessageResource::collection($messages)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showMessage(string $sessionId, string $messageId): JsonResponse
|
||||||
|
{
|
||||||
|
$message = $this->service->getMessage($sessionId, $messageId);
|
||||||
|
|
||||||
|
if (! $message) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new MessageResource($message))->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话列表。
|
||||||
|
*
|
||||||
|
* @param Request $request 获取会话列表的请求实例。
|
||||||
|
* @return JsonResponse 获取的会话列表的 JSON 响应。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = (int) $request->query('page', 1);
|
||||||
|
$perPage = (int) $request->query('per_page', 15);
|
||||||
|
$perPage = $perPage > 0 && $perPage <= 100 ? $perPage : 15;
|
||||||
|
|
||||||
|
$filter = [
|
||||||
|
'status' => $request->query('status'),
|
||||||
|
'q' => $request->query('q'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$paginator = $this->service->listSessions($filter, $page, $perPage);
|
||||||
|
|
||||||
|
return ChatSessionResource::collection($paginator)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话。
|
||||||
|
*
|
||||||
|
* @param string $sessionId 会话的唯一标识符。
|
||||||
|
* @param UpdateSessionRequest $request 更新会话的请求实例。
|
||||||
|
* @return JsonResponse 更新后的会话的 JSON 响应。
|
||||||
|
*/
|
||||||
|
public function update(string $sessionId, UpdateSessionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$session = $this->service->updateSession($sessionId, $request->validated());
|
||||||
|
} catch (ChatSessionStatusException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new ChatSessionResource($session))->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $sessionId): JsonResponse
|
||||||
|
{
|
||||||
|
$session = $this->service->getSessionWithLastMessage($sessionId);
|
||||||
|
|
||||||
|
return (new ChatSessionResource($session))->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archive(string $sessionId): JsonResponse
|
||||||
|
{
|
||||||
|
$session = $this->service->archiveSession($sessionId);
|
||||||
|
|
||||||
|
return (new ChatSessionResource($session))->response();
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Http/Controllers/ChatSessionSseController.php
Normal file
147
app/Http/Controllers/ChatSessionSseController.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Resources\MessageResource;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\ChatService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ChatSessionSseController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ChatService $service)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream(Request $request, string $sessionId): Response|StreamedResponse
|
||||||
|
{
|
||||||
|
set_time_limit(360);
|
||||||
|
$this->service->getSession($sessionId); // ensure exists
|
||||||
|
|
||||||
|
$lastEventId = $request->header('Last-Event-ID');
|
||||||
|
$afterSeq = is_numeric($lastEventId) ? (int) $lastEventId : (int) $request->query('after_seq', 0);
|
||||||
|
$limit = (int) $request->query('limit', 200);
|
||||||
|
$limit = $limit > 0 && $limit <= 500 ? $limit : 200;
|
||||||
|
|
||||||
|
$useBacklogOnly = app()->runningUnitTests()
|
||||||
|
|| app()->environment('testing')
|
||||||
|
|| defined('PHPUNIT_COMPOSER_INSTALL')
|
||||||
|
|| ! class_exists(\Redis::class);
|
||||||
|
|
||||||
|
if ($useBacklogOnly) {
|
||||||
|
$lastSentSeq = $afterSeq;
|
||||||
|
ob_start();
|
||||||
|
$this->sendBacklog($sessionId, $lastSentSeq, $limit, false);
|
||||||
|
$content = ob_get_clean() ?: '';
|
||||||
|
|
||||||
|
return response($content, 200, [
|
||||||
|
'Content-Type' => 'text/event-stream',
|
||||||
|
'Cache-Control' => 'no-cache',
|
||||||
|
'X-Accel-Buffering' => 'no',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new StreamedResponse(function () use ($sessionId, $afterSeq, $limit) {
|
||||||
|
$lastSentSeq = $afterSeq;
|
||||||
|
$lastHeartbeat = microtime(true);
|
||||||
|
|
||||||
|
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
|
||||||
|
$this->sendHeartbeat($lastHeartbeat);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis = Redis::connection()->client();
|
||||||
|
if (method_exists($redis, 'setOption')) {
|
||||||
|
$redis->setOption(\Redis::OPT_READ_TIMEOUT, 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = "session:{$sessionId}:messages";
|
||||||
|
logger('sse open');
|
||||||
|
// Fallback for Redis drivers without pubSubLoop (older phpredis)
|
||||||
|
$redis->subscribe([$channel], function ($redisInstance, $chan, $payload) use (&$lastSentSeq, $sessionId, $limit, &$lastHeartbeat) {
|
||||||
|
if (connection_aborted()) {
|
||||||
|
logger('sse aborted');
|
||||||
|
$redisInstance->unsubscribe([$chan]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendHeartbeat($lastHeartbeat);
|
||||||
|
|
||||||
|
if (! $payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$msg = new Message($msg);
|
||||||
|
|
||||||
|
// message.delta 不持久化,seq=0,直接推送
|
||||||
|
if ($msg && $msg->type === 'message.delta') {
|
||||||
|
$this->emitMessage($msg, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($msg && $msg->seq > $lastSentSeq) {
|
||||||
|
if ($msg->seq > $lastSentSeq + 1) {
|
||||||
|
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($msg->seq > $lastSentSeq) {
|
||||||
|
$this->emitMessage($msg);
|
||||||
|
$lastSentSeq = $msg->seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (\RedisException $exception) {
|
||||||
|
logger()->warning('SSE redis subscription failed', [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'code' => $exception->getCode(),
|
||||||
|
]);
|
||||||
|
echo ": redis-error\n\n";
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$response->headers->set('Content-Type', 'text/event-stream');
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache');
|
||||||
|
$response->headers->set('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendBacklog(string $sessionId, int &$lastSentSeq, int $limit, bool $flush = true): void
|
||||||
|
{
|
||||||
|
$backlog = $this->service->listMessagesBySeq($sessionId, $lastSentSeq, $limit);
|
||||||
|
foreach ($backlog as $message) {
|
||||||
|
$this->emitMessage($message, $flush);
|
||||||
|
$lastSentSeq = $message->seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emitMessage($message, bool $flush = true): void
|
||||||
|
{
|
||||||
|
$payload = (new MessageResource($message))->resolve();
|
||||||
|
echo 'id: '.$message->seq."\n";
|
||||||
|
echo "event: message\n";
|
||||||
|
echo 'data: '.json_encode($payload, JSON_UNESCAPED_UNICODE)."\n\n";
|
||||||
|
if ($flush) {
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendHeartbeat(float &$lastHeartbeat, int $intervalSeconds = 15): void
|
||||||
|
{
|
||||||
|
if ((microtime(true) - $lastHeartbeat) < $intervalSeconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ": ping\n\n";
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
$lastHeartbeat = microtime(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/RunController.php
Normal file
28
app/Http/Controllers/RunController.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\DispatchRunRequest;
|
||||||
|
use App\Jobs\TestJob;
|
||||||
|
use App\Services\RunDispatcher;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class RunController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RunDispatcher $dispatcher)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(string $sessionId, DispatchRunRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$runId = $this->dispatcher->dispatchForPrompt($sessionId, $request->validated()['trigger_message_id']);
|
||||||
|
|
||||||
|
return response()->json(['run_id' => $runId], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test()
|
||||||
|
{
|
||||||
|
$job = TestJob::dispatch();
|
||||||
|
unset($job);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Controllers/UserController.php
Normal file
54
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreUserRequest;
|
||||||
|
use App\Http\Requests\UpdateUserRequest;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = (int) $request->query('per_page', 15);
|
||||||
|
$perPage = $perPage > 0 && $perPage <= 100 ? $perPage : 15;
|
||||||
|
|
||||||
|
$users = User::orderBy('id')->paginate($perPage);
|
||||||
|
|
||||||
|
return UserResource::collection($users)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreUserRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['is_active'] = true;
|
||||||
|
|
||||||
|
$user = User::create($payload);
|
||||||
|
|
||||||
|
return (new UserResource($user))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateUserRequest $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user->update($request->validated());
|
||||||
|
|
||||||
|
return (new UserResource($user))->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user->update(['is_active' => false]);
|
||||||
|
|
||||||
|
return (new UserResource($user))->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user->update(['is_active' => true]);
|
||||||
|
|
||||||
|
return (new UserResource($user))->response();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Requests/AppendMessageRequest.php
Normal file
35
app/Http/Requests/AppendMessageRequest.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AppendMessageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', 'string', Rule::in([
|
||||||
|
Message::ROLE_USER,
|
||||||
|
Message::ROLE_AGENT,
|
||||||
|
Message::ROLE_TOOL,
|
||||||
|
Message::ROLE_SYSTEM,
|
||||||
|
])],
|
||||||
|
'type' => ['required', 'string', 'max:64'],
|
||||||
|
'content' => ['nullable', 'string'],
|
||||||
|
'payload' => ['nullable', 'array'],
|
||||||
|
'reply_to' => ['nullable', 'uuid'],
|
||||||
|
'dedupe_key' => ['nullable', 'string', 'max:128'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Requests/CreateSessionRequest.php
Normal file
23
app/Http/Requests/CreateSessionRequest.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CreateSessionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'session_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Requests/DispatchRunRequest.php
Normal file
23
app/Http/Requests/DispatchRunRequest.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class DispatchRunRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'trigger_message_id' => ['required', 'uuid'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/LoginRequest.php
Normal file
24
app/Http/Requests/LoginRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/StoreUserRequest.php
Normal file
25
app/Http/Requests/StoreUserRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Requests/UpdateSessionRequest.php
Normal file
42
app/Http/Requests/UpdateSessionRequest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\ChatSessionStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSessionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'session_name' => ['sometimes', 'string', 'min:1', 'max:255'],
|
||||||
|
'status' => ['sometimes', 'string', Rule::in(ChatSessionStatus::all())],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('session_name') && is_string($this->input('session_name'))) {
|
||||||
|
$this->merge(['session_name' => trim($this->input('session_name'))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator($validator)
|
||||||
|
{
|
||||||
|
$validator->after(function ($v) {
|
||||||
|
if (! $this->has('session_name') && ! $this->has('status')) {
|
||||||
|
$v->errors()->add('payload', '至少提供一个可更新字段');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/UpdateUserRequest.php
Normal file
32
app/Http/Requests/UpdateUserRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'sometimes',
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'email')->ignore($this->route('user')),
|
||||||
|
],
|
||||||
|
'password' => ['sometimes', 'required', 'string', 'min:8'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Resources/ChatSessionResource.php
Normal file
32
app/Http/Resources/ChatSessionResource.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/** @mixin \App\Models\ChatSession */
|
||||||
|
class ChatSessionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$preview = $this->last_message_content ?? '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_id' => $this->session_id,
|
||||||
|
'session_name' => $this->session_name,
|
||||||
|
'status' => $this->status,
|
||||||
|
'last_seq' => $this->last_seq,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
'last_message_at' => $this->last_message_at,
|
||||||
|
'last_message_preview' => $preview ? Str::limit($preview, 120) : '',
|
||||||
|
'last_message_role' => $this->last_message_role,
|
||||||
|
'last_message_type' => $this->last_message_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Resources/MessageResource.php
Normal file
31
app/Http/Resources/MessageResource.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin \App\Models\Message */
|
||||||
|
class MessageResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'message_id' => $this->message_id,
|
||||||
|
'session_id' => $this->session_id,
|
||||||
|
'seq' => $this->seq,
|
||||||
|
'role' => $this->role,
|
||||||
|
'type' => $this->type,
|
||||||
|
'content' => $this->content,
|
||||||
|
'payload' => $this->payload,
|
||||||
|
'reply_to' => $this->reply_to,
|
||||||
|
'dedupe_key' => $this->dedupe_key,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Resources/UserResource.php
Normal file
27
app/Http/Resources/UserResource.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin \App\Models\User */
|
||||||
|
class UserResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Jobs/AgentRunJob.php
Normal file
77
app/Jobs/AgentRunJob.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Agent\ProviderException;
|
||||||
|
use App\Services\CancelChecker;
|
||||||
|
use App\Services\OutputSink;
|
||||||
|
use App\Services\RunLoop;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class AgentRunJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries;
|
||||||
|
|
||||||
|
public int $timeout;
|
||||||
|
|
||||||
|
public int $backoff;
|
||||||
|
|
||||||
|
public function __construct(public string $sessionId, public string $runId)
|
||||||
|
{
|
||||||
|
$this->tries = (int) config('agent.job.tries', 1);
|
||||||
|
$this->timeout = (int) config('agent.job.timeout_seconds', 120);
|
||||||
|
$this->backoff = (int) config('agent.job.backoff_seconds', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(RunLoop $loop, OutputSink $sink, CancelChecker $cancelChecker): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
logger("Running run {$this->runId} for session {$this->sessionId}");
|
||||||
|
$loop->run($this->sessionId, $this->runId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger("Running error {$this->runId} for session {$this->sessionId}");
|
||||||
|
logger("error message:",[$e->getMessage(),$e->getTraceAsString()]);
|
||||||
|
$errorCode = $e instanceof ProviderException ? $e->errorCode : 'run.failed';
|
||||||
|
$dedupeKey = $e instanceof ProviderException
|
||||||
|
? "run:{$this->runId}:error:provider"
|
||||||
|
: "run:{$this->runId}:error:job";
|
||||||
|
|
||||||
|
$sink->appendError($this->sessionId, $this->runId, $errorCode, $e->getMessage(), [], $dedupeKey);
|
||||||
|
$sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'dedupe_key' => "run:{$this->runId}:status:FAILED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$latestStatus = Message::query()
|
||||||
|
->where('session_id', $this->sessionId)
|
||||||
|
->where('type', 'run.status')
|
||||||
|
->whereRaw("payload->>'run_id' = ?", [$this->runId])
|
||||||
|
->orderByDesc('seq')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$status = $latestStatus ? ($latestStatus->payload['status'] ?? null) : null;
|
||||||
|
|
||||||
|
if ($status === 'RUNNING' || ! $status) {
|
||||||
|
if ($cancelChecker->isCanceled($this->sessionId, $this->runId)) {
|
||||||
|
$sink->appendRunStatus($this->sessionId, $this->runId, 'CANCELED', [
|
||||||
|
'dedupe_key' => "run:{$this->runId}:status:CANCELED",
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [
|
||||||
|
'error' => 'JOB_ENDED_UNEXPECTEDLY',
|
||||||
|
'dedupe_key' => "run:{$this->runId}:status:FAILED",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Jobs/TestJob.php
Normal file
23
app/Jobs/TestJob.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TestJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
logger('TestJob');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Jobs/ToolRunJob.php
Normal file
72
app/Jobs/ToolRunJob.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\CancelChecker;
|
||||||
|
use App\Services\OutputSink;
|
||||||
|
use App\Services\Tool\ToolCall;
|
||||||
|
use App\Services\Tool\ToolExecutor;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ToolRunJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries;
|
||||||
|
|
||||||
|
public int $timeout;
|
||||||
|
|
||||||
|
public int $backoff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $toolCall
|
||||||
|
*/
|
||||||
|
public function __construct(public string $sessionId, public array $toolCall)
|
||||||
|
{
|
||||||
|
$this->tries = (int) config('agent.tools.job.tries', 1);
|
||||||
|
$this->timeout = (int) config('agent.tools.job.timeout_seconds', 60);
|
||||||
|
$this->backoff = (int) config('agent.tools.job.backoff_seconds', 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ToolExecutor $executor, OutputSink $sink, CancelChecker $cancelChecker): void
|
||||||
|
{
|
||||||
|
$call = ToolCall::fromArray($this->toolCall);
|
||||||
|
|
||||||
|
logger('ToolRunJob call:', $call->toArray());
|
||||||
|
|
||||||
|
if ($cancelChecker->isCanceled($this->sessionId, $call->parentRunId)) {
|
||||||
|
$sink->appendRunStatus($this->sessionId, $call->runId, 'CANCELED', [
|
||||||
|
'dedupe_key' => "run:{$call->runId}:status:CANCELED",
|
||||||
|
'parent_run_id' => $call->parentRunId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $executor->execute($call);
|
||||||
|
$sink->appendToolResult($this->sessionId, $result);
|
||||||
|
|
||||||
|
$status = $result->status === 'SUCCESS' ? 'DONE' : 'FAILED';
|
||||||
|
$sink->appendRunStatus($this->sessionId, $call->runId, $status, [
|
||||||
|
'parent_run_id' => $call->parentRunId,
|
||||||
|
'tool_call_id' => $call->toolCallId,
|
||||||
|
'dedupe_key' => "run:{$call->runId}:status:{$status}",
|
||||||
|
'error' => $result->error,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$sink->appendRunStatus($this->sessionId, $call->runId, 'FAILED', [
|
||||||
|
'parent_run_id' => $call->parentRunId,
|
||||||
|
'tool_call_id' => $call->toolCallId,
|
||||||
|
'dedupe_key' => "run:{$call->runId}:status:FAILED",
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/ChatSession.php
Normal file
49
app/Models/ChatSession.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ChatSessionStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class ChatSession extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $primaryKey = 'session_id';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'session_id',
|
||||||
|
'session_name',
|
||||||
|
'status',
|
||||||
|
'last_seq',
|
||||||
|
'last_message_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'last_seq' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Message::class, 'session_id', 'session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isClosed(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ChatSessionStatus::CLOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ChatSessionStatus::LOCKED;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Models/Message.php
Normal file
52
app/Models/Message.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Message extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const ROLE_USER = 'USER';
|
||||||
|
public const ROLE_AGENT = 'AGENT';
|
||||||
|
public const ROLE_TOOL = 'TOOL';
|
||||||
|
public const ROLE_SYSTEM = 'SYSTEM';
|
||||||
|
|
||||||
|
protected $primaryKey = 'message_id';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'message_id',
|
||||||
|
'session_id',
|
||||||
|
'role',
|
||||||
|
'type',
|
||||||
|
'content',
|
||||||
|
'payload',
|
||||||
|
'seq',
|
||||||
|
'reply_to',
|
||||||
|
'dedupe_key',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => 'array',
|
||||||
|
'seq' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function session(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ChatSession::class, 'session_id', 'session_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements JWTSubject
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
@@ -21,6 +22,7 @@ class User extends Authenticatable
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'is_active',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +45,17 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'is_active' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getJWTIdentifier(): mixed
|
||||||
|
{
|
||||||
|
return $this->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJWTCustomClaims(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->bind(\App\Services\Agent\AgentProviderInterface::class, function () {
|
||||||
|
return $this->app->make(\App\Services\Agent\HttpAgentProvider::class);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
31
app/Providers/HorizonServiceProvider.php
Normal file
31
app/Providers/HorizonServiceProvider.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Laravel\Horizon\Horizon;
|
||||||
|
|
||||||
|
class HorizonServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if (! class_exists(Horizon::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Horizon::auth(function ($request) {
|
||||||
|
return app()->environment('local');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Providers/TelescopeServiceProvider.php
Normal file
53
app/Providers/TelescopeServiceProvider.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Laravel\Telescope\IncomingEntry;
|
||||||
|
use Laravel\Telescope\Telescope;
|
||||||
|
use Laravel\Telescope\TelescopeApplicationServiceProvider;
|
||||||
|
|
||||||
|
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
Telescope::night();
|
||||||
|
|
||||||
|
$this->hideSensitiveRequestDetails();
|
||||||
|
|
||||||
|
$isLocal = $this->app->environment('local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent sensitive request details from being logged by Telescope.
|
||||||
|
*/
|
||||||
|
protected function hideSensitiveRequestDetails(): void
|
||||||
|
{
|
||||||
|
if ($this->app->environment('local')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Telescope::hideRequestParameters(['_token']);
|
||||||
|
|
||||||
|
Telescope::hideRequestHeaders([
|
||||||
|
'cookie',
|
||||||
|
'x-csrf-token',
|
||||||
|
'x-xsrf-token',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Telescope gate.
|
||||||
|
*
|
||||||
|
* This gate determines who can access Telescope in non-local environments.
|
||||||
|
*/
|
||||||
|
protected function gate(): void
|
||||||
|
{
|
||||||
|
Gate::define('viewTelescope', function ($user) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Services/Agent/AgentContext.php
Normal file
17
app/Services/Agent/AgentContext.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
final class AgentContext
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{message_id: string, role: string, type: string, content: ?string, payload: ?array, seq: int}> $messages
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $runId,
|
||||||
|
public string $sessionId,
|
||||||
|
public string $systemPrompt,
|
||||||
|
public array $messages,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Services/Agent/AgentPlatformAdapterInterface.php
Normal file
14
app/Services/Agent/AgentPlatformAdapterInterface.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
interface AgentPlatformAdapterInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return \Generator<int, \App\Services\Agent\ProviderEvent>
|
||||||
|
*/
|
||||||
|
public function stream(AgentContext $context, array $options = []): \Generator;
|
||||||
|
|
||||||
|
public function name(): string;
|
||||||
|
}
|
||||||
12
app/Services/Agent/AgentProviderInterface.php
Normal file
12
app/Services/Agent/AgentProviderInterface.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
interface AgentProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return \Generator<int, \App\Services\Agent\ProviderEvent>
|
||||||
|
*/
|
||||||
|
public function stream(AgentContext $context, array $options = []): \Generator;
|
||||||
|
}
|
||||||
32
app/Services/Agent/DummyAgentProvider.php
Normal file
32
app/Services/Agent/DummyAgentProvider.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
class DummyAgentProvider implements AgentProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*/
|
||||||
|
public function stream(AgentContext $context, array $options = []): \Generator
|
||||||
|
{
|
||||||
|
$messages = $context->messages;
|
||||||
|
$lastUser = null;
|
||||||
|
foreach (array_reverse($messages) as $msg) {
|
||||||
|
if (($msg['role'] ?? '') === 'USER' && ($msg['type'] ?? '') === 'user.prompt') {
|
||||||
|
$lastUser = $msg['content'] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $lastUser ? mb_substr($lastUser, 0, 80) : 'no user prompt';
|
||||||
|
|
||||||
|
$content = sprintf(
|
||||||
|
"Dummy-Agent: 我的当前回复的条目为 -> %s \n 我的上下文是: %s",
|
||||||
|
$summary,
|
||||||
|
json_encode($context->messages)
|
||||||
|
);
|
||||||
|
|
||||||
|
yield ProviderEvent::messageDelta($content);
|
||||||
|
yield ProviderEvent::done('dummy');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Services/Agent/HttpAgentProvider.php
Normal file
29
app/Services/Agent/HttpAgentProvider.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
use App\Services\Agent\OpenAi\OpenAiChatCompletionsAdapter;
|
||||||
|
|
||||||
|
class HttpAgentProvider implements AgentProviderInterface
|
||||||
|
{
|
||||||
|
private readonly bool $enabled;
|
||||||
|
|
||||||
|
public function __construct(private readonly OpenAiChatCompletionsAdapter $adapter)
|
||||||
|
{
|
||||||
|
$baseUrl = (string) config('agent.openai.base_url', '');
|
||||||
|
$apiKey = (string) config('agent.openai.api_key', '');
|
||||||
|
$this->enabled = trim($baseUrl) !== '' && trim($apiKey) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*/
|
||||||
|
public function stream(AgentContext $context, array $options = []): \Generator
|
||||||
|
{
|
||||||
|
if (! $this->enabled) {
|
||||||
|
return (new DummyAgentProvider())->stream($context, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->adapter->stream($context, $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
243
app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php
Normal file
243
app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent\OpenAi;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Agent\AgentContext;
|
||||||
|
use App\Services\Tool\ToolRegistry;
|
||||||
|
|
||||||
|
class ChatCompletionsRequestBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ToolRegistry $toolRegistry,
|
||||||
|
private ?string $model = null,
|
||||||
|
private ?float $temperature = null,
|
||||||
|
private ?float $topP = null,
|
||||||
|
private ?bool $includeUsage = null,
|
||||||
|
private ?string $toolChoice = null,
|
||||||
|
) {
|
||||||
|
$this->model = $this->model ?? (string) config('agent.openai.model', 'gpt-4o-mini');
|
||||||
|
$this->temperature = $this->temperature ?? (float) config('agent.openai.temperature', 0.7);
|
||||||
|
$this->topP = $this->topP ?? (float) config('agent.openai.top_p', 1.0);
|
||||||
|
$this->includeUsage = $this->includeUsage ?? (bool) config('agent.openai.include_usage', false);
|
||||||
|
$this->toolChoice = $this->toolChoice ?? (string) config('agent.tools.tool_choice', 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an OpenAI-compatible Chat Completions payload from AgentContext.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function build(AgentContext $context, array $options = []): array
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'model' => (string) ($options['model'] ?? $this->model),
|
||||||
|
'messages' => $this->buildMessages($context),
|
||||||
|
'stream' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (array_key_exists('temperature', $options)) {
|
||||||
|
$payload['temperature'] = (float) $options['temperature'];
|
||||||
|
} else {
|
||||||
|
$payload['temperature'] = (float) $this->temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('top_p', $options)) {
|
||||||
|
$payload['top_p'] = (float) $options['top_p'];
|
||||||
|
} else {
|
||||||
|
$payload['top_p'] = (float) $this->topP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('max_tokens', $options)) {
|
||||||
|
$payload['max_tokens'] = (int) $options['max_tokens'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('stop', $options)) {
|
||||||
|
$payload['stop'] = $options['stop'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('stream_options', $options)) {
|
||||||
|
$payload['stream_options'] = $options['stream_options'];
|
||||||
|
} elseif ($this->includeUsage) {
|
||||||
|
$payload['stream_options'] = ['include_usage' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('response_format', $options)) {
|
||||||
|
$payload['response_format'] = $options['response_format'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$toolsSpec = $this->toolRegistry->openAiToolsSpec();
|
||||||
|
$hasToolMessages = $this->hasToolMessages($context);
|
||||||
|
|
||||||
|
// 支持 disable_tools 选项,用于在达到工具调用上限后禁用工具
|
||||||
|
$disableTools = $options['disable_tools'] ?? false;
|
||||||
|
|
||||||
|
if (! empty($toolsSpec)) {
|
||||||
|
if ($disableTools) {
|
||||||
|
$payload['tool_choice'] = 'none';
|
||||||
|
if ($hasToolMessages) {
|
||||||
|
// 历史包含工具消息时仍需携带 tools 定义以满足接口校验
|
||||||
|
$payload['tools'] = $toolsSpec;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$payload['tools'] = $toolsSpec;
|
||||||
|
$payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildMessages(AgentContext $context): array
|
||||||
|
{
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
if ($context->systemPrompt !== '') {
|
||||||
|
$messages[] = [
|
||||||
|
'role' => 'system',
|
||||||
|
'content' => $context->systemPrompt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($context->messages as $message) {
|
||||||
|
$role = $this->mapRole((string) ($message['role'] ?? ''));
|
||||||
|
$content = $message['content'] ?? null;
|
||||||
|
$type = (string) ($message['type'] ?? '');
|
||||||
|
$payload = $message['payload'] ?? null;
|
||||||
|
|
||||||
|
if (! $role || ! is_string($content) || $content === '') {
|
||||||
|
$content = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'tool.call' && is_array($payload)) {
|
||||||
|
$toolCall = $this->normalizeToolCallPayload($payload, $content);
|
||||||
|
if ($toolCall) {
|
||||||
|
$messages[] = $toolCall;
|
||||||
|
logger('openai adapter: added tool.call', [
|
||||||
|
'tool_call_id' => $payload['tool_call_id'] ?? null,
|
||||||
|
'name' => $payload['name'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'tool.result' && is_array($payload)) {
|
||||||
|
$toolResult = $this->normalizeToolResultPayload($payload, $content);
|
||||||
|
if ($toolResult) {
|
||||||
|
$messages[] = $toolResult;
|
||||||
|
logger('openai adapter: added tool.result', [
|
||||||
|
'tool_call_id' => $payload['tool_call_id'] ?? null,
|
||||||
|
'name' => $payload['name'] ?? null,
|
||||||
|
'content_length' => strlen($toolResult['content'] ?? ''),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
logger('openai adapter: tool.result normalized to null', [
|
||||||
|
'payload' => $payload,
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($content !== null) {
|
||||||
|
$messages[] = [
|
||||||
|
'role' => $role,
|
||||||
|
'content' => $content,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('openai adapter: built messages', [
|
||||||
|
'total_messages' => count($messages),
|
||||||
|
'message_roles' => array_column($messages, 'role'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapRole(string $role): ?string
|
||||||
|
{
|
||||||
|
return match ($role) {
|
||||||
|
Message::ROLE_USER => 'user',
|
||||||
|
Message::ROLE_AGENT => 'assistant',
|
||||||
|
Message::ROLE_SYSTEM => 'system',
|
||||||
|
Message::ROLE_TOOL => 'tool',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function normalizeToolCallPayload(array $payload, ?string $content): ?array
|
||||||
|
{
|
||||||
|
$toolCallId = $payload['tool_call_id'] ?? null;
|
||||||
|
$name = $payload['name'] ?? null;
|
||||||
|
$arguments = $payload['arguments'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($toolCallId) || ! is_string($name) || $toolCallId === '' || $name === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$argumentsString = is_string($arguments) ? $arguments : json_encode($arguments, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => $content,
|
||||||
|
'tool_calls' => [
|
||||||
|
[
|
||||||
|
'id' => $toolCallId,
|
||||||
|
'type' => 'function',
|
||||||
|
'function' => [
|
||||||
|
'name' => $name,
|
||||||
|
'arguments' => $argumentsString ?? '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function normalizeToolResultPayload(array $payload, ?string $content): ?array
|
||||||
|
{
|
||||||
|
$toolCallId = $payload['tool_call_id'] ?? null;
|
||||||
|
$name = $payload['name'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($toolCallId) || $toolCallId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultContent = $content ?? ($payload['output'] ?? null);
|
||||||
|
|
||||||
|
if (! is_string($resultContent)) {
|
||||||
|
$resultContent = json_encode($payload['output'] ?? $payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'role' => 'tool',
|
||||||
|
'tool_call_id' => $toolCallId,
|
||||||
|
'name' => is_string($name) ? $name : null,
|
||||||
|
'content' => $resultContent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasToolMessages(AgentContext $context): bool
|
||||||
|
{
|
||||||
|
foreach ($context->messages as $message) {
|
||||||
|
$type = (string) ($message['type'] ?? '');
|
||||||
|
if ($type === 'tool.call' || $type === 'tool.result') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Services/Agent/OpenAi/OpenAiApiClient.php
Normal file
87
app/Services/Agent/OpenAi/OpenAiApiClient.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent\OpenAi;
|
||||||
|
|
||||||
|
use App\Services\Agent\ProviderException;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class OpenAiApiClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ?string $baseUrl = null,
|
||||||
|
private ?string $apiKey = null,
|
||||||
|
private ?string $organization = null,
|
||||||
|
private ?string $project = null,
|
||||||
|
private ?int $timeoutSeconds = null,
|
||||||
|
private ?int $connectTimeoutSeconds = null,
|
||||||
|
) {
|
||||||
|
$this->baseUrl = $this->baseUrl ?? (string) config('agent.openai.base_url', '');
|
||||||
|
$this->apiKey = $this->apiKey ?? (string) config('agent.openai.api_key', '');
|
||||||
|
$this->organization = $this->organization ?? (string) config('agent.openai.organization', '');
|
||||||
|
$this->project = $this->project ?? (string) config('agent.openai.project', '');
|
||||||
|
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.provider.timeout_seconds', 30);
|
||||||
|
$this->connectTimeoutSeconds = $this->connectTimeoutSeconds ?? (int) config('agent.provider.connect_timeout_seconds', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a streaming response for the Chat Completions endpoint.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function openStream(array $payload): ResponseInterface
|
||||||
|
{
|
||||||
|
$baseUrl = trim((string) $this->baseUrl);
|
||||||
|
$apiKey = trim((string) $this->apiKey);
|
||||||
|
|
||||||
|
if ($baseUrl === '' || $apiKey === '') {
|
||||||
|
throw new ProviderException('CONFIG_MISSING', 'Agent provider configuration missing', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = rtrim($baseUrl, '/').'/chat/completions';
|
||||||
|
$headers = [
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
'Accept' => 'text/event-stream',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (trim((string) $this->organization) !== '') {
|
||||||
|
$headers['OpenAI-Organization'] = (string) $this->organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $this->project) !== '') {
|
||||||
|
$headers['OpenAI-Project'] = (string) $this->project;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->connectTimeout($this->connectTimeoutSeconds)
|
||||||
|
->timeout($this->timeoutSeconds)
|
||||||
|
->withOptions(['stream' => true])
|
||||||
|
->post($endpoint, $payload);
|
||||||
|
} catch (ConnectionException $exception) {
|
||||||
|
throw new ProviderException(
|
||||||
|
'CONNECTION_FAILED',
|
||||||
|
'Agent provider connection failed',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
$exception->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $response->status();
|
||||||
|
|
||||||
|
if ($status < 200 || $status >= 300) {
|
||||||
|
$retryable = $status === 429 || $status >= 500;
|
||||||
|
throw new ProviderException(
|
||||||
|
'HTTP_ERROR',
|
||||||
|
'Agent provider failed',
|
||||||
|
$retryable,
|
||||||
|
$status,
|
||||||
|
$response->body()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->toPsrResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/Services/Agent/OpenAi/OpenAiChatCompletionsAdapter.php
Normal file
102
app/Services/Agent/OpenAi/OpenAiChatCompletionsAdapter.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent\OpenAi;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentContext;
|
||||||
|
use App\Services\Agent\AgentPlatformAdapterInterface;
|
||||||
|
use App\Services\Agent\ProviderEvent;
|
||||||
|
use App\Services\Agent\ProviderEventType;
|
||||||
|
use App\Services\Agent\ProviderException;
|
||||||
|
|
||||||
|
class OpenAiChatCompletionsAdapter implements AgentPlatformAdapterInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatCompletionsRequestBuilder $requestBuilder,
|
||||||
|
private readonly OpenAiApiClient $apiClient,
|
||||||
|
private readonly OpenAiStreamParser $streamParser,
|
||||||
|
private readonly OpenAiEventNormalizer $eventNormalizer,
|
||||||
|
private ?int $retryTimes = null,
|
||||||
|
private ?int $retryBackoffMs = null,
|
||||||
|
) {
|
||||||
|
$this->retryTimes = $this->retryTimes ?? (int) config('agent.provider.retry_times', 1);
|
||||||
|
$this->retryBackoffMs = $this->retryBackoffMs ?? (int) config('agent.provider.retry_backoff_ms', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams OpenAI-compatible chat completions and yields normalized events.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return \Generator<int, ProviderEvent>
|
||||||
|
*/
|
||||||
|
public function stream(AgentContext $context, array $options = []): \Generator
|
||||||
|
{
|
||||||
|
$payload = $this->requestBuilder->build($context, $options);
|
||||||
|
$attempts = $this->retryTimes + 1;
|
||||||
|
$attempt = 1;
|
||||||
|
$backoffMs = $this->retryBackoffMs;
|
||||||
|
$hasYielded = false;
|
||||||
|
$shouldStop = $options['should_stop'] ?? null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
$response = $this->apiClient->openStream($payload);
|
||||||
|
$stream = $response->getBody();
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($this->streamParser->parse($stream, is_callable($shouldStop) ? $shouldStop : null) as $chunk) {
|
||||||
|
$events = $this->eventNormalizer->normalize($chunk);
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$hasYielded = true;
|
||||||
|
yield $event;
|
||||||
|
if ($event->type === ProviderEventType::Done || $event->type === ProviderEventType::Error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$stream->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasYielded) {
|
||||||
|
if (is_callable($shouldStop) && $shouldStop()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield ProviderEvent::error('EMPTY_STREAM', 'Agent provider returned empty stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (ProviderException $exception) {
|
||||||
|
if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasYielded && $exception->retryable && $attempt < $attempts) {
|
||||||
|
usleep($backoffMs * 1000);
|
||||||
|
$attempt++;
|
||||||
|
$backoffMs *= 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield ProviderEvent::error($exception->errorCode, $exception->getMessage(), [
|
||||||
|
'retryable' => $exception->retryable,
|
||||||
|
'http_status' => $exception->httpStatus,
|
||||||
|
'raw_message' => $exception->rawMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield ProviderEvent::error('UNKNOWN_ERROR', $exception->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'openai.chat.completions';
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Agent/OpenAi/OpenAiEventNormalizer.php
Normal file
69
app/Services/Agent/OpenAi/OpenAiEventNormalizer.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent\OpenAi;
|
||||||
|
|
||||||
|
use App\Services\Agent\ProviderEvent;
|
||||||
|
|
||||||
|
class OpenAiEventNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalizes a single SSE payload into ProviderEvent values.
|
||||||
|
*
|
||||||
|
* @return array<int, ProviderEvent>
|
||||||
|
*/
|
||||||
|
public function normalize(string $payload): array
|
||||||
|
{
|
||||||
|
if (trim($payload) === '[DONE]') {
|
||||||
|
return [ProviderEvent::done('done')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($payload, true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return [ProviderEvent::error('INVALID_JSON', 'Agent provider returned invalid JSON', [
|
||||||
|
'raw' => $payload,
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
$choices = $decoded['choices'] ?? [];
|
||||||
|
$firstChoice = is_array($choices) ? ($choices[0] ?? null) : null;
|
||||||
|
$delta = is_array($firstChoice) ? ($firstChoice['delta'] ?? null) : null;
|
||||||
|
|
||||||
|
if (is_array($delta)) {
|
||||||
|
$content = $delta['content'] ?? null;
|
||||||
|
if (is_string($content) && $content !== '') {
|
||||||
|
$events[] = ProviderEvent::messageDelta($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($delta['tool_calls']) && is_array($delta['tool_calls'])) {
|
||||||
|
$toolCalls = [];
|
||||||
|
foreach ($delta['tool_calls'] as $toolCall) {
|
||||||
|
if (! is_array($toolCall)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toolCalls[] = [
|
||||||
|
'id' => $toolCall['id'] ?? null,
|
||||||
|
'name' => $toolCall['function']['name'] ?? null,
|
||||||
|
'arguments' => $toolCall['function']['arguments'] ?? '',
|
||||||
|
'index' => $toolCall['index'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($toolCalls)) {
|
||||||
|
$events[] = ProviderEvent::toolDelta(['tool_calls' => $toolCalls]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($firstChoice) && array_key_exists('finish_reason', $firstChoice) && $firstChoice['finish_reason'] !== null) {
|
||||||
|
$events[] = ProviderEvent::done((string) $firstChoice['finish_reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($decoded['usage']) && is_array($decoded['usage'])) {
|
||||||
|
$events[] = ProviderEvent::usage($decoded['usage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Services/Agent/OpenAi/OpenAiStreamParser.php
Normal file
75
app/Services/Agent/OpenAi/OpenAiStreamParser.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent\OpenAi;
|
||||||
|
|
||||||
|
use Psr\Http\Message\StreamInterface;
|
||||||
|
|
||||||
|
class OpenAiStreamParser
|
||||||
|
{
|
||||||
|
public function __construct(private readonly int $chunkSize = 1024)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses SSE data lines into payload strings.
|
||||||
|
*
|
||||||
|
* @return \Generator<int, string>
|
||||||
|
*/
|
||||||
|
public function parse(StreamInterface $stream, ?callable $shouldStop = null): \Generator
|
||||||
|
{
|
||||||
|
$buffer = '';
|
||||||
|
$eventData = '';
|
||||||
|
|
||||||
|
while (! $stream->eof()) {
|
||||||
|
if ($shouldStop && $shouldStop()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunk = $stream->read($this->chunkSize);
|
||||||
|
|
||||||
|
if ($chunk === '') {
|
||||||
|
usleep(10000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer .= $chunk;
|
||||||
|
|
||||||
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||||
|
$line = substr($buffer, 0, $pos);
|
||||||
|
$buffer = substr($buffer, $pos + 1);
|
||||||
|
$line = rtrim($line, "\r");
|
||||||
|
|
||||||
|
if ($line === '') {
|
||||||
|
if ($eventData !== '') {
|
||||||
|
yield $eventData;
|
||||||
|
$eventData = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($line, 'data:')) {
|
||||||
|
$data = ltrim(substr($line, 5));
|
||||||
|
if ($eventData !== '') {
|
||||||
|
$eventData .= "\n";
|
||||||
|
}
|
||||||
|
$eventData .= $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== '') {
|
||||||
|
$line = rtrim($buffer, "\r");
|
||||||
|
if (str_starts_with($line, 'data:')) {
|
||||||
|
$data = ltrim(substr($line, 5));
|
||||||
|
if ($eventData !== '') {
|
||||||
|
$eventData .= "\n";
|
||||||
|
}
|
||||||
|
$eventData .= $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventData !== '') {
|
||||||
|
yield $eventData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Services/Agent/ProviderEvent.php
Normal file
60
app/Services/Agent/ProviderEvent.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
final class ProviderEvent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ProviderEventType $type,
|
||||||
|
public array $payload = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function messageDelta(string $text): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::MessageDelta, ['text' => $text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public static function toolCall(array $payload): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::ToolCall, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public static function toolDelta(array $payload): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::ToolDelta, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $usage
|
||||||
|
*/
|
||||||
|
public static function usage(array $usage): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::Usage, $usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function done(?string $reason = null): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::Done, ['reason' => $reason]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public static function error(string $code, string $message, array $meta = []): self
|
||||||
|
{
|
||||||
|
return new self(ProviderEventType::Error, array_merge([
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
], $meta));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Services/Agent/ProviderEventType.php
Normal file
13
app/Services/Agent/ProviderEventType.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
enum ProviderEventType: string
|
||||||
|
{
|
||||||
|
case MessageDelta = 'message.delta';
|
||||||
|
case ToolCall = 'tool.call';
|
||||||
|
case ToolDelta = 'tool.delta';
|
||||||
|
case Usage = 'usage';
|
||||||
|
case Done = 'done';
|
||||||
|
case Error = 'error';
|
||||||
|
}
|
||||||
16
app/Services/Agent/ProviderException.php
Normal file
16
app/Services/Agent/ProviderException.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Agent;
|
||||||
|
|
||||||
|
class ProviderException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $errorCode,
|
||||||
|
string $message,
|
||||||
|
public readonly bool $retryable = false,
|
||||||
|
public readonly ?int $httpStatus = null,
|
||||||
|
public readonly ?string $rawMessage = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Services/CancelChecker.php
Normal file
18
app/Services/CancelChecker.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
|
||||||
|
class CancelChecker
|
||||||
|
{
|
||||||
|
public function isCanceled(string $sessionId, string $runId): bool
|
||||||
|
{
|
||||||
|
return Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('type', 'run.cancel.request')
|
||||||
|
->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM])
|
||||||
|
->whereRaw("payload->>'run_id' = ?", [$runId])
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
354
app/Services/ChatService.php
Normal file
354
app/Services/ChatService.php
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\ChatSessionStatus;
|
||||||
|
use App\Exceptions\ChatSessionStatusException;
|
||||||
|
use App\Models\ChatSession;
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ChatService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly MessageSequence $messageSequence)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个新聊天会话。
|
||||||
|
* @param string|null $name
|
||||||
|
* @return ChatSession
|
||||||
|
*/
|
||||||
|
public function createSession(string $name = null): ChatSession
|
||||||
|
{
|
||||||
|
return ChatSession::create([
|
||||||
|
'session_id' => (string) Str::uuid(),
|
||||||
|
'session_name' => $name,
|
||||||
|
'status' => ChatSessionStatus::OPEN,
|
||||||
|
'last_seq' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会话ID获取聊天会话对象
|
||||||
|
*
|
||||||
|
* @param string $sessionId 会话唯一标识符
|
||||||
|
* @return ChatSession 返回对应的聊天会话对象
|
||||||
|
* @throws ModelNotFoundException 当找不到指定会话时抛出异常
|
||||||
|
*/
|
||||||
|
public function getSession(string $sessionId): ChatSession
|
||||||
|
{
|
||||||
|
return ChatSession::query()->whereKey($sessionId)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将消息追加到指定的聊天会话中。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $dto 包含消息详细信息的数据传输对象,包括以下键:
|
||||||
|
* - session_id: 聊天会话的唯一标识符
|
||||||
|
* - role: 消息角色(如发件人或收件人)
|
||||||
|
* - type: 消息类型
|
||||||
|
* - content: 消息内容(可选)
|
||||||
|
* - payload: 附加信息(可选)
|
||||||
|
* - reply_to: 被回复的消息 ID(可选)
|
||||||
|
* - dedupe_key: 消息去重键(可选)
|
||||||
|
* @param bool|null $wasDeduped 是否发生了去重(可选,按引用返回)
|
||||||
|
* @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。
|
||||||
|
*/
|
||||||
|
public function appendMessage(array $dto, ?bool &$wasDeduped = null, bool $save = true): Message
|
||||||
|
{
|
||||||
|
$messageRef = null;
|
||||||
|
$isNew = false;
|
||||||
|
$wasDeduped = false;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($dto, &$messageRef, &$isNew, &$wasDeduped, $save) {
|
||||||
|
/** @var ChatSession $session */
|
||||||
|
$session = ChatSession::query()
|
||||||
|
->whereKey($dto['session_id'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->ensureCanAppend($session, $dto['role'], $dto['type']);
|
||||||
|
|
||||||
|
$dedupeKey = $dto['dedupe_key'] ?? null;
|
||||||
|
if ($dedupeKey) {
|
||||||
|
$existing = Message::query()
|
||||||
|
->where('session_id', $session->session_id)
|
||||||
|
->where('dedupe_key', $dedupeKey)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$messageRef = $existing;
|
||||||
|
$wasDeduped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts = 0;
|
||||||
|
while (true) {
|
||||||
|
$attempts++;
|
||||||
|
$newSeq = $this->messageSequence->nextForSession($session);
|
||||||
|
|
||||||
|
$message = new Message([
|
||||||
|
'message_id' => (string) Str::uuid(),
|
||||||
|
'session_id' => $session->session_id,
|
||||||
|
'role' => $dto['role'],
|
||||||
|
'type' => $dto['type'],
|
||||||
|
'content' => $dto['content'] ?? null,
|
||||||
|
'payload' => $dto['payload'] ?? null,
|
||||||
|
'reply_to' => $dto['reply_to'] ?? null,
|
||||||
|
'dedupe_key' => $dedupeKey,
|
||||||
|
'seq' => $newSeq,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($save) {
|
||||||
|
$message->save();
|
||||||
|
}
|
||||||
|
$isNew = true;
|
||||||
|
break;
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
if ($this->isUniqueConstraint($e) && $dedupeKey) {
|
||||||
|
$existing = Message::query()
|
||||||
|
->where('session_id', $session->session_id)
|
||||||
|
->where('dedupe_key', $dedupeKey)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$messageRef = $existing;
|
||||||
|
$wasDeduped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isUniqueConstraint($e) && $this->isSeqUniqueConstraint($e) && $attempts < 3) {
|
||||||
|
$maxPersistedSeq = (int) (Message::query()
|
||||||
|
->where('session_id', $session->session_id)
|
||||||
|
->max('seq') ?? 0);
|
||||||
|
|
||||||
|
$this->messageSequence->syncToAtLeast($session->session_id, max($session->last_seq, $maxPersistedSeq));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->update([
|
||||||
|
'last_seq' => $newSeq,
|
||||||
|
'last_message_id' => $message->message_id,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$messageRef = $message;
|
||||||
|
|
||||||
|
if ($isNew && $save) {
|
||||||
|
DB::afterCommit(fn () => $this->publishMessageAppended($message));
|
||||||
|
} else {
|
||||||
|
$this->publishMessageAppended($message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @var Message $messageRef */
|
||||||
|
return $messageRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出指定会话中指定序号之后的消息。
|
||||||
|
*
|
||||||
|
* @param string $sessionId 会话唯一标识符
|
||||||
|
* @param int $afterSeq 指定序号之后的消息将被列出
|
||||||
|
* @param int $limit 最多返回的消息数量,默认为 50
|
||||||
|
* @return Collection 返回指定序号之后的消息列表
|
||||||
|
*/
|
||||||
|
public function listMessagesBySeq(string $sessionId, int $afterSeq, int $limit = 50): Collection
|
||||||
|
{
|
||||||
|
$this->getSession($sessionId); // ensure exists
|
||||||
|
|
||||||
|
return Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('seq', '>', $afterSeq)
|
||||||
|
->orderBy('seq')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSessionWithLastMessage(string $sessionId): ChatSession
|
||||||
|
{
|
||||||
|
/** @var ChatSession $session */
|
||||||
|
$session = $this->baseSessionQuery()
|
||||||
|
->where('chat_sessions.session_id', $sessionId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archiveSession(string $sessionId): ChatSession
|
||||||
|
{
|
||||||
|
/** @var ChatSession $session */
|
||||||
|
$session = ChatSession::query()->whereKey($sessionId)->firstOrFail();
|
||||||
|
|
||||||
|
if ($session->status !== ChatSessionStatus::CLOSED) {
|
||||||
|
$session->update([
|
||||||
|
'status' => ChatSessionStatus::CLOSED,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getSessionWithLastMessage($sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(string $sessionId, string $messageId): ?Message
|
||||||
|
{
|
||||||
|
$message = Message::query()->where('message_id', $messageId)->first();
|
||||||
|
|
||||||
|
if (! $message || $message->session_id !== $sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话列表
|
||||||
|
*
|
||||||
|
* @param array $filter 过滤条件数组,支持status状态过滤和q关键字搜索
|
||||||
|
* @param int $page 页码
|
||||||
|
* @param int $perPage 每页数量
|
||||||
|
* @return LengthAwarePaginator 分页结果
|
||||||
|
*/
|
||||||
|
public function listSessions(array $filter, int $page, int $perPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
// 构建基础查询
|
||||||
|
$query = $this->baseSessionQuery();
|
||||||
|
|
||||||
|
// 根据状态过滤会话
|
||||||
|
if (! empty($filter['status'])) {
|
||||||
|
$query->where('chat_sessions.status', $filter['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据会话名称进行模糊搜索
|
||||||
|
if (! empty($filter['q'])) {
|
||||||
|
$q = $filter['q'];
|
||||||
|
$query->where('chat_sessions.session_name', 'ilike', '%'.$q.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按更新时间倒序排列
|
||||||
|
$query->orderByDesc('chat_sessions.updated_at');
|
||||||
|
|
||||||
|
// 执行分页查询
|
||||||
|
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新聊天会话信息
|
||||||
|
*
|
||||||
|
* @param string $sessionId 会话ID
|
||||||
|
* @param array $patch 需要更新的字段数据
|
||||||
|
* @return ChatSession 更新后的聊天会话对象
|
||||||
|
* @throws ChatSessionStatusException 当尝试重新打开已关闭的会话时抛出异常
|
||||||
|
*/
|
||||||
|
public function updateSession(string $sessionId, array $patch): ChatSession
|
||||||
|
{
|
||||||
|
// 查找包含最后一条消息的会话
|
||||||
|
$session = $this->findSessionWithLastMessage($sessionId);
|
||||||
|
|
||||||
|
// 检查会话状态变更合法性:已关闭的会话不能重新打开
|
||||||
|
if (isset($patch['status']) && $session->status === ChatSessionStatus::CLOSED && $patch['status'] !== ChatSessionStatus::CLOSED) {
|
||||||
|
throw new ChatSessionStatusException('Closed session cannot be reopened');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充更新数据并保存
|
||||||
|
$session->fill($patch);
|
||||||
|
$session->updated_at = now();
|
||||||
|
$session->save();
|
||||||
|
|
||||||
|
// 返回更新后的会话信息
|
||||||
|
return $this->findSessionWithLastMessage($sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function baseSessionQuery()
|
||||||
|
{
|
||||||
|
return ChatSession::query()
|
||||||
|
->leftJoin('messages as lm', 'lm.message_id', '=', 'chat_sessions.last_message_id')
|
||||||
|
->select('chat_sessions.*')
|
||||||
|
->addSelect([
|
||||||
|
'lm.created_at as last_message_at',
|
||||||
|
'lm.content as last_message_content',
|
||||||
|
'lm.role as last_message_role',
|
||||||
|
'lm.type as last_message_type',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSessionWithLastMessage(string $sessionId): ChatSession
|
||||||
|
{
|
||||||
|
/** @var ChatSession $session */
|
||||||
|
$session = $this->baseSessionQuery()
|
||||||
|
->where('chat_sessions.session_id', $sessionId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishMessageAppended(Message $message): void
|
||||||
|
{
|
||||||
|
$root = Redis::getFacadeRoot();
|
||||||
|
$isMocked = $root instanceof \Mockery\MockInterface;
|
||||||
|
|
||||||
|
if (! class_exists(\Redis::class) && ! $isMocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = "session:{$message->session_id}:messages";
|
||||||
|
try {
|
||||||
|
//todo::优化这里。
|
||||||
|
Redis::publish($channel, json_encode($message->toArray(), JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_IGNORE));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Redis publish failed', [
|
||||||
|
'session_id' => $message->session_id,
|
||||||
|
'message_id' => $message->message_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureCanAppend(ChatSession $session, string $role, string $type): void
|
||||||
|
{
|
||||||
|
if ($session->status === ChatSessionStatus::CLOSED) {
|
||||||
|
$allowed = $role === Message::ROLE_SYSTEM && in_array($type, ['run.status', 'error'], true);
|
||||||
|
if (! $allowed) {
|
||||||
|
throw new ChatSessionStatusException('Session is closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session->status === ChatSessionStatus::LOCKED) {
|
||||||
|
if ($role === Message::ROLE_USER && $type === 'user.prompt') {
|
||||||
|
throw new ChatSessionStatusException('Session is locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUniqueConstraint(QueryException $e): bool
|
||||||
|
{
|
||||||
|
$sqlState = $e->getCode() ?: ($e->errorInfo[0] ?? null);
|
||||||
|
|
||||||
|
return $sqlState === '23505';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSeqUniqueConstraint(QueryException $e): bool
|
||||||
|
{
|
||||||
|
$details = $e->errorInfo[2] ?? $e->getMessage();
|
||||||
|
|
||||||
|
return is_string($details) && str_contains($details, 'messages_session_id_seq_unique');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Services/ContextBuilder.php
Normal file
55
app/Services/ContextBuilder.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Agent\AgentContext;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ContextBuilder
|
||||||
|
{
|
||||||
|
public function __construct(private readonly int $limit = 20)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(string $sessionId, string $runId): AgentContext
|
||||||
|
{
|
||||||
|
$messages = $this->loadRecentMessages($sessionId);
|
||||||
|
|
||||||
|
logger('context builder loaded messages', [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'run_id' => $runId,
|
||||||
|
'message_count' => $messages->count(),
|
||||||
|
'message_types' => $messages->pluck('type', 'seq')->toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new AgentContext(
|
||||||
|
$runId,
|
||||||
|
$sessionId,
|
||||||
|
'You are an agent inside ARS. Respond concisely in markdown format. Use the following conversation context.',
|
||||||
|
$messages->map(function (Message $message) {
|
||||||
|
return [
|
||||||
|
'message_id' => $message->message_id,
|
||||||
|
'role' => $message->role,
|
||||||
|
'type' => $message->type,
|
||||||
|
'content' => $message->content,
|
||||||
|
'payload' => $message->payload,
|
||||||
|
'seq' => $message->seq,
|
||||||
|
];
|
||||||
|
})->values()->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadRecentMessages(string $sessionId): Collection
|
||||||
|
{
|
||||||
|
return Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->whereIn('role', [Message::ROLE_USER, Message::ROLE_AGENT, Message::ROLE_TOOL])
|
||||||
|
->whereIn('type', ['user.prompt', 'agent.message', 'tool.call', 'tool.result'])
|
||||||
|
->orderByDesc('seq')
|
||||||
|
->limit($this->limit)
|
||||||
|
->get()
|
||||||
|
->sortBy('seq')
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/MessageSequence.php
Normal file
59
app/Services/MessageSequence.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ChatSession;
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
class MessageSequence
|
||||||
|
{
|
||||||
|
public function nextForSession(ChatSession $session): int
|
||||||
|
{
|
||||||
|
$key = $this->redisKey($session->session_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$current = Redis::get($key);
|
||||||
|
|
||||||
|
if ($current === null) {
|
||||||
|
$seed = $this->seedFromDatabase($session);
|
||||||
|
Redis::setnx($key, $seed);
|
||||||
|
} elseif ((int) $current < $session->last_seq) {
|
||||||
|
Redis::set($key, (string) $session->last_seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) Redis::incr($key);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $session->last_seq + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncToAtLeast(string $sessionId, int $seq): void
|
||||||
|
{
|
||||||
|
$key = $this->redisKey($sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$current = Redis::get($key);
|
||||||
|
if ($current === null || (int) $current < $seq) {
|
||||||
|
Redis::set($key, (string) $seq);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedFromDatabase(ChatSession $session): int
|
||||||
|
{
|
||||||
|
$maxPersistedSeq = (int) (Message::query()
|
||||||
|
->where('session_id', $session->session_id)
|
||||||
|
->max('seq') ?? 0);
|
||||||
|
|
||||||
|
return max($session->last_seq, $maxPersistedSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redisKey(string $sessionId): string
|
||||||
|
{
|
||||||
|
return "chat_session:{$sessionId}:seq";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
176
app/Services/OutputSink.php
Normal file
176
app/Services/OutputSink.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\MessageSequence;
|
||||||
|
use App\Services\Tool\ToolCall;
|
||||||
|
use App\Services\Tool\ToolResult;
|
||||||
|
|
||||||
|
class OutputSink
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatService $chatService,
|
||||||
|
private readonly MessageSequence $messageSequence,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = [], ?string $dedupeKey = null): Message
|
||||||
|
{
|
||||||
|
$dedupeKey = $dedupeKey ?? "run:{$runId}:agent:message";
|
||||||
|
|
||||||
|
return $this->chatService->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'role' => Message::ROLE_AGENT,
|
||||||
|
'type' => 'agent.message',
|
||||||
|
'content' => $content,
|
||||||
|
'payload' => array_merge($meta, ['run_id' => $runId]),
|
||||||
|
'dedupe_key' => $dedupeKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加 Agent 流式文本增量(仅用于 SSE 推送,不落库)。
|
||||||
|
*
|
||||||
|
* message.delta 消息只用于实时流式推送,不需要持久化到数据库。
|
||||||
|
* 最终的完整回复会通过 appendAgentMessage() 落库。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public function appendAgentDelta(string $sessionId, string $runId, string $content, int $deltaIndex, array $meta = []): void
|
||||||
|
{
|
||||||
|
$session = $this->chatService->getSession($sessionId);
|
||||||
|
|
||||||
|
// 1. 创建临时 Message 对象(不保存到数据库)
|
||||||
|
$message = new Message([
|
||||||
|
'message_id' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
'session_id' => $session->session_id,
|
||||||
|
'role' => Message::ROLE_AGENT,
|
||||||
|
'type' => 'message.delta',
|
||||||
|
'content' => $content,
|
||||||
|
'payload' => array_merge($meta, [
|
||||||
|
'run_id' => $runId,
|
||||||
|
'delta_index' => $deltaIndex,
|
||||||
|
]),
|
||||||
|
'dedupe_key' => "run:{$runId}:agent:delta:{$deltaIndex}",
|
||||||
|
'seq' => $this->messageSequence->nextForSession($session),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 仅发布 Redis 事件,供 SSE 实时推送
|
||||||
|
$this->publishDeltaMessage($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布 delta 消息到 Redis(仅用于 SSE 推送)。
|
||||||
|
*
|
||||||
|
* 此方法不保存消息到数据库,只发布事件供 SSE 客户端接收。
|
||||||
|
*/
|
||||||
|
private function publishDeltaMessage(Message $message): void
|
||||||
|
{
|
||||||
|
$root = \Illuminate\Support\Facades\Redis::getFacadeRoot();
|
||||||
|
$isMocked = $root instanceof \Mockery\MockInterface;
|
||||||
|
|
||||||
|
// 如果 Redis 不可用(测试环境),直接返回
|
||||||
|
if (!class_exists(\Redis::class) && !$isMocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = "session:{$message->session_id}:messages";
|
||||||
|
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\Redis::publish(
|
||||||
|
$channel,
|
||||||
|
json_encode($message->toArray(), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE)
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Redis publish failed for delta message', [
|
||||||
|
'session_id' => $message->session_id,
|
||||||
|
'run_id' => $message->payload['run_id'] ?? null,
|
||||||
|
'delta_index' => $message->payload['delta_index'] ?? null,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = [], ?bool &$wasDeduped = null): Message
|
||||||
|
{
|
||||||
|
$dedupeKey = $meta['dedupe_key'] ?? null;
|
||||||
|
unset($meta['dedupe_key']);
|
||||||
|
|
||||||
|
return $this->chatService->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'role' => Message::ROLE_SYSTEM,
|
||||||
|
'type' => 'run.status',
|
||||||
|
'content' => null,
|
||||||
|
'payload' => array_merge($meta, [
|
||||||
|
'run_id' => $runId,
|
||||||
|
'status' => $status,
|
||||||
|
]),
|
||||||
|
'dedupe_key' => $dedupeKey,
|
||||||
|
], $wasDeduped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = [], ?string $dedupeKey = null): Message
|
||||||
|
{
|
||||||
|
return $this->chatService->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'role' => Message::ROLE_SYSTEM,
|
||||||
|
'type' => 'error',
|
||||||
|
'content' => $code,
|
||||||
|
'payload' => array_merge($meta, [
|
||||||
|
'run_id' => $runId,
|
||||||
|
'message' => $message,
|
||||||
|
]),
|
||||||
|
'dedupe_key' => $dedupeKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appendToolCall(string $sessionId, ToolCall $toolCall): Message
|
||||||
|
{
|
||||||
|
return $this->chatService->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'role' => Message::ROLE_AGENT,
|
||||||
|
'type' => 'tool.call',
|
||||||
|
'content' => $toolCall->rawArguments ?: json_encode($toolCall->arguments, JSON_UNESCAPED_UNICODE),
|
||||||
|
'payload' => [
|
||||||
|
'run_id' => $toolCall->parentRunId,
|
||||||
|
'tool_run_id' => $toolCall->runId,
|
||||||
|
'tool_call_id' => $toolCall->toolCallId,
|
||||||
|
'name' => $toolCall->name,
|
||||||
|
'arguments' => $toolCall->arguments,
|
||||||
|
],
|
||||||
|
'dedupe_key' => "run:{$toolCall->parentRunId}:tool_call:{$toolCall->toolCallId}",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appendToolResult(string $sessionId, ToolResult $result): Message
|
||||||
|
{
|
||||||
|
return $this->chatService->appendMessage([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'role' => Message::ROLE_TOOL,
|
||||||
|
'type' => 'tool.result',
|
||||||
|
'content' => $result->output,
|
||||||
|
'payload' => [
|
||||||
|
'run_id' => $result->runId,
|
||||||
|
'parent_run_id' => $result->parentRunId,
|
||||||
|
'tool_call_id' => $result->toolCallId,
|
||||||
|
'name' => $result->name,
|
||||||
|
'status' => $result->status,
|
||||||
|
'error' => $result->error,
|
||||||
|
'truncated' => $result->truncated,
|
||||||
|
],
|
||||||
|
'dedupe_key' => "run:{$result->runId}:tool_result",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Services/RunDispatcher.php
Normal file
76
app/Services/RunDispatcher.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Jobs\AgentRunJob;
|
||||||
|
use App\Models\ChatSession;
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class RunDispatcher
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatService $chatService,
|
||||||
|
private readonly OutputSink $outputSink,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public function dispatchForPrompt(string $sessionId, string $triggerMessageId): string
|
||||||
|
{
|
||||||
|
$triggerMessage = $this->chatService->getMessage($sessionId, $triggerMessageId);
|
||||||
|
if (! $triggerMessage) {
|
||||||
|
throw (new ModelNotFoundException())->setModel(Message::class, [$triggerMessageId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$shouldDispatch = false;
|
||||||
|
|
||||||
|
$runId = DB::transaction(function () use ($sessionId, $triggerMessageId, &$shouldDispatch) {
|
||||||
|
ChatSession::query()
|
||||||
|
->whereKey($sessionId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$latestStatus = Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('type', 'run.status')
|
||||||
|
->orderByDesc('seq')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestStatus && ($latestStatus->payload['status'] ?? null) === 'RUNNING' && ($latestStatus->payload['run_id'] ?? null)) {
|
||||||
|
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $latestStatus->payload['run_id']]);
|
||||||
|
return $latestStatus->payload['run_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateRunId = (string) Str::uuid();
|
||||||
|
$wasDeduped = null;
|
||||||
|
|
||||||
|
$statusMessage = $this->outputSink->appendRunStatus($sessionId, $candidateRunId, 'RUNNING', [
|
||||||
|
'trigger_message_id' => $triggerMessageId,
|
||||||
|
'dedupe_key' => 'run:trigger:'.$triggerMessageId,
|
||||||
|
], $wasDeduped);
|
||||||
|
|
||||||
|
$finalRunId = $statusMessage->payload['run_id'] ?? $candidateRunId;
|
||||||
|
|
||||||
|
if ($wasDeduped) {
|
||||||
|
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $finalRunId]);
|
||||||
|
return $finalRunId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shouldDispatch = true;
|
||||||
|
|
||||||
|
return $finalRunId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($shouldDispatch) {
|
||||||
|
logger('dispatching run', ['sessionId' => $sessionId, 'runId' => $runId]);
|
||||||
|
dispatch(new AgentRunJob($sessionId, $runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $runId;
|
||||||
|
}
|
||||||
|
}
|
||||||
876
app/Services/RunLoop.php
Normal file
876
app/Services/RunLoop.php
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentProviderInterface;
|
||||||
|
use App\Services\Agent\AgentContext;
|
||||||
|
use App\Services\Agent\DummyAgentProvider;
|
||||||
|
use App\Services\Agent\ProviderEventType;
|
||||||
|
use App\Services\Agent\ProviderException;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Tool\ToolCall;
|
||||||
|
use App\Services\Tool\ToolRunDispatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Run 主循环:
|
||||||
|
* - 构建上下文,消费 Provider 事件流(Streaming)
|
||||||
|
* - 处理取消、错误、增量输出、终态写回
|
||||||
|
*/
|
||||||
|
class RunLoop
|
||||||
|
{
|
||||||
|
private const TERMINAL_STATUSES = ['DONE', 'FAILED', 'CANCELED'];
|
||||||
|
|
||||||
|
private readonly int $maxToolCalls;
|
||||||
|
private readonly int $toolWaitTimeoutMs;
|
||||||
|
private readonly int $toolPollIntervalMs;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContextBuilder $contextBuilder,
|
||||||
|
private readonly AgentProviderInterface $provider,
|
||||||
|
private readonly OutputSink $outputSink,
|
||||||
|
private readonly CancelChecker $cancelChecker,
|
||||||
|
private readonly ToolRunDispatcher $toolRunDispatcher,
|
||||||
|
) {
|
||||||
|
$this->maxToolCalls = (int) config('agent.tools.max_calls_per_run', 10);
|
||||||
|
$this->toolWaitTimeoutMs = (int) config('agent.tools.wait_timeout_ms', 15000);
|
||||||
|
$this->toolPollIntervalMs = (int) config('agent.tools.wait_poll_interval_ms', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行单次 Agent Run(按 run_id 幂等)。
|
||||||
|
*
|
||||||
|
* 主流程:
|
||||||
|
* 1. 检查 run 是否已终止
|
||||||
|
* 2. 进入主循环,持续调用 Provider 直到完成或失败
|
||||||
|
* 3. 每轮迭代可能触发工具调用,工具完成后继续下一轮
|
||||||
|
* 4. 没有工具调用时,写入最终回复并标记 DONE
|
||||||
|
*/
|
||||||
|
public function run(string $sessionId, string $runId): void
|
||||||
|
{
|
||||||
|
// 1. 幂等性检查:避免重复执行已完成的 run
|
||||||
|
if ($this->isRunTerminal($sessionId, $runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerName = $this->resolveProviderName();
|
||||||
|
$toolCallCount = 0;
|
||||||
|
|
||||||
|
// 2. 主循环:持续调用 Provider 直到完成或失败
|
||||||
|
while (true) {
|
||||||
|
// 2.1 检查用户是否取消
|
||||||
|
if ($this->checkAndHandleCancel($sessionId, $runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 执行一轮 Provider 调用
|
||||||
|
$iterationResult = $this->executeProviderIteration(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$providerName,
|
||||||
|
$toolCallCount
|
||||||
|
);
|
||||||
|
|
||||||
|
logger('agent provider iteration', [$iterationResult]);
|
||||||
|
|
||||||
|
// 2.3 处理失败或取消
|
||||||
|
if ($iterationResult['should_exit']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.4 如果有工具调用,处理工具执行流程
|
||||||
|
if ($iterationResult['has_tool_calls']) {
|
||||||
|
$shouldExit = $this->handleToolCalls(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$providerName,
|
||||||
|
$iterationResult,
|
||||||
|
$toolCallCount
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($shouldExit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新工具调用计数,继续下一轮 Provider 调用
|
||||||
|
$toolCallCount = $iterationResult['updated_tool_count'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 没有工具调用,完成 run
|
||||||
|
$this->completeRun(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$providerName,
|
||||||
|
$iterationResult['stream_state'],
|
||||||
|
$iterationResult['latency_ms']
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并处理取消状态。
|
||||||
|
*
|
||||||
|
* @return bool 是否已处理取消(true 表示已取消并写入状态)
|
||||||
|
*/
|
||||||
|
private function checkAndHandleCancel(string $sessionId, string $runId): bool
|
||||||
|
{
|
||||||
|
if ($this->isCanceled($sessionId, $runId)) {
|
||||||
|
$this->appendCanceled($sessionId, $runId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一轮 Provider 调用迭代。
|
||||||
|
*
|
||||||
|
* 包括:
|
||||||
|
* - 构建上下文
|
||||||
|
* - 准备 Provider 选项(工具调用限制、取消回调等)
|
||||||
|
* - 调用 Provider 流式接口
|
||||||
|
* - 记录日志
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* stream_state: array,
|
||||||
|
* has_tool_calls: bool,
|
||||||
|
* updated_tool_count: int,
|
||||||
|
* should_exit: bool,
|
||||||
|
* latency_ms: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function executeProviderIteration(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $providerName,
|
||||||
|
int $currentToolCallCount
|
||||||
|
): array {
|
||||||
|
// 1. 构建上下文和 Provider 选项
|
||||||
|
$context = $this->contextBuilder->build($sessionId, $runId);
|
||||||
|
$providerOptions = $this->buildProviderOptions($sessionId, $runId, $currentToolCallCount);
|
||||||
|
|
||||||
|
// 2. 记录调用日志
|
||||||
|
$this->logProviderRequest($sessionId, $runId, $providerName, $context, $providerOptions, $currentToolCallCount);
|
||||||
|
|
||||||
|
// 3. 调用 Provider 并消费事件流
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$streamState = $this->consumeProviderStream(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$context,
|
||||||
|
$providerName,
|
||||||
|
$startedAt,
|
||||||
|
$providerOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
$latencyMs = $this->latencyMs($startedAt);
|
||||||
|
|
||||||
|
// 4. 检查流式调用是否失败或取消
|
||||||
|
if ($streamState['canceled'] || $streamState['failed']) {
|
||||||
|
return [
|
||||||
|
'stream_state' => $streamState,
|
||||||
|
'has_tool_calls' => false,
|
||||||
|
'updated_tool_count' => $currentToolCallCount,
|
||||||
|
'should_exit' => true,
|
||||||
|
'latency_ms' => $latencyMs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查是否有工具调用
|
||||||
|
$hasToolCalls = !empty($streamState['tool_calls']);
|
||||||
|
$updatedToolCount = $currentToolCallCount + count($streamState['tool_calls']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stream_state' => $streamState,
|
||||||
|
'has_tool_calls' => $hasToolCalls,
|
||||||
|
'updated_tool_count' => $updatedToolCount,
|
||||||
|
'should_exit' => false,
|
||||||
|
'latency_ms' => $latencyMs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 Provider 调用选项。
|
||||||
|
*
|
||||||
|
* 包括:
|
||||||
|
* - 取消检查回调
|
||||||
|
* - 工具调用限制控制
|
||||||
|
*/
|
||||||
|
private function buildProviderOptions(string $sessionId, string $runId, int $toolCallCount): array
|
||||||
|
{
|
||||||
|
$options = [
|
||||||
|
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 达到工具调用上限后,禁用工具列表,避免再次触发 TOOL_CALL_LIMIT 错误
|
||||||
|
if ($toolCallCount >= $this->maxToolCalls) {
|
||||||
|
$options['disable_tools'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Provider 请求日志。
|
||||||
|
*/
|
||||||
|
private function logProviderRequest(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $providerName,
|
||||||
|
AgentContext $context,
|
||||||
|
array $providerOptions,
|
||||||
|
int $iteration
|
||||||
|
): void {
|
||||||
|
// 日志选项(移除不可序列化的回调)
|
||||||
|
$logOptions = $providerOptions;
|
||||||
|
unset($logOptions['should_stop']);
|
||||||
|
|
||||||
|
logger('agent provider context', [
|
||||||
|
'sessionId' => $sessionId,
|
||||||
|
'runId' => $runId,
|
||||||
|
'provider' => $providerName,
|
||||||
|
'context' => $context,
|
||||||
|
'provider_options' => $logOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger('agent provider request', [
|
||||||
|
'sessionId' => $sessionId,
|
||||||
|
'runId' => $runId,
|
||||||
|
'provider' => $providerName,
|
||||||
|
'iteration' => $iteration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理工具调用流程。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 检查工具调用数量是否超限
|
||||||
|
* 2. 分发工具子 Run
|
||||||
|
* 3. 等待工具执行结果
|
||||||
|
*
|
||||||
|
* @return bool 是否应该退出主循环(超限、失败或取消时返回 true)
|
||||||
|
*/
|
||||||
|
private function handleToolCalls(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $providerName,
|
||||||
|
array $iterationResult,
|
||||||
|
int $originalToolCallCount
|
||||||
|
): bool {
|
||||||
|
$streamState = $iterationResult['stream_state'];
|
||||||
|
$latencyMs = $iterationResult['latency_ms'];
|
||||||
|
$updatedToolCount = $iterationResult['updated_tool_count'];
|
||||||
|
logger('agent tool calls', [$streamState, $latencyMs, $updatedToolCount]);
|
||||||
|
// 1. 检查工具调用数量是否超限
|
||||||
|
if ($updatedToolCount > $this->maxToolCalls) {
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
'TOOL_CALL_LIMIT',
|
||||||
|
'Tool call limit reached for this run',
|
||||||
|
$providerName,
|
||||||
|
$latencyMs,
|
||||||
|
[],
|
||||||
|
'TOOL_CALL_LIMIT'
|
||||||
|
);
|
||||||
|
|
||||||
|
return true; // 退出主循环
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 分发工具子 Run
|
||||||
|
$toolCalls = $this->dispatchToolRuns($sessionId, $runId, $streamState['tool_calls']);
|
||||||
|
|
||||||
|
// 3. 等待所有工具执行完成
|
||||||
|
$waitState = $this->awaitToolResults($sessionId, $runId, $toolCalls, $providerName);
|
||||||
|
|
||||||
|
// 4. 检查等待过程中是否失败或取消
|
||||||
|
if ($waitState['failed'] || $waitState['canceled']) {
|
||||||
|
return true; // 退出主循环
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具结果已写回上下文,继续下一轮 Agent 调用
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成 Run 并写入最终状态。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 验证流式响应的有效性
|
||||||
|
* 2. 写入最终 agent.message
|
||||||
|
* 3. 再次检查取消状态
|
||||||
|
* 4. 写入 run.status = DONE
|
||||||
|
*/
|
||||||
|
private function completeRun(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $providerName,
|
||||||
|
array $streamState,
|
||||||
|
int $latencyMs
|
||||||
|
): void {
|
||||||
|
// 1. 记录响应日志
|
||||||
|
logger('agent provider response', [
|
||||||
|
'sessionId' => $sessionId,
|
||||||
|
'runId' => $runId,
|
||||||
|
'provider' => $providerName,
|
||||||
|
'latency_ms' => $latencyMs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 再次检查取消状态(在写入最终消息前)
|
||||||
|
if ($this->checkAndHandleCancel($sessionId, $runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证流式响应的有效性
|
||||||
|
if (!$this->validateStreamResponse($sessionId, $runId, $providerName, $streamState, $latencyMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 写入最终 agent.message
|
||||||
|
$this->outputSink->appendAgentMessage($sessionId, $runId, $streamState['reply'], [
|
||||||
|
'provider' => $providerName,
|
||||||
|
'done_reason' => $streamState['done_reason'],
|
||||||
|
], "run:{$runId}:agent:message");
|
||||||
|
|
||||||
|
// 5. 最后一次检查取消状态(在写入 DONE 前)
|
||||||
|
if ($this->checkAndHandleCancel($sessionId, $runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 写入 run.status = DONE
|
||||||
|
$this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
|
||||||
|
'dedupe_key' => "run:{$runId}:status:DONE",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证流式响应的有效性。
|
||||||
|
*
|
||||||
|
* 检查:
|
||||||
|
* - 是否收到任何事件(避免空流)
|
||||||
|
* - 流是否正常结束(有 done_reason)
|
||||||
|
*
|
||||||
|
* @return bool 是否有效(true 表示有效,false 表示无效并已写入错误)
|
||||||
|
*/
|
||||||
|
private function validateStreamResponse(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $providerName,
|
||||||
|
array $streamState,
|
||||||
|
int $latencyMs
|
||||||
|
): bool {
|
||||||
|
// 1. 检查是否收到任何事件
|
||||||
|
if (!$streamState['received_event']) {
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
'EMPTY_STREAM',
|
||||||
|
'Agent provider returned no events',
|
||||||
|
$providerName,
|
||||||
|
$latencyMs,
|
||||||
|
[],
|
||||||
|
'EMPTY_STREAM'
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查流是否正常结束
|
||||||
|
if ($streamState['done_reason'] === null) {
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
'STREAM_INCOMPLETE',
|
||||||
|
'Agent provider stream ended unexpectedly',
|
||||||
|
$providerName,
|
||||||
|
$latencyMs,
|
||||||
|
[],
|
||||||
|
'STREAM_INCOMPLETE'
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定 run 是否已到终态,避免重复执行。
|
||||||
|
*/
|
||||||
|
private function isRunTerminal(string $sessionId, string $runId): bool
|
||||||
|
{
|
||||||
|
$latestStatus = Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('type', 'run.status')
|
||||||
|
->whereRaw("payload->>'run_id' = ?", [$runId])
|
||||||
|
->orderByDesc('seq')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$status = $latestStatus ? ($latestStatus->payload['status'] ?? null) : null;
|
||||||
|
|
||||||
|
return in_array($status, self::TERMINAL_STATUSES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消时写入终态 CANCELED(幂等)。
|
||||||
|
*/
|
||||||
|
private function appendCanceled(string $sessionId, string $runId): void
|
||||||
|
{
|
||||||
|
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
|
||||||
|
'dedupe_key' => "run:{$runId}:status:CANCELED",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消费 Provider Streaming 事件流:
|
||||||
|
* - message.delta:落增量并累计最终回复
|
||||||
|
* - done:记录结束理由
|
||||||
|
* - error/异常:写入 error + FAILED
|
||||||
|
* - cancel:即时中断并写 CANCELED
|
||||||
|
* - tool.delta/tool.call:收集工具调用信息,后续驱动子 Run
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* reply: string,
|
||||||
|
* done_reason: ?string,
|
||||||
|
* received_event: bool,
|
||||||
|
* failed: bool,
|
||||||
|
* canceled: bool,
|
||||||
|
* tool_calls: array<int, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function consumeProviderStream(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
AgentContext $context,
|
||||||
|
string $providerName,
|
||||||
|
float $startedAt,
|
||||||
|
array $providerOptions = []
|
||||||
|
): array {
|
||||||
|
$reply = '';
|
||||||
|
$deltaIndex = 0;
|
||||||
|
$doneReason = null;
|
||||||
|
$receivedEvent = false;
|
||||||
|
$toolCallBuffer = [];
|
||||||
|
$toolCallOrder = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$providerOptions = array_merge([
|
||||||
|
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
|
||||||
|
], $providerOptions);
|
||||||
|
|
||||||
|
foreach ($this->provider->stream($context, $providerOptions) as $event) {
|
||||||
|
$receivedEvent = true;
|
||||||
|
|
||||||
|
if ($this->isCanceled($sessionId, $runId)) {
|
||||||
|
$this->appendCanceled($sessionId, $runId);
|
||||||
|
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
|
||||||
|
|
||||||
|
return $this->streamState($reply, $doneReason, $receivedEvent, false, true, $toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本增量:持续写 message.delta 并拼接最终回复
|
||||||
|
if ($event->type === ProviderEventType::MessageDelta) {
|
||||||
|
$text = (string) ($event->payload['text'] ?? '');
|
||||||
|
if ($text !== '') {
|
||||||
|
$reply .= $text;
|
||||||
|
$deltaIndex++;
|
||||||
|
$this->outputSink->appendAgentDelta($sessionId, $runId, $text, $deltaIndex, [
|
||||||
|
'provider' => $providerName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event->type === ProviderEventType::ToolDelta || $event->type === ProviderEventType::ToolCall) {
|
||||||
|
$toolCalls = $event->payload['tool_calls'] ?? [];
|
||||||
|
if (is_array($toolCalls)) {
|
||||||
|
$this->accumulateToolCalls($toolCallBuffer, $toolCallOrder, $toolCalls);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束
|
||||||
|
if ($event->type === ProviderEventType::Done) {
|
||||||
|
$doneReason = $event->payload['reason'] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider 内部错误事件
|
||||||
|
if ($event->type === ProviderEventType::Error) {
|
||||||
|
$latencyMs = $this->latencyMs($startedAt);
|
||||||
|
$code = (string) ($event->payload['code'] ?? 'PROVIDER_ERROR');
|
||||||
|
$message = (string) ($event->payload['message'] ?? 'Agent provider error');
|
||||||
|
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$code,
|
||||||
|
$message,
|
||||||
|
$providerName,
|
||||||
|
$latencyMs,
|
||||||
|
[
|
||||||
|
'retryable' => $event->payload['retryable'] ?? null,
|
||||||
|
'http_status' => $event->payload['http_status'] ?? null,
|
||||||
|
'raw_message' => $event->payload['raw_message'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
|
||||||
|
|
||||||
|
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ProviderException $exception) {
|
||||||
|
$latencyMs = $this->latencyMs($startedAt);
|
||||||
|
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$runId,
|
||||||
|
$exception->errorCode,
|
||||||
|
$exception->getMessage(),
|
||||||
|
$providerName,
|
||||||
|
$latencyMs,
|
||||||
|
[
|
||||||
|
'retryable' => $exception->retryable,
|
||||||
|
'http_status' => $exception->httpStatus,
|
||||||
|
'raw_message' => $exception->rawMessage,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
|
||||||
|
|
||||||
|
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
|
||||||
|
|
||||||
|
return $this->streamState($reply, $doneReason, $receivedEvent, false, false, $toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一落库 Provider 错误与 FAILED 终态。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function appendProviderFailure(
|
||||||
|
string $sessionId,
|
||||||
|
string $runId,
|
||||||
|
string $code,
|
||||||
|
string $message,
|
||||||
|
string $providerName,
|
||||||
|
int $latencyMs,
|
||||||
|
array $meta = [],
|
||||||
|
?string $statusError = null
|
||||||
|
): void {
|
||||||
|
$this->outputSink->appendError($sessionId, $runId, $code, $message, array_merge($meta, [
|
||||||
|
'provider' => $providerName,
|
||||||
|
'latency_ms' => $latencyMs,
|
||||||
|
]), "run:{$runId}:error:provider");
|
||||||
|
|
||||||
|
$this->outputSink->appendRunStatus($sessionId, $runId, 'FAILED', [
|
||||||
|
'error' => $statusError ?? $message,
|
||||||
|
'dedupe_key' => "run:{$runId}:status:FAILED",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装流式状态返回,便于上层判断。
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* reply: string,
|
||||||
|
* done_reason: ?string,
|
||||||
|
* received_event: bool,
|
||||||
|
* failed: bool,
|
||||||
|
* canceled: bool,
|
||||||
|
* tool_calls: array<int, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function streamState(
|
||||||
|
string $reply,
|
||||||
|
?string $doneReason,
|
||||||
|
bool $receivedEvent,
|
||||||
|
bool $failed,
|
||||||
|
bool $canceled,
|
||||||
|
array $toolCalls
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'reply' => $reply,
|
||||||
|
'done_reason' => $doneReason,
|
||||||
|
'received_event' => $receivedEvent,
|
||||||
|
'failed' => $failed,
|
||||||
|
'canceled' => $canceled,
|
||||||
|
'tool_calls' => $toolCalls,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具增量收集:同一个 tool call 通过 index 关联,多次分片返回时拼接参数与名称。
|
||||||
|
*
|
||||||
|
* OpenAI 流式 API 中,tool call 的第一个 chunk 包含 id、name、index,
|
||||||
|
* 后续 chunks 只包含 arguments 增量和 index(无 id)。
|
||||||
|
* 因此必须使用 index 作为 buffer 的 key 来正确累积。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $buffer 以 index 为 key 的缓冲区
|
||||||
|
* @param array<int, int> $order 记录 index 出现顺序
|
||||||
|
* @param array<int, array<string, mixed>> $toolCalls
|
||||||
|
*/
|
||||||
|
private function accumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void
|
||||||
|
{
|
||||||
|
foreach ($toolCalls as $call) {
|
||||||
|
// 使用 index 作为主键(OpenAI 流式 API 的标准做法)
|
||||||
|
$index = is_int($call['index'] ?? null) ? (int) $call['index'] : 0;
|
||||||
|
|
||||||
|
if (! isset($buffer[$index])) {
|
||||||
|
$buffer[$index] = [
|
||||||
|
'id' => $call['id'] ?? null,
|
||||||
|
'name' => $call['name'] ?? null,
|
||||||
|
'arguments' => '',
|
||||||
|
'index' => $index,
|
||||||
|
];
|
||||||
|
$order[$index] = count($order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 id(第一个 chunk 才有)
|
||||||
|
if (isset($call['id']) && is_string($call['id']) && $call['id'] !== '') {
|
||||||
|
$buffer[$index]['id'] = $call['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 name(第一个 chunk 才有)
|
||||||
|
if (isset($call['name']) && is_string($call['name']) && $call['name'] !== '') {
|
||||||
|
$buffer[$index]['name'] = $call['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 累积 arguments
|
||||||
|
$arguments = $call['arguments'] ?? '';
|
||||||
|
if (is_string($arguments) && $arguments !== '') {
|
||||||
|
$buffer[$index]['arguments'] .= $arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将缓存的 tool.call 增量整理为最终列表(按 index 排序)。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $buffer 以 index 为 key 的缓冲区
|
||||||
|
* @param array<int, int> $order 记录 index 出现顺序
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function finalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
|
||||||
|
{
|
||||||
|
if (empty($buffer)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 index 排序
|
||||||
|
ksort($buffer);
|
||||||
|
|
||||||
|
return array_values(array_map(function (array $call) use ($doneReason) {
|
||||||
|
return [
|
||||||
|
'id' => (string) ($call['id'] ?? ''),
|
||||||
|
'name' => (string) ($call['name'] ?? ''),
|
||||||
|
'arguments' => (string) ($call['arguments'] ?? ''),
|
||||||
|
'finish_reason' => $doneReason,
|
||||||
|
];
|
||||||
|
}, $buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Tool 调用落库并触发子 Run。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $toolCalls
|
||||||
|
* @return array<int, ToolCall>
|
||||||
|
*/
|
||||||
|
private function dispatchToolRuns(string $sessionId, string $parentRunId, array $toolCalls): array
|
||||||
|
{
|
||||||
|
$dispatched = [];
|
||||||
|
|
||||||
|
foreach ($toolCalls as $call) {
|
||||||
|
$toolCallId = (string) ($call['id'] ?? '');
|
||||||
|
$name = (string) ($call['name'] ?? '');
|
||||||
|
$rawArguments = (string) ($call['arguments'] ?? '');
|
||||||
|
|
||||||
|
if ($toolCallId === '' || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments = $this->decodeToolArguments($rawArguments);
|
||||||
|
$toolRunId = $this->generateToolRunId($parentRunId, $toolCallId);
|
||||||
|
|
||||||
|
$toolCall = new ToolCall($toolRunId, $parentRunId, $toolCallId, $name, $arguments, $rawArguments);
|
||||||
|
|
||||||
|
$this->outputSink->appendToolCall($sessionId, $toolCall);
|
||||||
|
$this->toolRunDispatcher->dispatch($sessionId, $toolCall);
|
||||||
|
|
||||||
|
$dispatched[] = $toolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待工具子 Run 写入 tool.result,超时/失败会直接结束父 Run。
|
||||||
|
*
|
||||||
|
* @param array<int, ToolCall> $toolCalls
|
||||||
|
* @return array{failed: bool, canceled: bool}
|
||||||
|
*/
|
||||||
|
private function awaitToolResults(string $sessionId, string $parentRunId, array $toolCalls, string $providerName): array
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
$expectedIds = array_map(fn (ToolCall $call) => $call->toolCallId, $toolCalls);
|
||||||
|
$expectedRuns = array_map(fn (ToolCall $call) => $call->runId, $toolCalls);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if ($this->isCanceled($sessionId, $parentRunId)) {
|
||||||
|
$this->appendCanceled($sessionId, $parentRunId);
|
||||||
|
|
||||||
|
return ['failed' => false, 'canceled' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->findToolResults($sessionId, $parentRunId);
|
||||||
|
$statuses = $this->findToolRunStatuses($sessionId, $parentRunId);
|
||||||
|
|
||||||
|
foreach ($expectedRuns as $runId) {
|
||||||
|
$status = $statuses[$runId] ?? null;
|
||||||
|
if ($status === 'FAILED') {
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$parentRunId,
|
||||||
|
'TOOL_RUN_FAILED',
|
||||||
|
"Tool run {$runId} failed",
|
||||||
|
$providerName,
|
||||||
|
$this->latencyMs($start),
|
||||||
|
[],
|
||||||
|
'TOOL_RUN_FAILED'
|
||||||
|
);
|
||||||
|
|
||||||
|
return ['failed' => true, 'canceled' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'CANCELED') {
|
||||||
|
$this->appendCanceled($sessionId, $parentRunId);
|
||||||
|
|
||||||
|
return ['failed' => false, 'canceled' => true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$readyIds = array_intersect($expectedIds, array_keys($results));
|
||||||
|
|
||||||
|
if (count($readyIds) === count($expectedIds)) {
|
||||||
|
return ['failed' => false, 'canceled' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->latencyMs($start) >= $this->toolWaitTimeoutMs) {
|
||||||
|
$this->appendProviderFailure(
|
||||||
|
$sessionId,
|
||||||
|
$parentRunId,
|
||||||
|
'TOOL_RESULT_TIMEOUT',
|
||||||
|
'Tool result wait timeout',
|
||||||
|
$providerName,
|
||||||
|
$this->latencyMs($start),
|
||||||
|
[],
|
||||||
|
'TOOL_RESULT_TIMEOUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
return ['failed' => true, 'canceled' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep($this->toolPollIntervalMs * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \App\Models\Message>
|
||||||
|
*/
|
||||||
|
private function findToolResults(string $sessionId, string $parentRunId): array
|
||||||
|
{
|
||||||
|
$messages = Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('type', 'tool.result')
|
||||||
|
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
|
||||||
|
->orderBy('seq')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byToolCall = [];
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$toolCallId = $message->payload['tool_call_id'] ?? null;
|
||||||
|
if (is_string($toolCallId) && $toolCallId !== '') {
|
||||||
|
$byToolCall[$toolCallId] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byToolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function findToolRunStatuses(string $sessionId, string $parentRunId): array
|
||||||
|
{
|
||||||
|
$messages = Message::query()
|
||||||
|
->where('session_id', $sessionId)
|
||||||
|
->where('type', 'run.status')
|
||||||
|
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
|
||||||
|
->orderBy('seq')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$runId = $message->payload['run_id'] ?? null;
|
||||||
|
$status = $message->payload['status'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($runId) && is_string($status)) {
|
||||||
|
$statuses[$runId] = $status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToolRunId(string $parentRunId, string $toolCallId): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', $parentRunId.'|'.$toolCallId), 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function decodeToolArguments(string $rawArguments): array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($rawArguments, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算耗时(毫秒)。
|
||||||
|
*/
|
||||||
|
private function latencyMs(float $startedAt): int
|
||||||
|
{
|
||||||
|
return (int) ((microtime(true) - $startedAt) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一取消判断,便于 mock。
|
||||||
|
*/
|
||||||
|
private function isCanceled(string $sessionId, string $runId): bool
|
||||||
|
{
|
||||||
|
return $this->cancelChecker->isCanceled($sessionId, $runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 Provider 名称(Dummy 使用短名)。
|
||||||
|
*/
|
||||||
|
private function resolveProviderName(): string
|
||||||
|
{
|
||||||
|
if ($this->provider instanceof DummyAgentProvider) {
|
||||||
|
return 'dummy';
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_replace("\0", '', get_class($this->provider));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Services/Tool/Tool.php
Normal file
23
app/Services/Tool/Tool.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
interface Tool
|
||||||
|
{
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
public function description(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI function 参数 JSON Schema。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function parameters(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @return array<string, mixed>|string
|
||||||
|
*/
|
||||||
|
public function execute(array $arguments): array|string;
|
||||||
|
}
|
||||||
52
app/Services/Tool/ToolCall.php
Normal file
52
app/Services/Tool/ToolCall.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 调用请求 DTO,携带父子 Run 关联信息与参数。
|
||||||
|
*/
|
||||||
|
final class ToolCall
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $runId,
|
||||||
|
public string $parentRunId,
|
||||||
|
public string $toolCallId,
|
||||||
|
public string $name,
|
||||||
|
public array $arguments,
|
||||||
|
public string $rawArguments = '',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $payload): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
(string) $payload['run_id'],
|
||||||
|
(string) $payload['parent_run_id'],
|
||||||
|
(string) $payload['tool_call_id'],
|
||||||
|
(string) $payload['name'],
|
||||||
|
(array) ($payload['arguments'] ?? []),
|
||||||
|
(string) ($payload['raw_arguments'] ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'run_id' => $this->runId,
|
||||||
|
'parent_run_id' => $this->parentRunId,
|
||||||
|
'tool_call_id' => $this->toolCallId,
|
||||||
|
'name' => $this->name,
|
||||||
|
'arguments' => $this->arguments,
|
||||||
|
'raw_arguments' => $this->rawArguments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/Tool/ToolExecutor.php
Normal file
86
app/Services/Tool/ToolExecutor.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 执行调度器,负责超时/结果截断等基础防护。
|
||||||
|
*/
|
||||||
|
class ToolExecutor
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ToolRegistry $registry,
|
||||||
|
private ?int $timeoutSeconds = null,
|
||||||
|
private ?int $maxResultBytes = null,
|
||||||
|
) {
|
||||||
|
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.tools.timeout_seconds', 15);
|
||||||
|
$this->maxResultBytes = $this->maxResultBytes ?? (int) config('agent.tools.result_max_bytes', 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(ToolCall $call): ToolResult
|
||||||
|
{
|
||||||
|
$tool = $this->registry->get($call->name);
|
||||||
|
|
||||||
|
if (! $tool) {
|
||||||
|
return new ToolResult(
|
||||||
|
$call->runId,
|
||||||
|
$call->parentRunId,
|
||||||
|
$call->toolCallId,
|
||||||
|
$call->name,
|
||||||
|
'FAILED',
|
||||||
|
'',
|
||||||
|
'TOOL_NOT_FOUND'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $tool->execute($call->arguments);
|
||||||
|
$output = is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
return new ToolResult(
|
||||||
|
$call->runId,
|
||||||
|
$call->parentRunId,
|
||||||
|
$call->toolCallId,
|
||||||
|
$call->name,
|
||||||
|
'FAILED',
|
||||||
|
'',
|
||||||
|
Str::limit($exception->getMessage(), 200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = microtime(true) - $started;
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
if ($this->maxResultBytes > 0 && strlen($output) > $this->maxResultBytes) {
|
||||||
|
$output = substr($output, 0, $this->maxResultBytes);
|
||||||
|
$truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->timeoutSeconds > 0 && $duration > $this->timeoutSeconds) {
|
||||||
|
return new ToolResult(
|
||||||
|
$call->runId,
|
||||||
|
$call->parentRunId,
|
||||||
|
$call->toolCallId,
|
||||||
|
$call->name,
|
||||||
|
'TIMEOUT',
|
||||||
|
$output,
|
||||||
|
'TOOL_TIMEOUT',
|
||||||
|
$truncated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolResult(
|
||||||
|
$call->runId,
|
||||||
|
$call->parentRunId,
|
||||||
|
$call->toolCallId,
|
||||||
|
$call->name,
|
||||||
|
'SUCCESS',
|
||||||
|
$output,
|
||||||
|
null,
|
||||||
|
$truncated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Services/Tool/ToolRegistry.php
Normal file
70
app/Services/Tool/ToolRegistry.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
use App\Services\Tool\Tools\BashTool;
|
||||||
|
use App\Services\Tool\Tools\GetTimeTool;
|
||||||
|
use App\Services\Tool\Tools\LsTool;
|
||||||
|
use App\Services\Tool\Tools\FileReadTool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。
|
||||||
|
*/
|
||||||
|
class ToolRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, Tool>
|
||||||
|
*/
|
||||||
|
private array $tools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Tool>|null $tools
|
||||||
|
*/
|
||||||
|
public function __construct(?array $tools = null)
|
||||||
|
{
|
||||||
|
$tools = $tools ?? [
|
||||||
|
new GetTimeTool(),
|
||||||
|
new LsTool(),
|
||||||
|
new BashTool(),
|
||||||
|
new FileReadTool(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->tools = [];
|
||||||
|
|
||||||
|
foreach ($tools as $tool) {
|
||||||
|
$this->tools[$tool->name()] = $tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name): ?Tool
|
||||||
|
{
|
||||||
|
return $this->tools[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tool>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values($this->tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 OpenAI-compatible tools 描述。
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function openAiToolsSpec(): array
|
||||||
|
{
|
||||||
|
return array_map(function (Tool $tool) {
|
||||||
|
return [
|
||||||
|
'type' => 'function',
|
||||||
|
'function' => [
|
||||||
|
'name' => $tool->name(),
|
||||||
|
'description' => $tool->description(),
|
||||||
|
'parameters' => $tool->parameters(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}, $this->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Tool/ToolResult.php
Normal file
40
app/Services/Tool/ToolResult.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 执行结果 DTO,记录结果文本与状态。
|
||||||
|
*/
|
||||||
|
final class ToolResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $runId,
|
||||||
|
public string $parentRunId,
|
||||||
|
public string $toolCallId,
|
||||||
|
public string $name,
|
||||||
|
public string $status,
|
||||||
|
public string $output,
|
||||||
|
public ?string $error = null,
|
||||||
|
public bool $truncated = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 便于在测试/日志中以数组格式使用。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'run_id' => $this->runId,
|
||||||
|
'parent_run_id' => $this->parentRunId,
|
||||||
|
'tool_call_id' => $this->toolCallId,
|
||||||
|
'name' => $this->name,
|
||||||
|
'status' => $this->status,
|
||||||
|
'output' => $this->output,
|
||||||
|
'error' => $this->error,
|
||||||
|
'truncated' => $this->truncated,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Services/Tool/ToolRunDispatcher.php
Normal file
39
app/Services/Tool/ToolRunDispatcher.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool;
|
||||||
|
|
||||||
|
use App\Jobs\ToolRunJob;
|
||||||
|
use App\Services\OutputSink;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子 Run 调度:为单个 tool_call 创建 Run 并投递队列,幂等。
|
||||||
|
*/
|
||||||
|
class ToolRunDispatcher
|
||||||
|
{
|
||||||
|
public function __construct(private readonly OutputSink $outputSink)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $sessionId, ToolCall $toolCall): string
|
||||||
|
{
|
||||||
|
$shouldDispatch = false;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($sessionId, $toolCall, &$shouldDispatch): void {
|
||||||
|
$wasDeduped = null;
|
||||||
|
$this->outputSink->appendRunStatus($sessionId, $toolCall->runId, 'RUNNING', [
|
||||||
|
'parent_run_id' => $toolCall->parentRunId,
|
||||||
|
'tool_call_id' => $toolCall->toolCallId,
|
||||||
|
'dedupe_key' => "run:{$toolCall->runId}:status:RUNNING",
|
||||||
|
], $wasDeduped);
|
||||||
|
|
||||||
|
$shouldDispatch = ! $wasDeduped;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($shouldDispatch) {
|
||||||
|
ToolRunJob::dispatchSync($sessionId, $toolCall->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $toolCall->runId;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/Tool/Tools/BashTool.php
Normal file
59
app/Services/Tool/Tools/BashTool.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool\Tools;
|
||||||
|
|
||||||
|
use App\Services\Tool\Tool;
|
||||||
|
|
||||||
|
class BashTool implements Tool
|
||||||
|
{
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Execute bash commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function parameters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'directory' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'The directory to list',
|
||||||
|
'default' => '.',
|
||||||
|
],
|
||||||
|
'file' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'The file to read',
|
||||||
|
],
|
||||||
|
'command' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'The command to execute',
|
||||||
|
],
|
||||||
|
'user_confirmation_message' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => ' A message describing the purpose of this command, shown to the user for approval before execution ',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'required' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function execute(array $arguments): array|string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'output' => shell_exec($arguments['command']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
217
app/Services/Tool/Tools/FileReadTool.php
Normal file
217
app/Services/Tool/Tools/FileReadTool.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool\Tools;
|
||||||
|
|
||||||
|
use App\Services\Tool\Tool;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class FileReadTool implements Tool
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'file_read';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return '读取文件内容,支持指定行范围、编码和大文件分段读取。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function parameters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'path' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '要读取的文件路径(相对或绝对路径)。',
|
||||||
|
],
|
||||||
|
'start_line' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => '起始行号(从1开始),默认从第一行开始。',
|
||||||
|
'minimum' => 1,
|
||||||
|
'default' => 1,
|
||||||
|
],
|
||||||
|
'end_line' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => '结束行号(包含),默认读取到文件末尾。',
|
||||||
|
'minimum' => 1,
|
||||||
|
],
|
||||||
|
'max_size' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => '最大读取字节数(1-10MB),默认1MB,防止读取过大文件。',
|
||||||
|
'minimum' => 1,
|
||||||
|
'maximum' => 10485760,
|
||||||
|
'default' => 1048576,
|
||||||
|
],
|
||||||
|
'encoding' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '文件编码,默认UTF-8。',
|
||||||
|
'enum' => ['UTF-8', 'GBK', 'GB2312', 'ISO-8859-1'],
|
||||||
|
'default' => 'UTF-8',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'required' => ['path'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function execute(array $arguments): array
|
||||||
|
{
|
||||||
|
$path = $arguments['path'] ?? '';
|
||||||
|
|
||||||
|
// 验证路径
|
||||||
|
if (empty($path)) {
|
||||||
|
throw new InvalidArgumentException('文件路径不能为空。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:防止路径遍历攻击
|
||||||
|
$realPath = realpath($path);
|
||||||
|
if ($realPath === false) {
|
||||||
|
throw new InvalidArgumentException("文件不存在:{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($realPath)) {
|
||||||
|
throw new InvalidArgumentException("路径不是文件:{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($realPath)) {
|
||||||
|
throw new InvalidArgumentException("文件不可读:{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取参数
|
||||||
|
$startLine = max(1, (int)($arguments['start_line'] ?? 1));
|
||||||
|
$endLine = isset($arguments['end_line']) ? max(1, (int)$arguments['end_line']) : null;
|
||||||
|
$maxSize = min(10485760, max(1, (int)($arguments['max_size'] ?? 1048576)));
|
||||||
|
$encoding = $arguments['encoding'] ?? 'UTF-8';
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
$fileSize = filesize($realPath);
|
||||||
|
if ($fileSize === false) {
|
||||||
|
throw new InvalidArgumentException("无法获取文件大小:{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->readFileContent($realPath, $startLine, $endLine, $maxSize, $encoding, $fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取文件内容
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param int $startLine
|
||||||
|
* @param int|null $endLine
|
||||||
|
* @param int $maxSize
|
||||||
|
* @param string $encoding
|
||||||
|
* @param int $fileSize
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function readFileContent(
|
||||||
|
string $path,
|
||||||
|
int $startLine,
|
||||||
|
?int $endLine,
|
||||||
|
int $maxSize,
|
||||||
|
string $encoding,
|
||||||
|
int $fileSize
|
||||||
|
): array {
|
||||||
|
$result = [
|
||||||
|
'path' => $path,
|
||||||
|
'size' => $fileSize,
|
||||||
|
'encoding' => $encoding,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果文件为空
|
||||||
|
if ($fileSize === 0) {
|
||||||
|
$result['content'] = '';
|
||||||
|
$result['lines_read'] = 0;
|
||||||
|
$result['truncated'] = false;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new InvalidArgumentException("无法打开文件:{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->readLines($handle, $startLine, $endLine, $maxSize, $encoding, $result);
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按行读取文件
|
||||||
|
*
|
||||||
|
* @param resource $handle
|
||||||
|
* @param int $startLine
|
||||||
|
* @param int|null $endLine
|
||||||
|
* @param int $maxSize
|
||||||
|
* @param string $encoding
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function readLines(
|
||||||
|
$handle,
|
||||||
|
int $startLine,
|
||||||
|
?int $endLine,
|
||||||
|
int $maxSize,
|
||||||
|
string $encoding,
|
||||||
|
array $result
|
||||||
|
): array {
|
||||||
|
$lines = [];
|
||||||
|
$currentLine = 0;
|
||||||
|
$bytesRead = 0;
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
while (($line = fgets($handle)) !== false) {
|
||||||
|
$currentLine++;
|
||||||
|
|
||||||
|
// 跳过起始行之前的内容
|
||||||
|
if ($currentLine < $startLine) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超过结束行
|
||||||
|
if ($endLine !== null && $currentLine > $endLine) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查大小限制
|
||||||
|
$lineLength = strlen($line);
|
||||||
|
if ($bytesRead + $lineLength > $maxSize) {
|
||||||
|
$truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = $line;
|
||||||
|
$bytesRead += $lineLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = implode('', $lines);
|
||||||
|
|
||||||
|
// 编码转换
|
||||||
|
if ($encoding !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['content'] = $content;
|
||||||
|
$result['lines_read'] = count($lines);
|
||||||
|
$result['start_line'] = $startLine;
|
||||||
|
$result['end_line'] = $endLine ?? $currentLine;
|
||||||
|
$result['truncated'] = $truncated;
|
||||||
|
$result['bytes_read'] = $bytesRead;
|
||||||
|
|
||||||
|
if ($truncated) {
|
||||||
|
$result['warning'] = "内容已截断,已读取 {$bytesRead} 字节(限制:{$maxSize} 字节)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Services/Tool/Tools/GetTimeTool.php
Normal file
51
app/Services/Tool/Tools/GetTimeTool.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool\Tools;
|
||||||
|
|
||||||
|
use App\Services\Tool\Tool;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class GetTimeTool implements Tool
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'get_time';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return '返回服务器当前时间,可指定 PHP 日期格式。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function parameters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'format' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '可选的日期格式,默认为 RFC3339。',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'required' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function execute(array $arguments): array
|
||||||
|
{
|
||||||
|
$format = is_string($arguments['format'] ?? null) && $arguments['format'] !== ''
|
||||||
|
? $arguments['format']
|
||||||
|
: Carbon::RFC3339_EXTENDED;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'now' => Carbon::now()->format($format),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
256
app/Services/Tool/Tools/LsTool.php
Normal file
256
app/Services/Tool/Tools/LsTool.php
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Tool\Tools;
|
||||||
|
|
||||||
|
use App\Services\Tool\Tool;
|
||||||
|
use FilesystemIterator;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use SplFileInfo;
|
||||||
|
|
||||||
|
class LsTool implements Tool
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'ls';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return '列出指定目录下的文件与目录(支持过滤、排序与详情输出)。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function parameters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'directory' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '要列出的目录路径,默认为当前目录。',
|
||||||
|
'default' => '.',
|
||||||
|
],
|
||||||
|
'include_hidden' => [
|
||||||
|
'type' => 'boolean',
|
||||||
|
'description' => '是否包含以 . 开头的隐藏文件/目录(默认包含,兼容旧行为)。',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'filter' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '过滤类型:all/files/directories。',
|
||||||
|
'enum' => ['all', 'files', 'directories'],
|
||||||
|
'default' => 'all',
|
||||||
|
],
|
||||||
|
'match' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '可选的通配符匹配(fnmatch),例如:*.php。',
|
||||||
|
],
|
||||||
|
'sort' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '排序:name_asc/name_desc/mtime_asc/mtime_desc。',
|
||||||
|
'enum' => ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'],
|
||||||
|
'default' => 'name_asc',
|
||||||
|
],
|
||||||
|
'details' => [
|
||||||
|
'type' => 'boolean',
|
||||||
|
'description' => '是否返回条目详情(name/type/size/modified_at)。',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'limit' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => '最多返回条目数量(1-1000),默认 200。',
|
||||||
|
'minimum' => 1,
|
||||||
|
'maximum' => 1000,
|
||||||
|
'default' => 200,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'required' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function execute(array $arguments): array
|
||||||
|
{
|
||||||
|
$directory = $this->stringOrDefault($arguments, 'directory', '.');
|
||||||
|
|
||||||
|
if (! is_dir($directory) || ! is_readable($directory)) {
|
||||||
|
throw new InvalidArgumentException('目录不存在或不可读:'.$directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$includeHidden = $this->boolOrDefault($arguments, 'include_hidden', true);
|
||||||
|
$details = $this->boolOrDefault($arguments, 'details', false);
|
||||||
|
$filter = $this->enumOrDefault($arguments, 'filter', ['all', 'files', 'directories'], 'all');
|
||||||
|
$sort = $this->enumOrDefault($arguments, 'sort', ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'], 'name_asc');
|
||||||
|
$match = $this->nullableString($arguments, 'match');
|
||||||
|
$limit = $this->intOrDefault($arguments, 'limit', 200);
|
||||||
|
$limit = max(1, min(1000, $limit));
|
||||||
|
|
||||||
|
$rawEntries = $this->collectEntries($directory, $includeHidden, $filter, $match);
|
||||||
|
$rawEntries = $this->sortEntries($rawEntries, $sort);
|
||||||
|
$rawEntries = array_slice($rawEntries, 0, $limit);
|
||||||
|
|
||||||
|
$entries = $details
|
||||||
|
? array_map(fn (array $entry) => $entry, $rawEntries)
|
||||||
|
: array_map(fn (array $entry) => $entry['name'], $rawEntries);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'directory' => $directory,
|
||||||
|
'entries' => array_values($entries),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
|
||||||
|
*/
|
||||||
|
private function collectEntries(string $directory, bool $includeHidden, string $filter, ?string $match): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
throw new InvalidArgumentException('无法读取目录:'.$directory, 0, $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
if (! $fileInfo instanceof SplFileInfo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $fileInfo->getFilename();
|
||||||
|
|
||||||
|
if (! $includeHidden && str_starts_with($name, '.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filter === 'files' && ! $fileInfo->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filter === 'directories' && ! $fileInfo->isDir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($match) && $match !== '' && ! fnmatch($match, $name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'type' => $this->entryType($fileInfo),
|
||||||
|
'size' => $fileInfo->isFile() ? $fileInfo->getSize() : null,
|
||||||
|
'modified_at' => $fileInfo->getMTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, type: string, size: int|null, modified_at: int}> $entries
|
||||||
|
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
|
||||||
|
*/
|
||||||
|
private function sortEntries(array $entries, string $sort): array
|
||||||
|
{
|
||||||
|
usort($entries, function (array $left, array $right) use ($sort) {
|
||||||
|
if ($sort === 'mtime_asc' || $sort === 'mtime_desc') {
|
||||||
|
$compare = $left['modified_at'] <=> $right['modified_at'];
|
||||||
|
} else {
|
||||||
|
$compare = strnatcasecmp($left['name'], $right['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($compare === 0) {
|
||||||
|
$compare = strnatcasecmp($left['name'], $right['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($sort === 'name_desc' || $sort === 'mtime_desc') ? -$compare : $compare;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entryType(SplFileInfo $fileInfo): string
|
||||||
|
{
|
||||||
|
if ($fileInfo->isLink()) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fileInfo->isDir()) {
|
||||||
|
return 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fileInfo->isFile()) {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function stringOrDefault(array $arguments, string $key, string $default): string
|
||||||
|
{
|
||||||
|
$value = $arguments[$key] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($value) || trim($value) === '') {
|
||||||
|
throw new InvalidArgumentException($key.' 必须是非空字符串');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<int, string> $allowed
|
||||||
|
*/
|
||||||
|
private function enumOrDefault(array $arguments, string $key, array $allowed, string $default): string
|
||||||
|
{
|
||||||
|
$value = $arguments[$key] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($value, $allowed, true) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function boolOrDefault(array $arguments, string $key, bool $default): bool
|
||||||
|
{
|
||||||
|
$value = $arguments[$key] ?? null;
|
||||||
|
|
||||||
|
return is_bool($value) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function intOrDefault(array $arguments, string $key, int $default): int
|
||||||
|
{
|
||||||
|
$value = $arguments[$key] ?? null;
|
||||||
|
|
||||||
|
return is_int($value) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function nullableString(array $arguments, string $key): ?string
|
||||||
|
{
|
||||||
|
$value = $arguments[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backup.yaml
Executable file
74
backup.yaml
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
laravel.test:
|
||||||
|
build:
|
||||||
|
context: ./vendor/laravel/sail/runtimes/8.5
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
WWWGROUP: '${WWWGROUP}'
|
||||||
|
image: sail-8.5/app
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
ports:
|
||||||
|
- '${APP_PORT:-80}:80'
|
||||||
|
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||||
|
environment:
|
||||||
|
WWWUSER: '${WWWUSER}'
|
||||||
|
LARAVEL_SAIL: 1
|
||||||
|
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||||
|
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||||
|
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
depends_on:
|
||||||
|
- pgsql
|
||||||
|
- redis
|
||||||
|
pgsql:
|
||||||
|
image: 'postgres:18-alpine'
|
||||||
|
ports:
|
||||||
|
- '${FORWARD_DB_PORT:-5432}:5432'
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
POSTGRES_DB: '${DB_DATABASE}'
|
||||||
|
POSTGRES_USER: '${DB_USERNAME}'
|
||||||
|
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
volumes:
|
||||||
|
- 'sail-pgsql:/var/lib/postgresql'
|
||||||
|
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- pg_isready
|
||||||
|
- '-q'
|
||||||
|
- '-d'
|
||||||
|
- '${DB_DATABASE}'
|
||||||
|
- '-U'
|
||||||
|
- '${DB_USERNAME}'
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
redis:
|
||||||
|
image: 'redis:alpine'
|
||||||
|
ports:
|
||||||
|
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
|
volumes:
|
||||||
|
- 'sail-redis:/data'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
networks:
|
||||||
|
sail:
|
||||||
|
driver: bridge
|
||||||
|
volumes:
|
||||||
|
sail-pgsql:
|
||||||
|
driver: local
|
||||||
|
sail-redis:
|
||||||
|
driver: local
|
||||||
14
boost.json
Executable file
14
boost.json
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
"claude_code",
|
||||||
|
"codex",
|
||||||
|
"phpstorm"
|
||||||
|
],
|
||||||
|
"editors": [
|
||||||
|
"claude_code",
|
||||||
|
"codex",
|
||||||
|
"phpstorm"
|
||||||
|
],
|
||||||
|
"guidelines": [],
|
||||||
|
"sail": true
|
||||||
|
}
|
||||||
@@ -3,15 +3,26 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Middleware\HandleCors;
|
||||||
|
use PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate as JwtAuthenticate;
|
||||||
|
use PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken as JwtRefreshToken;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
->withProviders([
|
||||||
|
App\Providers\HorizonServiceProvider::class,
|
||||||
|
])
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->append(HandleCors::class);
|
||||||
|
$middleware->alias([
|
||||||
|
'auth.jwt' => JwtAuthenticate::class,
|
||||||
|
'auth.jwt.refresh' => JwtRefreshToken::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\HorizonServiceProvider::class,
|
||||||
|
App\Providers\TelescopeServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
7
composer.json
Normal file → Executable file
7
composer.json
Normal file → Executable file
@@ -8,11 +8,16 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/horizon": "^5.40",
|
||||||
"laravel/octane": "^2.13",
|
"laravel/octane": "^2.13",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/telescope": "^5.16",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"php-open-source-saver/jwt-auth": "^2.8",
|
||||||
|
"ext-redis": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/boost": "^1.8",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
|
|||||||
706
composer.lock
generated
Normal file → Executable file
706
composer.lock
generated
Normal file → Executable file
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "814e29ecc172cfe807c128b7df10ae19",
|
"content-hash": "effce82b9c1c86b0542543b2f1034edc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -1142,16 +1142,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v12.42.0",
|
"version": "v12.43.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "509b33095564c5165366d81bbaa0afaac28abe75"
|
"reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/9f875fad08f5d409b4c33293eca34f7af36e8ecf",
|
||||||
"reference": "509b33095564c5165366d81bbaa0afaac28abe75",
|
"reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1360,20 +1360,99 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"source": "https://github.com/laravel/framework"
|
||||||
},
|
},
|
||||||
"time": "2025-12-09T15:51:23+00:00"
|
"time": "2025-12-16T15:27:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/octane",
|
"name": "laravel/horizon",
|
||||||
"version": "v2.13.2",
|
"version": "v5.41.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/octane.git",
|
"url": "https://github.com/laravel/horizon.git",
|
||||||
"reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988"
|
"reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/octane/zipball/5b963d2da879f2cad3a84f22bafd3d8be7170988",
|
"url": "https://api.github.com/repos/laravel/horizon/zipball/eb6738246ab9d3450b705126b9794dfb0ea371b3",
|
||||||
"reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988",
|
"reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pcntl": "*",
|
||||||
|
"ext-posix": "*",
|
||||||
|
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
|
||||||
|
"nesbot/carbon": "^2.17|^3.0",
|
||||||
|
"php": "^8.0",
|
||||||
|
"ramsey/uuid": "^4.0",
|
||||||
|
"symfony/console": "^6.0|^7.0",
|
||||||
|
"symfony/error-handler": "^6.0|^7.0",
|
||||||
|
"symfony/polyfill-php83": "^1.28",
|
||||||
|
"symfony/process": "^6.0|^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.0",
|
||||||
|
"orchestra/testbench": "^7.55|^8.36|^9.15|^10.8",
|
||||||
|
"phpstan/phpstan": "^1.10|^2.0",
|
||||||
|
"predis/predis": "^1.1|^2.0|^3.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-redis": "Required to use the Redis PHP driver.",
|
||||||
|
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Horizon": "Laravel\\Horizon\\Horizon"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Horizon\\HorizonServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "6.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Horizon\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Dashboard and code-driven configuration for Laravel queues.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"queue"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/horizon/issues",
|
||||||
|
"source": "https://github.com/laravel/horizon/tree/v5.41.0"
|
||||||
|
},
|
||||||
|
"time": "2025-12-14T15:55:28+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/octane",
|
||||||
|
"version": "v2.13.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/octane.git",
|
||||||
|
"reference": "aae775360fceae422651042d73137fff092ba800"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/octane/zipball/aae775360fceae422651042d73137fff092ba800",
|
||||||
|
"reference": "aae775360fceae422651042d73137fff092ba800",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1450,7 +1529,7 @@
|
|||||||
"issues": "https://github.com/laravel/octane/issues",
|
"issues": "https://github.com/laravel/octane/issues",
|
||||||
"source": "https://github.com/laravel/octane"
|
"source": "https://github.com/laravel/octane"
|
||||||
},
|
},
|
||||||
"time": "2025-11-28T20:13:00+00:00"
|
"time": "2025-12-10T15:24:24+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/prompts",
|
"name": "laravel/prompts",
|
||||||
@@ -1572,6 +1651,74 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-21T20:52:36+00:00"
|
"time": "2025-11-21T20:52:36+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/telescope",
|
||||||
|
"version": "v5.16.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/telescope.git",
|
||||||
|
"reference": "a868e91a0912d6a44363636f7467a8578db83026"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/telescope/zipball/a868e91a0912d6a44363636f7467a8578db83026",
|
||||||
|
"reference": "a868e91a0912d6a44363636f7467a8578db83026",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0",
|
||||||
|
"symfony/console": "^5.3|^6.0|^7.0",
|
||||||
|
"symfony/var-dumper": "^5.0|^6.0|^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-gd": "*",
|
||||||
|
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||||
|
"laravel/octane": "^1.4|^2.0",
|
||||||
|
"orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Telescope\\TelescopeServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Telescope\\": "src/",
|
||||||
|
"Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mohamed Said",
|
||||||
|
"email": "mohamed@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An elegant debug assistant for the Laravel framework.",
|
||||||
|
"keywords": [
|
||||||
|
"debugging",
|
||||||
|
"laravel",
|
||||||
|
"monitoring"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/telescope/issues",
|
||||||
|
"source": "https://github.com/laravel/telescope/tree/v5.16.0"
|
||||||
|
},
|
||||||
|
"time": "2025-12-09T13:34:29+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/tinker",
|
"name": "laravel/tinker",
|
||||||
"version": "v2.10.2",
|
"version": "v2.10.2",
|
||||||
@@ -1638,6 +1785,79 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-20T16:29:12+00:00"
|
"time": "2025-11-20T16:29:12+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "lcobucci/jwt",
|
||||||
|
"version": "5.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/lcobucci/jwt.git",
|
||||||
|
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||||
|
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-sodium": "*",
|
||||||
|
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||||
|
"psr/clock": "^1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"infection/infection": "^0.29",
|
||||||
|
"lcobucci/clock": "^3.2",
|
||||||
|
"lcobucci/coding-standard": "^11.0",
|
||||||
|
"phpbench/phpbench": "^1.2",
|
||||||
|
"phpstan/extension-installer": "^1.2",
|
||||||
|
"phpstan/phpstan": "^1.10.7",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.1.3",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.3.10",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.5.0",
|
||||||
|
"phpunit/phpunit": "^11.1"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"lcobucci/clock": ">= 3.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Lcobucci\\JWT\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Luís Cobucci",
|
||||||
|
"email": "lcobucci@gmail.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
|
||||||
|
"keywords": [
|
||||||
|
"JWS",
|
||||||
|
"jwt"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/lcobucci/jwt/issues",
|
||||||
|
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/lcobucci",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/lcobucci",
|
||||||
|
"type": "patreon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-10-17T11:30:53+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
@@ -2300,6 +2520,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"time": "2025-03-24T10:02:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "namshi/jose",
|
||||||
|
"version": "7.2.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/namshi/jose.git",
|
||||||
|
"reference": "89a24d7eb3040e285dd5925fcad992378b82bcff"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff",
|
||||||
|
"reference": "89a24d7eb3040e285dd5925fcad992378b82bcff",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-date": "*",
|
||||||
|
"ext-hash": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pcre": "*",
|
||||||
|
"ext-spl": "*",
|
||||||
|
"php": ">=5.5",
|
||||||
|
"symfony/polyfill-php56": "^1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpseclib/phpseclib": "^2.0",
|
||||||
|
"phpunit/phpunit": "^4.5|^5.0",
|
||||||
|
"satooshi/php-coveralls": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-openssl": "Allows to use OpenSSL as crypto engine.",
|
||||||
|
"phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Namshi\\JOSE\\": "src/Namshi/JOSE/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alessandro Nadalin",
|
||||||
|
"email": "alessandro.nadalin@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alessandro Cinelli (cirpo)",
|
||||||
|
"email": "alessandro.cinelli@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "JSON Object Signing and Encryption library for PHP.",
|
||||||
|
"keywords": [
|
||||||
|
"JSON Web Signature",
|
||||||
|
"JSON Web Token",
|
||||||
|
"JWS",
|
||||||
|
"json",
|
||||||
|
"jwt",
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/namshi/jose/issues",
|
||||||
|
"source": "https://github.com/namshi/jose/tree/master"
|
||||||
|
},
|
||||||
|
"time": "2016-12-05T07:27:31+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
@@ -2704,6 +2991,102 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-11-20T02:34:59+00:00"
|
"time": "2025-11-20T02:34:59+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "php-open-source-saver/jwt-auth",
|
||||||
|
"version": "v2.8.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git",
|
||||||
|
"reference": "563f7dc025f48b9ecbacc271da509bbb4c6b3b23"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/563f7dc025f48b9ecbacc271da509bbb4c6b3b23",
|
||||||
|
"reference": "563f7dc025f48b9ecbacc271da509bbb4c6b3b23",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/auth": "^10|^11|^12",
|
||||||
|
"illuminate/contracts": "^10|^11|^12",
|
||||||
|
"illuminate/http": "^10|^11|^12",
|
||||||
|
"illuminate/support": "^10|^11|^12",
|
||||||
|
"lcobucci/jwt": "^5.4",
|
||||||
|
"namshi/jose": "^7.0",
|
||||||
|
"nesbot/carbon": "^2.0|^3.0",
|
||||||
|
"php": "^8.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3",
|
||||||
|
"illuminate/console": "^10|^11|^12",
|
||||||
|
"illuminate/routing": "^10|^11|^12",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^8|^9|^10",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpunit/phpunit": "^10.5|^11"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth",
|
||||||
|
"JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-develop": "2.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PHPOpenSourceSaver\\JWTAuth\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sean Tymon",
|
||||||
|
"email": "tymon148@gmail.com",
|
||||||
|
"homepage": "https://tymon.xyz",
|
||||||
|
"role": "Forked package creator | Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Eric Schricker",
|
||||||
|
"email": "eric.schricker@adiutabyte.de",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fabio William Conceição",
|
||||||
|
"email": "messhias@gmail.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Max Snow",
|
||||||
|
"email": "contact@maxsnow.me",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "JSON Web Token Authentication for Laravel and Lumen",
|
||||||
|
"homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth",
|
||||||
|
"keywords": [
|
||||||
|
"Authentication",
|
||||||
|
"JSON Web Token",
|
||||||
|
"auth",
|
||||||
|
"jwt",
|
||||||
|
"laravel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues",
|
||||||
|
"source": "https://github.com/PHP-Open-Source-Saver/jwt-auth"
|
||||||
|
},
|
||||||
|
"time": "2025-10-15T12:02:51+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
@@ -3193,16 +3576,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psy/psysh",
|
"name": "psy/psysh",
|
||||||
"version": "v0.12.16",
|
"version": "v0.12.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bobthecow/psysh.git",
|
"url": "https://github.com/bobthecow/psysh.git",
|
||||||
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67"
|
"reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
|
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/85fbbd9f3064e157fc21fe4362b2b5c19f2ea631",
|
||||||
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
|
"reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3266,9 +3649,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/bobthecow/psysh/issues",
|
"issues": "https://github.com/bobthecow/psysh/issues",
|
||||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.16"
|
"source": "https://github.com/bobthecow/psysh/tree/v0.12.17"
|
||||||
},
|
},
|
||||||
"time": "2025-12-07T03:39:01+00:00"
|
"time": "2025-12-15T04:55:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ralouphie/getallheaders",
|
"name": "ralouphie/getallheaders",
|
||||||
@@ -3392,20 +3775,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ramsey/uuid",
|
"name": "ramsey/uuid",
|
||||||
"version": "4.9.1",
|
"version": "4.9.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/ramsey/uuid.git",
|
"url": "https://github.com/ramsey/uuid.git",
|
||||||
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
|
"reference": "8429c78ca35a09f27565311b98101e2826affde0"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
|
"url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
|
||||||
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
|
"reference": "8429c78ca35a09f27565311b98101e2826affde0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
|
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
|
||||||
"php": "^8.0",
|
"php": "^8.0",
|
||||||
"ramsey/collection": "^1.2 || ^2.0"
|
"ramsey/collection": "^1.2 || ^2.0"
|
||||||
},
|
},
|
||||||
@@ -3464,9 +3847,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/ramsey/uuid/issues",
|
"issues": "https://github.com/ramsey/uuid/issues",
|
||||||
"source": "https://github.com/ramsey/uuid/tree/4.9.1"
|
"source": "https://github.com/ramsey/uuid/tree/4.9.2"
|
||||||
},
|
},
|
||||||
"time": "2025-09-04T20:59:21+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
@@ -4887,6 +5270,74 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-12-23T08:48:59+00:00"
|
"time": "2024-12-23T08:48:59+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-php56",
|
||||||
|
"version": "v1.20.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-php56.git",
|
||||||
|
"reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675",
|
||||||
|
"reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.1"
|
||||||
|
},
|
||||||
|
"type": "metapackage",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "1.20-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2020-10-23T14:02:19+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-php80",
|
"name": "symfony/polyfill-php80",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
@@ -6457,6 +6908,145 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"time": "2025-04-30T06:54:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/boost",
|
||||||
|
"version": "v1.8.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/boost.git",
|
||||||
|
"reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e",
|
||||||
|
"reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
|
||||||
|
"illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
|
||||||
|
"illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
|
||||||
|
"illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
|
||||||
|
"laravel/mcp": "^0.4.1",
|
||||||
|
"laravel/prompts": "0.1.25|^0.3.6",
|
||||||
|
"laravel/roster": "^0.2.9",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.20.0",
|
||||||
|
"mockery/mockery": "^1.6.12",
|
||||||
|
"orchestra/testbench": "^8.36.0|^9.15.0|^10.6",
|
||||||
|
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
|
||||||
|
"phpstan/phpstan": "^2.1.27",
|
||||||
|
"rector/rector": "^2.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Boost\\BoostServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Boost\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
|
||||||
|
"homepage": "https://github.com/laravel/boost",
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"dev",
|
||||||
|
"laravel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/boost/issues",
|
||||||
|
"source": "https://github.com/laravel/boost"
|
||||||
|
},
|
||||||
|
"time": "2025-12-08T21:54:49+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/mcp",
|
||||||
|
"version": "v0.4.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/mcp.git",
|
||||||
|
"reference": "1c7878be3931a19768f791ddf141af29f43fb4ef"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef",
|
||||||
|
"reference": "1c7878be3931a19768f791ddf141af29f43fb4ef",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"illuminate/console": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/container": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/http": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/json-schema": "^12.41.1",
|
||||||
|
"illuminate/routing": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/support": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"illuminate/validation": "^10.49.0|^11.45.3|^12.41.1",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.20",
|
||||||
|
"orchestra/testbench": "^8.36|^9.15|^10.8",
|
||||||
|
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.0",
|
||||||
|
"phpstan/phpstan": "^2.1.27",
|
||||||
|
"rector/rector": "^2.2.4"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Mcp\\": "src/",
|
||||||
|
"Laravel\\Mcp\\Server\\": "src/Server/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Rapidly build MCP servers for your Laravel applications.",
|
||||||
|
"homepage": "https://github.com/laravel/mcp",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"mcp"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/mcp/issues",
|
||||||
|
"source": "https://github.com/laravel/mcp"
|
||||||
|
},
|
||||||
|
"time": "2025-12-07T15:49:15+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/pail",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.4",
|
"version": "v1.2.4",
|
||||||
@@ -6603,6 +7193,67 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-25T21:15:52+00:00"
|
"time": "2025-11-25T21:15:52+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/roster",
|
||||||
|
"version": "v0.2.9",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/roster.git",
|
||||||
|
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||||
|
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/console": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/contracts": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/routing": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.1|^8.2",
|
||||||
|
"symfony/yaml": "^6.4|^7.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.14",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
|
||||||
|
"pestphp/pest": "^2.0|^3.0",
|
||||||
|
"phpstan/phpstan": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Roster\\RosterServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Roster\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "Detect packages & approaches in use within a Laravel project",
|
||||||
|
"homepage": "https://github.com/laravel/roster",
|
||||||
|
"keywords": [
|
||||||
|
"dev",
|
||||||
|
"laravel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/roster/issues",
|
||||||
|
"source": "https://github.com/laravel/roster"
|
||||||
|
},
|
||||||
|
"time": "2025-10-20T09:56:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/sail",
|
"name": "laravel/sail",
|
||||||
"version": "v1.51.0",
|
"version": "v1.51.0",
|
||||||
@@ -8641,7 +9292,8 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2",
|
||||||
|
"ext-redis": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
39
config/agent.php
Normal file
39
config/agent.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => [
|
||||||
|
'endpoint' => env('AGENT_PROVIDER_ENDPOINT', ''),
|
||||||
|
'timeout_seconds' => env('AGENT_PROVIDER_TIMEOUT', 30),
|
||||||
|
'connect_timeout_seconds' => env('AGENT_PROVIDER_CONNECT_TIMEOUT', 5),
|
||||||
|
'retry_times' => env('AGENT_PROVIDER_RETRY_TIMES', 1),
|
||||||
|
'retry_backoff_ms' => env('AGENT_PROVIDER_RETRY_BACKOFF_MS', 500),
|
||||||
|
],
|
||||||
|
'openai' => [
|
||||||
|
'base_url' => env('AGENT_OPENAI_BASE_URL', 'https://api.openai.com/v1'),
|
||||||
|
'api_key' => env('AGENT_OPENAI_API_KEY', ''),
|
||||||
|
'organization' => env('AGENT_OPENAI_ORGANIZATION', ''),
|
||||||
|
'project' => env('AGENT_OPENAI_PROJECT', ''),
|
||||||
|
'model' => env('AGENT_OPENAI_MODEL', 'gpt-4o-mini'),
|
||||||
|
'temperature' => env('AGENT_OPENAI_TEMPERATURE', 0.7),
|
||||||
|
'top_p' => env('AGENT_OPENAI_TOP_P', 1.0),
|
||||||
|
'include_usage' => env('AGENT_OPENAI_INCLUDE_USAGE', false),
|
||||||
|
],
|
||||||
|
'job' => [
|
||||||
|
'tries' => env('AGENT_RUN_JOB_TRIES', 1),
|
||||||
|
'backoff_seconds' => env('AGENT_RUN_JOB_BACKOFF', 3),
|
||||||
|
'timeout_seconds' => env('AGENT_RUN_JOB_TIMEOUT', 360),
|
||||||
|
],
|
||||||
|
'tools' => [
|
||||||
|
'max_calls_per_run' => env('AGENT_TOOL_MAX_CALLS_PER_RUN', 1),
|
||||||
|
'wait_timeout_ms' => env('AGENT_TOOL_WAIT_TIMEOUT_MS', 15000),
|
||||||
|
'wait_poll_interval_ms' => env('AGENT_TOOL_WAIT_POLL_MS', 200),
|
||||||
|
'timeout_seconds' => env('AGENT_TOOL_TIMEOUT_SECONDS', 15),
|
||||||
|
'result_max_bytes' => env('AGENT_TOOL_RESULT_MAX_BYTES', 4096),
|
||||||
|
'tool_choice' => env('AGENT_TOOL_CHOICE', 'auto'),
|
||||||
|
'job' => [
|
||||||
|
'tries' => env('AGENT_TOOL_JOB_TRIES', 1),
|
||||||
|
'backoff_seconds' => env('AGENT_TOOL_JOB_BACKOFF', 3),
|
||||||
|
'timeout_seconds' => env('AGENT_TOOL_JOB_TIMEOUT', 120),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -14,7 +14,7 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
'guard' => env('AUTH_GUARD', 'api'),
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -40,6 +40,11 @@ return [
|
|||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
|
'api' => [
|
||||||
|
'driver' => 'jwt',
|
||||||
|
'provider' => 'users',
|
||||||
|
'hash' => false,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
34
config/cors.php
Normal file
34
config/cors.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| Learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'up', 'octane'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins' => array_filter(array_map('trim', explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost:5173')))),
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => false,
|
||||||
|
|
||||||
|
];
|
||||||
186
config/horizon.php
Normal file
186
config/horizon.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the subdomain where Horizon will be accessible from. If this
|
||||||
|
| setting is null, Horizon will reside under the same domain as the
|
||||||
|
| application. Otherwise, this value will serve as the subdomain.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('HORIZON_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the URI path where Horizon will be accessible from. Feel free
|
||||||
|
| to change this path to anything you like. Note that the URI will not
|
||||||
|
| affect the paths of its internal API that aren't exposed to users.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('HORIZON_PATH', 'horizon'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the name of the Redis connection where Horizon will store the
|
||||||
|
| meta information required for it to function. It includes the list
|
||||||
|
| of supervisors, failed and completed jobs, job metrics, and more.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use' => 'default',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This prefix will be used when storing all Horizon data in Redis. You
|
||||||
|
| may modify the prefix when you are running multiple installations
|
||||||
|
| of Horizon on the same server so that they don't have problems.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('HORIZON_PREFIX', 'horizon:{single}'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Route Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These middleware will be assigned to every Horizon route, giving you the
|
||||||
|
| chance to add your own middleware to this list or change any of the
|
||||||
|
| existing middleware. Or, you can simply stick with this default list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Wait Time Thresholds
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to configure when Horizon will notify you the
|
||||||
|
| queue is waiting too long to process jobs. Each connection / queue
|
||||||
|
| combination may have its own, unique threshold (in seconds).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'waits' => [
|
||||||
|
'redis:default' => env('HORIZON_WAIT_TIME', 60),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Trimming Times
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||||
|
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||||
|
| for one hour while all failed jobs are stored for an entire week.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'trim' => [
|
||||||
|
'recent' => 60,
|
||||||
|
'pending' => 60,
|
||||||
|
'completed' => 60,
|
||||||
|
'recent_failed' => 10080,
|
||||||
|
'failed' => 10080,
|
||||||
|
'monitored' => 1440,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Silenced Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Silent jobs are not displayed in the Horizon UI. This allows you to
|
||||||
|
| fully avoid showing them or incrementing the failed jobs count
|
||||||
|
| or displaying them on the monitoring charts. They're silenced.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'silenced' => [
|
||||||
|
// App\Jobs\QuietJob::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fast Termination
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When this option is enabled, Horizon's "terminate" command will not
|
||||||
|
| wait on all of the workers to terminate unless the --wait option
|
||||||
|
| is provided. Fast termination can shorten deployment delay by
|
||||||
|
| allowing a new instance of Horizon to start immediately.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'fast_termination' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Memory Limit (MB)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value describes the maximum amount of memory the Horizon worker
|
||||||
|
| may consume before it is terminated and restarted. You should set
|
||||||
|
| this value according to the resources available to your server.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'memory_limit' => env('HORIZON_MEMORY_LIMIT', 128),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Worker Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the queue worker settings used by your application
|
||||||
|
| in all environments. These supervisors and settings handle all your
|
||||||
|
| queued jobs and will be provisioned by Horizon during deployment.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'environments' => [
|
||||||
|
'production' => [
|
||||||
|
'supervisor-default' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['default'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'maxProcesses' => 10,
|
||||||
|
'maxTime' => 60,
|
||||||
|
'maxJobs' => 1000,
|
||||||
|
'memory' => 256,
|
||||||
|
'tries' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'supervisor-default' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['default'],
|
||||||
|
'balance' => 'simple',
|
||||||
|
'maxProcesses' => env('HORIZON_PROCESSES', 3),
|
||||||
|
'maxTime' => 60,
|
||||||
|
'maxJobs' => 500,
|
||||||
|
'memory' => 256,
|
||||||
|
'tries' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
321
config/jwt.php
Normal file
321
config/jwt.php
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| JWT Authentication Secret
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Don't forget to set this in your .env file, as it will be used to sign
|
||||||
|
| your tokens. A helper command is provided for this:
|
||||||
|
| `php artisan jwt:secret`
|
||||||
|
|
|
||||||
|
| Note: This will be used for Symmetric algorithms only (HMAC),
|
||||||
|
| since RSA and ECDSA use a private/public key combo (See below).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secret' => env('JWT_SECRET'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| JWT Authentication Keys
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The algorithm you are using, will determine whether your tokens are
|
||||||
|
| signed with a random string (defined in `JWT_SECRET`) or using the
|
||||||
|
| following public & private keys.
|
||||||
|
|
|
||||||
|
| Symmetric Algorithms:
|
||||||
|
| HS256, HS384 & HS512 will use `JWT_SECRET`.
|
||||||
|
|
|
||||||
|
| Asymmetric Algorithms:
|
||||||
|
| RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'keys' => [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Public Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A path or resource to your public key.
|
||||||
|
|
|
||||||
|
| E.g. 'file://path/to/public/key'
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'public' => env('JWT_PUBLIC_KEY'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Private Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A path or resource to your private key.
|
||||||
|
|
|
||||||
|
| E.g. 'file://path/to/private/key'
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'private' => env('JWT_PRIVATE_KEY'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Passphrase
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The passphrase for your private key. Can be null if none set.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passphrase' => env('JWT_PASSPHRASE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| JWT time to live
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the length of time (in minutes) that the token will be valid for.
|
||||||
|
| Defaults to 1 hour.
|
||||||
|
|
|
||||||
|
| You can also set this to null, to yield a never expiring token.
|
||||||
|
| Some people may want this behaviour for e.g. a mobile app.
|
||||||
|
| This is not particularly recommended, so make sure you have appropriate
|
||||||
|
| systems in place to revoke the token if necessary.
|
||||||
|
| Notice: If you set this to null you should remove 'exp' element from 'required_claims' list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ttl' => (int) env('JWT_TTL', 60),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Refresh time to live
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the length of time (in minutes) that the token can be refreshed within.
|
||||||
|
| This defines the refresh window, during which the user can refresh their token
|
||||||
|
| before re-authentication is required.
|
||||||
|
|
|
||||||
|
| By default, a refresh will NOT issue a new "iat" (issued at) timestamp. If changed
|
||||||
|
| to true, each refresh will issue a new "iat" timestamp, extending the refresh
|
||||||
|
| period from the most recent refresh. This results in a rolling refresh
|
||||||
|
|
|
||||||
|
| To retain a fluid refresh window from the last refresh action (i.e., the behavior between
|
||||||
|
| version 2.5.0 and 2.8.2), set "refresh_iat" to true. With this setting, the refresh
|
||||||
|
| window will renew with each subsequent refresh.
|
||||||
|
|
|
||||||
|
| The refresh ttl defaults to 2 weeks.
|
||||||
|
|
|
||||||
|
| You can also set this to null, to yield an infinite refresh time.
|
||||||
|
| Some may want this instead of never expiring tokens for e.g. a mobile app.
|
||||||
|
| This is not particularly recommended, so make sure you have appropriate
|
||||||
|
| systems in place to revoke the token if necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'refresh_iat' => env('JWT_REFRESH_IAT', false),
|
||||||
|
'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20160),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| JWT hashing algorithm
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the hashing algorithm that will be used to sign the token.
|
||||||
|
|
|
||||||
|
| See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL
|
||||||
|
| for possible values.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'algo' => env('JWT_ALGO', 'HS256'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Required Claims
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the required claims that must exist in any token.
|
||||||
|
| A TokenInvalidException will be thrown if any of these claims are not
|
||||||
|
| present in the payload.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'required_claims' => [
|
||||||
|
'iss',
|
||||||
|
'iat',
|
||||||
|
'exp',
|
||||||
|
'nbf',
|
||||||
|
'sub',
|
||||||
|
'jti',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Persistent Claims
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the claim keys to be persisted when refreshing a token.
|
||||||
|
| `sub` and `iat` will automatically be persisted, in
|
||||||
|
| addition to the these claims.
|
||||||
|
|
|
||||||
|
| Note: If a claim does not exist then it will be ignored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'persistent_claims' => [
|
||||||
|
// 'foo',
|
||||||
|
// 'bar',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Lock Subject
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This will determine whether a `prv` claim is automatically added to
|
||||||
|
| the token. The purpose of this is to ensure that if you have multiple
|
||||||
|
| authentication models e.g. `App\User` & `App\OtherPerson`, then we
|
||||||
|
| should prevent one authentication request from impersonating another,
|
||||||
|
| if 2 tokens happen to have the same id across the 2 different models.
|
||||||
|
|
|
||||||
|
| Under specific circumstances, you may want to disable this behaviour
|
||||||
|
| e.g. if you only have one authentication model, then you would save
|
||||||
|
| a little on token size.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lock_subject' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Leeway
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This property gives the jwt timestamp claims some "leeway".
|
||||||
|
| Meaning that if you have any unavoidable slight clock skew on
|
||||||
|
| any of your servers then this will afford you some level of cushioning.
|
||||||
|
|
|
||||||
|
| This applies to the claims `iat`, `nbf` and `exp`.
|
||||||
|
|
|
||||||
|
| Specify in seconds - only if you know you need it.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'leeway' => (int) env('JWT_LEEWAY', 0),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Blacklist Enabled
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| In order to invalidate tokens, you must have the blacklist enabled.
|
||||||
|
| If you do not want or need this functionality, then set this to false.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| -------------------------------------------------------------------------
|
||||||
|
| Blacklist Grace Period
|
||||||
|
| -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When multiple concurrent requests are made with the same JWT,
|
||||||
|
| it is possible that some of them fail, due to token regeneration
|
||||||
|
| on every request.
|
||||||
|
|
|
||||||
|
| Set grace period in seconds to prevent parallel request failure.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Show blacklisted token option
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify if you want to show black listed token exception on the laravel logs.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'show_black_list_exception' => env('JWT_SHOW_BLACKLIST_EXCEPTION', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cookies encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default Laravel encrypt cookies for security reason.
|
||||||
|
| If you decide to not decrypt cookies, you will have to configure Laravel
|
||||||
|
| to not encrypt your cookie token by adding its name into the $except
|
||||||
|
| array available in the middleware "EncryptCookies" provided by Laravel.
|
||||||
|
| see https://laravel.com/docs/master/responses#cookies-and-encryption
|
||||||
|
| for details.
|
||||||
|
|
|
||||||
|
| Set it to true if you want to decrypt cookies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'decrypt_cookies' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cookie key name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the cookie key name that you would like to use for the cookie token.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie_key_name' => 'token',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the various providers used throughout the package.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| JWT Provider
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the provider that is used to create and decode the tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'jwt' => PHPOpenSourceSaver\JWTAuth\Providers\JWT\Lcobucci::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Provider
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the provider that is used to authenticate users.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => PHPOpenSourceSaver\JWTAuth\Providers\Auth\Illuminate::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Storage Provider
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify the provider that is used to store tokens in the blacklist.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'storage' => PHPOpenSourceSaver\JWTAuth\Providers\Storage\Illuminate::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -18,7 +18,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'driver' => env('SESSION_DRIVER', 'database'),
|
'driver' => env('SESSION_DRIVER', 'array'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
212
config/telescope.php
Normal file
212
config/telescope.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Telescope\Http\Middleware\Authorize;
|
||||||
|
use Laravel\Telescope\Watchers;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Master Switch
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option may be used to disable all Telescope watchers regardless
|
||||||
|
| of their individual configuration, which simply provides a single
|
||||||
|
| and convenient way to enable or disable Telescope data storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enabled' => env('TELESCOPE_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the subdomain where Telescope will be accessible from. If the
|
||||||
|
| setting is null, Telescope will reside under the same domain as the
|
||||||
|
| application. Otherwise, this value will be used as the subdomain.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('TELESCOPE_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the URI path where Telescope will be accessible from. Feel free
|
||||||
|
| to change this path to anything you like. Note that the URI will not
|
||||||
|
| affect the paths of its internal API that aren't exposed to users.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('TELESCOPE_PATH', 'telescope'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Storage Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration options determines the storage driver that will
|
||||||
|
| be used to store Telescope's data. In addition, you may set any
|
||||||
|
| custom options as needed by the particular driver you choose.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('TELESCOPE_DRIVER', 'database'),
|
||||||
|
|
||||||
|
'storage' => [
|
||||||
|
'database' => [
|
||||||
|
'connection' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
'chunk' => 1000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Queue
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration options determines the queue connection and queue
|
||||||
|
| which will be used to process ProcessPendingUpdate jobs. This can
|
||||||
|
| be changed if you would prefer to use a non-default connection.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'queue' => [
|
||||||
|
'connection' => env('TELESCOPE_QUEUE_CONNECTION'),
|
||||||
|
'queue' => env('TELESCOPE_QUEUE'),
|
||||||
|
'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Route Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These middleware will be assigned to every Telescope route, giving you
|
||||||
|
| the chance to add your own middleware to this list or change any of
|
||||||
|
| the existing middleware. Or, you can simply stick with this list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'web',
|
||||||
|
Authorize::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Allowed / Ignored Paths & Commands
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following array lists the URI paths and Artisan commands that will
|
||||||
|
| not be watched by Telescope. In addition to this list, some Laravel
|
||||||
|
| commands, like migrations and queue commands, are always ignored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'only_paths' => [
|
||||||
|
// 'api/*'
|
||||||
|
],
|
||||||
|
|
||||||
|
'ignore_paths' => [
|
||||||
|
'livewire*',
|
||||||
|
'nova-api*',
|
||||||
|
'pulse*',
|
||||||
|
'_boost*',
|
||||||
|
'telescope/*'
|
||||||
|
],
|
||||||
|
|
||||||
|
'ignore_commands' => [
|
||||||
|
//
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Telescope Watchers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following array lists the "watchers" that will be registered with
|
||||||
|
| Telescope. The watchers gather the application's profile data when
|
||||||
|
| a request or task is executed. Feel free to customize this list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'watchers' => [
|
||||||
|
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\CacheWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
|
||||||
|
'hidden' => [],
|
||||||
|
'ignore' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\ClientRequestWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
|
||||||
|
'ignore_hosts' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\CommandWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
|
||||||
|
'ignore' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\DumpWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
|
||||||
|
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\EventWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
|
||||||
|
'ignore' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\GateWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
|
||||||
|
'ignore_abilities' => [],
|
||||||
|
'ignore_packages' => true,
|
||||||
|
'ignore_paths' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\LogWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
|
||||||
|
'level' => 'error',
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\ModelWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
|
||||||
|
'events' => ['eloquent.*'],
|
||||||
|
'hydrations' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\QueryWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
|
||||||
|
'ignore_packages' => true,
|
||||||
|
'ignore_paths' => [],
|
||||||
|
'slow' => 100,
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
|
||||||
|
|
||||||
|
Watchers\RequestWatcher::class => [
|
||||||
|
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
|
||||||
|
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
|
||||||
|
'ignore_http_methods' => [],
|
||||||
|
'ignore_status_codes' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
|
||||||
|
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -29,6 +29,7 @@ class UserFactory extends Factory
|
|||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
'remember_token' => Str::random(10),
|
'remember_token' => Str::random(10),
|
||||||
|
'is_active' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ return new class extends Migration
|
|||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|||||||
43
database/migrations/2025_02_14_000003_create_chat_tables.php
Normal file
43
database/migrations/2025_02_14_000003_create_chat_tables.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('chat_sessions', function (Blueprint $table) {
|
||||||
|
$table->uuid('session_id')->primary();
|
||||||
|
$table->string('session_name', 255)->nullable();
|
||||||
|
$table->string('status', 16)->default('OPEN');
|
||||||
|
$table->unsignedBigInteger('last_seq')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('messages', function (Blueprint $table) {
|
||||||
|
$table->uuid('message_id')->primary();
|
||||||
|
$table->uuid('session_id');
|
||||||
|
$table->string('role', 32);
|
||||||
|
$table->string('type', 64);
|
||||||
|
$table->text('content')->nullable();
|
||||||
|
$table->jsonb('payload')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->unsignedBigInteger('seq');
|
||||||
|
$table->uuid('reply_to')->nullable();
|
||||||
|
$table->string('dedupe_key', 128)->nullable();
|
||||||
|
|
||||||
|
$table->foreign('session_id')->references('session_id')->on('chat_sessions')->onDelete('cascade');
|
||||||
|
$table->unique(['session_id', 'seq']);
|
||||||
|
$table->unique(['session_id', 'dedupe_key']);
|
||||||
|
$table->index(['session_id', 'seq']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('messages');
|
||||||
|
Schema::dropIfExists('chat_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('chat_sessions', function (Blueprint $table) {
|
||||||
|
$table->uuid('last_message_id')->nullable()->after('last_seq');
|
||||||
|
$table->index('updated_at');
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('last_message_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('chat_sessions', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['updated_at']);
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['last_message_id']);
|
||||||
|
$table->dropColumn('last_message_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the migration connection name.
|
||||||
|
*/
|
||||||
|
public function getConnection(): ?string
|
||||||
|
{
|
||||||
|
return config('telescope.storage.database.connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$schema = Schema::connection($this->getConnection());
|
||||||
|
|
||||||
|
$schema->create('telescope_entries', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('sequence');
|
||||||
|
$table->uuid('uuid');
|
||||||
|
$table->uuid('batch_id');
|
||||||
|
$table->string('family_hash')->nullable();
|
||||||
|
$table->boolean('should_display_on_index')->default(true);
|
||||||
|
$table->string('type', 20);
|
||||||
|
$table->longText('content');
|
||||||
|
$table->dateTime('created_at')->nullable();
|
||||||
|
|
||||||
|
$table->unique('uuid');
|
||||||
|
$table->index('batch_id');
|
||||||
|
$table->index('family_hash');
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index(['type', 'should_display_on_index']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$schema->create('telescope_entries_tags', function (Blueprint $table) {
|
||||||
|
$table->uuid('entry_uuid');
|
||||||
|
$table->string('tag');
|
||||||
|
|
||||||
|
$table->primary(['entry_uuid', 'tag']);
|
||||||
|
$table->index('tag');
|
||||||
|
|
||||||
|
$table->foreign('entry_uuid')
|
||||||
|
->references('uuid')
|
||||||
|
->on('telescope_entries')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
|
||||||
|
$schema->create('telescope_monitoring', function (Blueprint $table) {
|
||||||
|
$table->string('tag')->primary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$schema = Schema::connection($this->getConnection());
|
||||||
|
|
||||||
|
$schema->dropIfExists('telescope_entries_tags');
|
||||||
|
$schema->dropIfExists('telescope_entries');
|
||||||
|
$schema->dropIfExists('telescope_monitoring');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement("CREATE INDEX IF NOT EXISTS messages_payload_run_id_idx ON messages ((payload->>'run_id')) WHERE type IN ('run.status','run.cancel.request','error','agent.message')");
|
||||||
|
DB::statement("CREATE INDEX IF NOT EXISTS messages_payload_trigger_message_id_idx ON messages ((payload->>'trigger_message_id')) WHERE type = 'run.status'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS messages_payload_run_id_idx');
|
||||||
|
DB::statement('DROP INDEX IF EXISTS messages_payload_trigger_message_id_idx');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -15,11 +16,24 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
User::updateOrCreate(
|
||||||
|
['email' => 'root@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'root',
|
||||||
|
'password' => Hash::make('Root@123456'),
|
||||||
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
User::factory()->create([
|
User::updateOrCreate(
|
||||||
'name' => 'Test User',
|
['email' => 'test@qq.com'],
|
||||||
'email' => 'test@example.com',
|
[
|
||||||
]);
|
'name' => 'test',
|
||||||
|
'password' => Hash::make('test'),
|
||||||
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
docker-compose.yml
Normal file → Executable file
33
docker-compose.yml
Normal file → Executable file
@@ -3,9 +3,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/app/Dockerfile
|
dockerfile: docker/app/Dockerfile
|
||||||
|
image: ars_backend:latest
|
||||||
entrypoint: ["/app/docker/app/entrypoint.sh"]
|
entrypoint: ["/app/docker/app/entrypoint.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
|
#- ../ars-front:/app-frontend
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
APP_DEBUG: "true"
|
APP_DEBUG: "true"
|
||||||
@@ -20,6 +22,7 @@ services:
|
|||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PASSWORD: "null"
|
REDIS_PASSWORD: "null"
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
|
QUEUE_CONNECTION: redis
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -29,6 +32,36 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
|
horizon:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/app/Dockerfile
|
||||||
|
image: ars_backend:latest
|
||||||
|
entrypoint: ["/app/docker/app/entrypoint.sh"]
|
||||||
|
environment:
|
||||||
|
CONTAINER_ROLE: horizon
|
||||||
|
APP_ENV: local
|
||||||
|
APP_DEBUG: "true"
|
||||||
|
APP_URL: http://localhost:8000
|
||||||
|
OCTANE_SERVER: frankenphp
|
||||||
|
DB_CONNECTION: pgsql
|
||||||
|
DB_HOST: pgsql
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: ars_backend
|
||||||
|
DB_USERNAME: ars
|
||||||
|
DB_PASSWORD: secret
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PASSWORD: "null"
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
QUEUE_CONNECTION: redis
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
#- ../ars-front:/app-frontend
|
||||||
|
depends_on:
|
||||||
|
- pgsql
|
||||||
|
- redis
|
||||||
|
tty: true
|
||||||
|
|
||||||
pgsql:
|
pgsql:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
FROM dunglas/frankenphp:1-php8.3
|
FROM php:8.4.15-cli-alpine3.23
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# System packages and PHP extensions required by Laravel/Octane
|
# System packages and PHP extensions required by Laravel/Octane
|
||||||
RUN apt-get update \
|
RUN apk update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apk add --no-cache \
|
||||||
git \
|
git \
|
||||||
unzip \
|
unzip \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
libpq-dev \
|
postgresql-dev \
|
||||||
zlib1g-dev \
|
zlib-dev \
|
||||||
|
libpq \
|
||||||
|
icu-dev \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
autoconf \
|
||||||
|
libc-dev \
|
||||||
|
pkgconfig \
|
||||||
&& docker-php-ext-install \
|
&& docker-php-ext-install \
|
||||||
pcntl \
|
pcntl \
|
||||||
zip \
|
zip \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
|
intl \
|
||||||
&& pecl install redis \
|
&& pecl install redis \
|
||||||
&& docker-php-ext-enable redis \
|
&& docker-php-ext-enable redis \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
pdo_pgsql \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# Composer for dependency management
|
# Composer for dependency management
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||||
|
|||||||
@@ -15,4 +15,15 @@ if ! grep -q "^APP_KEY=" .env 2>/dev/null || grep -q "^APP_KEY=$" .env 2>/dev/nu
|
|||||||
php artisan key:generate --force
|
php artisan key:generate --force
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}" --watch
|
ROLE="${CONTAINER_ROLE:-app}"
|
||||||
|
|
||||||
|
if [ "$ROLE" = "queue" ]; then
|
||||||
|
exec php artisan queue:work --verbose --tries="${QUEUE_TRIES:-3}" --timeout="${QUEUE_TIMEOUT:-90}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ROLE" = "horizon" ]; then
|
||||||
|
exec php artisan horizon
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}"
|
||||||
|
#exec php -S 0.0.0.0:8000 -t public
|
||||||
|
|||||||
227
docs/ChatSession/chat-session-api.md
Normal file
227
docs/ChatSession/chat-session-api.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# ChatSession & Message API(MVP-1 + Agent Run MVP-0)
|
||||||
|
|
||||||
|
基地址:`http://localhost:8000/api`(FrankenPHP 容器 8000 端口)
|
||||||
|
认证方式:JWT,`Authorization: Bearer {token}`
|
||||||
|
自然语言:中文
|
||||||
|
|
||||||
|
## 变更记录
|
||||||
|
- 2025-02-14:新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。
|
||||||
|
- 2025-02-14:MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。
|
||||||
|
- 2025-02-15:Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider;自动在 user.prompt 后触发一次 Run,落地 run.status / agent.message。
|
||||||
|
- 2025-12-18:Agent Run 可靠性增强 —— 并发幂等、终态去重、取消语义加强、Provider 超时/重试/错误归一,SSE gap 回补与心跳。
|
||||||
|
- 2025-12-19:AgentProvider Streaming 接入 —— ProviderEvent 统一事件流,新增 message.delta 输出与 OpenAI-compatible 适配器。
|
||||||
|
- 2025-12-21:Tool 子 Run 模式 —— Provider 支持 tool.delta→tool.call,父 Run 调度子 Run 执行工具并写入 tool.result。
|
||||||
|
|
||||||
|
## 本次变更摘要(2025-12-21)
|
||||||
|
- RunDispatcher 并发幂等:同 trigger_message_id 只产生一个 RUNNING,且仅新建时 dispatch。
|
||||||
|
- RunLoop/OutputSink 幂等:agent.message、run.status、tool.call、tool.result 均采用 dedupe_key。
|
||||||
|
- Cancel 强化:多检查点取消,确保不落 agent.message 且落 CANCELED 终态;父 Run 取消会终止等待的子 Run。
|
||||||
|
- Provider 可靠性:超时/重试/429/5xx,错误落库包含 retryable/http_status/provider/latency_ms。
|
||||||
|
- Streaming:AgentProvider 产出 message.delta / tool.delta / done;finish_reason=tool_calls 会触发子 Run 执行工具。
|
||||||
|
- 工具闭环:tool.call(role=AGENT)落库→子 Run 调度→tool.result(role=TOOL)回灌→进入下一轮 LLM。
|
||||||
|
|
||||||
|
## 领域模型
|
||||||
|
- `ChatSession`:`session_id`(UUID)、`session_name`、`status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq`
|
||||||
|
- `Message`:`message_id`(UUID)、`session_id`、`role`(`USER`/`AGENT`/`TOOL`/`SYSTEM`)、`type`(字符串)、`content`、`payload`(json)、`seq`(会话内递增)、`reply_to`(UUID)、`dedupe_key`
|
||||||
|
- 幂等:`UNIQUE (session_id, dedupe_key)`;同一 dedupe_key 返回已有消息。
|
||||||
|
- 状态门禁:`CLOSED` 禁止追加,例外 `role=SYSTEM && type in [run.status, error]`;`LOCKED` 禁止 `role=USER && type=user.prompt`。
|
||||||
|
- 会话缓存:`chat_sessions.last_message_id` 记录最后一条消息;`appendMessage` 事务内同步更新 `last_seq`、`last_message_id`、`updated_at`。
|
||||||
|
- 工具消息:`tool.call`(role=AGENT,携带 tool_call_id/name/arguments)、`tool.result`(role=TOOL,携带 parent_run_id/run_id/status/result)。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
### 创建会话
|
||||||
|
- `POST /sessions`
|
||||||
|
- 请求体字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| session_name | 否 | string(≤255) | 会话名称 |
|
||||||
|
- 响应 201(JSON)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| session_id | uuid | 主键 |
|
||||||
|
| session_name | string|null | 会话名 |
|
||||||
|
| status | enum | `OPEN|LOCKED|CLOSED` |
|
||||||
|
| last_seq | int | 当前最大 seq |
|
||||||
|
| last_message_id | uuid|null | 最后一条消息 |
|
||||||
|
| created_at, updated_at | datetime | 时间戳 |
|
||||||
|
- 错误:401 未授权
|
||||||
|
|
||||||
|
### 追加消息
|
||||||
|
- `POST /sessions/{session_id}/messages`
|
||||||
|
- 请求体字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| role | 是 | enum | `USER|AGENT|TOOL|SYSTEM` |
|
||||||
|
| type | 是 | string(≤64) | 如 `user.prompt`/`agent.message`/`message.delta` 等 |
|
||||||
|
| content | 否 | string | 文本内容 |
|
||||||
|
| payload | 否 | object | jsonb 结构 |
|
||||||
|
| reply_to | 否 | uuid | 引用消息 |
|
||||||
|
| dedupe_key | 否 | string(≤128) | 幂等键 |
|
||||||
|
- 响应 201(JSON)
|
||||||
|
字段:`message_id, session_id, seq, role, type, content, payload, reply_to, dedupe_key, created_at`
|
||||||
|
- 幂等:同 session + dedupe_key 返回已存在的消息(同 `message_id/seq`)。
|
||||||
|
- 错误:401 未授权;403 违反状态门禁(CLOSED 禁止,LOCKED 禁止 user.prompt);404 session 不存在;422 校验失败。
|
||||||
|
|
||||||
|
### 按序增量查询
|
||||||
|
- `GET /sessions/{session_id}/messages?after_seq=0&limit=50`
|
||||||
|
- 查询参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| after_seq | 0 | int | 仅返回 seq 大于该值 |
|
||||||
|
| limit | 50 | int(≤200) | 返回数量上限 |
|
||||||
|
- 响应 200:`data` 数组,元素字段同“追加消息”响应。
|
||||||
|
- 错误:401/404/422
|
||||||
|
|
||||||
|
### 会话列表
|
||||||
|
- `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword`
|
||||||
|
- 查询参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| page | 1 | int | 分页页码 |
|
||||||
|
| per_page | 15 | int(≤100) | 分页大小 |
|
||||||
|
| status | - | enum | 过滤 `OPEN|LOCKED|CLOSED` |
|
||||||
|
| q | - | string | ILIKE 模糊匹配 session_name |
|
||||||
|
- 响应 200:分页结构(`data/links/meta`),`data` 每项字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| session_id | uuid | 会话主键 |
|
||||||
|
| session_name | string|null | 名称 |
|
||||||
|
| status | enum | `OPEN|LOCKED|CLOSED` |
|
||||||
|
| last_seq | int | 当前最大 seq |
|
||||||
|
| last_message_id | uuid|null | 最后一条消息 |
|
||||||
|
| last_message_at | datetime|null | 最后一条消息时间 |
|
||||||
|
| last_message_preview | string | content 截断 120,空内容返回空字符串 |
|
||||||
|
| last_message_role | string|null | 最后消息角色 |
|
||||||
|
| last_message_type | string|null | 最后消息类型 |
|
||||||
|
| created_at, updated_at | datetime | 时间戳 |
|
||||||
|
- 排序:`updated_at` DESC
|
||||||
|
- 错误:401/422
|
||||||
|
|
||||||
|
### 会话更新
|
||||||
|
- `PATCH /sessions/{session_id}`
|
||||||
|
请求体(至少一项,否则 422)
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| session_name | 否 | string 1..255 | 自动 trim |
|
||||||
|
| status | 否 | enum | `OPEN|LOCKED|CLOSED` |
|
||||||
|
- 规则:
|
||||||
|
- `CLOSED` 不可改回 `OPEN`(返回 403)。
|
||||||
|
- 任意更新都会刷新 `updated_at`。
|
||||||
|
- 响应 200:字段同“会话列表”项。
|
||||||
|
- 错误:401 未授权;403 状态门禁;404 session 不存在;422 校验失败。
|
||||||
|
|
||||||
|
### 获取会话详情
|
||||||
|
- `GET /sessions/{session_id}`
|
||||||
|
- 响应 200:字段同“会话列表”项。
|
||||||
|
- 错误:401 未授权;404 session 不存在。
|
||||||
|
|
||||||
|
### 归档会话(Archive)
|
||||||
|
- `POST /sessions/{session_id}/archive`
|
||||||
|
- 行为:将 `status` 置为 `CLOSED`,更新 `updated_at`,幂等(重复归档返回当前状态)。
|
||||||
|
- 响应 200:字段同“会话列表”项(status=CLOSED)。
|
||||||
|
- 错误:401 未授权;404 session 不存在。
|
||||||
|
|
||||||
|
### 获取单条消息(带会话校验)
|
||||||
|
- `GET /sessions/{session_id}/messages/{message_id}`
|
||||||
|
- 行为:校验 `message.session_id` 与路径参数一致,否则 404。
|
||||||
|
- 响应 200:字段同“追加消息”响应。
|
||||||
|
- 错误:401 未授权;404 不存在或不属于该会话。
|
||||||
|
|
||||||
|
### SSE 实时增量
|
||||||
|
- `GET /sessions/{session_id}/sse?after_seq=123`
|
||||||
|
- 头部:`Accept: text/event-stream`,可带 `Last-Event-ID`(优先于 query)用于断线续传。
|
||||||
|
- 查询参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| after_seq | 0 | int | backlog 起始 seq(若有 Last-Event-ID 则覆盖) |
|
||||||
|
| limit | 200 | int(≤500) | backlog 最多条数 |
|
||||||
|
- SSE 输出格式:
|
||||||
|
```
|
||||||
|
id: {seq}
|
||||||
|
event: message
|
||||||
|
data: {...message json...}
|
||||||
|
|
||||||
|
```
|
||||||
|
- `id` 为消息 `seq`,便于续传;`data` 为消息 JSON(同追加消息响应字段)。
|
||||||
|
- Backlog:建立连接后先补发 `seq > after_seq` 的消息(order asc,最多 `limit` 条),再进入实时订阅。
|
||||||
|
- 实时:Redis channel `session:{session_id}:messages` 发布消息 ID,SSE 侧读取后按 seq 去重、推送。
|
||||||
|
- Gap 回补:若订阅推送的 seq 与 last_sent_seq 存在缺口,会主动回补 backlog。
|
||||||
|
- 心跳:周期输出 `: ping` 保活。
|
||||||
|
- 错误:401 未授权;404 session 不存在。
|
||||||
|
|
||||||
|
## Agent Run MVP-0(RunDispatcher + AgentRunJob)
|
||||||
|
### 流程概述
|
||||||
|
1. 用户追加 `role=USER && type=user.prompt` 后,Controller 自动调用 `RunDispatcher->dispatchForPrompt`。
|
||||||
|
2. 并发保护:同会话只允许一个 RUNNING;同一个 `trigger_message_id` 幂等复用已有 `run_id`。
|
||||||
|
3. 立即写入 `run.status`(SYSTEM/run.status,payload `{run_id,status:'RUNNING',trigger_message_id}`,dedupe_key=`run:trigger:{message_id}`)。
|
||||||
|
4. 仅在新建 RUNNING 时推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。
|
||||||
|
5. RunLoop(默认 HttpAgentProvider,未配置 endpoint 时回退 DummyAgentProvider):
|
||||||
|
- 终态检测:若已 DONE/FAILED/CANCELED 则直接返回。
|
||||||
|
- Cancel 检查:存在 `run.cancel.request`(payload.run_id) 则写入 `run.status=CANCELED`,不产出 agent.message。
|
||||||
|
- ContextBuilder:提取最近 20 条 USER/AGENT 消息(type in user.prompt/agent.message),seq 升序提供给 Provider。
|
||||||
|
- Provider 以 Streaming 事件流产出文本增量(message.delta)。
|
||||||
|
- OutputSink 持续写入 `message.delta`,最终写入 `agent.message`(payload 含 run_id, provider,dedupe_key=`run:{run_id}:agent:message`)与 `run.status=DONE`(dedupe_key=`run:{run_id}:status:DONE`)。
|
||||||
|
6. 异常:ProviderException 写入 `error` + `run.status=FAILED`(dedupe),error payload 包含 retryable/http_status/provider/latency_ms。
|
||||||
|
|
||||||
|
### Run 相关消息类型(落库即真相源)
|
||||||
|
| type | role | payload 关键字段 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| run.status | SYSTEM | run_id, status(RUNNING/DONE/CANCELED/FAILED), trigger_message_id?, error? | Run 生命周期事件,CLOSED 状态下允许写入 |
|
||||||
|
| agent.message | AGENT | run_id, provider | Provider 的一次性回复 |
|
||||||
|
| message.delta | AGENT | run_id, delta_index | Provider 的增量输出(Streaming) |
|
||||||
|
| run.cancel.request | USER/SYSTEM | run_id | CancelChecker 依据该事件判断是否中止 |
|
||||||
|
| error | SYSTEM | run_id, message, retryable?, http_status?, provider?, latency_ms?, raw_message? | 任务异常时落库 |
|
||||||
|
|
||||||
|
### 触发 Run(调试入口)
|
||||||
|
- `POST /sessions/{session_id}/runs`
|
||||||
|
- 请求体字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| trigger_message_id | 是 | uuid | 通常为 `user.prompt` 消息 ID |
|
||||||
|
- 行为:同 `trigger_message_id` 幂等;若已有 RUNNING 则复用其 run_id。
|
||||||
|
- 响应 201:`{ run_id }`
|
||||||
|
- 错误:401 未授权;404 session 不存在或 trigger_message 不属于该 session。
|
||||||
|
|
||||||
|
## cURL 示例
|
||||||
|
```bash
|
||||||
|
# 创建会话
|
||||||
|
SESSION_ID=$(curl -s -X POST http://localhost:8000/api/sessions \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"session_name":"Demo"}' | jq -r '.data.session_id')
|
||||||
|
|
||||||
|
# 追加消息(支持 dedupe_key 幂等)
|
||||||
|
curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"role":"USER","type":"user.prompt","content":"hello","dedupe_key":"k1"}'
|
||||||
|
|
||||||
|
# 增量查询
|
||||||
|
curl -s "http://localhost:8000/api/sessions/$SESSION_ID/messages?after_seq=0&limit=50" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 归档
|
||||||
|
curl -X POST http://localhost:8000/api/sessions/$SESSION_ID/archive \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 获取单条消息
|
||||||
|
curl -s http://localhost:8000/api/sessions/$SESSION_ID/messages/{message_id} \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# SSE(断线续传:可带 Last-Event-ID)
|
||||||
|
curl -N http://localhost:8000/api/sessions/$SESSION_ID/sse?after_seq=10 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: text/event-stream"
|
||||||
|
|
||||||
|
# 手动触发 Run(调试用,实际 user.prompt 会自动触发)
|
||||||
|
curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/runs \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"trigger_message_id":"'$MESSAGE_ID'"}'
|
||||||
|
```
|
||||||
614
docs/ChatSession/chat-session-openapi.yaml
Normal file
614
docs/ChatSession/chat-session-openapi.yaml
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: ChatSession & Message API
|
||||||
|
version: 1.1.1
|
||||||
|
description: |
|
||||||
|
ChatSession & Message API(含 Archive/GetMessage/SSE 与 Run 调度)。自然语言:中文。
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8000/api
|
||||||
|
description: 本地开发(FrankenPHP / Docker)
|
||||||
|
tags:
|
||||||
|
- name: ChatSession
|
||||||
|
description: 会话管理
|
||||||
|
- name: Message
|
||||||
|
description: 消息追加与查询
|
||||||
|
- name: Run
|
||||||
|
description: Agent Run 调度
|
||||||
|
paths:
|
||||||
|
/test:
|
||||||
|
summary: 测试接口
|
||||||
|
get:
|
||||||
|
tags: [Test]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功
|
||||||
|
/sessions:
|
||||||
|
post:
|
||||||
|
tags: [ChatSession]
|
||||||
|
summary: 创建会话
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateSessionRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: 创建成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatSession'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
get:
|
||||||
|
tags: [ChatSession]
|
||||||
|
summary: 会话列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
- in: query
|
||||||
|
name: per_page
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 15
|
||||||
|
maximum: 100
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [OPEN, LOCKED, CLOSED]
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: 模糊匹配 session_name
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 分页会话列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ChatSession'
|
||||||
|
links:
|
||||||
|
$ref: '#/components/schemas/PaginationLinks'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/PaginationMeta'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
/sessions/{session_id}:
|
||||||
|
get:
|
||||||
|
tags: [ChatSession]
|
||||||
|
summary: 获取会话详情
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 会话详情
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatSession'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
"404":
|
||||||
|
description: 未找到
|
||||||
|
patch:
|
||||||
|
tags: [ChatSession]
|
||||||
|
summary: 更新会话(重命名/状态)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateSessionRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 更新成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatSession'
|
||||||
|
"403":
|
||||||
|
description: 状态门禁禁止
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
"422":
|
||||||
|
description: 校验失败
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
/sessions/{session_id}/archive:
|
||||||
|
post:
|
||||||
|
tags: [ChatSession]
|
||||||
|
summary: 归档会话(设为 CLOSED,幂等)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 归档成功(或已归档)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatSession'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
"404":
|
||||||
|
description: 未找到
|
||||||
|
/sessions/{session_id}/messages:
|
||||||
|
post:
|
||||||
|
tags: [Message]
|
||||||
|
summary: 追加消息(含幂等与状态门禁)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AppendMessageRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: 追加成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResource'
|
||||||
|
"403":
|
||||||
|
description: 状态门禁禁止
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
get:
|
||||||
|
tags: [Message]
|
||||||
|
summary: 按 seq 增量查询消息
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: after_seq
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
description: 仅返回 seq 大于该值的消息
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 200
|
||||||
|
description: 返回数量上限
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 按 seq 升序的消息列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MessageResource'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
/sessions/{session_id}/messages/{message_id}:
|
||||||
|
get:
|
||||||
|
tags: [Message]
|
||||||
|
summary: 获取单条消息(校验 session_id)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: path
|
||||||
|
name: message_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 消息详情
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResource'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
"404":
|
||||||
|
description: 未找到或不属于该会话
|
||||||
|
/sessions/{session_id}/sse:
|
||||||
|
get:
|
||||||
|
tags: [Message]
|
||||||
|
summary: SSE 增量推送(backlog + Redis 实时)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: after_seq
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
description: backlog 起始 seq(若有 Last-Event-ID 以其为准)
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 200
|
||||||
|
maximum: 500
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: text/event-stream SSE 流
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: |
|
||||||
|
id: 1
|
||||||
|
event: message
|
||||||
|
data: {"message_id":"...","seq":1}
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
"404":
|
||||||
|
description: 未找到
|
||||||
|
/sessions/{session_id}/runs:
|
||||||
|
post:
|
||||||
|
tags: [Run]
|
||||||
|
summary: 触发一次 Agent Run(按 trigger_message_id 幂等)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: session_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DispatchRunRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: 已触发(或复用进行中的 RUNNING)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
"404":
|
||||||
|
description: session 或 trigger_message 不存在
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
schemas:
|
||||||
|
ChatSession:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
session_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [OPEN, LOCKED, CLOSED]
|
||||||
|
last_seq:
|
||||||
|
type: integer
|
||||||
|
last_message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
last_message_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
last_message_preview:
|
||||||
|
type: string
|
||||||
|
last_message_role:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
last_message_type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
CreateSessionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
session_name:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
UpdateSessionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
session_name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 255
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [OPEN, LOCKED, CLOSED]
|
||||||
|
DispatchRunRequest:
|
||||||
|
type: object
|
||||||
|
required: [trigger_message_id]
|
||||||
|
properties:
|
||||||
|
trigger_message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
AppendMessageRequest:
|
||||||
|
type: object
|
||||||
|
required: [role, type]
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [USER, AGENT, TOOL, SYSTEM]
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
maxLength: 64
|
||||||
|
example: user.prompt
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
payload:
|
||||||
|
nullable: true
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/RunStatusPayload'
|
||||||
|
- $ref: '#/components/schemas/AgentMessagePayload'
|
||||||
|
- $ref: '#/components/schemas/MessageDeltaPayload'
|
||||||
|
- $ref: '#/components/schemas/RunCancelPayload'
|
||||||
|
- $ref: '#/components/schemas/RunErrorPayload'
|
||||||
|
- $ref: '#/components/schemas/ToolCallPayload'
|
||||||
|
- $ref: '#/components/schemas/ToolResultPayload'
|
||||||
|
- type: object
|
||||||
|
reply_to:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
dedupe_key:
|
||||||
|
type: string
|
||||||
|
maxLength: 128
|
||||||
|
nullable: true
|
||||||
|
MessageResource:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
seq:
|
||||||
|
type: integer
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [USER, AGENT, TOOL, SYSTEM]
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
nullable: true
|
||||||
|
reply_to:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
dedupe_key:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Session is closed
|
||||||
|
RunStatusPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [RUNNING, DONE, FAILED, CANCELED]
|
||||||
|
trigger_message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
AgentMessagePayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
MessageDeltaPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
delta_index:
|
||||||
|
type: integer
|
||||||
|
RunCancelPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
RunErrorPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
retryable:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
http_status:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
latency_ms:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
raw_message:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ToolCallPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
tool_run_id:
|
||||||
|
type: string
|
||||||
|
tool_call_id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
arguments:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
ToolResultPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
parent_run_id:
|
||||||
|
type: string
|
||||||
|
tool_call_id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
truncated:
|
||||||
|
type: boolean
|
||||||
|
PaginationLinks:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
first:
|
||||||
|
type: string
|
||||||
|
example: http://localhost:8000/api/sessions?page=1
|
||||||
|
last:
|
||||||
|
type: string
|
||||||
|
example: http://localhost:8000/api/sessions?page=1
|
||||||
|
prev:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
next:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
PaginationMeta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current_page:
|
||||||
|
type: integer
|
||||||
|
from:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
last_page:
|
||||||
|
type: integer
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
to:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
84
docs/User/user-api.md
Normal file
84
docs/User/user-api.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 接口文档(JWT,无状态 API)
|
||||||
|
|
||||||
|
基地址:`http://localhost:8000/api`(容器默认映射 8000 端口)
|
||||||
|
自然语言:中文
|
||||||
|
|
||||||
|
- 认证方式:在请求头添加 `Authorization: Bearer {token}`。
|
||||||
|
- 默认账号(来自 `php artisan db:seed`):`root@example.com` / `Root@123456`。
|
||||||
|
- 所有接口均返回 JSON;失败时返回 `{ "message": "错误信息" }`。
|
||||||
|
- 跨域:默认允许 `http://localhost:5173`,可通过环境变量 `CORS_ALLOWED_ORIGINS`(逗号分隔多个域名)调整。
|
||||||
|
|
||||||
|
## 健康检查
|
||||||
|
- `GET /health`
|
||||||
|
响应:`{ "status": "ok" }`
|
||||||
|
|
||||||
|
## 登录
|
||||||
|
- `POST /login`
|
||||||
|
- 请求体:
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "Password123" }
|
||||||
|
```
|
||||||
|
- 响应 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "jwt-token",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"user": { "id": 1, "name": "root", "email": "root@example.com", "is_active": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 401:凭证无效;403:用户已停用。
|
||||||
|
|
||||||
|
## 当前用户
|
||||||
|
- `GET /me`(需要 JWT)
|
||||||
|
- 响应 200:当前登录用户信息。
|
||||||
|
|
||||||
|
## 用户管理(需 JWT)
|
||||||
|
字段约束:`name` 必填字符串(<=255)、`email` 邮箱唯一、`password` 最少 8 字符。
|
||||||
|
|
||||||
|
### 用户列表
|
||||||
|
- `GET /users`
|
||||||
|
- 查询参数:`page`(默认 1)、`per_page`(默认 15,最大 100)
|
||||||
|
- 响应 200:分页列表,`data` 为用户数组,包含 `id/name/email/is_active/created_at/updated_at`。
|
||||||
|
|
||||||
|
### 创建用户
|
||||||
|
- `POST /users`
|
||||||
|
- 请求体:
|
||||||
|
```json
|
||||||
|
{ "name": "Alice", "email": "alice@example.com", "password": "Password123" }
|
||||||
|
```
|
||||||
|
- 响应 201:新建用户(含 `is_active: true`)。
|
||||||
|
|
||||||
|
### 更新用户
|
||||||
|
- `PUT /users/{id}`
|
||||||
|
- 请求体(任意字段可选):
|
||||||
|
```json
|
||||||
|
{ "name": "New Name", "email": "new@example.com", "password": "NewPass123" }
|
||||||
|
```
|
||||||
|
- 响应 200:更新后的用户。
|
||||||
|
|
||||||
|
### 停用用户
|
||||||
|
- `POST /users/{id}/deactivate`
|
||||||
|
- 响应 200:`is_active` 为 `false`。
|
||||||
|
|
||||||
|
### 启用用户
|
||||||
|
- `POST /users/{id}/activate`
|
||||||
|
- 响应 200:`is_active` 为 `true`。
|
||||||
|
|
||||||
|
## 示例(cURL)
|
||||||
|
```bash
|
||||||
|
# 登录
|
||||||
|
curl -X POST http://localhost:8000/api/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"root@example.com","password":"Root@123456"}'
|
||||||
|
|
||||||
|
# 创建用户(替换 TOKEN)
|
||||||
|
curl -X POST http://localhost:8000/api/users \
|
||||||
|
-H "Authorization: Bearer TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Alice","email":"alice@example.com","password":"Password123"}'
|
||||||
|
|
||||||
|
# 获取用户列表(替换 TOKEN)
|
||||||
|
curl -X GET http://localhost:8000/api/users \
|
||||||
|
-H "Authorization: Bearer TOKEN"
|
||||||
|
```
|
||||||
365
docs/User/user-openapi.yaml
Normal file
365
docs/User/user-openapi.yaml
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: ars-backend API (JWT)
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
ars-backend 无状态 API,认证方式为 JWT Bearer。自然语言:中文。
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8000/api
|
||||||
|
description: 本地开发(FrankenPHP Octane,Docker)
|
||||||
|
tags:
|
||||||
|
- name: System
|
||||||
|
description: 系统与健康检查
|
||||||
|
- name: Auth
|
||||||
|
description: 认证相关接口
|
||||||
|
- name: Users
|
||||||
|
description: 用户管理接口(需 JWT)
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: 健康检查
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 服务可用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
/login:
|
||||||
|
post:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 用户登录
|
||||||
|
description: 使用邮箱和密码换取 JWT,停用用户返回 403。
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 登录成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthResponse'
|
||||||
|
"401":
|
||||||
|
description: 凭证无效
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
"403":
|
||||||
|
description: 用户已停用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
/me:
|
||||||
|
get:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 获取当前用户
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 当前登录用户
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
"401":
|
||||||
|
description: 未授权或 token 失效
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
/users:
|
||||||
|
get:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 用户列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
description: 页码(默认 1)
|
||||||
|
- in: query
|
||||||
|
name: per_page
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 15
|
||||||
|
maximum: 100
|
||||||
|
description: 每页数量(1-100,默认 15)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 分页用户列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserPagination'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
post:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 创建用户
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateUserRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: 创建成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
"422":
|
||||||
|
description: 参数校验失败
|
||||||
|
/users/{id}:
|
||||||
|
put:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 更新用户
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateUserRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 更新成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
"422":
|
||||||
|
description: 参数校验失败
|
||||||
|
/users/{id}/deactivate:
|
||||||
|
post:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 停用用户
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已停用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
/users/{id}/activate:
|
||||||
|
post:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 启用用户
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已启用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
"401":
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
schemas:
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: root
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: root@example.com
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: 2025-12-14T05:37:47.000000Z
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: 2025-12-14T05:37:47.000000Z
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [email, password]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: root@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
example: Root@123456
|
||||||
|
CreateUserRequest:
|
||||||
|
type: object
|
||||||
|
required: [name, email, password]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Alice
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: alice@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
example: Password123
|
||||||
|
UpdateUserRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Alice Updated
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: alice.updated@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
example: NewPassword123
|
||||||
|
AuthResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||||
|
token_type:
|
||||||
|
type: string
|
||||||
|
example: bearer
|
||||||
|
expires_in:
|
||||||
|
type: integer
|
||||||
|
example: 3600
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 凭证无效
|
||||||
|
UserPagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
links:
|
||||||
|
$ref: '#/components/schemas/PaginationLinks'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/PaginationMeta'
|
||||||
|
PaginationLinks:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
first:
|
||||||
|
type: string
|
||||||
|
example: http://localhost:8000/api/users?page=1
|
||||||
|
last:
|
||||||
|
type: string
|
||||||
|
example: http://localhost:8000/api/users?page=1
|
||||||
|
prev:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
next:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
PaginationMeta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current_page:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
from:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
last_page:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
example: http://localhost:8000/api/users
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
example: 15
|
||||||
|
to:
|
||||||
|
type: integer
|
||||||
|
example: 3
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
example: 3
|
||||||
53
docs/agent-orchestrator-review.md
Normal file
53
docs/agent-orchestrator-review.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Agent Orchestrator 工程级 Review
|
||||||
|
|
||||||
|
A. 现状总结
|
||||||
|
|
||||||
|
整体链路(RunDispatcher → Job → RunLoop → Provider → OutputSink)已能跑通,职责划分基本清晰,append-only + seq 方案也成立;但在并发幂等、run 生命周期一致性、取消可靠性、provider 超时/重试,以及 SSE 丢事件补偿方面仍有明显缺口,尚未达到对接真实 LLM Provider 的最低可靠性门槛。
|
||||||
|
|
||||||
|
B. 高风险问题列表
|
||||||
|
|
||||||
|
- P0 | 影响: 并发触发同一 prompt 时可能生成“幽灵 run”,run_id 不一致且重复出消息/计费 | 复现: 并发调用 `dispatchForPrompt` 同一 `trigger_message_id`,第二个请求 dedupe 命中但仍派发新 job | 修复: 以 `appendRunStatus` 返回的 message 为准,若 run_id 不一致则直接返回已有 run_id 且不 dispatch;或使用确定性 run_id/事务化 get-or-create | 位置: `app/Services/RunDispatcher.php:21`, `app/Services/OutputSink.php:30`, `app/Services/ChatService.php:61`
|
||||||
|
- P0 | 影响: job 重试/重复派发会生成多个 `agent.message`,导致重复输出与双重计费 | 复现: 让队列重试或重复 dispatch 同一 run_id | 修复: 给 agent.message 增加 dedupe_key(按 run_id + step/chunk),并在 RunLoop 开始前检查 run.status 是否已终态 | 位置: `app/Services/OutputSink.php:16`, `app/Services/RunLoop.php:45`, `app/Jobs/AgentRunJob.php:21`
|
||||||
|
- P1 | 影响: 同 session 可并发多个 RUNNING(违反“单 run”假设),后续状态/输出互相覆盖 | 复现: 连续/并发发送多条 prompt;`latestStatus` 检查无锁 | 修复: 在 session 行锁内判断并创建 RUNNING,或用 Redis/DB 锁/独立 run 表做硬约束 | 位置: `app/Services/RunDispatcher.php:40`, `app/Http/Controllers/ChatSessionController.php:45`
|
||||||
|
- P1 | 影响: run 生命周期非原子写入,崩溃后会出现“有回复但仍 RUNNING”或“RUNNING 无后续” | 复现: 在 `appendAgentMessage` 后杀 worker;或 dispatch 失败后 RUNNING 已落库 | 修复: 将 agent.message 与 DONE 写入同一一致性边界;添加 watchdog/finally 标记 FAILED/CANCELED | 位置: `app/Services/RunLoop.php:45`, `app/Services/RunDispatcher.php:53`
|
||||||
|
- P1 | 影响: cancel 不能严格阻止 agent.message 落库,且取消后可能无 CANCELED 终态 | 复现: cancel 请求在 provider 返回后、写回前到达;或 job 崩溃 | 修复: 在写回前/写回时再校验 cancel;在 finally 中若存在 cancel 请求则写 CANCELED | 位置: `app/Services/RunLoop.php:20`, `app/Services/CancelChecker.php:9`
|
||||||
|
- P1 | 影响: provider 调用可能无限挂起或频繁失败,run 长时间 RUNNING | 复现: provider 超时/429/5xx | 修复: 设置 Http timeout/retry/backoff,配置 job $timeout/$tries;对 429 做退避 | 位置: `app/Services/Agent/HttpAgentProvider.php:20`, `app/Jobs/AgentRunJob.php:13`
|
||||||
|
- P2 | 影响: SSE 可能漏事件(backlog 与订阅之间存在窗口),Redis publish 异常会让主流程报错 | 复现: 在 backlog 发送后、订阅前插入消息;或 Redis 异常 | 修复: 订阅后检测 seq gap 回补;publish 异常仅告警不抛 | 位置: `app/Http/Controllers/ChatSessionSseController.php:46`, `app/Services/ChatService.php:130`
|
||||||
|
- P2 | 影响: 上下文固定 20 条且无 token 预算/截断,遇大消息容易爆 token | 复现: 长文本 prompt/agent 输出 | 修复: 通过配置控制窗口/预算并做截断或摘要 | 位置: `app/Services/ContextBuilder.php:10`
|
||||||
|
- P2 | 影响: JSONB payload 查询无索引,run/cancel 查询随数据量增长会退化 | 复现: 大量消息后 run.status/取消查询变慢 | 修复: 加表达式索引 `payload->>'run_id'` / `payload->>'trigger_message_id'` | 位置: `app/Services/RunDispatcher.php:28`, `app/Services/CancelChecker.php:11`
|
||||||
|
|
||||||
|
C. 对接真实 LLM Provider 前必须补齐的最小门槛清单
|
||||||
|
|
||||||
|
- 并发幂等:RunDispatcher 必须做到“同 trigger 只产生一个 run”,且不 dispatch 重复 job
|
||||||
|
- 输出幂等:agent.message 必须可去重,RunLoop 必须在终态时 no-op
|
||||||
|
- run 生命周期一致性:DONE/FAILED/CANCELED 的落库要有一致性边界或兜底 finalizer
|
||||||
|
- 取消语义:写回前必须再次校验 cancel,并确保取消后不再写 agent.message
|
||||||
|
- Provider 调用稳定性:timeout + retry/backoff + 429/5xx 处理 + job timeout/tries
|
||||||
|
- SSE 补偿:解决 backlog/subscribe 间隙与 publish 异常导致的漏消息
|
||||||
|
- Context 预算:可配置窗口/预算并限制大 payload
|
||||||
|
- 最小测试覆盖:并发幂等、取消、重试/失败路径
|
||||||
|
|
||||||
|
D. 建议的下一步路线选择
|
||||||
|
|
||||||
|
- 方案1:先补 Orchestrator —— 补齐并发幂等、输出去重、生命周期终态一致性、取消写回保护、provider 超时/重试、SSE 缺口补偿、context 预算配置,再接真实 Provider
|
||||||
|
- 方案2:直接接真实 Provider —— 目前仅适合“实验/灰度验证链路通不通”;已有 append-only + seq + SSE 基础与 ContextBuilder 过滤,但不满足可靠性门槛
|
||||||
|
|
||||||
|
E. 最小变更的 patch 建议(不要求实现)
|
||||||
|
|
||||||
|
- `app/Services/RunDispatcher.php`: `appendRunStatus` 后读取返回 message 的 `payload.run_id`;若与新 runId 不一致则直接返回已有 run_id 且跳过 dispatch;必要时把 “单 session 运行中检查” 放入带锁事务
|
||||||
|
- `app/Services/OutputSink.php`: 允许 `appendAgentMessage` 接收并透传 dedupe_key(默认 `run:{runId}:agent:message`),避免重复输出
|
||||||
|
- `app/Services/RunLoop.php`: 开始时/写回前检查 run 是否已终态;写回 DONE 前再检查 cancel,必要时写 CANCELED 并停止
|
||||||
|
- `app/Jobs/AgentRunJob.php`: 设置 `$tries/$backoff/$timeout`;在 finally 中若仍 RUNNING 则落 FAILED(需查询最新 run.status)
|
||||||
|
- `app/Services/Agent/HttpAgentProvider.php` + `config/services.php`: 增加 timeout、retry/backoff 配置项与 429 退避策略
|
||||||
|
- `app/Services/ChatService.php`: `publishMessageAppended` 改为捕获异常仅记录日志,避免 afterCommit 抛错中断主流程
|
||||||
|
- `app/Http/Controllers/ChatSessionSseController.php`: 收到 pubsub 时若 seq gap>1 则 `sendBacklog` 回补;可加心跳定期拉取
|
||||||
|
- 新 migration:为 `messages` 增加表达式索引 `payload->>'run_id'`、`payload->>'trigger_message_id'`(并结合 `type`)以保证查询性能
|
||||||
|
|
||||||
|
## 已实施增强(最小可行版本)
|
||||||
|
|
||||||
|
- RunDispatcher 并发幂等:RUNNING 消息以 dedupe_key 作为唯一真相,只有新建 RUNNING 才 dispatch
|
||||||
|
- RunLoop/OutputSink 幂等:agent.message/run.status 使用 dedupe_key,终态重复执行可安全 no-op
|
||||||
|
- Cancel/终态收敛:多检查点取消 + Job finally 兜底写入 CANCELED/FAILED
|
||||||
|
- Provider 可靠性:超时/重试/错误归一化(ProviderException)并写入错误元数据
|
||||||
|
- SSE 补偿:seq gap 回补 + 心跳 + publish 异常吞吐日志
|
||||||
|
- Postgres 索引:payload 表达式索引加速 run/cancel 查询
|
||||||
10
docs/agent-provider-update.md
Normal file
10
docs/agent-provider-update.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# AgentProvider Streaming 变更摘要(2025-12-19)
|
||||||
|
|
||||||
|
- 引入 ProviderEvent 事件流与 AgentContext,Provider 以 Generator 输出 message.delta/done/error
|
||||||
|
- 新增 OpenAI-compatible 适配器:RequestBuilder、ApiClient、StreamParser、EventNormalizer
|
||||||
|
- RunLoop/OutputSink 支持增量落库:message.delta + agent.message + run.status
|
||||||
|
- 新增配置项 `agent.openai.*`,用于 base_url/api_key/model 等
|
||||||
|
- 文档已补充 message.delta 的 payload 与消息类型说明
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- `docker compose exec app php artisan test`
|
||||||
25
docs/tools-subrun.md
Normal file
25
docs/tools-subrun.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 工具调用(子 Run 模式)最小闭环
|
||||||
|
|
||||||
|
本次改动新增 Tool 子系统,保持 RunLoop/Provider 的事件驱动模型不变,通过“子 Run”执行工具并把结果回灌到父 Run。
|
||||||
|
|
||||||
|
## 关键链路
|
||||||
|
- Provider 产生 `tool.call`(Streaming 中的 `tool.delta` 聚合),RunLoop 落库 `tool.call` 并生成子 Run `run:{parent}:{tool_call_id}`。
|
||||||
|
- `ToolRunJob` 执行具体工具(当前内置 `get_time`),写入 `tool.result` 与子 Run 的 `run.status`。
|
||||||
|
- 父 Run 轮询等待子 Run 结果(超时/失败即终止),将 `tool.result` 追加到上下文后再次调用 Provider,直至产出最终 `agent.message`。
|
||||||
|
- 幂等:`tool.call`、子 Run `run.status` 与 `tool.result` 均带 dedupe_key;同一个 tool_call_id 只会执行一次。
|
||||||
|
|
||||||
|
## 消息/事件
|
||||||
|
- 新增消息类型:`tool.call`(role=AGENT,payload 含 tool_call_id/name/arguments)、`tool.result`(role=TOOL,payload 含 parent_run_id/run_id/status/result)。
|
||||||
|
- Provider 事件新增 `tool.delta`,RunLoop 内部聚合后才触发子 Run;`finish_reason=tool_calls` 会结束本轮流并进入工具执行。
|
||||||
|
|
||||||
|
## 配置要点
|
||||||
|
- `AGENT_TOOL_MAX_CALLS_PER_RUN`:单个父 Run 允许的工具调用次数(默认 1,超过直接失败)。
|
||||||
|
- `AGENT_TOOL_WAIT_TIMEOUT_MS` / `AGENT_TOOL_WAIT_POLL_MS`:父 Run 等待子 Run 结果的超时与轮询间隔。
|
||||||
|
- `AGENT_TOOL_TIMEOUT_SECONDS` / `AGENT_TOOL_RESULT_MAX_BYTES`:工具执行超时标记与结果截断保护。
|
||||||
|
- `AGENT_TOOL_CHOICE`:传递给 OpenAI 的 tool_choice(默认 auto)。
|
||||||
|
- ToolRunJob 队列参数:`AGENT_TOOL_JOB_TRIES` / `AGENT_TOOL_JOB_BACKOFF` / `AGENT_TOOL_JOB_TIMEOUT`。
|
||||||
|
|
||||||
|
## 预留/限制
|
||||||
|
- 目前仅支持单工具调用闭环;多次调用的上限可调但仍是串行流程。
|
||||||
|
- 工具列表可通过 `ToolRegistry` 扩展(当前内置 `get_time` 纯函数)。
|
||||||
|
- 结果超时为父 Run 级别的软超时,PHP 层未强制中断长耗时函数(后续可接入外部超时控制)。
|
||||||
0
package.json
Normal file → Executable file
0
package.json
Normal file → Executable file
7
phpunit.xml
Normal file → Executable file
7
phpunit.xml
Normal file → Executable file
@@ -23,13 +23,16 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_DATABASE" value="testing"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
<env name="JWT_SECRET" value="testing_jwt_secret_for_unit_tests_32_chars_min"/>
|
||||||
|
<env name="AGENT_PROVIDER_ENDPOINT" value="null"/>
|
||||||
|
<env name="AGENT_OPENAI_BASE_URL" value="null"/>
|
||||||
|
<env name="AGENT_OPENAI_API_KEY" value="null"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
37
routes/api.php
Normal file
37
routes/api.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\ChatSessionController;
|
||||||
|
use App\Http\Controllers\RunController;
|
||||||
|
use App\Http\Controllers\UserController;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/health', fn () => ['status' => 'ok']);
|
||||||
|
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
|
Route::get('test',[RunController::class,'test']);
|
||||||
|
|
||||||
|
Route::middleware('auth.jwt')->group(function () {
|
||||||
|
Route::get('/me', function (Request $request) {
|
||||||
|
return $request->user();
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/users', [UserController::class, 'index']);
|
||||||
|
Route::post('/users', [UserController::class, 'store']);
|
||||||
|
Route::put('/users/{user}', [UserController::class, 'update']);
|
||||||
|
Route::post('/users/{user}/deactivate', [UserController::class, 'deactivate']);
|
||||||
|
Route::post('/users/{user}/activate', [UserController::class, 'activate']);
|
||||||
|
|
||||||
|
Route::get('/sessions', [ChatSessionController::class, 'index']);
|
||||||
|
Route::post('/sessions', [ChatSessionController::class, 'store']);
|
||||||
|
Route::get('/sessions/{session_id}', [ChatSessionController::class, 'show']);
|
||||||
|
Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']);
|
||||||
|
Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']);
|
||||||
|
Route::get('/sessions/{session_id}/messages/{message_id}', [ChatSessionController::class, 'showMessage']);
|
||||||
|
Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']);
|
||||||
|
Route::post('/sessions/{session_id}/archive', [ChatSessionController::class, 'archive']);
|
||||||
|
Route::get('/sessions/{session_id}/sse', [\App\Http\Controllers\ChatSessionSseController::class, 'stream']);
|
||||||
|
Route::post('/sessions/{session_id}/runs', [RunController::class, 'store']);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user