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:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View 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) };
}
}

View 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 {}

View 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();
});
});

View 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
View 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}` : ''}`;
}
}