refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,156 @@
import {
streamText as _streamText,
type CallSettings,
convertToModelMessages,
type LanguageModel,
type LanguageModelUsage,
type StreamTextOnFinishCallback,
stepCountIs,
} from 'ai';
import { getSystemPrompt } from '~/.server/prompts/prompts';
import { approximatePromptTokenCount, encode } from '~/.server/utils/token';
import type { ElementInfo } from '~/routes/api.chat/chat.server';
import type { UPageUIMessage } from '~/types/message';
import { MAX_TOKENS } from './constants';
import type { SelectContextResult } from './select-context';
import { tools } from './tools';
export type ChatStreamTextProps = CallSettings & {
messages: UPageUIMessage[];
summary: string;
pageSummary: string;
context?: Record<string, SelectContextResult>;
model: LanguageModel;
maxTokens?: number;
elementInfo?: ElementInfo;
onFinish?: StreamTextOnFinishCallback<any>;
onAbort?: (params: { event: any; totalUsage: LanguageModelUsage }) => void;
};
export async function chatStreamText({
messages,
summary,
pageSummary,
context,
model,
maxTokens,
elementInfo,
abortSignal,
onFinish,
onAbort,
}: ChatStreamTextProps) {
let systemPrompt = getSystemPrompt();
if (pageSummary) {
systemPrompt = `${systemPrompt}
以下是截止目前为止的页面摘要:
PAGE SUMMARY:
---
${pageSummary}
---
`;
}
if (summary) {
systemPrompt = `${systemPrompt}
以下是截至目前为止的聊天记录摘要:
CHAT SUMMARY:
---
${summary}
---
`;
}
if (context) {
systemPrompt = `${systemPrompt}
以下是根据用户的聊天记录和任务分析出的可能对此次任务有帮助的页面及其代码片段,按页面名称区分,多个页面使用 ------ 分割
CONTEXT:
---
${Object.entries(context)
.map(
([key, value]) => `
- 页面名称: ${value.pageName}
- 页面标题: ${value.pageTitle}
- 页面内容: ${value.sections.join('\n')}
`,
)
.join('------')}
---
`;
}
if (elementInfo) {
systemPrompt = `${systemPrompt}
${createElementEditPrompt(elementInfo)}
`;
}
return _streamText({
model,
tools: tools(),
system: systemPrompt,
maxOutputTokens: maxTokens || MAX_TOKENS,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(3),
prepareStep: async ({ messages }) => {
if (messages.length > 20) {
return {
messages: messages.slice(-10),
};
}
return {};
},
abortSignal,
onFinish,
onAbort(event) {
// 由于 AI SDK 没有提供在 onAbort 中计算 Token 消耗的方法。所以这里手动计算。
let inoutTokens = 0;
inoutTokens += approximatePromptTokenCount(messages);
inoutTokens += encode(systemPrompt).length;
onAbort?.({
event,
totalUsage: {
inputTokens: inoutTokens,
outputTokens: 0,
totalTokens: inoutTokens,
reasoningTokens: 0,
cachedInputTokens: 0,
},
});
},
});
}
/**
* 根据元素编辑信息创建相应的系统提示
* @param elementEdit 元素编辑信息
* @returns 系统提示字符串
*/
function createElementEditPrompt({ tagName, className, id }: ElementInfo): string {
// 构建元素选择器描述
const elementSelector = [tagName.toLowerCase(), id ? `#${id}` : '', className ? `.${className.split(' ')[0]}` : '']
.filter(Boolean)
.join('');
return `
<element_edit_context>
用户当前正在编辑特定元素。请将您的响应限制在此元素的范围内。
当前编辑的元素: ${elementSelector}
请严格遵循以下规则:
1. 仅修改用户当前选中的元素或其子元素
2. 不要修改页面上的其他元素
3. 如果是添加操作,仅在当前选中元素内添加内容
4. 如果是更新操作,确保使用最小化更新,并保留元素的 domId
5. 如果是删除操作,仅删除当前选中元素或其子元素
6. 保持页面的整体风格和一致性
7. 确保所有生成的 HTML 元素都有唯一的 domId不要使用相同的 domId
元素详细信息:
- 标签名: ${tagName.toLowerCase()}
${id ? `- ID: ${id}` : ''}
${className ? `- 类名: ${className}` : ''}
</element_edit_context>
`;
}

View File

@@ -0,0 +1,7 @@
// see https://docs.anthropic.com/en/docs/about-claude/models
export const MAX_TOKENS = process.env.MAX_TOKENS ? parseInt(process.env.MAX_TOKENS) : 8000;
// limits the number of model responses that can be returned in a single request
export const MAX_RESPONSE_SEGMENTS = process.env.MAX_RESPONSE_SEGMENTS
? parseInt(process.env.MAX_RESPONSE_SEGMENTS)
: 5;

View File

@@ -0,0 +1,148 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { UPageUIMessage } from '~/types/message';
import { createScopedLogger } from '~/utils/logger';
import { extractCurrentContext, getUserMessageContent, simplifyUPageActions } from './utils';
const logger = createScopedLogger('create-summary');
export async function createSummary({
messages,
model,
abortSignal,
}: {
messages: UPageUIMessage[];
model: LanguageModel;
} & CallSettings) {
const processedMessages = messages.map((message) => {
if (message.role === 'user') {
const content = getUserMessageContent(message);
return { ...message, content };
}
if (message.role == 'assistant') {
for (const part of message.parts) {
if (part.type === 'text') {
part.text = simplifyUPageActions(part.text);
part.text = part.text.replace(/<div class=\\"__uPageThought__\\">.*?<\/div>/s, '');
}
if (part.type === 'reasoning') {
part.text = part.text.replace(/<think>.*?<\/think>/s, '');
}
}
return message;
}
return message;
});
let slicedMessages = processedMessages;
const { summary } = extractCurrentContext(processedMessages);
let summaryText: string | undefined = undefined;
let chatId: string | undefined = undefined;
if (summary) {
chatId = summary.chatId;
summaryText = `以下是截至目前为止的聊天摘要,将其作为历史消息参考使用。
${summary.summary}`;
if (chatId) {
let index = 0;
for (let i = 0; i < processedMessages.length; i++) {
if (processedMessages[i].id === chatId) {
index = i;
break;
}
}
slicedMessages = processedMessages.slice(index + 1);
}
}
logger.debug('切片消息长度:', slicedMessages.length);
const extractTextContent = (message: UPageUIMessage) =>
message.parts
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('\n');
return await generateText({
system: `
你是一名软件工程师。你正在参与一个项目。你需要总结目前的工作内容,并提供截至目前对话的摘要。
请仅使用以下格式生成摘要:
---
# 项目概览
- **项目名称**: {project_name} - {brief_description}
- **当前阶段**: {phase}
# 对话上下文
- **最近讨论点**: {main_discussion_point}
- **重要决策**: {important_decisions_made}
# 实现状态
## 当前状态
- **活跃功能**: {feature_in_development}
- **进展**: {what_works_and_what_doesn't}
- **障碍**: {current_challenges}
## 代码演化
- **最近修改**: {latest_modifications}
# 需求
- **已实现**: {completed_features}
- **进行中**: {current_focus}
- **待定**: {upcoming_features}
# 关键记忆
- **必须保留**: {crucial_technical_context}
- **用户需求**: {specific_user_needs}
- **已知问题**: {documented_problems}
# 下一步行动
- **立即行动**: {next_steps}
- **待解决的问题**: {unresolved_issues}
---
Note:
4. 保持条目简洁,重点记录确保工作连续性所需的信息。
---
RULES:
* 仅提供截至目前为止的聊天摘要。
* 不要提供任何新信息。
* 不需要过多思考,立即开始写作
* 不要写任何与提供的结构不同的摘要
`,
prompt: `
以下是之前的聊天摘要:
<old_summary>
${summaryText}
</old_summary>
以下是之后的聊天记录:
---
<new_chats>
${slicedMessages
.map((x) => {
return `---\n[${x.role}] ${extractTextContent(x)}\n---`;
})
.join('\n')}
</new_chats>
---
请提供截至目前聊天的摘要,包括聊天的历史记录摘要。
`,
model,
abortSignal,
});
}

View File

