Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ai #1117

Draft
wants to merge 9 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"webpack": "5.91.0"
},
"dependencies": {
"@ai-sdk/openai": "0.0.72",
"@aws-sdk/client-s3": "3.609.0",
"@aws-sdk/s3-request-presigner": "3.609.0",
"@keyv/redis": "2.8.4",
Expand Down Expand Up @@ -144,6 +145,7 @@
"@teable/openapi": "workspace:^",
"@teamwork/websocket-json-stream": "2.0.0",
"@types/papaparse": "5.3.14",
"ai": "3.4.33",
"ajv": "8.12.0",
"axios": "1.7.7",
"bcrypt": "5.1.1",
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ICacheConfig } from './configs/cache.config';
import { ConfigModule } from './configs/config.module';
import { AccessTokenModule } from './features/access-token/access-token.module';
import { AggregationOpenApiModule } from './features/aggregation/open-api/aggregation-open-api.module';
import { AiModule } from './features/ai/ai.module';
import { AttachmentsModule } from './features/attachments/attachments.module';
import { AuthModule } from './features/auth/auth.module';
import { BaseModule } from './features/base/base.module';
Expand Down Expand Up @@ -66,6 +67,7 @@ export const appModules = {
PluginModule,
DashboardModule,
CommentOpenApiModule,
AiModule,
],
providers: [InitBootstrapProvider],
};
Expand Down
13 changes: 13 additions & 0 deletions apps/nestjs-backend/src/features/ai/ai.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Body, Controller, Post, Res } from '@nestjs/common';
import { Response } from 'express';
import { AiService, Task } from './ai.service';

@Controller('api/ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post()
async generate(@Body('prompt') prompt: string, @Body('task') task: Task, @Res() res: Response) {
const result = await this.aiService.generate(prompt, task);
result.pipeTextStreamToResponse(res);
}
}
11 changes: 11 additions & 0 deletions apps/nestjs-backend/src/features/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SettingModule } from '../setting/setting.module';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';

@Module({
imports: [SettingModule],
controllers: [AiController],
providers: [AiService],
})
export class AiModule {}
57 changes: 57 additions & 0 deletions apps/nestjs-backend/src/features/ai/ai.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createOpenAI } from '@ai-sdk/openai';
import { Injectable } from '@nestjs/common';
import { streamText } from 'ai';
import { SettingService } from '../setting/setting.service';

export enum Task {
Translation = 'translation',
Coding = 'coding',
}

