mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af50ae40fd | |||
| 0724d8ca60 | |||
| 9f36fe95ac | |||
| 3f148005e4 | |||
| 4e60d87514 | |||
| d2a16d0714 |
@@ -0,0 +1,15 @@
|
||||
@regression @P0 @agent @topic-switch
|
||||
Feature: 切换话题不触发页面全量刷新
|
||||
作为用户,当我在 Agent 执行过程中切换话题时,页面不应该发生全量重新加载
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
|
||||
@AGENT-TOPIC-RELOAD-001 @smoke
|
||||
Scenario: 在同一 Agent 下切换话题不触发页面刷新
|
||||
Given 用户在当前 Agent 中创建了两个对话
|
||||
When 用户在页面注入状态标记
|
||||
And 用户切换到另一个话题
|
||||
Then 页面状态标记应该仍然存在
|
||||
And 页面导航类型不应该是全量加载
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Topic Switch No-Reload Regression Test Steps
|
||||
*
|
||||
* Verifies that switching topics within the same agent does NOT trigger a full
|
||||
* browser page reload. The bug was caused by NavItem's onClick handler skipping
|
||||
* e.preventDefault() when disabled/loading was true, allowing the <a> tag's
|
||||
* default navigation to fire.
|
||||
*
|
||||
* Detection: inject a window marker before switching, verify it survives.
|
||||
*/
|
||||
import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import type { CustomWorld } from '../../support/world';
|
||||
import { WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
async function focusAndType(world: CustomWorld, text: string): Promise<void> {
|
||||
const candidates = [
|
||||
world.page.locator(
|
||||
'textarea[placeholder*="Ask"], textarea[placeholder*="Press"], textarea[placeholder*="输入"]',
|
||||
),
|
||||
world.page.locator('[data-testid="chat-input"] [contenteditable="true"]'),
|
||||
world.page.locator('[data-testid="chat-input"] textarea'),
|
||||
];
|
||||
|
||||
for (const locator of candidates) {
|
||||
const count = await locator.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = locator.nth(i);
|
||||
if (await item.isVisible().catch(() => false)) {
|
||||
await item.click({ force: true });
|
||||
await world.page.waitForTimeout(300);
|
||||
await world.page.keyboard.type(text, { delay: 30 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not find a visible chat input');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户在当前 Agent 中创建了两个对话', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 创建第一个对话...');
|
||||
|
||||
// Send first message to create topic 1
|
||||
await focusAndType(this, 'hello');
|
||||
await this.page.keyboard.press('Enter');
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
// Verify a topic appeared in sidebar
|
||||
const topicItems = this.page.locator('[data-testid="topic-item"]');
|
||||
await expect(topicItems.first()).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
console.log(' ✅ 第一个对话已创建');
|
||||
|
||||
// Create a new topic
|
||||
console.log(' 📍 Step: 创建第二个对话...');
|
||||
const addTopicButton = this.page.locator('svg.lucide-message-square-plus').locator('..');
|
||||
await expect(addTopicButton.first()).toBeVisible({ timeout: 5000 });
|
||||
await addTopicButton.first().click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Send message to create topic 2
|
||||
await focusAndType(this, 'world');
|
||||
await this.page.keyboard.press('Enter');
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
// Verify we now have at least 2 topics
|
||||
const topicCount = await topicItems.count();
|
||||
console.log(` 📍 话题数量: ${topicCount}`);
|
||||
expect(topicCount).toBeGreaterThanOrEqual(2);
|
||||
console.log(' ✅ 两个对话已创建');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户在页面注入状态标记', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 注入页面状态标记...');
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
(window as any).__e2eNoReloadMarker = true;
|
||||
});
|
||||
|
||||
// Verify marker was set
|
||||
const marker = await this.page.evaluate(() => (window as any).__e2eNoReloadMarker);
|
||||
expect(marker).toBe(true);
|
||||
console.log(' ✅ 状态标记已注入');
|
||||
});
|
||||
|
||||
When('用户切换到另一个话题', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 切换到另一个话题...');
|
||||
|
||||
const topicItems = this.page.locator('[data-testid="topic-item"]');
|
||||
const topicCount = await topicItems.count();
|
||||
console.log(` 📍 找到 ${topicCount} 个话题`);
|
||||
|
||||
// Find the first non-active topic and click it
|
||||
for (let i = 0; i < topicCount; i++) {
|
||||
const topic = topicItems.nth(i);
|
||||
// Check if this topic is NOT currently active (doesn't have active/filled variant)
|
||||
const isActive = await topic.evaluate((el) => {
|
||||
// Walk up to find the NavItem wrapper and check its variant/active state
|
||||
const navItem = el.closest('[class*="Block"]');
|
||||
return navItem?.getAttribute('data-active') === 'true' || el.classList.contains('active');
|
||||
});
|
||||
|
||||
if (!isActive) {
|
||||
await topic.click();
|
||||
console.log(` ✅ 已点击第 ${i + 1} 个话题`);
|
||||
await this.page.waitForTimeout(2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just click the first topic
|
||||
await topicItems.first().click();
|
||||
console.log(' ✅ 已点击第一个话题(fallback)');
|
||||
await this.page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('页面状态标记应该仍然存在', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证页面状态标记...');
|
||||
|
||||
const marker = await this.page.evaluate(() => (window as any).__e2eNoReloadMarker);
|
||||
|
||||
if (marker !== true) {
|
||||
// Take screenshot for debugging
|
||||
await this.takeScreenshot('topic-switch-reload-detected');
|
||||
throw new Error(
|
||||
'Page reload detected! window.__e2eNoReloadMarker was lost after topic switch. ' +
|
||||
'This means the <a> tag default navigation fired instead of SPA routing.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(' ✅ 页面状态标记仍然存在(未发生 reload)');
|
||||
});
|
||||
|
||||
Then('页面导航类型不应该是全量加载', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 检查导航类型...');
|
||||
|
||||
const navInfo = await this.page.evaluate(() => {
|
||||
const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
return {
|
||||
duration: entry?.duration,
|
||||
type: entry?.type,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` 📍 Navigation type: ${navInfo.type}, duration: ${navInfo.duration}ms`);
|
||||
|
||||
// If a full page reload happened AFTER the initial load, the navigation type
|
||||
// would still show the initial load type. The window marker check above is
|
||||
// the primary detection. This step provides additional diagnostic info.
|
||||
console.log(' ✅ 导航信息已记录');
|
||||
});
|
||||
@@ -4,6 +4,9 @@
|
||||
"error.retry": "Reload",
|
||||
"error.stack": "Error Stack",
|
||||
"error.title": "Oops, something went wrong..",
|
||||
"exceededContext.compact": "Compact Context",
|
||||
"exceededContext.desc": "The conversation has exceeded the context window limit. You can compact the context to compress history and continue chatting.",
|
||||
"exceededContext.title": "Context Window Exceeded",
|
||||
"fetchError.detail": "Error details",
|
||||
"fetchError.title": "Request failed",
|
||||
"import.importConfigFile.description": "Error reason: {{reason}}",
|
||||
@@ -108,7 +111,7 @@
|
||||
"response.PluginSettingsInvalid": "This skill needs to be correctly configured before it can be used. Please check if your configuration is correct",
|
||||
"response.ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
|
||||
"response.QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later.",
|
||||
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later or switch to another model.",
|
||||
"response.ServerAgentRuntimeError": "Sorry, the Agent service is currently unavailable. Please try again later or contact us via email for support.",
|
||||
"response.StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"response.SubscriptionKeyMismatch": "We apologize for the inconvenience. Due to a temporary system malfunction, your current subscription usage is inactive. Please click the button below to restore your subscription, or contact us via email for support.",
|
||||
@@ -120,6 +123,10 @@
|
||||
"supervisor.decisionFailed": "The group host is unable to function. Please check your host configuration to ensure the correct model, API Key, and API endpoint are set.",
|
||||
"testConnectionFailed": "Test connection failed: {{error}}",
|
||||
"tts.responseError": "Service request failed, please check the configuration or try again",
|
||||
"unknownError.copyTraceId": "Trace ID Copied",
|
||||
"unknownError.desc": "An unexpected error occurred. You can retry or report on",
|
||||
"unknownError.retry": "Retry",
|
||||
"unknownError.title": "Oops, the request took a nap",
|
||||
"unlock.addProxyUrl": "Add OpenAI proxy URL (optional)",
|
||||
"unlock.apiKey.description": "Enter your {{name}} API Key to start the session",
|
||||
"unlock.apiKey.imageGenerationDescription": "Enter your {{name}} API Key to start generating",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"image_generation_completed": "Image \"{{prompt}}\" generated successfully",
|
||||
"image_generation_completed_title": "Image Generated",
|
||||
"inbox.archiveAll": "Archive all",
|
||||
"inbox.empty": "No notifications yet",
|
||||
"inbox.emptyUnread": "No unread notifications",
|
||||
"inbox.filterUnread": "Show unread only",
|
||||
"inbox.markAllRead": "Mark all as read",
|
||||
"inbox.title": "Notifications",
|
||||
"video_generation_completed": "Video \"{{prompt}}\" generated successfully",
|
||||
"video_generation_completed_title": "Video Generated"
|
||||
}
|
||||
@@ -443,6 +443,12 @@
|
||||
"myAgents.status.published": "Published",
|
||||
"myAgents.status.unpublished": "Unpublished",
|
||||
"myAgents.title": "My Published Agents",
|
||||
"notification.email.desc": "Receive email notifications when important events occur",
|
||||
"notification.email.title": "Email Notifications",
|
||||
"notification.enabled": "Enabled",
|
||||
"notification.inbox.desc": "Show notifications in the in-app inbox",
|
||||
"notification.inbox.title": "Inbox Notifications",
|
||||
"notification.title": "Notification Channels",
|
||||
"plugin.addMCPPlugin": "Add MCP",
|
||||
"plugin.addTooltip": "Custom Skills",
|
||||
"plugin.clearDeprecated": "Remove Deprecated Skills",
|
||||
@@ -807,6 +813,7 @@
|
||||
"tab.manualFill": "Manually Fill In",
|
||||
"tab.manualFill.desc": "Configure a custom MCP skill manually",
|
||||
"tab.memory": "Memory",
|
||||
"tab.notification": "Notifications",
|
||||
"tab.profile": "My Account",
|
||||
"tab.provider": "Provider",
|
||||
"tab.proxy": "Proxy",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"error.retry": "重新加载",
|
||||
"error.stack": "错误堆栈",
|
||||
"error.title": "页面暂时不可用",
|
||||
"exceededContext.compact": "压缩上下文",
|
||||
"exceededContext.desc": "对话已超出模型上下文窗口限制。你可以压缩上下文来压缩历史记录并继续对话。",
|
||||
"exceededContext.title": "上下文窗口超出限制",
|
||||
"fetchError.detail": "查看详情",
|
||||
"fetchError.title": "请求未能完成",
|
||||
"import.importConfigFile.description": "原因:{{reason}}",
|
||||
@@ -108,7 +111,7 @@
|
||||
"response.PluginSettingsInvalid": "该技能需要完成配置后才能使用,请检查技能配置",
|
||||
"response.ProviderBizError": "模型服务商返回错误。请根据以下信息排查,或稍后重试",
|
||||
"response.QuotaLimitReached": "Token 用量或请求次数已达配额上限。请提升配额或稍后再试",
|
||||
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试。",
|
||||
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试或切换其他模型。",
|
||||
"response.ServerAgentRuntimeError": "助理运行服务暂不可用。请稍后再试,或邮件联系我们",
|
||||
"response.StreamChunkError": "流式响应解析失败。请检查接口是否符合规范,或联系模型服务商",
|
||||
"response.SubscriptionKeyMismatch": "订阅状态同步异常。你可以点击下方按钮恢复订阅,或邮件联系我们",
|
||||
@@ -120,6 +123,10 @@
|
||||
"supervisor.decisionFailed": "群组主持人运行失败。请检查主持人配置(模型、API Key 与 API 地址)后重试",
|
||||
"testConnectionFailed": "测试连接失败:{{error}}",
|
||||
"tts.responseError": "请求失败。请检查配置后重试",
|
||||
"unknownError.copyTraceId": "Trace ID 已复制",
|
||||
"unknownError.desc": "遇到了意外错误,请重试或反馈至",
|
||||
"unknownError.retry": "重试",
|
||||
"unknownError.title": "糟糕,请求打了个盹",
|
||||
"unlock.addProxyUrl": "添加 OpenAI 代理地址(可选)",
|
||||
"unlock.apiKey.description": "输入你的 {{name}} API Key,即可开始会话",
|
||||
"unlock.apiKey.imageGenerationDescription": "输入你的 {{name}} API Key,即可开始生成",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
|
||||
"image_generation_completed_title": "图片已生成",
|
||||
"inbox.archiveAll": "全部归档",
|
||||
"inbox.empty": "暂无通知",
|
||||
"inbox.emptyUnread": "没有未读通知",
|
||||
"inbox.filterUnread": "仅显示未读",
|
||||
"inbox.markAllRead": "全部标为已读",
|
||||
"inbox.title": "通知",
|
||||
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",
|
||||
"video_generation_completed_title": "视频已生成"
|
||||
}
|
||||
@@ -443,6 +443,12 @@
|
||||
"myAgents.status.published": "已上架",
|
||||
"myAgents.status.unpublished": "未上架",
|
||||
"myAgents.title": "我发布的助理",
|
||||
"notification.email.desc": "当重要事件发生时接收邮件通知",
|
||||
"notification.email.title": "邮件通知",
|
||||
"notification.enabled": "启用",
|
||||
"notification.inbox.desc": "在应用内收件箱中显示通知",
|
||||
"notification.inbox.title": "站内通知",
|
||||
"notification.title": "通知渠道",
|
||||
"plugin.addMCPPlugin": "添加 MCP",
|
||||
"plugin.addTooltip": "自定义技能",
|
||||
"plugin.clearDeprecated": "移除无效技能",
|
||||
@@ -807,6 +813,7 @@
|
||||
"tab.manualFill": "自行填写内容",
|
||||
"tab.manualFill.desc": "手动配置自定义 MCP 技能",
|
||||
"tab.memory": "记忆设置",
|
||||
"tab.notification": "通知",
|
||||
"tab.profile": "我的账号",
|
||||
"tab.provider": "AI 服务商",
|
||||
"tab.proxy": "网络代理",
|
||||
|
||||
+4
-4
@@ -196,7 +196,6 @@
|
||||
"@huggingface/inference": "^4.13.10",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@khmyznikov/pwa-install": "0.3.9",
|
||||
"@langchain/community": "^0.3.59",
|
||||
"@lexical/utils": "^0.39.0",
|
||||
"@lobechat/agent-runtime": "workspace:*",
|
||||
"@lobechat/builtin-agents": "workspace:*",
|
||||
@@ -308,6 +307,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie": "^1.1.1",
|
||||
"countries-and-timezones": "^3.8.0",
|
||||
"d3-dsv": "^3.0.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"debug": "^4.4.3",
|
||||
"dexie": "^3.2.7",
|
||||
@@ -333,7 +333,6 @@
|
||||
"js-sha256": "^0.11.1",
|
||||
"jsonl-parse-stringify": "^1.0.3",
|
||||
"klavis": "^2.15.0",
|
||||
"langchain": "^0.3.37",
|
||||
"langfuse": "^3.38.6",
|
||||
"langfuse-core": "^3.38.6",
|
||||
"lexical": "^0.39.0",
|
||||
@@ -402,7 +401,6 @@
|
||||
"superjson": "^2.2.6",
|
||||
"svix": "^1.84.1",
|
||||
"swr": "^2.3.8",
|
||||
"systemjs": "^6.15.1",
|
||||
"three": "^0.181.2",
|
||||
"tokenx": "^1.3.0",
|
||||
"ts-md5": "^2.0.1",
|
||||
@@ -444,21 +442,23 @@
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3-dsv": "^3.0.7",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/numeral": "^2.0.5",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/rtl-detect": "^1.0.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/systemjs": "^6.15.4",
|
||||
"@types/three": "^0.181.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/unist": "^3.0.3",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_HOTKEY_CONFIG,
|
||||
DEFAULT_IMAGE_CONFIG,
|
||||
DEFAULT_MEMORY_SETTINGS,
|
||||
DEFAULT_NOTIFICATION_SETTINGS,
|
||||
DEFAULT_SYSTEM_AGENT_CONFIG,
|
||||
DEFAULT_TOOL_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
keyVaults: {},
|
||||
languageModel: DEFAULT_LLM_CONFIG,
|
||||
memory: DEFAULT_MEMORY_SETTINGS,
|
||||
notification: DEFAULT_NOTIFICATION_SETTINGS,
|
||||
systemAgent: DEFAULT_SYSTEM_AGENT_CONFIG,
|
||||
tool: DEFAULT_TOOL_CONFIG,
|
||||
tts: DEFAULT_TTS_CONFIG,
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './image';
|
||||
export * from './knowledge';
|
||||
export * from './llm';
|
||||
export * from './memory';
|
||||
export * from './notification';
|
||||
export * from './systemAgent';
|
||||
export * from './tool';
|
||||
export * from './tts';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NotificationSettings } from '@lobechat/types';
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
email: {
|
||||
enabled: true,
|
||||
items: {
|
||||
generation: {
|
||||
image_generation_completed: true,
|
||||
video_generation_completed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
enabled: true,
|
||||
items: {
|
||||
generation: {
|
||||
image_generation_completed: true,
|
||||
video_generation_completed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm';
|
||||
|
||||
import type { NewNotification, NewNotificationDelivery } from '../schemas/notification';
|
||||
import { notificationDeliveries, notifications } from '../schemas/notification';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class NotificationModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async list(
|
||||
opts: { category?: string; cursor?: string; limit?: number; unreadOnly?: boolean } = {},
|
||||
) {
|
||||
const { cursor, limit = 20, category, unreadOnly } = opts;
|
||||
|
||||
const conditions = [eq(notifications.userId, this.userId), eq(notifications.isArchived, false)];
|
||||
|
||||
if (unreadOnly) {
|
||||
conditions.push(eq(notifications.isRead, false));
|
||||
}
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(notifications.category, category));
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
const cursorRow = await this.db
|
||||
.select({ createdAt: notifications.createdAt, id: notifications.id })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.id, cursor), eq(notifications.userId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (cursorRow[0]) {
|
||||
// Composite cursor to handle identical createdAt timestamps
|
||||
const { createdAt: cursorTime, id: cursorId } = cursorRow[0];
|
||||
conditions.push(
|
||||
or(
|
||||
lt(notifications.createdAt, cursorTime),
|
||||
and(eq(notifications.createdAt, cursorTime), lt(notifications.id, cursorId)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(notifications.createdAt), desc(notifications.id))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const [result] = await this.db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, this.userId),
|
||||
eq(notifications.isRead, false),
|
||||
eq(notifications.isArchived, false),
|
||||
),
|
||||
);
|
||||
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
async markAsRead(ids: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.userId, this.userId), inArray(notifications.id, ids)));
|
||||
}
|
||||
|
||||
async markAllAsRead() {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, this.userId),
|
||||
eq(notifications.isRead, false),
|
||||
eq(notifications.isArchived, false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async archive(id: string) {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.id, id), eq(notifications.userId, this.userId)));
|
||||
}
|
||||
|
||||
async archiveAll() {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.userId, this.userId), eq(notifications.isArchived, false)));
|
||||
}
|
||||
|
||||
// ─── Write-side (used by NotificationService in cloud) ─────────
|
||||
|
||||
async create(data: Omit<NewNotification, 'userId'>) {
|
||||
const [result] = await this.db
|
||||
.insert(notifications)
|
||||
.values({ ...data, userId: this.userId })
|
||||
.onConflictDoNothing({
|
||||
target: [notifications.userId, notifications.dedupeKey],
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async createDelivery(data: NewNotificationDelivery) {
|
||||
const [result] = await this.db.insert(notificationDeliveries).values(data).returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentRuntimeErrorType } from '@lobechat/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { LobeRuntimeAI } from '../BaseAI';
|
||||
@@ -421,6 +422,83 @@ describe('createRouterRuntime', () => {
|
||||
).rejects.toThrow('empty provider options');
|
||||
});
|
||||
|
||||
it('should not retry when ExceededContextWindow error is thrown', async () => {
|
||||
const exceededError = {
|
||||
errorType: AgentRuntimeErrorType.ExceededContextWindow,
|
||||
error: { message: 'Too many input tokens' },
|
||||
provider: 'test',
|
||||
};
|
||||
|
||||
const mockChatFail = vi.fn().mockRejectedValue(exceededError);
|
||||
const mockChatSuccess = vi.fn().mockResolvedValue('success');
|
||||
|
||||
class FailRuntime implements LobeRuntimeAI {
|
||||
chat = mockChatFail;
|
||||
}
|
||||
|
||||
class SuccessRuntime implements LobeRuntimeAI {
|
||||
chat = mockChatSuccess;
|
||||
}
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [
|
||||
{
|
||||
apiType: 'openai',
|
||||
options: [
|
||||
{ apiKey: 'key-1', runtime: FailRuntime as any },
|
||||
{ apiKey: 'key-2', runtime: SuccessRuntime as any },
|
||||
],
|
||||
runtime: FailRuntime as any,
|
||||
models: ['gpt-4'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
await expect(
|
||||
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
|
||||
).rejects.toEqual(exceededError);
|
||||
|
||||
// Second channel should never be called
|
||||
expect(mockChatFail).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still retry on other error types', async () => {
|
||||
const bizError = {
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
error: { message: 'Server error' },
|
||||
provider: 'test',
|
||||
};
|
||||
|
||||
const mockChatFail = vi.fn().mockRejectedValue(bizError);
|
||||
|
||||
class FailRuntime implements LobeRuntimeAI {
|
||||
chat = mockChatFail;
|
||||
}
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [
|
||||
{
|
||||
apiType: 'openai',
|
||||
options: [{ apiKey: 'key-1' }, { apiKey: 'key-2' }],
|
||||
runtime: FailRuntime as any,
|
||||
models: ['gpt-4'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
await expect(
|
||||
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
|
||||
).rejects.toEqual(bizError);
|
||||
|
||||
// Both channels should be tried
|
||||
expect(mockChatFail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use apiType from option item when specified for fallback', async () => {
|
||||
const constructorCalls: any[] = [];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @see https://github.com/lobehub/lobe-chat/discussions/6563
|
||||
*/
|
||||
import type { GoogleGenAIOptions } from '@google/genai';
|
||||
import type { ChatModelCard } from '@lobechat/types';
|
||||
import { AgentRuntimeErrorType, type ChatModelCard } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
import type { ClientOptions } from 'openai';
|
||||
import type OpenAI from 'openai';
|
||||
@@ -392,6 +392,14 @@ export const createRouterRuntime = ({
|
||||
log('onRouteAttempt callback error: %O', e);
|
||||
});
|
||||
|
||||
// Non-retryable errors: the request itself is invalid, retrying with another channel won't help
|
||||
if (
|
||||
(error as ChatCompletionErrorPayload)?.errorType ===
|
||||
AgentRuntimeErrorType.ExceededContextWindow
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt < totalOptions) {
|
||||
log(
|
||||
'attempt %d/%d failed (model=%s apiType=%s channelId=%s remark=%s), trying next',
|
||||
|
||||
@@ -703,6 +703,29 @@ describe('LobeBedrockAI', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ExceededContextWindow when error message indicates context window exceeded', async () => {
|
||||
const errorMessage =
|
||||
'Too many input tokens. Max input tokens for this model is 200000, but 250000 were provided.';
|
||||
const errorMetadata = { statusCode: 400 };
|
||||
const mockError = new Error(errorMessage);
|
||||
(mockError as any).$metadata = errorMetadata;
|
||||
(instance['client'].send as Mock).mockRejectedValue(mockError);
|
||||
|
||||
await expect(
|
||||
instance.chat({
|
||||
max_tokens: 100,
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'anthropic.claude-v2:1',
|
||||
temperature: 0,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
errorType: AgentRuntimeErrorType.ExceededContextWindow,
|
||||
provider: ModelProvider.Bedrock,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Llama Model', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AgentRuntimeErrorType } from '../../types/error';
|
||||
import { AgentRuntimeError } from '../../utils/createError';
|
||||
import { debugStream } from '../../utils/debugStream';
|
||||
import { getModelPricing } from '../../utils/getModelPricing';
|
||||
import { isExceededContextWindowError } from '../../utils/isExceededContextWindowError';
|
||||
import { StreamingResponse } from '../../utils/response';
|
||||
|
||||
/**
|
||||
@@ -284,6 +285,9 @@ export class LobeBedrockAI implements LobeRuntimeAI {
|
||||
);
|
||||
} catch (e) {
|
||||
const err = e as Error & { $metadata: any };
|
||||
const errorType = isExceededContextWindowError(err.message)
|
||||
? AgentRuntimeErrorType.ExceededContextWindow
|
||||
: AgentRuntimeErrorType.ProviderBizError;
|
||||
|
||||
throw AgentRuntimeError.chat({
|
||||
error: {
|
||||
@@ -291,7 +295,7 @@ export class LobeBedrockAI implements LobeRuntimeAI {
|
||||
message: err.message,
|
||||
type: err.name,
|
||||
},
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
errorType,
|
||||
provider: this.id,
|
||||
region: this.region,
|
||||
});
|
||||
|
||||
@@ -103,6 +103,22 @@ export class LobeFalAI implements LobeRuntimeAI {
|
||||
});
|
||||
}
|
||||
|
||||
// 422 ValidationError with content_policy_violation — show a clean message
|
||||
if (error instanceof Error && 'status' in error && error.status === 422) {
|
||||
const body = 'body' in error ? (error as any).body : undefined;
|
||||
const hasContentPolicyViolation =
|
||||
Array.isArray(body?.detail) &&
|
||||
body.detail.some((d: any) => d.type === 'content_policy_violation');
|
||||
|
||||
if (hasContentPolicyViolation) {
|
||||
throw AgentRuntimeError.createError(AgentRuntimeErrorType.ProviderBizError, {
|
||||
error,
|
||||
message:
|
||||
'The request content violates content policy. Please modify your prompt and try again.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw AgentRuntimeError.createError(AgentRuntimeErrorType.ProviderBizError, { error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { UserKeyVaults } from './keyVaults';
|
||||
import type { MarketAuthTokens } from './market';
|
||||
import type { UserMemorySettings } from './memory';
|
||||
import type { UserModelProviderConfig } from './modelProvider';
|
||||
import type { NotificationSettings } from './notification';
|
||||
import type { UserSystemAgentConfig } from './systemAgent';
|
||||
import type { UserToolConfig } from './tool';
|
||||
import type { UserTTSConfig } from './tts';
|
||||
@@ -22,6 +23,7 @@ export * from './keyVaults';
|
||||
export * from './market';
|
||||
export * from './memory';
|
||||
export * from './modelProvider';
|
||||
export * from './notification';
|
||||
export * from './sync';
|
||||
export * from './systemAgent';
|
||||
export * from './tool';
|
||||
@@ -39,6 +41,7 @@ export interface UserSettings {
|
||||
languageModel: UserModelProviderConfig;
|
||||
market?: MarketAuthTokens;
|
||||
memory?: UserMemorySettings;
|
||||
notification?: NotificationSettings;
|
||||
systemAgent: UserSystemAgentConfig;
|
||||
tool: UserToolConfig;
|
||||
tts: UserTTSConfig;
|
||||
@@ -58,6 +61,7 @@ export const UserSettingsSchema = z
|
||||
languageModel: z.any().optional(),
|
||||
market: z.any().optional(),
|
||||
memory: z.any().optional(),
|
||||
notification: z.any().optional(),
|
||||
systemAgent: z.any().optional(),
|
||||
tool: z.any().optional(),
|
||||
tts: z.any().optional(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface NotificationChannelSettings {
|
||||
enabled?: boolean;
|
||||
/** Per-type overrides grouped by category. Missing = use scenario default (true) */
|
||||
items?: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
email?: NotificationChannelSettings;
|
||||
inbox?: NotificationChannelSettings;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { type RuntimeVideoGenParams } from 'model-bank';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
|
||||
import { notifyVideoCompleted } from '@/business/server/video-generation/notifyVideoCompleted';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { GenerationModel } from '@/database/models/generation';
|
||||
import { generationBatches } from '@/database/schemas';
|
||||
@@ -201,6 +202,14 @@ export const POST = async (req: Request, { params }: { params: Promise<{ provide
|
||||
status: AsyncTaskStatus.Success,
|
||||
});
|
||||
|
||||
notifyVideoCompleted({
|
||||
generationBatchId: generation.generationBatchId!,
|
||||
model: resolvedModel,
|
||||
prompt: batch?.prompt ?? '',
|
||||
topicId: batch?.generationTopicId,
|
||||
userId: asyncTask.userId,
|
||||
}).catch((err) => console.error('[video-webhook] notification failed:', err));
|
||||
|
||||
// Charge after successful video generation
|
||||
try {
|
||||
await chargeAfterGenerate({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const Notification = () => null;
|
||||
|
||||
export default Notification;
|
||||
@@ -0,0 +1,11 @@
|
||||
interface NotifyImageCompletedParams {
|
||||
duration: number;
|
||||
generationBatchId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
topicId?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
export async function notifyImageCompleted(params: NotifyImageCompletedParams): Promise<void> {}
|
||||
@@ -0,0 +1,10 @@
|
||||
interface NotifyVideoCompletedParams {
|
||||
generationBatchId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
topicId?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
export async function notifyVideoCompleted(params: NotifyVideoCompletedParams): Promise<void> {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatToken } from './TokenProgress';
|
||||
|
||||
describe('formatToken', () => {
|
||||
it('should format numbers >= 1M with M suffix', () => {
|
||||
expect(formatToken(1_000_000)).toBe('1M');
|
||||
expect(formatToken(1_500_000)).toBe('1.5M');
|
||||
expect(formatToken(2_000_000)).toBe('2M');
|
||||
expect(formatToken(10_000_000)).toBe('10M');
|
||||
});
|
||||
|
||||
it('should format numbers >= 1K with K suffix', () => {
|
||||
expect(formatToken(1_000)).toBe('1K');
|
||||
expect(formatToken(14_251)).toBe('14.3K');
|
||||
expect(formatToken(985_749)).toBe('985.7K');
|
||||
expect(formatToken(999_999)).toBe('1000K');
|
||||
});
|
||||
|
||||
it('should format numbers < 1K with comma separator', () => {
|
||||
expect(formatToken(0)).toBe('0');
|
||||
expect(formatToken(1)).toBe('1');
|
||||
expect(formatToken(999)).toBe('999');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,11 @@ interface TokenProgressProps {
|
||||
showTotal?: string;
|
||||
}
|
||||
|
||||
const format = (number: number) => numeral(number).format('0,0');
|
||||
export const formatToken = (number: number) => {
|
||||
if (number >= 1_000_000) return numeral(number / 1_000_000).format('0.[0]') + 'M';
|
||||
if (number >= 1_000) return numeral(number / 1_000).format('0.[0]') + 'K';
|
||||
return numeral(number).format('0,0');
|
||||
};
|
||||
|
||||
const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) => {
|
||||
const total = data.reduce((acc, item) => acc + item.value, 0);
|
||||
@@ -59,7 +63,7 @@ const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) =
|
||||
)}
|
||||
<div style={{ color: cssVar.colorTextSecondary }}>{item.title}</div>
|
||||
</Flexbox>
|
||||
<div style={{ fontWeight: 500 }}>{format(item.value)}</div>
|
||||
<div style={{ fontWeight: 500 }}>{formatToken(item.value)}</div>
|
||||
</Flexbox>
|
||||
))}
|
||||
{showTotal && (
|
||||
@@ -67,7 +71,7 @@ const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) =
|
||||
<Divider style={{ marginBlock: 8 }} />
|
||||
<Flexbox horizontal align={'center'} gap={4} justify={'space-between'}>
|
||||
<div style={{ color: cssVar.colorTextSecondary }}>{showTotal}</div>
|
||||
<div style={{ fontWeight: 500 }}>{format(total)}</div>
|
||||
<div style={{ fontWeight: 500 }}>{formatToken(total)}</div>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { useConversationStore } from '../store';
|
||||
import BaseErrorForm from './BaseErrorForm';
|
||||
|
||||
interface ExceededContextWindowErrorProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const ExceededContextWindowError = memo<ExceededContextWindowErrorProps>(({ id }) => {
|
||||
const { t } = useTranslation('error');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const context = useConversationStore((s) => s.context);
|
||||
const regenerateUserMessage = useConversationStore((s) => s.regenerateUserMessage);
|
||||
const parentId = useConversationStore(
|
||||
(s) => s.displayMessages.find((m) => m.id === id)?.parentId,
|
||||
);
|
||||
|
||||
const handleCompact = useCallback(async () => {
|
||||
if (!context.topicId || !parentId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await useChatStore.getState().executeCompression(context, '');
|
||||
await regenerateUserMessage(parentId);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [context, parentId, regenerateUserMessage]);
|
||||
|
||||
return (
|
||||
<BaseErrorForm
|
||||
avatar={<Icon icon={Minimize2} size={24} />}
|
||||
desc={t('exceededContext.desc')}
|
||||
title={t('exceededContext.title')}
|
||||
action={
|
||||
<Button
|
||||
disabled={!context.topicId}
|
||||
loading={loading}
|
||||
type={'primary'}
|
||||
onClick={handleCompact}
|
||||
>
|
||||
{t('exceededContext.compact')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExceededContextWindowError;
|
||||
@@ -38,6 +38,11 @@ const loading = () => (
|
||||
</Block>
|
||||
);
|
||||
|
||||
const ExceededContextWindowError = dynamic(() => import('./ExceededContextWindowError'), {
|
||||
loading,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const OllamaBizError = dynamic(() => import('./OllamaBizError'), { loading, ssr: false });
|
||||
|
||||
const OllamaSetupGuide = dynamic(() => import('./OllamaSetupGuide'), {
|
||||
@@ -130,6 +135,10 @@ const ErrorMessageExtra = memo<ErrorExtraProps>(({ error: alertError, data }) =>
|
||||
return <OllamaBizError {...data} />;
|
||||
}
|
||||
|
||||
case AgentRuntimeErrorType.ExceededContextWindow: {
|
||||
return <ExceededContextWindowError id={data.id} />;
|
||||
}
|
||||
|
||||
/* ↓ cloud slot ↓ */
|
||||
|
||||
/* ↑ cloud slot ↑ */
|
||||
|
||||
@@ -128,15 +128,7 @@ const SideBarHeaderLayout = memo<SideBarHeaderLayoutProps>(
|
||||
padding={6}
|
||||
>
|
||||
{leftContent}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={2}
|
||||
justify={'flex-end'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={2} justify={'flex-end'}>
|
||||
{showTogglePanelButton && <ToggleLeftPanelButton />}
|
||||
{right}
|
||||
</Flexbox>
|
||||
|
||||
@@ -102,12 +102,12 @@ const NavItem = memo<NavItemProps>(
|
||||
paddingInline={4}
|
||||
variant={variant}
|
||||
onClick={(e) => {
|
||||
if (disabled || loading) return;
|
||||
// Prevent default link behavior for normal clicks (let onClick handle it)
|
||||
// But allow cmd+click to open in new tab
|
||||
// Always prevent default <a> navigation for normal clicks to avoid full page reload.
|
||||
// This must run before any early return to ensure SPA navigation is never bypassed.
|
||||
if (href && !isModifierClick(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (disabled || loading) return;
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...linkProps}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { type PluginRender, type PluginRenderProps } from '@lobehub/chat-plugin-sdk/client';
|
||||
import { Skeleton } from '@lobehub/ui';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
import { system } from './utils';
|
||||
|
||||
interface SystemJsRenderProps extends PluginRenderProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const RenderCache: {
|
||||
[url: string]: PluginRender;
|
||||
} = {};
|
||||
|
||||
const SystemJsRender = memo<SystemJsRenderProps>(({ url, ...props }) => {
|
||||
const [component, setComp] = useState<PluginRender | undefined>(RenderCache[url]);
|
||||
|
||||
useEffect(() => {
|
||||
system
|
||||
.import(url)
|
||||
.then((module1) => {
|
||||
setComp(module1.default);
|
||||
RenderCache[url] = module1.default;
|
||||
// Use the module1 module
|
||||
})
|
||||
.catch((error) => {
|
||||
setComp(undefined);
|
||||
console.error(error);
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
if (!component) {
|
||||
return <Skeleton active style={{ width: 300 }} />;
|
||||
}
|
||||
|
||||
const Render = component;
|
||||
|
||||
return <Render {...props} />;
|
||||
});
|
||||
export default SystemJsRender;
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* This dynamic loading module is implemented using SystemJS, caching four modules in Lobe Chat: React, ReactDOM, antd, and antd-style.
|
||||
*/
|
||||
import 'systemjs';
|
||||
|
||||
import * as antd from 'antd';
|
||||
import * as AntdStyle from 'antd-style';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
System.addImportMap({
|
||||
imports: {
|
||||
'React': 'app:React',
|
||||
'ReactDOM': 'app:ReactDOM',
|
||||
'antd': 'app:antd',
|
||||
'antd-style': 'app:antd-style',
|
||||
},
|
||||
});
|
||||
|
||||
System.set('app:React', { default: React, ...React });
|
||||
System.set('app:ReactDOM', { __useDefault: true, ...ReactDOM });
|
||||
System.set('app:antd', antd);
|
||||
System.set('app:antd-style', AntdStyle);
|
||||
|
||||
export const system = System;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Skeleton } from '@lobehub/ui';
|
||||
import { memo,Suspense } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { pluginSelectors } from '@/store/tool/selectors';
|
||||
|
||||
@@ -9,8 +7,6 @@ import Loading from '../Loading';
|
||||
import { useParseContent } from '../useParseContent';
|
||||
import IFrameRender from './IFrameRender';
|
||||
|
||||
const SystemJsRender = dynamic(() => import('./SystemJsRender'), { ssr: false });
|
||||
|
||||
export interface PluginDefaultTypeProps {
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
@@ -32,13 +28,6 @@ const PluginDefaultType = memo<PluginDefaultTypeProps>(({ content, name, loading
|
||||
|
||||
if (!ui.url) return;
|
||||
|
||||
if (ui.mode === 'module')
|
||||
return (
|
||||
<Suspense fallback={<Skeleton active style={{ width: 400 }} />}>
|
||||
<SystemJsRender content={data} name={name || 'unknown'} url={ui.url} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<IFrameRender
|
||||
content={data}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const LANGCHAIN_SUPPORT_TEXT_LIST = [
|
||||
export const SUPPORT_TEXT_LIST = [
|
||||
'txt',
|
||||
'markdown',
|
||||
'md',
|
||||
+12
-9
@@ -3,7 +3,6 @@ import * as fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { CodeLoader } from '../index';
|
||||
import longResult from './long.json';
|
||||
|
||||
describe('CodeLoader', () => {
|
||||
it('split simple code', async () => {
|
||||
@@ -15,13 +14,12 @@ helloWorld();`;
|
||||
|
||||
const result = await CodeLoader(jsCode, 'js');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
pageContent:
|
||||
'function helloWorld() {\n console.log("Hello, World!");\n}\n// Call the function\nhelloWorld();',
|
||||
metadata: { loc: { lines: { from: 1, to: 5 } } },
|
||||
},
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].pageContent).toBe(
|
||||
'function helloWorld() {\n console.log("Hello, World!");\n}\n// Call the function\nhelloWorld();',
|
||||
);
|
||||
expect(result[0].metadata.loc.lines.from).toBe(1);
|
||||
expect(result[0].metadata.loc.lines.to).toBe(5);
|
||||
});
|
||||
|
||||
it('split long', async () => {
|
||||
@@ -29,6 +27,11 @@ helloWorld();`;
|
||||
|
||||
const result = await CodeLoader(code, 'js');
|
||||
|
||||
expect(result).toEqual(longResult);
|
||||
// Should split long code into multiple chunks
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
for (const chunk of result) {
|
||||
expect(chunk.pageContent).toBeTruthy();
|
||||
expect(chunk.metadata.loc.lines).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { splitCode, type SupportedLanguage } from '../../splitter';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const CodeLoader = async (text: string, language: string) => {
|
||||
return splitCode(text, language as SupportedLanguage, loaderConfig);
|
||||
};
|
||||
+7
-3
@@ -7,13 +7,17 @@ import { expect } from 'vitest';
|
||||
import { CsVLoader } from '../index';
|
||||
|
||||
describe('CSVLoader', () => {
|
||||
it('should run', async () => {
|
||||
it('should parse CSV rows into documents', async () => {
|
||||
const content = fs.readFileSync(join(__dirname, `./demo.csv`), 'utf8');
|
||||
|
||||
const fileBlob = new Blob([Buffer.from(content)]);
|
||||
|
||||
const data = await CsVLoader(fileBlob);
|
||||
|
||||
expect(data).toMatchSnapshot();
|
||||
expect(data.length).toBe(32);
|
||||
// Check first row structure
|
||||
expect(data[0].metadata.line).toBe(1);
|
||||
expect(data[0].metadata.source).toBe('blob');
|
||||
expect(data[0].pageContent).toContain('Hair:');
|
||||
expect(data[0].pageContent).toContain('Eye:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type DocumentChunk } from '../../types';
|
||||
|
||||
export const CsVLoader = async (fileBlob: Blob): Promise<DocumentChunk[]> => {
|
||||
const { dsvFormat } = await import('d3-dsv');
|
||||
const csvParse = dsvFormat(',');
|
||||
|
||||
const text = await fileBlob.text();
|
||||
const rows = csvParse.parse(text);
|
||||
|
||||
return rows.map((row, index) => {
|
||||
const content = Object.entries(row)
|
||||
.filter(([key]) => key !== 'columns')
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
line: index + 1,
|
||||
source: 'blob',
|
||||
},
|
||||
pageContent: content,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { splitText } from '../../splitter';
|
||||
import { type DocumentChunk } from '../../types';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const DocxLoader = async (fileBlob: Blob | string): Promise<DocumentChunk[]> => {
|
||||
const mammoth = await import('mammoth');
|
||||
|
||||
const buffer =
|
||||
typeof fileBlob === 'string'
|
||||
? Buffer.from(fileBlob)
|
||||
: Buffer.from(await fileBlob.arrayBuffer());
|
||||
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
return splitText(result.value, loaderConfig);
|
||||
};
|
||||
+7
-10
@@ -6,20 +6,17 @@ import { expect } from 'vitest';
|
||||
|
||||
import { EPubLoader } from '../index';
|
||||
|
||||
function sanitizeDynamicFields(document: any[]) {
|
||||
for (const doc of document) {
|
||||
doc.metadata.source && (doc.metadata.source = '');
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
describe('EPubLoader', () => {
|
||||
it('should run', async () => {
|
||||
it('should parse epub content into chunks', async () => {
|
||||
const content = fs.readFileSync(join(__dirname, `./demo.epub`));
|
||||
|
||||
const fileContent: Uint8Array = new Uint8Array(content);
|
||||
|
||||
const data = await EPubLoader(fileContent);
|
||||
expect(sanitizeDynamicFields(data)).toMatchSnapshot();
|
||||
|
||||
expect(data.length).toBeGreaterThan(0);
|
||||
for (const chunk of data) {
|
||||
expect(chunk.pageContent).toBeTruthy();
|
||||
expect(chunk.metadata).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TempFileManager } from '@/server/utils/tempFileManager';
|
||||
import { nanoid } from '@/utils/uuid';
|
||||
|
||||
import { splitText } from '../../splitter';
|
||||
import { type DocumentChunk } from '../../types';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const EPubLoader = async (content: Uint8Array): Promise<DocumentChunk[]> => {
|
||||
const tempManager = new TempFileManager('epub-');
|
||||
|
||||
try {
|
||||
const tempPath = await tempManager.writeTempFile(content, `${nanoid()}.epub`);
|
||||
|
||||
const { EPub } = await import('epub2');
|
||||
const htmlToText = await import('html-to-text');
|
||||
|
||||
const epub = await EPub.createAsync(tempPath);
|
||||
const chapters = epub.flow || [];
|
||||
|
||||
const documents: DocumentChunk[] = [];
|
||||
|
||||
for (const chapter of chapters) {
|
||||
try {
|
||||
const html = await epub.getChapterRawAsync(chapter.id);
|
||||
const text = htmlToText.convert(html, {
|
||||
wordwrap: 80,
|
||||
});
|
||||
|
||||
if (text.trim()) {
|
||||
const chunks = splitText(text, loaderConfig);
|
||||
for (const chunk of chunks) {
|
||||
documents.push({
|
||||
metadata: {
|
||||
...chunk.metadata,
|
||||
source: tempPath,
|
||||
},
|
||||
pageContent: chunk.pageContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip chapters that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
} catch (e) {
|
||||
throw new Error(`EPubLoader error: ${(e as Error).message}`, { cause: e });
|
||||
} finally {
|
||||
tempManager.cleanup();
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { type SupportedTextSplitterLanguage } from 'langchain/text_splitter';
|
||||
import { SupportedTextSplitterLanguages } from 'langchain/text_splitter';
|
||||
|
||||
import { LANGCHAIN_SUPPORT_TEXT_LIST } from '@/libs/langchain/file';
|
||||
import { type LangChainLoaderType } from '@/libs/langchain/types';
|
||||
|
||||
import { SUPPORT_TEXT_LIST } from '../file';
|
||||
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../splitter';
|
||||
import { type DocumentChunk, type FileLoaderType } from '../types';
|
||||
import { CodeLoader } from './code';
|
||||
import { CsVLoader } from './csv';
|
||||
import { DocxLoader } from './docx';
|
||||
@@ -14,15 +11,15 @@ import { PdfLoader } from './pdf';
|
||||
import { PPTXLoader } from './pptx';
|
||||
import { TextLoader } from './txt';
|
||||
|
||||
class LangChainError extends Error {
|
||||
class DocumentLoaderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LangChainChunkingError';
|
||||
this.name = 'DocumentLoaderError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ChunkingLoader {
|
||||
partitionContent = async (filename: string, content: Uint8Array) => {
|
||||
partitionContent = async (filename: string, content: Uint8Array): Promise<DocumentChunk[]> => {
|
||||
try {
|
||||
const fileBlob = new Blob([Buffer.from(content)]);
|
||||
const txt = this.uint8ArrayToString(content);
|
||||
@@ -74,11 +71,11 @@ export class ChunkingLoader {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new LangChainError((e as Error).message);
|
||||
throw new DocumentLoaderError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
private getType = (filename: string): LangChainLoaderType | undefined => {
|
||||
private getType = (filename: string): FileLoaderType | undefined => {
|
||||
if (filename.endsWith('pptx')) {
|
||||
return 'ppt';
|
||||
}
|
||||
@@ -109,11 +106,11 @@ export class ChunkingLoader {
|
||||
|
||||
const ext = filename.split('.').pop();
|
||||
|
||||
if (ext && SupportedTextSplitterLanguages.includes(ext as SupportedTextSplitterLanguage)) {
|
||||
if (ext && SUPPORTED_LANGUAGES.includes(ext as SupportedLanguage)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
if (ext && LANGCHAIN_SUPPORT_TEXT_LIST.includes(ext)) return 'text';
|
||||
if (ext && SUPPORT_TEXT_LIST.includes(ext)) return 'text';
|
||||
};
|
||||
|
||||
private uint8ArrayToString(uint8Array: Uint8Array) {
|
||||
+6
-2
@@ -7,11 +7,15 @@ import { expect } from 'vitest';
|
||||
import { LatexLoader } from '../index';
|
||||
|
||||
describe('LatexLoader', () => {
|
||||
it('should run', async () => {
|
||||
it('should split LaTeX content into chunks', async () => {
|
||||
const content = fs.readFileSync(join(__dirname, `./demo.tex`), 'utf8');
|
||||
|
||||
const data = await LatexLoader(content);
|
||||
|
||||
expect(data).toMatchSnapshot();
|
||||
expect(data.length).toBeGreaterThan(1);
|
||||
for (const chunk of data) {
|
||||
expect(chunk.pageContent).toBeTruthy();
|
||||
expect(chunk.metadata.loc.lines).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { splitLatex } from '../../splitter';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const LatexLoader = async (text: string) => {
|
||||
return splitLatex(text, loaderConfig);
|
||||
};
|
||||
+32
-8
@@ -5,12 +5,14 @@ import Callout from '@components/markdown/Callout.astro';
|
||||
import Section from '@components/markdown/Section.astro';
|
||||
|
||||
# Views (WIP)
|
||||
|
||||
<Callout emoji="⚠️" type="warning">
|
||||
Views are currently only implemented in the `drizzle-orm`, `drizzle-kit` does not support views yet.
|
||||
You can query the views that already exist in the database, but they won't be added to `drizzle-kit` migrations or `db push` as of now.
|
||||
</Callout>
|
||||
|
||||
## Views declaration
|
||||
|
||||
There're several ways you can declare views with Drizzle ORM.
|
||||
|
||||
You can declare views that have to be created or you can declare views that already exist in the database.
|
||||
@@ -21,6 +23,7 @@ When views are created with either inlined or standalone query builders, view co
|
||||
yet when you use `sql` you have to explicitly declare view columns schema.
|
||||
|
||||
### Declaring views
|
||||
|
||||
<Tabs items={['PostgreSQL', 'MySQL', 'SQLite']}>
|
||||
<Tab>
|
||||
<Section>
|
||||
@@ -40,12 +43,14 @@ yet when you use `sql` you have to explicitly declare view columns schema.
|
||||
export const userView = pgView("user_view").as((qb) => qb.select().from(user));
|
||||
export const customersView = pgView("customers_view").as((qb) => qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
```
|
||||
</Section>
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
<Section>
|
||||
```ts filename="schema.ts" copy {13-14}
|
||||
@@ -64,12 +69,14 @@ yet when you use `sql` you have to explicitly declare view columns schema.
|
||||
export const userView = mysqlView("user_view").as((qb) => qb.select().from(user));
|
||||
export const customersView = mysqlView("customers_view").as((qb) => qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
```
|
||||
</Section>
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
<Section>
|
||||
```ts filename="schema.ts" copy {13-14}
|
||||
@@ -88,6 +95,7 @@ yet when you use `sql` you have to explicitly declare view columns schema.
|
||||
export const userView = sqliteView("user_view").as((qb) => qb.select().from(user));
|
||||
export const customersView = sqliteView("customers_view").as((qb) => qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
@@ -97,6 +105,7 @@ yet when you use `sql` you have to explicitly declare view columns schema.
|
||||
</Tabs>
|
||||
|
||||
If you need a subset of columns you can use `.select({ ... })` method in query builder, like this:
|
||||
|
||||
<Section>
|
||||
```ts {4-6}
|
||||
export const customersView = pgView("customers_view").as((qb) => {
|
||||
@@ -109,12 +118,14 @@ If you need a subset of columns you can use `.select({ ... })` method in query b
|
||||
.from(user);
|
||||
});
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "customers_view" AS SELECT "id", "name", "email" FROM "user" WHERE "role" = 'customer';
|
||||
```
|
||||
</Section>
|
||||
|
||||
You can also declare views using `standalone query builder`, it works exactly the same way:
|
||||
|
||||
<Tabs items={['PostgreSQL', 'MySQL', 'SQLite']}>
|
||||
<Tab>
|
||||
<Section>
|
||||
@@ -136,12 +147,14 @@ You can also declare views using `standalone query builder`, it works exactly th
|
||||
export const userView = pgView("user_view").as(qb.select().from(user));
|
||||
export const customersView = pgView("customers_view").as(qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
```
|
||||
</Section>
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
<Section>
|
||||
```ts filename="schema.ts" copy {3, 15-16}
|
||||
@@ -162,12 +175,14 @@ You can also declare views using `standalone query builder`, it works exactly th
|
||||
export const userView = mysqlView("user_view").as(qb.select().from(user));
|
||||
export const customersView = mysqlView("customers_view").as(qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
```
|
||||
</Section>
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
<Section>
|
||||
```ts filename="schema.ts" copy {3, 15-16}
|
||||
@@ -188,6 +203,7 @@ You can also declare views using `standalone query builder`, it works exactly th
|
||||
export const userView = sqliteView("user_view").as((qb) => qb.select().from(user));
|
||||
export const customerView = sqliteView("customers_view").as((qb) => qb.select().from(user).where(eq(user.role, "customer")));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE VIEW "user_view" AS SELECT * FROM "user";
|
||||
CREATE VIEW "customers_view" AS SELECT * FROM "user" WHERE "role" = 'customer';
|
||||
@@ -197,6 +213,7 @@ You can also declare views using `standalone query builder`, it works exactly th
|
||||
</Tabs>
|
||||
|
||||
### Declaring views with raw SQL
|
||||
|
||||
Whenever you need to declare view using a syntax that is not supported by the query builder,
|
||||
you can directly use `sql` operator and explicitly specify view columns schema.
|
||||
|
||||
@@ -217,8 +234,10 @@ const newYorkers = pgMaterializedView('new_yorkers', {
|
||||
```
|
||||
|
||||
### Declaring existing views
|
||||
|
||||
When you're provided with a read only access to an existing view in the database you should use `.existing()` view configuration,
|
||||
`drizzle-kit` will ignore and will not generate a `create view` statement in the generated migration.
|
||||
|
||||
```ts
|
||||
export const user = pgTable("user", {
|
||||
id: serial("id"),
|
||||
@@ -246,27 +265,31 @@ export const trimmedUser = pgMaterializedView("trimmed_user", {
|
||||
```
|
||||
|
||||
### Materialized views
|
||||
|
||||
<IsSupportedChipGroup chips={{ 'MySQL': false, 'PostgreSQL': true, 'SQLite': false }} />
|
||||
|
||||
According to the official docs, PostgreSQL has both **[`regular`](https://www.postgresql.org/docs/current/sql-createview.html)**
|
||||
and **[`materialized`](https://www.postgresql.org/docs/current/sql-creatematerializedview.html)** views.
|
||||
|
||||
Materialized views in PostgreSQL use the rule system like views do, but persist the results in a table-like form.
|
||||
|
||||
{/* This means that when a query is executed against a materialized view, the results are returned directly from the materialized view,
|
||||
like from a table, rather than being reconstructed by executing the query against the underlying base tables that make up the view. */}
|
||||
like from a table, rather than being reconstructed by executing the query against the underlying base tables that make up the view. */}
|
||||
|
||||
Drizzle ORM natively supports PostgreSQL materialized views:
|
||||
|
||||
<Section>
|
||||
```ts filename="schema.ts" copy
|
||||
const newYorkers = pgMaterializedView('new_yorkers').as((qb) => qb.select().from(users).where(eq(users.cityId, 1)));
|
||||
```
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW "new_yorkers" AS SELECT * FROM "users";
|
||||
```
|
||||
```ts filename="schema.ts" copy
|
||||
const newYorkers = pgMaterializedView('new_yorkers').as((qb) => qb.select().from(users).where(eq(users.cityId, 1)));
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW "new_yorkers" AS SELECT * FROM "users";
|
||||
```
|
||||
</Section>
|
||||
|
||||
You can then refresh materialized views in the application runtime:
|
||||
|
||||
```ts copy
|
||||
await db.refreshMaterializedView(newYorkers);
|
||||
|
||||
@@ -276,8 +299,9 @@ await db.refreshMaterializedView(newYorkers).withNoData();
|
||||
```
|
||||
|
||||
### Extended example
|
||||
|
||||
<Callout emoji="ℹ️" type="info">
|
||||
All the parameters inside the query will be inlined, instead of replaced by `$1`, `$2`, etc.
|
||||
All the parameters inside the query will be inlined, instead of replaced by `$1`, `$2`, etc.
|
||||
</Callout>
|
||||
|
||||
```ts copy
|
||||
+7
-1
@@ -8,6 +8,12 @@ describe('MarkdownLoader', () => {
|
||||
it('should run', async () => {
|
||||
const content = fs.readFileSync(join(__dirname, `./demo.mdx`), 'utf8');
|
||||
|
||||
await MarkdownLoader(content);
|
||||
const result = await MarkdownLoader(content);
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
for (const chunk of result) {
|
||||
expect(chunk.pageContent).toBeTruthy();
|
||||
expect(chunk.metadata.loc.lines).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { splitMarkdown } from '../../splitter';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const MarkdownLoader = async (text: string) => {
|
||||
return splitMarkdown(text, loaderConfig);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type DocumentChunk } from '../../types';
|
||||
|
||||
export const PdfLoader = async (fileBlob: Blob): Promise<DocumentChunk[]> => {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
|
||||
const buffer = Buffer.from(await fileBlob.arrayBuffer());
|
||||
const data = await pdfParse(buffer);
|
||||
|
||||
// Split by pages using form feed character, or treat as single page
|
||||
const pages: string[] = data.text
|
||||
? data.text.split(/\f/).filter((page: string) => page.trim().length > 0)
|
||||
: [];
|
||||
|
||||
return pages.map((pageContent: string, index: number) => ({
|
||||
metadata: {
|
||||
loc: { pageNumber: index + 1 },
|
||||
},
|
||||
pageContent: pageContent.trim(),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type DocumentChunk } from '../../types';
|
||||
|
||||
export const PPTXLoader = async (fileBlob: Blob | string): Promise<DocumentChunk[]> => {
|
||||
const { parseOfficeAsync } = await import('officeparser');
|
||||
|
||||
const buffer =
|
||||
typeof fileBlob === 'string'
|
||||
? Buffer.from(fileBlob)
|
||||
: Buffer.from(await fileBlob.arrayBuffer());
|
||||
|
||||
const text = await parseOfficeAsync(buffer);
|
||||
|
||||
return [
|
||||
{
|
||||
metadata: {},
|
||||
pageContent: text,
|
||||
},
|
||||
];
|
||||
};
|
||||
+13
-9
@@ -3,7 +3,6 @@ import * as fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { TextLoader } from '../index';
|
||||
import longResult from './long.json';
|
||||
|
||||
describe('TextLoader', () => {
|
||||
it('split simple content', async () => {
|
||||
@@ -35,13 +34,11 @@ describe('TextLoader', () => {
|
||||
|
||||
const result = await TextLoader(content);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
pageContent:
|
||||
'好的,我们以基于 Puppeteer 的截图服务为例,给出一个具体的示例:\n\n| 服务器配置 | 并发量 |\n| --- | --- |\n| 1c1g | 50-100 |\n| 2c4g | 200-500 |\n| 4c8g | 500-1000 |\n| 8c16g | 1000-2000 |\n\n这里的并发量是根据以下假设条件估算的:\n\n1. 应用程序使用 Puppeteer 进行网页截图,每个请求需要 500ms-1s 的处理时间。\n2. CPU 密集型任务,CPU 是主要的性能瓶颈。\n3. 每个请求需要 50-100MB 的内存。\n4. 没有其他依赖服务,如数据库等。\n5. 网络带宽足够,不是瓶颈。\n\n在这种情况下:\n\n- 1c1g 的服务器,由于 CPU 资源较少,并发量较低,大约在 50-100 左右。\n- 2c4g 的服务器,CPU 资源增加,并发量可以提高到 200-500 左右。\n- 4c8g 的服务器,CPU 资源进一步增加,并发量可以提高到 500-1000 左右。\n- 8c16g 的服务器,CPU 资源进一步增加,并发量可以提高到 1000-2000 左右。\n\n需要注意的是,这只是一个大致的估计,实际情况可能会有差异。在正式部署时,建议进行负载测试,根据实际情况进行调整和优化。',
|
||||
metadata: { loc: { lines: { from: 1, to: 25 } } },
|
||||
},
|
||||
]);
|
||||
// Should produce a single chunk for short content
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].pageContent).toBe(content);
|
||||
expect(result[0].metadata.loc.lines.from).toBe(1);
|
||||
expect(result[0].metadata.loc.lines.to).toBe(25);
|
||||
});
|
||||
|
||||
it('split long', async () => {
|
||||
@@ -49,6 +46,13 @@ describe('TextLoader', () => {
|
||||
|
||||
const result = await TextLoader(content);
|
||||
|
||||
expect(result).toEqual(longResult);
|
||||
// Should split long content into multiple chunks
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
// Each chunk should have pageContent and metadata
|
||||
for (const chunk of result) {
|
||||
expect(chunk.pageContent).toBeTruthy();
|
||||
expect(chunk.metadata.loc.lines.from).toBeGreaterThanOrEqual(1);
|
||||
expect(chunk.metadata.loc.lines.to).toBeGreaterThanOrEqual(chunk.metadata.loc.lines.from);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { splitText } from '../../splitter';
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const TextLoader = async (text: string) => {
|
||||
return splitText(text, loaderConfig);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import { type DocumentChunk } from '../types';
|
||||
import {
|
||||
DEFAULT_SEPARATORS,
|
||||
getSeparatorsForLanguage,
|
||||
LATEX_SEPARATORS,
|
||||
MARKDOWN_SEPARATORS,
|
||||
type SupportedLanguage,
|
||||
} from './separators';
|
||||
|
||||
export { SUPPORTED_LANGUAGES, type SupportedLanguage } from './separators';
|
||||
|
||||
interface SplitterConfig {
|
||||
chunkOverlap: number;
|
||||
chunkSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into overlapping chunks using a recursive separator strategy.
|
||||
* Replicates LangChain's RecursiveCharacterTextSplitter algorithm.
|
||||
*/
|
||||
function splitTextWithSeparators(
|
||||
text: string,
|
||||
separators: string[],
|
||||
config: SplitterConfig,
|
||||
): string[] {
|
||||
const { chunkSize, chunkOverlap } = config;
|
||||
|
||||
// Find the appropriate separator
|
||||
let separator = separators.at(-1)!;
|
||||
let newSeparators: string[] | undefined;
|
||||
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
const sep = separators[i];
|
||||
if (sep === '') {
|
||||
separator = '';
|
||||
break;
|
||||
}
|
||||
if (text.includes(sep)) {
|
||||
separator = sep;
|
||||
newSeparators = separators.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split the text by the chosen separator
|
||||
const splits = separator ? text.split(separator) : [...text];
|
||||
|
||||
// Merge splits into chunks respecting chunkSize
|
||||
const goodSplits: string[] = [];
|
||||
const finalChunks: string[] = [];
|
||||
|
||||
for (const s of splits) {
|
||||
if (s.length < chunkSize) {
|
||||
goodSplits.push(s);
|
||||
} else {
|
||||
if (goodSplits.length > 0) {
|
||||
const merged = mergeSplits(goodSplits, separator, config);
|
||||
finalChunks.push(...merged);
|
||||
goodSplits.length = 0;
|
||||
}
|
||||
// If this piece is still too large and we have more separators, recurse
|
||||
if (newSeparators && newSeparators.length > 0) {
|
||||
const subChunks = splitTextWithSeparators(s, newSeparators, config);
|
||||
finalChunks.push(...subChunks);
|
||||
} else {
|
||||
finalChunks.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (goodSplits.length > 0) {
|
||||
const merged = mergeSplits(goodSplits, separator, config);
|
||||
finalChunks.push(...merged);
|
||||
}
|
||||
|
||||
return finalChunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge small splits into chunks respecting chunkSize and chunkOverlap.
|
||||
*/
|
||||
function mergeSplits(splits: string[], separator: string, config: SplitterConfig): string[] {
|
||||
const { chunkSize, chunkOverlap } = config;
|
||||
const chunks: string[] = [];
|
||||
const currentChunk: string[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const s of splits) {
|
||||
const len = s.length;
|
||||
const sepLen = currentChunk.length > 0 ? separator.length : 0;
|
||||
|
||||
if (total + len + sepLen > chunkSize && currentChunk.length > 0) {
|
||||
const chunk = currentChunk.join(separator);
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Keep overlap: drop from the start of currentChunk until we fit in overlap
|
||||
while (total > chunkOverlap || (total + len + separator.length > chunkSize && total > 0)) {
|
||||
if (currentChunk.length === 0) break;
|
||||
const removed = currentChunk.shift()!;
|
||||
total -= removed.length + (currentChunk.length > 0 ? separator.length : 0);
|
||||
}
|
||||
}
|
||||
|
||||
currentChunk.push(s);
|
||||
total += len + (currentChunk.length > 1 ? separator.length : 0);
|
||||
}
|
||||
|
||||
const lastChunk = currentChunk.join(separator);
|
||||
if (lastChunk.length > 0) {
|
||||
chunks.push(lastChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate line location metadata for a chunk within the original text.
|
||||
*/
|
||||
function getLineLocation(fullText: string, chunk: string): { from: number; to: number } {
|
||||
const index = fullText.indexOf(chunk);
|
||||
if (index === -1) {
|
||||
return { from: 1, to: 1 };
|
||||
}
|
||||
|
||||
const beforeChunk = fullText.slice(0, index);
|
||||
const from = beforeChunk.split('\n').length;
|
||||
const chunkLines = chunk.split('\n').length;
|
||||
const to = from + chunkLines - 1;
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create document chunks from text using given separators.
|
||||
*/
|
||||
function createDocuments(
|
||||
text: string,
|
||||
separators: string[],
|
||||
config: SplitterConfig,
|
||||
baseMetadata?: Record<string, any>,
|
||||
): DocumentChunk[] {
|
||||
const chunks = splitTextWithSeparators(text, separators, config);
|
||||
|
||||
// Track search position to handle duplicate chunks correctly
|
||||
let searchFrom = 0;
|
||||
|
||||
return chunks.map((chunk) => {
|
||||
const index = text.indexOf(chunk, searchFrom);
|
||||
let loc = { from: 1, to: 1 };
|
||||
|
||||
if (index !== -1) {
|
||||
const beforeChunk = text.slice(0, index);
|
||||
const from = beforeChunk.split('\n').length;
|
||||
const chunkLines = chunk.split('\n').length;
|
||||
loc = { from, to: from + chunkLines - 1 };
|
||||
// Advance search position past this match (but allow overlap)
|
||||
searchFrom = index + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
loc: { lines: loc },
|
||||
},
|
||||
pageContent: chunk,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function splitText(text: string, config: SplitterConfig): DocumentChunk[] {
|
||||
return createDocuments(text, DEFAULT_SEPARATORS, config);
|
||||
}
|
||||
|
||||
export function splitMarkdown(text: string, config: SplitterConfig): DocumentChunk[] {
|
||||
return createDocuments(text, MARKDOWN_SEPARATORS, config);
|
||||
}
|
||||
|
||||
export function splitLatex(text: string, config: SplitterConfig): DocumentChunk[] {
|
||||
return createDocuments(text, LATEX_SEPARATORS, config);
|
||||
}
|
||||
|
||||
export function splitCode(
|
||||
text: string,
|
||||
language: SupportedLanguage,
|
||||
config: SplitterConfig,
|
||||
): DocumentChunk[] {
|
||||
const separators = getSeparatorsForLanguage(language);
|
||||
return createDocuments(text, separators, config);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Language-specific separators for recursive text splitting.
|
||||
* Each array is ordered from most to least specific separator.
|
||||
*/
|
||||
|
||||
export type SupportedLanguage =
|
||||
| 'cpp'
|
||||
| 'go'
|
||||
| 'java'
|
||||
| 'js'
|
||||
| 'php'
|
||||
| 'proto'
|
||||
| 'python'
|
||||
| 'rst'
|
||||
| 'ruby'
|
||||
| 'rust'
|
||||
| 'scala'
|
||||
| 'swift'
|
||||
| 'markdown'
|
||||
| 'latex'
|
||||
| 'html'
|
||||
| 'sol';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
|
||||
'cpp',
|
||||
'go',
|
||||
'java',
|
||||
'js',
|
||||
'php',
|
||||
'proto',
|
||||
'python',
|
||||
'rst',
|
||||
'ruby',
|
||||
'rust',
|
||||
'scala',
|
||||
'swift',
|
||||
'markdown',
|
||||
'latex',
|
||||
'html',
|
||||
'sol',
|
||||
];
|
||||
|
||||
export const DEFAULT_SEPARATORS = ['\n\n', '\n', ' ', ''];
|
||||
|
||||
export const MARKDOWN_SEPARATORS = [
|
||||
'\n## ',
|
||||
'\n### ',
|
||||
'\n#### ',
|
||||
'\n##### ',
|
||||
'\n###### ',
|
||||
'```\n\n',
|
||||
'\n\n***\n\n',
|
||||
'\n\n---\n\n',
|
||||
'\n\n___\n\n',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
];
|
||||
|
||||
export const LATEX_SEPARATORS = [
|
||||
'\n\\chapter{',
|
||||
'\n\\section{',
|
||||
'\n\\subsection{',
|
||||
'\n\\subsubsection{',
|
||||
'\n\\begin{enumerate}',
|
||||
'\n\\begin{itemize}',
|
||||
'\n\\begin{description}',
|
||||
'\n\\begin{list}',
|
||||
'\n\\begin{quote}',
|
||||
'\n\\begin{quotation}',
|
||||
'\n\\begin{verse}',
|
||||
'\n\\begin{verbatim}',
|
||||
'\n\\begin{align}',
|
||||
'$$',
|
||||
'$',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
];
|
||||
|
||||
const LANGUAGE_SEPARATORS: Record<SupportedLanguage, string[]> = {
|
||||
cpp: [
|
||||
'\nclass ',
|
||||
'\nvoid ',
|
||||
'\nint ',
|
||||
'\nfloat ',
|
||||
'\ndouble ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
go: [
|
||||
'\nfunc ',
|
||||
'\nvar ',
|
||||
'\nconst ',
|
||||
'\ntype ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
html: [
|
||||
'<body>',
|
||||
'<div>',
|
||||
'<p>',
|
||||
'<br>',
|
||||
'<li>',
|
||||
'<h1>',
|
||||
'<h2>',
|
||||
'<h3>',
|
||||
'<h4>',
|
||||
'<h5>',
|
||||
'<h6>',
|
||||
'<span>',
|
||||
'<table>',
|
||||
'<tr>',
|
||||
'<td>',
|
||||
'<th>',
|
||||
'<ul>',
|
||||
'<ol>',
|
||||
'<header>',
|
||||
'<footer>',
|
||||
'<nav>',
|
||||
'<head>',
|
||||
'<style>',
|
||||
'<script>',
|
||||
'<meta>',
|
||||
'<title>',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
java: [
|
||||
'\nclass ',
|
||||
'\npublic ',
|
||||
'\nprotected ',
|
||||
'\nprivate ',
|
||||
'\nstatic ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
js: [
|
||||
'\nfunction ',
|
||||
'\nconst ',
|
||||
'\nlet ',
|
||||
'\nvar ',
|
||||
'\nclass ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\ndefault ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
latex: LATEX_SEPARATORS,
|
||||
markdown: MARKDOWN_SEPARATORS,
|
||||
php: [
|
||||
'\nfunction ',
|
||||
'\nclass ',
|
||||
'\nif ',
|
||||
'\nforeach ',
|
||||
'\nwhile ',
|
||||
'\ndo ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
proto: [
|
||||
'\nmessage ',
|
||||
'\nservice ',
|
||||
'\nenum ',
|
||||
'\noption ',
|
||||
'\nimport ',
|
||||
'\nsyntax ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
python: ['\nclass ', '\ndef ', '\n\tdef ', '\n\n', '\n', ' ', ''],
|
||||
rst: ['\n===\n', '\n---\n', '\n***\n', '\n.. ', '\n\n', '\n', ' ', ''],
|
||||
ruby: [
|
||||
'\ndef ',
|
||||
'\nclass ',
|
||||
'\nif ',
|
||||
'\nunless ',
|
||||
'\nwhile ',
|
||||
'\nfor ',
|
||||
'\ndo ',
|
||||
'\nbegin ',
|
||||
'\nrescue ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
rust: [
|
||||
'\nfn ',
|
||||
'\nconst ',
|
||||
'\nlet ',
|
||||
'\nif ',
|
||||
'\nwhile ',
|
||||
'\nfor ',
|
||||
'\nloop ',
|
||||
'\nmatch ',
|
||||
'\nconst ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
scala: [
|
||||
'\nclass ',
|
||||
'\nobject ',
|
||||
'\ndef ',
|
||||
'\nval ',
|
||||
'\nvar ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\nmatch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
sol: [
|
||||
'\npragma ',
|
||||
'\nusing ',
|
||||
'\ncontract ',
|
||||
'\ninterface ',
|
||||
'\nlibrary ',
|
||||
'\nconstructor ',
|
||||
'\ntype ',
|
||||
'\nfunction ',
|
||||
'\nevent ',
|
||||
'\nmodifier ',
|
||||
'\nerror ',
|
||||
'\nstruct ',
|
||||
'\nenum ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\ndo while ',
|
||||
'\nassembly ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
swift: [
|
||||
'\nfunc ',
|
||||
'\nclass ',
|
||||
'\nstruct ',
|
||||
'\nenum ',
|
||||
'\nif ',
|
||||
'\nfor ',
|
||||
'\nwhile ',
|
||||
'\ndo ',
|
||||
'\nswitch ',
|
||||
'\ncase ',
|
||||
'\n\n',
|
||||
'\n',
|
||||
' ',
|
||||
'',
|
||||
],
|
||||
};
|
||||
|
||||
export function getSeparatorsForLanguage(language: SupportedLanguage): string[] {
|
||||
return LANGUAGE_SEPARATORS[language];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface DocumentChunk {
|
||||
id?: string;
|
||||
metadata: Record<string, any>;
|
||||
pageContent: string;
|
||||
}
|
||||
|
||||
export type FileLoaderType =
|
||||
| 'code'
|
||||
| 'ppt'
|
||||
| 'pdf'
|
||||
| 'markdown'
|
||||
| 'doc'
|
||||
| 'text'
|
||||
| 'latex'
|
||||
| 'csv'
|
||||
| 'epub';
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type SupportedTextSplitterLanguage } from 'langchain/text_splitter';
|
||||
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { loaderConfig } from '@/libs/langchain/loaders/config';
|
||||
|
||||
export const CodeLoader = async (text: string, language: string) => {
|
||||
const splitter = RecursiveCharacterTextSplitter.fromLanguage(
|
||||
language as SupportedTextSplitterLanguage,
|
||||
loaderConfig,
|
||||
);
|
||||
|
||||
return await splitter.createDocuments([text]);
|
||||
};
|
||||
@@ -1,422 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CSVLoader > should run 1`] = `
|
||||
[
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 1,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 1
|
||||
Hair: Black
|
||||
Eye: Brown
|
||||
Sex: Male
|
||||
Freq: 32",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 2,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 2
|
||||
Hair: Brown
|
||||
Eye: Brown
|
||||
Sex: Male
|
||||
Freq: 53",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 3,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 3
|
||||
Hair: Red
|
||||
Eye: Brown
|
||||
Sex: Male
|
||||
Freq: 10",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 4,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 4
|
||||
Hair: Blond
|
||||
Eye: Brown
|
||||
Sex: Male
|
||||
Freq: 3",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 5,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 5
|
||||
Hair: Black
|
||||
Eye: Blue
|
||||
Sex: Male
|
||||
Freq: 11",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 6,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 6
|
||||
Hair: Brown
|
||||
Eye: Blue
|
||||
Sex: Male
|
||||
Freq: 50",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 7,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 7
|
||||
Hair: Red
|
||||
Eye: Blue
|
||||
Sex: Male
|
||||
Freq: 10",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 8,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 8
|
||||
Hair: Blond
|
||||
Eye: Blue
|
||||
Sex: Male
|
||||
Freq: 30",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 9,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 9
|
||||
Hair: Black
|
||||
Eye: Hazel
|
||||
Sex: Male
|
||||
Freq: 10",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 10,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 10
|
||||
Hair: Brown
|
||||
Eye: Hazel
|
||||
Sex: Male
|
||||
Freq: 25",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 11,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 11
|
||||
Hair: Red
|
||||
Eye: Hazel
|
||||
Sex: Male
|
||||
Freq: 7",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 12,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 12
|
||||
Hair: Blond
|
||||
Eye: Hazel
|
||||
Sex: Male
|
||||
Freq: 5",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 13,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 13
|
||||
Hair: Black
|
||||
Eye: Green
|
||||
Sex: Male
|
||||
Freq: 3",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 14,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 14
|
||||
Hair: Brown
|
||||
Eye: Green
|
||||
Sex: Male
|
||||
Freq: 15",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 15,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 15
|
||||
Hair: Red
|
||||
Eye: Green
|
||||
Sex: Male
|
||||
Freq: 7",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 16,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 16
|
||||
Hair: Blond
|
||||
Eye: Green
|
||||
Sex: Male
|
||||
Freq: 8",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 17,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 17
|
||||
Hair: Black
|
||||
Eye: Brown
|
||||
Sex: Female
|
||||
Freq: 36",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 18,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 18
|
||||
Hair: Brown
|
||||
Eye: Brown
|
||||
Sex: Female
|
||||
Freq: 66",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 19,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 19
|
||||
Hair: Red
|
||||
Eye: Brown
|
||||
Sex: Female
|
||||
Freq: 16",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 20,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 20
|
||||
Hair: Blond
|
||||
Eye: Brown
|
||||
Sex: Female
|
||||
Freq: 4",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 21,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 21
|
||||
Hair: Black
|
||||
Eye: Blue
|
||||
Sex: Female
|
||||
Freq: 9",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 22,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 22
|
||||
Hair: Brown
|
||||
Eye: Blue
|
||||
Sex: Female
|
||||
Freq: 34",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 23,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 23
|
||||
Hair: Red
|
||||
Eye: Blue
|
||||
Sex: Female
|
||||
Freq: 7",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 24,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 24
|
||||
Hair: Blond
|
||||
Eye: Blue
|
||||
Sex: Female
|
||||
Freq: 64",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 25,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 25
|
||||
Hair: Black
|
||||
Eye: Hazel
|
||||
Sex: Female
|
||||
Freq: 5",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 26,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 26
|
||||
Hair: Brown
|
||||
Eye: Hazel
|
||||
Sex: Female
|
||||
Freq: 29",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 27,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 27
|
||||
Hair: Red
|
||||
Eye: Hazel
|
||||
Sex: Female
|
||||
Freq: 7",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 28,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 28
|
||||
Hair: Blond
|
||||
Eye: Hazel
|
||||
Sex: Female
|
||||
Freq: 5",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 29,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 29
|
||||
Hair: Black
|
||||
Eye: Green
|
||||
Sex: Female
|
||||
Freq: 2",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 30,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 30
|
||||
Hair: Brown
|
||||
Eye: Green
|
||||
Sex: Female
|
||||
Freq: 14",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 31,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 31
|
||||
Hair: Red
|
||||
Eye: Green
|
||||
Sex: Female
|
||||
Freq: 7",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"blobType": "",
|
||||
"line": 32,
|
||||
"source": "blob",
|
||||
},
|
||||
"pageContent": ": 32
|
||||
Hair: Blond
|
||||
Eye: Green
|
||||
Sex: Female
|
||||
Freq: 8",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { CSVLoader } from '@langchain/community/document_loaders/fs/csv';
|
||||
|
||||
export const CsVLoader = async (fileBlob: Blob) => {
|
||||
const loader = new CSVLoader(fileBlob);
|
||||
|
||||
return await loader.load();
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DocxLoader as Loader } from '@langchain/community/document_loaders/fs/docx';
|
||||
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const DocxLoader = async (fileBlob: Blob | string) => {
|
||||
const loader = new Loader(fileBlob);
|
||||
|
||||
const splitter = new RecursiveCharacterTextSplitter(loaderConfig);
|
||||
const documents = await loader.load();
|
||||
|
||||
return await splitter.splitDocuments(documents);
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`EPubLoader > should run 1`] = `
|
||||
[
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 1,
|
||||
"to": 13,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "HEFTY WATER
|
||||
|
||||
This document serves to test Reading System support for the epub:switch
|
||||
[http://idpf.org/epub/30/spec/epub30-contentdocs.html#sec-xhtml-content-switch]
|
||||
element. There is also a little bit of ruby markup
|
||||
[http://www.w3.org/TR/html5/the-ruby-element.html#the-ruby-element] available.
|
||||
|
||||
|
||||
THE SWITCH
|
||||
|
||||
Below is an instance of the epub:switch element, containing Chemical Markup
|
||||
Language [http://en.wikipedia.org/wiki/Chemical_Markup_Language] (CML). The
|
||||
fallback content is a chunk of plain XHTML5.",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 9,
|
||||
"to": 22,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "THE SWITCH
|
||||
|
||||
Below is an instance of the epub:switch element, containing Chemical Markup
|
||||
Language [http://en.wikipedia.org/wiki/Chemical_Markup_Language] (CML). The
|
||||
fallback content is a chunk of plain XHTML5.
|
||||
|
||||
* If your Reading System supports epub:switch and CML, it will render the CML
|
||||
formula natively, and ignore (a.k.a not display) the XHTML fallback.
|
||||
* If your Reading System supports epub:switch but not CML, it will ignore (not
|
||||
display) the CML formula, and render the the XHTML fallback instead.
|
||||
* If your Reading System does not support epub:switch at all, then the
|
||||
rendering results are somewhat unpredictable, but the most likely result is
|
||||
that it will display both a failed attempt to render the CML and the XHTML
|
||||
fallback.",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 24,
|
||||
"to": 43,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "Note: the XHTML fallback is bold and enclosed in a gray dotted box with a
|
||||
slightly gray background. A failed CML rendering will most likely appear above
|
||||
the gray fallback box and read:
|
||||
"H hydrogen O oxygen hefty H O water".
|
||||
|
||||
Here the switch begins...
|
||||
|
||||
|
||||
H hydrogen O oxygen hefty H O water
|
||||
|
||||
2H2 + O2 ⟶ 2H2O
|
||||
|
||||
... and here the switch ends.
|
||||
|
||||
|
||||
THE SOURCE
|
||||
|
||||
Below is a rendition of the source code of the switch element. Your Reading
|
||||
System should display this correctly regardless of whether it supports the
|
||||
switch element.",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 46,
|
||||
"to": 66,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "<switch xmlns="http://www.idpf.org/2007/ops">
|
||||
<case required-namespace="http://www.xml-cml.org/schema">
|
||||
<chem xmlns="http://www.xml-cml.org/schema">
|
||||
<reaction>
|
||||
<molecule n="2">
|
||||
<atom n="2"> H </atom>
|
||||
<caption> hydrogen </caption>
|
||||
</molecule>
|
||||
<plus></plus>
|
||||
<molecule>
|
||||
<atom n="2"> O </atom>
|
||||
<caption> oxygen </caption>
|
||||
</molecule>
|
||||
<gives>
|
||||
<caption> hefty </caption>
|
||||
</gives>
|
||||
<molecule n="2">
|
||||
<atom n="2"> H </atom>
|
||||
<atom> O </atom>
|
||||
<caption> water </caption>
|
||||
</molecule>",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 57,
|
||||
"to": 79,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "<caption> oxygen </caption>
|
||||
</molecule>
|
||||
<gives>
|
||||
<caption> hefty </caption>
|
||||
</gives>
|
||||
<molecule n="2">
|
||||
<atom n="2"> H </atom>
|
||||
<atom> O </atom>
|
||||
<caption> water </caption>
|
||||
</molecule>
|
||||
</reaction>
|
||||
</chem>
|
||||
</case>
|
||||
<default>
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" id="fallback">
|
||||
<span>2H<sub>2</sub></span>
|
||||
<span>+</span>
|
||||
<span>O<sub>2</sub></span>
|
||||
<span>⟶</span>
|
||||
<span>2H<sub>2</sub>O</span>
|
||||
</p>
|
||||
</default>
|
||||
</switch>",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 84,
|
||||
"to": 94,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "HEFTY RUBY WATER
|
||||
|
||||
While the ruby element is mostly used in east-asian languages, it can also be
|
||||
useful in other contexts. As an example, and as you can see in the source of the
|
||||
CML element above, the code includes a caption element which is intended to be
|
||||
displayed below the formula segments. Following this paragraph is a reworked
|
||||
version of the XHTML fallback used above, using the ruby element. If your
|
||||
Reading System does not support ruby markup, then the captions will appear in
|
||||
parentheses on the same line as the formula segments.
|
||||
|
||||
2H2(hydrogen) + O2(oxygen) ⟶(hefty) 2H2O(water)",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 94,
|
||||
"to": 111,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "2H2(hydrogen) + O2(oxygen) ⟶(hefty) 2H2O(water)
|
||||
|
||||
If your Reading System in addition to supporting ruby markup also supports the
|
||||
-epub-ruby-position
|
||||
[http://idpf.org/epub/30/spec/epub30-contentdocs.html#sec-css-ruby-position]
|
||||
property, then the captions will appear under the formula segments instead of
|
||||
over them.
|
||||
|
||||
The source code for the ruby version of the XHTML fallback looks as follows:
|
||||
|
||||
|
||||
<p id="rubyp">
|
||||
<ruby>2H<sub>2</sub><rp>(</rp><rt>hydrogen</rt><rp>)</rp></ruby>
|
||||
<span>+</span>
|
||||
<ruby>O<sub>2</sub><rp>(</rp><rt>oxygen</rt><rp>)</rp></ruby>
|
||||
<ruby>⟶<rp>(</rp><rt>hefty</rt><rp>)</rp></ruby>
|
||||
<ruby>2H<sub>2</sub>O<rp>(</rp><rt>water</rt><rp>)</rp></ruby>
|
||||
</p>",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 105,
|
||||
"to": 120,
|
||||
},
|
||||
},
|
||||
"source": "",
|
||||
},
|
||||
"pageContent": "<p id="rubyp">
|
||||
<ruby>2H<sub>2</sub><rp>(</rp><rt>hydrogen</rt><rp>)</rp></ruby>
|
||||
<span>+</span>
|
||||
<ruby>O<sub>2</sub><rp>(</rp><rt>oxygen</rt><rp>)</rp></ruby>
|
||||
<ruby>⟶<rp>(</rp><rt>hefty</rt><rp>)</rp></ruby>
|
||||
<ruby>2H<sub>2</sub>O<rp>(</rp><rt>water</rt><rp>)</rp></ruby>
|
||||
</p>
|
||||
|
||||
|
||||
... and the css declaration using the -epub-ruby-position property looks like
|
||||
this:
|
||||
|
||||
|
||||
p#rubyp {
|
||||
-epub-ruby-position : under;
|
||||
}",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -1,24 +0,0 @@
|
||||
import { EPubLoader as Loader } from '@langchain/community/document_loaders/fs/epub';
|
||||
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { TempFileManager } from '@/server/utils/tempFileManager';
|
||||
import { nanoid } from '@/utils/uuid';
|
||||
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const EPubLoader = async (content: Uint8Array) => {
|
||||
const tempManager = new TempFileManager('epub-');
|
||||
|
||||
try {
|
||||
const tempPath = await tempManager.writeTempFile(content, `${nanoid()}.epub`);
|
||||
const loader = new Loader(tempPath);
|
||||
const documents = await loader.load();
|
||||
|
||||
const splitter = new RecursiveCharacterTextSplitter(loaderConfig);
|
||||
return await splitter.splitDocuments(documents);
|
||||
} catch (e) {
|
||||
throw new Error(`EPubLoader error: ${(e as Error).message}`, { cause: e });
|
||||
} finally {
|
||||
tempManager.cleanup(); // Ensure cleanup
|
||||
}
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LatexLoader > should run 1`] = `
|
||||
[
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 1,
|
||||
"to": 41,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pageContent": "\\documentclass{article}
|
||||
|
||||
|
||||
\\usepackage{graphicx} % Required for inserting images
|
||||
\\usepackage{amsmath} % Required for mathematical symbols
|
||||
\\usepackage{hyperref} % For hyperlinks
|
||||
|
||||
|
||||
\\title{Sample LaTeX Document}
|
||||
\\author{Generated by ChatGPT}
|
||||
\\date{\\today}
|
||||
|
||||
|
||||
\\begin{document}
|
||||
|
||||
|
||||
\\maketitle
|
||||
|
||||
|
||||
\\tableofcontents
|
||||
|
||||
|
||||
\\section{Introduction}
|
||||
This is a sample LaTeX document that includes various common elements such as sections, lists, tables, figures, and mathematical equations.
|
||||
|
||||
|
||||
\\section{Lists}
|
||||
\\subsection{Itemized List}
|
||||
\\begin{itemize}
|
||||
\\item First item
|
||||
\\item Second item
|
||||
\\item Third item
|
||||
\\end{itemize}
|
||||
|
||||
|
||||
\\subsection{Enumerated List}
|
||||
\\begin{enumerate}
|
||||
\\item First item
|
||||
\\item Second item
|
||||
\\item Third item
|
||||
\\end{enumerate}",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 27,
|
||||
"to": 61,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pageContent": "\\section{Lists}
|
||||
\\subsection{Itemized List}
|
||||
\\begin{itemize}
|
||||
\\item First item
|
||||
\\item Second item
|
||||
\\item Third item
|
||||
\\end{itemize}
|
||||
|
||||
|
||||
\\subsection{Enumerated List}
|
||||
\\begin{enumerate}
|
||||
\\item First item
|
||||
\\item Second item
|
||||
\\item Third item
|
||||
\\end{enumerate}
|
||||
|
||||
|
||||
\\section{Mathematical Equations}
|
||||
Here are some sample mathematical equations:
|
||||
|
||||
|
||||
\\subsection{Inline Equation}
|
||||
This is an inline equation: \\( E = mc^2 \\).
|
||||
|
||||
|
||||
\\subsection{Displayed Equations}
|
||||
\\begin{equation}
|
||||
a^2 + b^2 = c^2
|
||||
\\end{equation}
|
||||
|
||||
|
||||
\\begin{align}
|
||||
x &= y + z \\\\
|
||||
y &= mx + b
|
||||
\\end{align}",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 44,
|
||||
"to": 93,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pageContent": "\\section{Mathematical Equations}
|
||||
Here are some sample mathematical equations:
|
||||
|
||||
|
||||
\\subsection{Inline Equation}
|
||||
This is an inline equation: \\( E = mc^2 \\).
|
||||
|
||||
|
||||
\\subsection{Displayed Equations}
|
||||
\\begin{equation}
|
||||
a^2 + b^2 = c^2
|
||||
\\end{equation}
|
||||
|
||||
|
||||
\\begin{align}
|
||||
x &= y + z \\\\
|
||||
y &= mx + b
|
||||
\\end{align}
|
||||
|
||||
|
||||
\\section{Tables}
|
||||
Here is a sample table:
|
||||
|
||||
|
||||
\\begin{table}[h!]
|
||||
\\centering
|
||||
\\begin{tabular}{|c|c|c|}
|
||||
\\hline
|
||||
Header 1 & Header 2 & Header 3 \\\\
|
||||
\\hline
|
||||
Data 1 & Data 2 & Data 3 \\\\
|
||||
Data 4 & Data 5 & Data 6 \\\\
|
||||
Data 7 & Data 8 & Data 9 \\\\
|
||||
\\hline
|
||||
\\end{tabular}
|
||||
\\caption{Sample Table}
|
||||
\\label{table:1}
|
||||
\\end{table}
|
||||
|
||||
|
||||
\\section{Figures}
|
||||
Here is a sample figure:
|
||||
|
||||
|
||||
\\begin{figure}[h!]
|
||||
\\centering
|
||||
\\includegraphics[width=0.5\\textwidth]{example-image}
|
||||
\\caption{Sample Figure}
|
||||
\\label{fig:1}
|
||||
\\end{figure}",
|
||||
},
|
||||
Document {
|
||||
"id": undefined,
|
||||
"metadata": {
|
||||
"loc": {
|
||||
"lines": {
|
||||
"from": 84,
|
||||
"to": 112,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pageContent": "\\section{Figures}
|
||||
Here is a sample figure:
|
||||
|
||||
|
||||
\\begin{figure}[h!]
|
||||
\\centering
|
||||
\\includegraphics[width=0.5\\textwidth]{example-image}
|
||||
\\caption{Sample Figure}
|
||||
\\label{fig:1}
|
||||
\\end{figure}
|
||||
|
||||
|
||||
\\section{Sections and Subsections}
|
||||
This is an example of a section with subsections.
|
||||
|
||||
|
||||
\\subsection{Subsection 1}
|
||||
Content of subsection 1.
|
||||
|
||||
|
||||
\\subsection{Subsection 2}
|
||||
Content of subsection 2.
|
||||
|
||||
|
||||
\\section{References}
|
||||
Here is a reference to the table \\ref{table:1} and the figure \\ref{fig:1}.
|
||||
|
||||
|
||||
\\end{document}",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { LatexTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const LatexLoader = async (text: string) => {
|
||||
const splitter = new LatexTextSplitter(loaderConfig);
|
||||
|
||||
return await splitter.createDocuments([text]);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { MarkdownTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const MarkdownLoader = async (text: string) => {
|
||||
const splitter = new MarkdownTextSplitter(loaderConfig);
|
||||
|
||||
return await splitter.createDocuments([text]);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
|
||||
|
||||
export const PdfLoader = async (fileBlob: Blob) => {
|
||||
const loader = new PDFLoader(fileBlob, { splitPages: true });
|
||||
|
||||
return await loader.load();
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PPTXLoader as Loader } from '@langchain/community/document_loaders/fs/pptx';
|
||||
|
||||
export const PPTXLoader = async (fileBlob: Blob | string) => {
|
||||
const loader = new Loader(fileBlob);
|
||||
|
||||
return await loader.load();
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { loaderConfig } from '../config';
|
||||
|
||||
export const TextLoader = async (text: string) => {
|
||||
const splitter = new RecursiveCharacterTextSplitter(loaderConfig);
|
||||
|
||||
return await splitter.createDocuments([text]);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export type LangChainLoaderType =
|
||||
| 'code'
|
||||
| 'ppt'
|
||||
| 'pdf'
|
||||
| 'markdown'
|
||||
| 'doc'
|
||||
| 'text'
|
||||
| 'latex'
|
||||
| 'csv'
|
||||
| 'epub';
|
||||
@@ -104,12 +104,22 @@ export default {
|
||||
'The request returned empty. Please check if the API proxy address does not end with `/v1`.',
|
||||
'response.CreateMessageError':
|
||||
'Sorry, the message could not be sent successfully. Please copy the content and try sending it again. This message will not be retained after refreshing the page.',
|
||||
'exceededContext.compact': 'Compact Context',
|
||||
'exceededContext.desc':
|
||||
'The conversation has exceeded the context window limit. You can compact the context to compress history and continue chatting.',
|
||||
'exceededContext.title': 'Context Window Exceeded',
|
||||
|
||||
'unknownError.copyTraceId': 'Trace ID Copied',
|
||||
'unknownError.desc': 'An unexpected error occurred. You can retry or report on',
|
||||
'unknownError.retry': 'Retry',
|
||||
'unknownError.title': 'Oops, the request took a nap',
|
||||
|
||||
'response.ExceededContextWindow':
|
||||
'The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.',
|
||||
'response.ExceededContextWindowCloud':
|
||||
'The conversation is too long to process. Please edit your last message to reduce input or delete some messages and try again.',
|
||||
'response.QuotaLimitReachedCloud':
|
||||
'The model service is currently under heavy load. Please try again later.',
|
||||
'The model service is currently under heavy load. Please try again later or switch to another model.',
|
||||
'response.FreePlanLimit':
|
||||
'You are currently a free user and cannot use this feature. Please upgrade to a paid plan to continue using it.',
|
||||
'response.InsufficientBudgetForModel':
|
||||
|
||||
@@ -25,6 +25,7 @@ import metadata from './metadata';
|
||||
import migration from './migration';
|
||||
import modelProvider from './modelProvider';
|
||||
import models from './models';
|
||||
import notification from './notification';
|
||||
import oauth from './oauth';
|
||||
import onboarding from './onboarding';
|
||||
import plugin from './plugin';
|
||||
@@ -72,6 +73,7 @@ const resources = {
|
||||
migration,
|
||||
modelProvider,
|
||||
models,
|
||||
notification,
|
||||
oauth,
|
||||
onboarding,
|
||||
plugin,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
'image_generation_completed': 'Image "{{prompt}}" generated successfully',
|
||||
'image_generation_completed_title': 'Image Generated',
|
||||
'inbox.archiveAll': 'Archive all',
|
||||
'inbox.empty': 'No notifications yet',
|
||||
'inbox.emptyUnread': 'No unread notifications',
|
||||
'inbox.filterUnread': 'Show unread only',
|
||||
'inbox.markAllRead': 'Mark all as read',
|
||||
'inbox.title': 'Notifications',
|
||||
'video_generation_completed': 'Video "{{prompt}}" generated successfully',
|
||||
'video_generation_completed_title': 'Video Generated',
|
||||
} as const;
|
||||
@@ -450,6 +450,12 @@ export default {
|
||||
'memory.enabled.title': 'Enable Memory',
|
||||
'memory.title': 'Memory Settings',
|
||||
'message.success': 'Update successful',
|
||||
'notification.enabled': 'Enabled',
|
||||
'notification.email.desc': 'Receive email notifications when important events occur',
|
||||
'notification.email.title': 'Email Notifications',
|
||||
'notification.inbox.desc': 'Show notifications in the in-app inbox',
|
||||
'notification.inbox.title': 'Inbox Notifications',
|
||||
'notification.title': 'Notification Channels',
|
||||
'myAgents.actions.cancel': 'Cancel',
|
||||
'myAgents.actions.confirmDeprecate': 'Confirm Deprecate',
|
||||
'myAgents.actions.deprecate': 'Deprecate Permanently',
|
||||
@@ -927,6 +933,7 @@ When I am ___, I need ___
|
||||
'tab.manualFill': 'Manually Fill In',
|
||||
'tab.manualFill.desc': 'Configure a custom MCP skill manually',
|
||||
'tab.memory': 'Memory',
|
||||
'tab.notification': 'Notifications',
|
||||
'tab.profile': 'My Account',
|
||||
'tab.provider': 'Provider',
|
||||
'tab.proxy': 'Proxy',
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ export const LoadingState = memo<LoadingStateProps>(
|
||||
return (
|
||||
<Block
|
||||
align={'center'}
|
||||
className={styles.placeholderContainer}
|
||||
className={`${styles.placeholderContainer} ${styles.placeholderContainerLoading}`}
|
||||
justify={'center'}
|
||||
variant={'filled'}
|
||||
style={{
|
||||
@@ -30,7 +30,7 @@ export const LoadingState = memo<LoadingStateProps>(
|
||||
maxWidth: getThumbnailMaxWidth(generation, generationBatch),
|
||||
}}
|
||||
>
|
||||
<div className={styles.placeholderContainer} />
|
||||
<div className={`${styles.placeholderContainer} ${styles.placeholderContainerLoading}`} />
|
||||
<Center gap={8} style={{ zIndex: 2 }}>
|
||||
<NeuralNetworkLoading size={48} />
|
||||
<ElapsedTime generationId={generation.id} isActive={isGenerating} />
|
||||
|
||||
@@ -43,6 +43,12 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&:hover .generation-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
|
||||
placeholderContainerLoading: css`
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -54,10 +60,6 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
animation: ${shimmer} 2s linear infinite;
|
||||
}
|
||||
|
||||
&:hover .generation-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
|
||||
spinIcon: css`
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Badge } from 'antd';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { notificationService } from '@/services/notification';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
import InboxDrawer from './InboxDrawer';
|
||||
import { UNREAD_COUNT_KEY } from './InboxDrawer/constants';
|
||||
|
||||
const InboxButton = memo(() => {
|
||||
const { t } = useTranslation('notification');
|
||||
const [open, setOpen] = useState(false);
|
||||
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
|
||||
const { data: unreadCount = 0 } = useClientDataSWR<number>(
|
||||
enableBusinessFeatures ? UNREAD_COUNT_KEY : null,
|
||||
() => notificationService.getUnreadCount(),
|
||||
{ refreshInterval: 10_000 },
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
if (!enableBusinessFeatures) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge count={unreadCount} offset={[-4, 4]} size="small">
|
||||
<ActionIcon
|
||||
icon={BellIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.title')}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
</Badge>
|
||||
<InboxDrawer open={open} onClose={handleClose} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default InboxButton;
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { BellOffIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { notificationService } from '@/services/notification';
|
||||
|
||||
import { FETCH_KEY } from './constants';
|
||||
import NotificationItem from './NotificationItem';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface ContentProps {
|
||||
onArchive: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
open: boolean;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
const Content = memo<ContentProps>(({ open, unreadOnly, onMarkAsRead, onArchive }) => {
|
||||
const { t } = useTranslation('notification');
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
|
||||
const getKey = useCallback(
|
||||
(pageIndex: number, previousPageData: any[] | null) => {
|
||||
if (!open) return null;
|
||||
if (previousPageData && previousPageData.length < PAGE_SIZE) return null;
|
||||
|
||||
if (pageIndex === 0) return [FETCH_KEY, undefined, unreadOnly] as const;
|
||||
|
||||
const lastItem = previousPageData?.at(-1);
|
||||
return [FETCH_KEY, lastItem?.id, unreadOnly] as const;
|
||||
},
|
||||
[open, unreadOnly],
|
||||
);
|
||||
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
isValidating,
|
||||
setSize,
|
||||
} = useSWRInfinite(getKey, async ([, cursor, filterUnread]) => {
|
||||
return notificationService.list({
|
||||
cursor: cursor as string | undefined,
|
||||
limit: PAGE_SIZE,
|
||||
unreadOnly: filterUnread,
|
||||
});
|
||||
});
|
||||
|
||||
// Reset scroll position and pagination when filter changes
|
||||
useEffect(() => {
|
||||
setSize(1);
|
||||
virtuaRef.current?.scrollTo(0);
|
||||
}, [unreadOnly, setSize]);
|
||||
|
||||
const notifications = pages?.flat() ?? [];
|
||||
const hasMore = pages ? pages.at(-1)?.length === PAGE_SIZE : false;
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const ref = virtuaRef.current;
|
||||
if (!ref || !hasMore || isValidating) return;
|
||||
|
||||
const bottomVisibleIndex = ref.findItemIndex(ref.scrollOffset + ref.viewportSize);
|
||||
if (bottomVisibleIndex + 5 > notifications.length) {
|
||||
setSize((prev) => prev + 1);
|
||||
}
|
||||
}, [hasMore, isValidating, notifications.length, setSize]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
|
||||
<SkeletonList rows={5} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<Flexbox align="center" gap={12} justify="center" paddingBlock={48}>
|
||||
<Icon color={cssVar.colorTextQuaternary} icon={BellOffIcon} size={40} />
|
||||
<Text type="secondary">{t(unreadOnly ? 'inbox.emptyUnread' : 'inbox.empty')}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VList ref={virtuaRef} style={{ height: '100%' }} onScroll={handleScroll}>
|
||||
{notifications.map((item) => (
|
||||
<Flexbox key={item.id} padding="4px 8px">
|
||||
<NotificationItem
|
||||
actionUrl={item.actionUrl}
|
||||
content={item.content}
|
||||
createdAt={item.createdAt}
|
||||
id={item.id}
|
||||
isRead={item.isRead}
|
||||
title={item.title}
|
||||
type={item.type}
|
||||
onArchive={onArchive}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
</Flexbox>
|
||||
))}
|
||||
{isValidating && (
|
||||
<Flexbox padding="4px 8px">
|
||||
<SkeletonList rows={2} />
|
||||
</Flexbox>
|
||||
)}
|
||||
</VList>
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = 'InboxDrawerContent';
|
||||
|
||||
export default Content;
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Block, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArchiveIcon, BellIcon, ImageIcon, VideoIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ACTION_CLASS_NAME = 'notification-item-actions';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.${ACTION_CLASS_NAME} {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ${cssVar.motionEaseOut};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.${ACTION_CLASS_NAME} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
unreadDot: css`
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
background: ${cssVar.colorPrimary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, typeof BellIcon> = {
|
||||
image_generation_completed: ImageIcon,
|
||||
video_generation_completed: VideoIcon,
|
||||
};
|
||||
|
||||
interface NotificationItemProps {
|
||||
actionUrl?: string | null;
|
||||
content: string;
|
||||
createdAt: Date | string;
|
||||
id: string;
|
||||
isRead: boolean;
|
||||
onArchive: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const NotificationItem = memo<NotificationItemProps>(
|
||||
({ id, type, title, content, createdAt, isRead, actionUrl, onMarkAsRead, onArchive }) => {
|
||||
const navigate = useNavigate();
|
||||
const TypeIcon = TYPE_ICON_MAP[type] || BellIcon;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isRead) onMarkAsRead(id);
|
||||
if (actionUrl) navigate(actionUrl);
|
||||
}, [id, isRead, actionUrl, onMarkAsRead, navigate]);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onArchive(id);
|
||||
},
|
||||
[id, onArchive],
|
||||
);
|
||||
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
className={styles.container}
|
||||
gap={4}
|
||||
paddingBlock={8}
|
||||
paddingInline={12}
|
||||
variant="borderless"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Flexbox horizontal align="flex-start" gap={8}>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={TypeIcon}
|
||||
size={18}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Flexbox flex={1} gap={4} style={{ overflow: 'hidden' }}>
|
||||
<Flexbox horizontal align="center" gap={4} justify="space-between">
|
||||
<Flexbox horizontal align="center" flex={1} gap={6} style={{ overflow: 'hidden' }}>
|
||||
{!isRead && <span className={styles.unreadDot} />}
|
||||
<Text ellipsis style={{ fontWeight: isRead ? 400 : 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={2} style={{ flexShrink: 0 }}>
|
||||
<span className={ACTION_CLASS_NAME}>
|
||||
<ActionIcon
|
||||
icon={ArchiveIcon}
|
||||
size={{ blockSize: 24, size: 14 }}
|
||||
onClick={handleArchive}
|
||||
/>
|
||||
</span>
|
||||
<Text fontSize={12} style={{ flexShrink: 0 }} type="secondary">
|
||||
{dayjs(createdAt).fromNow()}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Text ellipsis={{ rows: 3 }} fontSize={12} type="secondary">
|
||||
{content}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default NotificationItem;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UNREAD_COUNT_KEY = 'inbox-unread-count';
|
||||
export const FETCH_KEY = 'inbox-notifications';
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { ArchiveIcon, CheckCheckIcon, ListFilterIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import SideBarDrawer from '@/features/NavPanel/SideBarDrawer';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { mutate } from '@/libs/swr';
|
||||
import { notificationService } from '@/services/notification';
|
||||
|
||||
import { FETCH_KEY, UNREAD_COUNT_KEY } from './constants';
|
||||
|
||||
const Content = dynamic(() => import('./Content'), {
|
||||
loading: () => (
|
||||
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
|
||||
<SkeletonList rows={3} />
|
||||
</Flexbox>
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface InboxDrawerProps {
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const InboxDrawer = memo<InboxDrawerProps>(({ open, onClose }) => {
|
||||
const { t } = useTranslation('notification');
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
|
||||
const refreshList = useCallback(() => {
|
||||
mutate((key: unknown) => Array.isArray(key) && key[0] === FETCH_KEY);
|
||||
mutate(UNREAD_COUNT_KEY);
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
await notificationService.markAsRead([id]);
|
||||
refreshList();
|
||||
},
|
||||
[refreshList],
|
||||
);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string) => {
|
||||
await notificationService.archive(id);
|
||||
refreshList();
|
||||
},
|
||||
[refreshList],
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await notificationService.markAllAsRead();
|
||||
refreshList();
|
||||
}, [refreshList]);
|
||||
|
||||
const handleArchiveAll = useCallback(async () => {
|
||||
await notificationService.archiveAll();
|
||||
refreshList();
|
||||
}, [refreshList]);
|
||||
|
||||
const handleToggleFilter = useCallback(() => {
|
||||
setUnreadOnly((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SideBarDrawer
|
||||
open={open}
|
||||
title={t('inbox.title')}
|
||||
action={
|
||||
<>
|
||||
<ActionIcon
|
||||
icon={ArchiveIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.archiveAll')}
|
||||
onClick={handleArchiveAll}
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={CheckCheckIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.markAllRead')}
|
||||
onClick={handleMarkAllAsRead}
|
||||
/>
|
||||
<ActionIcon
|
||||
active={unreadOnly}
|
||||
icon={ListFilterIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.filterUnread')}
|
||||
onClick={handleToggleFilter}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Content
|
||||
open={open}
|
||||
unreadOnly={unreadOnly}
|
||||
onArchive={handleArchive}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
/>
|
||||
</SideBarDrawer>
|
||||
);
|
||||
});
|
||||
|
||||
InboxDrawer.displayName = 'InboxDrawer';
|
||||
|
||||
export default InboxDrawer;
|
||||
@@ -5,13 +5,23 @@ import { memo } from 'react';
|
||||
import SideBarHeaderLayout from '@/features/NavPanel/SideBarHeaderLayout';
|
||||
|
||||
import AddButton from './components/AddButton';
|
||||
import InboxButton from './components/InboxButton';
|
||||
import Nav from './components/Nav';
|
||||
import User from './components/User';
|
||||
|
||||
const Header = memo(() => {
|
||||
return (
|
||||
<>
|
||||
<SideBarHeaderLayout left={<User />} right={<AddButton />} showBack={false} />
|
||||
<SideBarHeaderLayout
|
||||
left={<User />}
|
||||
showBack={false}
|
||||
right={
|
||||
<>
|
||||
<InboxButton />
|
||||
<AddButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Nav />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Billing from '@/business/client/BusinessSettingPages/Billing';
|
||||
import Credits from '@/business/client/BusinessSettingPages/Credits';
|
||||
import Notification from '@/business/client/BusinessSettingPages/Notification';
|
||||
import Plans from '@/business/client/BusinessSettingPages/Plans';
|
||||
import Referral from '@/business/client/BusinessSettingPages/Referral';
|
||||
import Usage from '@/business/client/BusinessSettingPages/Usage';
|
||||
@@ -28,6 +29,7 @@ export const componentMap = {
|
||||
[SettingsTabs.Provider]: Provider,
|
||||
[SettingsTabs.ServiceModel]: ServiceModel,
|
||||
[SettingsTabs.Memory]: Memory,
|
||||
[SettingsTabs.Notification]: Notification,
|
||||
[SettingsTabs.About]: About,
|
||||
[SettingsTabs.Hotkey]: Hotkey,
|
||||
[SettingsTabs.Proxy]: Proxy,
|
||||
|
||||
@@ -22,6 +22,12 @@ export const componentMap = {
|
||||
[SettingsTabs.Memory]: dynamic(() => import('../memory'), {
|
||||
loading: loading('Settings > Memory'),
|
||||
}),
|
||||
[SettingsTabs.Notification]: dynamic(
|
||||
() => import('@/business/client/BusinessSettingPages/Notification'),
|
||||
{
|
||||
loading: loading('Settings > Notification'),
|
||||
},
|
||||
),
|
||||
[SettingsTabs.About]: dynamic(() => import('../about'), {
|
||||
loading: loading('Settings > About'),
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isDesktop } from '@lobechat/const';
|
||||
import { Avatar } from '@lobehub/ui';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import {
|
||||
// BellIcon,
|
||||
Brain,
|
||||
BrainCircuit,
|
||||
ChartColumnBigIcon,
|
||||
@@ -100,6 +101,12 @@ export const useCategory = () => {
|
||||
key: SettingsTabs.Hotkey,
|
||||
label: t('tab.hotkey'),
|
||||
},
|
||||
// TODO: temporarily disabled until notification UI is polished
|
||||
// enableBusinessFeatures && {
|
||||
// icon: BellIcon,
|
||||
// key: SettingsTabs.Notification,
|
||||
// label: t('tab.notification'),
|
||||
// },
|
||||
].filter(Boolean) as CategoryItem[];
|
||||
|
||||
groups.push({
|
||||
@@ -127,7 +134,7 @@ export const useCategory = () => {
|
||||
|
||||
// Agent group
|
||||
const agentItems: CategoryItem[] = [
|
||||
{
|
||||
(!enableBusinessFeatures || isDevMode) && {
|
||||
icon: Brain,
|
||||
key: SettingsTabs.Provider,
|
||||
label: t('tab.provider'),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { knowledgeEnv } from '@/envs/knowledge';
|
||||
import { ChunkingLoader } from '@/libs/langchain';
|
||||
import { ChunkingLoader } from '@/libs/document-loaders';
|
||||
|
||||
import { ContentChunk } from './index';
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/libs/langchain');
|
||||
vi.mock('@/libs/document-loaders');
|
||||
vi.mock('@/envs/knowledge', () => ({
|
||||
knowledgeEnv: {
|
||||
FILE_TYPE_CHUNKING_RULES: '',
|
||||
@@ -70,7 +70,7 @@ describe('ContentChunk', () => {
|
||||
index: 0,
|
||||
metadata: { source: 'test' },
|
||||
text: 'Test content chunk 1',
|
||||
type: 'LangChainElement',
|
||||
type: 'DocumentChunk',
|
||||
});
|
||||
expect(result.unstructuredChunks).toBeUndefined();
|
||||
});
|
||||
@@ -143,13 +143,13 @@ describe('ContentChunk', () => {
|
||||
loc: { lines: { from: 1, to: 10 } },
|
||||
},
|
||||
text: 'First paragraph content',
|
||||
type: 'LangChainElement',
|
||||
type: 'DocumentChunk',
|
||||
});
|
||||
expect(result.chunks[1]).toMatchObject({
|
||||
id: 'chunk-2',
|
||||
index: 1,
|
||||
text: 'Second paragraph content',
|
||||
type: 'LangChainElement',
|
||||
type: 'DocumentChunk',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('ContentChunk', () => {
|
||||
index: 0,
|
||||
metadata: {},
|
||||
text: 'Content with no metadata',
|
||||
type: 'LangChainElement',
|
||||
type: 'DocumentChunk',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NewChunkItem, type NewUnstructuredChunkItem } from '@/database/schemas';
|
||||
import { knowledgeEnv } from '@/envs/knowledge';
|
||||
import { ChunkingLoader } from '@/libs/langchain';
|
||||
import { ChunkingLoader } from '@/libs/document-loaders';
|
||||
|
||||
import { type ChunkingService } from './rules';
|
||||
import { ChunkingRuleParser } from './rules';
|
||||
@@ -18,11 +18,11 @@ interface ChunkResult {
|
||||
}
|
||||
|
||||
export class ContentChunk {
|
||||
private langchainClient: ChunkingLoader;
|
||||
private chunkingClient: ChunkingLoader;
|
||||
private chunkingRules: Record<string, ChunkingService[]>;
|
||||
|
||||
constructor() {
|
||||
this.langchainClient = new ChunkingLoader();
|
||||
this.chunkingClient = new ChunkingLoader();
|
||||
this.chunkingRules = ChunkingRuleParser.parse(knowledgeEnv.FILE_TYPE_CHUNKING_RULES || '');
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ContentChunk {
|
||||
}
|
||||
|
||||
default: {
|
||||
return await this.chunkByLangChain(params.filename, params.content);
|
||||
return await this.chunkByDefault(params.filename, params.content);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -54,26 +54,23 @@ export class ContentChunk {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to langchain if no service succeeded
|
||||
return await this.chunkByLangChain(params.filename, params.content);
|
||||
// Fallback to default chunking if no service succeeded
|
||||
return await this.chunkByDefault(params.filename, params.content);
|
||||
}
|
||||
|
||||
private canUseUnstructured(): boolean {
|
||||
return !!(knowledgeEnv.UNSTRUCTURED_API_KEY && knowledgeEnv.UNSTRUCTURED_SERVER_URL);
|
||||
}
|
||||
|
||||
private chunkByLangChain = async (
|
||||
filename: string,
|
||||
content: Uint8Array,
|
||||
): Promise<ChunkResult> => {
|
||||
const res = await this.langchainClient.partitionContent(filename, content);
|
||||
private chunkByDefault = async (filename: string, content: Uint8Array): Promise<ChunkResult> => {
|
||||
const res = await this.chunkingClient.partitionContent(filename, content);
|
||||
|
||||
const documents = res.map((item, index) => ({
|
||||
id: item.id,
|
||||
index,
|
||||
metadata: item.metadata,
|
||||
text: item.pageContent,
|
||||
type: 'LangChainElement',
|
||||
type: 'DocumentChunk',
|
||||
}));
|
||||
|
||||
return { chunks: documents };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type RuntimeImageGenParams } from 'model-bank';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chargeAfterGenerate } from '@/business/server/image-generation/chargeAfterGenerate';
|
||||
import { notifyImageCompleted } from '@/business/server/image-generation/notifyImageCompleted';
|
||||
import { createImageBusinessMiddleware } from '@/business/server/trpc-middlewares/async';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -155,6 +156,22 @@ const categorizeError = (
|
||||
};
|
||||
}
|
||||
|
||||
// Content moderation / policy violation — return a clean, generic message
|
||||
const errorMsg: string = error.message || error.error?.message || '';
|
||||
const errorCode: string = error.code || error.error?.code || '';
|
||||
if (
|
||||
errorCode === 'InputTextSensitiveContentDetected' ||
|
||||
errorCode === 'content_policy_violation' ||
|
||||
errorMsg.toLowerCase().includes('content policy') ||
|
||||
errorMsg.toLowerCase().includes('sensitive information')
|
||||
) {
|
||||
return {
|
||||
errorMessage:
|
||||
'The request content may violate content policy. Please modify your prompt and try again.',
|
||||
errorType: AsyncTaskErrorType.ServerError,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof AsyncTaskError) {
|
||||
return {
|
||||
errorMessage: typeof error.body === 'string' ? error.body : error.body.detail,
|
||||
@@ -342,6 +359,15 @@ export const imageRouter = router({
|
||||
status: AsyncTaskStatus.Success,
|
||||
});
|
||||
|
||||
notifyImageCompleted({
|
||||
duration,
|
||||
generationBatchId,
|
||||
model,
|
||||
prompt: params.prompt,
|
||||
topicId: generationTopicId,
|
||||
userId: ctx.userId,
|
||||
}).catch((err) => console.error('[image-async] notification failed:', err));
|
||||
|
||||
if (ENABLE_BUSINESS_FEATURES) {
|
||||
await chargeAfterGenerate({
|
||||
metrics: { latency: duration },
|
||||
|
||||
@@ -41,6 +41,7 @@ import { knowledgeBaseRouter } from './knowledgeBase';
|
||||
import { marketRouter } from './market';
|
||||
import { messageRouter } from './message';
|
||||
import { notebookRouter } from './notebook';
|
||||
import { notificationRouter } from './notification';
|
||||
import { oauthDeviceFlowRouter } from './oauthDeviceFlow';
|
||||
import { pluginRouter } from './plugin';
|
||||
import { ragEvalRouter } from './ragEval';
|
||||
@@ -94,6 +95,7 @@ export const lambdaRouter = router({
|
||||
market: marketRouter,
|
||||
message: messageRouter,
|
||||
notebook: notebookRouter,
|
||||
notification: notificationRouter,
|
||||
oauthDeviceFlow: oauthDeviceFlowRouter,
|
||||
plugin: pluginRouter,
|
||||
ragEval: ragEvalRouter,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { NotificationModel } from '@/database/models/notification';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const notificationProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
return opts.next({
|
||||
ctx: { notificationModel: new NotificationModel(ctx.serverDB, ctx.userId) },
|
||||
});
|
||||
});
|
||||
|
||||
export const notificationRouter = router({
|
||||
archive: notificationProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.archive(input.id);
|
||||
}),
|
||||
|
||||
archiveAll: notificationProcedure.mutation(async ({ ctx }) => {
|
||||
return ctx.notificationModel.archiveAll();
|
||||
}),
|
||||
|
||||
list: notificationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
category: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().min(1).max(50).default(20),
|
||||
unreadOnly: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.list(input);
|
||||
}),
|
||||
|
||||
markAllAsRead: notificationProcedure.mutation(async ({ ctx }) => {
|
||||
return ctx.notificationModel.markAllAsRead();
|
||||
}),
|
||||
|
||||
markAsRead: notificationProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.markAsRead(input.ids);
|
||||
}),
|
||||
|
||||
unreadCount: notificationProcedure.query(async ({ ctx }) => {
|
||||
return ctx.notificationModel.getUnreadCount();
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotificationRouter = typeof notificationRouter;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
class NotificationService {
|
||||
list = (
|
||||
params: {
|
||||
category?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
unreadOnly?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
return lambdaClient.notification.list.query(params);
|
||||
};
|
||||
|
||||
getUnreadCount = (): Promise<number> => {
|
||||
return lambdaClient.notification.unreadCount.query();
|
||||
};
|
||||
|
||||
markAsRead = (ids: string[]) => {
|
||||
return lambdaClient.notification.markAsRead.mutate({ ids });
|
||||
};
|
||||
|
||||
markAllAsRead = () => {
|
||||
return lambdaClient.notification.markAllAsRead.mutate();
|
||||
};
|
||||
|
||||
archive = (id: string) => {
|
||||
return lambdaClient.notification.archive.mutate({ id });
|
||||
};
|
||||
|
||||
archiveAll = () => {
|
||||
return lambdaClient.notification.archiveAll.mutate();
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -149,7 +149,7 @@ export class ConversationLifecycleActionImpl {
|
||||
compressContext.topicId &&
|
||||
!hasRunningCompressionOperation(Object.values(this.#get().operations), compressContext)
|
||||
) {
|
||||
await this.#executeCompression(compressContext, '');
|
||||
await this.executeCompression(compressContext, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -596,7 +596,7 @@ export class ConversationLifecycleActionImpl {
|
||||
* Execute context compression for /compact command.
|
||||
* Reuses the same service methods as the agent runtime's compress_context executor.
|
||||
*/
|
||||
#executeCompression = async (
|
||||
executeCompression = async (
|
||||
context: Record<string, any>,
|
||||
parentOperationId: string,
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -170,8 +170,7 @@ export class FileActionImpl {
|
||||
// image don't need to be chunked and embedding
|
||||
if (isChunkingUnsupported(file.type)) return;
|
||||
|
||||
const data = await ragService.parseFileContent(fileResult.id);
|
||||
console.info('parseFileContent data:', data);
|
||||
await ragService.parseFileContent(fileResult.id);
|
||||
});
|
||||
|
||||
await Promise.all(pools);
|
||||
|
||||
@@ -56,6 +56,7 @@ export enum SettingsTabs {
|
||||
Image = 'image',
|
||||
LLM = 'llm',
|
||||
Memory = 'memory',
|
||||
Notification = 'notification',
|
||||
// business
|
||||
Plans = 'plans',
|
||||
Profile = 'profile',
|
||||
|
||||
Reference in New Issue
Block a user