@@ -0,0 +1,137 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { Page } from '~/types/actions';
import type { UPageUIMessage } from '~/types/message';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('select-context');
export type SelectContextResult = {
sections: string[];
pageName: string;
pageTitle: string;
};
export async function selectContext({
messages,
pages,
summary,
model,
abortSignal,
}: {
messages: UPageUIMessage[];
pages: Page[];
summary: string;
model: LanguageModel;
} & CallSettings) {
const extractTextContent = (message: UPageUIMessage) =>
message.parts.find((part) => part.type === 'text')?.text || '';
const lastUserMessage = messages.filter((x) => x.role == 'user').pop();
if (!lastUserMessage) {
throw new Error('未找到用户消息');
}
const pagesContent = pages.map((page) => {
return `
---
页面名称:${page.name}
---
页面标题:${page.title}
---
页面内容:${page.content}
`;
});
const resp = await generateText({
system: `
你是一名软件工程师。你正在从事一个 HTML 项目,该项目包含多个页面,每个页面内容中包含 HTML 片段,可能是 HTML、style、JavaScript 片段。
${pagesContent.join('\n')}
---
现在,你将获得一个任务。你需要从上述页面列表中选择与任务相关的页面与用户任务相关的 HTML 片段。请务必保证:
- 如果涉及到脚本,则需要选择可能相关的所有脚本的完整内容。
- 如果涉及到样式,则需要选择可能相关的所有样式,包括内联样式、外部样式表和 style 标签中的样式。
RESPONSE FORMAT:
你的回复应严格遵循以下格式:
---
<updateContextBuffer>
<selectPage pageName="pageName" pageTitle="pageTitle">
<selectSection>
...section content...
</selectSection>
...
</selectPage>
...
</updateContextBuffer>
---
* 你应该从 <updateContextBuffer> 开始,以 </updateContextBuffer> 结束。
* 你可以在回复中包含多个 <selectPage> 标签,每个 <selectPage> 标签中也可以包含多个 <selectSection> 标签。
* 你需要在 <selectPage> 标签中包含页面名称和页面标题,但每个页面只能出现一次。
* 你需要在 <selectSection> 标签中包含完整的 HTML 内容,只做选择,但不要对 HTML 内容进行任何修改。
* 如果不需要任何更改,你可以留下空的 updateContextBuffer 标签。
`,
prompt: `
以下是截至目前聊天的摘要: ${summary}
用户当前任务: ${extractTextContent(lastUserMessage)}
请根据当前页面、页面属性以及内容,选择与任务相关的页面以及 HTML 片段。
`,
model,
abortSignal,
});
const response = resp.text;
const updateContextBuffer = response.match(/<updateContextBuffer>([\s\S]*?)<\/updateContextBuffer>/);
if (!updateContextBuffer) {
throw new Error('无效响应。请遵循响应格式');
}
const updateContextBufferContent = updateContextBuffer[1];
const selectedPages: Record<string, SelectContextResult> = {};
const selectPageRegex = /<selectPage\s+pageName="([^"]+)"\s+pageTitle="([^"]+)">([\s\S]*?)<\/selectPage>/g;
let selectPageMatch;
while ((selectPageMatch = selectPageRegex.exec(updateContextBufferContent)) !== null) {
const pageName = selectPageMatch[1];
const pageTitle = selectPageMatch[2];
const pageContent = selectPageMatch[3];
if (!pageName) {
logger.warn('页面名称为空');
continue;
}
const selectSectionRegex = /<selectSection>([\s\S]*?)<\/selectSection>/g;
const sections: string[] = [];
let selectSectionMatch;
while ((selectSectionMatch = selectSectionRegex.exec(pageContent)) !== null) {
const sectionContent = selectSectionMatch[1];
if (sectionContent.trim()) {
sections.push(sectionContent.trim());
}
}
if (sections.length > 0) {
selectedPages[pageName] = {
sections,
pageName,
pageTitle,
};
}
}
const { text, content, totalUsage } = resp;
return {
text,
content,
totalUsage,
context: selectedPages,
};
}

View File

@@ -0,0 +1,45 @@
import { convertToModelMessages, type LanguageModel, streamText, type UIMessage } from 'ai';
import { DEFAULT_PROVIDER } from '~/.server/modules/constants';
import { createScopedLogger } from '~/utils/logger';
import { stripIndents } from '~/utils/strip-indent';
const logger = createScopedLogger('stream-enhancer');
export async function streamEnhancer(props: { messages: UIMessage[]; model: LanguageModel; maxTokens?: number }) {
const { messages, model, maxTokens } = props;
logger.info(`发送 llm 调用至 ${DEFAULT_PROVIDER.name} 使用模型 ${model}`);
const systemMessage = stripIndents`
你是一位专业提示工程师,专注于制作精确、有效的提示。
你的任务是增强提示,使其更加具体、可操作且有效。
对于有效的提示:
- 使指令明确且无歧义
- 添加相关上下文和约束
- 删除冗余信息
- 保持核心意图
- 确保提示自包含
- 使用专业语言
对于无效或不明确的提示:
- 提供清晰、专业的指导
- 保持响应简洁且可操作
- 保持有帮助、建设性的语气
- 专注于用户应该提供的内容
- 使用标准模板保持一致
<output_format>
1. 响应必须仅包含增强后的提示文本。
2. 不要包含任何解释、元数据或包装标签。
</output_format>
`;
const result = streamText({
model,
system: systemMessage,
maxOutputTokens: maxTokens,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}

View File

@@ -0,0 +1,149 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { Page } from '~/types/actions';
export async function structuredPageSnapshot({
pages,
model,
abortSignal,
}: {
pages: Page[];
model: LanguageModel;
} & CallSettings) {
return await generateText({
system: `
你是一名严谨的前端页面审阅与摘要助手。你将收到多个页面的 Body 片段,可能包含 script 与 style。你的任务是
1) 从这些页面内容中提取结构化信息;
2) 以严格的 XML 风格(非 JSON、非 Markdown、无额外说明文本输出一个“现有页面摘要快照”
3) 仅基于给定内容进行总结,不得臆测未出现的信息;不回显原始全文;
4) 若某项信息无法确定,请输出空元素,不要编造。
输出必须严格遵循以下 XML 模板(标签名与层级必须一致;可重复的节点可按需要重复;所有一级大纲必须保留):
<snapshot>
<generated_at></generated_at>
<pages_count></pages_count>
<global_overview>
<shared_components></shared_components>
<shared_styles></shared_styles>
<shared_scripts></shared_scripts>
<navigation_overview></navigation_overview>
<notable_assets></notable_assets>
</global_overview>
<pages>
<page>
<name></name>
<summary></summary>
<layout>
<structure></structure>
<sections></sections>
<components>
<component>
<name></name>
<role></role>
<props></props>
<events></events>
</component>
</components>
</layout>
<content>
<headings>
<h1></h1>
<h2_list>
<h2></h2>
</h2_list>
</headings>
<text_stats>
<char_count></char_count>
<word_count></word_count>
<language_guess></language_guess>
</text_stats>
<links>
<a href="" text=""></a>
</links>
<forms>
<form id="" action="" method="">
<fields>
<field name="" type="" required=""></field>
</fields>
<validation></validation>
<submit_targets></submit_targets>
</form>
</forms>
<media>
<img src="" alt=""></img>
<video src="" title=""></video>
</media>
<tables>
<table summary=""></table>
</tables>
</content>
<interactions>
<events>
<event type="" target="" handler_summary=""></event>
</events>
<state>
<variables>
<variable name="" initial=""></variable>
</variables>
<persistence></persistence>
</state>
</interactions>
<data_flow>
<inputs></inputs>
<outputs></outputs>
<api_calls>
<api url="" method="" when=""></api>
</api_calls>
</data_flow>
<style_summary>
<inline_styles></inline_styles>
<classes></classes>
<themes></themes>
</style_summary>
<script_summary>
<libraries></libraries>
<modules></modules>
<security_notes></security_notes>
</script_summary>
<seo>
<title></title>
<meta_description></meta_description>
<canonical></canonical>
<h_tags></h_tags>
</seo>
<i18n>
<locales_detected></locales_detected>
<hardcoded_texts></hardcoded_texts>
</i18n>
<issues>
<problem severity="">
<desc></desc>
<evidence></evidence>
<suggestion></suggestion>
</problem>
</issues>
<complexity score=""></complexity>
<confidence score=""></confidence>
</page>
</pages>
</snapshot>
严格输出规则:
- 仅输出上述 XML不要输出任何解释性文字、代码块符号或 Markdown
- 标签名、层级和顺序必须与模板保持一致;
- 允许重复的子节点按需要重复;
- 内容以中文撰写;
- 不得包含未在输入中出现的臆测信息;
- 无法确定的信息保留为空元素。
`,
prompt: `
以下是页面内容:
---
<pages>
${pages.map((page) => `<page_name>${page.name}</page_name><page_content>${page.content}</page_content>`).join('\n --- \n')}
</pages>
---
`,
model,
abortSignal,
});
}

View File

@@ -0,0 +1,66 @@
export default class SwitchableStream extends TransformStream {
private _controller: TransformStreamDefaultController | null = null;
private _currentReader: ReadableStreamDefaultReader | null = null;
private _switches = 0;
constructor() {
let controllerRef: TransformStreamDefaultController | undefined;
super({
start(controller) {
controllerRef = controller;
},
});
if (controllerRef === undefined) {
throw new Error('Controller not properly initialized');
}
this._controller = controllerRef;
}
async switchSource(newStream: ReadableStream) {
if (this._currentReader) {
await this._currentReader.cancel();
}
this._currentReader = newStream.getReader();
this._pumpStream();
this._switches++;
}
private async _pumpStream() {
if (!this._currentReader || !this._controller) {
throw new Error('Stream is not properly initialized');
}
try {
while (true) {
const { done, value } = await this._currentReader.read();
if (done) {
break;
}
this._controller.enqueue(value);
}
} catch (error) {
console.log(error);
this._controller.error(error);
}
}
close() {
if (this._currentReader) {
this._currentReader.cancel();
}
this._controller?.terminate();
}
get switches() {
return this._switches;
}
}

View File

@@ -0,0 +1,17 @@
import type { Tool, ToolSet } from 'ai';
import { serperTool } from './serper';
import { weatherTool } from './weather';
export const tools: () => ToolSet = () => {
const tools: Record<string, Tool> = {};
if (process.env.SERPER_API_KEY) {
tools.serper = serperTool;
}
if (process.env.WEATHER_API_KEY) {
tools.weather = weatherTool;
}
return tools;
};

View File

@@ -0,0 +1,54 @@
/**
* 由于 agentic 暂时不支持 AI SDK v5因此使用自定义的 Serper 工具。
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
*/
import { tool } from 'ai';
import { z } from 'zod';
const API_BASE_URL = 'https://google.serper.dev';
const searchParamsSchema = z.object({
q: z.string().describe('搜索查询词'),
autocorrect: z.boolean().optional().default(true).describe('是否自动纠正拼写错误'),
gl: z.string().optional().default('us').describe('地理位置代码,如"us"表示美国'),
hl: z.string().optional().default('en').describe('语言代码,如"en"表示英语'),
page: z.number().optional().default(1).describe('页码'),
num: z.number().optional().default(10).describe('结果数量'),
type: z
.enum(['search', 'images', 'videos', 'places', 'news', 'shopping'])
.optional()
.default('search')
.describe('搜索类型'),
});
export const serperTool = tool({
description: '使用Google搜索获取最新信息。适用于查找新闻、事实、数据和当前事件等实时信息。',
inputSchema: searchParamsSchema,
execute: async ({ q, ...params }) => {
const apiKey = process.env.SERPER_API_KEY || '';
if (!apiKey) {
throw new Error('Missing SERPER_API_KEY');
}
try {
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ q, ...params }),
});
if (!response.ok) {
throw new Error(`Serper API responded with status: ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
console.error('Serper API error:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
throw new Error(`搜索失败: ${errorMessage}`);
}
},
});

View File

@@ -0,0 +1,43 @@
/**
* 由于 agentic 暂时不支持 AI SDK v5因此使用自定义的 Weather 工具。
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
*/
import { tool } from 'ai';
import { z } from 'zod';
const API_BASE_URL = 'https://api.weatherapi.com/v1';
const weatherParamsSchema = z.object({
q: z
.string()
.describe('位置查询可以是城市名称、邮政编码、IP地址或经纬度坐标。必须使用英语或拼音。例如"London"、"Beijing"'),
});
export const weatherTool = tool({
description: '获取指定位置的天气信息',
inputSchema: weatherParamsSchema,
execute: async ({ q }) => {
const apiKey = process.env.WEATHER_API_KEY || '';
if (!apiKey) {
throw new Error('Missing WEATHER_API_KEY');
}
try {
const url = new URL(`${API_BASE_URL}/current.json`);
url.searchParams.append('key', apiKey);
url.searchParams.append('q', q);
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Weather API responded with status: ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
console.error('Weather API error:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
throw new Error(`获取天气信息失败: ${errorMessage}`);
}
},
});

92
app/.server/llm/utils.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Section } from '~/types/actions';
import type { SummaryAnnotation, UPageUIMessage } from '~/types/message';
import type { PageMap, SectionMap } from '~/types/pages';
export function getUserMessageContent(message: Omit<UPageUIMessage, 'id'>): string {
if (message.role !== 'user') {
throw new Error('Message is not a user message');
}
return message.parts
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('\n');
}
export function simplifyUPageActions(input: string): string {
// Using regex to match uPageAction tags that have type="page"
const regex = /(<uPageAction[^>]*type="page"[^>]*>)([\s\S]*?)(<\/uPageAction>)/g;
// Replace each matching occurrence
return input.replace(regex, (_0, openingTag, _2, closingTag) => {
return `${openingTag}\n ...\n ${closingTag}`;
});
}
export function getSectionByPageName(sections: SectionMap) {
return Object.values(sections).reduce(
(acc, section) => {
if (section) {
const pageName = section.pageName;
acc[pageName] = [...(acc[pageName] || []), section];
}
return acc;
},
{} as Record<string, Section[]>,
);
}
export function createPagesContext(pages: PageMap, sections: SectionMap) {
const pagePaths = Object.keys(pages);
const sectionGroupByPageName = Object.values(sections).reduce(
(acc, section) => {
if (section) {
const pageName = section.pageName;
if (pagePaths.includes(pageName)) {
acc[section.pageName] = [...(acc[section.pageName] || []), section];
}
}
return acc;
},
{} as Record<string, Section[]>,
);
const pageContexts = Object.entries(sectionGroupByPageName).map(([pageName, sections]) => {
return `<uPageAction id="${pageName}" title="Code Content">${sections
.map((section) => {
return `<uPageAction id="${section.domId}" type="page" pageName="${pageName}" action="${section.action}" domId="${section.domId}">${section.content}</uPageAction>`;
})
.join('\n')}</uPageAction>`;
});
return pageContexts.join('\n');
}
export function extractCurrentContext(messages: UPageUIMessage[]) {
const lastAssistantMessage = messages.filter((x) => x.role == 'assistant').slice(-1)[0];
if (!lastAssistantMessage) {
return { summary: undefined };
}
let summary: SummaryAnnotation | undefined;
if (!lastAssistantMessage.parts?.length) {
return { summary: undefined };
}
const parts = lastAssistantMessage.parts;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type === 'data-summary') {
summary = part.data;
break;
}
}
return { summary };
}

View File

@@ -0,0 +1,17 @@
import { LLMManager } from '~/.server/modules/llm/manager.server';
const llmManager = LLMManager.getInstance();
export const DEFAULT_MODEL = llmManager.getDefaultModel();
export const MINOR_MODEL = llmManager.getMinorModel();
export const DEFAULT_PROVIDER = llmManager.getDefaultProvider();
export const DEFAULT_MODEL_DETAILS = DEFAULT_PROVIDER.staticModels.find((m) => m.name === DEFAULT_MODEL);
export const MINOR_MODEL_DETAILS = DEFAULT_PROVIDER.staticModels.find((m) => m.name === MINOR_MODEL);
export const getModel = (model: string) => {
return DEFAULT_PROVIDER.getModelInstance({
model,
providerSettings: llmManager.getConfiguredProviderSettings(),
});
};

View File

@@ -0,0 +1,83 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import type { IProviderSetting } from '~/types/model';
import type { ModelInfo, ProviderInfo } from './types';
export abstract class BaseProvider implements ProviderInfo {
abstract name: string;
abstract staticModels: ModelInfo[];
cachedDynamicModels?: {
cacheId: string;
models: ModelInfo[];
};
getApiKeyLink?: string;
labelForGetApiKey?: string;
icon?: string;
getProviderBaseUrlAndKey(providerSettings?: IProviderSetting) {
let baseUrl = providerSettings?.baseUrl;
if (baseUrl && baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
const apiKey = providerSettings?.apiKey;
return {
baseUrl,
apiKey,
};
}
getModelsFromCache(options: { providerSettings?: Record<string, IProviderSetting> }): ModelInfo[] | null {
if (!this.cachedDynamicModels) {
// console.log('no dynamic models',this.name);
return null;
}
const cacheKey = this.cachedDynamicModels.cacheId;
const generatedCacheKey = this.getDynamicModelsCacheKey(options);
if (cacheKey !== generatedCacheKey) {
// console.log('cache key mismatch',this.name,cacheKey,generatedCacheKey);
this.cachedDynamicModels = undefined;
return null;
}
return this.cachedDynamicModels.models;
}
getDynamicModelsCacheKey(options: { providerSettings?: Record<string, IProviderSetting> }) {
return JSON.stringify({
apiKeys: options.providerSettings?.[this.name]?.apiKey,
providerSettings: options.providerSettings?.[this.name],
});
}
storeDynamicModels(options: { providerSettings?: Record<string, IProviderSetting> }, models: ModelInfo[]) {
const cacheId = this.getDynamicModelsCacheKey(options);
// console.log('caching dynamic models',this.name,cacheId);
this.cachedDynamicModels = {
cacheId,
models,
};
}
// Declare the optional getDynamicModels method
getDynamicModels?(settings?: IProviderSetting): Promise<ModelInfo[]>;
abstract getModelInstance(options: {
model: string;
providerSettings?: Record<string, IProviderSetting>;
}): LanguageModel;
}
type OptionalApiKey = string | undefined;
export function getOpenAILikeModel(baseURL: string, apiKey: OptionalApiKey, model: string) {
const openai = createOpenAI({
baseURL,
apiKey,
});
return openai(model);
}

View File

@@ -0,0 +1,234 @@
import type { IProviderSetting } from '~/types/model';
import { createScopedLogger } from '~/utils/logger';
import { BaseProvider } from './base-provider';
import * as providers from './registry';
import type { ModelInfo, ProviderInfo } from './types';
const logger = createScopedLogger('LLMManager');
export class LLMManager {
private static _instance: LLMManager;
private _providers: Map<string, BaseProvider> = new Map();
private _modelList: ModelInfo[] = [];
constructor() {
this._registerProvidersFromDirectory();
}
static getInstance(): LLMManager {
if (!LLMManager._instance) {
LLMManager._instance = new LLMManager();
}
return LLMManager._instance;
}
private _getEnvConfig<T>(key: string, defaultValue: T): T {
const value = process?.env?.[key] || (import.meta.env as any)?.[key];
if (value === undefined || value === null || value === '') {
return defaultValue;
}
if (typeof defaultValue === 'boolean') {
return (value === 'true' || value === true) as unknown as T;
}
if (typeof defaultValue === 'number') {
return Number(value) as unknown as T;
}
if (Array.isArray(defaultValue)) {
return (value
? String(value)
.split(',')
.map((item) => item.trim())
: []) as unknown as T;
}
return value as T;
}
private _getUnifiedProviderConfig() {
const providerName = this._getEnvConfig<string>('LLM_PROVIDER', '');
const baseUrl = this._getEnvConfig<string>('PROVIDER_BASE_URL', '');
const apiKey = this._getEnvConfig<string>('PROVIDER_API_KEY', '');
return {
providerName,
baseUrl,
apiKey,
};
}
getDefaultProvider(): BaseProvider {
const { providerName } = this._getUnifiedProviderConfig();
if (!providerName || !this._providers.has(providerName)) {
throw new Error(
`Provider ${providerName} not found, Effective Provider: ${Array.from(this._providers.values())
.map((p) => p.name)
.join(', ')}`,
);
}
return this._providers.get(providerName)!;
}
private _registerProvidersFromDirectory() {
const allProviders: BaseProvider[] = Object.values(providers).map((providerClass) => new providerClass());
for (const provider of allProviders) {
this.registerProvider(provider);
}
}
registerProvider(provider: BaseProvider) {
if (this._providers.has(provider.name)) {
logger.warn(`Provider ${provider.name} is already registered. Skipping.`);
return;
}
this._providers.set(provider.name, provider);
this._modelList = [...this._modelList, ...provider.staticModels];
}
getDefaultModel(): string {
return this._getEnvConfig<string>('LLM_DEFAULT_MODEL', '');
}
getMinorModel(): string {
return this._getEnvConfig<string>('LLM_MINOR_MODEL', '');
}
getConfiguredProviderSettings(): Record<string, IProviderSetting> {
const providerSettings: Record<string, IProviderSetting> = {};
const { providerName, baseUrl, apiKey } = this._getUnifiedProviderConfig();
providerSettings[providerName] = {
enabled: true,
baseUrl,
apiKey,
};
return providerSettings;
}
getModelList(): ModelInfo[] {
return this._modelList;
}
async updateModelList(options: { providerSettings?: Record<string, IProviderSetting> }): Promise<ModelInfo[]> {
const { providerSettings } = options;
let enabledProviders = Array.from(this._providers.values()).map((p) => p.name);
if (providerSettings && Object.keys(providerSettings).length > 0) {
enabledProviders = enabledProviders.filter((p) => providerSettings[p]?.enabled);
}
// Get dynamic models from all providers that support them
const dynamicModels = await Promise.all(
Array.from(this._providers.values())
.filter((provider) => enabledProviders.includes(provider.name))
.filter(
(provider): provider is BaseProvider & Required<Pick<ProviderInfo, 'getDynamicModels'>> =>
!!provider.getDynamicModels,
)
.map(async (provider) => {
const cachedModels = provider.getModelsFromCache(options);
if (cachedModels) {
return cachedModels;
}
const dynamicModels = await provider
.getDynamicModels(providerSettings?.[provider.name])
.then((models) => {
logger.info(`Caching ${models.length} dynamic models for ${provider.name}`);
provider.storeDynamicModels(options, models);
return models;
})
.catch((err) => {
logger.error(`Error getting dynamic models ${provider.name} :`, err);
return [];
});
return dynamicModels;
}),
);
const staticModels = Array.from(this._providers.values()).flatMap((p) => p.staticModels || []);
const dynamicModelsFlat = dynamicModels.flat();
const dynamicModelKeys = dynamicModelsFlat.map((d) => `${d.name}-${d.provider}`);
const filteredStaticModesl = staticModels.filter((m) => !dynamicModelKeys.includes(`${m.name}-${m.provider}`));
// Combine static and dynamic models
const modelList = [...dynamicModelsFlat, ...filteredStaticModesl];
modelList.sort((a, b) => a.name.localeCompare(b.name));
this._modelList = modelList;
return modelList;
}
getStaticModelList() {
return [...this._providers.values()].flatMap((p) => p.staticModels || []);
}
async getModelListFromProvider(
providerArg: BaseProvider,
options: {
providerSettings?: Record<string, IProviderSetting>;
},
): Promise<ModelInfo[]> {
const provider = this._providers.get(providerArg.name);
if (!provider) {
throw new Error(`Provider ${providerArg.name} not found`);
}
const staticModels = provider.staticModels || [];
if (!provider.getDynamicModels) {
return staticModels;
}
const { providerSettings } = options;
const cachedModels = provider.getModelsFromCache({
providerSettings,
});
if (cachedModels) {
logger.info(`Found ${cachedModels.length} cached models for ${provider.name}`);
return [...cachedModels, ...staticModels];
}
logger.info(`Getting dynamic models for ${provider.name}`);
const dynamicModels = await provider
.getDynamicModels?.(providerSettings?.[provider.name])
.then((models) => {
logger.info(`Got ${models.length} dynamic models for ${provider.name}`);
provider.storeDynamicModels(options, models);
return models;
})
.catch((err) => {
logger.error(`Error getting dynamic models ${provider.name} :`, err);
return [];
});
const dynamicModelsName = dynamicModels.map((d) => d.name);
const filteredStaticList = staticModels.filter((m) => !dynamicModelsName.includes(m.name));
const modelList = [...dynamicModels, ...filteredStaticList];
modelList.sort((a, b) => a.name.localeCompare(b.name));
return modelList;
}
getStaticModelListFromProvider(providerArg: BaseProvider) {
const provider = this._providers.get(providerArg.name);
if (!provider) {
throw new Error(`Provider ${providerArg.name} not found`);
}
return [...(provider.staticModels || [])];
}
}

View File

@@ -0,0 +1,104 @@
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { type LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
interface AWSBedRockConfig {
region: string;
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
}
export default class AmazonBedrockProvider extends BaseProvider {
name = 'AmazonBedrock';
getApiKeyLink = 'https://console.aws.amazon.com/iam/home';
staticModels: ModelInfo[] = [
{
name: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
label: 'Claude 3.5 Sonnet v2 (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 200000,
},
{
name: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
label: 'Claude 3.5 Sonnet (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 4096,
},
{
name: 'anthropic.claude-3-sonnet-20240229-v1:0',
label: 'Claude 3 Sonnet (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 4096,
},
{
name: 'anthropic.claude-3-haiku-20240307-v1:0',
label: 'Claude 3 Haiku (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 4096,
},
{
name: 'amazon.nova-pro-v1:0',
label: 'Amazon Nova Pro (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 5120,
},
{
name: 'amazon.nova-lite-v1:0',
label: 'Amazon Nova Lite (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 5120,
},
{
name: 'mistral.mistral-large-2402-v1:0',
label: 'Mistral Large 24.02 (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 8192,
},
];
private _parseAndValidateConfig(apiKey: string): AWSBedRockConfig {
let parsedConfig: AWSBedRockConfig;
try {
parsedConfig = JSON.parse(apiKey);
} catch {
throw new Error(
'Invalid AWS Bedrock configuration format. Please provide a valid JSON string containing region, accessKeyId, and secretAccessKey.',
);
}
const { region, accessKeyId, secretAccessKey, sessionToken } = parsedConfig;
if (!region || !accessKeyId || !secretAccessKey) {
throw new Error(
'Missing required AWS credentials. Configuration must include region, accessKeyId, and secretAccessKey.',
);
}
return {
region,
accessKeyId,
secretAccessKey,
...(sessionToken && { sessionToken }),
};
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const config = this._parseAndValidateConfig(apiKey);
const bedrock = createAmazonBedrock(config);
return bedrock(model);
}
}

View File

@@ -0,0 +1,78 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class AnthropicProvider extends BaseProvider {
name = 'Anthropic';
getApiKeyLink = 'https://console.anthropic.com/settings/keys';
staticModels: ModelInfo[] = [
{
name: 'claude-3-7-sonnet-20250219',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
maxTokenAllowed: 8000,
},
{
name: 'claude-3-5-sonnet-latest',
label: 'Claude 3.5 Sonnet (new)',
provider: 'Anthropic',
maxTokenAllowed: 8000,
},
{
name: 'claude-3-5-sonnet-20240620',
label: 'Claude 3.5 Sonnet (old)',
provider: 'Anthropic',
maxTokenAllowed: 8000,
},
{
name: 'claude-3-5-haiku-latest',
label: 'Claude 3.5 Haiku (new)',
provider: 'Anthropic',
maxTokenAllowed: 8000,
},
{ name: 'claude-3-opus-latest', label: 'Claude 3 Opus', provider: 'Anthropic', maxTokenAllowed: 8000 },
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
];
async getDynamicModels(settings: IProviderSetting): Promise<ModelInfo[]> {
const { apiKey } = this.getProviderBaseUrlAndKey(settings);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`https://api.anthropic.com/v1/models`, {
headers: {
'x-api-key': `${apiKey}`,
'anthropic-version': '2023-06-01',
},
});
const res = (await response.json()) as any;
const staticModelIds = this.staticModels.map((m) => m.name);
const data = res.data.filter((model: any) => model.type === 'model' && !staticModelIds.includes(model.id));
return data.map((m: any) => ({
name: m.id,
label: `${m.display_name}`,
provider: this.name,
maxTokenAllowed: 32000,
}));
}
getModelInstance: (options: { model: string; providerSettings?: Record<string, IProviderSetting> }) => LanguageModel =
(options) => {
const { providerSettings, model } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings);
const anthropic = createAnthropic({
apiKey,
});
return anthropic(model);
};
}

View File

