feat: expand multimodal and knowledge workflows
This commit is contained in:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user