Refactor AI chat: streaming, tool calls, UI polish
All checks were successful
CI / check (pull_request) Successful in 3m25s

This commit is contained in:
2026-07-24 16:27:50 +08:00
parent e605586fc9
commit 8656394b9b
55 changed files with 2774 additions and 460 deletions

View File

@@ -2,9 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
import { AuthorizationService } from '../authorization';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AGENT_SKILLS } from './agent-skill.catalog';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolContextFactory } from './agent-tool.types';
import type { AgentToolContext, ToolExecutionResult, ToolStatus, ToolDescriptor } from './agent-tool.types';
import type {
AgentSkillDescriptor,
AgentToolContext,
ToolDescriptor,
ToolExecutionResult,
ToolStatus,
} from './agent-tool.types';
/** Safe tool name: alphanumeric + underscore, max 64 chars. */
const TOOL_NAME_RE = /^[a-zA-Z0-9_]+$/;
@@ -60,7 +67,7 @@ export class AgentToolExecutor {
* @param context — trusted context from
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
*/
listAvailable(context: AgentToolContext): ToolDescriptor[] {
listAvailable(context: AgentToolContext, skillKey?: string | null): ToolDescriptor[] {
AgentToolContextFactory.assertTrusted(context);
const ability = this.abilityFactory.createForUser({
@@ -70,13 +77,25 @@ export class AgentToolExecutor {
return this.registry
.listAvailableInternal(ability)
.map(({ name, description, inputSchema }) => ({
.filter((tool) => !skillKey || tool.skillKey === skillKey)
.map(({ name, skillKey: toolSkillKey, description, inputSchema }) => ({
name,
skillKey: toolSkillKey,
description,
...(inputSchema ? { inputSchema } : {}),
}));
}
listSkills(context: AgentToolContext): AgentSkillDescriptor[] {
const tools = this.listAvailable(context);
return AGENT_SKILLS.map((skill) => ({
...skill,
tools: tools
.filter((tool) => tool.skillKey === skill.key)
.map(({ name, description }) => ({ name, description })),
})).filter((skill) => skill.tools.length > 0);
}
/**
* Execute a tool by name.
*
@@ -89,6 +108,7 @@ export class AgentToolExecutor {
name: string,
rawInput: unknown,
context: AgentToolContext,
allowedSkillKey?: string | null,
): Promise<ToolExecutionResult> {
// 0. Context trust validation — must be first
try {
@@ -111,6 +131,17 @@ export class AgentToolExecutor {
);
}
if (allowedSkillKey && tool.skillKey !== allowedSkillKey) {
return this.auditAndReturn(
safeName,
'denied',
undefined,
SAFE_MESSAGES.permissionDenied,
context,
tool.skillKey,
);
}
// 2. Build ability from principal fields — never trust a pre-built one
const ability = this.abilityFactory.createForUser({
permissions: context.permissions,
@@ -125,6 +156,7 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.permissionDenied,
context,
tool.skillKey,
);
}
@@ -136,6 +168,7 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.invalidInput,
context,
tool.skillKey,
);
}
@@ -150,6 +183,7 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.invalidInput,
context,
tool.skillKey,
);
}
if (!parsed.ok) {
@@ -159,13 +193,21 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.invalidInput,
context,
tool.skillKey,
);
}
// 6. Execute
try {
const result = await tool.execute(parsed.value, context);
return this.auditAndReturn(safeName, 'success', result, undefined, context);
return this.auditAndReturn(
safeName,
'success',
result,
undefined,
context,
tool.skillKey,
);
} catch (err: unknown) {
// NotFoundException → not_found with safe message
if (err instanceof NotFoundException) {
@@ -175,6 +217,7 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.notFound,
context,
tool.skillKey,
);
}
// All other errors → generic failed message
@@ -184,6 +227,7 @@ export class AgentToolExecutor {
undefined,
SAFE_MESSAGES.executionFailed,
context,
tool.skillKey,
);
}
}
@@ -213,6 +257,7 @@ export class AgentToolExecutor {
result: unknown,
error: string | undefined,
context: AgentToolContext,
skillKey?: string,
): Promise<ToolExecutionResult> {
// Await audit (best-effort — failure is silently swallowed)
try {
@@ -228,7 +273,7 @@ export class AgentToolExecutor {
// Swallow — audit failure must not break the tool call
}
return { status, toolName, result, error };
return { status, toolName, skillKey, result, error };
}
/**