feat: add minapp window

This commit is contained in:
kangfenmao 2024-08-17 17:11:48 +08:00
parent 1996e163c9
commit 4d7a3bb8c3
15 changed files with 284 additions and 22 deletions

66
resources/graphrag.html Normal file
View File

@ -0,0 +1,66 @@
<head>
<style>
body {
margin: 0;
}
</style>
<script src="https://unpkg.com/3d-force-graph"></script>
</head>
<body>
<div id="3d-graph"></div>
<script src="./js/bridge.js"></script>
<script type="module">
import { getQueryParam } from './js/utils.js'
const apiUrl = getQueryParam('apiUrl')
const modelId = getQueryParam('modelId')
const jsonUrl = `${apiUrl}/v1/global_graph/${modelId}`
const infoCard = document.createElement('div')
infoCard.style.position = 'fixed'
infoCard.style.backgroundColor = 'rgba(255, 255, 255, 0.9)'
infoCard.style.padding = '8px'
infoCard.style.borderRadius = '4px'
infoCard.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'
infoCard.style.fontSize = '12px'
infoCard.style.maxWidth = '200px'
infoCard.style.display = 'none'
infoCard.style.zIndex = '1000'
document.body.appendChild(infoCard)
document.addEventListener('mousemove', (event) => {
infoCard.style.left = `${event.clientX + 10}px`
infoCard.style.top = `${event.clientY + 10}px`
})
const elem = document.getElementById('3d-graph')
const Graph = ForceGraph3D()(elem)
.jsonUrl(jsonUrl)
.nodeAutoColorBy((node) => node.properties.type || 'default')
.nodeVal((node) => node.properties.degree)
.linkWidth((link) => link.properties.weight)
.onNodeHover((node) => {
if (node) {
infoCard.innerHTML = `
<div style="font-weight: bold; margin-bottom: 4px; color: #333;">
${node.properties.title}
</div>
<div style="color: #666;">
${node.properties.description}
</div>`
infoCard.style.display = 'block'
} else {
infoCard.style.display = 'none'
}
})
.onNodeClick((node) => {
const url = `${apiUrl}/v1/references/${modelId}/relationships/${node.properties.human_readable_id}`
window.api.minApp({
url,
windowOptions: {
title: node.properties.title
}
})
})
</script>
</body>

36
resources/js/bridge.js Normal file
View File

@ -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)
}
}
)
})()

5
resources/js/utils.js Normal file
View File

@ -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)
}

View File

