From addea48b5203f57a581ac0e0929b456ce0008c64 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 6 Apr 2025 12:09:57 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style:=20support=20default=20con?= =?UTF-8?q?fig=20for=20system=20agent=20and=20pre-merge=20some=20desktop?= =?UTF-8?q?=20code=20(#7296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor * update * improve scripts * fix changelog issue * improve system agent config * fix tests * update scripts * update ollama models * Update ollama.ts --- .github/scripts/pr-comment.js | 151 ++++++++++++------ .gitignore | 2 +- .../environment-variables/basic.mdx | 31 ++++ .../environment-variables/basic.zh-CN.mdx | 31 ++++ scripts/migrateServerDB/index.ts | 5 +- .../features/ProviderConfig/index.tsx | 4 +- src/app/desktop/devtools/page.tsx | 73 +++++---- src/config/aiModels/ollama.ts | 22 +-- .../PostgresViewer/SchemaSidebar/index.tsx | 2 +- src/features/User/UserPanel/index.tsx | 2 +- .../User/__tests__/PanelContent.test.tsx | 1 + src/libs/trpc/index.ts | 9 +- src/libs/trpc/middleware/userAuth.ts | 5 +- .../globalConfig/parseSystemAgent.test.ts | 56 +++++++ src/server/globalConfig/parseSystemAgent.ts | 25 +++ src/server/services/changelog/index.test.ts | 2 + src/server/services/changelog/index.ts | 8 +- src/services/electron/devtools.ts | 2 +- src/utils/electron/dispatch.ts | 10 -- 19 files changed, 323 insertions(+), 118 deletions(-) delete mode 100644 src/utils/electron/dispatch.ts diff --git a/.github/scripts/pr-comment.js b/.github/scripts/pr-comment.js index 61b4d1a82e..5930d0baca 100644 --- a/.github/scripts/pr-comment.js +++ b/.github/scripts/pr-comment.js @@ -1,59 +1,69 @@ /** * Generate PR comment with download links for desktop builds + * and handle comment creation/update logic */ module.exports = async ({ github, context, releaseUrl, version, tag }) => { - try { - // Get release assets to create download links - const release = await github.rest.repos.getReleaseByTag({ - owner: context.repo.owner, - repo: context.repo.repo, - tag, - }); + // 用于识别构建评论的标识符 + const COMMENT_IDENTIFIER = ''; - // Organize assets by platform - const macAssets = release.data.assets.filter( - (asset) => - ((asset.name.includes('.dmg') || asset.name.includes('.zip')) && - !asset.name.includes('.blockmap')) || - (asset.name.includes('latest-mac') && asset.name.endsWith('.yml')), - ); + /** + * 生成评论内容 + */ + const generateCommentBody = async () => { + try { + // Get release assets to create download links + const release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag, + }); - const winAssets = release.data.assets.filter( - (asset) => - (asset.name.includes('.exe') && !asset.name.includes('.blockmap')) || - (asset.name.includes('latest-win') && asset.name.endsWith('.yml')), - ); + // Organize assets by platform + const macAssets = release.data.assets.filter( + (asset) => + ((asset.name.includes('.dmg') || asset.name.includes('.zip')) && + !asset.name.includes('.blockmap')) || + (asset.name.includes('latest-mac') && asset.name.endsWith('.yml')), + ); - const linuxAssets = release.data.assets.filter( - (asset) => - (asset.name.includes('.AppImage') && !asset.name.includes('.blockmap')) || - (asset.name.includes('latest-linux') && asset.name.endsWith('.yml')), - ); + const winAssets = release.data.assets.filter( + (asset) => + (asset.name.includes('.exe') && !asset.name.includes('.blockmap')) || + (asset.name.includes('latest-win') && asset.name.endsWith('.yml')), + ); - // Generate combined download table - let assetTable = '| Platform | File | Size |\n| --- | --- | --- |\n'; + const linuxAssets = release.data.assets.filter( + (asset) => + (asset.name.includes('.AppImage') && !asset.name.includes('.blockmap')) || + (asset.name.includes('latest-linux') && asset.name.endsWith('.yml')), + ); - // Add macOS assets - macAssets.forEach((asset) => { - const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); - assetTable += `| macOS | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; - }); + // Generate combined download table + let assetTable = '| Platform | File | Size |\n| --- | --- | --- |\n'; - // Add Windows assets - winAssets.forEach((asset) => { - const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); - assetTable += `| Windows | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; - }); + // Add macOS assets + macAssets.forEach((asset) => { + const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); + assetTable += `| macOS | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; + }); - // Add Linux assets - linuxAssets.forEach((asset) => { - const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); - assetTable += `| Linux | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; - }); + // Add Windows assets + winAssets.forEach((asset) => { + const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); + assetTable += `| Windows | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; + }); - return `### 🚀 Desktop App Build Completed! + // Add Linux assets + linuxAssets.forEach((asset) => { + const sizeInMB = (asset.size / (1024 * 1024)).toFixed(2); + assetTable += `| Linux | [${asset.name}](${asset.browser_download_url}) | ${sizeInMB} MB |\n`; + }); + + return `${COMMENT_IDENTIFIER} +### 🚀 Desktop App Build Completed! **Version**: \`${version}\` +**Build Time**: \`${new Date().toISOString()}\` 📦 [View All Build Artifacts](${releaseUrl}) @@ -65,17 +75,62 @@ ${assetTable} > [!Warning] > > Note: This is a temporary build for testing purposes only.`; - } catch (error) { - console.error('Error generating PR comment:', error); - // Fallback to a simple comment if error occurs - return ` + } catch (error) { + console.error('Error generating PR comment:', error); + // Fallback to a simple comment if error occurs + return `${COMMENT_IDENTIFIER} ### 🚀 Desktop App Build Completed! **Version**: \`${version}\` +**Build Time**: \`${new Date().toISOString()}\` ## 📦 [View All Build Artifacts](${releaseUrl}) > Note: This is a temporary build for testing purposes only. - `; - } + `; + } + }; + + /** + * 查找并更新或创建PR评论 + */ + const updateOrCreateComment = async () => { + // 生成评论内容 + const body = await generateCommentBody(); + + // 查找我们之前可能创建的评论 + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + // 查找包含我们标识符的评论 + const buildComment = comments.find((comment) => comment.body.includes(COMMENT_IDENTIFIER)); + + if (buildComment) { + // 如果找到现有评论,则更新它 + await github.rest.issues.updateComment({ + comment_id: buildComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); + console.log(`已更新现有评论 ID: ${buildComment.id}`); + return { updated: true, id: buildComment.id }; + } else { + // 如果没有找到现有评论,则创建新评论 + const result = await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); + console.log(`已创建新评论 ID: ${result.data.id}`); + return { updated: false, id: result.data.id }; + } + }; + + // 执行评论更新或创建 + return await updateOrCreateComment(); }; diff --git a/.gitignore b/.gitignore index 213af9b910..d4394bbd48 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,4 @@ public/swe-worker* *.patch *.pdf vertex-ai-key.json -.pnpm-store \ No newline at end of file +.pnpm-store diff --git a/docs/self-hosting/environment-variables/basic.mdx b/docs/self-hosting/environment-variables/basic.mdx index c02fbec72c..e1977ea475 100644 --- a/docs/self-hosting/environment-variables/basic.mdx +++ b/docs/self-hosting/environment-variables/basic.mdx @@ -71,6 +71,37 @@ Further reading: - [\[RFC\] 022 - Default Assistant Parameters Configuration via Environment Variables](https://github.com/lobehub/lobe-chat/discussions/913) +### `SYSTEM_AGENT` + +- Type: Optional +- Description: Used to configure models and providers for LobeChat system agents (such as topic generation, translation, etc.). +- Default value: `-` +- Example: `default=ollama/deepseek-v3` or `topic=openai/gpt-4,translation=anthropic/claude-1` + +The `SYSTEM_AGENT` environment variable supports two configuration methods: + +1. Use `default=provider/model` to set the same default configuration for all system agents +2. Configure specific system agents individually using the format `agent-name=provider/model` + +Configuration details: + +| Config Type | Format | Explanation | +| ------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- | +| Default setting | `default=ollama/deepseek-v3` | Set deepseek-v3 from ollama as the default model for all system agents | +| Specific setting | `topic=openai/gpt-4` | Set a specific provider and model for topic generation | +| Mixed configuration | `default=ollama/deepseek-v3,topic=openai/gpt-4` | First set default values for all agents, then override specific agents | + +Available system agents and their functions: + +| System Agent | Key Name | Function Description | +| ------------------- | ----------------- | --------------------------------------------------------------------------------------------------- | +| Topic Generation | `topic` | Automatically generates topic names and summaries based on chat content | +| Translation | `translation` | Handles text translation between multiple languages | +| Metadata Generation | `agentMeta` | Generates descriptive information and metadata for assistants | +| History Compression | `historyCompress` | Compresses and organizes history for long conversations, optimizing context management | +| Query Rewrite | `queryRewrite` | Rewrites follow-up questions as standalone questions with context, improving conversation coherence | +| Thread Management | `thread` | Handles the creation and management of conversation threads | + ### `FEATURE_FLAGS` - Type: Optional diff --git a/docs/self-hosting/environment-variables/basic.zh-CN.mdx b/docs/self-hosting/environment-variables/basic.zh-CN.mdx index bb9964cda3..07a5fcc793 100644 --- a/docs/self-hosting/environment-variables/basic.zh-CN.mdx +++ b/docs/self-hosting/environment-variables/basic.zh-CN.mdx @@ -67,6 +67,37 @@ LobeChat 在部署时提供了一些额外的配置项,你可以使用环境 - [\[RFC\] 022 - 环境变量配置默认助手参数](https://github.com/lobehub/lobe-chat/discussions/913) +### `SYSTEM_AGENT` + +- 类型:可选 +- 描述:用于配置 LobeChat 系统助手(如主题生成、翻译等功能)的模型和供应商。 +- 默认值:`-` +- 示例:`default=ollama/deepseek-v3` 或 `topic=openai/gpt-4,translation=anthropic/claude-1` + +`SYSTEM_AGENT` 环境变量支持两种配置方式: + +1. 使用 `default=供应商/模型` 为所有系统助手设置相同的默认配置 +2. 针对特定的系统助手进行单独配置,格式为 `助手名称=供应商/模型` + +配置项说明: + +| 配置项 | 格式 | 解释 | +| ---- | ----------------------------------------------- | ----------------------------------- | +| 默认设置 | `default=ollama/deepseek-v3` | 为所有系统助手设置默认模型为 ollama 的 deepseek-v3 | +| 特定设置 | `topic=openai/gpt-4` | 为主题生成设置特定的供应商和模型 | +| 混合配置 | `default=ollama/deepseek-v3,topic=openai/gpt-4` | 先为所有助手设置默认值,然后针对特定助手进行覆盖 | + +可配置的系统助手及其作用: + +| 系统助手 | 键名 | 作用描述 | +| ------- | ----------------- | --------------------------- | +| 主题生成 | `topic` | 根据聊天内容自动生成主题名称和摘要 | +| 翻译 | `translation` | 文本翻译使用的助手 | +| 元数据生成 | `agentMeta` | 为助手生成描述性信息和元数据 | +| 历史记录压缩 | `historyCompress` | 压缩和整理长对话的历史记录,优化上下文管理 | +| 知识库查询重写 | `queryRewrite` | 将后续问题改写为包含上下文的独立问题,提升对话的连贯性 | +| 分支对话 | `thread` | 自定生成分支对话的标题 | + ### `FEATURE_FLAGS` - 类型:可选 diff --git a/scripts/migrateServerDB/index.ts b/scripts/migrateServerDB/index.ts index 45f0a32b3e..1ac9410e5a 100644 --- a/scripts/migrateServerDB/index.ts +++ b/scripts/migrateServerDB/index.ts @@ -12,6 +12,7 @@ dotenv.config(); const migrationsFolder = join(__dirname, '../../src/database/migrations'); +const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1'; const runMigrations = async () => { if (process.env.DATABASE_DRIVER === 'node') { await nodeMigrate(serverDB, { migrationsFolder }); @@ -27,7 +28,7 @@ const runMigrations = async () => { let connectionString = process.env.DATABASE_URL; // only migrate database if the connection string is available -if (connectionString) { +if (!isDesktop && connectionString) { // eslint-disable-next-line unicorn/prefer-top-level-await runMigrations().catch((err) => { console.error('❌ Database migrate failed:', err); @@ -44,5 +45,5 @@ if (connectionString) { process.exit(1); }); } else { - console.log('🟢 not find database env, migration skipped'); + console.log('🟢 not find database env or in desktop mode, migration skipped'); } diff --git a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx index 84d49a5d01..302515b062 100644 --- a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +++ b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx @@ -16,7 +16,7 @@ import { z } from 'zod'; import { FormInput, FormPassword } from '@/components/FormInput'; import { FORM_STYLE } from '@/const/layoutTokens'; import { AES_GCM_URL, BASE_PROVIDER_DOC_URL } from '@/const/url'; -import { isServerMode } from '@/const/version'; +import { isDesktop, isServerMode } from '@/const/version'; import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra'; import { AiProviderDetailItem, @@ -244,12 +244,14 @@ const ProviderConfig = memo( /* * Conditions to show Client Fetch Switch + * 0. is not desktop app * 1. provider is not disabled browser request * 2. provider show browser request by default * 3. Provider allow to edit endpoint and the value of endpoint is not empty * 4. There is an apikey provided by user */ const showClientFetch = + !isDesktop && !disableBrowserRequest && (defaultShowBrowserRequest || (showEndpoint && isProviderEndpointNotEmpty) || diff --git a/src/app/desktop/devtools/page.tsx b/src/app/desktop/devtools/page.tsx index dffe2776e0..591b35ef0f 100644 --- a/src/app/desktop/devtools/page.tsx +++ b/src/app/desktop/devtools/page.tsx @@ -30,44 +30,43 @@ const DevTools = memo(() => { const [tab, setTab] = useState(items[0].key); return ( - - ( - setTab(item.key)} - placement={'right'} - title={item.key} - > - {item.icon} - - ))} - /> - - - - {BRANDING_NAME} Dev Tools - / - {tab} - + + + + {BRANDING_NAME} Dev Tools + / + {tab} + + + ( + setTab(item.key)} + placement={'right'} + title={item.key} + > + {item.icon} + + ))} + /> {items.map((item) => ( { }; return ( - + { return { popover: css` - inset-block-start: ${isDesktop ? 24 : 8}px !important; + inset-block-start: ${isDesktop ? 32 : 8}px !important; inset-inline-start: 8px !important; `, }; diff --git a/src/features/User/__tests__/PanelContent.test.tsx b/src/features/User/__tests__/PanelContent.test.tsx index dc8b2b164f..01c09c9cad 100644 --- a/src/features/User/__tests__/PanelContent.test.tsx +++ b/src/features/User/__tests__/PanelContent.test.tsx @@ -63,6 +63,7 @@ vi.mock('../DataStatistics', () => ({ vi.mock('@/const/version', () => ({ isDeprecatedEdition: false, + isDesktop: false, })); // 定义一个变量来存储 enableAuth 的值 diff --git a/src/libs/trpc/index.ts b/src/libs/trpc/index.ts index c507507d98..aaaa4e6eef 100644 --- a/src/libs/trpc/index.ts +++ b/src/libs/trpc/index.ts @@ -7,6 +7,9 @@ * @link https://trpc.io/docs/v11/router * @link https://trpc.io/docs/v11/procedures */ +import { DESKTOP_USER_ID } from '@/const/desktop'; +import { isDesktop } from '@/const/version'; + import { trpc } from './init'; import { jwtPayloadChecker } from './middleware/jwtPayload'; import { userAuth } from './middleware/userAuth'; @@ -21,7 +24,11 @@ export const router = trpc.router; * Create an unprotected procedure * @link https://trpc.io/docs/v11/procedures **/ -export const publicProcedure = trpc.procedure; +export const publicProcedure = trpc.procedure.use(({ next }) => { + return next({ + ctx: { userId: isDesktop ? DESKTOP_USER_ID : null }, + }); +}); // procedure that asserts that the user is logged in export const authedProcedure = trpc.procedure.use(userAuth); diff --git a/src/libs/trpc/middleware/userAuth.ts b/src/libs/trpc/middleware/userAuth.ts index e165659258..7b86894264 100644 --- a/src/libs/trpc/middleware/userAuth.ts +++ b/src/libs/trpc/middleware/userAuth.ts @@ -9,11 +9,10 @@ import { trpc } from '../init'; export const userAuth = trpc.middleware(async (opts) => { const { ctx } = opts; + // 桌面端模式下,跳过默认鉴权逻辑 if (isDesktop) { return opts.next({ - ctx: { - userId: DESKTOP_USER_ID, - }, + ctx: { userId: DESKTOP_USER_ID }, }); } // `ctx.user` is nullable diff --git a/src/server/globalConfig/parseSystemAgent.test.ts b/src/server/globalConfig/parseSystemAgent.test.ts index d769c1480d..70ff014a59 100644 --- a/src/server/globalConfig/parseSystemAgent.test.ts +++ b/src/server/globalConfig/parseSystemAgent.test.ts @@ -92,4 +92,60 @@ describe('parseSystemAgent', () => { expect(parseSystemAgent(envValue)).toEqual(expected); }); + + it('should apply default setting to all system agents when default is specified', () => { + const envValue = 'default=ollama/deepseek-v3'; + + const result = parseSystemAgent(envValue); + + expect(result.topic).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.translation).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.agentMeta).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.historyCompress).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.thread).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.queryRewrite).toEqual({ + provider: 'ollama', + model: 'deepseek-v3', + enabled: true, + }); + }); + + it('should override default setting with specific settings', () => { + const envValue = 'default=ollama/deepseek-v3,topic=openai/gpt-4'; + + const result = parseSystemAgent(envValue); + + expect(result.topic).toEqual({ provider: 'openai', model: 'gpt-4' }); + + expect(result.translation).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.agentMeta).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.historyCompress).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.thread).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.queryRewrite).toEqual({ + provider: 'ollama', + model: 'deepseek-v3', + enabled: true, + }); + }); + + it('should properly handle priority when topic appears before default in the string', () => { + // 即使 topic 在 default 之前出现,topic 的设置仍然应该优先 + const envValue = 'topic=openai/gpt-4,default=ollama/deepseek-v3'; + + const result = parseSystemAgent(envValue); + + // topic 应该保持自己的设置而不被 default 覆盖 + expect(result.topic).toEqual({ provider: 'openai', model: 'gpt-4' }); + + // 其他系统智能体应该使用默认配置 + expect(result.translation).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.agentMeta).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.historyCompress).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.thread).toEqual({ provider: 'ollama', model: 'deepseek-v3' }); + expect(result.queryRewrite).toEqual({ + provider: 'ollama', + model: 'deepseek-v3', + enabled: true, + }); + }); }); diff --git a/src/server/globalConfig/parseSystemAgent.ts b/src/server/globalConfig/parseSystemAgent.ts index 550147b40b..dd63ac413c 100644 --- a/src/server/globalConfig/parseSystemAgent.ts +++ b/src/server/globalConfig/parseSystemAgent.ts @@ -13,6 +13,9 @@ export const parseSystemAgent = (envString: string = ''): Partial s.trim()); @@ -24,6 +27,15 @@ export const parseSystemAgent = (envString: string = ''): Partial { describe('getChangelogIndex', () => { it('should fetch and merge changelog data', async () => { const mockResponse = { + ok: true, json: vi.fn().mockResolvedValue({ cloud: [{ id: 'cloud1', date: '2023-01-01', versionRange: ['1.0.0'] }], community: [{ id: 'community1', date: '2023-01-02', versionRange: ['1.1.0'] }], @@ -104,6 +105,7 @@ describe('ChangelogService', () => { it('should return only community items when config type is community', async () => { service.config.type = 'community'; const mockResponse = { + ok: true, json: vi.fn().mockResolvedValue({ cloud: [{ id: 'cloud1', date: '2023-01-01', versionRange: ['1.0.0'] }], community: [{ id: 'community1', date: '2023-01-02', versionRange: ['1.1.0'] }], diff --git a/src/server/services/changelog/index.ts b/src/server/services/changelog/index.ts index de0f777528..e9326ebfaa 100644 --- a/src/server/services/changelog/index.ts +++ b/src/server/services/changelog/index.ts @@ -55,9 +55,13 @@ export class ChangelogService { next: { revalidate: 3600, tags: [FetchCacheTag.Changelog] }, }); - const data = await res.json(); + if (res.ok) { + const data = await res.json(); - return this.mergeChangelogs(data.cloud, data.community).slice(0, 5); + return this.mergeChangelogs(data.cloud, data.community).slice(0, 5); + } + + return []; } catch (e) { const cause = (e as Error).cause as { code: string }; if (cause?.code.includes('ETIMEDOUT')) { diff --git a/src/services/electron/devtools.ts b/src/services/electron/devtools.ts index cfd314d7ce..db34a9aa9e 100644 --- a/src/services/electron/devtools.ts +++ b/src/services/electron/devtools.ts @@ -1,4 +1,4 @@ -import { dispatch } from '@/utils/electron/dispatch'; +import { dispatch } from '@lobechat/electron-client-ipc'; class DevtoolsService { async openDevtools(): Promise { diff --git a/src/utils/electron/dispatch.ts b/src/utils/electron/dispatch.ts deleted file mode 100644 index 4514bfc120..0000000000 --- a/src/utils/electron/dispatch.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DispatchInvoke } from '@lobechat/electron-client-ipc'; - -/** - * client 端请求 sketch 端 event 数据的方法 - */ -export const dispatch: DispatchInvoke = async (event, ...data) => { - if (!window.electronAPI) throw new Error('electronAPI not found'); - - return window.electronAPI.invoke(event, ...data); -};