@Injectable()
export class AiService {
constructor(private readonly settingService: SettingService) {}

// eslint-disable-next-line @typescript-eslint/naming-convention
static taskModelMap = {
[Task.Coding]: 'codingModel',
[Task.Translation]: 'translationModel',
};

private async getModelConfig(task: Task) {
const { aiConfig } = await this.settingService.getSetting();
// aiConfig?.codingModel model@provider
const currentTaskModel = AiService.taskModelMap[task];
const [model, provider] =
(aiConfig?.[currentTaskModel as keyof typeof aiConfig] as string)?.split('@') || [];
const llmProviders = aiConfig?.llmProviders || [];

const providerConfig = llmProviders.find(
(p) => p.name.toLowerCase() === provider.toLowerCase()
);

if (!providerConfig) {
throw new Error('AI provider configuration is not set');
}

return { model, baseUrl: providerConfig.baseUrl, apiKey: providerConfig.apiKey };
}

async generate(prompt: string, task: Task = Task.Coding) {
const { baseUrl, apiKey, model } = await this.getModelConfig(task);

if (!baseUrl || !apiKey) {
throw new Error('AI configuration is not set');
}

const openai = createOpenAI({
baseURL: baseUrl,
apiKey,
});

return await streamText({
model: openai(model),
prompt: prompt,
});
}
}
35 changes: 32 additions & 3 deletions apps/nestjs-backend/src/features/setting/setting.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { IUpdateSettingRo, updateSettingRoSchema } from '@teable/openapi';
import type { ISettingVo } from '@teable/openapi';
import { IUpdateSettingRo, updateSettingRoSchema } from '@teable/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { Public } from '../auth/decorators/public.decorator';
Expand All @@ -10,7 +10,10 @@ import { SettingService } from './setting.service';
export class SettingController {
constructor(private readonly settingService: SettingService) {}

@Public()
/**
* Get the instance settings, now we have config for AI, there are some sensitive fields, we need check the permission before return.
*/
@Permissions('instance|read')
@Get()
async getSetting(): Promise<ISettingVo> {
return await this.settingService.getSetting();
Expand All @@ -22,6 +25,32 @@ export class SettingController {
@Body(new ZodValidationPipe(updateSettingRoSchema))
updateSettingRo: IUpdateSettingRo
): Promise<ISettingVo> {
return await this.settingService.updateSetting(updateSettingRo);
const res = await this.settingService.updateSetting(updateSettingRo);
return {
...res,
aiConfig: res.aiConfig ? JSON.parse(res.aiConfig) : null,
};
}

/**
* Public endpoint for getting public settings without authentication
*/
@Public()
@Get('public')
async getPublicSetting(): Promise<
Pick<ISettingVo, 'disallowSignUp' | 'disallowSpaceCreation' | 'disallowSpaceInvitation'> & {
aiConfig: {
enable: boolean;
};
}
> {
const setting = await this.settingService.getSetting();
const { aiConfig, ...rest } = setting;
return {
...rest,
aiConfig: {
enable: aiConfig?.enable ?? false,
},
};
}
}
10 changes: 9 additions & 1 deletion apps/nestjs-backend/src/features/setting/setting.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ export class SettingService {
disallowSignUp: true,
disallowSpaceCreation: true,
disallowSpaceInvitation: true,
aiConfig: true,
},
})
.then((setting) => ({
...setting,
aiConfig: setting.aiConfig ? JSON.parse(setting.aiConfig as string) : null,
}))
.catch(() => {
throw new NotFoundException('Setting not found');
});
Expand All @@ -25,7 +30,10 @@ export class SettingService {
const setting = await this.getSetting();
return await this.prismaService.setting.update({
where: { instanceId: setting.instanceId },
data: updateSettingRo,
data: {
...updateSettingRo,
aiConfig: updateSettingRo.aiConfig ? JSON.stringify(updateSettingRo.aiConfig) : null,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getSetting, updateSetting } from '@teable/openapi';
import { Label, Switch } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { CopyInstance } from './components';
import { AIConfigForm } from './components/ai-config/ai-form';

export interface ISettingPageProps {
settingServerData?: ISettingVo;
Expand All @@ -26,7 +27,7 @@ export const SettingPage = (props: ISettingPageProps) => {
},
});

const onCheckedChange = (key: string, value: boolean) => {
const onValueChange = (key: string, value: unknown) => {
mutateUpdateSetting({ [key]: value });
};

Expand All @@ -41,48 +42,61 @@ export const SettingPage = (props: ISettingPageProps) => {
<div className="mt-3 text-sm text-slate-500">{t('admin.setting.description')}</div>
</div>

<div className="flex w-full flex-col space-y-4 py-4">
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-sign-up">{t('admin.setting.allowSignUp')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSignUpDescription')}
{/* General Settings Section */}
<div className="border-b py-4">
<h2 className="mb-4 text-lg font-medium">{t('admin.setting.generalSettings')}</h2>
<div className="flex w-full flex-col space-y-4">
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-sign-up">{t('admin.setting.allowSignUp')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSignUpDescription')}
</div>
</div>
<Switch
id="allow-sign-up"
checked={!disallowSignUp}
onCheckedChange={(checked) => onValueChange('disallowSignUp', !checked)}
/>
</div>
<Switch
id="allow-sign-up"
checked={!disallowSignUp}
onCheckedChange={(checked) => onCheckedChange('disallowSignUp', !checked)}
/>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-sign-up">{t('admin.setting.allowSpaceInvitation')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSpaceInvitationDescription')}
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-sign-up">{t('admin.setting.allowSpaceInvitation')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSpaceInvitationDescription')}
</div>
</div>
<Switch
id="allow-space-invitation"
checked={!disallowSpaceInvitation}
onCheckedChange={(checked) => onValueChange('disallowSpaceInvitation', !checked)}
/>
</div>
<Switch
id="allow-space-invitation"
checked={!disallowSpaceInvitation}
onCheckedChange={(checked) => onCheckedChange('disallowSpaceInvitation', !checked)}
/>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-space-creation">{t('admin.setting.allowSpaceCreation')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSpaceCreationDescription')}
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<Label htmlFor="allow-space-creation">{t('admin.setting.allowSpaceCreation')}</Label>
<div className="text-[13px] text-gray-500">
{t('admin.setting.allowSpaceCreationDescription')}
</div>
</div>
<Switch
id="allow-space-creation"
checked={!disallowSpaceCreation}
onCheckedChange={(checked) => onValueChange('disallowSpaceCreation', !checked)}
/>
</div>
<Switch
id="allow-space-creation"
checked={!disallowSpaceCreation}
onCheckedChange={(checked) => onCheckedChange('disallowSpaceCreation', !checked)}
/>
</div>
</div>

{/* AI Configuration Section */}
<div className="py-4">
<h2 className="mb-4 text-lg font-medium">{t('admin.setting.aiSettings')}</h2>
<AIConfigForm
aiConfig={setting.aiConfig}
setAiConfig={(value) => onValueChange('aiConfig', value)}
/>
</div>

<div className="grow" />
<p className="p-4 text-right text-xs">
{t('settings.setting.version')}: {process.env.NEXT_PUBLIC_BUILD_VERSION}
Expand Down
Loading
Loading