283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import { Message } from 'discord.js';
|
|
import { LLMProvider } from './provider';
|
|
import { OpenAI } from 'openai';
|
|
import 'dotenv/config';
|
|
import { serializeMessageHistory } from '../util';
|
|
import { logError, logInfo } from '../../logging';
|
|
import { LLMConfig } from '../commands/types';
|
|
|
|
const USER_PROMPT = `Complete the next message as Hatsune Miku. Return JSON with only the "content" field filled in.
|
|
|
|
Conversation (last line is yours to complete):
|
|
|
|
`;
|
|
|
|
const USER_PROMPT_STREAMING = `Complete the next message as Hatsune Miku. Output ONLY the raw message content (no JSON, no quotes).
|
|
|
|
Conversation (last line is yours to complete):
|
|
|
|
`;
|
|
|
|
export class OpenAIProvider implements LLMProvider {
|
|
private client: OpenAI;
|
|
private model: string;
|
|
|
|
constructor(
|
|
token: string | undefined = process.env.LLM_TOKEN,
|
|
model = 'zai-org/glm-4.7-flash'
|
|
) {
|
|
if (!token) {
|
|
throw new TypeError(
|
|
'LLM token was not passed in, and environment variable LLM_TOKEN was unset!'
|
|
);
|
|
}
|
|
this.client = new OpenAI({
|
|
baseURL: process.env.OPENAI_HOST,
|
|
apiKey: token,
|
|
});
|
|
this.model = model;
|
|
}
|
|
|
|
name() {
|
|
return `OpenAI (${this.model})`;
|
|
}
|
|
|
|
setModel(model: string) {
|
|
this.model = model;
|
|
}
|
|
|
|
async requestLLMResponse(
|
|
history: Message[],
|
|
sysprompt: string,
|
|
params: LLMConfig
|
|
): Promise<string> {
|
|
let messageList = await Promise.all(history.map(serializeMessageHistory));
|
|
messageList = messageList.filter((x) => !!x);
|
|
|
|
if (messageList.length === 0) {
|
|
throw new TypeError('No messages with content provided in history!');
|
|
}
|
|
|
|
// dummy message for last line of prompt
|
|
const lastMsg = messageList[messageList.length - 1];
|
|
|
|
// advance by 5 seconds
|
|
let newDate = new Date(lastMsg!.timestamp);
|
|
newDate.setSeconds(newDate.getSeconds() + 5);
|
|
|
|
let templateMsgTxt = JSON.stringify({
|
|
timestamp: newDate.toUTCString(),
|
|
author: 'Hatsune Miku',
|
|
name: 'Hatsune Miku',
|
|
context: lastMsg!.content,
|
|
content: '...',
|
|
});
|
|
|
|
const messageHistoryTxt =
|
|
messageList.map((msg) => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
|
|
logInfo(`[openai] Requesting response for message history: ${messageHistoryTxt}`);
|
|
|
|
try {
|
|
const response = await this.client.chat.completions.create({
|
|
model: this.model,
|
|
messages: [
|
|
{ role: 'system', content: sysprompt },
|
|
{ role: 'user', content: USER_PROMPT + messageHistoryTxt },
|
|
],
|
|
temperature: params?.temperature || 0.5,
|
|
top_p: params?.top_p || 0.9,
|
|
max_tokens: params?.max_new_tokens || 128,
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'miku_message',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
content: {
|
|
type: 'string',
|
|
description: 'The message content as Hatsune Miku',
|
|
},
|
|
},
|
|
required: ['content'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let content = response.choices[0].message.content;
|
|
if (!content) {
|
|
throw new TypeError('OpenAI API returned no message.');
|
|
}
|
|
|
|
logInfo(`[openai] API response: ${content}`);
|
|
|
|
// Parse JSON and extract content field
|
|
const parsed = JSON.parse(content);
|
|
return parsed.content || '';
|
|
} catch (err) {
|
|
logError(`[openai] API Error: ` + err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async *requestLLMResponseStreaming(
|
|
history: Message[],
|
|
sysprompt: string,
|
|
params: LLMConfig
|
|
): AsyncGenerator<{ reasoning?: string; content?: string; done?: boolean }, string, unknown> {
|
|
let messageList = await Promise.all(history.map(serializeMessageHistory));
|
|
messageList = messageList.filter((x) => !!x);
|
|
|
|
if (messageList.length === 0) {
|
|
throw new TypeError('No messages with content provided in history!');
|
|
}
|
|
|
|
const lastMsg = messageList[messageList.length - 1];
|
|
let newDate = new Date(lastMsg!.timestamp);
|
|
newDate.setSeconds(newDate.getSeconds() + 5);
|
|
|
|
let templateMsgTxt = JSON.stringify({
|
|
timestamp: newDate.toUTCString(),
|
|
author: 'Hatsune Miku',
|
|
name: 'Hatsune Miku',
|
|
context: lastMsg!.content,
|
|
content: '...',
|
|
});
|
|
|
|
const messageHistoryTxt =
|
|
messageList.map((msg) => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
|
|
logInfo(`[openai] Requesting streaming response for message history: ${messageHistoryTxt}`);
|
|
|
|
try {
|
|
const stream = await this.client.chat.completions.create({
|
|
model: this.model,
|
|
messages: [
|
|
{ role: 'system', content: sysprompt },
|
|
{ role: 'user', content: USER_PROMPT_STREAMING + messageHistoryTxt },
|
|
],
|
|
temperature: params?.temperature || 0.5,
|
|
top_p: params?.top_p || 0.9,
|
|
max_tokens: params?.max_new_tokens || 128,
|
|
stream: true,
|
|
});
|
|
|
|
let fullContent = '';
|
|
let reasoningContent = '';
|
|
let chunkCount = 0;
|
|
|
|
for await (const chunk of stream) {
|
|
chunkCount++;
|
|
const delta = chunk.choices[0]?.delta;
|
|
|
|
// Handle reasoning content if present (some models include it)
|
|
// Also check for 'reasoning' field which some OpenAI-compatible APIs use
|
|
const reasoningDelta =
|
|
('reasoning_content' in delta && delta.reasoning_content) ||
|
|
('reasoning' in delta && delta.reasoning);
|
|
if (reasoningDelta) {
|
|
reasoningContent += reasoningDelta;
|
|
yield { reasoning: reasoningContent };
|
|
}
|
|
|
|
// Handle regular content
|
|
if (delta.content) {
|
|
fullContent += delta.content;
|
|
yield { content: fullContent };
|
|
}
|
|
}
|
|
|
|
logInfo(
|
|
`[openai] Streaming complete: ${chunkCount} chunks, ${fullContent.length} chars`
|
|
);
|
|
|
|
// Strip </think> tags if present
|
|
if (fullContent.lastIndexOf('</think>') > -1) {
|
|
fullContent = fullContent.slice(fullContent.lastIndexOf('</think>') + 8);
|
|
}
|
|
|
|
logInfo(`[openai] Streaming API response: ${fullContent}`);
|
|
return fullContent;
|
|
} catch (err) {
|
|
logError(`[openai] Streaming API Error: ` + err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request a structured response for voice messages with message and instruct fields.
|
|
* Uses OpenAI's structured outputs via JSON mode.
|
|
*/
|
|
async requestStructuredVoiceResponse(
|
|
userText: string,
|
|
sysprompt: string,
|
|
params: LLMConfig
|
|
): Promise<{ message: string; instruct: string }> {
|
|
const prompt = `You are Hatsune Miku. A user wants you to respond with a voice message.
|
|
|
|
User message: "${userText}"
|
|
|
|
Respond with a JSON object containing:
|
|
- "message": Your spoken response as Miku (keep it concise, 1-3 sentences)
|
|
- "instruct": A one-sentence instruction describing the expression/tone to use (e.g., "Speak cheerfully and energetically", "Whisper softly and sweetly")
|
|
|
|
Return ONLY valid JSON, no other text.`;
|
|
|
|
logInfo(`[openai] Requesting structured voice response for: "${userText}"`);
|
|
|
|
try {
|
|
const response = await this.client.chat.completions.create({
|
|
model: this.model,
|
|
messages: [
|
|
{ role: 'system', content: sysprompt },
|
|
{ role: 'user', content: prompt },
|
|
],
|
|
temperature: params?.temperature || 0.7,
|
|
top_p: params?.top_p || 0.9,
|
|
max_tokens: params?.max_new_tokens || 256,
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'voice_message_response',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
message: {
|
|
type: 'string',
|
|
description:
|
|
'Your spoken response as Miku (keep it concise, 1-3 sentences)',
|
|
},
|
|
instruct: {
|
|
type: 'string',
|
|
description:
|
|
'A one-sentence instruction describing the expression/tone to use',
|
|
},
|
|
},
|
|
required: ['message', 'instruct'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let content = response.choices[0].message.content;
|
|
if (!content) {
|
|
throw new TypeError('OpenAI API returned no message.');
|
|
}
|
|
|
|
logInfo(`[openai] Structured API response: ${content}`);
|
|
|
|
// Parse and validate JSON response
|
|
const parsed = JSON.parse(content);
|
|
return {
|
|
message: parsed.message || 'Hello! I am Miku~ ♪',
|
|
instruct: parsed.instruct || 'Speak in a friendly and enthusiastic tone',
|
|
};
|
|
} catch (err) {
|
|
logError(`[openai] Structured API Error: ` + err);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|