@@ -0,0 +1,39 @@
import { createCohere } from '@ai-sdk/cohere';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class CohereProvider extends BaseProvider {
name = 'Cohere';
getApiKeyLink = 'https://dashboard.cohere.com/api-keys';
staticModels: ModelInfo[] = [
{ name: 'command-r-plus-08-2024', label: 'Command R plus Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-r-08-2024', label: 'Command R Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-r-plus', label: 'Command R plus', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-r', label: 'Command R', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command', label: 'Command', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-nightly', label: 'Command Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-light', label: 'Command Light', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'command-light-nightly', label: 'Command Light Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'c4ai-aya-expanse-8b', label: 'c4AI Aya Expanse 8b', provider: 'Cohere', maxTokenAllowed: 4096 },
{ name: 'c4ai-aya-expanse-32b', label: 'c4AI Aya Expanse 32b', provider: 'Cohere', maxTokenAllowed: 4096 },
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const cohere = createCohere({
apiKey,
});
return cohere(model);
}
}

View File

@@ -0,0 +1,37 @@
import { createDeepSeek } from '@ai-sdk/deepseek';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class DeepseekProvider extends BaseProvider {
name = 'DeepSeek';
getApiKeyLink = 'https://platform.deepseek.com/apiKeys';
config = {
apiTokenKey: 'DEEPSEEK_API_KEY',
baseUrlKey: '',
};
staticModels: ModelInfo[] = [
{ name: 'deepseek-coder', label: 'DeepSeek-Coder', provider: 'DeepSeek', maxTokenAllowed: 8000 },
{ name: 'deepseek-chat', label: 'DeepSeek-Chat', provider: 'DeepSeek', maxTokenAllowed: 8000 },
{ name: 'deepseek-reasoner', label: 'DeepSeek-Reasoner', provider: 'DeepSeek', maxTokenAllowed: 8000 },
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const deepseek = createDeepSeek({
apiKey,
});
return deepseek(model);
}
}

View File

@@ -0,0 +1,57 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class DouBaoProvider extends BaseProvider {
name = 'DouBao';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://ark.cn-beijing.volces.com/api/v3';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const provider = createOpenAICompatible({
name: this.name,
baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey,
includeUsage: true,
});
return provider(model);
}
}

View File

@@ -0,0 +1,57 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class ErnieProvider extends BaseProvider {
name = 'Ernie';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://qianfan.baidubce.com/v2';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const provider = createOpenAICompatible({
name: this.name,
baseURL: 'https://qianfan.baidubce.com/v2',
apiKey,
includeUsage: true,
});
return provider(model);
}
}

View File

