diff --git a/resources/graphrag.html b/resources/graphrag.html new file mode 100644 index 00000000..169a1e55 --- /dev/null +++ b/resources/graphrag.html @@ -0,0 +1,66 @@ + + + + + +
+ + + diff --git a/resources/js/bridge.js b/resources/js/bridge.js new file mode 100644 index 00000000..f6c0021a --- /dev/null +++ b/resources/js/bridge.js @@ -0,0 +1,36 @@ +;(() => { + let messageId = 0 + const pendingCalls = new Map() + + function api(method, ...args) { + const id = messageId++ + return new Promise((resolve, reject) => { + pendingCalls.set(id, { resolve, reject }) + window.parent.postMessage({ id, type: 'api-call', method, args }, '*') + }) + } + + window.addEventListener('message', (event) => { + if (event.data.type === 'api-response') { + const { id, result, error } = event.data + const pendingCall = pendingCalls.get(id) + if (pendingCall) { + if (error) { + pendingCall.reject(new Error(error)) + } else { + pendingCall.resolve(result) + } + pendingCalls.delete(id) + } + } + }) + + window.api = new Proxy( + {}, + { + get: (target, prop) => { + return (...args) => api(prop, ...args) + } + } + ) +})() diff --git a/resources/js/utils.js b/resources/js/utils.js new file mode 100644 index 00000000..36981ac4 --- /dev/null +++ b/resources/js/utils.js @@ -0,0 +1,5 @@ +export function getQueryParam(paramName) { + const url = new URL(window.location.href) + const params = new URLSearchParams(url.search) + return params.get(paramName) +} diff --git a/resources/minapp.html b/resources/minapp.html index 164ce2e8..f782f790 100644 --- a/resources/minapp.html +++ b/resources/minapp.html @@ -19,22 +19,22 @@ justify-content: space-between; -webkit-app-region: drag; } - .header-right { + #header-left { + margin-left: 10px; + margin-right: auto; + } + #header-center { + color: #fff; + font-size: 14px; + margin-left: 10px; + } + #header-right { margin-left: auto; margin-right: 10px; display: flex; flex-direction: row; align-items: center; } - .header-left { - margin-left: 10px; - margin-right: auto; - } - .header-center { - color: #fff; - font-size: 14px; - margin-left: 10px; - } button { background: none; border: none; @@ -57,9 +57,14 @@
-
-
MinApp
-
+
+
+
+ diff --git a/src/main/index.ts b/src/main/index.ts index 78128905..432063f0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,7 +49,9 @@ app.whenReady().then(() => { ipcMain.handle('save-file', saveFile) - ipcMain.handle('minapp', (_, url: string) => createMinappWindow(url)) + ipcMain.handle('minapp', (_, args) => { + createMinappWindow(args) + }) ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => { appConfig.set('theme', theme) diff --git a/src/main/utils.ts b/src/main/utils.ts new file mode 100644 index 00000000..6d2e4423 --- /dev/null +++ b/src/main/utils.ts @@ -0,0 +1,32 @@ +/** + * 将 JavaScript 对象转换为 URL 查询参数字符串 + * @param obj - 要转换的对象 + * @param options - 配置选项 + * @returns 转换后的查询参数字符串 + */ +export function objectToQueryParams( + obj: Record, + options: { + skipNull?: boolean + skipUndefined?: boolean + } = {} +): string { + const { skipNull = false, skipUndefined = false } = options + + const params = new URLSearchParams() + + for (const [key, value] of Object.entries(obj)) { + if (skipNull && value === null) continue + if (skipUndefined && value === undefined) continue + + if (Array.isArray(value)) { + value.forEach((item) => params.append(key, String(item))) + } else if (typeof value === 'object' && value !== null) { + params.append(key, JSON.stringify(value)) + } else if (value !== undefined && value !== null) { + params.append(key, String(value)) + } + } + + return params.toString() +} diff --git a/src/main/window.ts b/src/main/window.ts index eadb8492..7f6a229f 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -5,6 +5,7 @@ import { join } from 'path' import icon from '../../build/icon.png?asset' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' +import { objectToQueryParams } from './utils' export function createMainWindow() { // Load the previous state with fallback to defaults @@ -76,7 +77,13 @@ export function createMainWindow() { return mainWindow } -export function createMinappWindow(url) { +export function createMinappWindow({ + url, + windowOptions +}: { + url: string + windowOptions?: Electron.BrowserWindowConstructorOptions +}) { const width = 500 const height = 800 const headerHeight = 40 @@ -88,20 +95,26 @@ export function createMinappWindow(url) { alwaysOnTop: true, titleBarOverlay: titleBarOverlayDark, titleBarStyle: 'hidden', + ...windowOptions, webPreferences: { preload: join(__dirname, '../preload/minapp.js'), sandbox: false } }) - minappWindow.loadFile(app.getAppPath() + '/resources/minapp.html') - const view = new BrowserView() - - minappWindow.setBrowserView(view) view.setBounds({ x: 0, y: headerHeight, width, height: height - headerHeight }) view.webContents.loadURL(url) + const minappWindowParams = { + title: windowOptions?.title || 'CherryStudio' + } + + const appPath = app.getAppPath() + const minappHtmlPath = appPath + '/resources/minapp.html' + + minappWindow.loadURL('file://' + minappHtmlPath + '?' + objectToQueryParams(minappWindowParams)) + minappWindow.setBrowserView(view) minappWindow.on('resize', () => { view.setBounds({ x: 0, diff --git a/src/renderer/src/components/MinApp/index.tsx b/src/renderer/src/components/MinApp/index.tsx index 10659260..62f711ee 100644 --- a/src/renderer/src/components/MinApp/index.tsx +++ b/src/renderer/src/components/MinApp/index.tsx @@ -1,4 +1,5 @@ import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons' +import { useBridge } from '@renderer/hooks/useBridge' import store from '@renderer/store' import { setMinappShow } from '@renderer/store/runtime' import { Drawer } from 'antd' @@ -20,6 +21,8 @@ const PopupContainer: React.FC = ({ title, url, resolve }) => { const [open, setOpen] = useState(true) const iframeRef = useRef(null) + useBridge() + const canOpenExternalLink = url.startsWith('http://') || url.startsWith('https://') const onClose = () => { diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 0b54f916..c6e0b5ba 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -9,7 +9,7 @@ const navbarBackgroundColor = isMac ? 'var(--navbar-background-mac)' : 'var(--na export const Navbar: FC = ({ children, ...props }) => { const { minappShow } = useRuntime() - const backgroundColor = minappShow ? 'var(--color-background)' : navbarBackgroundColor + const backgroundColor = minappShow ? 'var(--navbar-background)' : navbarBackgroundColor return ( diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 84559e8d..32e3f1cb 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -24,7 +24,7 @@ const Sidebar: FC = () => { } return ( - + diff --git a/src/renderer/src/hooks/useBridge.ts b/src/renderer/src/hooks/useBridge.ts new file mode 100644 index 00000000..98de20c1 --- /dev/null +++ b/src/renderer/src/hooks/useBridge.ts @@ -0,0 +1,51 @@ +import { useEffect } from 'react' + +export function useBridge() { + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + const targetOrigin = { targetOrigin: '*' } + + try { + if (event.origin !== 'file://') { + return + } + + const { type, method, args, id } = event.data + + if (type !== 'api-call' || !window.api) { + return + } + + const apiMethod = window.api[method] + + if (typeof apiMethod !== 'function') { + return + } + + event.source?.postMessage( + { + id, + type: 'api-response', + result: await apiMethod(...args) + }, + targetOrigin + ) + } catch (error) { + event.source?.postMessage( + { + id: event.data?.id, + type: 'api-response', + error: error instanceof Error ? error.message : String(error) + }, + targetOrigin + ) + } + } + + window.addEventListener('message', handleMessage) + + return () => { + window.removeEventListener('message', handleMessage) + } + }, []) +} diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 72c6992b..243f5d76 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -220,6 +220,10 @@ const resources = { }, error: { 'chat.response': 'Something went wrong. Please check if you have set your API key in the Settings > Providers' + }, + words: { + knowledgeGraph: 'Knowledge Graph', + visualization: 'Visualization' } } }, @@ -441,6 +445,10 @@ const resources = { }, error: { 'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥' + }, + words: { + knowledgeGraph: '知识图谱', + visualization: '可视化' } } } diff --git a/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx new file mode 100644 index 00000000..2a0ac01b --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx @@ -0,0 +1,37 @@ +import MinApp from '@renderer/components/MinApp' +import { Provider } from '@renderer/types' +import { Button } from 'antd' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { SettingSubtitle } from '..' + +interface Props { + provider: Provider +} + +const GraphRAGSettings: FC = ({ provider }) => { + const apiUrl = provider.apiHost + const modalId = provider.models[0].id + const { t } = useTranslation() + + const onShowGraphRAG = async () => { + const { appPath } = await window.api.getAppInfo() + const url = `file://${appPath}/resources/graphrag.html?apiUrl=${apiUrl}&modelId=${modalId}` + MinApp.start({ url, title: t('words.knowledgeGraph') }) + } + + return ( + + {t('words.knowledgeGraph')} + + + ) +} + +const Container = styled.div`` + +export default GraphRAGSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/OllamaSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/OllamaSettings.tsx index b12e51ba..63a12881 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/OllamaSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/OllamaSettings.tsx @@ -14,7 +14,7 @@ const OllamSettings: FC = () => { return ( - {t('ollama.keep_alive_time.title')} + {t('ollama.keep_alive_time.title')} = ({ provider: _provider }) => { {apiEditable && } {provider.id === 'ollama' && } + {provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && ( + + )} {t('common.models')} {Object.keys(modelGroups).map((group) => (