@ -19,22 +19,22 @@
justify-content: space-between; justify-content: space-between;
-webkit-app-region: drag; -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-left: auto;
margin-right: 10px; margin-right: 10px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
} }
.header-left {
margin-left: 10px;
margin-right: auto;
}
.header-center {
color: #fff;
font-size: 14px;
margin-left: 10px;
}
button { button {
background: none; background: none;
border: none; border: none;
@ -57,9 +57,14 @@
</head> </head>
<body> <body>
<header> <header>
<div class="header-left"></div> <div id="header-left"></div>
<div class="header-center">MinApp</div> <div id="header-center"></div>
<div class="header-right"></div> <div id="header-right"></div>
</header> </header>
<script type="module">
import { getQueryParam } from './js/utils.js'
const title = getQueryParam('title')
document.getElementById('header-center').innerHTML = title
</script>
</body> </body>
</html> </html>

View File

@ -49,7 +49,9 @@ app.whenReady().then(() => {
ipcMain.handle('save-file', saveFile) 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') => { ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
appConfig.set('theme', theme) appConfig.set('theme', theme)

32
src/main/utils.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* JavaScript URL
* @param obj -
* @param options -
* @returns
*/
export function objectToQueryParams(
obj: Record<string, string | number | boolean | null | undefined | object>,
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()
}

View File

@ -5,6 +5,7 @@ import { join } from 'path'
import icon from '../../build/icon.png?asset' import icon from '../../build/icon.png?asset'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import { objectToQueryParams } from './utils'
export function createMainWindow() { export function createMainWindow() {
// Load the previous state with fallback to defaults // Load the previous state with fallback to defaults
@ -76,7 +77,13 @@ export function createMainWindow() {
return mainWindow return mainWindow
} }
export function createMinappWindow(url) { export function createMinappWindow({
url,
windowOptions
}: {
url: string
windowOptions?: Electron.BrowserWindowConstructorOptions
}) {
const width = 500 const width = 500
const height = 800 const height = 800
const headerHeight = 40 const headerHeight = 40
@ -88,20 +95,26 @@ export function createMinappWindow(url) {
alwaysOnTop: true, alwaysOnTop: true,
titleBarOverlay: titleBarOverlayDark, titleBarOverlay: titleBarOverlayDark,
titleBarStyle: 'hidden', titleBarStyle: 'hidden',
...windowOptions,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/minapp.js'), preload: join(__dirname, '../preload/minapp.js'),
sandbox: false sandbox: false
} }
}) })
minappWindow.loadFile(app.getAppPath() + '/resources/minapp.html')
const view = new BrowserView() const view = new BrowserView()
minappWindow.setBrowserView(view)
view.setBounds({ x: 0, y: headerHeight, width, height: height - headerHeight }) view.setBounds({ x: 0, y: headerHeight, width, height: height - headerHeight })
view.webContents.loadURL(url) 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', () => { minappWindow.on('resize', () => {
view.setBounds({ view.setBounds({
x: 0, x: 0,

View File

@ -1,4 +1,5 @@
import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons' import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons'
import { useBridge } from '@renderer/hooks/useBridge'
import store from '@renderer/store' import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime' import { setMinappShow } from '@renderer/store/runtime'
import { Drawer } from 'antd' import { Drawer } from 'antd'
@ -20,6 +21,8 @@ const PopupContainer: React.FC<Props> = ({ title, url, resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
useBridge()
const canOpenExternalLink = url.startsWith('http://') || url.startsWith('https://') const canOpenExternalLink = url.startsWith('http://') || url.startsWith('https://')
const onClose = () => { const onClose = () => {

View File

@ -9,7 +9,7 @@ const navbarBackgroundColor = isMac ? 'var(--navbar-background-mac)' : 'var(--na
export const Navbar: FC<Props> = ({ children, ...props }) => { export const Navbar: FC<Props> = ({ children, ...props }) => {
const { minappShow } = useRuntime() const { minappShow } = useRuntime()
const backgroundColor = minappShow ? 'var(--color-background)' : navbarBackgroundColor const backgroundColor = minappShow ? 'var(--navbar-background)' : navbarBackgroundColor
return ( return (
<NavbarContainer {...props} style={{ backgroundColor }}> <NavbarContainer {...props} style={{ backgroundColor }}>

View File

@ -24,7 +24,7 @@ const Sidebar: FC = () => {
} }
return ( return (
<Container style={{ backgroundColor: minappShow ? 'var(--color-background)' : sidebarBackgroundColor }}> <Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBackgroundColor }}>
<AvatarImg src={avatar || Logo} draggable={false} className="dragdisable" onClick={onEditUser} /> <AvatarImg src={avatar || Logo} draggable={false} className="dragdisable" onClick={onEditUser} />
<MainMenus> <MainMenus>
<Menus> <Menus>

View File

@ -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)
}
}, [])
}

View File

@ -220,6 +220,10 @@ const resources = {
}, },
error: { error: {
'chat.response': 'Something went wrong. Please check if you have set your API key in the Settings > Providers' '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: { error: {
'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥' 'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥'
},
words: {
knowledgeGraph: '知识图谱',
visualization: '可视化'
} }
} }
} }

View File

@ -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<Props> = ({ 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 (
<Container>
<SettingSubtitle>{t('words.knowledgeGraph')}</SettingSubtitle>
<Button style={{ marginTop: 10 }} onClick={onShowGraphRAG}>
{t('words.visualization')}
</Button>
</Container>
)
}
const Container = styled.div``
export default GraphRAGSettings

View File

@ -14,7 +14,7 @@ const OllamSettings: FC = () => {
return ( return (
<Container> <Container>
<SettingSubtitle>{t('ollama.keep_alive_time.title')}</SettingSubtitle> <SettingSubtitle style={{ marginBottom: 5 }}>{t('ollama.keep_alive_time.title')}</SettingSubtitle>
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
value={keepAliveMinutes} value={keepAliveMinutes}

View File

@ -22,6 +22,7 @@ import styled from 'styled-components'
import { SettingContainer, SettingSubtitle, SettingTitle } from '..' import { SettingContainer, SettingSubtitle, SettingTitle } from '..'
import AddModelPopup from './AddModelPopup' import AddModelPopup from './AddModelPopup'
import EditModelsPopup from './EditModelsPopup' import EditModelsPopup from './EditModelsPopup'
import GraphRAGSettings from './GraphRAGSettings'
import OllamSettings from './OllamaSettings' import OllamSettings from './OllamaSettings'
interface Props { interface Props {
@ -128,6 +129,9 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{apiEditable && <Button onClick={onReset}>{t('settings.provider.api.url.reset')}</Button>} {apiEditable && <Button onClick={onReset}>{t('settings.provider.api.url.reset')}</Button>}
</Space.Compact> </Space.Compact>
{provider.id === 'ollama' && <OllamSettings />} {provider.id === 'ollama' && <OllamSettings />}
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (
<GraphRAGSettings provider={provider} />
)}
<SettingSubtitle style={{ marginBottom: 5 }}>{t('common.models')}</SettingSubtitle> <SettingSubtitle style={{ marginBottom: 5 }}>{t('common.models')}</SettingSubtitle>
{Object.keys(modelGroups).map((group) => ( {Object.keys(modelGroups).map((group) => (
<Card key={group} type="inner" title={group} style={{ marginBottom: '10px' }} size="small"> <Card key={group} type="inner" title={group} style={{ marginBottom: '10px' }} size="small">