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

This commit is contained in:
lofyer
2026-08-07 01:56:32 +08:00
parent b8fc7bc86e
commit 4100911c34
23 changed files with 1779 additions and 404 deletions
+16 -14
View File
@@ -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:
+70 -17
View File
@@ -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,
+3 -17
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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 =
+88
View File
@@ -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
View File
@@ -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>
+488
View File
@@ -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"
/>
)
}
+253 -18
View File
@@ -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 })
)
+87 -180
View File
@@ -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>
+74 -5
View File
@@ -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;
}