chore: prepare GoodBuddy 0.8.3
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
This commit is contained in:
@@ -19,7 +19,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
family: 'sensevoice',
|
||||
quantization: 'int8',
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17',
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
|
||||
license: {
|
||||
name: '模型仓库自定义许可(Model License)',
|
||||
notice:
|
||||
@@ -33,9 +34,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' +
|
||||
'resolve/6a65851692da9706cbddfac66ea9b96ebb1dee21/' +
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
|
||||
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
|
||||
'model.int8.onnx',
|
||||
size: 239_233_841,
|
||||
sha256:
|
||||
@@ -47,9 +48,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' +
|
||||
'resolve/86f7114c4a83bcba0c609dd8d8dfa730d967ade9/' +
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
|
||||
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
|
||||
'tokens.txt',
|
||||
size: 315_894,
|
||||
sha256:
|
||||
@@ -67,7 +68,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
family: 'whisper',
|
||||
quantization: 'int8',
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-tiny',
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
@@ -81,9 +83,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
role: 'encoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-encoder.int8.onnx',
|
||||
size: 12_937_772,
|
||||
sha256:
|
||||
@@ -95,9 +97,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
role: 'decoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-decoder.int8.onnx',
|
||||
size: 89_855_401,
|
||||
sha256:
|
||||
@@ -109,9 +111,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-tokens.txt',
|
||||
size: 816_730,
|
||||
sha256:
|
||||
|
||||
@@ -55,7 +55,8 @@ function downloadableCatalog(
|
||||
languages: ['中文'],
|
||||
family: 'whisper',
|
||||
quantization: 'int8',
|
||||
repositoryUrl: 'https://huggingface.co/example/download-test-model',
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/example/download-test-model',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test-only model metadata.',
|
||||
@@ -68,8 +69,8 @@ function downloadableCatalog(
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
'resolve/revision/model.onnx',
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/model.onnx`,
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes)
|
||||
}
|
||||
@@ -79,8 +80,8 @@ function downloadableCatalog(
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
'resolve/revision/tokens.txt',
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/tokens.txt`,
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes)
|
||||
}
|
||||
@@ -124,6 +125,16 @@ describe('speech model catalog', () => {
|
||||
'tiny-decoder.int8.onnx',
|
||||
'tiny-tokens.txt'
|
||||
])
|
||||
for (const entry of SPEECH_MODEL_CATALOG) {
|
||||
expect(entry.repositoryUrl).toMatch(
|
||||
/^https:\/\/modelscope\.cn\/models\//u
|
||||
)
|
||||
for (const file of entry.files) {
|
||||
expect(file.download?.url).toMatch(
|
||||
/^https:\/\/modelscope\.cn\/models\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,7 +179,9 @@ describe('SpeechModelManager downloads', () => {
|
||||
})
|
||||
expect(transport).toHaveBeenCalledTimes(2)
|
||||
for (const [input, init] of transport.mock.calls) {
|
||||
expect(String(input)).toMatch(/^https:\/\/huggingface\.co\//u)
|
||||
expect(String(input)).toMatch(
|
||||
/^https:\/\/modelscope\.cn\/models\//u
|
||||
)
|
||||
expect(init).toMatchObject({
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
@@ -203,26 +216,66 @@ describe('SpeechModelManager downloads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects untrusted redirects and bad digests without installing', async () => {
|
||||
it('accepts arbitrary HTTP hosts and cross-host redirects', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const catalog = downloadableCatalog(modelBytes)
|
||||
const tokenBytes = new TextEncoder().encode('tokens')
|
||||
const catalog = downloadableCatalog(modelBytes).map((entry) => ({
|
||||
...entry,
|
||||
files: entry.files.map((file) => ({
|
||||
...file,
|
||||
download: file.download
|
||||
? {
|
||||
...file.download,
|
||||
url: file.download.url.replace(
|
||||
'https://modelscope.cn',
|
||||
'http://models.internal.example'
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}))
|
||||
}))
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(String(input))
|
||||
if (url.hostname === 'models.internal.example') {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: `https://cdn.example.net${url.pathname}`
|
||||
}
|
||||
})
|
||||
}
|
||||
const bytes = url.pathname.endsWith('model.onnx')
|
||||
? modelBytes
|
||||
: tokenBytes
|
||||
return new Response(bytes, {
|
||||
headers: { 'content-length': String(bytes.byteLength) }
|
||||
})
|
||||
})
|
||||
const redirected = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
fetch: vi.fn<typeof fetch>(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://attacker.invalid/model.onnx'
|
||||
}
|
||||
})
|
||||
)
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(
|
||||
redirected.install('download-test-model')
|
||||
).rejects.toThrow('允许的 Hugging Face HTTPS')
|
||||
).resolves.toMatchObject({ id: 'download-test-model' })
|
||||
expect(transport).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
transport.mock.calls.map(([input]) => new URL(String(input)).hostname)
|
||||
).toEqual([
|
||||
'models.internal.example',
|
||||
'cdn.example.net',
|
||||
'models.internal.example',
|
||||
'cdn.example.net'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects bad digests without installing', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const catalog = downloadableCatalog(modelBytes)
|
||||
const badDigest = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
|
||||
@@ -32,17 +32,6 @@ const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const SELECTION_FILE_NAME = '.selection.json'
|
||||
const PARTIAL_SUFFIX = '.partial'
|
||||
|
||||
const SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS = new Set([
|
||||
'huggingface.co',
|
||||
'cdn-lfs.huggingface.co',
|
||||
'cdn-lfs-us-1.huggingface.co',
|
||||
'cdn-lfs-eu-1.huggingface.co',
|
||||
'cdn-lfs.hf.co',
|
||||
'cdn-lfs-us-1.hf.co',
|
||||
'cdn-lfs-eu-1.hf.co',
|
||||
'cas-bridge.xethub.hf.co'
|
||||
])
|
||||
|
||||
const selectionSchema = z
|
||||
.object({
|
||||
selectedModelId: speechModelIdSchema.nullable()
|
||||
@@ -110,13 +99,10 @@ function safeChild(parent: string, name: string): string {
|
||||
function validateDownloadUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
!SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())
|
||||
url.protocol !== 'http:' &&
|
||||
url.protocol !== 'https:'
|
||||
) {
|
||||
throw new Error('模型下载地址必须是允许的 Hugging Face HTTPS 地址')
|
||||
throw new Error('模型下载地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const files = [
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: `GoodBuddy-${latestVersion}-windows-x64-portable.exe`,
|
||||
name: `GoodBuddy-${latestVersion}-windows-x64-portable.zip`,
|
||||
size: 102,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ function hasExpectedFileFormats(
|
||||
if (platform === 'windows') {
|
||||
return (
|
||||
files.filter((file) => /-setup\.exe$/u.test(file.name)).length === 1 &&
|
||||
files.filter((file) => /-portable\.exe$/u.test(file.name)).length === 1
|
||||
files.filter((file) => /-portable\.zip$/u.test(file.name)).length === 1
|
||||
)
|
||||
}
|
||||
const extensions =
|
||||
|
||||
@@ -549,6 +549,94 @@ describe('App', () => {
|
||||
expect(removeMaximizedChangedListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('checks for updates silently on startup and only reports a new version', async () => {
|
||||
const check = vi.fn(async () => ({
|
||||
updateAvailable: true,
|
||||
currentVersion: '0.8.3',
|
||||
latestVersion: '0.9.0',
|
||||
releaseUrl:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0',
|
||||
target: {
|
||||
platform: 'windows' as const,
|
||||
arch: 'x64' as const,
|
||||
formats: ['nsis', 'portable'],
|
||||
files: [
|
||||
{
|
||||
name: 'GoodBuddy-0.9.0-windows-x64-portable.zip',
|
||||
size: 1,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true
|
||||
})),
|
||||
updateSettings: vi.fn(async (input) => input),
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
}
|
||||
try {
|
||||
render(<App />)
|
||||
await waitFor(() => expect(check).toHaveBeenCalledOnce())
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'发现 GoodBuddy 0.9.0,可在“关于与更新”中查看'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.updates.onResult).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
})
|
||||
|
||||
it('does not disturb startup when updates are current or offline', async () => {
|
||||
const currentResult = {
|
||||
updateAvailable: false,
|
||||
currentVersion: '0.8.3',
|
||||
latestVersion: '0.8.3',
|
||||
releaseUrl:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.3',
|
||||
target: {
|
||||
platform: 'windows' as const,
|
||||
arch: 'x64' as const,
|
||||
formats: ['nsis', 'portable'],
|
||||
files: []
|
||||
}
|
||||
}
|
||||
const check = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(currentResult)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true
|
||||
})),
|
||||
updateSettings: vi.fn(async (input) => input),
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
}
|
||||
try {
|
||||
const first = render(<App />)
|
||||
await waitFor(() => expect(check).toHaveBeenCalledTimes(1))
|
||||
expect(
|
||||
screen.queryByText(/发现 GoodBuddy|版本检查失败/u)
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
first.unmount()
|
||||
render(<App />)
|
||||
await waitFor(() => expect(check).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
screen.queryByText(/发现 GoodBuddy|版本检查失败/u)
|
||||
).not.toBeInTheDocument()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps rendering when an older preload has no browser bridge', async () => {
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
|
||||
+18
-18
@@ -1021,24 +1021,22 @@ function App(): React.JSX.Element {
|
||||
if (!updates) {
|
||||
return
|
||||
}
|
||||
const removeListener = updates.onResult((result) => {
|
||||
if (result.updateAvailable) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: `发现 GoodBuddy ${result.latestVersion},可在“关于与更新”中查看`,
|
||||
dedupeKey: 'update-available'
|
||||
})
|
||||
}
|
||||
})
|
||||
void updates
|
||||
.getSettings()
|
||||
.then((settings) =>
|
||||
settings.checkUpdatesOnStartup
|
||||
? updates.check()
|
||||
: undefined
|
||||
)
|
||||
.then(async (settings) => {
|
||||
if (!settings.checkUpdatesOnStartup) {
|
||||
return
|
||||
}
|
||||
const result = await updates.check()
|
||||
if (result.updateAvailable) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: `发现 GoodBuddy ${result.latestVersion},可在“关于与更新”中查看`,
|
||||
dedupeKey: 'update-available'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return removeListener
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2647,7 +2645,7 @@ function App(): React.JSX.Element {
|
||||
const transcript = conversation.messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.role === 'user' ? '你' : 'GoodBuddy'}:\n${message.content}${formatAttachmentList(message.attachments)}`
|
||||
`${message.role === 'user' ? '用户' : 'GoodBuddy'}:\n${message.content}${formatAttachmentList(message.attachments)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
try {
|
||||
@@ -2668,7 +2666,7 @@ function App(): React.JSX.Element {
|
||||
`# ${conversation.title}`,
|
||||
'',
|
||||
...conversation.messages.flatMap((message) => [
|
||||
`## ${message.role === 'user' ? '你' : 'GoodBuddy'}`,
|
||||
`## ${message.role === 'user' ? '用户' : 'GoodBuddy'}`,
|
||||
'',
|
||||
`${message.content}${formatAttachmentList(message.attachments)}`,
|
||||
''
|
||||
@@ -3802,7 +3800,9 @@ function App(): React.JSX.Element {
|
||||
<div className="message__body">
|
||||
<div className="message__meta">
|
||||
<strong>
|
||||
{message.role === 'assistant' ? 'GoodBuddy' : '你'}
|
||||
{message.role === 'assistant'
|
||||
? 'GoodBuddy'
|
||||
: '用户'}
|
||||
</strong>
|
||||
<span>{formatTime(message.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import { TooltipComponent } from 'echarts/components'
|
||||
import {
|
||||
init,
|
||||
use as registerECharts,
|
||||
type ECElementEvent,
|
||||
type ECharts,
|
||||
type EChartsCoreOption
|
||||
} from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
KnowledgeGraphNode,
|
||||
KnowledgeGraphRelation
|
||||
} from '../../shared/contracts'
|
||||
|
||||
registerECharts([GraphChart, TooltipComponent, CanvasRenderer])
|
||||
|
||||
type ChartKnowledgeGraphNode = Omit<
|
||||
KnowledgeGraphNode,
|
||||
'aliases' | 'evidenceIds'
|
||||
> & {
|
||||
aliases?: readonly string[]
|
||||
evidenceIds?: readonly string[]
|
||||
}
|
||||
|
||||
type ChartKnowledgeGraphRelation = Omit<
|
||||
KnowledgeGraphRelation,
|
||||
'evidenceIds'
|
||||
> & {
|
||||
evidenceIds?: readonly string[]
|
||||
}
|
||||
|
||||
type KnowledgeGraphChartProps = {
|
||||
nodes: readonly ChartKnowledgeGraphNode[]
|
||||
relations: readonly ChartKnowledgeGraphRelation[]
|
||||
selectedNodeId?: string
|
||||
zoom: number
|
||||
onMoveNode: (nodeId: string, position: { x: number; y: number }) => void
|
||||
onSelectNode: (nodeId: string) => void
|
||||
onZoomChange: (zoom: number) => void
|
||||
}
|
||||
|
||||
type GraphViewport = {
|
||||
center?: [number | string, number | string]
|
||||
}
|
||||
|
||||
type NodeDrag = {
|
||||
id: string
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function readToken(name: string): string {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function createOption({
|
||||
nodes,
|
||||
relations,
|
||||
selectedNodeId,
|
||||
zoom
|
||||
}: Pick<
|
||||
KnowledgeGraphChartProps,
|
||||
'nodes' | 'relations' | 'selectedNodeId' | 'zoom'
|
||||
>): EChartsCoreOption {
|
||||
const textPrimary = readToken('--text-primary')
|
||||
const textSecondary = readToken('--text-secondary')
|
||||
const textMuted = readToken('--text-muted')
|
||||
const accent = readToken('--accent')
|
||||
const accentSelected = readToken('--accent-selected')
|
||||
const accentSubtle = readToken('--accent-subtle')
|
||||
const surfaceRaised = readToken('--surface-raised')
|
||||
const borderDefault = readToken('--border-default')
|
||||
const dense = nodes.length > 24
|
||||
const veryDense = nodes.length > 60
|
||||
const showEdgeLabels =
|
||||
nodes.length <= 18 && relations.length <= 24
|
||||
|
||||
return {
|
||||
animation: !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
renderMode: 'richText',
|
||||
backgroundColor: surfaceRaised,
|
||||
borderColor: borderDefault,
|
||||
textStyle: { color: textPrimary },
|
||||
formatter: (params: {
|
||||
dataType?: string
|
||||
data?: { name?: string; type?: string; value?: string }
|
||||
}) => {
|
||||
if (params.dataType === 'edge') {
|
||||
return params.data?.value ?? '关系'
|
||||
}
|
||||
return [params.data?.name, params.data?.type]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
roam: true,
|
||||
zoom,
|
||||
scaleLimit: {
|
||||
min: 0.5,
|
||||
max: 2
|
||||
},
|
||||
force: {
|
||||
repulsion: dense
|
||||
? Math.min(520, 130 + nodes.length * 3)
|
||||
: 220,
|
||||
gravity: dense ? 0.14 : 0.08,
|
||||
edgeLength: dense
|
||||
? veryDense
|
||||
? [45, 80]
|
||||
: [60, 110]
|
||||
: [110, 190],
|
||||
friction: dense ? 0.5 : 0.6,
|
||||
layoutAnimation:
|
||||
!window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
.matches
|
||||
},
|
||||
selectedMode: 'single',
|
||||
symbol: 'circle',
|
||||
data: nodes.map((node) => {
|
||||
const selected = node.id === selectedNodeId
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.label,
|
||||
type: node.type,
|
||||
...(dense ? {} : { x: node.x, y: node.y }),
|
||||
draggable: true,
|
||||
selected,
|
||||
symbolSize: selected
|
||||
? dense
|
||||
? 34
|
||||
: 60
|
||||
: dense
|
||||
? veryDense
|
||||
? 18
|
||||
: 24
|
||||
: 52,
|
||||
itemStyle: {
|
||||
color: selected ? accentSelected : accentSubtle,
|
||||
borderColor: accent,
|
||||
borderWidth: selected ? 3 : 2
|
||||
},
|
||||
label: {
|
||||
show: !dense || selected,
|
||||
color: textPrimary,
|
||||
fontSize: dense ? 11 : 12,
|
||||
fontWeight: 700,
|
||||
formatter:
|
||||
node.label.length > 8
|
||||
? `${node.label.slice(0, 8)}…`
|
||||
: node.label
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
label: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
select: {
|
||||
itemStyle: {
|
||||
color: accentSelected,
|
||||
borderColor: accent,
|
||||
borderWidth: 3
|
||||
},
|
||||
label: {
|
||||
show: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
links: relations.map((relation) => ({
|
||||
id: relation.id,
|
||||
source: relation.sourceId,
|
||||
target: relation.targetId,
|
||||
value: relation.type,
|
||||
description: relation.description,
|
||||
lineStyle: {
|
||||
color: textMuted,
|
||||
width: 1.5,
|
||||
curveness: 0.08
|
||||
}
|
||||
})),
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: 8,
|
||||
autoCurveness: true,
|
||||
edgeLabel: {
|
||||
show: showEdgeLabels,
|
||||
color: textSecondary,
|
||||
fontSize: 11,
|
||||
formatter: (params: { data?: { value?: string } }) =>
|
||||
params.data?.value ?? ''
|
||||
},
|
||||
lineStyle: {
|
||||
color: textMuted
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgeGraphChart({
|
||||
nodes,
|
||||
relations,
|
||||
selectedNodeId,
|
||||
zoom,
|
||||
onMoveNode,
|
||||
onSelectNode,
|
||||
onZoomChange
|
||||
}: KnowledgeGraphChartProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<ECharts | null>(null)
|
||||
const onMoveNodeRef = useRef(onMoveNode)
|
||||
const onSelectNodeRef = useRef(onSelectNode)
|
||||
const onZoomChangeRef = useRef(onZoomChange)
|
||||
const dragRef = useRef<NodeDrag | undefined>(undefined)
|
||||
const viewportRef = useRef<GraphViewport>({})
|
||||
const zoomRef = useRef(zoom)
|
||||
const appliedZoomRef = useRef<number | undefined>(undefined)
|
||||
const [themeRevision, setThemeRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
onMoveNodeRef.current = onMoveNode
|
||||
onSelectNodeRef.current = onSelectNode
|
||||
onZoomChangeRef.current = onZoomChange
|
||||
}, [onMoveNode, onSelectNode, onZoomChange])
|
||||
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom
|
||||
}, [zoom])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof MutationObserver !== 'function') {
|
||||
return
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
setThemeRevision((revision) => revision + 1)
|
||||
})
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const chart = init(container, undefined, { renderer: 'canvas' })
|
||||
chartRef.current = chart
|
||||
|
||||
const selectNode = (event: ECElementEvent): void => {
|
||||
const data = event.data as { id?: unknown } | undefined
|
||||
if (event.dataType === 'node' && typeof data?.id === 'string') {
|
||||
onSelectNodeRef.current(data.id)
|
||||
}
|
||||
}
|
||||
const beginNodeDrag = (event: ECElementEvent): void => {
|
||||
const data = event.data as { id?: unknown } | undefined
|
||||
const pointerEvent = event.event
|
||||
if (
|
||||
event.dataType !== 'node' ||
|
||||
typeof data?.id !== 'string' ||
|
||||
!pointerEvent ||
|
||||
!Number.isFinite(pointerEvent.offsetX) ||
|
||||
!Number.isFinite(pointerEvent.offsetY)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const pointer = chart.convertFromPixel(
|
||||
{ seriesIndex: 0 },
|
||||
[pointerEvent.offsetX, pointerEvent.offsetY]
|
||||
)
|
||||
const centerPixel =
|
||||
pointerEvent.target?.transformCoordToGlobal(0, 0)
|
||||
const center = centerPixel
|
||||
? chart.convertFromPixel(
|
||||
{ seriesIndex: 0 },
|
||||
centerPixel
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
Array.isArray(pointer) &&
|
||||
Number.isFinite(pointer[0]) &&
|
||||
Number.isFinite(pointer[1]) &&
|
||||
Array.isArray(center) &&
|
||||
Number.isFinite(center[0]) &&
|
||||
Number.isFinite(center[1])
|
||||
) {
|
||||
dragRef.current = {
|
||||
id: data.id,
|
||||
pointerX: Number(pointer[0]),
|
||||
pointerY: Number(pointer[1]),
|
||||
x: Number(center[0]),
|
||||
y: Number(center[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
const persistNodePosition = (event: ECElementEvent): void => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = undefined
|
||||
const pointerEvent = event.event
|
||||
if (
|
||||
!drag ||
|
||||
!pointerEvent ||
|
||||
!Number.isFinite(pointerEvent.offsetX) ||
|
||||
!Number.isFinite(pointerEvent.offsetY)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const pointer = chart.convertFromPixel(
|
||||
{ seriesIndex: 0 },
|
||||
[pointerEvent.offsetX, pointerEvent.offsetY]
|
||||
)
|
||||
if (
|
||||
!Array.isArray(pointer) ||
|
||||
!Number.isFinite(pointer[0]) ||
|
||||
!Number.isFinite(pointer[1])
|
||||
) {
|
||||
return
|
||||
}
|
||||
const deltaX = Number(pointer[0]) - drag.pointerX
|
||||
const deltaY = Number(pointer[1]) - drag.pointerY
|
||||
if (Math.hypot(deltaX, deltaY) < 2) {
|
||||
return
|
||||
}
|
||||
onMoveNodeRef.current(drag.id, {
|
||||
x: drag.x + deltaX,
|
||||
y: drag.y + deltaY
|
||||
})
|
||||
}
|
||||
const persistViewport = (): void => {
|
||||
const option = chart.getOption()
|
||||
const series = Array.isArray(option.series)
|
||||
? option.series[0]
|
||||
: option.series
|
||||
if (!series || typeof series !== 'object') {
|
||||
return
|
||||
}
|
||||
const nextViewport: GraphViewport = {}
|
||||
if (
|
||||
'center' in series &&
|
||||
Array.isArray(series.center) &&
|
||||
series.center.length === 2 &&
|
||||
series.center.every(
|
||||
(value: unknown) =>
|
||||
typeof value === 'number' || typeof value === 'string'
|
||||
)
|
||||
) {
|
||||
nextViewport.center = [
|
||||
series.center[0] as number | string,
|
||||
series.center[1] as number | string
|
||||
]
|
||||
}
|
||||
if (
|
||||
'zoom' in series &&
|
||||
typeof series.zoom === 'number' &&
|
||||
Number.isFinite(series.zoom)
|
||||
) {
|
||||
if (Math.abs(series.zoom - zoomRef.current) >= 0.001) {
|
||||
zoomRef.current = series.zoom
|
||||
appliedZoomRef.current = series.zoom
|
||||
onZoomChangeRef.current(series.zoom)
|
||||
}
|
||||
}
|
||||
viewportRef.current = nextViewport
|
||||
}
|
||||
const resize = (): void => chart.resize()
|
||||
|
||||
chart.on('click', selectNode)
|
||||
chart.on('mousedown', beginNodeDrag)
|
||||
chart.on('mouseup', persistNodePosition)
|
||||
chart.on('graphRoam', persistViewport)
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(container)
|
||||
} else {
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', resize)
|
||||
chart.off('click', selectNode)
|
||||
chart.off('mousedown', beginNodeDrag)
|
||||
chart.off('mouseup', persistNodePosition)
|
||||
chart.off('graphRoam', persistViewport)
|
||||
chart.dispose()
|
||||
chartRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current
|
||||
if (!chart) {
|
||||
return
|
||||
}
|
||||
const option = createOption({
|
||||
nodes,
|
||||
relations,
|
||||
selectedNodeId: undefined,
|
||||
zoom: zoomRef.current
|
||||
})
|
||||
const series = Array.isArray(option.series)
|
||||
? option.series[0]
|
||||
: option.series
|
||||
if (
|
||||
series &&
|
||||
typeof series === 'object' &&
|
||||
viewportRef.current.center
|
||||
) {
|
||||
series.center = viewportRef.current.center
|
||||
}
|
||||
chart.setOption(
|
||||
option,
|
||||
{ notMerge: true }
|
||||
)
|
||||
appliedZoomRef.current = zoomRef.current
|
||||
}, [nodes, relations, themeRevision])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current
|
||||
if (!chart) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
appliedZoomRef.current !== undefined &&
|
||||
Math.abs(appliedZoomRef.current - zoom) < 0.001
|
||||
) {
|
||||
return
|
||||
}
|
||||
chart.setOption({
|
||||
series: [{ zoom }]
|
||||
})
|
||||
appliedZoomRef.current = zoom
|
||||
}, [zoom])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current
|
||||
if (!chart) {
|
||||
return
|
||||
}
|
||||
chart.dispatchAction({
|
||||
type: 'unselect',
|
||||
seriesIndex: 0
|
||||
})
|
||||
const dataIndex = selectedNodeId
|
||||
? nodes.findIndex((node) => node.id === selectedNodeId)
|
||||
: -1
|
||||
if (dataIndex >= 0) {
|
||||
chart.dispatchAction({
|
||||
type: 'select',
|
||||
seriesIndex: 0,
|
||||
dataIndex
|
||||
})
|
||||
}
|
||||
}, [nodes, selectedNodeId, themeRevision])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="实体关系图"
|
||||
className="knowledge-graph__chart"
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -11,6 +12,38 @@ import {
|
||||
type KnowledgeWorkspaceProps
|
||||
} from './KnowledgeWorkspace'
|
||||
|
||||
const echartsMock = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: unknown) => void>()
|
||||
const chart = {
|
||||
convertFromPixel: vi.fn(() => [240, 320]),
|
||||
dispose: vi.fn(),
|
||||
dispatchAction: vi.fn(),
|
||||
getOption: vi.fn(() => ({
|
||||
series: [{ center: ['50%', '50%'], zoom: 1 }]
|
||||
})),
|
||||
off: vi.fn((eventName: string) => handlers.delete(eventName)),
|
||||
on: vi.fn((eventName: string, handler: (event: unknown) => void) => {
|
||||
handlers.set(eventName, handler)
|
||||
}),
|
||||
resize: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
}
|
||||
return {
|
||||
chart,
|
||||
handlers,
|
||||
init: vi.fn(() => chart),
|
||||
use: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('echarts/core', () => ({
|
||||
init: echartsMock.init,
|
||||
use: echartsMock.use
|
||||
}))
|
||||
vi.mock('echarts/charts', () => ({ GraphChart: {} }))
|
||||
vi.mock('echarts/components', () => ({ TooltipComponent: {} }))
|
||||
vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} }))
|
||||
|
||||
const library: KnowledgeWorkspaceProps['libraries'][number] = {
|
||||
id: 'library-1',
|
||||
name: '产品知识',
|
||||
@@ -117,6 +150,8 @@ function createProps(
|
||||
describe('KnowledgeWorkspace', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
echartsMock.handlers.clear()
|
||||
})
|
||||
|
||||
it('creates a configured knowledge library', async () => {
|
||||
@@ -178,31 +213,49 @@ describe('KnowledgeWorkspace', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
expect(screen.getByLabelText('实体关系图')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' }))
|
||||
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
|
||||
target: { value: 'entity-1' }
|
||||
})
|
||||
expect(screen.getByLabelText('实体详情')).toBeInTheDocument()
|
||||
expect(screen.getByText('跨平台 AI 桌面助手')).toBeInTheDocument()
|
||||
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders and filters graph nodes with their relationships', () => {
|
||||
it('renders and filters graph nodes with their relationships', async () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
expect(
|
||||
screen.getByRole('button', { name: '实体 GoodBuddy' })
|
||||
screen.getByRole('option', { name: 'GoodBuddy · 产品' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '实体 Electron' })
|
||||
screen.getByRole('option', { name: 'Electron · 技术' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('使用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('可见关系 1 条'))
|
||||
expect(await screen.findByText('使用')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
|
||||
target: { value: 'Electron' }
|
||||
})
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '实体 GoodBuddy' })
|
||||
screen.queryByRole('option', { name: 'GoodBuddy · 产品' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('使用')).not.toBeInTheDocument()
|
||||
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
id: 'entity-2'
|
||||
})
|
||||
],
|
||||
links: []
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
|
||||
target: { value: '' }
|
||||
@@ -211,10 +264,10 @@ describe('KnowledgeWorkspace', () => {
|
||||
target: { value: '产品' }
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('button', { name: '实体 GoodBuddy' })
|
||||
screen.getByRole('option', { name: 'GoodBuddy · 产品' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '实体 Electron' })
|
||||
screen.queryByRole('option', { name: 'Electron · 技术' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -263,7 +316,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
expect(screen.queryByTitle('D:\\Private\\产品手册')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' }))
|
||||
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
|
||||
target: { value: 'entity-1' }
|
||||
})
|
||||
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
|
||||
'knowledge-graph--with-details'
|
||||
)
|
||||
@@ -272,26 +327,204 @@ describe('KnowledgeWorkspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('supports graph zoom, keyboard selection, and related-node navigation', () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
it('manages the graph chart, zoom, selection, movement, and cleanup', () => {
|
||||
const onMoveNode = vi.fn()
|
||||
const { unmount } = render(
|
||||
<KnowledgeWorkspace {...createProps({ onMoveNode })} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
const graph = screen.getByLabelText('实体关系图')
|
||||
expect(graph).toHaveAttribute('viewBox', '0 0 900 560')
|
||||
expect(graph).toHaveClass('knowledge-graph__chart')
|
||||
expect(echartsMock.init).toHaveBeenCalledWith(
|
||||
graph,
|
||||
undefined,
|
||||
{ renderer: 'canvas' }
|
||||
)
|
||||
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
layout: 'force',
|
||||
symbol: 'circle',
|
||||
type: 'graph',
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'entity-1',
|
||||
name: 'GoodBuddy'
|
||||
})
|
||||
]),
|
||||
links: [
|
||||
expect.objectContaining({
|
||||
id: 'relation-1',
|
||||
value: '使用'
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
|
||||
expect(screen.getByText('115%')).toBeInTheDocument()
|
||||
expect(graph.getAttribute('viewBox')).not.toBe('0 0 900 560')
|
||||
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole('button', { name: '实体 GoodBuddy' }),
|
||||
{ key: 'Enter' }
|
||||
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
zoom: 1.15
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
echartsMock.handlers.get('click')?.({
|
||||
dataType: 'node',
|
||||
data: { id: 'entity-1' }
|
||||
})
|
||||
})
|
||||
expect(screen.getByLabelText('实体详情')).toBeInTheDocument()
|
||||
expect(echartsMock.chart.dispatchAction).toHaveBeenCalledWith({
|
||||
type: 'select',
|
||||
seriesIndex: 0,
|
||||
dataIndex: 0
|
||||
})
|
||||
expect(onMoveNode).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
echartsMock.chart.convertFromPixel
|
||||
.mockReturnValueOnce([100, 100])
|
||||
.mockReturnValueOnce([220, 260])
|
||||
.mockReturnValueOnce([120, 160])
|
||||
echartsMock.handlers.get('mousedown')?.({
|
||||
dataType: 'node',
|
||||
data: { id: 'entity-1' },
|
||||
event: {
|
||||
offsetX: 100,
|
||||
offsetY: 100,
|
||||
target: {
|
||||
transformCoordToGlobal: () => [220, 260]
|
||||
}
|
||||
}
|
||||
})
|
||||
echartsMock.handlers.get('mouseup')?.({
|
||||
dataType: 'node',
|
||||
data: { id: 'entity-1' },
|
||||
event: { offsetX: 120, offsetY: 160 }
|
||||
})
|
||||
})
|
||||
expect(onMoveNode).toHaveBeenCalledWith('entity-1', {
|
||||
x: 240,
|
||||
y: 320
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看 Electron' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Electron' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
expect(echartsMock.chart.off).toHaveBeenCalledWith(
|
||||
'click',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(echartsMock.chart.off).toHaveBeenCalledWith(
|
||||
'mousedown',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(echartsMock.chart.off).toHaveBeenCalledWith(
|
||||
'mouseup',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(echartsMock.chart.off).toHaveBeenCalledWith(
|
||||
'graphRoam',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(echartsMock.chart.dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the graph viewport and refreshes theme colors', async () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
|
||||
echartsMock.chart.getOption.mockReturnValueOnce({
|
||||
series: [{ center: ['46%', '54%'], zoom: 1.3 }]
|
||||
})
|
||||
act(() => {
|
||||
echartsMock.handlers.get('graphRoam')?.({})
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('130%')).toBeInTheDocument()
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
|
||||
target: { value: 'Electron' }
|
||||
})
|
||||
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
center: ['46%', '54%'],
|
||||
zoom: 1.3
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
)
|
||||
|
||||
const optionCalls = echartsMock.chart.setOption.mock.calls.length
|
||||
act(() => {
|
||||
document.documentElement.dataset.theme = 'dark'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(echartsMock.chart.setOption.mock.calls.length).toBeGreaterThan(
|
||||
optionCalls
|
||||
)
|
||||
)
|
||||
delete document.documentElement.dataset.theme
|
||||
})
|
||||
|
||||
it('reduces labels and node size for dense graphs', () => {
|
||||
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `entity-${index}`,
|
||||
label: `实体 ${index}`,
|
||||
type: '概念',
|
||||
x: index * 10,
|
||||
y: index * 5
|
||||
}))
|
||||
render(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ graphNodes, graphRelations: [] })}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
|
||||
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'entity-0',
|
||||
symbolSize: 24,
|
||||
label: expect.objectContaining({ show: false })
|
||||
})
|
||||
]),
|
||||
edgeLabel: expect.objectContaining({ show: false }),
|
||||
force: expect.objectContaining({
|
||||
repulsion: 220
|
||||
})
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
)
|
||||
const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as {
|
||||
series?: Array<{ data?: Array<Record<string, unknown>> }>
|
||||
}
|
||||
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('x')
|
||||
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('y')
|
||||
})
|
||||
|
||||
it('creates relationships, merges entities, and opens graph evidence', async () => {
|
||||
@@ -309,7 +542,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' }))
|
||||
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
|
||||
target: { value: 'entity-1' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /架构说明\.md/u })
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
PageTabs,
|
||||
type PageTab
|
||||
} from './WorkspacePrimitives'
|
||||
import { KnowledgeGraphChart } from './KnowledgeGraphChart'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
|
||||
export type KnowledgeStorageMode = 'reference' | 'managed'
|
||||
@@ -265,14 +266,13 @@ const styles = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 7,
|
||||
font: 'inherit'
|
||||
gap: 'var(--space-2)'
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
minHeight: 40,
|
||||
padding: '9px 11px',
|
||||
minHeight: 'var(--control-height)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
border: '1px solid var(--border-control)',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
outline: 'none',
|
||||
@@ -282,14 +282,14 @@ const styles = {
|
||||
},
|
||||
label: {
|
||||
display: 'grid',
|
||||
gap: 7,
|
||||
gap: 'var(--space-2)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
fontSize: 'var(--font-body)',
|
||||
fontWeight: 650
|
||||
},
|
||||
muted: {
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 13,
|
||||
fontSize: 'var(--font-body)',
|
||||
lineHeight: 1.55
|
||||
}
|
||||
} as const
|
||||
@@ -1524,12 +1524,7 @@ function GraphView({
|
||||
useState<KnowledgeGraphRelation | 'new'>()
|
||||
const [mergeTargetId, setMergeTargetId] = useState('')
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [draggingNode, setDraggingNode] = useState<{
|
||||
id: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}>()
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [relationsExpanded, setRelationsExpanded] = useState(false)
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => new Map(graphNodes.map((node) => [node.id, node])),
|
||||
@@ -1554,10 +1549,14 @@ function GraphView({
|
||||
() => new Set(visibleNodes.map((node) => node.id)),
|
||||
[visibleNodes]
|
||||
)
|
||||
const visibleRelations = graphRelations.filter(
|
||||
(relation) =>
|
||||
visibleIds.has(relation.sourceId) &&
|
||||
visibleIds.has(relation.targetId)
|
||||
const visibleRelations = useMemo(
|
||||
() =>
|
||||
graphRelations.filter(
|
||||
(relation) =>
|
||||
visibleIds.has(relation.sourceId) &&
|
||||
visibleIds.has(relation.targetId)
|
||||
),
|
||||
[graphRelations, visibleIds]
|
||||
)
|
||||
const selectedNode = selectedNodeId
|
||||
? nodeMap.get(selectedNodeId)
|
||||
@@ -1577,21 +1576,11 @@ function GraphView({
|
||||
selectedEvidenceIds.has(item.id)
|
||||
)
|
||||
|
||||
const pointerPosition = (
|
||||
event: React.PointerEvent<SVGElement>
|
||||
): { x: number; y: number } | undefined => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) {
|
||||
return undefined
|
||||
}
|
||||
const rect = svg.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * 900 / zoom,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 560 / zoom
|
||||
}
|
||||
const selectNode = (nodeId: string): void => {
|
||||
setSelectedNodeId(nodeId)
|
||||
setCreatingEntity(false)
|
||||
setEditingEntity(false)
|
||||
setRelationForm(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1608,7 +1597,7 @@ function GraphView({
|
||||
style={{
|
||||
...styles.surface,
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
@@ -1647,6 +1636,27 @@ function GraphView({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="选择图谱实体"
|
||||
className="knowledge-graph__entity-picker"
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.value) {
|
||||
selectNode(event.currentTarget.value)
|
||||
}
|
||||
}}
|
||||
value={
|
||||
selectedNodeId && visibleIds.has(selectedNodeId)
|
||||
? selectedNodeId
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<option value="">选择实体</option>
|
||||
{visibleNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label} · {node.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
@@ -1673,10 +1683,10 @@ function GraphView({
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="knowledge-graph__zoom"
|
||||
style={{
|
||||
minWidth: 42,
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 12
|
||||
color: 'var(--text-muted)'
|
||||
}}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
@@ -1710,153 +1720,50 @@ function GraphView({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
aria-label="实体关系图"
|
||||
onPointerMove={(event) => {
|
||||
if (!draggingNode) {
|
||||
return
|
||||
}
|
||||
const point = pointerPosition(event)
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
onMoveNode(draggingNode.id, {
|
||||
x: Math.max(38, Math.min(862, point.x - draggingNode.offsetX)),
|
||||
y: Math.max(28, Math.min(532, point.y - draggingNode.offsetY))
|
||||
})
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (draggingNode) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDraggingNode(undefined)
|
||||
}
|
||||
}}
|
||||
ref={svgRef}
|
||||
role="img"
|
||||
className="knowledge-graph__svg"
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--surface-subtle)',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
viewBox={`0 0 ${900 / zoom} ${560 / zoom}`}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="knowledge-arrow"
|
||||
markerHeight="7"
|
||||
markerWidth="7"
|
||||
orient="auto-start-reverse"
|
||||
refX="17"
|
||||
refY="3.5"
|
||||
<>
|
||||
<KnowledgeGraphChart
|
||||
nodes={visibleNodes}
|
||||
onMoveNode={onMoveNode}
|
||||
onSelectNode={selectNode}
|
||||
onZoomChange={setZoom}
|
||||
relations={visibleRelations}
|
||||
selectedNodeId={selectedNodeId}
|
||||
zoom={zoom}
|
||||
/>
|
||||
{visibleRelations.length > 0 && (
|
||||
<details
|
||||
className="knowledge-graph__accessible-surface"
|
||||
onToggle={(event) =>
|
||||
setRelationsExpanded(event.currentTarget.open)
|
||||
}
|
||||
open={relationsExpanded}
|
||||
>
|
||||
<polygon
|
||||
fill="var(--text-muted)"
|
||||
points="0 0, 7 3.5, 0 7"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
{visibleRelations.map((relation) => {
|
||||
const source = nodeMap.get(relation.sourceId)
|
||||
const target = nodeMap.get(relation.targetId)
|
||||
if (!source || !target) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<g key={relation.id}>
|
||||
<line
|
||||
markerEnd="url(#knowledge-arrow)"
|
||||
stroke="var(--text-muted)"
|
||||
strokeWidth="1.5"
|
||||
x1={source.x}
|
||||
x2={target.x}
|
||||
y1={source.y}
|
||||
y2={target.y}
|
||||
/>
|
||||
<text
|
||||
fill="var(--text-secondary)"
|
||||
fontSize="11"
|
||||
textAnchor="middle"
|
||||
x={(source.x + target.x) / 2}
|
||||
y={(source.y + target.y) / 2 - 6}
|
||||
<summary>
|
||||
可见关系 {visibleRelations.length} 条
|
||||
</summary>
|
||||
{relationsExpanded && (
|
||||
<ul
|
||||
aria-label="可见关系列表"
|
||||
className="knowledge-graph__relation-list"
|
||||
>
|
||||
{relation.type}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{visibleNodes.map((node) => {
|
||||
const selected = selectedNodeId === node.id
|
||||
return (
|
||||
<g
|
||||
aria-label={`实体 ${node.label}`}
|
||||
key={node.id}
|
||||
onClick={() => {
|
||||
setSelectedNodeId(node.id)
|
||||
setCreatingEntity(false)
|
||||
setEditingEntity(false)
|
||||
setRelationForm(undefined)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
const point = pointerPosition(event)
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
event.currentTarget.ownerSVGElement?.setPointerCapture(
|
||||
event.pointerId
|
||||
)
|
||||
setDraggingNode({
|
||||
id: node.id,
|
||||
offsetX: point.x - node.x,
|
||||
offsetY: point.y - node.y
|
||||
})
|
||||
}}
|
||||
role="button"
|
||||
style={{ cursor: 'grab', outline: 'none' }}
|
||||
tabIndex={0}
|
||||
transform={`translate(${node.x} ${node.y})`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
setSelectedNodeId(node.id)
|
||||
setCreatingEntity(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
fill={
|
||||
selected
|
||||
? 'var(--accent-selected)'
|
||||
: 'var(--accent-subtle)'
|
||||
}
|
||||
r={selected ? 30 : 26}
|
||||
stroke={
|
||||
selected ? 'var(--accent)' : 'var(--accent-hover)'
|
||||
}
|
||||
strokeWidth={selected ? 3 : 2}
|
||||
/>
|
||||
<text
|
||||
fill="var(--text-primary)"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
y="4"
|
||||
>
|
||||
{node.label.length > 8
|
||||
? `${node.label.slice(0, 8)}…`
|
||||
: node.label}
|
||||
</text>
|
||||
<text
|
||||
fill="var(--text-secondary)"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
y="44"
|
||||
>
|
||||
{node.type}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
{visibleRelations.map((relation) => (
|
||||
<li key={relation.id}>
|
||||
<span>
|
||||
{nodeMap.get(relation.sourceId)?.label}
|
||||
</span>
|
||||
<ArrowRight aria-hidden="true" size={12} />
|
||||
<strong>{relation.type}</strong>
|
||||
<ArrowRight aria-hidden="true" size={12} />
|
||||
<span>
|
||||
{nodeMap.get(relation.targetId)?.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4570,6 +4570,7 @@ details.settings-section > :not(summary) {
|
||||
.knowledge-workspace {
|
||||
width: 100%;
|
||||
min-height: max(520px, calc(100dvh - 114px));
|
||||
font-size: var(--font-body);
|
||||
grid-template-columns: clamp(280px, 24vw, 340px) minmax(0, 1fr);
|
||||
container-type: inline-size;
|
||||
}
|
||||
@@ -4615,9 +4616,14 @@ details.settings-section > :not(summary) {
|
||||
|
||||
.knowledge-workspace__header h2 {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-page-title);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.knowledge-workspace h3 {
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.knowledge-workspace__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4684,7 +4690,7 @@ details.settings-section > :not(summary) {
|
||||
.knowledge-graph {
|
||||
display: grid;
|
||||
min-height: clamp(500px, calc(100dvh - 260px), 780px);
|
||||
gap: 14px;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -4700,10 +4706,10 @@ details.settings-section > :not(summary) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-subtle);
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-graph__search {
|
||||
@@ -4716,8 +4722,70 @@ details.settings-section > :not(summary) {
|
||||
flex: 0 1 150px;
|
||||
}
|
||||
|
||||
.knowledge-graph__svg {
|
||||
.knowledge-graph__entity-picker {
|
||||
width: 180px;
|
||||
min-height: var(--control-height);
|
||||
flex: 0 1 180px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.knowledge-graph__zoom {
|
||||
font-size: var(--font-body);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-graph__chart {
|
||||
width: 100%;
|
||||
min-height: clamp(420px, calc(100dvh - 330px), 720px);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.knowledge-graph__accessible-surface {
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.knowledge-graph__relation-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.knowledge-graph__accessible-surface > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.knowledge-graph__accessible-surface[open] > summary {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-graph__relation-list li {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-graph__relation-list strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.knowledge-graph__detail {
|
||||
@@ -4831,7 +4899,8 @@ details.settings-section > :not(summary) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.knowledge-graph__filter {
|
||||
.knowledge-graph__filter,
|
||||
.knowledge-graph__entity-picker {
|
||||
width: min(180px, 100%);
|
||||
flex-basis: 180px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user