@@ -0,0 +1,38 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class GithubProvider extends BaseProvider {
name = 'Github';
getApiKeyLink = 'https://github.com/settings/personal-access-tokens';
// find more in https://github.com/marketplace?type=models
staticModels: ModelInfo[] = [
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'o1', label: 'o1-preview', provider: 'Github', maxTokenAllowed: 100000 },
{ name: 'o1-mini', label: 'o1-mini', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'gpt-4', label: 'GPT-4', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo', provider: 'Github', maxTokenAllowed: 8000 },
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const openai = createOpenAI({
baseURL: 'https://models.inference.ai.azure.com',
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,68 @@
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class GoogleProvider extends BaseProvider {
name = 'Google';
getApiKeyLink = 'https://aistudio.google.com/app/apikey';
staticModels: ModelInfo[] = [
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google', maxTokenAllowed: 8192 },
{
name: 'gemini-2.0-flash-thinking-exp-01-21',
label: 'Gemini 2.0 Flash-thinking-exp-01-21',
provider: 'Google',
maxTokenAllowed: 65536,
},
{ name: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-flash-002', label: 'Gemini 1.5 Flash-002', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8b', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-pro-002', label: 'Gemini 1.5 Pro-002', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-exp-1206', label: 'Gemini exp-1206', provider: 'Google', maxTokenAllowed: 8192 },
];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { apiKey } = this.getProviderBaseUrlAndKey(settings);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, {
headers: {
// biome-ignore lint: ignore Content-Type header
['Content-Type']: 'application/json',
},
});
const res = (await response.json()) as any;
const data = res.models.filter((model: any) => model.outputTokenLimit > 8000);
return data.map((m: any) => ({
name: m.name.replace('models/', ''),
label: `${m.displayName} - context ${Math.floor((m.inputTokenLimit + m.outputTokenLimit) / 1000) + 'k'}`,
provider: this.name,
maxTokenAllowed: m.inputTokenLimit + m.outputTokenLimit || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const google = createGoogleGenerativeAI({
apiKey,
});
return google(model);
}
}

View File

@@ -0,0 +1,69 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class GroqProvider extends BaseProvider {
name = 'Groq';
getApiKeyLink = 'https://console.groq.com/keys';
staticModels: ModelInfo[] = [
{ name: 'llama-3.1-8b-instant', label: 'Llama 3.1 8b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-90b-vision-preview', label: 'Llama 3.2 90b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{
name: 'deepseek-r1-distill-llama-70b',
label: 'DeepSeek R1 Distill Llama 70b (Groq)',
provider: 'Groq',
maxTokenAllowed: 131072,
},
];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { apiKey } = this.getProviderBaseUrlAndKey(settings);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`https://api.groq.com/openai/v1/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter(
(model: any) => model.object === 'model' && model.active && model.context_window > 8000,
);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_window ? Math.floor(m.context_window / 1000) + 'k' : 'N/A'} [ by ${m.owned_by}]`,
provider: this.name,
maxTokenAllowed: m.context_window || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const openai = createOpenAI({
baseURL: 'https://api.groq.com/openai/v1',
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,96 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class HuggingFaceProvider extends BaseProvider {
name = 'HuggingFace';
getApiKeyLink = 'https://huggingface.co/settings/tokens';
staticModels: ModelInfo[] = [
{
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: '01-ai/Yi-1.5-34B-Chat',
label: 'Yi-1.5-34B-Chat (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'codellama/CodeLlama-34b-Instruct-hf',
label: 'CodeLlama-34b-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'Qwen/Qwen2.5-72B-Instruct',
label: 'Qwen2.5-72B-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'meta-llama/Llama-3.1-70B-Instruct',
label: 'Llama-3.1-70B-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'meta-llama/Llama-3.1-405B',
label: 'Llama-3.1-405B (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: '01-ai/Yi-1.5-34B-Chat',
label: 'Yi-1.5-34B-Chat (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'codellama/CodeLlama-34b-Instruct-hf',
label: 'CodeLlama-34b-Instruct (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
{
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
provider: 'HuggingFace',
maxTokenAllowed: 8000,
},
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const openai = createOpenAI({
baseURL: 'https://api-inference.huggingface.co/v1/',
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,86 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class HyperbolicProvider extends BaseProvider {
name = 'Hyperbolic';
getApiKeyLink = 'https://app.hyperbolic.xyz/settings';
staticModels: ModelInfo[] = [
{
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
label: 'Qwen 2.5 Coder 32B Instruct',
provider: 'Hyperbolic',
maxTokenAllowed: 8192,
},
{
name: 'Qwen/Qwen2.5-72B-Instruct',
label: 'Qwen2.5-72B-Instruct',
provider: 'Hyperbolic',
maxTokenAllowed: 8192,
},
{
name: 'deepseek-ai/DeepSeek-V2.5',
label: 'DeepSeek-V2.5',
provider: 'Hyperbolic',
maxTokenAllowed: 8192,
},
{
name: 'Qwen/QwQ-32B-Preview',
label: 'QwQ-32B-Preview',
provider: 'Hyperbolic',
maxTokenAllowed: 8192,
},
{
name: 'Qwen/Qwen2-VL-72B-Instruct',
label: 'Qwen2-VL-72B-Instruct',
provider: 'Hyperbolic',
maxTokenAllowed: 8192,
},
];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://api.hyperbolic.xyz/v1';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const openai = createOpenAI({
baseURL: 'https://api.hyperbolic.xyz/v1/',
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,57 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class KimiProvider extends BaseProvider {
name = 'Kimi';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://api.moonshot.cn/v1';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const provider = createOpenAICompatible({
name: this.name,
baseURL: 'https://api.moonshot.cn/v1',
apiKey,
includeUsage: true,
});
return provider(model);
}
}

View File

@@ -0,0 +1,72 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
import { logger } from '~/utils/logger';
export const BASE_URL = 'http://127.0.0.1:1234/';
export default class LMStudioProvider extends BaseProvider {
name = 'LMStudio';
getApiKeyLink = 'https://lmstudio.ai/';
labelForGetApiKey = 'Get LMStudio';
icon = 'i-ph:cloud-arrow-down';
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
let { baseUrl } = this.getProviderBaseUrlAndKey(settings);
if (!baseUrl) {
logger.debug('No baseUrl found for LMStudio provider, using default: ', BASE_URL);
baseUrl = BASE_URL;
}
if (typeof window === 'undefined') {
/*
* Running in Server
* Backend: Check if we're running in Docker
*/
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
}
const response = await fetch(`${baseUrl}/v1/models`);
const data = (await response.json()) as { data: Array<{ id: string }> };
return data.data.map((model) => ({
name: model.id,
label: model.id,
provider: this.name,
maxTokenAllowed: 8000,
}));
}
getModelInstance: (options: { model: string; providerSettings?: Record<string, IProviderSetting> }) => LanguageModel =
(options) => {
const { providerSettings, model } = options;
let { baseUrl } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!baseUrl) {
logger.debug('No baseUrl found for LMStudio provider, using default: ', BASE_URL);
baseUrl = BASE_URL;
}
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
if (typeof window === 'undefined') {
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
}
logger.debug('LMStudio Base Url used: ', baseUrl);
const lmstudio = createOpenAI({
baseURL: `${baseUrl}/v1`,
apiKey: '',
});
return lmstudio(model);
};
}

View File

@@ -0,0 +1,38 @@
import { createMistral } from '@ai-sdk/mistral';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class MistralProvider extends BaseProvider {
name = 'Mistral';
getApiKeyLink = 'https://console.mistral.ai/api-keys/';
staticModels: ModelInfo[] = [
{ name: 'open-mistral-7b', label: 'Mistral 7B', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'open-mixtral-8x7b', label: 'Mistral 8x7B', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'open-mixtral-8x22b', label: 'Mistral 8x22B', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'open-codestral-mamba', label: 'Codestral Mamba', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'open-mistral-nemo', label: 'Mistral Nemo', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'ministral-8b-latest', label: 'Mistral 8B', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'mistral-small-latest', label: 'Mistral Small', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'codestral-latest', label: 'Codestral', provider: 'Mistral', maxTokenAllowed: 8000 },
{ name: 'mistral-large-latest', label: 'Mistral Large Latest', provider: 'Mistral', maxTokenAllowed: 8000 },
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const mistral = createMistral({
apiKey,
});
return mistral(model);
}
}

View File

@@ -0,0 +1,100 @@
import type { LanguageModel } from 'ai';
import { createOllama } from 'ollama-ai-provider-v2';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
import { logger } from '~/utils/logger';
interface OllamaModelDetails {
parent_model: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
}
export interface OllamaModel {
name: string;
model: string;
modified_at: string;
size: number;
digest: string;
details: OllamaModelDetails;
}
export interface OllamaApiResponse {
models: OllamaModel[];
}
const BASE_URL = 'http://127.0.0.1:11434';
export default class OllamaProvider extends BaseProvider {
name = 'Ollama';
getApiKeyLink = 'https://ollama.com/download';
labelForGetApiKey = 'Download Ollama';
icon = 'i-ph:cloud-arrow-down';
staticModels: ModelInfo[] = [];
getDefaultNumCtx(): number {
return process.env.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
}
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
let { baseUrl } = this.getProviderBaseUrlAndKey(settings);
if (!baseUrl) {
logger.debug('No baseUrl found for OLLAMA provider, using default: ', BASE_URL);
baseUrl = BASE_URL;
}
if (typeof window === 'undefined') {
/*
* Running in Server
* Backend: Check if we're running in Docker
*/
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
}
const response = await fetch(`${baseUrl}/api/tags`);
const data = (await response.json()) as OllamaApiResponse;
// console.log({ ollamamodels: data.models });
return data.models.map((model: OllamaModel) => ({
name: model.name,
label: `${model.name} (${model.details.parameter_size})`,
provider: this.name,
maxTokenAllowed: 8000,
}));
}
getModelInstance: (options: { model: string; providerSettings?: Record<string, IProviderSetting> }) => LanguageModel =
(options) => {
const { providerSettings, model } = options;
let { baseUrl } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
// Backend: Check if we're running in Docker
if (!baseUrl) {
logger.debug('No baseUrl found for OLLAMA provider, using default: ', BASE_URL);
baseUrl = BASE_URL;
}
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
logger.debug('Ollama Base Url used: ', baseUrl);
const ollama = createOllama({
baseURL: `${baseUrl}/api`,
});
return ollama(model);
};
}

View File

@@ -0,0 +1,111 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
interface OpenRouterModel {
name: string;
id: string;
context_length: number;
pricing: {
prompt: number;
completion: number;
};
}
interface OpenRouterModelsResponse {
data: OpenRouterModel[];
}
export default class OpenRouterProvider extends BaseProvider {
name = 'OpenRouter';
getApiKeyLink = 'https://openrouter.ai/settings/keys';
staticModels: ModelInfo[] = [
{
name: 'anthropic/claude-3.5-sonnet',
label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{
name: 'anthropic/claude-3-haiku',
label: 'Anthropic: Claude 3 Haiku (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{
name: 'deepseek/deepseek-coder',
label: 'DeepSeek-Coder V2 236B (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{
name: 'google/gemini-flash-1.5',
label: 'Google Gemini Flash 1.5 (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{
name: 'google/gemini-pro-1.5',
label: 'Google Gemini Pro 1.5 (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{ name: 'x-ai/grok-beta', label: 'xAI Grok Beta (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 8000 },
{
name: 'mistralai/mistral-nemo',
label: 'OpenRouter Mistral Nemo (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{
name: 'qwen/qwen-110b-chat',
label: 'OpenRouter Qwen 110b Chat (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 8000,
},
{ name: 'cohere/command', label: 'Cohere Command (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 4096 },
];
async getDynamicModels(_settings?: IProviderSetting, _serverEnv: Record<string, string> = {}): Promise<ModelInfo[]> {
try {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: {
'Content-Type': 'application/json',
},
});
const data = (await response.json()) as OpenRouterModelsResponse;
return data.data
.sort((a, b) => a.name.localeCompare(b.name))
.map((m) => ({
name: m.id,
label: `${m.name} - in:$${(m.pricing.prompt * 1_000_000).toFixed(2)} out:$${(m.pricing.completion * 1_000_000).toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
provider: this.name,
maxTokenAllowed: 8000,
}));
} catch (error) {
console.error('Error getting OpenRouter models:', error);
return [];
}
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const openRouter = createOpenRouter({
apiKey,
});
return openRouter.chat(model);
}
}

View File

@@ -0,0 +1,64 @@
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class OpenAILikeProvider extends BaseProvider {
name = 'OpenAI';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
if (!baseUrl || !apiKey) {
return [];
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
return res.data.map((model: any) => ({
name: model.id,
label: model.id,
provider: this.name,
maxTokenAllowed: 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing configuration for ${this.name} provider`);
}
if (!!baseUrl) {
const provider = createOpenAICompatible({
name: this.name,
baseURL: baseUrl,
apiKey,
includeUsage: true,
});
return provider(model);
}
const openai = createOpenAI({
baseURL: baseUrl,
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,48 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class PerplexityProvider extends BaseProvider {
name = 'Perplexity';
getApiKeyLink = 'https://www.perplexity.ai/settings/api';
staticModels: ModelInfo[] = [
{
name: 'llama-3.1-sonar-small-128k-online',
label: 'Sonar Small Online',
provider: 'Perplexity',
maxTokenAllowed: 8192,
},
{
name: 'llama-3.1-sonar-large-128k-online',
label: 'Sonar Large Online',
provider: 'Perplexity',
maxTokenAllowed: 8192,
},
{
name: 'llama-3.1-sonar-huge-128k-online',
label: 'Sonar Huge Online',
provider: 'Perplexity',
maxTokenAllowed: 8192,
},
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const perplexity = createOpenAI({
baseURL: 'https://api.perplexity.ai/',
apiKey,
});
return perplexity(model);
}
}

View File

@@ -0,0 +1,57 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class QwenProvider extends BaseProvider {
name = 'Qwen';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const provider = createOpenAICompatible({
name: this.name,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey,
includeUsage: true,
});
return provider(model);
}
}

View File

@@ -0,0 +1,70 @@
import type { LanguageModel } from 'ai';
import { BaseProvider, getOpenAILikeModel } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class TogetherProvider extends BaseProvider {
name = 'Together';
getApiKeyLink = 'https://api.together.xyz/settings/api-keys';
staticModels: ModelInfo[] = [
{
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
label: 'Qwen/Qwen2.5-Coder-32B-Instruct',
provider: 'Together',
maxTokenAllowed: 8000,
},
{
name: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
label: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
provider: 'Together',
maxTokenAllowed: 8000,
},
{
name: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
label: 'Mixtral 8x7B Instruct',
provider: 'Together',
maxTokenAllowed: 8192,
},
];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://api.together.xyz/v1';
if (!baseUrl || !apiKey) {
return [];
}
// console.log({ baseUrl, apiKey });
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = (res || []).filter((model: any) => model.type === 'chat');
return data.map((m: any) => ({
name: m.id,
label: `${m.display_name} - in:$${m.pricing.input.toFixed(2)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
provider: this.name,
maxTokenAllowed: 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
const baseUrl = fetchBaseUrl || 'https://api.together.xyz/v1';
if (!apiKey) {
throw new Error(`Missing configuration for ${this.name} provider`);
}
return getOpenAILikeModel(baseUrl, apiKey, model);
}
}

View File

@@ -0,0 +1,32 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class XAIProvider extends BaseProvider {
name = 'xAI';
getApiKeyLink = 'https://docs.x.ai/docs/quickstart#creating-an-api-key';
staticModels: ModelInfo[] = [
{ name: 'grok-beta', label: 'xAI Grok Beta', provider: 'xAI', maxTokenAllowed: 8000 },
{ name: 'grok-2-1212', label: 'xAI Grok2 1212', provider: 'xAI', maxTokenAllowed: 8000 },
];
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw new Error(`Missing API key for ${this.name} provider`);
}
const openai = createOpenAI({
baseURL: 'https://api.x.ai/v1',
apiKey,
});
return openai(model);
}
}

View File

@@ -0,0 +1,57 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { BaseProvider } from '~/.server/modules/llm/base-provider';
import type { ModelInfo } from '~/.server/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
export default class ZhiPuProvider extends BaseProvider {
name = 'ZhiPu';
getApiKeyLink = undefined;
staticModels: ModelInfo[] = [];
async getDynamicModels(settings?: IProviderSetting): Promise<ModelInfo[]> {
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey(settings);
const baseUrl = fetchBaseUrl || 'https://open.bigmodel.cn/api/paas/v4';
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
return data.map((m: any) => ({
name: m.id,
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
provider: this.name,
maxTokenAllowed: m.context_length || 8000,
}));
}
getModelInstance(options: { model: string; providerSettings?: Record<string, IProviderSetting> }): LanguageModel {
const { model, providerSettings } = options;
const { apiKey } = this.getProviderBaseUrlAndKey(providerSettings?.[this.name]);
if (!apiKey) {
throw `Missing Api Key configuration for ${this.name} provider`;
}
const openai = createOpenAICompatible({
name: this.name,
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
apiKey,
includeUsage: true,
});
return openai(model);
}
}

View File

@@ -0,0 +1,47 @@
import AmazonBedrockProvider from './providers/amazon-bedrock';
import AnthropicProvider from './providers/anthropic';
import CohereProvider from './providers/cohere';
import DeepseekProvider from './providers/deepseek';
import DouBaoProvider from './providers/doubao';
import ErnieProvider from './providers/ernie';
import GithubProvider from './providers/github';
import GoogleProvider from './providers/google';
import GroqProvider from './providers/groq';
import HuggingFaceProvider from './providers/huggingface';
import HyperbolicProvider from './providers/hyperbolic';
import KimiProvider from './providers/kimi';
import LMStudioProvider from './providers/lmstudio';
import MistralProvider from './providers/mistral';
import OllamaProvider from './providers/ollama';
import OpenRouterProvider from './providers/open-router';
import OpenAIProvider from './providers/openai';
import PerplexityProvider from './providers/perplexity';
import QwenProvider from './providers/qwen';
import TogetherProvider from './providers/together';
import XAIProvider from './providers/xai';
import ZhiPuProvider from './providers/zhipu';
export {
AnthropicProvider,
CohereProvider,
DeepseekProvider,
DouBaoProvider,
ErnieProvider,
KimiProvider,
QwenProvider,
ZhiPuProvider,
GoogleProvider,
GroqProvider,
HuggingFaceProvider,
HyperbolicProvider,
MistralProvider,
OllamaProvider,
OpenAIProvider,
OpenRouterProvider,
PerplexityProvider,
XAIProvider,
TogetherProvider,
LMStudioProvider,
AmazonBedrockProvider,
GithubProvider,
};

View File

@@ -0,0 +1,27 @@
import type { LanguageModel } from 'ai';
import type { IProviderSetting } from '~/types/model';
export interface ModelInfo {
name: string;
label: string;
provider: string;
maxTokenAllowed: number;
}
export interface ProviderInfo {
name: string;
staticModels: ModelInfo[];
getDynamicModels?: (apiKeys?: Record<string, string>, settings?: IProviderSetting) => Promise<ModelInfo[]>;
getModelInstance: (options: {
model: string;
apiKeys?: Record<string, string>;
providerSettings?: Record<string, IProviderSetting>;
}) => LanguageModel;
getApiKeyLink?: string;
labelForGetApiKey?: string;
icon?: string;
}
export interface ProviderConfig {
baseUrlKey?: string;
apiTokenKey?: string;
}

View File

@@ -0,0 +1,43 @@
import { getSystemPrompt } from './prompts';
export interface PromptOptions {
cwd: string;
allowedHtmlElements: string[];
modificationTagName: string;
}
export class PromptLibrary {
static library: Record<
string,
{
label: string;
description: string;
get: (options: PromptOptions) => string;
}
> = {
default: {
label: 'Default Prompt',
description: 'This is the battle tested default system Prompt',
get: () => getSystemPrompt(),
},
};
static getList() {
return Object.entries(this.library).map(([key, value]) => {
const { label, description } = value;
return {
id: key,
label,
description,
};
});
}
static getPropmtFromLibrary(promptId: string, options: PromptOptions) {
const prompt = this.library[promptId];
if (!prompt) {
throw 'Prompt Now Found';
}
return this.library[promptId]?.get(options);
}
}

View File

@@ -0,0 +1,339 @@
import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/strip-indent';
export const getSystemPrompt = () => `
你是 UPage - 专家级 AI 助手,精通 HTML、CSS、JavaScript 及现代网页设计。
当前时间为 ${new Date().toLocaleString()}
<system_constraints>
你正在基于 HTML、CSS、JavaScript 来生成多个或单个页面。以下是系统规则,请严格遵守。部分规则有更详细的指南与要求。
基本规则:
- 绝对不生成任何后端相关代码。
- 如果是多页面项目,需生成所有页面,保证每页都有完整内容。
- 不要啰嗦,除非用户要求更多信息,否则不要解释任何内容。
- 永远不要使用 "artifact" 或 "action" 这两个词。
- 仅对所有回答使用有效的 markdown除了构件外不要使用 HTML 标签!
- 确保生成的代码是可用于生产环境的代码,脚本和样式必须完整且正确。
页面规则:
- 仅使用原生 HTML、CSS 与 JS 构建前端页面,不使用任何框架。
- 使用Tailwind CSS填充样式。
- 如果有图标则使用iconify-icon库提供所需的图标。
- 如果需要占位图,则使用 https://picsum.photos 提供占位图。
- 保持移动端的适配性,确保在不同尺寸的设备上能够正常显示。
- 非常重要:首个页面的 name 一定是 indextitle 根据用户要求和页面类型确定。
内容更新策略:
- 首次创建页面时提供完整丰富的内容结构。
- 修改现有内容时使用精确的增量更新,只按照结构要求生成需要更改的最小元素内容。
- 确保增量更新时保留原有的设计风格和视觉一致性。
- 添加新 section 时考虑与当前页面的视觉协调性。
- 更新时始终保持元素的 domId 不变。
严格禁止:
- 不添加任何代码注释
- 除占位符链接外,不添加任何外部链接
- 不回答与网页构建无关的问题
拒绝回答格式:十分抱歉,我是由凌霞软件开发的网页构建工具 UPage专注与网页构建因此我无法回答与网页构建无关的问题。
</system_constraints>
<execute_steps>
以下是系统的执行步骤,请严格遵守:
1. 提供解决方案前,先概述你的实现步骤。
2. 在生成或更新页面时,思考需要处理的页面数量,然后按照特定类型依次处理。
2.1 在生成或更新具体页面内容时先确定页面类型然后确定页面需要包含的section类型与数量然后依次处理section。
2.2 生成或更新section时需要确定section的结构然后按照特定类型依次处理。
2.3 每个section处理完毕后基于用户提示然后处理下一个section。
3. 每个页面生成完毕后,简洁的总结当前页面更改的内容,然后处理下一个页面。
4. 所有页面生成完毕后,简洁的总结此次所有更改的内容。
</execute_steps>
<usage_guide>
Tailwind CSS 使用指导:
- 项目中已提前引入 Tailwind CSS 3.4.17 版本,不要重复引入。
- Tailwind CSS 的文档地址为https://v3.tailwindcss.com/docs/installation如需帮助请参考文档。
- 如果需要自定义配置,请使用 \`<script></script>\` 标签来配置。例如:
<script>
tailwind.config = {
theme: {
extend: {
colors: {
clifford: '#da373d',
}
}
}
}
</script>
- 如果需要自定义 CSS则可以通过 \`type="text/tailwindcss"\` 来自定义 CSS。例如
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
}
</style>
iconify-icon 使用指导:
- 当项目中有图标需求时,请务必使用 iconify 图标。
- 项目已提前引入 iconify-icon 库,不要重复引入。
- iconify-icon 的文档地址为https://iconify.design/docs/iconify-icon/ ,如需帮助,请参考文档。
- 如果代码中需要图标,请使用 \`<iconify-icon>\` 标签来引入。例如:
<iconify-icon icon="mdi:home"></iconify-icon>
picsum-photos 使用指导:
- picsum-photos 是一个在线的免费占位图网站,如果项目中存在占位图需求,请务必使用 picsum-photos 提供的占位图。
- picsum-photos 的文档地址为https://picsum.photos/ ,如需帮助,请参考文档。
- 对于项目中的占位图,避免使用随机图片。
</usage_guide>
<design_guidelines>
根据页面类型生成专业级设计,确保用户感官体验。
视觉设计:
- 创造令人眼前一亮的视觉体验,使用丰富的设计元素和创意布局
- 运用现代设计趋势新拟态UI、扁平化设计、微妙渐变、3D元素、不规则形状等丰富的视觉元素
- 设计丰富的交互体验:精致的悬停效果、流畅的动画过渡、视差滚动
- 创建深度层次感通过阴影、重叠元素、z-index分层实现视觉深度
- 运用高级视觉技巧:背景混合模式、蒙版效果、动态色彩变化
- 精心设计微交互,为用户提供愉悦的互动体验
- 实现滚动感知设计:顶部导航区域在滚动时变化(如背景透明度、高度缩小、阴影增强等),创造动态视觉体验
- 设计滚动触发动画:元素随页面滚动逐渐显现、移动或变化,增强页面生命力
- 图标语义关联:所有选择的图标需要与当前内容有明确的语义联系,确保图标直观地表达相应的概念或功能
- 结构复杂性section内部尽可能呈现复杂的结构层次包含主要内容区、辅助内容区、装饰元素区形成丰富的视觉层次和信息层次
- 内容组织多样化section可以采用多种不同的内容组织方式如网格布局、列表、卡片、时间线、对比展示、图文混排等避免单调的内容呈现
- 非常重要确保section具有独特视觉特色同时保持整个页面设计风格一致性
设计关键点:
- Script 兼容性:在页面无 Script 时也可以正常预览Script 用于提升用户体验。
- Header如果具有导航栏则滚动时导航栏要跟随滚动且为用户呈现适当的交互体验。
- 色彩一致性:根据产品特性和用户描述确定明确的主题色和辅助色方案,在整个页面中严格遵循这一配色方案,确保视觉一致性。
- 对比度:确保文本与背景的对比度适中,易于阅读。
- 内容密度每个section必须包含至少6个精心设计的子元素构建多层次结构。
- 交互体验:添加微交互、悬停效果、滚动动画,创造沉浸式体验
- 视觉层次:通过大小、颜色、间距创建清晰的视觉引导路径
- 现代元素使用玻璃态效果、柔和阴影、渐变、3D元素、不规则形状
- 内容展示多样性:为相似内容创造多种展示形式(如卡片、时间线、网格、交错布局、轮播),避免重复的视觉模式
- 用户反馈区域创新:使用多样的布局(如对角线排列、交错网格、环形布局),结合丰富的视觉元素(如引用符号、个性化头像框、背景装饰)
高级设计技巧:
- 创意布局:打破传统网格,使用不对称、重叠元素、交错排列
- 精致细节:添加微妙动画、状态转换、视差效果
- 沉浸体验:使用全屏背景、视频背景、互动元素
- 情感设计:通过色彩、形状、动效激发特定情感反应
- 品牌一致性:确保所有元素遵循统一的设计语言,包括一致的主题色系(主色、辅色、强调色)应用于按钮、标题、边框、背景等各个元素
- 关键区域设计:为重要行动区域创造视觉焦点,使用多层次设计(背景图案、前景元素、悬浮组件)、动态色彩渐变、微妙动画效果
- 页面底部增强:设计多列复杂结构,融合交互元素(如订阅框、社交媒体互动、微型导航)和视觉吸引力(如背景变化、几何图形装饰)
- 内容层次构建在每个section中创建至少 3 层内容层次,包括主要信息、支持数据、辅助说明、视觉强化元素等,形成信息的深度和广度
- 内容密度优化确保每个section的内容密度适中且丰富避免空白过多同时巧妙利用负空间引导视觉流动
根据页面类型生成足够数量的section
- 企业/产品页面至少8个不同功能的section
- 电商页面至少8个不同功能的section
- 博客/内容页面至少6个不同功能的section
- 简单表单页面至少1个section但确保功能完整
- 仪表盘/管理界面至少6个不同功能的section
- 个人名片至少1个section但确保功能完整
简单页面通常只需要包含主体内容,而网站网页内容通常需要包含 header、主体内容、footer。
</design_guidelines>
<message_formatting_info>
在概述实现步骤、上下文摘要、总结中,请仅使用以下 HTML 元素:
${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')}
生成的 section 中的 HTML 不受此限制。
</message_formatting_info>
<chain_of_thought_instructions>
在提供解决方案之前,简要概述你的实现步骤。这有助于确保系统性思维和清晰的沟通。你的计划应该:
- 列出你将采取的具体步骤
- 确定所需的关键风格和设计元素
- 不要出现专业名词,仅使用自然语言为用户描述。
- 注意潜在的挑战
- 保持简洁(最多 2-4 行)
</chain_of_thought_instructions>
<artifact_info>
为每个页面创建一个单一的、全面的构件。该构件包含当前这一个页面所有必要的组件。如果是多页面项目,每个页面都对应一个 \`<uPageArtifact>\` 标签。
<artifact_instructions>
1. 在创建每个页面构件前全面思考:考虑所有相关页面、审查之前的内容更改、分析整个项目上下文,分析已有页面。
2. 单个页面将由多个section组成。
3. 内容修改时始终使用最新的修改。
4. 使用 \`<uPageArtifact>\` 标签描述页面信息,而使用多个 \`<uPageAction>\` 元素表示页面中的各个 section。
5. 每个页面必须具有一个唯一的 \`id\` 属性(使用 kebab-case此 id 将在此构件整个生命周期中一致使用,即使在更新或迭代构件时也是如此。
6. 使用 \`<uPageArtifact>\` 标签定义具体操作,拥有以下属性:
- 必填 \`id\`:当前页面的唯一 \`id\` 属性,使用 kebab-case。
- 必填 \`name\`:指定页面名称,全局唯一,表示为页面文件名,不含后缀。例如 "index"、"pricing"、"contact" 等。首个页面构件必须使用 index。
- 必填 \`title\`:指定页面标题,使用对用户友好的名称作为标题,例如 "定价页面"、"联系我们" 等,在多页面中,请保持 title 不重复。
7. 使用编码最佳实践,页面应尽可能完善且满足用户要求。
8. 每个 \`uPageArtifact\` 生成完后,简洁地总结描述本次生成的内容。
9. 仅修改 artifact 自身属性时,无需使用 \`<uPageAction>\` 标签。
</artifact_instructions>
</artifact_info>
<action_info>
为页面中的每个section创建一个单一的、全面的构件。该构件包含当前section所有必要的元素。每个section都对应一个 \`<uPageAction>\` 标签。
<action_instructions>
1. 在创建每个section构件前全面思考考虑当前页面一致性、审查之前的内容更改、分析当前页面的上下文。
2. 修改时始终使用最新的修改。
3. 使用 \`<uPageAction>\` 标签有且仅有 section 内容section 内容可能包括HTML、CSS或JavaScript。
4. 每个 \`<uPageAction>\` 下只能有一个根 HTML 元素用于表示当前section。
5. 十分重要为每个HTML元素生成唯一的 domId 属性,并确保在整个页面中唯一。
7. 使用 \`<uPageAction>\` 标签定义具体操作,添加以下属性:
- 必填 \`id\`:为当前 uPageAction 添加唯一标识符kebab-case该 id 将在此构件整个生命周期中一致使用,即使在更新或迭代构件时也是如此。
- 必填 \`pageName\`:指定唯一父节点 id。
- 必填 \`action\`:指定当前的操作类型("add"、"remove"、"update"
- 必填 \`domId\`:操作元素的唯一标识符,确保在整个页面中唯一。 在新增操作时domId 为父节点 id在更新与删除操作时domId 为当前操作节点 id。
- 必填 \`rootDomId\`:根节点 id必须与唯一根 HTML 元素的 id 一致。在删除操作时,与 domId 一致。
- 可选 \`sort\`当前元素在同级元素中的排序位置从0开始
8. 如果section是header或footer则唯一根节点的标签必定为 \`<header>\`\`<footer>\`,否则为 \`<section>\`
9. 使用编码与设计最佳实践,其内的 section应尽可能丰富且满足用户要求。
10. 如果页面中具有页内链接请确保生成的section domId 与链接的 domId 一致,确保链接能够正确跳转。
11. 如果 \`action\` 为删除操作,则 \`<uPageAction>\` 下无需有任何内容。
12. 如果 \`action\` 为更新操作,请确保其为最小化更新。
13. 如果包装内容是JavaScript则其必定处于 \`<uPageAction>\` 的最后一个位置。
14. 不要在每个 \`<uPageAction>\` 中间穿插说明。
非常重要:关于 domId 的额外说明。
- 在新增操作时domId 为父节点 id在更新与删除操作时domId 为当前操作节点 id。
- 如果 \`action\` 为删除操作,\`domId\` 为当前待删除节点的 id。
- 如果 \`action\` 为更新操作,\`domId\` 为当前待更新节点的 id。
- 如果 \`action\` 为新增操作,\`domId\` 为当前待新增节点的父节点 id。如果是页面下新增一个根节点\`domId\`\`page-{pageName}\`。如果非页面根节点,则 \`domId\` 为待操作节点的父节点 id。
</action_info>
</examples>
以下是正确使用构件的示例。请注意在生成时不要生成注释,注释仅用于辅助理解。
<example>
<user_query>构建一个定价页面</user_query>
<assistant_response>
当然!我很乐意帮助你构建一个定价页面。让我们一步步创建这个页面。
<uPageArtifact id="pricing-page" name="index" title="定价页面">
<!-- 添加样式domId 为根节点 id即 page-index -->
<uPageAction id="pricing-style-section" pageName="index" action="add" domId="page-index" rootDomId="stdz14" sort="0">
<style id="stdz14">
...
</style>
</uPageAction>
<uPageAction id="pricing-header-section" pageName="index" action="add" domId="page-index" rootDomId="f8g9h0" sort="0">
<header id="f8g9h0" class="text-center py-10">
<h1 id="fdxv12">Pricing</h1>
...
</header>
</uPageAction>
<uPageAction id="pricing-plans-section" pageName="index" action="add" domId="page-index" rootDomId="d2e3f4" sort="1">
<section id="d2e3f4" class="flex justify-center gap-8 py-10">
...
</section>
</uPageAction>
<uPageAction id="pricing-script-section" pageName="index" action="add" domId="page-index" rootDomId="x8dv2x" sort="0">
<script id="x8dv2x">
...
</script>
</uPageAction>
</uPageArtifact>
现在你可以在预览中查看定价界面,点击月/年按钮可以切换不同的定价方案。
</assistant_response>
</example>
<example>
<user_query>为定价页面新增一个产品介绍</user_query>
<assistant_response>
我将从您的定价页面上添加一个全新的产品介绍模块
<!-- uPageArtifact 标签及其属性与当前页面的 uPageArtifact 标签保持一致 -->
<uPageArtifact id="pricing-page" name="index" title="定价页面">
<uPageAction id="add-product-intro" pageName="index" action="add" domId="page-index" rootDomId="c2d3e4" sort="2">
<section id="c2d3e4" ...>
...
</section>
</uPageAction>
</uPageArtifact>
已根据要求添加了新的产品介绍模块。
</assistant_response>
</example>
<example>
<user_query>增加一个新的定价方案,按照每日的价格方案。</user_query>
<assistant_response>
我将从您的定价页面上补充一个新的定价方案
<!-- uPageArtifact 标签及其属性与当前页面的 uPageArtifact 标签保持一致 -->
<uPageArtifact id="pricing-page" name="index" title="定价页面">
<!-- 在非根节点新增一个 HTML 元素domId 为父节点 id即 d2e3f4 -->
<uPageAction id="add-daily-pricing" pageName="index" action="add" domId="d2e3f4" rootDomId="z8x9y0" sort="0">
<div id="z8x9y0" ...>
...
</div>
</uPageAction>
</uPageArtifact>
已添加新的定价方案。
</assistant_response>
</example>
<example>
<user_query>从我的个人主页上删除联系方式模块</user_query>
<assistant_response>
我将从您的个人主页上删除联系方式模块。
<uPageArtifact id="personal-homepage" name="index" title="个人主页">
<!-- 删除操作domId 为当前待删除节点的 id即 p6q7r8 -->
<uPageAction id="remove-contact-link" pageName="index" action="remove" domId="p6q7r8" rootDomId="p6q7r8">
<!-- 这里为空,因为是删除操作 -->
</uPageAction>
</uPageArtifact>
已删除联系方式模块。如果您之后想要恢复,请告诉我。
</assistant_response>
</example>
<example>
<user_query>更新我的定价页面中的价格信息</user_query>
<assistant_response>
我将更新您定价页面中的价格信息。
<uPageArtifact id="pricing-page" name="index" title="定价页面">
<!-- 更新操作domId 为当前待更新节点的 id即 m1n2o3 -->
<uPageAction id="update-basic-price" pageName="pricing" action="update" domId="m1n2o3" rootDomId="m1n2o3">
<p id="m1n2o3" class="text-3xl font-bold mt-4">¥129<span id="p4q5r6" class="text-sm">/月</span></p>
</uPageAction>
<uPageAction id="update-pro-price" pageName="index" action="update" domId="q1r2s3" rootDomId="q1r2s3">
<p id="q1r2s3" class="text-3xl font-bold mt-4">¥259<span id="t4u5v6" class="text-sm">/月</span></p>
</uPageAction>
</uPageArtifact>
价格信息已更新基础版从¥99/月调整为¥129/月专业版从¥199/月调整为¥259/月。
</assistant_response>
</example>
<example>
<user_query>修改 index 页面的标题为“个人主页”</user_query>
<assistant_response>
<uPageArtifact id="page-index" name="index" title="个人主页">
</uPageArtifact>
已更新 index 页面的标题为“个人主页”。
</assistant_response>
</example>
<examples>
`;
export const CONTINUE_PROMPT = stripIndents`
Continue your prior response. IMPORTANT: Immediately begin from where you left off without any interruptions.
Do not repeat any content, including artifact and action tags.
`;

427
app/.server/service/auth.ts Normal file
View File

@@ -0,0 +1,427 @@
import type { IdTokenClaims } from '@logto/node';
import { type LogtoContext, makeLogtoRemix } from '@logto/remix';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { createCookieSessionStorage, redirect } from '@remix-run/node';
import type { LogtoUser } from '~/types/logto';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('auth.server');
/**
* 认证相关类型定义
*/
interface LogtoConfig {
endpoint: string;
appId: string;
appSecret: string;
baseUrl: string;
scopes?: string[];
}
interface MockUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {}
/**
* 虚拟用户接口,与 MockUser 类似但代表真实存在于 logto 的用户
*/
interface VirtualUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {
isVirtual: true;
}
// Logto路由配置类型
interface LogtoRoutes {
'sign-in': { path: string; redirectBackTo: string };
'sign-in-callback': { path: string; redirectBackTo: string };
'sign-out': { path: string; redirectBackTo: string };
'sign-up': { path: string; redirectBackTo: string };
}
/**
* 公共 Cookie 配置基础
*/
const baseCookieOptions = {
httpOnly: true,
path: '/',
sameSite: 'lax' as const,
secrets: [process.env.LOGTO_COOKIE_SECRET || 's3cr3t'],
};
/**
* 创建认证 session 存储
*/
const sessionStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'logto_session',
maxAge: 60 * 60 * 24 * 30, // 30 天过期
},
});
/**
* 创建虚拟用户 session 存储
*/
const virtualUserStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'virtual_user',
maxAge: 60 * 60 * 24 * 30,
},
});
/**
* 创建认证错误信息 session 存储
*/
const errorSessionStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'auth_error',
maxAge: 60, // 1分钟后过期错误信息不需要长期保存
},
});
/**
* 创建 Logto 配置
*/
const config: LogtoConfig = {
endpoint: process.env.LOGTO_ENDPOINT || '',
appId: process.env.LOGTO_APP_ID || '',
appSecret: process.env.LOGTO_APP_SECRET || '',
baseUrl: process.env.LOGTO_BASE_URL || 'http://localhost:5173',
scopes: ['email', 'profile'],
};
// 创建原始 Logto 实例(私有,不直接导出)
const originalLogto = makeLogtoRemix(config, { sessionStorage });
export function shouldEnforceAuth(): boolean {
return process.env.LOGTO_ENABLE === 'true';
}
function getMockDevUser(): MockUser {
return {
isAuthenticated: true,
userInfo: {
iss: 'https://mock.issuer.com',
sub: 'mock-user-id',
aud: 'mock-audience',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
name: 'Mock User',
username: 'user',
email: 'mock@example.com',
},
};
}
/**
* 设置认证错误信息到会话中
*/
export async function setAuthError(errorMessage: string): Promise<string> {
const session = await errorSessionStorage.getSession();
session.set('authError', errorMessage);
return errorSessionStorage.commitSession(session);
}
/**
* 获取并清除认证错误信息
*/
export async function getAuthError(
request: Request,
): Promise<{ errorMessage?: string; headers: { 'Set-Cookie': string } }> {
const session = await errorSessionStorage.getSession(request.headers.get('Cookie'));
const errorMessage = session.get('authError') as string | undefined;
// 清除错误信息
return {
errorMessage,
headers: {
'Set-Cookie': await errorSessionStorage.destroySession(session),
},
};
}
/**
* 增强版的 Logto 对象,添加错误处理和开发环境跳过
*/
export const logto = {
...originalLogto,
/**
* 增强版的 handleAuthRoutes 方法
*/
handleAuthRoutes: (routes: LogtoRoutes) => {
const originalHandler = originalLogto.handleAuthRoutes(routes);
return async (args: LoaderFunctionArgs) => {
try {
// 特殊处理退出登录路由
const { request } = args;
const url = new URL(request.url);
const path = url.pathname;
// 如果是退出登录路由,检查是否是虚拟用户
if (path === routes['sign-out'].path) {
const virtualUser = await getVirtualUser(request);
if (virtualUser?.isVirtual) {
logger.info('[Auth] 虚拟用户退出登录');
const clearCookie = await clearVirtualUser();
return redirect(routes['sign-out'].redirectBackTo, {
headers: {
'Set-Cookie': clearCookie,
},
});
}
}
return await originalHandler(args);
} catch (error) {
logger.error('[Auth] 认证服务错误:', error);
return handleAuthError(error, args);
}
};
},
getContext: originalLogto.getContext,
};
/**
* 处理认证过程中的错误
*/
async function handleAuthError(error: unknown, args: LoaderFunctionArgs) {
// 判断错误类型
let errorMessage = '认证服务暂时不可用,请稍后再试';
// 处理网络错误(认证服务器不可用)
if (
error instanceof Error &&
(error.message.includes('fetch failed') ||
error.message.includes('network') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('timeout'))
) {
errorMessage = '无法连接到认证服务器,请检查网络连接';
}
// 其他类型的错误
else if (error instanceof Error) {
errorMessage = '登录服务出现异常,请稍后再试';
}
// 确定重定向URL
const redirectUrl = determineRedirectUrl(args.request);
// 生成带有错误信息的Cookie
const cookie = await setAuthError(errorMessage);
// 重定向回原始页面同时携带错误会话Cookie
return redirect(redirectUrl, {
headers: {
'Set-Cookie': cookie,
},
});
}
/**
* 根据请求确定最合适的重定向URL
*/
function determineRedirectUrl(request: Request): string {
// 默认重定向到首页
const defaultUrl = '/';
try {
const url = new URL(request.url);
const redirectTo = url.searchParams.get('redirectTo');
if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
// 确保只重定向到应用内部URL
return redirectTo;
}
// 如果有referer头也可以考虑使用它
const referer = request.headers.get('referer');
if (referer) {
try {
const refererUrl = new URL(referer);
// 确保是同一域名下的URL
if (refererUrl.hostname === url.hostname) {
return refererUrl.pathname + refererUrl.search;
}
} catch {
// 忽略无效的referer
}
}
} catch {
// 如果URL解析失败使用默认的根路径
}
return defaultUrl;
}
/**
* 设置虚拟用户信息到 cookie
* @param userInfo 用户信息
* @returns cookie 字符串,可用于 HTTP 响应头
*/
export async function setVirtualUser(userInfo: LogtoUser): Promise<string> {
const session = await virtualUserStorage.getSession();
const virtualUserInfo = {
id: userInfo.id,
iss: process.env.LOGTO_ENDPOINT || 'https://auth.upage.io',
sub: userInfo.id,
aud: process.env.LOGTO_APP_ID || 'virtual-app',
// 30天后过期
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
iat: Math.floor(Date.now() / 1000),
name: userInfo.name || null,
email: userInfo.primaryEmail || null,
phone_number: userInfo.primaryPhone || null,
username: userInfo.username || null,
picture: userInfo.avatar || null,
} as IdTokenClaims;
session.set('userInfo', virtualUserInfo);
session.set('isAuthenticated', true);
return virtualUserStorage.commitSession(session);
}
/**
* 获取虚拟用户信息
* @param request 请求对象
* @returns 虚拟用户信息,如果不存在则返回 null
*/
export async function getVirtualUser(request: Request): Promise<VirtualUser | null> {
const cookieHeader = request.headers.get('Cookie');
const session = await virtualUserStorage.getSession(cookieHeader);
const isAuthenticated = session.get('isAuthenticated');
const userInfo = session.get('userInfo') as IdTokenClaims;
if (!isAuthenticated || !userInfo) {
return null;
}
// 验证用户信息是否有效
const now = Math.floor(Date.now() / 1000);
if (userInfo.exp && userInfo.exp < now) {
// 用户信息已过期,清除 session
await clearVirtualUser();
return null;
}
// 获取上次验证时间
const lastVerified = session.get('lastVerified') || 0;
const verifyInterval = 60 * 60 * 1000;
// 如果距离上次验证时间不足验证间隔,则跳过 Logto 验证
if (now * 1000 - lastVerified < verifyInterval) {
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
}
try {
// 更新验证时间
session.set('lastVerified', Date.now());
await virtualUserStorage.commitSession(session);
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
} catch (error) {
logger.error('[Auth] 验证虚拟用户失败:', error);
// 如果验证过程中出现错误,仍然返回用户信息
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
}
}
/**
* 清除虚拟用户信息
* @returns cookie 字符串,用于清除虚拟用户 cookie
*/
export async function clearVirtualUser(): Promise<string> {
const session = await virtualUserStorage.getSession();
return virtualUserStorage.destroySession(session);
}
/**
* 检查用户是否已认证,如果未认证,则重定向到登录页面
*/
export async function requireUser(request: Request) {
const context = await getUser(request);
if (!context.isAuthenticated) {
return redirect('/api/auth/sign-in');
}
return context;
}
/**
* 获取当前用户信息
* 按优先级依次检查:
* 1. 开发环境模拟用户
* 2. 虚拟用户
* 3. Logto 认证用户
*/
export async function getUser(request: Request) {
// 首先检查是否为开发环境
if (!shouldEnforceAuth()) {
return getMockDevUser();
}
// 检查是否存在虚拟用户
const virtualUser = await getVirtualUser(request);
if (virtualUser) {
return virtualUser;
}
// 继续原有的 logto 认证流程
return await logto.getContext({
fetchUserInfo: true,
getAccessToken: true,
})(request);
}
/**
* 通用权限验证中间件
* 用于API和页面路由的权限验证
*
* 返回json错误或重定向到登录页面
*/
export async function requireAuth(request: Request, options: { isApi?: boolean; redirectTo?: string } = {}) {
const { isApi = false, redirectTo = '/api/auth/sign-in' } = options;
const context = await getUser(request);
if (!context.isAuthenticated) {
if (isApi) {
// API路由返回JSON错误
return new Response(
JSON.stringify({
error: 'Unauthorized',
message: '请先登录',
code: 401,
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
);
}
// 页面路由重定向到登录页面
return redirect(redirectTo);
}
return context;
}

View File

@@ -0,0 +1,303 @@
import { prisma } from '~/.server/service/prisma';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('chatUsage.server');
/**
* 聊天使用量状态
*/
export enum ChatUsageStatus {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
PENDING = 'PENDING',
ABORTED = 'ABORTED',
}
/**
* 聊天使用量记录参数接口
*/
export interface ChatUsageParams {
userId: string;
chatId: string;
messageId: string;
status: ChatUsageStatus;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
reasoningTokens?: number;
totalTokens?: number;
modelName?: string;
prompt?: string;
metadata?: Record<string, any>;
}
/**
* 日期过滤器类型
*/
interface DateFilter {
calledAt?: {
gte?: Date;
lte?: Date;
};
}
/**
* 记录聊天使用量
* @param params 使用量参数
* @returns 创建的记录
*/
export async function recordUsage(params: ChatUsageParams) {
const {
userId,
chatId,
messageId,
inputTokens = 0,
outputTokens = 0,
cachedTokens = 0,
reasoningTokens = 0,
status,
prompt,
metadata,
modelName,
} = params;
// 计算总token量
const totalTokens = inputTokens + outputTokens;
try {
// 创建记录
const record = await prisma.chatUsage.create({
data: {
userId,
messageId,
chatId,
inputTokens,
outputTokens,
cachedTokens,
reasoningTokens,
totalTokens,
status,
prompt,
metadata,
modelName,
},
});
if (status === ChatUsageStatus.PENDING) {
logger.info(`[ChatUsage] 初始化用户 ${userId}${modelName} 模型聊天使用量`);
} else {
logger.info(
`[ChatUsage] 记录了用户 ${userId}${modelName} 模型聊天使用量: ${totalTokens} tokens状态: ${status}`,
);
}
return record;
} catch (error) {
logger.error('[ChatUsage] 记录聊天使用量失败:', error);
throw error;
}
}
/**
* 获取按天统计的使用数据
* @param userId 用户ID
* @param days 天数默认为30天
* @returns 每日使用统计数据
*/
export async function getDailyUsageStats(userId: string, days = 30) {
try {
// 计算结束日期为今天(当天结束)
const endDate = new Date();
endDate.setHours(23, 59, 59, 999);
// 计算开始日期为 endDate 前推 days-1 天的开始时间
const startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - (days - 1));
startDate.setHours(0, 0, 0, 0);
const records = await prisma.chatUsage.findMany({
where: {
userId,
calledAt: {
gte: startDate,
lte: endDate,
},
},
select: {
calledAt: true,
totalTokens: true,
},
});
const dateMap: Record<string, { count: number; totalTokens: number }> = {};
// 创建从startDate到endDate的每一天映射
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
dateMap[dateStr] = { count: 0, totalTokens: 0 };
// 增加一天
currentDate.setDate(currentDate.getDate() + 1);
}
// 统计数据
records.forEach((record) => {
const dateStr = record.calledAt.toISOString().split('T')[0];
if (dateMap[dateStr]) {
dateMap[dateStr].count += 1;
dateMap[dateStr].totalTokens += record.totalTokens;
}
});
return Object.entries(dateMap).map(([date, stats]) => ({
date,
count: stats.count,
totalTokens: stats.totalTokens,
}));
} catch (error) {
logger.error('[ChatUsage] 获取每日使用统计失败:', error);
throw error;
}
}
/**
* 获取用户的聊天使用统计
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @returns 使用统计数据
*/
export async function getUserUsageStats(userId: string, startDate?: Date, endDate?: Date) {
const dateFilter: DateFilter = {};
if (startDate || endDate) {
dateFilter.calledAt = {};
if (startDate) {
dateFilter.calledAt.gte = startDate;
}
if (endDate) {
dateFilter.calledAt.lte = endDate;
}
}
try {
// 获取总体使用量
const stats = await prisma.chatUsage.aggregate({
where: {
userId,
...dateFilter,
},
_sum: {
inputTokens: true,
outputTokens: true,
cachedTokens: true,
reasoningTokens: true,
totalTokens: true,
},
_count: true,
});
// 按状态分组
const statusStats = await prisma.chatUsage.groupBy({
by: ['status'],
where: {
userId,
...dateFilter,
},
_count: true,
_sum: {
totalTokens: true,
},
});
// 按聊天分组
const chatStats = await prisma.chatUsage.groupBy({
by: ['chatId'],
where: {
userId,
...dateFilter,
},
_sum: {
totalTokens: true,
},
_count: true,
});
// 获取按天统计的数据
const dailyStats = await getDailyUsageStats(userId, 7);
return {
total: stats,
byStatus: statusStats,
byChat: chatStats,
byDate: dailyStats,
};
} catch (error) {
logger.error('[ChatUsage] 获取用户使用统计失败:', error);
throw error;
}
}
/**
* 更新使用记录的状态
* @param id 记录ID
* @param status 新状态
* @param additionalData 额外要更新的数据
* @returns 更新后的记录
*/
export async function updateUsageStatus(
id: string,
status: ChatUsageStatus,
additionalData?: Partial<ChatUsageParams>,
) {
try {
const updatedRecord = await prisma.chatUsage.update({
where: { id },
data: {
status,
...additionalData,
},
});
return updatedRecord;
} catch (error) {
logger.error('[ChatUsage] 更新使用记录状态失败:', error);
throw error;
}
}
export async function updateUsageError(id: string, error: string, additionalData?: Partial<ChatUsageParams>) {
return updateUsageStatus(id, ChatUsageStatus.FAILED, {
...additionalData,
metadata: {
error: error || '未知错误',
} as unknown as Record<string, any>,
});
}
/**
* 获取最近的使用记录
* @param userId 用户ID
* @param limit 限制返回记录数量
* @returns 使用记录列表
*/
export async function getRecentUsage(userId: string, limit = 10) {
try {
const records = await prisma.chatUsage.findMany({
where: {
userId,
},
orderBy: {
calledAt: 'desc',
},
take: limit,
});
return records;
} catch (error) {
logger.error('[ChatUsage] 获取最近使用记录失败:', error);
throw error;
}
}

329
app/.server/service/chat.ts Normal file
View File

@@ -0,0 +1,329 @@
import { prisma } from '~/.server/service/prisma';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('chat.server');
/**
* 聊天创建参数接口
*/
export interface ChatCreateParams {
userId: string;
id?: string;
// 聊天URL ID
urlId?: string;
// 聊天描述
description?: string;
// 包含额外信息的元数据
metadata?: Record<string, any>;
}
/**
* 聊天更新参数接口
*/
export interface ChatUpdateParams {
description?: string;
metadata?: Record<string, any>;
}
/**
* 聊天创建或更新参数接口
*/
export interface ChatUpsertParams {
id: string;
userId: string;
urlId?: string;
description?: string;
metadata?: Record<string, any>;
}
/**
* 创建新的聊天
* @param params 聊天创建参数
* @returns 创建的聊天记录
*/
export async function createChat(params: ChatCreateParams) {
const { userId, id, urlId, description, metadata } = params;
try {
const chat = await prisma.chat.create({
data: {
...(id ? { id } : {}),
userId,
urlId,
description,
metadata,
},
});
logger.info(`[Chat] 创建了用户 ${userId} 的聊天: ${chat.id}`);
return chat;
} catch (error) {
logger.error('[Chat] 创建聊天失败:', error);
throw error;
}
}
/**
* 根据ID获取聊天
* @param id 聊天ID
* @returns 聊天记录
*/
export async function getChatById(id: string) {
try {
const chat = await prisma.chat.findUnique({
where: { id },
include: {
messages: {
where: {
isDiscarded: false,
},
orderBy: {
createdAt: 'asc',
},
include: {
sections: true,
page: true,
},
},
},
});
return chat;
} catch (error) {
logger.error(`[Chat] 获取聊天 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据 URL ID 获取聊天
* @param urlId 聊天的 URL ID
* @returns 聊天记录
*/
export async function getChatByUrlId(urlId: string) {
try {
const chat = await prisma.chat.findUnique({
where: { urlId },
include: {
messages: {
where: {
isDiscarded: false,
},
orderBy: {
createdAt: 'asc',
},
},
},
});
return chat;
} catch (error) {
logger.error(`[Chat] 获取聊天 URL ${urlId} 失败:`, error);
throw error;
}
}
/**
* 获取用户的所有聊天
* @param userId 用户ID
* @param limit 限制返回记录数量
* @param offset 偏移量
* @returns 聊天记录列表
*/
export async function getUserChats(userId: string, limit = 20, offset = 0) {
try {
const chats = await prisma.chat.findMany({
where: { userId },
include: {
messages: {
where: {
isDiscarded: false,
},
take: 1,
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
updatedAt: 'desc',
},
skip: offset,
take: limit,
});
const total = await prisma.chat.count({
where: { userId },
});
return {
chats,
total,
};
} catch (error) {
logger.error(`[Chat] 获取用户 ${userId} 的聊天列表失败:`, error);
throw error;
}
}
/**
* 更新聊天信息
* @param id 聊天ID
* @param params 更新参数
* @returns 更新后的聊天记录
*/
export async function updateChat(id: string, params: ChatUpdateParams) {
try {
const updatedChat = await prisma.chat.update({
where: { id },
data: {
...params,
version: {
increment: 1,
},
},
});
logger.info(`[Chat] 更新了聊天 ${id}`);
return updatedChat;
} catch (error) {
logger.error(`[Chat] 更新聊天 ${id} 失败:`, error);
throw error;
}
}
/**
* 删除聊天
* @param id 聊天ID
* @returns 删除结果
*
* 注意:由于在 Prisma Schema 中配置了级联删除关系:
*
* 1. 删除 Chat 会自动级联删除所有关联的 Message 记录
* 2. 删除 Message 会自动级联删除关联的 Section 记录
*/
export async function deleteChat(id: string) {
try {
const chatToDelete = await prisma.chat.findUnique({
where: { id },
include: {
_count: {
select: {
messages: true,
},
},
},
});
if (!chatToDelete) {
logger.info(`[Chat] 未找到ID为 ${id} 的聊天,无法删除`);
return false;
}
await prisma.chat.delete({
where: { id },
});
logger.info(`[Chat] 删除了聊天 ${id},级联删除了 ${chatToDelete._count.messages} 条关联消息及其项目数据`);
return true;
} catch (error) {
logger.error(`[Chat] 删除聊天 ${id} 失败:`, error);
throw error;
}
}
/**
* 获取或创建指定ID的聊天
* @param chatId 指定的聊天ID
* @param params 创建聊天所需的参数
* @returns 聊天记录
*/
export async function getOrCreateChat(chatId: string, params: Omit<ChatCreateParams, 'id'>) {
try {
// 尝试查找现有聊天
const existingChat = await getChatById(chatId);
if (existingChat) {
logger.info(`[Chat] 找到现有聊天: ${chatId}`);
return existingChat;
}
// 聊天不存在创建新聊天使用指定的ID
const newChat = await createChat({
...params,
id: chatId,
});
logger.info(`[Chat] 聊天不存在,创建新聊天: ${newChat.id}`);
return newChat;
} catch (error) {
logger.error(`[Chat] 获取或创建聊天失败:`, error);
throw error;
}
}
/**
* 根据 ID 获取当前用户的聊天
* @param id 聊天ID
* @returns 聊天记录
*/
export async function getUserChatById(id: string, userId: string) {
try {
const chat = await prisma.chat.findUnique({
where: { id, userId },
include: {
messages: {
where: {
isDiscarded: false,
},
orderBy: {
createdAt: 'asc',
},
include: {
sections: true,
page: true,
},
},
},
});
return chat;
} catch (error) {
logger.error(`[Chat] 获取聊天 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据ID创建或更新聊天upsert操作
* @param params 聊天创建或更新参数
* @returns 创建或更新后的聊天记录
*/
export async function upsertChat(params: ChatUpsertParams) {
const { id, userId, urlId, description, metadata } = params;
try {
const chat = await prisma.chat.upsert({
where: { id },
update: {
version: {
increment: 1,
},
description,
metadata,
},
create: {
id,
userId,
urlId,
description,
metadata,
},
});
logger.info(`[Chat] 创建或更新了聊天 ${id}`);
return chat;
} catch (error) {
logger.error(`[Chat] 创建或更新聊天 ${id} 失败:`, error);
throw error;
}
}

View File

@@ -0,0 +1,235 @@
import { deleteUserSetting, deleteUserSettings, getUserSetting, setUserSetting } from '~/.server/service/user-settings';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('connectionSettings.server');
/**
* 1Panel 连接设置
*/
export const ONEPANEL_SETTINGS = {
CATEGORY: 'connectivity',
SERVER_URL_KEY: '1panel_server_url',
API_KEY_KEY: '1panel_api_key',
};
/**
* Netlify 连接设置
*/
export const NETLIFY_SETTINGS = {
CATEGORY: 'connectivity',
TOKEN_KEY: 'netlify_token',
};
/**
* Vercel 连接设置
*/
export const VERCEL_SETTINGS = {
CATEGORY: 'connectivity',
TOKEN_KEY: 'vercel_token',
};
/**
* 获取1Panel连接设置
* @param userId 用户ID
* @returns 包含serverUrl和apiKey的对象如果未设置则返回null
*/
export async function get1PanelConnectionSettings(
userId: string,
): Promise<{ serverUrl: string; apiKey: string } | null> {
try {
const serverUrlSetting = await getUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.SERVER_URL_KEY);
const apiKeySetting = await getUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.API_KEY_KEY);
if (!serverUrlSetting || !apiKeySetting) {
return null;
}
return {
serverUrl: serverUrlSetting.value,
apiKey: apiKeySetting.value,
};
} catch (error) {
logger.error(`[1Panel] 获取用户 ${userId} 的连接设置失败:`, error);
return null;
}
}
/**
* 保存1Panel连接设置
* @param userId 用户ID
* @param serverUrl 服务器URL
* @param apiKey API密钥
*/
export async function save1PanelConnectionSettings(userId: string, serverUrl: string, apiKey: string): Promise<void> {
try {
await setUserSetting({
userId,
category: ONEPANEL_SETTINGS.CATEGORY,
key: ONEPANEL_SETTINGS.SERVER_URL_KEY,
value: serverUrl,
});
await setUserSetting({
userId,
category: ONEPANEL_SETTINGS.CATEGORY,
key: ONEPANEL_SETTINGS.API_KEY_KEY,
value: apiKey,
isSecret: true,
});
logger.info(`[1Panel] 保存用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[1Panel] 保存用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 删除1Panel连接设置
* @param userId 用户ID
*/
export async function delete1PanelConnectionSettings(userId: string): Promise<void> {
try {
await deleteUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.SERVER_URL_KEY);
await deleteUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.API_KEY_KEY);
logger.info(`[1Panel] 删除用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[1Panel] 删除用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 获取Netlify连接设置
* @param userId 用户ID
* @returns 包含token的对象如果未设置则返回null
*/
export async function getNetlifyConnectionSettings(userId: string): Promise<{ token: string } | null> {
try {
const tokenSetting = await getUserSetting(userId, NETLIFY_SETTINGS.CATEGORY, NETLIFY_SETTINGS.TOKEN_KEY);
if (!tokenSetting) {
return null;
}
return {
token: tokenSetting.value,
};
} catch (error) {
logger.error(`[Netlify] 获取用户 ${userId} 的连接设置失败:`, error);
return null;
}
}
/**
* 保存Netlify连接设置
* @param userId 用户ID
* @param token 访问令牌
*/
export async function saveNetlifyConnectionSettings(userId: string, token: string): Promise<void> {
try {
await setUserSetting({
userId,
category: NETLIFY_SETTINGS.CATEGORY,
key: NETLIFY_SETTINGS.TOKEN_KEY,
value: token,
isSecret: true,
});
logger.info(`[Netlify] 保存用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[Netlify] 保存用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 删除Netlify连接设置
* @param userId 用户ID
*/
export async function deleteNetlifyConnectionSettings(userId: string): Promise<void> {
try {
await deleteUserSetting(userId, NETLIFY_SETTINGS.CATEGORY, NETLIFY_SETTINGS.TOKEN_KEY);
logger.info(`[Netlify] 删除用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[Netlify] 删除用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 获取Vercel连接设置
* @param userId 用户ID
* @returns 包含token的对象如果未设置则返回null
*/
export async function getVercelConnectionSettings(userId: string): Promise<{ token: string } | null> {
try {
const tokenSetting = await getUserSetting(userId, VERCEL_SETTINGS.CATEGORY, VERCEL_SETTINGS.TOKEN_KEY);
if (!tokenSetting) {
return null;
}
return {
token: tokenSetting.value,
};
} catch (error) {
logger.error(`[Vercel] 获取用户 ${userId} 的连接设置失败:`, error);
return null;
}
}
/**
* 保存Vercel连接设置
* @param userId 用户ID
* @param token 访问令牌
*/
export async function saveVercelConnectionSettings(userId: string, token: string): Promise<void> {
try {
await setUserSetting({
userId,
category: VERCEL_SETTINGS.CATEGORY,
key: VERCEL_SETTINGS.TOKEN_KEY,
value: token,
isSecret: true,
});
logger.info(`[Vercel] 保存用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[Vercel] 保存用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 删除Vercel连接设置
* @param userId 用户ID
*/
export async function deleteVercelConnectionSettings(userId: string): Promise<void> {
try {
await deleteUserSetting(userId, VERCEL_SETTINGS.CATEGORY, VERCEL_SETTINGS.TOKEN_KEY);
logger.info(`[Vercel] 删除用户 ${userId} 的连接设置成功`);
} catch (error) {
logger.error(`[Vercel] 删除用户 ${userId} 的连接设置失败:`, error);
throw error;
}
}
/**
* 删除所有连接设置
* @param userId 用户ID
*/
export async function deleteAllConnectionSettings(userId: string): Promise<void> {
try {
// 使用 deleteUserSettings 删除 'connectivity' 类别下的所有设置
await deleteUserSettings(userId, 'connectivity');
logger.info(`[连接设置] 删除用户 ${userId} 的所有连接设置成功`);
} catch (error) {
logger.error(`[连接设置] 删除用户 ${userId} 的所有连接设置失败:`, error);
throw error;
}
}

View File

@@ -0,0 +1,316 @@
import { prisma } from '~/.server/service/prisma';
import type { DeploymentPlatform } from '~/types/deployment';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('deployment.server');
/**
* 部署记录创建参数接口
*/
export interface DeploymentCreateParams {
userId: string;
chatId: string;
platform: DeploymentPlatform;
deploymentId: string;
url: string;
status: string;
metadata?: Record<string, any>;
}
/**
* 创建或更新部署记录
* 当 userId、chatId 和 platform 都匹配时,更新现有记录而不是创建新记录
*
* @param params 部署记录创建参数
* @returns 创建或更新的部署记录
*/
export async function createOrUpdateDeployment(params: DeploymentCreateParams) {
const { userId, chatId, platform, deploymentId, url, status, metadata } = params;
try {
const existingDeployment = await prisma.deployment.findFirst({
where: {
userId,
chatId,
platform,
},
});
let deployment;
if (existingDeployment) {
deployment = await prisma.deployment.update({
where: { id: existingDeployment.id },
data: {
deploymentId,
url,
status,
metadata,
},
});
logger.info(`[Deployment] 更新了用户 ${userId} 的部署记录: ${deployment.id}, 平台: ${platform}`);
} else {
deployment = await prisma.deployment.create({
data: {
userId,
chatId,
platform,
deploymentId,
url,
status,
metadata,
},
});
logger.info(`[Deployment] 创建了用户 ${userId} 的部署记录: ${deployment.id}, 平台: ${platform}`);
}
return deployment;
} catch (error) {
logger.error('[Deployment] 创建或更新部署记录失败:', error);
throw error;
}
}
/**
* 根据ID获取部署记录
* @param id 部署记录ID
* @returns 部署记录
*/
export async function getDeploymentById(id: string) {
try {
const deployment = await prisma.deployment.findUnique({
where: { id },
});
return deployment;
} catch (error) {
logger.error(`[Deployment] 获取部署记录 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据ID删除部署记录
* @param id 部署记录ID
* @returns 删除的部署记录
*/
export async function deleteDeploymentById(id: string) {
try {
const deployment = await prisma.deployment.delete({
where: { id },
});
return deployment;
} catch (error) {
logger.error(`[Deployment] 删除部署记录 ${id} 失败:`, error);
throw error;
}
}
/**
* 获取用户的所有部署记录
* @param userId 用户ID
* @param limit 限制返回记录数量
* @param offset 偏移量
* @returns 部署记录列表
*/
export async function getUserDeployments(userId: string, limit = 20, offset = 0) {
try {
const deployments = await prisma.deployment.findMany({
where: { userId },
orderBy: {
createdAt: 'desc',
},
skip: offset,
take: limit,
});
const total = await prisma.deployment.count({
where: { userId },
});
return {
deployments,
total,
};
} catch (error) {
logger.error(`[Deployment] 获取用户 ${userId} 的部署记录列表失败:`, error);
throw error;
}
}
/**
* 获取特定聊天的所有部署记录
* @param chatId 聊天ID
* @returns 部署记录列表
*/
export async function getChatDeployments(chatId: string) {
try {
const deployments = await prisma.deployment.findMany({
where: { chatId },
orderBy: {
createdAt: 'desc',
},
});
return deployments;
} catch (error) {
logger.error(`[Deployment] 获取聊天 ${chatId} 的部署记录列表失败:`, error);
throw error;
}
}
/**
* 获取用户在特定平台的所有部署记录
* @param userId 用户ID
* @param platform 平台名称
* @returns 部署记录列表
*/
export async function getUserPlatformDeployments(userId: string, platform: DeploymentPlatform) {
try {
const deployments = await prisma.deployment.findMany({
where: {
userId,
platform,
},
orderBy: {
createdAt: 'desc',
},
});
return deployments;
} catch (error) {
logger.error(`[Deployment] 获取用户 ${userId} 在平台 ${platform} 的部署记录列表失败:`, error);
throw error;
}
}
/**
* 获取用户在特定平台和聊天的最新部署记录
* @param userId 用户ID
* @param chatId 聊天ID
* @param platform 平台名称
* @returns 最新的部署记录,如果不存在则返回 null
*/
export async function getLatestDeployment(userId: string, chatId: string, platform: DeploymentPlatform) {
try {
const deployment = await prisma.deployment.findFirst({
where: {
userId,
chatId,
platform,
},
orderBy: {
createdAt: 'desc',
},
});
return deployment;
} catch (error) {
logger.error(`[Deployment] 获取用户 ${userId} 在平台 ${platform} 的最新部署记录失败:`, error);
return null;
}
}
/**
* 更新部署记录状态
* @param id 部署记录ID
* @param status 新状态
* @param metadata 可选的元数据更新
* @returns 更新后的部署记录
*/
export async function updateDeploymentStatus(id: string, status: string, metadata?: Record<string, any>) {
try {
const updatedDeployment = await prisma.deployment.update({
where: { id },
data: {
status,
...(metadata ? { metadata } : {}),
},
});
logger.info(`[Deployment] 更新了部署记录 ${id} 的状态为 ${status}`);
return updatedDeployment;
} catch (error) {
logger.error(`[Deployment] 更新部署记录 ${id} 状态失败:`, error);
throw error;
}
}
/**
* 根据平台和平台特定的ID删除所有相关的部署记录
*
* @param platform 平台名称
* @param platformId 平台特定的ID
* @returns 删除的记录数量
*/
export async function deleteDeploymentsByPlatformAndId(platform: DeploymentPlatform, platformId: string | number) {
try {
// 将 platformId 转换为字符串,因为在数据库中 deploymentId 是字符串类型
const deploymentId = String(platformId);
const result = await prisma.deployment.deleteMany({
where: {
platform,
deploymentId,
},
});
logger.info(`[Deployment] 删除了平台 ${platform} 上 ID 为 ${platformId}${result.count} 条部署记录`);
return result.count;
} catch (error) {
logger.error(`[Deployment] 删除平台 ${platform} 上 ID 为 ${platformId} 的部署记录失败:`, error);
throw error;
}
}
/**
* 分页获取用户在特定平台的部署记录
* @param userId 用户ID
* @param platform 平台名称(可选)
* @param limit 每页记录数
* @param offset 偏移量
* @returns 部署记录列表和总数
*/
export async function getUserPlatformDeploymentsWithPagination(
userId: string,
platform?: DeploymentPlatform,
limit = 10,
offset = 0,
) {
try {
const where = {
userId,
...(platform ? { platform } : {}),
};
const deployments = await prisma.deployment.findMany({
where,
orderBy: {
createdAt: 'desc',
},
skip: offset,
take: limit,
include: {
chat: {
select: {
id: true,
description: true,
},
},
},
});
const total = await prisma.deployment.count({ where });
return {
deployments,
total,
};
} catch (error) {
logger.error(
`[Deployment] 分页获取用户 ${userId} ${platform ? `在平台 ${platform} ` : ''}的部署记录列表失败:`,
error,
);
throw error;
}
}

View File

@@ -0,0 +1,317 @@
import type { Message } from '@prisma/client';
import type { JsonArray } from '@prisma/client/runtime/library';
import type { TextUIPart, UIMessagePart } from 'ai';
import { prisma } from '~/.server/service/prisma';
import type { SummaryAnnotation, UPageDataParts, UPageUIMessage } from '~/types/message';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('message.server');
/**
* 消息创建参数接口
*/
export interface MessageCreateParams {
chatId: string;
userId: string;
role: string;
content: string;
revisionId?: string;
annotations?: any[];
version?: number;
}
/**
* 消息更新参数接口
*/
export interface MessageUpdateParams {
content?: string;
revisionId?: string;
annotations?: any[];
version?: number;
}
/**
* 消息创建或更新参数接口
*/
export interface MessageUpsertParams {
id: string;
chatId: string;
userId: string;
role: string;
content: string;
revisionId?: string;
annotations?: any[];
version?: number;
}
/**
* 根据ID创建或更新消息upsert操作
* @param params 消息创建或更新参数
* @returns 创建或更新后的消息记录
*/
export async function upsertMessage(params: MessageUpsertParams) {
const { id, chatId, userId, role, content, revisionId, annotations } = params;
try {
const message = await prisma.message.upsert({
where: { id },
update: {
content,
revisionId,
annotations,
},
create: {
id,
chatId,
userId,
role,
content,
revisionId,
annotations,
},
});
logger.info(`[Message] 创建或更新了消息 ${id}`);
return message;
} catch (error) {
logger.error(`[Message] 创建或更新消息 ${id} 失败:`, error);
throw error;
}
}
/**
* 更新消息为遗弃消息。
*
* 此方法将会更新同一 {@param chatId} 下 startMessageId不含与 endMessageId 之间(不含)的所有消息为遗弃消息。
*
* @param chatId 聊天ID
* @param startMessageId 开始消息ID
* @param endMessageId 结束消息ID
*/
export async function updateDiscardedMessage(chatId: string, startMessageId: string) {
try {
const startMessage = await prisma.message.findUnique({
where: { id: startMessageId },
select: { createdAt: true },
});
if (!startMessage) {
logger.error(`[Message] 找不到开始消息 ${startMessageId}`);
return false;
}
// 更新 startMessageId 之后的所有消息为遗弃消息
const result = await prisma.message.updateMany({
where: {
chatId,
createdAt: {
gt: startMessage?.createdAt,
},
},
data: {
isDiscarded: true,
},
});
logger.info(`[Message] 已将聊天 ${chatId}${startMessageId} 之后的 ${result.count} 条消息标记为遗弃`);
return true;
} catch (error) {
logger.error(`[Message] 更新遗弃消息失败:`, error);
throw error;
}
}
/**
* 获取历史聊天消息接口参数
*/
export interface GetHistoryChatMessagesParams {
chatId: string;
rewindTo?: string;
}
/**
* 获取从第一条消息到指定消息之间的所有历史消息
* @param params 包含 chatId 和可选的 rewindTo 参数
* @returns 消息记录列表
*/
export async function getHistoryChatMessages(params: GetHistoryChatMessagesParams): Promise<UPageUIMessage[]> {
const { chatId, rewindTo } = params;
try {
// 如果指定了 rewindTo则获取该消息的创建时间
if (rewindTo) {
const rewindToMessage = await prisma.message.findUnique({
where: { id: rewindTo },
select: { createdAt: true },
});
if (!rewindToMessage) {
logger.warn(`[Message] 获取历史消息: 找不到指定的 rewindTo 消息 ${rewindTo}`);
// 如果找不到指定消息,则返回所有消息
return await getAllChatMessages(chatId);
}
// 获取所有在 rewindTo 消息创建时间之前(包括该消息)的消息
const messages = await prisma.message.findMany({
where: {
chatId,
isDiscarded: false,
createdAt: {
lte: rewindToMessage.createdAt,
},
},
orderBy: {
createdAt: 'asc',
},
});
logger.info(`[Message] 获取了聊天 ${chatId} 中直到消息 ${rewindTo}${messages.length} 条历史消息`);
return messages.map(convertToUIMessage);
} else {
// 如果没有指定 rewindTo则获取所有消息
return await getAllChatMessages(chatId);
}
} catch (error) {
logger.error(`[Message] 获取聊天 ${chatId} 的历史消息失败:`, error);
throw error;
}
}
function convertToUIMessage(message: Message): UPageUIMessage {
if (message.version === 2) {
return {
id: message.id,
role: message.role as 'user' | 'assistant',
parts: message.parts as any[],
metadata: message.metadata as any,
};
}
const parts: UIMessagePart<UPageDataParts, never>[] = [];
if (message.role === 'user') {
const content = JSON.parse(message.content) as TextUIPart;
parts.push({
type: 'text',
text: content.text,
});
} else {
parts.push({
type: 'text',
text: message.content,
});
}
if (message.annotations) {
const messageAnnotations = message.annotations as JsonArray;
messageAnnotations.forEach((annotation) => {
const { type } = annotation as { type: string };
if (type === 'chatSummary') {
parts.push({
type: 'data-summary',
data: annotation as unknown as SummaryAnnotation,
});
}
});
}
return {
id: message.id,
role: message.role as 'user' | 'assistant',
parts,
metadata: message.metadata as any,
};
}
/**
* 获取聊天的所有消息(内部辅助方法)
* @param chatId 聊天ID
* @returns 消息记录列表
*/
async function getAllChatMessages(chatId: string): Promise<UPageUIMessage[]> {
const messages = await prisma.message.findMany({
where: {
chatId,
isDiscarded: false,
},
orderBy: {
createdAt: 'asc',
},
});
logger.info(`[Message] 获取了聊天 ${chatId} 的所有 ${messages.length} 条历史消息`);
return messages.map(convertToUIMessage);
}
/**
* 保存聊天消息列表到数据库
* @param chatId 聊天ID
* @param messages 消息列表UPageUIMessage[]
* @returns 保存结果
*/
export async function saveChatMessages(chatId: string, messages: UPageUIMessage[]): Promise<number> {
if (!messages || messages.length === 0) {
logger.warn('[Message] 保存聊天消息: 没有提供消息数据');
return 0;
}
try {
// 获取聊天的用户ID
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: { userId: true },
});
if (!chat) {
logger.error(`[Message] 保存聊天消息: 找不到聊天 ${chatId}`);
throw new Error(`找不到聊天 ${chatId}`);
}
const userId = chat.userId;
let savedCount = 0;
// 逐条保存消息
for (const message of messages) {
// 跳过没有ID的消息
if (!message.id) {
logger.warn('[Message] 保存聊天消息: 跳过没有ID的消息');
continue;
}
// 提取消息的文本内容
const textPart = message.parts.find((part) => part.type === 'text');
const content = textPart?.text || '';
// 创建或更新消息
const updateData: any = {
content,
parts: message.parts,
metadata: message.metadata,
version: 2,
};
const createData: any = {
id: message.id,
chatId,
userId,
role: message.role,
content,
parts: message.parts,
metadata: message.metadata,
version: 2,
};
await prisma.message.upsert({
where: { id: message.id },
update: updateData,
create: createData,
});
savedCount++;
}
logger.info(`[Message] 成功保存了聊天 ${chatId}${savedCount} 条消息`);
return savedCount;
} catch (error) {
logger.error(`[Message] 保存聊天消息失败:`, error);
throw error;
}
}

278
app/.server/service/page.ts Normal file
View File

@@ -0,0 +1,278 @@
import type { JsonArray, JsonObject } from '@prisma/client/runtime/library';
import type { Page } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { prisma } from './prisma';
const logger = createScopedLogger('page.server');
/**
* 页面创建参数接口
*/
export interface PageCreateParams extends Page {
messageId: string;
}
/**
* 页面更新参数接口
*/
export interface PageUpdateParams {
pages?: Page[];
}
/**
* 创建新的页面
* @param params 页面创建参数
* @returns 创建的页面记录
*/
export async function createPage(params: PageCreateParams) {
const { messageId, name, title, content, actionIds } = params;
try {
const pageData = [
{
name,
title,
content,
actionIds,
},
];
const page = await prisma.page.create({
data: {
messageId,
pages: JSON.parse(JSON.stringify(pageData)),
},
});
logger.info(`[Page] 创建了消息 ${messageId} 的页面: ${page.id}`);
return page;
} catch (error) {
logger.error('[Page] 创建页面失败:', error);
throw error;
}
}
/**
* 创建或更新页面
* @param params 页面创建参数
* @returns 创建或更新的页面记录
*/
export async function createOrUpdatePage(params: PageCreateParams) {
const { messageId, name, title, content, actionIds } = params;
try {
const existingPage = await getPageByMessageId(messageId);
if (existingPage) {
const updatedPage = await updatePageByMessageId(messageId, {
pages: [
{
name,
title,
content,
actionIds,
},
],
});
return updatedPage;
}
const newPage = await createPage(params);
return newPage;
} catch (error) {
logger.error('[Page] 创建或更新页面失败:', error);
throw error;
}
}
/**
* 创建多个页面
* @param messageId 消息ID
* @param pages 页面数组
* @returns 创建的页面记录
*/
export async function createPages(messageId: string, pages: Page[]) {
try {
const page = await prisma.page.create({
data: {
messageId,
pages: JSON.parse(JSON.stringify(pages)),
},
});
logger.info(`[Page] 为消息 ${messageId} 创建了 ${pages.length} 个页面: ${page.id}`);
return page;
} catch (error) {
logger.error('[Page] 创建多个页面失败:', error);
throw error;
}
}
/**
* 创建或更新多个页面
* @param messageId 消息ID
* @param pages 页面数组
* @returns 创建或更新的页面记录
*/
export async function createOrUpdatePages(messageId: string, pages: Page[]) {
try {
const existingPage = await getPageByMessageId(messageId);
if (existingPage) {
const updatedPage = await updatePageByMessageId(messageId, { pages });
return updatedPage;
}
const newPage = await createPages(messageId, pages);
return newPage;
} catch (error) {
logger.error('[Page] 创建或更新多个页面失败:', error);
throw error;
}
}
/**
* 根据ID获取页面
* @param id 页面ID
* @returns 页面记录
*/
export async function getPageById(id: string) {
try {
const page = await prisma.page.findUnique({
where: { id },
});
return page;
} catch (error) {
logger.error(`[Page] 获取页面 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据消息ID获取页面
* @param messageId 消息ID
* @returns 页面记录
*/
export async function getPageByMessageId(messageId: string) {
try {
const page = await prisma.page.findUnique({
where: { messageId },
});
return page;
} catch (error) {
logger.error(`[Page] 获取消息 ${messageId} 的页面失败:`, error);
throw error;
}
}
export async function getPageByMessageIdAndName(messageId: string, name: string): Promise<JsonObject | null> {
try {
const page = await getPageByMessageId(messageId);
if (!page) {
return null;
}
const pages = page.pages as JsonArray;
const pageData = pages.find((p) => {
const page = p as JsonObject;
return page.name === name;
});
if (!pageData) {
return null;
}
return pageData as JsonObject;
} catch (error) {
logger.error(`[Page] 获取消息 ${messageId} 的页面 ${name} 失败:`, error);
throw error;
}
}
/**
* 更新页面信息
* @param id 页面ID
* @param params 更新参数
* @returns 更新后的页面记录
*/
export async function updatePage(id: string, params: PageUpdateParams) {
try {
const updateData: any = {};
if (params.pages) {
updateData.pages = JSON.parse(JSON.stringify(params.pages));
}
const updatedPage = await prisma.page.update({
where: { id },
data: updateData,
});
logger.info(`[Page] 更新了页面 ${id}`);
return updatedPage;
} catch (error) {
logger.error(`[Page] 更新页面 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据消息ID更新页面信息
* @param messageId 消息ID
* @param params 更新参数
* @returns 更新后的页面记录
*/
export async function updatePageByMessageId(messageId: string, params: PageUpdateParams) {
try {
const updateData: any = {};
if (params.pages) {
updateData.pages = JSON.parse(JSON.stringify(params.pages));
}
const updatedPage = await prisma.page.update({
where: { messageId },
data: updateData,
});
logger.info(`[Page] 更新了消息 ${messageId} 的页面`);
return updatedPage;
} catch (error) {
logger.error(`[Page] 更新消息 ${messageId} 的页面失败:`, error);
throw error;
}
}
/**
* 删除页面
* @param id 页面ID
* @returns 删除结果
*/
export async function deletePage(id: string) {
try {
await prisma.page.delete({
where: { id },
});
logger.info(`[Page] 删除了页面 ${id}`);
return true;
} catch (error) {
logger.error(`[Page] 删除页面 ${id} 失败:`, error);
throw error;
}
}
/**
* 根据消息ID删除页面
* @param messageId 消息ID
* @returns 删除结果
*/
export async function deletePageByMessageId(messageId: string) {
try {
await prisma.page.delete({
where: { messageId },
});
logger.info(`[Page] 删除了消息 ${messageId} 的页面`);
return true;
} catch (error) {
logger.error(`[Page] 删除消息 ${messageId} 的页面失败:`, error);
throw error;
}
}

View File

@@ -0,0 +1,28 @@
import { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '@prisma/client';
// 创建PrismaClient实例
let prisma: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
const adapter = new PrismaBetterSQLite3({
url: 'file:data/upage.db',
});
// 在开发环境中使用全局变量,避免热重载时创建多个实例
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient({ adapter });
} else {
if (!global.__db) {
global.__db = new PrismaClient({
log: ['query', 'error', 'warn'],
adapter,
});
}
prisma = global.__db;
}
export { prisma };

View File

@@ -0,0 +1,142 @@
import type { Page } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { createOrUpdatePages, getPageByMessageId } from './page';
import { createSection, deleteMessageSections, getMessageSections, type SectionCreateParams } from './section';
const logger = createScopedLogger('projectService');
/**
* 保存项目数据接口
*/
export interface SaveProjectParams {
messageId: string;
projectData: Record<string, any>;
}
/**
* 保存项目部分接口
*/
export interface SaveSectionsParams {
messageId: string;
sections: SectionCreateParams[];
}
/**
* 保存页面数据接口
*/
export interface SavePagesParams {
messageId: string;
pages: Page[];
}
/**
* 保存项目和部分数据接口
*/
export interface SaveProjectAndSectionsParams {
messageId: string;
projectData: Record<string, any>;
sections: SectionCreateParams[];
}
/**
* 保存页面和部分数据接口
*/
export interface SavePagesAndSectionsParams {
messageId: string;
pages: Page[];
sections: SectionCreateParams[];
}
/**
* 保存页面数据
* @param params 保存页面参数
* @returns 保存结果
*/
export async function savePages(params: SavePagesParams) {
const { messageId, pages } = params;
try {
// 检查页面是否已存在
const existingPage = await getPageByMessageId(messageId);
// 创建或更新页面
const page = await createOrUpdatePages(messageId, pages);
if (existingPage) {
logger.info(`更新了消息 ${messageId} 的页面`);
return { success: true, message: '页面已更新', id: page.id };
} else {
logger.info(`创建了消息 ${messageId} 的页面: ${page.id}`);
return { success: true, message: '页面已创建', id: page.id };
}
} catch (error) {
logger.error('保存页面数据失败:', error);
throw error;
}
}
/**
* 保存项目部分数据
* @param params 保存部分参数
* @returns 保存结果
*/
export async function saveSections(params: SaveSectionsParams) {
const { messageId, sections } = params;
try {
// 获取现有部分
const existingSections = await getMessageSections(messageId);
// 如果有现有部分,则先删除
if (existingSections.length > 0) {
await deleteMessageSections(messageId);
logger.info(`删除了消息 ${messageId} 的现有部分数据`);
}
// 创建新部分
const createdSections = await Promise.all(
sections.map((section) =>
createSection({
...section,
messageId,
}),
),
);
logger.info(`为消息 ${messageId} 创建了 ${createdSections.length} 个部分`);
return {
success: true,
message: `已保存 ${createdSections.length} 个部分`,
count: createdSections.length,
};
} catch (error) {
logger.error('保存部分数据失败:', error);
throw error;
}
}
/**
* 保存页面和部分数据
* @param params 保存页面和部分参数
* @returns 保存结果
*/
export async function savePagesAndSections(params: SavePagesAndSectionsParams) {
const { messageId, pages, sections } = params;
try {
// 保存页面数据
const pagesResult = await savePages({ messageId, pages });
// 保存部分数据
const sectionsResult = await saveSections({ messageId, sections });
return {
success: true,
pages: pagesResult,
sections: sectionsResult,
};
} catch (error) {
logger.error('保存页面和部分数据失败:', error);
throw error;
}
}

View File

@@ -0,0 +1,234 @@
import { prisma } from '~/.server/service/prisma';
import type { Section } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('section.server');
/**
* section 创建参数接口
*/
export interface SectionCreateParams extends Section {
messageId: string;
actionId: string;
}
/**
* section 更新参数接口
*/
export interface SectionUpdateParams {
type?: string;
action?: string;
actionId?: string;
pageName?: string;
content?: string;
domId?: string;
rootDomId?: string;
sort?: number;
}
/**
* 创建新的 section
* @param params section 创建参数
* @returns 创建的 section 记录
*/
export async function createSection(params: SectionCreateParams) {
const { messageId, action = 'add', actionId, pageName = '', content, domId, rootDomId, sort = 0 } = params;
try {
const section = await prisma.section.create({
data: {
messageId,
action,
actionId,
pageName,
content,
domId,
rootDomId,
sort,
},
});
logger.info(`[Section] 创建了消息 ${messageId} 的 section : ${section.id}`);
return section;
} catch (error) {
logger.error('[Section] 创建 section 失败:', error);
throw error;
}
}
/**
* 批量创建多个 section
* @param params section 创建参数数组
* @returns 创建的 section 数量
*/
export async function createManySections(params: SectionCreateParams[]) {
if (!params || params.length === 0) {
logger.warn('[Section] 批量创建 section : 没有提供 section 数据');
return 0;
}
try {
const result = await prisma.section.createMany({
data: params.map(
({ messageId, action = 'add', actionId, pageName = '', content, domId, rootDomId, sort = 0 }) => ({
messageId,
action,
actionId,
pageName,
content,
domId,
rootDomId,
sort,
}),
),
});
logger.info(`[Section] 批量创建了 ${result.count} 个 section `);
return result.count;
} catch (error) {
logger.error('[Section] 批量创建 section 失败:', error);
throw error;
}
}
/**
* 根据ID获取 section
* @param id section ID
* @returns section 记录
*/
export async function getSectionById(id: string) {
try {
const section = await prisma.section.findUnique({
where: { id },
});
return section;
} catch (error) {
logger.error(`[Section] 获取 section ${id} 失败:`, error);
throw error;
}
}
/**
* 获取消息的所有 section
* @param messageId 消息ID
* @returns section 记录列表
*/
export async function getMessageSections(messageId: string) {
try {
const sections = await prisma.section.findMany({
where: { messageId },
orderBy: {
sort: 'asc',
},
});
return sections;
} catch (error) {
logger.error(`[Section] 获取消息 ${messageId} 的 section 列表失败:`, error);
throw error;
}
}
/**
* 根据DOM ID获取 section
* @param domId DOM ID
* @returns section 记录
*/
export async function getSectionByDomId(domId: string) {
try {
const sections = await prisma.section.findMany({
where: { domId },
orderBy: {
updatedAt: 'desc',
},
});
return sections;
} catch (error) {
logger.error(`[Section] 获取DOM ID ${domId} 的 section 失败:`, error);
throw error;
}
}
/**
* 获取特定页面的所有 section
* @param pageName 页面名称
* @returns section 记录列表
*/
export async function getPageSections(pageName: string) {
try {
const sections = await prisma.section.findMany({
where: { pageName },
orderBy: {
sort: 'asc',
},
});
return sections;
} catch (error) {
logger.error(`[Section] 获取页面 ${pageName} 的 section 列表失败:`, error);
throw error;
}
}
/**
* 更新 section 信息
* @param id section ID
* @param params 更新参数
* @returns 更新后的 section 记录
*/
export async function updateSection(id: string, params: SectionUpdateParams) {
try {
const updatedSection = await prisma.section.update({
where: { id },
data: {
...params,
},
});
logger.info(`[Section] 更新了 section ${id}`);
return updatedSection;
} catch (error) {
logger.error(`[Section] 更新 section ${id} 失败:`, error);
throw error;
}
}
/**
* 删除 section
* @param id section ID
* @returns 删除结果
*/
export async function deleteSection(id: string) {
try {
await prisma.section.delete({
where: { id },
});
logger.info(`[Section] 删除了 section ${id}`);
return true;
} catch (error) {
logger.error(`[Section] 删除 section ${id} 失败:`, error);
throw error;
}
}
/**
* 删除消息的所有 section
* @param messageId 消息ID
* @returns 删除结果
*/
export async function deleteMessageSections(messageId: string) {
try {
const result = await prisma.section.deleteMany({
where: { messageId },
});
logger.info(`[Section] 删除了消息 ${messageId}${result.count} 个 section `);
return result.count > 0;
} catch (error) {
logger.error(`[Section] 删除消息 ${messageId} 的 section 失败:`, error);
throw error;
}
}

View File

@@ -0,0 +1,181 @@
import { prisma } from '~/.server/service/prisma';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('userSettings.server');
/**
* 用户设置创建/更新参数接口
*/
export interface UserSettingParams {
userId: string;
category: string;
key: string;
value: string;
isSecret?: boolean;
}
/**
* 用户设置查询参数接口
*/
export interface UserSettingQueryParams {
userId: string;
category?: string;
key?: string;
includeSecrets?: boolean;
}
/**
* 创建或更新用户设置
*
* @param params 用户设置参数
* @returns 创建或更新的用户设置
*/
export async function setUserSetting(params: UserSettingParams) {
const { userId, category, key, value, isSecret = false } = params;
try {
const setting = await prisma.userSetting.upsert({
where: {
userId_category_key: {
userId,
category,
key,
},
},
update: {
value,
isSecret,
},
create: {
userId,
category,
key,
value,
isSecret,
},
});
logger.info(`[UserSetting] 设置用户 ${userId}${category}.${key} 成功`);
return setting;
} catch (error) {
logger.error(`[UserSetting] 设置用户 ${userId}${category}.${key} 失败:`, error);
throw error;
}
}
/**
* 获取用户设置
* @param params 查询参数
* @returns 用户设置列表
*/
export async function getUserSettings(params: UserSettingQueryParams) {
const { userId, category, key, includeSecrets = false } = params;
try {
const where: any = { userId };
if (category) {
where.category = category;
}
if (key) {
where.key = key;
}
// 如果不包含敏感信息,则过滤掉敏感设置
if (!includeSecrets) {
where.isSecret = false;
}
const settings = await prisma.userSetting.findMany({
where,
orderBy: [{ category: 'asc' }, { key: 'asc' }],
});
return settings;
} catch (error) {
logger.error(`[UserSetting] 获取用户 ${userId} 的设置失败:`, error);
throw error;
}
}
/**
* 获取单个用户设置
* @param userId 用户ID
* @param category 设置类别
* @param key 设置键名
* @returns 用户设置如果不存在则返回null
*/
export async function getUserSetting(userId: string, category: string, key: string) {
try {
const setting = await prisma.userSetting.findUnique({
where: {
userId_category_key: {
userId,
category,
key,
},
},
});
return setting;
} catch (error) {
logger.error(`[UserSetting] 获取用户 ${userId}${category}.${key} 失败:`, error);
throw error;
}
}
/**
* 删除用户设置
* @param userId 用户ID
* @param category 设置类别
* @param key 设置键名
* @returns 删除的用户设置
*/
export async function deleteUserSetting(userId: string, category: string, key: string) {
try {
const setting = await prisma.userSetting.delete({
where: {
userId_category_key: {
userId,
category,
key,
},
},
});
logger.info(`[UserSetting] 删除用户 ${userId}${category}.${key} 成功`);
return setting;
} catch (error) {
logger.error(`[UserSetting] 删除用户 ${userId}${category}.${key} 失败:`, error);
throw error;
}
}
/**
* 删除用户的所有设置
* @param userId 用户ID
* @param category 可选的设置类别,如果提供则只删除该类别的设置
* @returns 删除的设置数量
*/
export async function deleteUserSettings(userId: string, category?: string) {
try {
const where: any = { userId };
if (category) {
where.category = category;
}
const result = await prisma.userSetting.deleteMany({
where,
});
logger.info(
`[UserSetting] 删除用户 ${userId}${category ? ` ${category}` : '所有'}设置成功,共 ${result.count}`,
);
return result.count;
} catch (error) {
logger.error(`[UserSetting] 删除用户 ${userId}${category ? ` ${category}` : '所有'}设置失败:`, error);
throw error;
}
}

View File

@@ -0,0 +1,66 @@
import type { StorageFile, StorageProvider, StorageUploadOptions } from './types';
/**
* 存储提供者类
* 提供通用的文件存储功能实现
*/
export abstract class BaseStorageProvider implements StorageProvider {
/**
* 上传文件
* @param options 上传选项
*/
abstract uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
/**
* 获取文件
* @param userId 用户ID
* @param filename 文件名
*/
abstract getFile(userId: string, filename: string): Promise<StorageFile | null>;
/**
* 删除文件
* @param userId 用户ID
* @param filename 文件名
*/
abstract deleteFile(userId: string, filename: string): Promise<boolean>;
/**
* 检查文件是否存在
* @param userId 用户ID
* @param filename 文件名
*/
abstract fileExists(userId: string, filename: string): Promise<boolean>;
/**
* 生成文件访问URL
* @param userId 用户ID
* @param filename 文件名
* @returns 文件访问URL
*/
getFileUrl(userId: string, filename: string): string {
return `/assets/${userId}/${filename}`;
}
/**
* 生成唯一文件名
* @param originalFilename 原始文件名
* @returns 唯一文件名
*/
protected generateUniqueFilename(originalFilename: string): string {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 8);
const extension = this.getFileExtension(originalFilename);
return `${timestamp}-${randomStr}${extension}`;
}
/**
* 获取文件扩展名
* @param filename 文件名
* @returns 文件扩展名(包含点号)
*/
protected getFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.');
return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
}
}

View File

@@ -0,0 +1,35 @@
import { createScopedLogger } from '~/utils/logger';
import { LocalStorageProvider } from './local-provider.server';
import type { StorageProvider } from './types';
const logger = createScopedLogger('storage');
/**
* 获取存储目录配置
* @returns 存储目录路径
*/
const getStorageDir = (): string | undefined => {
// 如果在Docker环境中运行使用环境变量中的配置
if (process.env.RUNNING_IN_DOCKER === 'true' && process.env.STORAGE_DIR) {
logger.debug('使用Docker环境中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
return process.env.STORAGE_DIR;
}
// 使用环境变量中的配置
if (process.env.STORAGE_DIR) {
logger.debug('使用环境变量中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
return process.env.STORAGE_DIR;
}
// 默认使用项目根目录下的 public/uploads 目录
logger.debug('使用默认存储目录');
return undefined;
};
const createStorageProvider = (): StorageProvider => {
return new LocalStorageProvider(getStorageDir());
};
export const storageProvider = createStorageProvider();
export * from './types';

View File

@@ -0,0 +1,176 @@
import fs from 'fs';
import path from 'path';
import { createScopedLogger } from '~/utils/logger';
import { BaseStorageProvider } from './base-provider.server';
import type { StorageFile, StorageUploadOptions } from './types';
const logger = createScopedLogger('storage.local-provider');
/**
* 本地文件存储提供者
* 将文件存储在本地文件系统中
*/
export class LocalStorageProvider extends BaseStorageProvider {
private baseDir: string;
constructor(baseDir?: string) {
super();
// 默认使用项目根目录下的 public/uploads 目录
this.baseDir = baseDir || path.join(process.cwd(), 'public', 'uploads');
this.ensureDirectoryExists(this.baseDir);
logger.debug('本地存储初始化', JSON.stringify({ baseDir: this.baseDir }));
}
/**
* 上传文件
* @param options 上传选项
* @returns 存储文件信息
*/
async uploadFile(options: StorageUploadOptions): Promise<StorageFile> {
const { userId, contentType, filename, data } = options;
// 生成唯一文件名
const uniqueFilename = this.generateUniqueFilename(filename);
// 确保用户目录存在
const userDir = path.join(this.baseDir, userId);
this.ensureDirectoryExists(userDir);
const filePath = path.join(userDir, uniqueFilename);
try {
if (typeof data === 'string') {
// base64 数据
if (data.startsWith('data:')) {
const base64Data = data.split(',')[1];
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
} else {
await fs.promises.writeFile(filePath, data);
}
} else if (Buffer.isBuffer(data)) {
await fs.promises.writeFile(filePath, data);
} else {
// 处理 Blob 类型
const arrayBuffer = await data.arrayBuffer();
await fs.promises.writeFile(filePath, Buffer.from(arrayBuffer));
}
// 获取文件大小
const stats = await fs.promises.stat(filePath);
logger.debug('文件上传成功', { userId, filename: uniqueFilename, size: stats.size });
return {
filename: uniqueFilename,
contentType,
size: stats.size,
path: filePath,
metadata: options.metadata,
};
} catch (error) {
logger.error('文件上传失败', { userId, filename, error });
throw new Error(`文件上传失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取文件
* @param userId 用户ID
* @param filename 文件名
* @returns 存储文件信息如果不存在则返回null
*/
async getFile(userId: string, filename: string): Promise<StorageFile | null> {
const filePath = path.join(this.baseDir, userId, filename);
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile()) {
return null;
}
const contentType = this.getContentTypeFromFilename(filename);
return {
filename,
contentType,
size: stats.size,
path: filePath,
};
} catch (error) {
logger.error('获取文件失败', { userId, filename, error });
return null;
}
}
/**
* 删除文件
* @param userId 用户ID
* @param filename 文件名
* @returns 是否删除成功
*/
async deleteFile(userId: string, filename: string): Promise<boolean> {
const filePath = path.join(this.baseDir, userId, filename);
try {
await fs.promises.unlink(filePath);
logger.debug('文件删除成功', { userId, filename });
return true;
} catch (error) {
logger.error('删除文件失败', { userId, filename, error });
return false;
}
}
/**
* 检查文件是否存在
* @param userId 用户ID
* @param filename 文件名
* @returns 文件是否存在
*/
async fileExists(userId: string, filename: string): Promise<boolean> {
const filePath = path.join(this.baseDir, userId, filename);
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* 确保目录存在
* @param dir 目录路径
*/
private ensureDirectoryExists(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* 根据文件名获取内容类型
* @param filename 文件名
* @returns 内容类型
*/
private getContentTypeFromFilename(filename: string): string {
const extension = this.getFileExtension(filename).toLowerCase();
// 常见文件类型映射
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.pdf': 'application/pdf',
};
return mimeTypes[extension] || 'application/octet-stream';
}
}

View File

@@ -0,0 +1,75 @@
/**
* 存储文件信息接口
*/
export interface StorageFile {
/** 文件名 */
filename: string;
/** 内容类型 */
contentType: string;
/** 文件大小(字节) */
size: number;
/** 文件路径 */
path: string;
/** 元数据 */
metadata?: Record<string, any>;
}
/**
* 存储上传选项接口
*/
export interface StorageUploadOptions {
/** 用户ID */
userId: string;
/** 内容类型 */
contentType: string;
/** 文件名 */
filename: string;
/** 文件数据 */
data: Buffer | Blob | string;
/** 元数据 */
metadata?: Record<string, any>;
}
/**
* 存储提供者接口
*/
export interface StorageProvider {
/**
* 上传文件
* @param options 上传选项
* @returns 存储文件信息
*/
uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
/**
* 获取文件
* @param userId 用户ID
* @param filename 文件名
* @returns 存储文件信息如果不存在则返回null
*/
getFile(userId: string, filename: string): Promise<StorageFile | null>;
/**
* 删除文件
* @param userId 用户ID
* @param filename 文件名
* @returns 是否删除成功
*/
deleteFile(userId: string, filename: string): Promise<boolean>;
/**
* 检查文件是否存在
* @param userId 用户ID
* @param filename 文件名
* @returns 文件是否存在
*/
fileExists(userId: string, filename: string): Promise<boolean>;
/**
* 生成文件访问URL
* @param userId 用户ID
* @param filename 文件名
* @returns 文件访问URL
*/
getFileUrl(userId: string, filename: string): string;
}

View File

@@ -0,0 +1,57 @@
import { json, type TypedResponse } from '@remix-run/node';
import type { ApiResponse } from '~/types/global';
/**
* 创建标准化的 API 响应
*
* @param data 响应数据
* @param message 响应消息
* @param status HTTP 状态码,默认为 200
* @returns 标准化的 API 响应
*/
export function apiResponse<T = any>(
status: number = 200,
data?: T,
message?: string,
success: boolean = true,
headers?: HeadersInit,
): TypedResponse<ApiResponse<T>> {
const finalSuccess = success ?? (status >= 200 && status < 300);
const responseBody: ApiResponse<T> = {
success: finalSuccess,
...(data !== undefined ? { data } : {}),
...(message !== undefined ? { message } : {}),
};
return json(responseBody, { status, headers });
}
/**
* 创建成功的 API 响应
* @param data 响应数据
* @param message 成功消息
* @returns 成功的 API 响应
*/
export function successResponse<T = any>(
data?: T,
message?: string,
headers?: HeadersInit,
): TypedResponse<ApiResponse<T>> {
return apiResponse(200, data, message, true, headers);
}
/**
* 创建错误的 API 响应
* @param message 错误消息
* @param status HTTP 状态码,默认为 400
* @param data 额外的错误数据
* @returns 错误的 API 响应
*/
export function errorResponse<T = any>(
status: number = 400,
errorDetails?: string,
headers?: HeadersInit,
): TypedResponse<ApiResponse<T>> {
return apiResponse<T>(status, undefined, errorDetails, false, headers);
}

View File

@@ -0,0 +1,10 @@
type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParams | FormData | string };
export async function request(url: string, init?: CommonRequest) {
const nodeFetch = await import('node-fetch');
const https = await import('node:https');
const agent = url.startsWith('https') ? new https.Agent({ rejectUnauthorized: false }) : undefined;
return nodeFetch.default(url, { ...init, agent });
}

View File

@@ -0,0 +1,34 @@
import type { UIMessage, UIMessagePart } from 'ai';
import { Tiktoken } from 'js-tiktoken/lite';
import o200k_base from 'js-tiktoken/ranks/o200k_base';
const tiktoken = new Tiktoken(o200k_base);
export function encode(text: string) {
return tiktoken.encode(text);
}
export function decode(tokens: number[]) {
return tiktoken.decode(tokens);
}
export function approximatePromptTokenCount(messages: UIMessage[]): number {
return messages.reduce((acc, message) => {
return acc + approximateUsageFromContent(message.parts);
}, 0);
}
export function approximateUsageFromContent(parts: Array<UIMessagePart<any, any>>): number {
let totalLength = 0;
for (const part of parts) {
if (part.type === 'text') {
totalLength += encode(part.text).length;
}
if (part.type === 'reasoning') {
totalLength += encode(part.text).length;
}
}
return totalLength;
}