feat: expand multimodal and knowledge workflows

This commit is contained in:
lofyer
2026-08-10 21:25:47 +08:00
parent 0fab985f28
commit ad79659308
38 changed files with 3036 additions and 2416 deletions
+67 -24
View File
@@ -436,6 +436,9 @@ const api: DesktopApi = {
},
context: {
selectFiles: vi.fn(async () => []),
addPastedImage: vi.fn(async () => {
throw new Error('not used')
}),
captureScreen: vi.fn(async () => {
throw new Error('not used')
}),
@@ -1446,48 +1449,82 @@ describe('App', () => {
)
})
it('lists capturable application windows vertically before capture', async () => {
vi.mocked(api.context.listWindows).mockResolvedValueOnce([
{ id: 'window-1', name: 'Visual Studio Code' },
{ id: 'window-2', name: 'Browser' },
{ id: 'window-3', name: 'Terminal' }
])
vi.mocked(api.context.captureWindow).mockResolvedValueOnce({
it('accepts pasted images without intercepting pasted text', async () => {
vi.mocked(api.context.addPastedImage).mockResolvedValueOnce({
id: '00000000-0000-4000-8000-000000000303',
name: '窗口-Browser.jpg',
name: '粘贴图片.jpg',
size: 120_000,
preview: '1280 × 800',
kind: 'image',
thumbnailUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'
})
const pastedImage = new File(
[Uint8Array.from([0x89, 0x50, 0x4e, 0x47])],
'pasted.png',
{ type: 'image/png' }
)
render(<App />)
fireEvent.click(await screen.findByLabelText('捕获应用窗口'))
const input = await screen.findByLabelText('向 GoodBuddy 提问')
expect(
fireEvent.paste(input, {
clipboardData: {
items: [
{
getAsFile: () => null,
kind: 'string',
type: 'text/plain'
}
]
}
})
).toBe(true)
expect(api.context.addPastedImage).not.toHaveBeenCalled()
const dialog = await screen.findByRole('dialog', {
name: '选择应用窗口'
fireEvent.paste(input, {
clipboardData: {
items: [
{
getAsFile: () => pastedImage,
kind: 'file',
type: 'image/png'
}
]
}
})
const list = within(dialog).getByLabelText('可捕获的应用窗口')
expect(list).toHaveClass('window-capture-dialog__list')
expect(within(list).getAllByRole('button')).toHaveLength(3)
fireEvent.click(
within(list).getByRole('button', { name: 'Browser' })
)
await waitFor(() =>
expect(api.context.captureWindow).toHaveBeenCalledWith('window-2')
expect(api.context.addPastedImage).toHaveBeenCalledWith({
data: Uint8Array.from([0x89, 0x50, 0x4e, 0x47]),
mimeType: 'image/png'
})
)
const composer = screen
.getByLabelText('向 GoodBuddy 提问')
.closest<HTMLElement>('.composer')
expect(api.context.addPastedImage).toHaveBeenCalledTimes(1)
expect(api.context.readClipboard).not.toHaveBeenCalled()
const composer = input.closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
if (!composer) {
return
}
expect(
await within(composer).findByText('窗口-Browser.jpg')
await within(composer).findByText('粘贴图片.jpg')
).toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '截取当前屏幕'
})
).not.toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '捕获应用窗口'
})
).not.toBeInTheDocument()
expect(
within(composer).queryByRole('button', {
name: '读取剪贴板'
})
).not.toBeInTheDocument()
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
@@ -2016,9 +2053,14 @@ describe('App', () => {
name: '工作模式:Ask · 只读问答'
})
).toBeInTheDocument()
expect(
within(conversationSettings).getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toHaveTextContent(/^Ask$/u)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
)
expect(
within(conversationSettings).getByRole('button', {
@@ -2256,6 +2298,7 @@ describe('App', () => {
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
)
expect(mode).toHaveTextContent(/^Execute$/u)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行任务' }
@@ -3219,7 +3262,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
+46 -129
View File
@@ -5,7 +5,6 @@ import {
ChevronDown,
CircleAlert,
CircleHelp,
ClipboardPaste,
Copy,
Download,
Edit3,
@@ -27,9 +26,7 @@ import {
Send,
Settings,
ShieldCheck,
MonitorUp,
PanelRightOpen,
PanelsTopLeft,
Sparkles,
Square,
Sun,
@@ -57,9 +54,9 @@ import type {
ContextAttachment,
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeSettings,
WindowCaptureOption
RuntimeSettings
} from '../../shared/contracts'
import { maximumPastedImageBytes } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
@@ -1052,6 +1049,7 @@ function ComposerMenuSelect<T extends string>({
onChange,
onOpenChange,
options,
triggerLabel,
value
}: {
ariaLabel: string
@@ -1063,6 +1061,7 @@ function ComposerMenuSelect<T extends string>({
onChange: (value: T) => void
onOpenChange: (open: boolean) => void
options: readonly ComposerMenuOption<T>[]
triggerLabel?: string
value: T
}): React.JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null)
@@ -1144,7 +1143,7 @@ function ComposerMenuSelect<T extends string>({
>
{icon}
<span className="model-button__label">
{selectedOption?.label}
{triggerLabel ?? selectedOption?.label}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
@@ -1423,10 +1422,6 @@ function App(): React.JSX.Element {
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
undefined
)
const [windowCaptureOptions, setWindowCaptureOptions] = useState<
WindowCaptureOption[]
>()
const [windowCaptureLoading, setWindowCaptureLoading] = useState(false)
const [knowledgeSnapshot, setKnowledgeSnapshot] = useState<KnowledgeSnapshot>({
libraries: [],
sources: [],
@@ -3999,31 +3994,6 @@ function App(): React.JSX.Element {
}
}
const openWindowCapture = async (): Promise<void> => {
setContextError(undefined)
setWindowCaptureLoading(true)
try {
setWindowCaptureOptions(
await window.goodbuddy.context.listWindows()
)
} catch (reason) {
setContextError(
reason instanceof Error ? reason.message : '读取应用窗口失败'
)
} finally {
setWindowCaptureLoading(false)
}
}
const captureSelectedWindow = async (
sourceId: string
): Promise<void> => {
setWindowCaptureOptions(undefined)
await addContext(() =>
window.goodbuddy.context.captureWindow(sourceId)
)
}
const removeAttachment = (attachmentId: string): void => {
void window.goodbuddy.context.remove(attachmentId)
updateAttachments((current) =>
@@ -5445,7 +5415,7 @@ function App(): React.JSX.Element {
runtime?.capability === 'image-generation'
? '描述你想生成的图片…'
: '给 GoodBuddy 发消息…'
}\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送`}
}\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本`}
ref={inputRef}
rows={3}
value={input}
@@ -5453,6 +5423,41 @@ function App(): React.JSX.Element {
onInput={(event) =>
resizeComposerTextarea(event.currentTarget)
}
onPaste={(event) => {
const imageItem = Array.from(
event.clipboardData.items
).find(
(item) =>
item.kind === 'file' &&
item.type.startsWith('image/')
)
if (!imageItem) {
return
}
const image = imageItem.getAsFile()
const mimeType =
image?.type === 'image/jpeg' ||
image?.type === 'image/png' ||
image?.type === 'image/webp'
? image.type
: undefined
event.preventDefault()
if (!image || !mimeType) {
setContextError(
'仅支持粘贴 JPEG、PNG 或 WebP 图片'
)
return
}
void addContext(async () => {
if (image.size > maximumPastedImageBytes) {
throw new Error('粘贴图片不能超过 12MB')
}
return window.goodbuddy.context.addPastedImage({
data: new Uint8Array(await image.arrayBuffer()),
mimeType
})
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
@@ -5480,30 +5485,6 @@ function App(): React.JSX.Element {
>
<Paperclip aria-hidden="true" size={18} />
</button>
<button
aria-label="读取剪贴板"
onClick={() =>
void addContext(() =>
window.goodbuddy.context.readClipboard()
)
}
title="添加剪贴板文本或图片"
type="button"
>
<ClipboardPaste aria-hidden="true" size={18} />
</button>
<button
aria-label="截取当前屏幕"
onClick={() =>
void addContext(() =>
window.goodbuddy.context.captureScreen()
)
}
title="截取当前屏幕"
type="button"
>
<MonitorUp aria-hidden="true" size={18} />
</button>
<button
aria-label={
voiceRecording
@@ -5539,15 +5520,6 @@ function App(): React.JSX.Element {
>
<Mic aria-hidden="true" size={18} />
</button>
<button
aria-label="捕获应用窗口"
disabled={windowCaptureLoading}
onClick={() => void openWindowCapture()}
title="选择一个应用或浏览器窗口,仅捕获当前画面"
type="button"
>
<PanelsTopLeft aria-hidden="true" size={18} />
</button>
</div>
{knowledgeSnapshot.libraries.length > 0 && (
<div className="knowledge-scope">
@@ -5627,6 +5599,11 @@ function App(): React.JSX.Element {
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={
effectiveWorkMode === 'execute'
? 'Execute'
: 'Ask'
}
value={effectiveWorkMode}
/>
<div className="runtime-picker">
@@ -6194,66 +6171,6 @@ function App(): React.JSX.Element {
</section>
</div>
)}
{windowCaptureOptions && (
<div
className="window-capture-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setWindowCaptureOptions(undefined)
}
}}
>
<section
aria-labelledby="window-capture-title"
aria-modal="true"
className="window-capture-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
setWindowCaptureOptions(undefined)
}
}}
role="dialog"
>
<div className="window-capture-dialog__header">
<div>
<strong id="window-capture-title"></strong>
<small></small>
</div>
<button
aria-label="关闭应用窗口选择"
className="icon-button"
onClick={() => setWindowCaptureOptions(undefined)}
type="button"
>
×
</button>
</div>
<div
aria-label="可捕获的应用窗口"
className="window-capture-dialog__list"
>
{windowCaptureOptions.map((source, index) => (
<button
autoFocus={index === 0}
key={source.id}
onClick={() => void captureSelectedWindow(source.id)}
type="button"
>
<PanelsTopLeft size={16} />
<span>{source.name}</span>
</button>
))}
</div>
<button
className="secondary-button"
onClick={() => setWindowCaptureOptions(undefined)}
type="button"
>
</button>
</section>
</div>
)}
<RightAssistantSidebar
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
+350 -358
View File
@@ -1,21 +1,18 @@
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'
Graph,
GraphEvent,
NodeEvent,
type GraphOptions,
type IElementDragEvent,
type IElementEvent,
type NodeData
} from '@antv/g6'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
KnowledgeGraphNode,
KnowledgeGraphRelation
} from '../../shared/contracts'
registerECharts([GraphChart, TooltipComponent, CanvasRenderer])
type ChartKnowledgeGraphNode = Omit<
KnowledgeGraphNode,
'aliases' | 'evidenceIds'
@@ -41,24 +38,20 @@ type KnowledgeGraphChartProps = {
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 graphErrorMessage(error: unknown): string {
return (
(error instanceof Error ? error.message : '图谱渲染失败')
.trim()
.slice(0, 500) || '图谱渲染失败'
)
}
function graphTypeStyles(nodes: readonly ChartKnowledgeGraphNode[]): Map<
string,
{ color: string; borderColor: string }
@@ -82,9 +75,7 @@ function graphRevision(
nodes: nodes.map((node) => [
node.id,
node.label,
node.type,
node.x,
node.y
node.type
]),
relations: relations.map((relation) => [
relation.id,
@@ -95,18 +86,33 @@ function graphRevision(
})
}
function createOption({
nodes,
relations,
selectedNodeId,
zoom
}: Pick<
KnowledgeGraphChartProps,
'nodes' | 'relations' | 'selectedNodeId' | 'zoom'
>): EChartsCoreOption {
type G6NodeMetadata = {
label: string
entityType: string
degree: number
size: number
fill: string
stroke: string
}
type G6EdgeMetadata = {
label: string
description?: string
}
function nodeMetadata(node: NodeData): G6NodeMetadata {
return node.data as G6NodeMetadata
}
function createPresentation(
nodes: readonly ChartKnowledgeGraphNode[],
relations: readonly ChartKnowledgeGraphRelation[]
): Pick<
GraphOptions,
'data' | 'layout' | 'node' | 'edge' | 'behaviors' | 'plugins'
> {
const textPrimary = readToken('--text-primary')
const textSecondary = readToken('--text-secondary')
const textMuted = readToken('--text-muted')
const accent = readToken('--accent')
const accentSubtle = readToken('--accent-subtle')
const surfaceRaised = readToken('--surface-raised')
@@ -125,168 +131,162 @@ function createOption({
)
}
const maximumDegree = Math.max(1, ...degreeByNodeId.values())
const keyNodeCount = Math.min(
nodes.length,
Math.max(8, Math.min(16, Math.round(Math.sqrt(nodes.length) * 1.4)))
)
const keyNodeIds = new Set(
[...nodes]
.sort((left, right) => {
const degreeDifference =
(degreeByNodeId.get(right.id) ?? 0) -
(degreeByNodeId.get(left.id) ?? 0)
return (
degreeDifference ||
left.label.localeCompare(right.label, 'zh-CN')
)
})
.slice(0, keyNodeCount)
.map((node) => node.id)
)
const reducedMotion =
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const showEdgeLabels =
nodes.length <= 18 && relations.length <= 24
return {
animation: !reducedMotion,
animationDuration: 220,
animationDurationUpdate: 160,
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 ?? '关系'
data: {
nodes: nodes.map((node) => {
const typeStyle = typeStyles.get(node.type) ?? {
color: accentSubtle,
borderColor: accent
}
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(480, 220 + nodes.length * 2)
: 200,
gravity: 0.06,
edgeLength: dense ? [70, 130] : [90, 150],
friction: 0.08,
layoutAnimation: !reducedMotion
},
selectedMode: 'single',
symbol: 'circle',
categories: [...typeStyles.entries()].map(([name, style]) => ({
name,
itemStyle: style
})),
data: nodes.map((node) => {
const selected = node.id === selectedNodeId
const typeStyle = typeStyles.get(node.type) ?? {
color: accentSubtle,
borderColor: accent
}
const degree = degreeByNodeId.get(node.id) ?? 0
const degreeRatio = Math.sqrt(degree / maximumDegree)
const symbolSize = dense
? 16 + degreeRatio * 16
: 32 + degreeRatio * 16
const showLabel = !dense || keyNodeIds.has(node.id)
return {
id: node.id,
name: node.label,
type: node.type,
value: degree,
category: node.type,
const degree = degreeByNodeId.get(node.id) ?? 0
const degreeRatio = Math.sqrt(degree / maximumDegree)
const size = dense
? 16 + degreeRatio * 16
: 32 + degreeRatio * 16
return {
id: node.id,
data: {
label:
node.label.length > 12
? `${node.label.slice(0, 12)}`
: node.label,
entityType: node.type,
degree,
size,
fill: typeStyle.color,
stroke: typeStyle.borderColor
} satisfies G6NodeMetadata,
style: {
x: node.x,
y: node.y,
draggable: true,
selected,
symbolSize: selected ? symbolSize + 4 : symbolSize,
itemStyle: {
color: typeStyle.color,
borderColor: selected ? accent : typeStyle.borderColor,
borderWidth: selected ? 2.5 : 1.5
},
label: {
show: showLabel || selected,
color: textPrimary,
fontSize: dense ? 11 : 12,
fontWeight: keyNodeIds.has(node.id) ? 650 : 500,
position: dense ? 'right' : 'inside',
distance: dense ? 5 : 0,
formatter:
node.label.length > 8
? `${node.label.slice(0, 8)}`
: node.label
},
emphasis: {
focus: 'adjacency',
itemStyle: {
borderColor: accent,
borderWidth: 2.5
},
label: {
show: true
}
},
select: {
itemStyle: {
color: typeStyle.color,
borderColor: accent,
borderWidth: 2.5
},
label: {
show: true
}
}
y: node.y
}
}),
links: relations.map((relation) => ({
id: relation.id,
source: relation.sourceId,
target: relation.targetId,
value: relation.type,
description: relation.description,
lineStyle: {
color: borderDefault,
width: 1.2,
opacity: 0.72,
curveness: 0.06
}
})),
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 6,
edgeLabel: {
show: showEdgeLabels,
color: textSecondary,
fontSize: 11,
formatter: (params: { data?: { value?: string } }) =>
params.data?.value ?? ''
}
}),
edges: relations.map((relation) => ({
id: relation.id,
source: relation.sourceId,
target: relation.targetId,
data: {
label: relation.type,
description: relation.description
} satisfies G6EdgeMetadata
}))
},
layout: {
type: 'circular',
animate: false,
ordering: 'topology',
startAngle: -Math.PI / 2,
endAngle: Math.PI * 1.5,
clockwise: true,
divisions: 1,
angleRatio: 1,
nodeSize: (datum: Record<string, unknown>) => {
const metadata = datum.data as G6NodeMetadata | undefined
return metadata?.size ?? 24
},
nodeSpacing: dense ? 20 : 28
},
node: {
type: 'circle',
style: {
size: (datum) => nodeMetadata(datum).size,
fill: (datum) => nodeMetadata(datum).fill,
stroke: (datum) => nodeMetadata(datum).stroke,
lineWidth: 1.5,
label: true,
labelText: (datum) => nodeMetadata(datum).label,
labelFill: textPrimary,
labelFontSize: dense ? 11 : 12,
labelFontWeight: 550,
labelPlacement: 'right',
labelOffsetX: 6,
labelMaxWidth: 120
},
state: {
active: {
lineWidth: 2.5,
stroke: accent,
label: true
},
lineStyle: {
color: textMuted
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 3
selected: {
lineWidth: 3,
stroke: accent,
halo: true,
haloStroke: accent,
haloLineWidth: 6,
haloOpacity: 0.18,
label: true
}
},
animation: false
},
edge: {
type: 'line',
style: {
stroke: borderDefault,
lineWidth: 1.2,
opacity: 0.72,
endArrow: true,
endArrowSize: 6,
label: showEdgeLabels,
labelText: (datum) =>
String((datum.data as G6EdgeMetadata | undefined)?.label ?? ''),
labelFill: textSecondary,
labelFontSize: 11,
labelBackground: true,
labelBackgroundFill: surfaceRaised,
labelPadding: [2, 4]
},
state: {
active: {
stroke: accent,
lineWidth: 2,
opacity: 1
}
},
animation: false
},
behaviors: [
'drag-canvas',
'zoom-canvas',
'drag-element',
{
type: 'auto-adapt-label',
sortNode: { type: 'degree' },
padding: 4,
throttle: 80
}
],
plugins: [
{
type: 'tooltip',
enable: (event: IElementEvent) =>
event.targetType === 'node' || event.targetType === 'edge',
getContent: (
event: IElementEvent,
items: Array<{ data?: Record<string, unknown> }>
) => {
const content = document.createElement('div')
const datum = items[0]
if (event.targetType === 'node') {
const metadata = datum?.data as
| G6NodeMetadata
| undefined
content.textContent = [metadata?.label, metadata?.entityType]
.filter(Boolean)
.join(' · ')
} else {
const metadata = datum?.data as
| G6EdgeMetadata
| undefined
content.textContent =
metadata?.description || metadata?.label || '关系'
}
return content
}
}
]
@@ -303,27 +303,36 @@ export function KnowledgeGraphChart({
onZoomChange
}: KnowledgeGraphChartProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(null)
const graphRef = useRef<Graph | null>(null)
const onMoveNodeRef = useRef(onMoveNode)
const onSelectNodeRef = useRef(onSelectNode)
const onZoomChangeRef = useRef(onZoomChange)
const nodesRef = useRef(nodes)
const relationsRef = useRef(relations)
const dragRef = useRef<NodeDrag | undefined>(undefined)
const viewportRef = useRef<GraphViewport>({})
const selectedNodeIdRef = useRef(selectedNodeId)
const zoomRef = useRef(zoom)
const appliedZoomRef = useRef<number | undefined>(undefined)
const renderVersionRef = useRef(0)
const renderedRevisionRef = useRef<string | undefined>(undefined)
const pendingRenderRef = useRef<
{ graph: Graph; promise: Promise<void> } | undefined
>(undefined)
const dataRevision = useMemo(
() => graphRevision(nodes, relations),
[nodes, relations]
)
const [themeRevision, setThemeRevision] = useState(0)
const [renderError, setRenderError] = useState<string>()
useEffect(() => {
nodesRef.current = nodes
relationsRef.current = relations
}, [nodes, relations])
useEffect(() => {
selectedNodeIdRef.current = selectedNodeId
}, [selectedNodeId])
useEffect(() => {
onMoveNodeRef.current = onMoveNode
onSelectNodeRef.current = onSelectNode
@@ -354,131 +363,56 @@ export function KnowledgeGraphChart({
return
}
const chart = init(container, undefined, { renderer: 'canvas' })
chartRef.current = chart
const graph = new Graph({
container,
animation: false,
autoFit: {
type: 'view',
options: {
when: 'overflow',
direction: 'both'
},
animation: false
},
padding: 40,
zoom: zoomRef.current,
zoomRange: [0.5, 2]
})
graphRef.current = graph
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 selectNode = (event: IElementEvent): void => {
onSelectNodeRef.current(String(event.target.id))
}
const beginNodeDrag = (event: ECElementEvent): void => {
const data = event.data as { id?: unknown } | undefined
const pointerEvent = event.event
const persistNodePosition = (event: IElementDragEvent): void => {
const id = String(event.target.id)
const position = graph.getElementPosition(id)
if (
event.dataType !== 'node' ||
typeof data?.id !== 'string' ||
!pointerEvent ||
!Number.isFinite(pointerEvent.offsetX) ||
!Number.isFinite(pointerEvent.offsetY)
!Number.isFinite(position[0]) ||
!Number.isFinite(position[1])
) {
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
onMoveNodeRef.current(id, {
x: Number(position[0]),
y: Number(position[1])
})
}
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 = {}
const nextZoom = graph.getZoom()
if (
'center' in series &&
Array.isArray(series.center) &&
series.center.length === 2 &&
series.center.every(
(value: unknown) =>
typeof value === 'number' || typeof value === 'string'
)
Number.isFinite(nextZoom) &&
Math.abs(nextZoom - zoomRef.current) >= 0.001
) {
nextViewport.center = [
series.center[0] as number | string,
series.center[1] as number | string
]
zoomRef.current = nextZoom
appliedZoomRef.current = nextZoom
onZoomChangeRef.current(nextZoom)
}
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()
const resize = (): void => graph.resize()
chart.on('click', selectNode)
chart.on('mousedown', beginNodeDrag)
chart.on('mouseup', persistNodePosition)
chart.on('graphRoam', persistViewport)
graph.on(NodeEvent.CLICK, selectNode)
graph.on(NodeEvent.DRAG_END, persistNodePosition)
graph.on(GraphEvent.AFTER_TRANSFORM, persistViewport)
let resizeObserver: ResizeObserver | undefined
if (typeof ResizeObserver === 'function') {
@@ -489,48 +423,97 @@ export function KnowledgeGraphChart({
}
return () => {
renderVersionRef.current += 1
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
graph.off(NodeEvent.CLICK, selectNode)
graph.off(NodeEvent.DRAG_END, persistNodePosition)
graph.off(GraphEvent.AFTER_TRANSFORM, persistViewport)
const pendingRender = pendingRenderRef.current
if (pendingRender?.graph === graph) {
void pendingRender.promise.finally(() => graph.destroy())
} else {
graph.destroy()
}
graphRef.current = null
}
}, [])
useEffect(() => {
const chart = chartRef.current
if (!chart) {
const graph = graphRef.current
if (!graph) {
return
}
const option = createOption({
nodes: nodesRef.current,
relations: relationsRef.current,
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 }
const presentation = createPresentation(
nodesRef.current,
relationsRef.current
)
appliedZoomRef.current = zoomRef.current
graph.setOptions({
...presentation,
animation: false,
autoFit: {
type: 'view',
options: {
when: 'overflow',
direction: 'both'
},
animation: false
},
padding: 40,
zoomRange: [0.5, 2]
})
const renderVersion = ++renderVersionRef.current
setRenderError(undefined)
const renderPromise = graph
.render()
.then(async () => {
if (
graphRef.current !== graph ||
renderVersionRef.current !== renderVersion
) {
return
}
renderedRevisionRef.current = dataRevision
appliedZoomRef.current = graph.getZoom()
const states = Object.fromEntries(
nodesRef.current.map((node) => [
node.id,
node.id === selectedNodeIdRef.current ? ['selected'] : []
])
)
await graph.setElementState(states, false)
})
.catch((error: unknown) => {
if (
graphRef.current === graph &&
renderVersionRef.current === renderVersion
) {
setRenderError(
graphErrorMessage(error)
)
}
})
pendingRenderRef.current = {
graph,
promise: renderPromise
}
void renderPromise.finally(() => {
if (
pendingRenderRef.current?.graph === graph &&
renderVersionRef.current === renderVersion
) {
pendingRenderRef.current = undefined
}
})
}, [dataRevision, themeRevision])
useEffect(() => {
const chart = chartRef.current
if (!chart) {
const graph = graphRef.current
if (
!graph ||
renderedRevisionRef.current === undefined ||
renderedRevisionRef.current !== dataRevision
) {
return
}
if (
@@ -539,39 +522,48 @@ export function KnowledgeGraphChart({
) {
return
}
chart.setOption({
series: [{ zoom }]
void graph.zoomTo(zoom, false).catch((error: unknown) => {
if (graphRef.current === graph) {
setRenderError(graphErrorMessage(error))
}
})
appliedZoomRef.current = zoom
}, [zoom])
}, [dataRevision, zoom])
useEffect(() => {
const chart = chartRef.current
if (!chart) {
const graph = graphRef.current
if (
!graph ||
renderedRevisionRef.current !== dataRevision
) {
return
}
chart.dispatchAction({
type: 'unselect',
seriesIndex: 0
const states = Object.fromEntries(
nodesRef.current.map((node) => [
node.id,
node.id === selectedNodeId ? ['selected'] : []
])
)
void graph.setElementState(states, false).catch((error: unknown) => {
if (graphRef.current === graph) {
setRenderError(graphErrorMessage(error))
}
})
const dataIndex = selectedNodeId
? nodesRef.current.findIndex((node) => node.id === selectedNodeId)
: -1
if (dataIndex >= 0) {
chart.dispatchAction({
type: 'select',
seriesIndex: 0,
dataIndex
})
}
}, [dataRevision, selectedNodeId, themeRevision])
}, [dataRevision, selectedNodeId])
return (
<div
aria-label="实体关系图"
className="knowledge-graph__chart"
ref={containerRef}
role="img"
/>
<div className="knowledge-graph__chart-shell">
<div
aria-label="实体关系图"
className="knowledge-graph__chart"
ref={containerRef}
role="img"
/>
{renderError && (
<div className="knowledge-graph__chart-error" role="alert">
{renderError}
</div>
)}
</div>
)
}
+185 -177
View File
@@ -13,37 +13,46 @@ import {
type KnowledgeWorkspaceProps
} from './KnowledgeWorkspace'
const echartsMock = vi.hoisted(() => {
const g6Mock = 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 }]
})),
const graph = {
destroy: vi.fn(),
draw: vi.fn(async () => undefined),
getElementPosition: vi.fn(() => [240, 320]),
getZoom: vi.fn(() => 1),
off: vi.fn((eventName: string) => handlers.delete(eventName)),
on: vi.fn((eventName: string, handler: (event: unknown) => void) => {
handlers.set(eventName, handler)
}),
render: vi.fn(async () => undefined),
resize: vi.fn(),
setOption: vi.fn()
setData: vi.fn(),
setEdge: vi.fn(),
setElementState: vi.fn(async () => undefined),
setLayout: vi.fn(),
setNode: vi.fn(),
setOptions: vi.fn(),
zoomTo: vi.fn(async () => undefined)
}
return {
chart,
graph,
handlers,
init: vi.fn(() => chart),
use: vi.fn()
Graph: vi.fn(function () {
return graph
})
}
})
vi.mock('echarts/core', () => ({
init: echartsMock.init,
use: echartsMock.use
vi.mock('@antv/g6', () => ({
Graph: g6Mock.Graph,
GraphEvent: {
AFTER_TRANSFORM: 'aftertransform'
},
NodeEvent: {
CLICK: 'node:click',
DRAG_END: 'node:dragend'
}
}))
vi.mock('echarts/charts', () => ({ GraphChart: {} }))
vi.mock('echarts/components', () => ({ TooltipComponent: {} }))
vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} }))
const library: KnowledgeWorkspaceProps['libraries'][number] = {
id: 'library-1',
@@ -154,7 +163,9 @@ describe('KnowledgeWorkspace', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
echartsMock.handlers.clear()
g6Mock.handlers.clear()
g6Mock.graph.getZoom.mockReturnValue(1)
g6Mock.graph.getElementPosition.mockReturnValue([240, 320])
})
it('creates a configured knowledge library', async () => {
@@ -215,10 +226,17 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(screen.getByLabelText('实体关系图')).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: //u })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByLabelText('图谱拓扑')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
expect(
screen.getByRole('tab', { name: '详情' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByLabelText('实体详情')).toBeInTheDocument()
expect(screen.getByText('跨平台 AI 桌面助手')).toBeInTheDocument()
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
@@ -319,8 +337,8 @@ describe('KnowledgeWorkspace', () => {
expect(
screen.getByRole('option', { name: 'Electron · 技术' })
).toBeInTheDocument()
fireEvent.click(screen.getByText('可见关系 1 条'))
expect(await screen.findByText('使用')).toBeInTheDocument()
expect(screen.getByText('可见关系')).toBeInTheDocument()
expect(screen.getByText('使用')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
target: { value: 'Electron' }
@@ -329,20 +347,17 @@ describe('KnowledgeWorkspace', () => {
screen.queryByRole('option', { name: 'GoodBuddy · 产品' })
).not.toBeInTheDocument()
expect(screen.queryByText('使用')).not.toBeInTheDocument()
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
series: [
expect.objectContaining({
data: [
expect.objectContaining({
id: 'entity-2'
})
],
links: []
})
]
}),
{ notMerge: true }
data: {
nodes: [
expect.objectContaining({
id: 'entity-2'
})
],
edges: []
}
})
)
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
@@ -419,7 +434,7 @@ describe('KnowledgeWorkspace', () => {
)
})
it('manages the graph chart, zoom, selection, movement, and cleanup', () => {
it('manages the G6 graph, zoom, selection, movement, and cleanup', async () => {
const onMoveNode = vi.fn()
const { rerender, unmount } = render(
<KnowledgeWorkspace {...createProps({ onMoveNode })} />
@@ -428,100 +443,102 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
const graph = screen.getByLabelText('实体关系图')
expect(graph).toHaveClass('knowledge-graph__chart')
expect(echartsMock.init).toHaveBeenCalledWith(
graph,
undefined,
{ renderer: 'canvas' }
)
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
expect(g6Mock.Graph).toHaveBeenCalledWith(
expect.objectContaining({
series: [
expect.objectContaining({
categories: expect.arrayContaining([
expect.objectContaining({ name: '产品' }),
expect.objectContaining({ name: '技术' })
]),
layout: 'force',
symbol: 'circle',
type: 'graph',
data: expect.arrayContaining([
expect.objectContaining({
category: '产品',
id: 'entity-1',
name: 'GoodBuddy'
})
]),
force: expect.objectContaining({
edgeLength: [90, 150],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 200
}),
links: [
expect.objectContaining({
id: 'relation-1',
value: '使用'
})
]
})
]
}),
{ notMerge: true }
container: graph,
zoomRange: [0.5, 2]
})
)
const stableOptionCallCount =
echartsMock.chart.setOption.mock.calls.length
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
data: {
nodes: expect.arrayContaining([
expect.objectContaining({
id: 'entity-1',
data: expect.objectContaining({
entityType: '产品',
label: 'GoodBuddy'
})
})
]),
edges: [
expect.objectContaining({
id: 'relation-1',
data: expect.objectContaining({
label: '使用'
})
})
]
},
layout: expect.objectContaining({
animate: false,
angleRatio: 1,
ordering: 'topology',
type: 'circular'
}),
behaviors: expect.arrayContaining([
'drag-canvas',
'zoom-canvas',
'drag-element',
expect.objectContaining({ type: 'auto-adapt-label' })
])
})
)
const graphOptions = g6Mock.graph.setOptions.mock.lastCall?.[0]
expect(graphOptions?.behaviors).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'hover-activate' })
])
)
expect(graphOptions?.node.state).not.toHaveProperty('inactive')
expect(graphOptions?.edge.state).not.toHaveProperty('inactive')
await waitFor(() => expect(g6Mock.graph.render).toHaveBeenCalledTimes(1))
expect(g6Mock.graph.setElementState).toHaveBeenCalledWith(
{
'entity-1': [],
'entity-2': []
},
false
)
const stableRenderCallCount = g6Mock.graph.render.mock.calls.length
rerender(<KnowledgeWorkspace {...createProps({ onMoveNode })} />)
expect(echartsMock.chart.setOption).toHaveBeenCalledTimes(
stableOptionCallCount
expect(g6Mock.graph.render).toHaveBeenCalledTimes(stableRenderCallCount)
const movedGraphNodes = createProps().graphNodes.map((node) =>
node.id === 'entity-1' ? { ...node, x: 240, y: 320 } : node
)
rerender(
<KnowledgeWorkspace
{...createProps({ graphNodes: movedGraphNodes, onMoveNode })}
/>
)
expect(g6Mock.graph.render).toHaveBeenCalledTimes(stableRenderCallCount)
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
expect(screen.getByText('115%')).toBeInTheDocument()
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
expect.objectContaining({
series: [
expect.objectContaining({
zoom: 1.15
})
]
})
)
expect(g6Mock.graph.zoomTo).toHaveBeenLastCalledWith(1.15, false)
act(() => {
echartsMock.handlers.get('click')?.({
dataType: 'node',
data: { id: 'entity-1' }
g6Mock.handlers.get('node:click')?.({
target: { id: 'entity-1' },
targetType: 'node'
})
})
expect(screen.getByLabelText('实体详情')).toBeInTheDocument()
expect(echartsMock.chart.dispatchAction).toHaveBeenCalledWith({
type: 'select',
seriesIndex: 0,
dataIndex: 0
})
await waitFor(() =>
expect(g6Mock.graph.setElementState).toHaveBeenCalledWith(
expect.objectContaining({
'entity-1': ['selected'],
'entity-2': []
}),
false
)
)
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 }
g6Mock.handlers.get('node:dragend')?.({
target: { id: 'entity-1' },
targetType: 'node'
})
})
expect(onMoveNode).toHaveBeenCalledWith('entity-1', {
@@ -529,67 +546,59 @@ describe('KnowledgeWorkspace', () => {
y: 320
})
fireEvent.click(screen.getByRole('button', { name: '查看 Electron' }))
fireEvent.click(
screen.getByRole('button', { name: 'Electron' })
)
expect(
screen.getByRole('heading', { name: 'Electron' })
).toBeInTheDocument()
unmount()
expect(echartsMock.chart.off).toHaveBeenCalledWith(
'click',
expect(g6Mock.graph.off).toHaveBeenCalledWith(
'node:click',
expect.any(Function)
)
expect(echartsMock.chart.off).toHaveBeenCalledWith(
'mousedown',
expect(g6Mock.graph.off).toHaveBeenCalledWith(
'node:dragend',
expect.any(Function)
)
expect(echartsMock.chart.off).toHaveBeenCalledWith(
'mouseup',
expect(g6Mock.graph.off).toHaveBeenCalledWith(
'aftertransform',
expect.any(Function)
)
expect(echartsMock.chart.off).toHaveBeenCalledWith(
'graphRoam',
expect.any(Function)
)
expect(echartsMock.chart.dispose).toHaveBeenCalled()
expect(g6Mock.graph.destroy).toHaveBeenCalled()
})
it('preserves the graph viewport and refreshes theme colors', async () => {
it('preserves the G6 instance 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 }]
})
g6Mock.graph.getZoom.mockReturnValueOnce(1.3)
act(() => {
echartsMock.handlers.get('graphRoam')?.({})
g6Mock.handlers.get('aftertransform')?.({})
})
await waitFor(() =>
expect(screen.getByText('130%')).toBeInTheDocument()
)
const renderCalls = g6Mock.graph.render.mock.calls.length
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 }
await waitFor(() =>
expect(g6Mock.graph.render.mock.calls.length).toBeGreaterThan(
renderCalls
)
)
expect(g6Mock.Graph).toHaveBeenCalledTimes(1)
const optionCalls = echartsMock.chart.setOption.mock.calls.length
const themeRenderCalls = g6Mock.graph.render.mock.calls.length
act(() => {
document.documentElement.dataset.theme = 'dark'
})
await waitFor(() =>
expect(echartsMock.chart.setOption.mock.calls.length).toBeGreaterThan(
optionCalls
expect(g6Mock.graph.render.mock.calls.length).toBeGreaterThan(
themeRenderCalls
)
)
delete document.documentElement.dataset.theme
@@ -626,45 +635,44 @@ describe('KnowledgeWorkspace', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith(
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
series: [
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({
id: 'entity-0',
symbolSize: 32,
x: 0,
y: 0,
label: expect.objectContaining({
position: 'right',
show: true
})
data: expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
id: 'entity-0',
data: expect.objectContaining({
degree: 2,
size: 32
}),
expect.objectContaining({
id: 'entity-29',
symbolSize: 16,
label: expect.objectContaining({ show: false })
style: expect.objectContaining({
x: 0,
y: 0
})
}),
expect.objectContaining({
id: 'entity-29',
data: expect.objectContaining({
degree: 0,
size: 16
})
]),
edgeLabel: expect.objectContaining({ show: false }),
force: expect.objectContaining({
edgeLength: [70, 130],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 280
})
])
}),
layout: expect.objectContaining({
animate: false,
nodeSpacing: 20,
ordering: 'topology',
type: 'circular'
}),
behaviors: expect.arrayContaining([
expect.objectContaining({
sortNode: { type: 'degree' },
type: 'auto-adapt-label'
})
]
}),
{ 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]).toHaveProperty('x', 0)
expect(option.series?.[0]?.data?.[0]).toHaveProperty('y', 0)
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
+217 -131
View File
@@ -4,7 +4,6 @@ import {
ArrowRight,
BookOpen,
Check,
ChevronRight,
CirclePause,
Database,
FilePlus2,
@@ -241,6 +240,7 @@ export type KnowledgeWorkspaceProps = {
}
type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings'
type GraphSidebarTab = 'topology' | 'details'
const storageModeLabels: Record<KnowledgeStorageMode, string> = {
reference: '引用原文件',
@@ -1831,6 +1831,75 @@ function KnowledgeTasksView({
)
}
function GraphRelationPath({
nodeMap,
onSelectNode,
relation
}: {
nodeMap: ReadonlyMap<string, KnowledgeGraphNode>
onSelectNode: (nodeId: string) => void
relation: KnowledgeGraphRelation
}): React.JSX.Element {
const source = nodeMap.get(relation.sourceId)
const target = nodeMap.get(relation.targetId)
return (
<div className="knowledge-graph__relation-path">
<button
className="knowledge-graph__entity-link"
disabled={!source}
onClick={() => source && onSelectNode(source.id)}
type="button"
>
{source?.label ?? '未知实体'}
</button>
<span className="knowledge-graph__relation-type">
<ArrowRight aria-hidden="true" size={13} />
{relation.type}
</span>
<button
className="knowledge-graph__entity-link"
disabled={!target}
onClick={() => target && onSelectNode(target.id)}
type="button"
>
{target?.label ?? '未知实体'}
</button>
</div>
)
}
function GraphSidebarNavigation({
onChange,
relationCount,
value
}: {
onChange: (value: GraphSidebarTab) => void
relationCount: number
value: GraphSidebarTab
}): React.JSX.Element {
return (
<PageTabs
ariaLabel="图谱侧栏"
idPrefix="knowledge-graph-sidebar"
onChange={onChange}
tabs={[
{
id: 'topology',
label: '拓扑',
count: relationCount
},
{
id: 'details',
label: '详情'
}
]}
value={value}
variant="segmented"
/>
)
}
function GraphView({
evidence,
graphNodes,
@@ -1873,7 +1942,8 @@ function GraphView({
useState<KnowledgeGraphRelation | 'new'>()
const [mergeTargetId, setMergeTargetId] = useState('')
const [zoom, setZoom] = useState(1)
const [relationsExpanded, setRelationsExpanded] = useState(false)
const [sidebarTab, setSidebarTab] =
useState<GraphSidebarTab>('topology')
const [reextracting, setReextracting] = useState(false)
const [reextractError, setReextractError] = useState<string>()
@@ -1929,19 +1999,14 @@ function GraphView({
const selectNode = (nodeId: string): void => {
setSelectedNodeId(nodeId)
setSidebarTab('details')
setCreatingEntity(false)
setEditingEntity(false)
setRelationForm(undefined)
}
return (
<div
className={
selectedNode || creatingEntity
? 'knowledge-graph knowledge-graph--with-details'
: 'knowledge-graph'
}
>
<div className="knowledge-graph knowledge-graph--with-details">
<section
aria-label="知识图谱画布"
className="knowledge-graph__canvas"
@@ -2029,6 +2094,7 @@ function GraphView({
onClick={() => {
setSelectedNodeId(undefined)
setCreatingEntity(true)
setSidebarTab('details')
}}
style={styles.button}
type="button"
@@ -2095,84 +2161,119 @@ function GraphView({
</div>
</div>
) : (
<>
<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}
>
<summary>
{visibleRelations.length}
</summary>
{relationsExpanded && (
<ul
aria-label="可见关系列表"
className="knowledge-graph__relation-list"
>
{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>
)}
</>
<KnowledgeGraphChart
nodes={visibleNodes}
onMoveNode={onMoveNode}
onSelectNode={selectNode}
onZoomChange={setZoom}
relations={visibleRelations}
selectedNodeId={selectedNodeId}
zoom={zoom}
/>
)}
</section>
{creatingEntity && (
{sidebarTab === 'topology' && (
<aside
aria-label="新增实体面板"
aria-label="图谱拓扑"
className="knowledge-graph__detail"
style={{
...styles.surface,
padding: 15,
overflowY: 'auto'
}}
>
<h3 style={{ marginTop: 0 }}></h3>
<EntityEditor
onCancel={() => setCreatingEntity(false)}
onSave={async (input) => {
await onCreateEntity(input)
setCreatingEntity(false)
}}
<GraphSidebarNavigation
onChange={setSidebarTab}
relationCount={visibleRelations.length}
value={sidebarTab}
/>
<section
aria-labelledby="knowledge-graph-sidebar-tab-topology"
className="knowledge-graph__detail-panel"
id="knowledge-graph-sidebar-panel-topology"
role="tabpanel"
>
<div className="knowledge-graph__panel-heading">
<div>
<h3></h3>
<p></p>
</div>
<span>{visibleRelations.length} </span>
</div>
{visibleRelations.length === 0 ? (
<p className="knowledge-graph__panel-empty">
</p>
) : (
<ul
aria-label="可见关系列表"
className="knowledge-graph__topology-list"
>
{visibleRelations.map((relation) => (
<li
className="knowledge-graph__relation-card"
key={relation.id}
>
<GraphRelationPath
nodeMap={nodeMap}
onSelectNode={selectNode}
relation={relation}
/>
{relation.description && (
<p>{relation.description}</p>
)}
</li>
))}
</ul>
)}
</section>
</aside>
)}
{selectedNode && (
{sidebarTab === 'details' && creatingEntity && (
<aside
aria-label="新增实体面板"
className="knowledge-graph__detail"
>
<GraphSidebarNavigation
onChange={setSidebarTab}
relationCount={visibleRelations.length}
value={sidebarTab}
/>
<section
aria-labelledby="knowledge-graph-sidebar-tab-details"
className="knowledge-graph__detail-panel"
id="knowledge-graph-sidebar-panel-details"
role="tabpanel"
>
<h3 style={{ marginTop: 0 }}></h3>
<EntityEditor
onCancel={() => {
setCreatingEntity(false)
setSidebarTab('topology')
}}
onSave={async (input) => {
await onCreateEntity(input)
setCreatingEntity(false)
setSidebarTab('topology')
}}
/>
</section>
</aside>
)}
{sidebarTab === 'details' && selectedNode && (
<aside
aria-label="实体详情"
className="knowledge-graph__detail"
style={{
...styles.surface,
padding: 15,
overflowY: 'auto'
}}
>
<GraphSidebarNavigation
onChange={setSidebarTab}
relationCount={visibleRelations.length}
value={sidebarTab}
/>
<section
aria-labelledby="knowledge-graph-sidebar-tab-details"
className="knowledge-graph__detail-panel"
id="knowledge-graph-sidebar-panel-details"
role="tabpanel"
>
<div
style={{
display: 'flex',
@@ -2190,7 +2291,10 @@ function GraphView({
<button
aria-label="关闭实体详情"
className="secondary-button"
onClick={() => setSelectedNodeId(undefined)}
onClick={() => {
setSelectedNodeId(undefined)
setSidebarTab('topology')
}}
style={{ ...styles.button, padding: 7 }}
type="button"
>
@@ -2219,7 +2323,7 @@ function GraphView({
{selectedNode.aliases?.join('、')}
</div>
)}
<div style={{ display: 'flex', gap: 7 }}>
<div className="knowledge-graph__entity-actions">
<button
className="secondary-button"
onClick={() => setEditingEntity(true)}
@@ -2250,11 +2354,7 @@ function GraphView({
}}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
className="knowledge-graph__section-heading"
>
<strong></strong>
<button
@@ -2286,46 +2386,23 @@ function GraphView({
/>
)}
<ul
style={{
display: 'grid',
gap: 8,
padding: 0,
listStyle: 'none'
}}
className="knowledge-graph__entity-relations"
>
{relatedRelations.map((relation) => {
const otherId =
relation.sourceId === selectedNode.id
? relation.targetId
: relation.sourceId
const other = nodeMap.get(otherId)
return (
<li
className="knowledge-graph__relation-card"
key={relation.id}
style={{ ...styles.surface, padding: 10 }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 13
}}
>
<span>{nodeMap.get(relation.sourceId)?.label}</span>
<ArrowRight
aria-label={relation.type}
size={13}
/>
<span>{nodeMap.get(relation.targetId)?.label}</span>
</div>
<div style={{ ...styles.muted, marginTop: 4 }}>
{relation.type}
{relation.description
? ` · ${relation.description}`
: ''}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 7 }}>
<GraphRelationPath
nodeMap={nodeMap}
onSelectNode={selectNode}
relation={relation}
/>
{relation.description && (
<p>{relation.description}</p>
)}
<div className="knowledge-graph__relation-actions">
<button
aria-label={`编辑关系 ${relation.type}`}
className="secondary-button"
@@ -2334,6 +2411,7 @@ function GraphView({
type="button"
>
<Pencil aria-hidden="true" size={13} />
</button>
<button
aria-label={`删除关系 ${relation.type}`}
@@ -2345,22 +2423,6 @@ function GraphView({
<Trash2 aria-hidden="true" size={13} />
</button>
{other && (
<button
className="secondary-button"
onClick={() => setSelectedNodeId(other.id)}
style={{
...styles.button,
minHeight: 30,
padding: '5px 8px',
marginLeft: 'auto'
}}
type="button"
>
{other.label}
<ChevronRight aria-hidden="true" size={13} />
</button>
)}
</div>
</li>
)
@@ -2368,7 +2430,7 @@ function GraphView({
</ul>
<strong></strong>
<div style={{ display: 'flex', gap: 7, marginTop: 8 }}>
<div className="knowledge-graph__merge">
<select
aria-label="选择合并目标"
onChange={(event) => setMergeTargetId(event.currentTarget.value)}
@@ -2477,6 +2539,30 @@ function GraphView({
))}
</ol>
)}
</section>
</aside>
)}
{sidebarTab === 'details' && !selectedNode && !creatingEntity && (
<aside
aria-label="图谱详情"
className="knowledge-graph__detail"
>
<GraphSidebarNavigation
onChange={setSidebarTab}
relationCount={visibleRelations.length}
value={sidebarTab}
/>
<section
aria-labelledby="knowledge-graph-sidebar-tab-details"
className="knowledge-graph__detail-panel"
id="knowledge-graph-sidebar-panel-details"
role="tabpanel"
>
<p className="knowledge-graph__panel-empty">
</p>
</section>
</aside>
)}
</div>
+91
View File
@@ -521,6 +521,47 @@ describe('SettingsPanel runtime files', () => {
).toBeNull()
})
it('places explicit configuration actions at the top of the content', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
const settings = screen.getByRole('region', {
name: '设置中心'
})
const content = screen.getByRole('tabpanel')
const toolbar = content.querySelector(
'.settings-panel__content-toolbar'
)
expect(toolbar).toBe(content.firstElementChild)
expect(
within(toolbar as HTMLElement).getByRole('button', {
name: '保存设置'
})
).toBeInTheDocument()
expect(
within(toolbar as HTMLElement).getByRole('button', {
name: '保存并测试 OpenCode'
})
).toBeInTheDocument()
expect(
settings.querySelector('.settings-panel__footer')
).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.queryByRole('button', { name: '保存设置' })
).not.toBeInTheDocument()
})
it('uses one first-level heading for the settings page', () => {
render(
<SettingsPanel
@@ -1006,6 +1047,19 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('button', { name: '添加自定义' })
)
expect(screen.getAllByLabelText('名称')).toHaveLength(1)
expect(screen.getByLabelText('模型接口 URL')).toHaveValue('')
expect(screen.getByLabelText('模型接口 URL')).toHaveAttribute(
'placeholder',
'https://api.example.com/v1'
)
expect(screen.getByLabelText('模型')).toHaveValue('')
expect(screen.getByLabelText('模型')).toHaveAttribute(
'placeholder',
'model-name'
)
expect(
screen.getByLabelText('接口协议 模型连接 2')
).toHaveValue('openai-chat-completions')
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: 'OpenCode 独立模型' }
})
@@ -1144,6 +1198,39 @@ describe('SettingsPanel runtime files', () => {
)
})
it('saves the image input capability for a model connection', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const imageInput = await screen.findByRole('checkbox', {
name: '支持图像输入'
})
expect(imageInput).not.toBeChecked()
fireEvent.click(imageInput)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
id: modelProfileId,
supportsImageInput: true
})
]
})
)
)
})
it('keeps saved Runtime sources valid when defaulting a new text profile', async () => {
render(
<SettingsPanel
@@ -1913,6 +2000,7 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('浏览器操作')).toBeInTheDocument()
expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument()
expect(screen.getByText('知识库 MCP')).toBeInTheDocument()
expect(screen.queryByText('knowledge_list')).not.toBeInTheDocument()
expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument()
expect(screen.queryByText('note_search')).not.toBeInTheDocument()
const knowledgeServerToggle = screen.getByRole('button', {
@@ -1924,6 +2012,9 @@ describe('SettingsPanel runtime files', () => {
const knowledgeTools = screen.getByRole('region', {
name: '知识库 MCP 工具'
})
expect(knowledgeTools).toContainElement(
screen.getByText('knowledge_list')
)
expect(knowledgeTools).toContainElement(
screen.getByText('knowledge_search')
)
+78 -58
View File
@@ -4,6 +4,7 @@ import {
KeyRound,
LockKeyhole,
Plus,
Save,
SunMoon,
TerminalSquare,
Trash2,
@@ -60,6 +61,7 @@ type SettingsTab =
type ModelType = 'llm' | 'embedding' | 'speech'
type AgentRuntimeType = RuntimeConfigActionInput['runtime']
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
supportsImageInput: boolean
apiKey: string
clearApiKey: boolean
}
@@ -138,6 +140,7 @@ function toModelProfileDrafts(
): ModelProfileDraft[] {
return settings.modelProfiles.map((profile) => ({
...profile,
supportsImageInput: profile.supportsImageInput ?? false,
apiKey: '',
clearApiKey: false
}))
@@ -346,11 +349,8 @@ export function SettingsPanel({
activeTab === 'runtime' ||
activeTab === 'security' ||
activeTab === 'roles'
const showFooter =
presentation !== 'page' ||
configurationTab ||
Boolean(error) ||
saved
const showContentActions =
configurationTab || Boolean(error) || saved
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
@@ -515,6 +515,7 @@ export function SettingsPanel({
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
@@ -780,10 +781,11 @@ export function SettingsPanel({
{
id,
name: `模型连接 ${profiles.length + 1}`,
baseUrl: defaultRuntimeSettings.modelBaseUrl,
modelName: defaultRuntimeSettings.modelName,
protocol: defaultRuntimeSettings.modelProtocol,
baseUrl: '',
modelName: '',
protocol: 'openai-chat-completions',
authentication: defaultRuntimeSettings.modelAuthentication,
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
imageGenerationQuality:
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: false,
@@ -1176,6 +1178,53 @@ export function SettingsPanel({
ref={settingsBodyRef}
role="tabpanel"
>
{showContentActions && (
<div className="settings-panel__content-toolbar">
<div className="settings-feedback">
{error && (
<span className="settings-error" role="alert">
{error}
</span>
)}
{saved && (
<span className="settings-success" role="status">
<Check aria-hidden="true" size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
{configurationTab && (
<div className="settings-panel__content-actions">
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
<button
className="secondary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
<Save aria-hidden="true" size={13} />
{saving ? '保存中…' : '保存设置'}
</button>
</div>
)}
</div>
)}
{activeTab === 'appearance' && (
<div className="settings-section appearance-settings">
<div className="settings-section__title">
@@ -1815,6 +1864,7 @@ export function SettingsPanel({
baseUrl: event.target.value
})
}
placeholder="https://api.example.com/v1"
value={profile.baseUrl}
/>
</label>
@@ -1827,6 +1877,7 @@ export function SettingsPanel({
modelName: event.target.value
})
}
placeholder="model-name"
value={profile.modelName}
/>
</label>
@@ -1915,6 +1966,25 @@ export function SettingsPanel({
<option value="none"></option>
</select>
</label>
{isAgentRuntimeModelProtocol(profile.protocol) && (
<div className="field">
<label className="check-field">
<input
checked={profile.supportsImageInput}
onChange={(event) =>
updateModelProfile(profile.id, {
supportsImageInput: event.target.checked
})
}
type="checkbox"
/>
<span></span>
</label>
<small>
GoodBuddy
</small>
</div>
)}
{profile.protocol ===
'openai-images-generations' && (
<label className="field">
@@ -2301,56 +2371,6 @@ export function SettingsPanel({
{activeTab === 'about' && <UpdateSettingsSection />}
</div>
</div>
{showFooter && (
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
<button
className="secondary-button"
onClick={close}
type="button"
>
{configurationTab ? '取消' : '关闭'}
</button>
{configurationTab && (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
<button
className="secondary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
{saving ? '保存中…' : '保存设置'}
</button>
</>
)}
</footer>
)}
</section>
</div>
)
+202 -139
View File
@@ -3321,16 +3321,6 @@ button > svg * {
color: var(--danger);
}
.window-capture-backdrop {
position: fixed;
z-index: 70;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.image-viewer-backdrop {
position: fixed;
z-index: 80;
@@ -3396,77 +3386,6 @@ button > svg * {
object-fit: contain;
}
.window-capture-dialog {
display: grid;
width: min(520px, 100%);
max-height: min(680px, calc(100vh - 70px));
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-3);
}
.window-capture-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.window-capture-dialog__header > div {
display: grid;
gap: var(--space-1);
}
.window-capture-dialog__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.window-capture-dialog__header small {
color: var(--text-secondary);
font-size: var(--font-body);
}
.window-capture-dialog__list {
display: grid;
min-height: 0;
padding: var(--space-1);
overflow-y: auto;
gap: var(--space-2);
}
.window-capture-dialog__list > button {
display: flex;
width: 100%;
min-width: 0;
min-height: 40px;
align-items: center;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
text-align: left;
}
.window-capture-dialog__list > button:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.window-capture-dialog__list > button span {
min-width: 0;
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer:focus-within {
border-color: var(--accent);
box-shadow:
@@ -3525,7 +3444,7 @@ button > svg * {
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--space-3);
}
@@ -3611,7 +3530,7 @@ button > svg * {
}
.composer-picker--mode > .model-button {
width: 168px;
width: 96px;
}
.composer-picker--ask svg {
@@ -3830,6 +3749,10 @@ button > svg * {
}
@container (max-width: 700px) {
.composer__controls {
flex-wrap: wrap;
}
.composer__configuration {
width: 100%;
padding-left: 0;
@@ -3874,7 +3797,7 @@ button > svg * {
overflow: hidden;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
}
.settings-page .settings-panel {
@@ -4040,6 +3963,29 @@ button > svg * {
scrollbar-gutter: stable;
}
.settings-panel__content-toolbar {
display: flex;
min-height: var(--control-height);
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
}
.settings-panel__content-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.settings-panel__content-actions .primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.settings-page .settings-tabs button {
min-height: 58px;
padding: 10px 12px;
@@ -5519,15 +5465,6 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--success) !important;
}
.settings-panel__footer {
display: flex;
align-items: center;
padding: 14px 24px;
border-top: 1px solid var(--border-subtle);
background: var(--surface-subtle);
gap: 8px;
}
.settings-feedback {
flex: 1;
min-width: 0;
@@ -6633,11 +6570,11 @@ details.settings-section > :not(summary) + :not(summary) {
display: grid;
min-height: clamp(500px, calc(100dvh - 260px), 780px);
gap: var(--space-4);
grid-template-columns: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
}
.knowledge-graph--with-details {
grid-template-columns: minmax(0, 1fr) minmax(300px, 340px);
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
}
.knowledge-graph__canvas {
@@ -6687,60 +6624,183 @@ details.settings-section > :not(summary) + :not(summary) {
text-align: center;
}
.knowledge-graph__chart {
.knowledge-graph__chart-shell {
position: relative;
width: 100%;
min-height: clamp(420px, calc(100dvh - 330px), 720px);
overflow: hidden;
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__chart {
width: 100%;
min-height: inherit;
}
.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__chart-error {
position: absolute;
inset: 50% auto auto 50%;
max-width: min(420px, calc(100% - 48px));
padding: var(--space-3);
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
background: var(--danger-subtle);
color: var(--danger);
transform: translate(-50%, -50%);
}
.knowledge-graph__detail {
display: flex;
min-width: 0;
max-height: clamp(500px, calc(100dvh - 260px), 780px);
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
@container (max-width: 1120px) {
.knowledge-graph__detail > .page-tabs--segmented {
width: calc(100% - (var(--space-4) * 2));
margin: var(--space-4);
}
.knowledge-graph__detail > .page-tabs--segmented .page-tabs__tab {
min-width: 0;
flex: 1;
justify-content: center;
}
.knowledge-graph__detail-panel {
min-height: 0;
padding: 0 var(--space-4) var(--space-4);
overflow-y: auto;
scrollbar-gutter: stable;
}
.knowledge-graph__panel-heading,
.knowledge-graph__section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.knowledge-graph__panel-heading {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.knowledge-graph__panel-heading h3 {
margin: 0;
}
.knowledge-graph__panel-heading p,
.knowledge-graph__relation-card > p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.knowledge-graph__panel-heading > span {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.knowledge-graph__panel-empty {
padding: var(--space-6) var(--space-2);
margin: 0;
color: var(--text-muted);
text-align: center;
}
.knowledge-graph__topology-list,
.knowledge-graph__entity-relations {
display: grid;
padding: 0;
margin: var(--space-3) 0 0;
gap: var(--space-3);
list-style: none;
}
.knowledge-graph__relation-card {
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.knowledge-graph__relation-path {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.knowledge-graph__entity-link {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
font: inherit;
font-weight: 650;
line-height: 1.45;
overflow-wrap: anywhere;
text-align: left;
cursor: pointer;
}
.knowledge-graph__entity-link:last-child {
padding-left: var(--space-6);
}
.knowledge-graph__entity-link:hover {
color: var(--accent);
text-decoration: underline;
}
.knowledge-graph__entity-link:disabled {
color: var(--text-muted);
cursor: default;
text-decoration: none;
}
.knowledge-graph__relation-type {
display: inline-flex;
min-width: 0;
align-items: center;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
overflow-wrap: anywhere;
}
.knowledge-graph__entity-actions,
.knowledge-graph__relation-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.knowledge-graph__relation-actions {
padding-top: var(--space-3);
margin-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.knowledge-graph__merge {
display: grid;
min-width: 0;
margin-top: var(--space-2);
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
}
@container (max-width: 880px) {
.knowledge-graph--with-details {
grid-template-columns: minmax(0, 1fr);
}
@@ -8082,7 +8142,6 @@ details.settings-section > :not(summary) + :not(summary) {
.sidebar-search,
.settings-section,
.settings-tabs,
.settings-panel__footer,
.assistant-sidebar__row,
.assistant-sidebar__library,
.assistant-sidebar__diff,
@@ -8117,7 +8176,6 @@ details.settings-section > :not(summary) + :not(summary) {
.divider,
.runtime-picker__divider,
.settings-panel__header,
.settings-panel__footer,
.knowledge-workspace__sidebar,
.knowledge-workspace__header,
.knowledge-panel__header,
@@ -8613,7 +8671,8 @@ details.settings-section > :not(summary) + :not(summary) {
flex-basis: 100%;
}
.settings-panel__footer {
.settings-panel__content-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
@@ -8621,6 +8680,10 @@ details.settings-section > :not(summary) + :not(summary) {
flex-basis: 100%;
}
.settings-panel__content-actions {
width: 100%;
}
.mcp-editor {
width: 100%;
}