Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions lib/middleware/parameter.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { http, HttpResponse } from 'msw';
import Parser from 'rss-parser';
import { describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -459,4 +460,60 @@ describe('openai', () => {
expect(parsedDescriptionOnly.items[0].title).not.toContain('AI processed content.');
expect(parsedDescriptionOnly.items[0].content).toContain('AI processed content.');
});

it('supports target language override via query', async () => {
const { default: server } = await import('@/setup.test');
const originalInput = config.openai.inputOption;

server.use(
http.post('https://api.openai.mock/v1/chat/completions', async ({ request }) => {
const body = (await request.json()) as { messages?: Array<{ content?: string }> };
const prompt = body.messages?.[0]?.content ?? '';

if (prompt.includes('into Japanese')) {
return HttpResponse.json({
choices: [{ message: { content: '日本語タイトル' } }],
});
}

return HttpResponse.json({
choices: [{ message: { content: 'AI processed content.' } }],
});
})
);

config.openai.inputOption = 'title';
const response = await app.request('/test/gpt?chatgpt=true&chatgpt_to=Japanese&chatgpt_from=English');
const parsed = await parser.parseString(await response.text());
expect(parsed.items[0].title).toBe('日本語タイトル');

config.openai.inputOption = originalInput;
});

it('keeps the default cache key when only source language is provided', async () => {
const { default: server } = await import('@/setup.test');
const cache = (await import('@/utils/cache')).default;
const originalInput = config.openai.inputOption;
let requests = 0;

cache.clients.memoryCache?.clear();
server.use(
http.post('https://api.openai.mock/v1/chat/completions', () => {
requests += 1;
return HttpResponse.json({
choices: [{ message: { content: 'cache-compatible output' } }],
});
})
);

config.openai.inputOption = 'title';
await app.request('/test/gpt?chatgpt=true');
const requestsAfterDefault = requests;
await app.request('/test/gpt?chatgpt=true&chatgpt_from=English');

expect(requests).toBe(requestsAfterDefault);

config.openai.inputOption = originalInput;
cache.clients.memoryCache?.clear();
});
});
57 changes: 49 additions & 8 deletions lib/middleware/parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,43 @@ const getAiCompletion = async (prompt: string, text: string) => {
return response.choices[0].message.content;
};

const getChatgptLanguageOptions = (ctx) => {
const targetLanguage = ctx.req.query('chatgpt_to') ?? ctx.req.query('chatgpt_lang');
const sourceLanguage = ctx.req.query('chatgpt_from');

return {
targetLanguage: targetLanguage?.trim() || undefined,
sourceLanguage: sourceLanguage?.trim() || undefined,
};
};

const buildTranslationPrompt = (field: 'title' | 'content', targetLanguage: string, sourceLanguage?: string) => {
const fromSegment = sourceLanguage ? ` from ${sourceLanguage}` : '';
const contentSegment = field === 'title' ? 'title' : 'content';
const formatSegment = field === 'title' ? 'reply only translated text' : 'preserve markdown formatting';

return `Please translate the following ${contentSegment}${fromSegment} into ${targetLanguage} and ${formatSegment}.`;
};

const getOpenAiPrompt = (ctx, field: 'title' | 'content') => {
const { targetLanguage, sourceLanguage } = getChatgptLanguageOptions(ctx);

if (!targetLanguage) {
return field === 'title' ? config.openai.promptTitle : config.openai.promptDescription;
}

return buildTranslationPrompt(field, targetLanguage, sourceLanguage);
};

const getOpenAiCachePrefix = (ctx) => {
const { targetLanguage, sourceLanguage } = getChatgptLanguageOptions(ctx);
if (!targetLanguage) {
return 'default';
}

return `${sourceLanguage ?? 'auto'}->${targetLanguage}`;
};

const getAuthorString = (item) => {
let author = '';
if (item.author) {
Expand Down Expand Up @@ -330,14 +367,18 @@ const middleware: MiddlewareHandler = async (ctx, next) => {

// openai
if (ctx.req.query('chatgpt') && config.openai.apiKey) {
const titlePrompt = getOpenAiPrompt(ctx, 'title');
const descriptionPrompt = getOpenAiPrompt(ctx, 'content');
const cachePrefix = getOpenAiCachePrefix(ctx);

data.item = await Promise.all(
data.item.map(async (item) => {
try {
// handle description
if (config.openai.inputOption === 'description' && item.description) {
const description = await cache.tryGet(`openai:description:${item.link}`, async () => {
const description = await cache.tryGet(`openai:${cachePrefix}:description:${item.link}`, async () => {
const description = convert(item.description!);
const descriptionMd = await getAiCompletion(config.openai.promptDescription, description);
const descriptionMd = await getAiCompletion(descriptionPrompt, description);
return md.render(descriptionMd);
});
// add it to the description
Expand All @@ -347,9 +388,9 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
}
// handle title
else if (config.openai.inputOption === 'title' && item.title) {
const title = await cache.tryGet(`openai:title:${item.link}`, async () => {
const title = await cache.tryGet(`openai:${cachePrefix}:title:${item.link}`, async () => {
const title = convert(item.title!);
return await getAiCompletion(config.openai.promptTitle, title);
return await getAiCompletion(titlePrompt, title);
});
// replace the title
if (title !== '') {
Expand All @@ -358,18 +399,18 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
}
// handle both
else if (config.openai.inputOption === 'both' && item.title && item.description) {
const title = await cache.tryGet(`openai:title:${item.link}`, async () => {
const title = await cache.tryGet(`openai:${cachePrefix}:title:${item.link}`, async () => {
const title = convert(item.title!);
return await getAiCompletion(config.openai.promptTitle, title);
return await getAiCompletion(titlePrompt, title);
});
// replace the title
if (title !== '') {
item.title = title + '';
}

const description = await cache.tryGet(`openai:description:${item.link}`, async () => {
const description = await cache.tryGet(`openai:${cachePrefix}:description:${item.link}`, async () => {
const description = convert(item.description!);
const descriptionMd = await getAiCompletion(config.openai.promptDescription, description);
const descriptionMd = await getAiCompletion(descriptionPrompt, description);
return md.render(descriptionMd);
});
// add it to the description
Expand Down