feat: add minapp window
This commit is contained in:
parent
1996e163c9
commit
4d7a3bb8c3
66
resources/graphrag.html
Normal file
66
resources/graphrag.html
Normal 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
36
resources/js/bridge.js
Normal 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
5
resources/js/utils.js
Normal 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)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
32
src/main/utils.ts
Normal 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()
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
51
src/renderer/src/hooks/useBridge.ts
Normal file
51
src/renderer/src/hooks/useBridge.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@ -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: '可视化'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user