Initialize backendized SurClaw report system
- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows. - Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization. - Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration. - Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress. - Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
25
server/src/ai/ai.controller.ts
Normal file
25
server/src/ai/ai.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from '../auth/auth.service.js';
|
||||
import { getSessionUser } from '../auth/session-user.js';
|
||||
import { AiService } from './ai.service.js';
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
|
||||
@Get('models')
|
||||
async listModels(@Req() request: Request) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: await this.aiService.listModels(actor) };
|
||||
}
|
||||
|
||||
@Post('chat')
|
||||
async chat(@Req() request: Request, @Body() body: unknown) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: await this.aiService.chat(actor, body) };
|
||||
}
|
||||
}
|
||||
13
server/src/ai/ai.module.ts
Normal file
13
server/src/ai/ai.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module.js';
|
||||
import { PrismaModule } from '../prisma/prisma.module.js';
|
||||
import { SettingsModule } from '../settings/settings.module.js';
|
||||
import { AiController } from './ai.controller.js';
|
||||
import { AiService } from './ai.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, SettingsModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
28
server/src/ai/ai.schemas.test.ts
Normal file
28
server/src/ai/ai.schemas.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aiChatSchema } from './ai.schemas';
|
||||
|
||||
describe('AI schemas', () => {
|
||||
it('accepts OpenAI-compatible chat payloads with multimodal content', () => {
|
||||
const parsed = aiChatSchema.parse({
|
||||
model: 'ignored-by-proxy',
|
||||
messages: [
|
||||
{ role: 'system', content: 'system prompt' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA==' } },
|
||||
{ type: 'text', text: '生成报告' },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
expect(parsed.messages).toHaveLength(2);
|
||||
expect(parsed.temperature).toBe(0.3);
|
||||
});
|
||||
|
||||
it('rejects empty message arrays', () => {
|
||||
expect(() => aiChatSchema.parse({ messages: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
12
server/src/ai/ai.schemas.ts
Normal file
12
server/src/ai/ai.schemas.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aiChatSchema = z.object({
|
||||
messages: z.array(z.unknown()).min(1, '消息不能为空'),
|
||||
model: z.string().optional(),
|
||||
temperature: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AiChatInput = z.infer<typeof aiChatSchema>;
|
||||
126
server/src/ai/ai.service.ts
Normal file
126
server/src/ai/ai.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { SafeUser } from '../auth/auth.types.js';
|
||||
import { SettingsService } from '../settings/settings.service.js';
|
||||
import { aiChatSchema } from './ai.schemas.js';
|
||||
|
||||
interface AiProvider {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
async listModels(actor: SafeUser) {
|
||||
const provider = await this.getActiveProvider(actor);
|
||||
const response = await this.fetchProvider(`${provider.endpoint}/models`, {
|
||||
method: 'GET',
|
||||
headers: this.headers(provider),
|
||||
});
|
||||
|
||||
const payload = await this.parseProviderResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(this.formatProviderError(response.status, payload));
|
||||
}
|
||||
|
||||
const models = Array.isArray((payload as { data?: unknown[] }).data)
|
||||
? ((payload as { data: Array<{ id?: string }> }).data)
|
||||
.map((model) => model.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: [];
|
||||
|
||||
return {
|
||||
models,
|
||||
provider: {
|
||||
endpoint: provider.endpoint,
|
||||
modelName: provider.modelName,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async chat(actor: SafeUser, rawInput: unknown) {
|
||||
const result = aiChatSchema.safeParse(rawInput);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
|
||||
const provider = await this.getActiveProvider(actor);
|
||||
const input = result.data;
|
||||
const payload = {
|
||||
...input,
|
||||
model: provider.modelName || input.model,
|
||||
};
|
||||
|
||||
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(provider),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responsePayload = await this.parseProviderResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
|
||||
}
|
||||
|
||||
return responsePayload;
|
||||
}
|
||||
|
||||
private async getActiveProvider(actor: SafeUser): Promise<AiProvider> {
|
||||
const settings = await this.settingsService.getSystemSettings(actor, { includeSecrets: true });
|
||||
const activeProvider = settings.activeAiProvider || 'kimi';
|
||||
const provider = settings.aiProviders?.[activeProvider];
|
||||
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
|
||||
const apiKey = provider?.apiKey || '';
|
||||
const modelName = provider?.modelName || '';
|
||||
|
||||
if (!endpoint) {
|
||||
throw new BadRequestException('尚未配置 AI 接口地址');
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw new BadRequestException('尚未配置 AI API Key');
|
||||
}
|
||||
if (!modelName) {
|
||||
throw new BadRequestException('尚未配置 AI 模型名称');
|
||||
}
|
||||
|
||||
return { endpoint, apiKey, modelName };
|
||||
}
|
||||
|
||||
private headers(provider: AiProvider) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async parseProviderResponse(response: Response) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchProvider(url: string, init: RequestInit) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatProviderError(status: number, payload: unknown) {
|
||||
const message =
|
||||
typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
? JSON.stringify((payload as { error: unknown }).error)
|
||||
: typeof payload === 'object' && payload !== null && 'message' in payload
|
||||
? String((payload as { message: unknown }).message)
|
||||
: JSON.stringify(payload);
|
||||
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user