From 1466376db397824d80b76473dba58e7fb6c4c7b9 Mon Sep 17 00:00:00 2001 From: mayneyao Date: Fri, 8 Nov 2024 13:40:53 +0800 Subject: [PATCH 1/8] feat(ai): backend api --- apps/nestjs-backend/package.json | 2 ++ apps/nestjs-backend/src/app.module.ts | 2 ++ .../src/features/ai/ai.controller.ts | 13 +++++++++++ .../src/features/ai/ai.module.ts | 11 +++++++++ .../src/features/ai/ai.service.ts | 23 +++++++++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 apps/nestjs-backend/src/features/ai/ai.controller.ts create mode 100644 apps/nestjs-backend/src/features/ai/ai.module.ts create mode 100644 apps/nestjs-backend/src/features/ai/ai.service.ts diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index dfea09b30..06cc5f79a 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -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", @@ -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.6.8", "bcrypt": "5.1.1", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index 258940acb..5c7402241 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -35,6 +35,7 @@ import { GlobalModule } from './global/global.module'; import { InitBootstrapProvider } from './global/init-bootstrap.provider'; import { LoggerModule } from './logger/logger.module'; import { WsModule } from './ws/ws.module'; +import { AiModule } from './features/ai/ai.module'; export const appModules = { imports: [ @@ -66,6 +67,7 @@ export const appModules = { PluginModule, DashboardModule, CommentOpenApiModule, + AiModule, ], providers: [InitBootstrapProvider], }; diff --git a/apps/nestjs-backend/src/features/ai/ai.controller.ts b/apps/nestjs-backend/src/features/ai/ai.controller.ts new file mode 100644 index 000000000..1d11c157e --- /dev/null +++ b/apps/nestjs-backend/src/features/ai/ai.controller.ts @@ -0,0 +1,13 @@ +import { Body, Controller, Post, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { AiService } from './ai.service'; + +@Controller('api/ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + @Post() + async generate(@Body('prompt') prompt: string, @Res() res: Response) { + const result = await this.aiService.generate(prompt); + result.pipeTextStreamToResponse(res); + } +} diff --git a/apps/nestjs-backend/src/features/ai/ai.module.ts b/apps/nestjs-backend/src/features/ai/ai.module.ts new file mode 100644 index 000000000..ab10fa97a --- /dev/null +++ b/apps/nestjs-backend/src/features/ai/ai.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AiController } from './ai.controller'; +import { ConfigModule } from '@nestjs/config'; +import { AiService } from './ai.service'; + +@Module({ + imports: [ConfigModule], + controllers: [AiController], + providers: [AiService], +}) +export class AiModule {} diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts new file mode 100644 index 000000000..ce936ca65 --- /dev/null +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createOpenAI } from '@ai-sdk/openai'; +import { streamText } from 'ai'; + +@Injectable() +export class AiService { + constructor(private readonly configService: ConfigService) {} + + async generate(prompt: string) { + const openAIBaseUrl = this.configService.get('OPENAI_BASE_URL'); + const openaiApiKey = this.configService.get('OPENAI_API_KEY'); + const openai = createOpenAI({ + baseURL: openAIBaseUrl, + apiKey: openaiApiKey, + }); + const result = await streamText({ + model: openai('gpt-4o-mini'), + prompt: prompt, + }); + return result; + } +} From 204e49dc433db2a887cf74e7ddf9bd7f1df240f4 Mon Sep 17 00:00:00 2001 From: mayneyao Date: Fri, 8 Nov 2024 14:42:01 +0800 Subject: [PATCH 2/8] feat(ai): generate formula with AI --- apps/nextjs-app/.env.example | 5 + apps/nextjs-app/src/styles/global.css | 8 + packages/common-i18n/src/locales/en/sdk.json | 3 +- .../comment/comment-editor/plate-ui/icons.tsx | 32 ++ .../src/components/editor/formula/Editor.tsx | 48 ++- .../editor/formula/components/CodeEditor.tsx | 45 ++- .../editor/formula/extensions/ai.ts | 18 + packages/sdk/src/hooks/use-ai.ts | 63 ++++ packages/sdk/tailwind.config.js | 11 +- pnpm-lock.yaml | 310 +++++++++++++++++- 10 files changed, 517 insertions(+), 26 deletions(-) create mode 100644 packages/sdk/src/components/editor/formula/extensions/ai.ts create mode 100644 packages/sdk/src/hooks/use-ai.ts diff --git a/apps/nextjs-app/.env.example b/apps/nextjs-app/.env.example index 301c36cd4..699787a83 100644 --- a/apps/nextjs-app/.env.example +++ b/apps/nextjs-app/.env.example @@ -161,3 +161,8 @@ NEXT_BUILD_ENV_SENTRY_DEBUG=false NEXT_BUILD_ENV_SENTRY_TRACING=false # enable nextjs image optimization NEXT_ENV_IMAGES_ALL_REMOTE=true + +# openai api key +OPENAI_API_KEY="xxxxxxxxx" +# openai base url +OPENAI_BASE_URL="xxxxxxxxx" diff --git a/apps/nextjs-app/src/styles/global.css b/apps/nextjs-app/src/styles/global.css index add01057f..1fe3520e4 100644 --- a/apps/nextjs-app/src/styles/global.css +++ b/apps/nextjs-app/src/styles/global.css @@ -36,3 +36,11 @@ body { border-color: hsl(var(--primary)) !important; background-color: hsl(var(--muted-foreground)) !important; } + +.cm-placeholder { + color: hsl(var(--muted)); + display: inline-block; + pointer-events: none; + padding-left: 0.3rem; + font-size: small; +} diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 48881fc69..f975ba5bc 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -54,7 +54,8 @@ "guideSyntax": "Syntax", "guideExample": "Example", "helperExample": "Example: ", - "fieldValue": "Returns the value to the cells of the {{fieldName}} field." + "fieldValue": "Returns the value to the cells of the {{fieldName}} field.", + "placeholder": "Enter an expression or press // to generate with AI" }, "link": { "placeholder": "Select records to link", diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx index 5a2666e96..17c9ee923 100644 --- a/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx @@ -370,6 +370,38 @@ const LayoutIcon = (props: LucideProps) => ( ); +export const MagicAI = ( + props: LucideProps & { + active?: boolean; + } +) => ( + + + + + +); + export const Icons = { LayoutIcon, add: Plus, diff --git a/packages/sdk/src/components/editor/formula/Editor.tsx b/packages/sdk/src/components/editor/formula/Editor.tsx index fe598f973..d130f7648 100644 --- a/packages/sdk/src/components/editor/formula/Editor.tsx +++ b/packages/sdk/src/components/editor/formula/Editor.tsx @@ -11,27 +11,30 @@ import { CharStreams } from 'antlr4ts'; import Fuse from 'fuse.js'; import { cloneDeep, keyBy } from 'lodash'; import type { FC } from 'react'; -import { useRef, useState, useMemo, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from '../../../context/app/i18n'; import { useFieldStaticGetter, useFields } from '../../../hooks'; import { FormulaField } from '../../../model'; import type { ICodeEditorRef } from './components'; -import { FunctionGuide, FunctionHelper, CodeEditor } from './components'; +import { CodeEditor, FunctionGuide, FunctionHelper } from './components'; import { - Type2IconMap, FOCUS_TOKENS_SET, - useFunctionsDisplayMap, + Type2IconMap, useFormulaFunctionsMap, + useFunctionsDisplayMap, } from './constants'; import { THEME_EXTENSIONS, TOKEN_EXTENSIONS, getVariableExtensions } from './extensions'; -import { SuggestionItemType } from './interface'; +import { getFormulaPrompt } from './extensions/ai'; import type { IFocusToken, IFuncHelpData, IFunctionCollectionItem, IFunctionSchema, } from './interface'; +import { SuggestionItemType } from './interface'; import { FormulaNodePathVisitor } from './visitor'; +import { useAIStream } from '../../../hooks/use-ai'; +import { MagicAI } from '../../comment/comment-editor/plate-ui/icons'; interface IFormulaEditorProps { expression?: string; @@ -63,6 +66,13 @@ export const FormulaEditor: FC = (props) => { [formulaFunctionsMap] ); const functionsDisplayMap = useFunctionsDisplayMap(); + const { generateAIResponse, text, loading } = useAIStream(); + + useEffect(() => { + if (text) { + setExpressionByName(text); + } + }, [text]); const filteredFields = useMemo(() => { const fuse = new Fuse(fields, { @@ -329,20 +339,44 @@ export const FormulaEditor: FC = (props) => { } }; + const handleGenerateFormula = useCallback(() => { + if (!expressionByName || loading) return; + if (expressionByName.startsWith('//')) { + generateAIResponse(getFormulaPrompt(expressionByName.slice(2), fields)); + } + }, [expressionByName, fields, loading]); const codeBg = isLightTheme ? 'bg-slate-100' : 'bg-gray-900'; + // only generate formula when the expression starts with // + const isReadyToGenerate = expressionByName.startsWith('//'); + return (
-

{t('editor.formula.title')}

+
+

{t('editor.formula.title')}

+ +
-
+ +
{errMsg}
diff --git a/packages/sdk/src/components/editor/formula/components/CodeEditor.tsx b/packages/sdk/src/components/editor/formula/components/CodeEditor.tsx index 1a01496b9..7e7db8bd5 100644 --- a/packages/sdk/src/components/editor/formula/components/CodeEditor.tsx +++ b/packages/sdk/src/components/editor/formula/components/CodeEditor.tsx @@ -1,7 +1,7 @@ import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; import type { EditorSelection, Extension } from '@codemirror/state'; import { EditorState, StateEffect } from '@codemirror/state'; -import { EditorView } from '@codemirror/view'; +import { EditorView, Decoration, WidgetType } from '@codemirror/view'; import type { ForwardRefRenderFunction } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { AUTOCOMPLETE_EXTENSIONS, HISTORY_EXTENSIONS } from '../extensions'; @@ -11,6 +11,7 @@ interface ICodeEditorProps { extensions?: Extension[]; onChange?: (value: string) => void; onSelectionChange?: (value: string, selection: EditorSelection) => void; + placeholder?: string; } export interface ICodeEditorRef { @@ -20,7 +21,13 @@ export interface ICodeEditorRef { const emptyExtensions: Extension[] = []; const CodeEditorBase: ForwardRefRenderFunction = (props, ref) => { - const { value = '', extensions = emptyExtensions, onChange, onSelectionChange } = props; + const { + value = '', + extensions = emptyExtensions, + onChange, + onSelectionChange, + placeholder, + } = props; const editorRef = useRef(null); const editorViewRef = useRef(null); @@ -42,15 +49,38 @@ const CodeEditorBase: ForwardRefRenderFunction } }); const highlight = syntaxHighlighting(defaultHighlightStyle, { fallback: true }); + + const placeholderExt = placeholder + ? EditorView.decorations.of((view) => { + const doc = view.state.doc; + return doc.length === 0 + ? Decoration.set([ + Decoration.widget({ + widget: new (class extends WidgetType { + toDOM() { + const span = document.createElement('span'); + span.className = 'cm-placeholder'; + span.textContent = placeholder; + return span; + } + })(), + side: 1, + }).range(0), + ]) + : Decoration.none; + }) + : []; + return [ ...HISTORY_EXTENSIONS, ...AUTOCOMPLETE_EXTENSIONS, highlight, updateListener, EditorView.lineWrapping, + placeholderExt, ...extensions, ]; - }, [extensions, onChange, onSelectionChange]); + }, [extensions, onChange, onSelectionChange, placeholder]); useEffect(() => { if (!editorRef.current) return; @@ -73,6 +103,15 @@ const CodeEditorBase: ForwardRefRenderFunction // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (editorViewRef.current) { + const transaction = editorViewRef.current.state.update({ + changes: { from: 0, to: editorViewRef.current.state.doc.length, insert: value }, + }); + editorViewRef.current.dispatch(transaction); + } + }, [value]); + useEffect(() => { editorViewRef.current?.dispatch({ effects: StateEffect.reconfigure.of(allExtensions) }); }, [allExtensions]); diff --git a/packages/sdk/src/components/editor/formula/extensions/ai.ts b/packages/sdk/src/components/editor/formula/extensions/ai.ts new file mode 100644 index 000000000..6744e0448 --- /dev/null +++ b/packages/sdk/src/components/editor/formula/extensions/ai.ts @@ -0,0 +1,18 @@ +import { useFields } from '../../../../hooks/use-fields'; + +export const getFormulaPrompt = (prompt: string, fields: ReturnType) => { + const context = fields.map((field) => `${field.id}: ${field.name}`).join('\n'); + return ` + you are a expert of airtable formula, especially good at writing formula. + 1. please generate a airtable formula based on the user's description. + 2. only return the formula, no need to explain. do not use \`\` to wrap it. when referencing by field name, use \`{}\` to wrap it. + 3. the field information of the current table is in the tag, please refer to the field information to generate the formula. + 4. the user's description is in the tag. + + ${context} + + + ${prompt} + + `; +}; diff --git a/packages/sdk/src/hooks/use-ai.ts b/packages/sdk/src/hooks/use-ai.ts new file mode 100644 index 000000000..7734f52eb --- /dev/null +++ b/packages/sdk/src/hooks/use-ai.ts @@ -0,0 +1,63 @@ +import { useCallback, useState, useRef } from 'react'; + +const API_ENDPOINT = '/api/ai'; + +interface IUseAIStreamOptions { + timeout?: number; // unit: ms +} + +export const useAIStream = (options?: IUseAIStreamOptions) => { + const { timeout = 30000 } = options || {}; + const [loading, setLoading] = useState(false); + const [text, setText] = useState(''); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + + const generateAIResponse = useCallback(async (prompt: string) => { + setText(''); + setError(null); + setLoading(true); + + controllerRef.current = new AbortController(); + const timeoutId = setTimeout(() => controllerRef.current?.abort(), timeout); + + try { + const result = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt }), + signal: controllerRef.current.signal, + }); + + if (!result.ok) { + throw new Error(`HTTP error! status: ${result.status}`); + } + + const reader = result.body?.getReader(); + if (!reader) throw new Error('No reader available'); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = new TextDecoder().decode(value); + setText((prev) => prev + chunk); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setError(errorMessage); + console.error('Error streaming AI response:', error); + } finally { + clearTimeout(timeoutId); + setLoading(false); + } + }, []); + + const stop = useCallback(() => { + controllerRef.current?.abort(); + }, []); + + return { text, generateAIResponse, loading, error, stop }; +}; diff --git a/packages/sdk/tailwind.config.js b/packages/sdk/tailwind.config.js index 38cb12319..47e78bcdb 100644 --- a/packages/sdk/tailwind.config.js +++ b/packages/sdk/tailwind.config.js @@ -7,6 +7,15 @@ const buildFilePath = join(__dirname, './dist/**/*.{js,ts,jsx,tsx}'); module.exports = uiConfig({ content: [sdkPath, buildFilePath], darkMode: ['class'], - theme: {}, + theme: { + extend: { + keyframes: { + scale: { + '0%, 100%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(0.8)' }, + }, + }, + }, + }, plugins: [], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71d422cf..8bf19f07b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: apps/nestjs-backend: dependencies: + '@ai-sdk/openai': + specifier: 0.0.72 + version: 0.0.72(zod@3.22.4) '@aws-sdk/client-s3': specifier: 3.609.0 version: 3.609.0 @@ -162,6 +165,9 @@ importers: '@types/papaparse': specifier: 5.3.14 version: 5.3.14 + ai: + specifier: 3.4.33 + version: 3.4.33(react@18.3.1)(svelte@5.1.12)(vue@3.5.12)(zod@3.22.4) ajv: specifier: 8.12.0 version: 8.12.0 @@ -2028,6 +2034,126 @@ packages: resolution: {integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==} dev: true + /@ai-sdk/openai@0.0.72(zod@3.22.4): + resolution: {integrity: sha512-IKsgxIt6KJGkEHyMp975xW5VPmetwhI8g9H6dDmwvemBB41IRQa78YMNttiJqPcgmrZX2QfErOICv1gQvZ1gZg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + dependencies: + '@ai-sdk/provider': 0.0.26 + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + zod: 3.22.4 + dev: false + + /@ai-sdk/provider-utils@1.0.22(zod@3.22.4): + resolution: {integrity: sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + '@ai-sdk/provider': 0.0.26 + eventsource-parser: 1.1.2 + nanoid: 3.3.7 + secure-json-parse: 2.7.0 + zod: 3.22.4 + dev: false + + /@ai-sdk/provider@0.0.26: + resolution: {integrity: sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==} + engines: {node: '>=18'} + dependencies: + json-schema: 0.4.0 + dev: false + + /@ai-sdk/react@0.0.70(react@18.3.1)(zod@3.22.4): + resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + zod: + optional: true + dependencies: + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + '@ai-sdk/ui-utils': 0.0.50(zod@3.22.4) + react: 18.3.1 + swr: 2.2.5(react@18.3.1) + throttleit: 2.1.0 + zod: 3.22.4 + dev: false + + /@ai-sdk/solid@0.0.54(zod@3.22.4): + resolution: {integrity: sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==} + engines: {node: '>=18'} + peerDependencies: + solid-js: ^1.7.7 + peerDependenciesMeta: + solid-js: + optional: true + dependencies: + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + '@ai-sdk/ui-utils': 0.0.50(zod@3.22.4) + transitivePeerDependencies: + - zod + dev: false + + /@ai-sdk/svelte@0.0.57(svelte@5.1.12)(zod@3.22.4): + resolution: {integrity: sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==} + engines: {node: '>=18'} + peerDependencies: + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + dependencies: + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + '@ai-sdk/ui-utils': 0.0.50(zod@3.22.4) + sswr: 2.1.0(svelte@5.1.12) + svelte: 5.1.12 + transitivePeerDependencies: + - zod + dev: false + + /@ai-sdk/ui-utils@0.0.50(zod@3.22.4): + resolution: {integrity: sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + '@ai-sdk/provider': 0.0.26 + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + json-schema: 0.4.0 + secure-json-parse: 2.7.0 + zod: 3.22.4 + zod-to-json-schema: 3.23.5(zod@3.22.4) + dev: false + + /@ai-sdk/vue@0.0.59(vue@3.5.12)(zod@3.22.4): + resolution: {integrity: sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==} + engines: {node: '>=18'} + peerDependencies: + vue: ^3.3.4 + peerDependenciesMeta: + vue: + optional: true + dependencies: + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + '@ai-sdk/ui-utils': 0.0.50(zod@3.22.4) + swrv: 1.0.4(vue@3.5.12) + vue: 3.5.12(typescript@5.4.3) + transitivePeerDependencies: + - zod + dev: false + /@alloc/quick-lru@5.2.0: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -7570,6 +7696,11 @@ packages: engines: {node: '>=8.0.0'} dev: false + /@opentelemetry/api@1.9.0: + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + dev: false + /@opentelemetry/context-async-hooks@1.22.0(@opentelemetry/api@1.8.0): resolution: {integrity: sha512-Nfdxyg8YtWqVWkyrCukkundAjPhUXi93JtVQmqDT1mZRVKqA7e2r7eJCrI+F651XUBMp0hsOJSGiFk3QSpaIJw==} engines: {node: '>=14'} @@ -12097,6 +12228,10 @@ packages: dependencies: '@types/ms': 0.7.34 + /@types/diff-match-patch@1.0.36: + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + dev: false + /@types/doctrine@0.0.3: resolution: {integrity: sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==} dev: true @@ -12146,7 +12281,6 @@ packages: /@types/estree@1.0.6: resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - dev: true /@types/express-serve-static-core@4.17.43: resolution: {integrity: sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==} @@ -14350,7 +14484,6 @@ packages: estree-walker: 2.0.2 source-map-js: 1.2.1 dev: false - optional: true /@vue/compiler-dom@3.5.12: resolution: {integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==} @@ -14359,7 +14492,6 @@ packages: '@vue/compiler-core': 3.5.12 '@vue/shared': 3.5.12 dev: false - optional: true /@vue/compiler-sfc@3.5.12: resolution: {integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==} @@ -14375,7 +14507,6 @@ packages: postcss: 8.4.47 source-map-js: 1.2.1 dev: false - optional: true /@vue/compiler-ssr@3.5.12: resolution: {integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==} @@ -14384,7 +14515,6 @@ packages: '@vue/compiler-dom': 3.5.12 '@vue/shared': 3.5.12 dev: false - optional: true /@vue/reactivity@3.5.12: resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==} @@ -14392,7 +14522,6 @@ packages: dependencies: '@vue/shared': 3.5.12 dev: false - optional: true /@vue/runtime-core@3.5.12: resolution: {integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==} @@ -14401,7 +14530,6 @@ packages: '@vue/reactivity': 3.5.12 '@vue/shared': 3.5.12 dev: false - optional: true /@vue/runtime-dom@3.5.12: resolution: {integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==} @@ -14412,7 +14540,6 @@ packages: '@vue/shared': 3.5.12 csstype: 3.1.3 dev: false - optional: true /@vue/server-renderer@3.5.12(vue@3.5.12): resolution: {integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==} @@ -14424,13 +14551,11 @@ packages: '@vue/shared': 3.5.12 vue: 3.5.12(typescript@5.4.3) dev: false - optional: true /@vue/shared@3.5.12: resolution: {integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==} requiresBuild: true dev: false - optional: true /@webassemblyjs/ast@1.12.1: resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} @@ -14761,6 +14886,14 @@ packages: dependencies: acorn: 8.11.3 + /acorn-typescript@1.4.13(acorn@8.12.1): + resolution: {integrity: sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==} + peerDependencies: + acorn: '>=8.9.0' + dependencies: + acorn: 8.12.1 + dev: false + /acorn-walk@7.2.0: resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} engines: {node: '>=0.4.0'} @@ -14797,7 +14930,6 @@ packages: resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} engines: {node: '>=0.4.0'} hasBin: true - dev: true /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} @@ -14843,6 +14975,48 @@ packages: clean-stack: 2.2.0 indent-string: 4.0.0 + /ai@3.4.33(react@18.3.1)(svelte@5.1.12)(vue@3.5.12)(zod@3.22.4): + resolution: {integrity: sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==} + engines: {node: '>=18'} + peerDependencies: + openai: ^4.42.0 + react: ^18 || ^19 || ^19.0.0-rc + sswr: ^2.1.0 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + zod: ^3.0.0 + peerDependenciesMeta: + openai: + optional: true + react: + optional: true + sswr: + optional: true + svelte: + optional: true + zod: + optional: true + dependencies: + '@ai-sdk/provider': 0.0.26 + '@ai-sdk/provider-utils': 1.0.22(zod@3.22.4) + '@ai-sdk/react': 0.0.70(react@18.3.1)(zod@3.22.4) + '@ai-sdk/solid': 0.0.54(zod@3.22.4) + '@ai-sdk/svelte': 0.0.57(svelte@5.1.12)(zod@3.22.4) + '@ai-sdk/ui-utils': 0.0.50(zod@3.22.4) + '@ai-sdk/vue': 0.0.59(vue@3.5.12)(zod@3.22.4) + '@opentelemetry/api': 1.9.0 + eventsource-parser: 1.1.2 + json-schema: 0.4.0 + jsondiffpatch: 0.6.0 + react: 18.3.1 + secure-json-parse: 2.7.0 + svelte: 5.1.12 + zod: 3.22.4 + zod-to-json-schema: 3.23.5(zod@3.22.4) + transitivePeerDependencies: + - solid-js + - vue + dev: false + /airbnb-js-shims@2.2.1: resolution: {integrity: sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==} dependencies: @@ -15121,6 +15295,11 @@ packages: dependencies: dequal: 2.0.3 + /aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + dev: false + /arr-diff@4.0.0: resolution: {integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==} engines: {node: '>=0.10.0'} @@ -15430,7 +15609,6 @@ packages: /axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - dev: true /babel-loader@8.3.0(@babel/core@7.24.3)(webpack@4.47.0): resolution: {integrity: sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==} @@ -16113,7 +16291,6 @@ packages: /chalk@5.3.0: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: true /character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -17663,6 +17840,10 @@ packages: /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + /diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dev: false + /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -19102,6 +19283,10 @@ packages: transitivePeerDependencies: - supports-color + /esm-env@1.1.4: + resolution: {integrity: sha512-oO82nKPHKkzIj/hbtuDYy/JHqBHFlMIW36SDiPCVsj87ntDLcWN+sJ1erdVryd4NxODacFTsdrIE3b7IamqbOg==} + dev: false + /esm@3.2.25: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} @@ -19133,6 +19318,13 @@ packages: dependencies: estraverse: 5.3.0 + /esrap@1.2.2: + resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + dev: false + /esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -21574,6 +21766,12 @@ packages: dependencies: '@types/estree': 1.0.5 + /is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + dependencies: + '@types/estree': 1.0.6 + dev: false + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -22052,6 +22250,10 @@ packages: /json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: false + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -22078,6 +22280,16 @@ packages: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} dev: true + /jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.3.0 + diff-match-patch: 1.0.5 + dev: false + /jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} optionalDependencies: @@ -22467,6 +22679,10 @@ packages: lie: 3.1.1 dev: false + /locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + dev: false + /locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -29536,6 +29752,15 @@ packages: dev: false optional: true + /sswr@2.1.0(svelte@5.1.12): + resolution: {integrity: sha512-Cqc355SYlTAaUt8iDPaC/4DPPXK925PePLMxyBKuWd5kKc5mwsG3nT9+Mq2tyguL5s7b4Jg+IRMpTRsNTAfpSQ==} + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + dependencies: + svelte: 5.1.12 + swrev: 4.0.0 + dev: false + /stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -30020,6 +30245,25 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /svelte@5.1.12: + resolution: {integrity: sha512-U9BwbSybb9QAKAHg4hl61hVBk97U2QjUKmZa5++QEGoi6Nml6x6cC9KmNT1XObGawToN3DdLpdCs/Z5Yl5IXjQ==} + engines: {node: '>=18'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + acorn: 8.12.1 + acorn-typescript: 1.4.13(acorn@8.12.1) + aria-query: 5.3.2 + axobject-query: 4.1.0 + esm-env: 1.1.4 + esrap: 1.2.2 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.12 + zimmerframe: 1.1.2 + dev: false + /svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} dev: true @@ -30056,6 +30300,28 @@ packages: resolution: {integrity: sha512-jQG0cRgJNMZ7aCoiFofnoojeSaa/+KgWaDlfgs8QN+BXoGMpxeMVY5OEnjq4OlNvF3yjftO8c9GRAgcHlO+u7A==} dev: false + /swr@2.2.5(react@18.3.1): + resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.3.1 + use-sync-external-store: 1.2.0(react@18.3.1) + dev: false + + /swrev@4.0.0: + resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==} + dev: false + + /swrv@1.0.4(vue@3.5.12): + resolution: {integrity: sha512-zjEkcP8Ywmj+xOJW3lIT65ciY/4AL4e/Or7Gj0MzU3zBJNMdJiT8geVZhINavnlHRMMCcJLHhraLTAiDOTmQ9g==} + peerDependencies: + vue: '>=3.2.26 < 4' + dependencies: + vue: 3.5.12(typescript@5.4.3) + dev: false + /symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -30433,6 +30699,11 @@ packages: engines: {node: '>=10'} dev: false + /throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + dev: false + /through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: @@ -31720,7 +31991,6 @@ packages: '@vue/shared': 3.5.12 typescript: 5.4.3 dev: false - optional: true /w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -32460,6 +32730,10 @@ packages: engines: {node: '>=12.20'} dev: true + /zimmerframe@1.1.2: + resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + dev: false + /zod-i18n-map@2.27.0(i18next@23.10.1)(zod@3.22.4): resolution: {integrity: sha512-ORu9XpiVh3WDiEUs5Cr9siGgnpeODoBsTIgSD8sQCH9B//f9KowlzqHUEdPYb3vFonaSH8yPvPCOFM4niwp3Sg==} peerDependencies: @@ -32470,6 +32744,14 @@ packages: zod: 3.22.4 dev: false + /zod-to-json-schema@3.23.5(zod@3.22.4): + resolution: {integrity: sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==} + peerDependencies: + zod: ^3.23.3 + dependencies: + zod: 3.22.4 + dev: false + /zod-validation-error@3.0.3(zod@3.22.4): resolution: {integrity: sha512-cETTrcMq3Ze58vhdR0zD37uJm/694I6mAxcf/ei5bl89cC++fBNxrC2z8lkFze/8hVMPwrbtrwXHR2LB50fpHw==} engines: {node: '>=18.0.0'} From 7ac9cc89174bc39d39353eb28b0350f8bbc05a4a Mon Sep 17 00:00:00 2001 From: mayneyao Date: Fri, 8 Nov 2024 15:11:12 +0800 Subject: [PATCH 3/8] chore: fix lint --- apps/nestjs-backend/src/app.module.ts | 2 +- apps/nestjs-backend/src/features/ai/ai.module.ts | 2 +- apps/nestjs-backend/src/features/ai/ai.service.ts | 5 ++--- .../sdk/src/components/editor/formula/Editor.tsx | 6 +++--- .../src/components/editor/formula/extensions/ai.ts | 2 +- packages/sdk/src/hooks/use-ai.ts | 13 +++++++++---- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index 5c7402241..1e4c9cbe1 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -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'; @@ -35,7 +36,6 @@ import { GlobalModule } from './global/global.module'; import { InitBootstrapProvider } from './global/init-bootstrap.provider'; import { LoggerModule } from './logger/logger.module'; import { WsModule } from './ws/ws.module'; -import { AiModule } from './features/ai/ai.module'; export const appModules = { imports: [ diff --git a/apps/nestjs-backend/src/features/ai/ai.module.ts b/apps/nestjs-backend/src/features/ai/ai.module.ts index ab10fa97a..746462ee1 100644 --- a/apps/nestjs-backend/src/features/ai/ai.module.ts +++ b/apps/nestjs-backend/src/features/ai/ai.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { AiController } from './ai.controller'; import { ConfigModule } from '@nestjs/config'; +import { AiController } from './ai.controller'; import { AiService } from './ai.service'; @Module({ diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts index ce936ca65..4d4342e88 100644 --- a/apps/nestjs-backend/src/features/ai/ai.service.ts +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -1,6 +1,6 @@ +import { createOpenAI } from '@ai-sdk/openai'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { createOpenAI } from '@ai-sdk/openai'; import { streamText } from 'ai'; @Injectable() @@ -14,10 +14,9 @@ export class AiService { baseURL: openAIBaseUrl, apiKey: openaiApiKey, }); - const result = await streamText({ + return await streamText({ model: openai('gpt-4o-mini'), prompt: prompt, }); - return result; } } diff --git a/packages/sdk/src/components/editor/formula/Editor.tsx b/packages/sdk/src/components/editor/formula/Editor.tsx index d130f7648..0595da6bb 100644 --- a/packages/sdk/src/components/editor/formula/Editor.tsx +++ b/packages/sdk/src/components/editor/formula/Editor.tsx @@ -14,7 +14,9 @@ import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from '../../../context/app/i18n'; import { useFieldStaticGetter, useFields } from '../../../hooks'; +import { useAIStream } from '../../../hooks/use-ai'; import { FormulaField } from '../../../model'; +import { MagicAI } from '../../comment/comment-editor/plate-ui/icons'; import type { ICodeEditorRef } from './components'; import { CodeEditor, FunctionGuide, FunctionHelper } from './components'; import { @@ -33,8 +35,6 @@ import type { } from './interface'; import { SuggestionItemType } from './interface'; import { FormulaNodePathVisitor } from './visitor'; -import { useAIStream } from '../../../hooks/use-ai'; -import { MagicAI } from '../../comment/comment-editor/plate-ui/icons'; interface IFormulaEditorProps { expression?: string; @@ -369,7 +369,7 @@ export const FormulaEditor: FC = (props) => {
-
+
) => { const context = fields.map((field) => `${field.id}: ${field.name}`).join('\n'); diff --git a/packages/sdk/src/hooks/use-ai.ts b/packages/sdk/src/hooks/use-ai.ts index 7734f52eb..8b600811a 100644 --- a/packages/sdk/src/hooks/use-ai.ts +++ b/packages/sdk/src/hooks/use-ai.ts @@ -1,6 +1,6 @@ import { useCallback, useState, useRef } from 'react'; -const API_ENDPOINT = '/api/ai'; +const aiApiEndpoint = '/api/ai'; interface IUseAIStreamOptions { timeout?: number; // unit: ms @@ -22,9 +22,10 @@ export const useAIStream = (options?: IUseAIStreamOptions) => { const timeoutId = setTimeout(() => controllerRef.current?.abort(), timeout); try { - const result = await fetch(API_ENDPOINT, { + const result = await fetch(aiApiEndpoint, { method: 'POST', headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention 'Content-Type': 'application/json', }, body: JSON.stringify({ prompt }), @@ -38,9 +39,13 @@ export const useAIStream = (options?: IUseAIStreamOptions) => { const reader = result.body?.getReader(); if (!reader) throw new Error('No reader available'); - while (true) { + let reading = true; + while (reading) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + reading = false; + break; + } const chunk = new TextDecoder().decode(value); setText((prev) => prev + chunk); From 9714210080af32ed881266fca3ed0df5c9281121 Mon Sep 17 00:00:00 2001 From: mayneyao Date: Mon, 11 Nov 2024 15:59:51 +0800 Subject: [PATCH 4/8] feat(ai): store ai config in database --- .../features/setting/setting.controller.ts | 37 +++++++++++++++++-- .../src/features/setting/setting.service.ts | 10 ++++- .../prisma/postgres/schema.prisma | 1 + .../prisma/sqlite/schema.prisma | 1 + .../db-main-prisma/prisma/template.prisma | 1 + packages/openapi/src/admin/setting/get.ts | 2 + packages/openapi/src/admin/setting/update.ts | 20 ++++++++++ 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/nestjs-backend/src/features/setting/setting.controller.ts b/apps/nestjs-backend/src/features/setting/setting.controller.ts index fa9d49351..84db30c49 100644 --- a/apps/nestjs-backend/src/features/setting/setting.controller.ts +++ b/apps/nestjs-backend/src/features/setting/setting.controller.ts @@ -1,16 +1,19 @@ 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'; import { SettingService } from './setting.service'; +import { Public } from '../auth/decorators/public.decorator'; @Controller('api/admin/setting') 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 { return await this.settingService.getSetting(); @@ -22,6 +25,32 @@ export class SettingController { @Body(new ZodValidationPipe(updateSettingRoSchema)) updateSettingRo: IUpdateSettingRo ): Promise { - 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 & { + aiConfig: { + enable: boolean; + }; + } + > { + const setting = await this.settingService.getSetting(); + const { aiConfig, ...rest } = setting; + return { + ...rest, + aiConfig: { + enable: aiConfig?.enable ?? false, + }, + }; } } diff --git a/apps/nestjs-backend/src/features/setting/setting.service.ts b/apps/nestjs-backend/src/features/setting/setting.service.ts index e439070a4..da0b14634 100644 --- a/apps/nestjs-backend/src/features/setting/setting.service.ts +++ b/apps/nestjs-backend/src/features/setting/setting.service.ts @@ -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'); }); @@ -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, + }, }); } } diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index 30e05d7ae..b58216406 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -331,6 +331,7 @@ model Setting { disallowSignUp Boolean? @map("disallow_sign_up") disallowSpaceCreation Boolean? @map("disallow_space_creation") disallowSpaceInvitation Boolean? @map("disallow_space_invitation") + aiConfig String? @map("ai_config") @@map("setting") } diff --git a/packages/db-main-prisma/prisma/sqlite/schema.prisma b/packages/db-main-prisma/prisma/sqlite/schema.prisma index b4eb76e1f..65d613a79 100644 --- a/packages/db-main-prisma/prisma/sqlite/schema.prisma +++ b/packages/db-main-prisma/prisma/sqlite/schema.prisma @@ -331,6 +331,7 @@ model Setting { disallowSignUp Boolean? @map("disallow_sign_up") disallowSpaceCreation Boolean? @map("disallow_space_creation") disallowSpaceInvitation Boolean? @map("disallow_space_invitation") + aiConfig String? @map("ai_config") @@map("setting") } diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index a83b28f4d..137654f10 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -331,6 +331,7 @@ model Setting { disallowSignUp Boolean? @map("disallow_sign_up") disallowSpaceCreation Boolean? @map("disallow_space_creation") disallowSpaceInvitation Boolean? @map("disallow_space_invitation") + aiConfig String? @map("ai_config") @@map("setting") } diff --git a/packages/openapi/src/admin/setting/get.ts b/packages/openapi/src/admin/setting/get.ts index d4bbfb617..16f0e73fe 100644 --- a/packages/openapi/src/admin/setting/get.ts +++ b/packages/openapi/src/admin/setting/get.ts @@ -2,12 +2,14 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { axios } from '../../axios'; import { registerRoute } from '../../utils'; +import { aiConfigSchema } from './update'; export const settingVoSchema = z.object({ instanceId: z.string(), disallowSignUp: z.boolean().nullable(), disallowSpaceCreation: z.boolean().nullable(), disallowSpaceInvitation: z.boolean().nullable(), + aiConfig: aiConfigSchema.nullable(), }); export type ISettingVo = z.infer; diff --git a/packages/openapi/src/admin/setting/update.ts b/packages/openapi/src/admin/setting/update.ts index 2080264ab..b869af40c 100644 --- a/packages/openapi/src/admin/setting/update.ts +++ b/packages/openapi/src/admin/setting/update.ts @@ -3,10 +3,30 @@ import { z } from 'zod'; import { axios } from '../../axios'; import { registerRoute } from '../../utils'; +export const llmProviderSchema = z.object({ + type: z.enum(['openai']).default('openai'), + name: z.string(), + apiKey: z.string().optional(), + baseUrl: z.string().url().optional(), + models: z.string().default(''), +}); + +export type LLMProvider = z.infer; + +export const aiConfigSchema = z.object({ + enable: z.boolean().default(false), + llmProviders: z.array(llmProviderSchema).default([]), + // task preferred model + embeddingModel: z.string().optional(), + translationModel: z.string().optional(), + codingModel: z.string().optional(), +}); + export const updateSettingRoSchema = z.object({ disallowSignUp: z.boolean().optional(), disallowSpaceCreation: z.boolean().optional(), disallowSpaceInvitation: z.boolean().optional(), + aiConfig: aiConfigSchema.optional(), }); export type IUpdateSettingRo = z.infer; From ab98fbcd2dc7350742a80dbd7f789f2429629cf1 Mon Sep 17 00:00:00 2001 From: mayneyao Date: Mon, 11 Nov 2024 16:02:22 +0800 Subject: [PATCH 5/8] feat(ssr): inject public config into SSR props --- apps/nextjs-app/src/lib/server-env.ts | 10 ++++++++++ apps/nextjs-app/src/lib/withEnv.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/apps/nextjs-app/src/lib/server-env.ts b/apps/nextjs-app/src/lib/server-env.ts index c48bd64cf..256915131 100644 --- a/apps/nextjs-app/src/lib/server-env.ts +++ b/apps/nextjs-app/src/lib/server-env.ts @@ -10,6 +10,16 @@ export interface IServerEnv { socialAuthProviders?: string[]; storagePrefix?: string; edition?: string; + + // global settings + globalSettings?: { + disallowSignUp?: boolean; + disallowSpaceCreation?: boolean; + disallowSpaceInvitation?: boolean; + aiConfig?: { + enable: boolean; + }; + }; } export const EnvContext = React.createContext({}); diff --git a/apps/nextjs-app/src/lib/withEnv.ts b/apps/nextjs-app/src/lib/withEnv.ts index c25287943..142871520 100644 --- a/apps/nextjs-app/src/lib/withEnv.ts +++ b/apps/nextjs-app/src/lib/withEnv.ts @@ -15,12 +15,27 @@ type GetServerSideProps< D extends PreviewData = PreviewData, > = (context: GetServerSidePropsContext) => Promise>; +async function fetchPublicConfig(context?: GetServerSidePropsContext) { + try { + const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'; + const host = context?.req?.headers?.host || 'localhost:3000'; + const url = `${protocol}://${host}/api/admin/setting/public`; + console.log('fetchPublicConfig', url); + const response = await fetch(url); + return await response.json(); + } catch (error) { + console.error('Failed to fetch public config:', error); + return {}; + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function withEnv

( handler: GetServerSideProps ): NextGetServerSideProps

{ return async (context: GetServerSidePropsContext) => { const { driver } = parseDsn(process.env.PRISMA_DATABASE_URL as string); + const publicConfig = await fetchPublicConfig(context); const env = omitBy( { driver, @@ -31,6 +46,7 @@ export default function withEnv

( sentryDsn: process.env.SENTRY_DSN, socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(','), storagePrefix: process.env.STORAGE_PREFIX, + globalSettings: publicConfig, }, isUndefined ); From 9c505bfa7c1a122a632d9513e8d015cc27ebb0fb Mon Sep 17 00:00:00 2001 From: mayneyao Date: Mon, 11 Nov 2024 16:05:56 +0800 Subject: [PATCH 6/8] feat(ai): global ai config --- .../app/blocks/admin/setting/SettingPage.tsx | 82 +++--- .../setting/components/ai-config/ai-form.tsx | 196 ++++++++++++++ .../components/ai-config/ai-model-select.tsx | 80 ++++++ .../setting/components/ai-config/hooks.ts | 5 + .../ai-config/llm-provider-manage.tsx | 61 +++++ .../ai-config/new-llm-provider-form.tsx | 245 ++++++++++++++++++ .../common-i18n/src/locales/en/common.json | 33 ++- 7 files changed, 667 insertions(+), 35 deletions(-) create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-form.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/hooks.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/llm-provider-manage.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/new-llm-provider-form.tsx diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx index 4d9427fc7..ccdff5b4e 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx @@ -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; @@ -26,7 +27,7 @@ export const SettingPage = (props: ISettingPageProps) => { }, }); - const onCheckedChange = (key: string, value: boolean) => { + const onValueChange = (key: string, value: unknown) => { mutateUpdateSetting({ [key]: value }); }; @@ -41,48 +42,61 @@ export const SettingPage = (props: ISettingPageProps) => {

{t('admin.setting.description')}
-
-
-
- -
- {t('admin.setting.allowSignUpDescription')} + {/* General Settings Section */} +
+

{t('admin.setting.generalSettings')}

+
+
+
+ +
+ {t('admin.setting.allowSignUpDescription')} +
+ onValueChange('disallowSignUp', !checked)} + />
- onCheckedChange('disallowSignUp', !checked)} - /> -
-
-
- -
- {t('admin.setting.allowSpaceInvitationDescription')} +
+
+ +
+ {t('admin.setting.allowSpaceInvitationDescription')} +
+ onValueChange('disallowSpaceInvitation', !checked)} + />
- onCheckedChange('disallowSpaceInvitation', !checked)} - /> -
-
-
- -
- {t('admin.setting.allowSpaceCreationDescription')} +
+
+ +
+ {t('admin.setting.allowSpaceCreationDescription')} +
+ onValueChange('disallowSpaceCreation', !checked)} + />
- onCheckedChange('disallowSpaceCreation', !checked)} - />
+ {/* AI Configuration Section */} +
+

