main: 增强工具功能与消息处理
- 添加 `FileReadTool`,支持文件内容读取与安全验证 - 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理 - 修改工具选项逻辑,支持禁用工具时的动态调整 - 增加消息序列化逻辑,优化 Redis 序列管理与数据同步 - 扩展测试覆盖,验证序列化与工具调用场景 - 增强 Docker Compose 脚本,支持应用重置与日志清理 - 调整工具调用超时设置,提升运行时用户体验
This commit is contained in:
@@ -92,7 +92,7 @@ AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
|
||||
|
||||
# Tool 子 Run 调度与超时
|
||||
AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数
|
||||
AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒)
|
||||
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 # 工具结果最大保存字节数(截断后仍会写入)
|
||||
|
||||
@@ -68,13 +68,22 @@ class ChatCompletionsRequestBuilder
|
||||
}
|
||||
|
||||
$toolsSpec = $this->toolRegistry->openAiToolsSpec();
|
||||
$hasToolMessages = $this->hasToolMessages($context);
|
||||
|
||||
// 支持 disable_tools 选项,用于在达到工具调用上限后禁用工具
|
||||
$disableTools = $options['disable_tools'] ?? false;
|
||||
|
||||
if (! empty($toolsSpec) && ! $disableTools) {
|
||||
$payload['tools'] = $toolsSpec;
|
||||
$payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto';
|
||||
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;
|
||||
@@ -219,4 +228,16 @@ class ChatCompletionsRequestBuilder
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ use Illuminate\Support\Str;
|
||||
|
||||
class ChatService
|
||||
{
|
||||
public function __construct(private readonly MessageSequence $messageSequence)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新聊天会话。
|
||||
@@ -88,41 +91,55 @@ class ChatService
|
||||
}
|
||||
}
|
||||
|
||||
$newSeq = $session->last_seq + 1;
|
||||
$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(),
|
||||
]);
|
||||
$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;
|
||||
} 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;
|
||||
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();
|
||||
|
||||
throw $e;
|
||||
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([
|
||||
@@ -327,4 +344,11 @@ class ChatService
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
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)
|
||||
public function __construct(
|
||||
private readonly ChatService $chatService,
|
||||
private readonly MessageSequence $messageSequence,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,10 +43,12 @@ class OutputSink
|
||||
*/
|
||||
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' => $sessionId,
|
||||
'session_id' => $session->session_id,
|
||||
'role' => Message::ROLE_AGENT,
|
||||
'type' => 'message.delta',
|
||||
'content' => $content,
|
||||
@@ -51,7 +57,7 @@ class OutputSink
|
||||
'delta_index' => $deltaIndex,
|
||||
]),
|
||||
'dedupe_key' => "run:{$runId}:agent:delta:{$deltaIndex}",
|
||||
'seq' => 0, // delta 消息不需要真实的 seq
|
||||
'seq' => $this->messageSequence->nextForSession($session),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 兼容的声明。
|
||||
@@ -25,6 +26,7 @@ class ToolRegistry
|
||||
new GetTimeTool(),
|
||||
new LsTool(),
|
||||
new BashTool(),
|
||||
new FileReadTool(),
|
||||
];
|
||||
|
||||
$this->tools = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
0
backup.yaml
Normal file → Executable file
0
backup.yaml
Normal file → Executable file
0
boost.json
Normal file → Executable file
0
boost.json
Normal file → Executable file
0
composer.json
Normal file → Executable file
0
composer.json
Normal file → Executable file
0
composer.lock
generated
Normal file → Executable file
0
composer.lock
generated
Normal file → Executable file
2
docker-compose.yml
Normal file → Executable file
2
docker-compose.yml
Normal file → Executable file
@@ -7,6 +7,7 @@ services:
|
||||
entrypoint: ["/app/docker/app/entrypoint.sh"]
|
||||
volumes:
|
||||
- ./:/app
|
||||
#- ../ars-front:/app-frontend
|
||||
environment:
|
||||
APP_ENV: local
|
||||
APP_DEBUG: "true"
|
||||
@@ -55,6 +56,7 @@ services:
|
||||
QUEUE_CONNECTION: redis
|
||||
volumes:
|
||||
- ./:/app
|
||||
#- ../ars-front:/app-frontend
|
||||
depends_on:
|
||||
- pgsql
|
||||
- redis
|
||||
|
||||
0
package.json
Normal file → Executable file
0
package.json
Normal file → Executable file
0
phpunit.xml
Normal file → Executable file
0
phpunit.xml
Normal file → Executable file
53
script/clear-log.sh
Normal file
53
script/clear-log.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 定义颜色
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印带颜色的信息
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# 打印分割线
|
||||
print_separator() {
|
||||
echo -e "${BLUE}────────────────────────────────────${NC}"
|
||||
}
|
||||
|
||||
# 主流程
|
||||
print_separator
|
||||
print_info "清理日志"
|
||||
print_separator
|
||||
echo ""
|
||||
|
||||
# Step 1: 运行测试
|
||||
print_info "Step 1/1 清理日志"
|
||||
echo ""
|
||||
if docker compose exec -T app rm -rf /app/storage/logs/laravel-*.log ; then
|
||||
echo ""
|
||||
print_success "删除日志成功"
|
||||
else
|
||||
echo ""
|
||||
print_error "删除日志失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
# 完成
|
||||
print_separator
|
||||
print_success "🎉 测试和数据库填充全部完成!"
|
||||
print_separator
|
||||
77
script/reset-app.sh
Executable file
77
script/reset-app.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 获取脚本所在目录的绝对路径
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 定义颜色
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印带颜色的信息
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# 打印分割线
|
||||
print_separator() {
|
||||
echo -e "${BLUE}════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
# 主流程
|
||||
print_separator
|
||||
print_info "开始重置应用"
|
||||
print_separator
|
||||
echo ""
|
||||
|
||||
# Step 1: 重启 Docker Compose
|
||||
print_info "Step 1/3: 重启 Docker Compose"
|
||||
echo ""
|
||||
if bash "${SCRIPT_DIR}/restart-docker-compose.sh"; then
|
||||
print_success "Docker Compose 重启成功"
|
||||
else
|
||||
print_error "Docker Compose 重启失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 2: 运行测试和数据填充
|
||||
print_info "Step 2/3: 运行测试和数据填充"
|
||||
echo ""
|
||||
if bash "${SCRIPT_DIR}/run-tests-seed.sh"; then
|
||||
print_success "测试和数据填充成功"
|
||||
else
|
||||
print_error "测试和数据填充失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 3: 清理日志
|
||||
print_info "Step 3/3: 清理日志"
|
||||
echo ""
|
||||
if bash "${SCRIPT_DIR}/clear-log.sh"; then
|
||||
print_success "日志清理成功"
|
||||
else
|
||||
print_error "日志清理失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 完成
|
||||
print_separator
|
||||
print_success "🎉 应用重置完成!"
|
||||
print_separator
|
||||
67
script/restart-docker-compose.sh
Normal file
67
script/restart-docker-compose.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 定义颜色
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印带颜色的信息
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# 打印分割线
|
||||
print_separator() {
|
||||
echo -e "${BLUE}────────────────────────────────────${NC}"
|
||||
}
|
||||
|
||||
# 主流程
|
||||
print_separator
|
||||
print_info "Docker Compose 重启脚本"
|
||||
print_separator
|
||||
echo ""
|
||||
|
||||
# Step 1: 停止服务
|
||||
print_info "Step 1/3: 停止 Docker Compose 服务..."
|
||||
if docker compose down 2>&1; then
|
||||
print_success "服务已成功停止"
|
||||
else
|
||||
print_error "停止服务失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 2: 启动服务
|
||||
print_info "Step 2/3: 启动 Docker Compose 服务(后台模式)..."
|
||||
if docker compose up -d 2>&1; then
|
||||
print_success "服务已成功启动"
|
||||
else
|
||||
print_error "启动服务失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 3: 显示状态
|
||||
print_info "Step 3/3: 检查服务状态..."
|
||||
echo ""
|
||||
docker compose ps
|
||||
echo ""
|
||||
|
||||
# 完成
|
||||
print_separator
|
||||
print_success "🎉 Docker Compose 服务重启完成!"
|
||||
print_separator
|
||||
67
script/run-tests-seed.sh
Normal file
67
script/run-tests-seed.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 定义颜色
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印带颜色的信息
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# 打印分割线
|
||||
print_separator() {
|
||||
echo -e "${BLUE}────────────────────────────────────${NC}"
|
||||
}
|
||||
|
||||
# 主流程
|
||||
print_separator
|
||||
print_info "Docker Compose 测试与数据库填充脚本"
|
||||
print_separator
|
||||
echo ""
|
||||
|
||||
# Step 1: 运行测试
|
||||
print_info "Step 1/2: 运行 PHPUnit 测试..."
|
||||
echo ""
|
||||
if docker compose exec -T app php artisan test; then
|
||||
echo ""
|
||||
print_success "测试通过"
|
||||
else
|
||||
echo ""
|
||||
print_error "测试失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 2: 数据库填充
|
||||
print_info "Step 2/2: 执行数据库填充..."
|
||||
echo ""
|
||||
if docker compose exec -T app php artisan db:seed; then
|
||||
echo ""
|
||||
print_success "数据库填充完成"
|
||||
else
|
||||
echo ""
|
||||
print_error "数据库填充失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 完成
|
||||
print_separator
|
||||
print_success "🎉 测试和数据库填充全部完成!"
|
||||
print_separator
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\ChatSessionStatus;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Mockery;
|
||||
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -270,6 +272,9 @@ class ChatSessionTest extends TestCase
|
||||
|
||||
public function test_publish_to_redis_on_append(): void
|
||||
{
|
||||
Redis::shouldReceive('get')->andReturn(null);
|
||||
Redis::shouldReceive('setnx')->andReturn(1);
|
||||
Redis::shouldReceive('incr')->andReturn(1);
|
||||
Redis::shouldReceive('publish')->once()->andReturn(1);
|
||||
|
||||
$service = app(ChatService::class);
|
||||
@@ -283,6 +288,64 @@ class ChatSessionTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_message_seq_seeds_from_db_when_redis_key_missing(): void
|
||||
{
|
||||
$service = app(ChatService::class);
|
||||
$session = $service->createSession('Seed Test');
|
||||
|
||||
Message::query()->create([
|
||||
'message_id' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'session_id' => $session->session_id,
|
||||
'role' => Message::ROLE_USER,
|
||||
'type' => 'user.prompt',
|
||||
'content' => 'existing',
|
||||
'payload' => null,
|
||||
'seq' => 10,
|
||||
'reply_to' => null,
|
||||
'dedupe_key' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Redis::shouldReceive('get')->andReturn(null);
|
||||
Redis::shouldReceive('setnx')->once()->andReturn(1);
|
||||
Redis::shouldReceive('incr')->once()->andReturn(11);
|
||||
Redis::shouldReceive('publish')->zeroOrMoreTimes()->andReturn(1);
|
||||
|
||||
$message = $service->appendMessage([
|
||||
'session_id' => $session->session_id,
|
||||
'role' => Message::ROLE_USER,
|
||||
'type' => 'user.prompt',
|
||||
'content' => 'new',
|
||||
]);
|
||||
|
||||
$this->assertEquals(11, $message->seq);
|
||||
}
|
||||
|
||||
public function test_agent_delta_uses_redis_seq_and_publishes_with_seq(): void
|
||||
{
|
||||
$service = app(ChatService::class);
|
||||
$session = $service->createSession('Delta Seq');
|
||||
|
||||
Redis::shouldReceive('get')->andReturn(null);
|
||||
Redis::shouldReceive('setnx')->once()->with("chat_session:{$session->session_id}:seq", 0)->andReturn(1);
|
||||
Redis::shouldReceive('incr')->once()->with("chat_session:{$session->session_id}:seq")->andReturn(1);
|
||||
Redis::shouldReceive('publish')->once()->with(
|
||||
"session:{$session->session_id}:messages",
|
||||
Mockery::on(function ($payload) {
|
||||
$decoded = json_decode($payload, true);
|
||||
return is_array($decoded) && ($decoded['seq'] ?? null) === 1;
|
||||
})
|
||||
)->andReturn(1);
|
||||
|
||||
app(\App\Services\OutputSink::class)->appendAgentDelta(
|
||||
$session->session_id,
|
||||
'run-1',
|
||||
'partial',
|
||||
1,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
public function test_sse_backlog_contains_messages(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -106,6 +106,51 @@ class OpenAiAdapterTest extends TestCase
|
||||
$this->assertSame('call_1', $toolResultMessage['tool_call_id']);
|
||||
}
|
||||
|
||||
public function test_disable_tools_still_includes_definitions_when_history_has_tools(): void
|
||||
{
|
||||
$context = new AgentContext('run-1', 'session-1', 'system prompt', [
|
||||
[
|
||||
'message_id' => 'm1',
|
||||
'role' => Message::ROLE_USER,
|
||||
'type' => 'user.prompt',
|
||||
'content' => 'call tool',
|
||||
'payload' => [],
|
||||
'seq' => 1,
|
||||
],
|
||||
[
|
||||
'message_id' => 'm2',
|
||||
'role' => Message::ROLE_AGENT,
|
||||
'type' => 'tool.call',
|
||||
'content' => null,
|
||||
'payload' => [
|
||||
'tool_call_id' => 'call_1',
|
||||
'name' => 'get_time',
|
||||
'arguments' => ['format' => 'c'],
|
||||
],
|
||||
'seq' => 2,
|
||||
],
|
||||
[
|
||||
'message_id' => 'm3',
|
||||
'role' => Message::ROLE_TOOL,
|
||||
'type' => 'tool.result',
|
||||
'content' => '2024-01-01',
|
||||
'payload' => [
|
||||
'tool_call_id' => 'call_1',
|
||||
'name' => 'get_time',
|
||||
'output' => '2024-01-01',
|
||||
],
|
||||
'seq' => 3,
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context, [
|
||||
'disable_tools' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame('none', $payload['tool_choice']);
|
||||
$this->assertNotEmpty($payload['tools']);
|
||||
}
|
||||
|
||||
public function test_event_normalizer_maps_delta_and_done(): void
|
||||
{
|
||||
$normalizer = new OpenAiEventNormalizer();
|
||||
|
||||
0
vite.config.js
Normal file → Executable file
0
vite.config.js
Normal file → Executable file
Reference in New Issue
Block a user