Feat: Add Claude 3 support (#318)
* 3.0.12 * build * feat: anthropic claude 3 support * fix: add system prompt * fix: type check * fix: package version * fix: update anthropic for dependency bug fix * feat: update build files * feat: update version number --------- Co-authored-by: di-sukharev <dim.sukharev@gmail.com>
This commit is contained in:
+15232
-2126
File diff suppressed because one or more lines are too long
+28569
-2130
File diff suppressed because one or more lines are too long
Binary file not shown.
Generated
+1750
-1286
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencommit",
|
"name": "opencommit",
|
||||||
"version": "3.0.12",
|
"version": "3.0.13",
|
||||||
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"git",
|
"git",
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
"@actions/core": "^1.10.0",
|
"@actions/core": "^1.10.0",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"@actions/github": "^5.1.1",
|
"@actions/github": "^5.1.1",
|
||||||
|
"@anthropic-ai/sdk": "^0.19.2",
|
||||||
"@clack/prompts": "^0.6.1",
|
"@clack/prompts": "^0.6.1",
|
||||||
"@dqbd/tiktoken": "^1.0.2",
|
"@dqbd/tiktoken": "^1.0.2",
|
||||||
"@octokit/webhooks-schemas": "^6.11.0",
|
"@octokit/webhooks-schemas": "^6.11.0",
|
||||||
|
|||||||
+46
-15
@@ -15,6 +15,7 @@ dotenv.config();
|
|||||||
|
|
||||||
export enum CONFIG_KEYS {
|
export enum CONFIG_KEYS {
|
||||||
OCO_OPENAI_API_KEY = 'OCO_OPENAI_API_KEY',
|
OCO_OPENAI_API_KEY = 'OCO_OPENAI_API_KEY',
|
||||||
|
OCO_ANTHROPIC_API_KEY = 'OCO_ANTHROPIC_API_KEY',
|
||||||
OCO_TOKENS_MAX_INPUT = 'OCO_TOKENS_MAX_INPUT',
|
OCO_TOKENS_MAX_INPUT = 'OCO_TOKENS_MAX_INPUT',
|
||||||
OCO_TOKENS_MAX_OUTPUT = 'OCO_TOKENS_MAX_OUTPUT',
|
OCO_TOKENS_MAX_OUTPUT = 'OCO_TOKENS_MAX_OUTPUT',
|
||||||
OCO_OPENAI_BASE_PATH = 'OCO_OPENAI_BASE_PATH',
|
OCO_OPENAI_BASE_PATH = 'OCO_OPENAI_BASE_PATH',
|
||||||
@@ -34,6 +35,31 @@ export enum CONFIG_MODES {
|
|||||||
set = 'set'
|
set = 'set'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MODEL_LIST = {
|
||||||
|
openai: ['gpt-3.5-turbo',
|
||||||
|
'gpt-3.5-turbo-0125',
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-4-turbo',
|
||||||
|
'gpt-4-1106-preview',
|
||||||
|
'gpt-4-turbo-preview',
|
||||||
|
'gpt-4-0125-preview'],
|
||||||
|
|
||||||
|
anthropic: ['claude-3-haiku-20240307',
|
||||||
|
'claude-3-sonnet-20240229',
|
||||||
|
'claude-3-opus-20240229']
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultModel = (provider: string | undefined): string => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'ollama':
|
||||||
|
return '';
|
||||||
|
case 'anthropic':
|
||||||
|
return MODEL_LIST.anthropic[0];
|
||||||
|
default:
|
||||||
|
return MODEL_LIST.openai[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export enum DEFAULT_TOKEN_LIMITS {
|
export enum DEFAULT_TOKEN_LIMITS {
|
||||||
DEFAULT_MAX_TOKENS_INPUT = 4096,
|
DEFAULT_MAX_TOKENS_INPUT = 4096,
|
||||||
DEFAULT_MAX_TOKENS_OUTPUT = 500
|
DEFAULT_MAX_TOKENS_OUTPUT = 500
|
||||||
@@ -57,9 +83,9 @@ export const configValidators = {
|
|||||||
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
|
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
|
||||||
//need api key unless running locally with ollama
|
//need api key unless running locally with ollama
|
||||||
validateConfig(
|
validateConfig(
|
||||||
'API_KEY',
|
'OpenAI API_KEY',
|
||||||
value || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
|
value || config.OCO_ANTHROPIC_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
|
||||||
'You need to provide an API key'
|
'You need to provide an OpenAI/Anthropic API key'
|
||||||
);
|
);
|
||||||
validateConfig(
|
validateConfig(
|
||||||
CONFIG_KEYS.OCO_OPENAI_API_KEY,
|
CONFIG_KEYS.OCO_OPENAI_API_KEY,
|
||||||
@@ -75,6 +101,16 @@ export const configValidators = {
|
|||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY](value: any, config: any = {}) {
|
||||||
|
validateConfig(
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
value || config.OCO_OPENAI_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
|
||||||
|
'You need to provide an OpenAI/Anthropic API key'
|
||||||
|
);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
[CONFIG_KEYS.OCO_DESCRIPTION](value: any) {
|
[CONFIG_KEYS.OCO_DESCRIPTION](value: any) {
|
||||||
validateConfig(
|
validateConfig(
|
||||||
CONFIG_KEYS.OCO_DESCRIPTION,
|
CONFIG_KEYS.OCO_DESCRIPTION,
|
||||||
@@ -154,19 +190,12 @@ export const configValidators = {
|
|||||||
[CONFIG_KEYS.OCO_MODEL](value: any) {
|
[CONFIG_KEYS.OCO_MODEL](value: any) {
|
||||||
validateConfig(
|
validateConfig(
|
||||||
CONFIG_KEYS.OCO_MODEL,
|
CONFIG_KEYS.OCO_MODEL,
|
||||||
[
|
[...MODEL_LIST.openai, ...MODEL_LIST.anthropic].includes(value),
|
||||||
'gpt-3.5-turbo',
|
`${value} is not supported yet, use 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview', 'gpt-4-0125-preview', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229' or 'claude-3-haiku-20240307'`
|
||||||
'gpt-3.5-turbo-0125',
|
|
||||||
'gpt-4',
|
|
||||||
'gpt-4-1106-preview',
|
|
||||||
'gpt-4-0125-preview',
|
|
||||||
'gpt-4-turbo',
|
|
||||||
'gpt-4-turbo-preview'
|
|
||||||
].includes(value),
|
|
||||||
`${value} is not supported yet, use 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-0125-preview' or 'gpt-4-turbo-preview'`
|
|
||||||
);
|
);
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
|
|
||||||
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) {
|
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) {
|
||||||
validateConfig(
|
validateConfig(
|
||||||
CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
||||||
@@ -200,10 +229,11 @@ export const configValidators = {
|
|||||||
[
|
[
|
||||||
'',
|
'',
|
||||||
'openai',
|
'openai',
|
||||||
|
'anthropic',
|
||||||
'ollama',
|
'ollama',
|
||||||
'test'
|
'test'
|
||||||
].includes(value),
|
].includes(value),
|
||||||
`${value} is not supported yet, use 'ollama' or 'openai' (default)`
|
`${value} is not supported yet, use 'ollama' 'anthropic' or 'openai' (default)`
|
||||||
);
|
);
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
@@ -228,6 +258,7 @@ const configPath = pathJoin(homedir(), '.opencommit');
|
|||||||
export const getConfig = (): ConfigType | null => {
|
export const getConfig = (): ConfigType | null => {
|
||||||
const configFromEnv = {
|
const configFromEnv = {
|
||||||
OCO_OPENAI_API_KEY: process.env.OCO_OPENAI_API_KEY,
|
OCO_OPENAI_API_KEY: process.env.OCO_OPENAI_API_KEY,
|
||||||
|
OCO_ANTHROPIC_API_KEY: process.env.OCO_ANTHROPIC_API_KEY,
|
||||||
OCO_TOKENS_MAX_INPUT: process.env.OCO_TOKENS_MAX_INPUT
|
OCO_TOKENS_MAX_INPUT: process.env.OCO_TOKENS_MAX_INPUT
|
||||||
? Number(process.env.OCO_TOKENS_MAX_INPUT)
|
? Number(process.env.OCO_TOKENS_MAX_INPUT)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -237,7 +268,7 @@ export const getConfig = (): ConfigType | null => {
|
|||||||
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
|
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
|
||||||
OCO_DESCRIPTION: process.env.OCO_DESCRIPTION === 'true' ? true : false,
|
OCO_DESCRIPTION: process.env.OCO_DESCRIPTION === 'true' ? true : false,
|
||||||
OCO_EMOJI: process.env.OCO_EMOJI === 'true' ? true : false,
|
OCO_EMOJI: process.env.OCO_EMOJI === 'true' ? true : false,
|
||||||
OCO_MODEL: process.env.OCO_MODEL || 'gpt-3.5-turbo',
|
OCO_MODEL: process.env.OCO_MODEL || getDefaultModel(process.env.OCO_AI_PROVIDER),
|
||||||
OCO_LANGUAGE: process.env.OCO_LANGUAGE || 'en',
|
OCO_LANGUAGE: process.env.OCO_LANGUAGE || 'en',
|
||||||
OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
|
OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
|
||||||
process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER || '$msg',
|
process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER || '$msg',
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import {ChatCompletionRequestMessage} from 'openai'
|
||||||
|
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources';
|
||||||
|
|
||||||
|
import { intro, outro } from '@clack/prompts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONFIG_MODES,
|
||||||
|
DEFAULT_TOKEN_LIMITS,
|
||||||
|
getConfig
|
||||||
|
} from '../commands/config';
|
||||||
|
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||||
|
import { tokenCount } from '../utils/tokenCount';
|
||||||
|
import { AiEngine } from './Engine';
|
||||||
|
import { MODEL_LIST } from '../commands/config';
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
const MAX_TOKENS_OUTPUT =
|
||||||
|
config?.OCO_TOKENS_MAX_OUTPUT ||
|
||||||
|
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
|
||||||
|
const MAX_TOKENS_INPUT =
|
||||||
|
config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
|
||||||
|
|
||||||
|
let provider = config?.OCO_AI_PROVIDER;
|
||||||
|
let apiKey = config?.OCO_ANTHROPIC_API_KEY;
|
||||||
|
const [command, mode] = process.argv.slice(2);
|
||||||
|
if (
|
||||||
|
provider === 'anthropic' &&
|
||||||
|
!apiKey &&
|
||||||
|
command !== 'config' &&
|
||||||
|
mode !== CONFIG_MODES.set
|
||||||
|
) {
|
||||||
|
intro('opencommit');
|
||||||
|
|
||||||
|
outro(
|
||||||
|
'OCO_ANTHROPIC_API_KEY is not set, please run `oco config set OCO_ANTHROPIC_API_KEY=<your token> . If you are using Claude, make sure you add payment details, so API works.`'
|
||||||
|
);
|
||||||
|
outro(
|
||||||
|
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL = config?.OCO_MODEL;
|
||||||
|
if (provider === 'anthropic' &&
|
||||||
|
!MODEL_LIST.anthropic.includes(MODEL) &&
|
||||||
|
command !== 'config' &&
|
||||||
|
mode !== CONFIG_MODES.set) {
|
||||||
|
outro(
|
||||||
|
`${chalk.red('✖')} Unsupported model ${MODEL} for Anthropic. Supported models are: ${MODEL_LIST.anthropic.join(
|
||||||
|
', '
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnthropicAi implements AiEngine {
|
||||||
|
private anthropicAiApiConfiguration = {
|
||||||
|
apiKey: apiKey
|
||||||
|
};
|
||||||
|
private anthropicAI!: Anthropic;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.anthropicAI = new Anthropic(this.anthropicAiApiConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateCommitMessage = async (
|
||||||
|
messages: Array<ChatCompletionRequestMessage>
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
|
||||||
|
const systemMessage = messages.find(msg => msg.role === 'system')?.content as string;
|
||||||
|
const restMessages = messages.filter((msg) => msg.role !== 'system') as MessageParam[];
|
||||||
|
|
||||||
|
const params: MessageCreateParamsNonStreaming = {
|
||||||
|
model: MODEL,
|
||||||
|
system: systemMessage,
|
||||||
|
messages: restMessages,
|
||||||
|
temperature: 0,
|
||||||
|
top_p: 0.1,
|
||||||
|
max_tokens: MAX_TOKENS_OUTPUT
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const REQUEST_TOKENS = messages
|
||||||
|
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
if (REQUEST_TOKENS > MAX_TOKENS_INPUT - MAX_TOKENS_OUTPUT) {
|
||||||
|
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.anthropicAI.messages.create(params);
|
||||||
|
|
||||||
|
const message = data?.content[0].text;
|
||||||
|
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
outro(`${chalk.red('✖')} ${JSON.stringify(params)}`);
|
||||||
|
|
||||||
|
const err = error as Error;
|
||||||
|
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||||
|
error.response?.status === 401
|
||||||
|
) {
|
||||||
|
const anthropicAiError = error.response.data.error;
|
||||||
|
|
||||||
|
if (anthropicAiError?.message) outro(anthropicAiError.message);
|
||||||
|
outro(
|
||||||
|
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const anthropicAi = new AnthropicAi();
|
||||||
+27
-8
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatCompletionRequestMessage,
|
ChatCompletionRequestMessage,
|
||||||
Configuration as OpenAiApiConfiguration,
|
Configuration as OpenAiApiConfiguration,
|
||||||
@@ -17,20 +18,28 @@ import {
|
|||||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||||
import { tokenCount } from '../utils/tokenCount';
|
import { tokenCount } from '../utils/tokenCount';
|
||||||
import { AiEngine } from './Engine';
|
import { AiEngine } from './Engine';
|
||||||
|
import { MODEL_LIST } from '../commands/config';
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
const MAX_TOKENS_OUTPUT = config?.OCO_TOKENS_MAX_OUTPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
|
const MAX_TOKENS_OUTPUT =
|
||||||
const MAX_TOKENS_INPUT = config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
|
config?.OCO_TOKENS_MAX_OUTPUT ||
|
||||||
|
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
|
||||||
|
const MAX_TOKENS_INPUT =
|
||||||
|
config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
|
||||||
let basePath = config?.OCO_OPENAI_BASE_PATH;
|
let basePath = config?.OCO_OPENAI_BASE_PATH;
|
||||||
let apiKey = config?.OCO_OPENAI_API_KEY
|
let apiKey = config?.OCO_OPENAI_API_KEY;
|
||||||
|
|
||||||
const [command, mode] = process.argv.slice(2);
|
const [command, mode] = process.argv.slice(2);
|
||||||
|
|
||||||
const isLocalModel = config?.OCO_AI_PROVIDER == 'ollama' || config?.OCO_AI_PROVIDER == 'test';
|
const provider = config?.OCO_AI_PROVIDER;
|
||||||
|
|
||||||
|
if (
|
||||||
if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalModel) {
|
provider === 'openai' &&
|
||||||
|
!apiKey &&
|
||||||
|
command !== 'config' &&
|
||||||
|
mode !== CONFIG_MODES.set
|
||||||
|
) {
|
||||||
intro('opencommit');
|
intro('opencommit');
|
||||||
|
|
||||||
outro(
|
outro(
|
||||||
@@ -44,6 +53,18 @@ if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MODEL = config?.OCO_MODEL || 'gpt-3.5-turbo';
|
const MODEL = config?.OCO_MODEL || 'gpt-3.5-turbo';
|
||||||
|
if (provider === 'openai' &&
|
||||||
|
!MODEL_LIST.openai.includes(MODEL) &&
|
||||||
|
command !== 'config' &&
|
||||||
|
mode !== CONFIG_MODES.set) {
|
||||||
|
outro(
|
||||||
|
`${chalk.red('✖')} Unsupported model ${MODEL} for OpenAI. Supported models are: ${MODEL_LIST.openai.join(
|
||||||
|
', '
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
class OpenAi implements AiEngine {
|
class OpenAi implements AiEngine {
|
||||||
private openAiApiConfiguration = new OpenAiApiConfiguration({
|
private openAiApiConfiguration = new OpenAiApiConfiguration({
|
||||||
@@ -105,6 +126,4 @@ class OpenAi implements AiEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const api = new OpenAi();
|
export const api = new OpenAi();
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { AiEngine } from '../engine/Engine';
|
|||||||
import { api } from '../engine/openAi';
|
import { api } from '../engine/openAi';
|
||||||
import { getConfig } from '../commands/config';
|
import { getConfig } from '../commands/config';
|
||||||
import { ollamaAi } from '../engine/ollama';
|
import { ollamaAi } from '../engine/ollama';
|
||||||
|
import { anthropicAi } from '../engine/anthropic'
|
||||||
import { testAi } from '../engine/testAi';
|
import { testAi } from '../engine/testAi';
|
||||||
|
|
||||||
export function getEngine(): AiEngine {
|
export function getEngine(): AiEngine {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (config?.OCO_AI_PROVIDER == 'ollama') {
|
if (config?.OCO_AI_PROVIDER == 'ollama') {
|
||||||
return ollamaAi;
|
return ollamaAi;
|
||||||
|
} else if (config?.OCO_AI_PROVIDER == 'anthropic') {
|
||||||
|
return anthropicAi;
|
||||||
} else if (config?.OCO_AI_PROVIDER == 'test') {
|
} else if (config?.OCO_AI_PROVIDER == 'test') {
|
||||||
return testAi;
|
return testAi;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user