{t('admin.setting.aiSettings')}

+ onValueChange('aiConfig', value)} + /> +
+

{t('settings.setting.version')}: {process.env.NEXT_PUBLIC_BUILD_VERSION} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-form.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-form.tsx new file mode 100644 index 000000000..20d2e0a98 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-form.tsx @@ -0,0 +1,196 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { LLMProvider } from '@teable/openapi/src/admin/setting'; +import { aiConfigSchema } from '@teable/openapi/src/admin/setting'; +import type { ISettingVo } from '@teable/openapi/src/admin/setting/get'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Switch, + toast, +} from '@teable/ui-lib/shadcn'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { AIModelSelect } from './ai-model-select'; +import { LLMProviderManage } from './llm-provider-manage'; + +export function AIConfigForm({ + aiConfig, + setAiConfig, +}: { + aiConfig: ISettingVo['aiConfig']; + setAiConfig: (data: NonNullable) => void; +}) { + const defaultValues = useMemo( + () => + aiConfig ?? { + enable: false, + llmProviders: [], + }, + [aiConfig] + ); + + const form = useForm>({ + resolver: zodResolver(aiConfigSchema), + defaultValues: defaultValues, + }); + const models = (form.watch('llmProviders') ?? []).map((provider) => + provider.models.split(',').map((model) => model.trim() + '@' + provider.name) + ); + const { reset } = form; + const { t } = useTranslation(); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + function onSubmit(data: NonNullable) { + console.log(data); + setAiConfig(data); + // data.token = "sk-**********" + toast({ + title: t('admin.setting.ai.configUpdated'), + }); + } + + function updateProviders(providers: LLMProvider[]) { + form.setValue('llmProviders', providers); + form.trigger('llmProviders'); + onSubmit(form.getValues()); + } + + return ( +

+ + ( + +
+ {t('admin.setting.ai.enable')} + {t('admin.setting.ai.enableDescription')} +
+ + { + field.onChange(checked); + onSubmit(form.getValues()); + }} + /> + +
+ )} + /> + + + {t('admin.setting.ai.provider')} + {t('admin.setting.ai.providerDescription')} + + + ( + + + + + + + )} + /> + + + + + {t('admin.setting.ai.modelPreferences')} + {t('admin.setting.ai.modelPreferencesDescription')} + + + ( + +
+ + {t('admin.setting.ai.translationModel')} + +
+ + { + field.onChange(value); + onSubmit(form.getValues()); + }} + options={models.flat()} + /> + + {/* */} +
+
+ + {t('admin.setting.ai.translationModelDescription')} + + +
+ )} + /> + ( + +
+ {t('admin.setting.ai.codingModel')} +
+ + { + field.onChange(value); + onSubmit(form.getValues()); + }} + options={models.flat()} + /> + + {/* */} +
+
+ {t('admin.setting.ai.codingModelDescription')} + +
+ )} + /> +
+
+ + + ); +} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select.tsx new file mode 100644 index 000000000..f3a737556 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Button } from '@teable/ui-lib'; +import { + cn, + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + ScrollArea, +} from '@teable/ui-lib/shadcn'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import * as React from 'react'; + +export function AIModelSelect({ + value = '', + onValueChange: setValue, + size = 'default', + className, + options = [], +}: { + onValueChange: (value: string) => void; + value: string; + size?: 'xs' | 'sm' | 'lg' | 'default' | null | undefined; + className?: string; + options?: string[]; +}) { + const [open, setOpen] = React.useState(false); + const currentModel = options.find((model) => model.toLowerCase() === value.toLowerCase()); + return ( + + + + + + + + No model found. + +
+ + {options.map((model) => ( + { + setValue(model.toLowerCase() === value.toLowerCase() ? '' : model); + setOpen(false); + }} + > + +

{model}

{' '} +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/hooks.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/hooks.ts new file mode 100644 index 000000000..f0bf79e38 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/hooks.ts @@ -0,0 +1,5 @@ +export enum TaskType { + Embedding = 'Embedding', + Translation = 'Translation', + Coding = 'Coding', +} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/llm-provider-manage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/llm-provider-manage.tsx new file mode 100644 index 000000000..10c3118f8 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/llm-provider-manage.tsx @@ -0,0 +1,61 @@ +import type { LLMProvider } from '@teable/openapi/src/admin/setting'; +import { Button } from '@teable/ui-lib/shadcn'; +import { SlidersHorizontalIcon, XIcon } from 'lucide-react'; + +import { NewLLMProviderForm, UpdateLLMProviderForm } from './new-llm-provider-form'; + +interface ILLMProviderManageProps { + value: LLMProvider[]; + onChange: (value: LLMProvider[]) => void; +} + +export const LLMProviderManage = ({ value, onChange }: ILLMProviderManageProps) => { + const handleAdd = (data: LLMProvider) => { + const newData = [...value, data]; + onChange(newData); + }; + + const handleUpdate = (index: number) => (data: LLMProvider) => { + const newData = value.map((provider, i) => (i === index ? data : provider)); + onChange(newData); + }; + const handleRemove = (index: number) => { + const newData = value.filter((_, i) => i !== index); + onChange(newData); + }; + if (value.length === 0) { + return ; + } + return ( +
+
+ {value.map((provider, index) => ( +
+
+ {provider.name} - {provider.type} +
+
+ + + + +
+
+ ))} + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/new-llm-provider-form.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/new-llm-provider-form.tsx new file mode 100644 index 000000000..8a587211f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/new-llm-provider-form.tsx @@ -0,0 +1,245 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { LLMProvider } from '@teable/openapi/src/admin/setting'; +import { llmProviderSchema } from '@teable/openapi/src/admin/setting'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@teable/ui-lib/shadcn'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +interface ILLMProviderManageProps { + onAdd: (data: LLMProvider) => void; +} + +export const UpdateLLMProviderForm = ({ + value, + onChange, + children, +}: LLMProviderFormProps & { + children?: React.ReactNode; +}) => { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const handleChange = (data: LLMProvider) => { + onChange?.(data); + setOpen(false); + }; + return ( + + {children} + + + {t('admin.setting.ai.updateLLMProvider')} + + + + + ); +}; + +export const NewLLMProviderForm = ({ onAdd }: ILLMProviderManageProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const handleAdd = (data: LLMProvider) => { + onAdd(data); + setOpen(false); + }; + return ( + + + + + + + {t('admin.setting.ai.addProvider')} + {t('admin.setting.ai.addProviderDescription')} + + + + + ); +}; + +interface LLMProviderFormProps { + value?: LLMProvider; + onChange?: (value: LLMProvider) => void; + onAdd?: (data: LLMProvider) => void; +} + +export const LLMProviderForm = ({ onAdd, value, onChange }: LLMProviderFormProps) => { + const { t } = useTranslation(); + const form = useForm({ + resolver: zodResolver(llmProviderSchema), + defaultValues: value || { + name: '', + type: 'openai', + apiKey: '', + baseUrl: '', + models: '', + }, + }); + + function onSubmit(data: LLMProvider) { + onChange ? onChange(data) : onAdd?.(data); + } + + function handleSubmit() { + const data = form.getValues(); + onSubmit(data); + } + + // async function getModelList(e: React.MouseEvent) { + // e.preventDefault(); + // const baseUrl = form.getValues('baseUrl'); + // if (!baseUrl) { + // toast.toast({ + // title: t('common.error'), + // description: t('admin.setting.ai.baseUrlRequired'), + // }); + // return; + // } + // const openai = new OpenAI({ + // apiKey: form.getValues('apiKey'), + // baseURL: baseUrl, + // dangerouslyAllowBrowser: true, + // }); + // try { + // const resp = await openai.models.list(); + // const modelIds = resp.data.map((model) => model.id).join(', '); + // form.setValue('models', modelIds); + // // focus on models input + // form.setFocus('models'); + // } catch (error) { + // console.error(error); + // toast.toast({ + // title: t('common.error'), + // description: t('admin.setting.ai.fetchModelListError'), + // }); + // } + // } + + const mode = onChange ? 'Update' : 'Add'; + + return ( +
+ + ( + + {t('admin.setting.ai.name')} + {t('admin.setting.ai.nameDescription')} + + + + + + )} + /> + ( + + {t('admin.setting.ai.providerType')} + + + + + + )} + /> + { + // only show the following fields if the type is openai + form.watch('type') === 'openai' && ( + ( + + {t('admin.setting.ai.baseUrl')} + + + + {t('admin.setting.ai.baseUrlDescription')} + + + )} + /> + ) + } + ( + + {t('admin.setting.ai.apiKey')} + + + + {t('admin.setting.ai.apiKeyDescription')} + + + )} + /> + ( + + {/*
+ {t('admin.setting.ai.models')} + {form.watch('type') === 'openai' && ( + + )} +
*/} + + + + {t('admin.setting.ai.modelsDescription')} + +
+ )} + /> + + + + ); +}; diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 92edb8b4c..f5060b9ad 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -235,7 +235,38 @@ "allowSpaceInvitation": "Allow sending space invitations", "allowSpaceInvitationDescription": "Disabling this option will prevent users other than administrators from inviting others to join spaces. Enabling this option will allow directly invited users to create an account even if new account registration is disabled.", "allowSpaceCreation": "Allow everyone to create new spaces", - "allowSpaceCreationDescription": "Disabling this option will prevent users other than administrators from creating new spaces." + "allowSpaceCreationDescription": "Disabling this option will prevent users other than administrators from creating new spaces.", + "generalSettings": "General settings", + "aiSettings": "AI settings", + "ai": { + "name": "Name", + "nameDescription": "The name of the LLM provider", + "enable": "Enable AI", + "enableDescription": "Enable AI for current instance, all users will be able to use AI features", + "updateLLMProvider": "Update LLM provider", + "addProvider": "Add LLM provider", + "addProviderDescription": "Add a new LLM provider to the list", + "providerType": "Provider type", + "baseUrl": "Base URL", + "apiKey": "API key", + "baseUrlDescription": "The base URL of the LLM provider", + "apiKeyDescription": "The API key of the LLM provider", + "models": "Models", + "modelsDescription": "The models supported by the LLM provider", + "baseUrlRequired": "Base URL is required", + "fetchModelListError": "Failed to fetch model list", + "provider": "LLM provider", + "providerDescription": "The LLM provider to use", + "modelPreferences": "Model preferences", + "modelPreferencesDescription": "The model preferences for the LLM provider", + "embeddingModel": "Embedding model", + "embeddingModelDescription": "The embedding model to use", + "translationModel": "Translation model", + "translationModelDescription": "The translation model to use", + "codingModel": "Coding model", + "codingModelDescription": "The coding model to use", + "configUpdated": "AI config updated" + } } }, "notification": { From 65a1cff2fce2ef8995a21bc875888ba23401349e Mon Sep 17 00:00:00 2001 From: mayneyao Date: Mon, 11 Nov 2024 16:07:56 +0800 Subject: [PATCH 7/8] fix(ai): enable AI features based on configuration --- .../field-setting/options/FormulaOptions.tsx | 8 +++- .../src/features/app/hooks/useAI.ts | 8 ++++ .../src/components/editor/formula/Editor.tsx | 40 ++++++++++++------- 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 apps/nextjs-app/src/features/app/hooks/useAI.ts diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx index 1b090a52c..97c50398b 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx @@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn'; import { isEmpty, isEqual, keyBy } from 'lodash'; import { useTranslation } from 'next-i18next'; import { useCallback, useMemo, useState } from 'react'; +import { useAI } from '@/features/app/hooks/useAI'; import { TimeZoneFormatting } from '../formatting/TimeZoneFormatting'; import { UnionFormatting } from '../formatting/UnionFormatting'; import { UnionShowAs } from '../show-as/UnionShowAs'; @@ -34,6 +35,7 @@ export const FormulaOptionsInner = (props: { onChange?: (options: Partial) => void; }) => { const { options = {}, onChange } = props; + const { enable: enableAI } = useAI(); const { expression, formatting, showAs } = options; const fields = useFields({ withHidden: true, withDenied: true }); const [visible, setVisible] = useState(false); @@ -119,7 +121,11 @@ export const FormulaOptionsInner = (props: { closeable className="flex size-auto max-w-full overflow-hidden rounded-sm p-0 outline-0 md:w-auto" > - +
diff --git a/apps/nextjs-app/src/features/app/hooks/useAI.ts b/apps/nextjs-app/src/features/app/hooks/useAI.ts new file mode 100644 index 000000000..e9b1495ac --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useAI.ts @@ -0,0 +1,8 @@ +import { useEnv } from './useEnv'; + +export function useAI() { + const env = useEnv(); + return { + enable: env.globalSettings?.aiConfig?.enable, + }; +} diff --git a/packages/sdk/src/components/editor/formula/Editor.tsx b/packages/sdk/src/components/editor/formula/Editor.tsx index 0595da6bb..17647eae9 100644 --- a/packages/sdk/src/components/editor/formula/Editor.tsx +++ b/packages/sdk/src/components/editor/formula/Editor.tsx @@ -35,14 +35,16 @@ import type { } from './interface'; import { SuggestionItemType } from './interface'; import { FormulaNodePathVisitor } from './visitor'; +import { AlertCircle } from 'lucide-react'; interface IFormulaEditorProps { expression?: string; onConfirm?: (expression: string) => void; + enableAI?: boolean; } export const FormulaEditor: FC = (props) => { - const { expression, onConfirm } = props; + const { expression, onConfirm, enableAI } = props; const fields = useFields({ withHidden: true, withDenied: true }); const { resolvedTheme } = useTheme(); const { t } = useTranslation(); @@ -66,7 +68,7 @@ export const FormulaEditor: FC = (props) => { [formulaFunctionsMap] ); const functionsDisplayMap = useFunctionsDisplayMap(); - const { generateAIResponse, text, loading } = useAIStream(); + const { generateAIResponse, text, loading, error } = useAIStream(); useEffect(() => { if (text) { @@ -355,17 +357,27 @@ export const FormulaEditor: FC = (props) => {

{t('editor.formula.title')}

- + {enableAI && ( +
+ + {error && ( +
+ + {error} +
+ )} +
+ )}
@@ -376,7 +388,7 @@ export const FormulaEditor: FC = (props) => { extensions={extensions} onChange={onValueChange} onSelectionChange={onSelectionChange} - placeholder={t('editor.formula.placeholder')} + placeholder={enableAI ? t('editor.formula.placeholder') : undefined} />
{errMsg}
From 00694f9e0776efd0c0679684eca26c3b6e08422c Mon Sep 17 00:00:00 2001 From: mayneyao Date: Mon, 25 Nov 2024 18:24:18 +0800 Subject: [PATCH 8/8] fix(ai): get model config from settings --- .../src/features/ai/ai.controller.ts | 6 +-- .../src/features/ai/ai.service.ts | 50 ++++++++++++++++--- .../src/features/app/hooks/useAI.ts | 11 ++-- apps/nextjs-app/src/lib/withEnv.ts | 16 ------ packages/openapi/src/admin/setting/get.ts | 31 ++++++++++++ 5 files changed, 84 insertions(+), 30 deletions(-) diff --git a/apps/nestjs-backend/src/features/ai/ai.controller.ts b/apps/nestjs-backend/src/features/ai/ai.controller.ts index 1d11c157e..12c6fc236 100644 --- a/apps/nestjs-backend/src/features/ai/ai.controller.ts +++ b/apps/nestjs-backend/src/features/ai/ai.controller.ts @@ -1,13 +1,13 @@ import { Body, Controller, Post, Res } from '@nestjs/common'; import { Response } from 'express'; -import { AiService } from './ai.service'; +import { AiService, Task } from './ai.service'; @Controller('api/ai') export class AiController { constructor(private readonly aiService: AiService) {} @Post() - async generate(@Body('prompt') prompt: string, @Res() res: Response) { - const result = await this.aiService.generate(prompt); + async generate(@Body('prompt') prompt: string, @Body('task') task: Task, @Res() res: Response) { + const result = await this.aiService.generate(prompt, task); result.pipeTextStreamToResponse(res); } } diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts index 4d4342e88..784ea1b47 100644 --- a/apps/nestjs-backend/src/features/ai/ai.service.ts +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -1,21 +1,55 @@ import { createOpenAI } from '@ai-sdk/openai'; import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { SettingService } from '../setting/setting.service'; import { streamText } from 'ai'; +export enum Task { + Translation = 'translation', + Coding = 'coding', +} + @Injectable() export class AiService { - constructor(private readonly configService: ConfigService) {} + constructor(private readonly settingService: SettingService) {} + + 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'); + } - async generate(prompt: string) { - const openAIBaseUrl = this.configService.get('OPENAI_BASE_URL'); - const openaiApiKey = this.configService.get('OPENAI_API_KEY'); const openai = createOpenAI({ - baseURL: openAIBaseUrl, - apiKey: openaiApiKey, + baseURL: baseUrl, + apiKey, }); + return await streamText({ - model: openai('gpt-4o-mini'), + model: openai(model), prompt: prompt, }); } diff --git a/apps/nextjs-app/src/features/app/hooks/useAI.ts b/apps/nextjs-app/src/features/app/hooks/useAI.ts index e9b1495ac..12d40baad 100644 --- a/apps/nextjs-app/src/features/app/hooks/useAI.ts +++ b/apps/nextjs-app/src/features/app/hooks/useAI.ts @@ -1,8 +1,13 @@ -import { useEnv } from './useEnv'; +import { useQuery } from '@tanstack/react-query'; +import { getPublicSetting } from '@teable/openapi'; export function useAI() { - const env = useEnv(); + const { data } = useQuery({ + queryKey: ['public-ai-config'], + queryFn: () => getPublicSetting().then(({ data }) => data), + }); + return { - enable: env.globalSettings?.aiConfig?.enable, + enable: data?.aiConfig?.enable ?? false, }; } diff --git a/apps/nextjs-app/src/lib/withEnv.ts b/apps/nextjs-app/src/lib/withEnv.ts index 142871520..c25287943 100644 --- a/apps/nextjs-app/src/lib/withEnv.ts +++ b/apps/nextjs-app/src/lib/withEnv.ts @@ -15,27 +15,12 @@ type GetServerSideProps< D extends PreviewData = PreviewData, > = (context: GetServerSidePropsContext) => Promise>; -async function fetchPublicConfig(context?: GetServerSidePropsContext) { - try { - const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'; - const host = context?.req?.headers?.host || 'localhost:3000'; - const url = `${protocol}://${host}/api/admin/setting/public`; - console.log('fetchPublicConfig', url); - const response = await fetch(url); - return await response.json(); - } catch (error) { - console.error('Failed to fetch public config:', error); - return {}; - } -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function withEnv

( handler: GetServerSideProps ): NextGetServerSideProps

{ return async (context: GetServerSidePropsContext) => { const { driver } = parseDsn(process.env.PRISMA_DATABASE_URL as string); - const publicConfig = await fetchPublicConfig(context); const env = omitBy( { driver, @@ -46,7 +31,6 @@ export default function withEnv

( sentryDsn: process.env.SENTRY_DSN, socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(','), storagePrefix: process.env.STORAGE_PREFIX, - globalSettings: publicConfig, }, isUndefined ); diff --git a/packages/openapi/src/admin/setting/get.ts b/packages/openapi/src/admin/setting/get.ts index 16f0e73fe..931ba689e 100644 --- a/packages/openapi/src/admin/setting/get.ts +++ b/packages/openapi/src/admin/setting/get.ts @@ -37,3 +37,34 @@ export const GetSettingRoute: RouteConfig = registerRoute({ export const getSetting = async () => { return axios.get(GET_SETTING); }; + +const publicAiConfigSchema = z.object({ + enable: z.boolean(), +}); + +export const publicSettingVoSchema = z.object({ + aiConfig: publicAiConfigSchema.nullable(), +}); +export type IPublicSettingVo = z.infer; + +export const GET_PUBLIC_SETTING = '/admin/setting/public'; +export const GetPublicSettingRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_PUBLIC_SETTING, + description: 'Get the public instance settings', + request: {}, + responses: { + 200: { + description: 'Returns the public instance settings.', + content: { + 'application/json': { + schema: publicSettingVoSchema, + }, + }, + }, + }, +}); + +export const getPublicSetting = async () => { + return axios.get(GET_PUBLIC_SETTING); +};