feat: enhance knowledge workflows and refresh interface
This commit is contained in:
+153
-48
@@ -498,6 +498,7 @@ const api: DesktopApi = {
|
||||
})),
|
||||
updateLibrary: vi.fn(async () => {}),
|
||||
deleteLibrary: vi.fn(async () => {}),
|
||||
reextractGraph: vi.fn(async () => {}),
|
||||
selectFiles: vi.fn(async () => {}),
|
||||
selectDirectory: vi.fn(async () => {}),
|
||||
importDroppedFiles: vi.fn(async () => {}),
|
||||
@@ -518,6 +519,35 @@ const api: DesktopApi = {
|
||||
}
|
||||
}
|
||||
|
||||
function composerMenuTrigger(
|
||||
label: '专家角色' | '工作模式'
|
||||
): HTMLButtonElement {
|
||||
return screen.getByRole('button', {
|
||||
name: new RegExp(`^${label}:`, 'u')
|
||||
})
|
||||
}
|
||||
|
||||
function openComposerMenu(
|
||||
label: '专家角色' | '工作模式'
|
||||
): HTMLElement {
|
||||
fireEvent.click(composerMenuTrigger(label))
|
||||
return screen.getByRole('menu', { name: label })
|
||||
}
|
||||
|
||||
function selectComposerOption(
|
||||
label: '专家角色' | '工作模式',
|
||||
optionLabel: string
|
||||
): void {
|
||||
const menu = openComposerMenu(label)
|
||||
const option = within(menu)
|
||||
.getByText(optionLabel, { selector: 'span' })
|
||||
.closest<HTMLButtonElement>('button')
|
||||
if (!option) {
|
||||
throw new Error(`Missing ${label} option: ${optionLabel}`)
|
||||
}
|
||||
fireEvent.click(option)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
@@ -788,26 +818,28 @@ describe('App', () => {
|
||||
return
|
||||
}
|
||||
|
||||
expect(within(topbar).queryByLabelText('专家角色')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('专家角色').closest('.composer')).not.toBeNull()
|
||||
expect(
|
||||
within(topbar).queryByRole('button', {
|
||||
name: /^专家角色:/u
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(composerMenuTrigger('专家角色').closest('.composer')).not.toBeNull()
|
||||
|
||||
const appMenuTrigger = within(topbar).getByLabelText('应用菜单')
|
||||
fireEvent.click(appMenuTrigger)
|
||||
const themeToggle = within(topbar).getByRole('button', {
|
||||
name: '切换深色主题'
|
||||
})
|
||||
fireEvent.click(themeToggle)
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: '重命名会话' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
|
||||
).toBeVisible()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
|
||||
).toHaveFocus()
|
||||
)
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
expect(screen.getByRole('menuitem', { name: '使用帮助' })).toHaveFocus()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(appMenuTrigger).toHaveFocus()
|
||||
within(topbar).getByRole('button', {
|
||||
name: '切换浅色主题'
|
||||
})
|
||||
).toBe(themeToggle)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
|
||||
const conversationMenuTrigger = within(
|
||||
@@ -1831,7 +1863,9 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: '任务与活动' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('专家角色')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^专家角色:/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -1884,11 +1918,14 @@ describe('App', () => {
|
||||
it('offers only Ask and Execute in visible work mode controls', async () => {
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
const modeMenu = openComposerMenu('工作模式')
|
||||
expect(
|
||||
within(mode)
|
||||
.getAllByRole('option')
|
||||
.map((option) => option.textContent)
|
||||
within(modeMenu)
|
||||
.getAllByRole('menuitemradio')
|
||||
.map((option) => option.querySelector('span')?.textContent)
|
||||
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
@@ -1904,6 +1941,46 @@ describe('App', () => {
|
||||
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
|
||||
})
|
||||
|
||||
it('matches expert and work mode keyboard menus to the model picker', async () => {
|
||||
render(<App />)
|
||||
|
||||
const expertTrigger = await screen.findByRole('button', {
|
||||
name: '专家角色:通用助手'
|
||||
})
|
||||
expect(expertTrigger).toHaveClass('model-button')
|
||||
fireEvent.keyDown(expertTrigger, { key: 'ArrowDown' })
|
||||
|
||||
const expertMenu = screen.getByRole('menu', {
|
||||
name: '专家角色'
|
||||
})
|
||||
expect(expertMenu).toHaveClass('runtime-picker__menu')
|
||||
const generalExpert = within(expertMenu).getByRole(
|
||||
'menuitemradio',
|
||||
{ name: /^通用助手/u }
|
||||
)
|
||||
const expertTeam = within(expertMenu).getByRole(
|
||||
'menuitemradio',
|
||||
{ name: /^专家团队(并行)/u }
|
||||
)
|
||||
await waitFor(() => expect(generalExpert).toHaveFocus())
|
||||
fireEvent.keyDown(generalExpert, { key: 'ArrowDown' })
|
||||
expect(expertTeam).toHaveFocus()
|
||||
fireEvent.keyDown(expertTeam, { key: 'Escape' })
|
||||
expect(expertTrigger).toHaveFocus()
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '专家角色' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const modeTrigger = composerMenuTrigger('工作模式')
|
||||
fireEvent.click(modeTrigger)
|
||||
const modeMenu = screen.getByRole('menu', { name: '工作模式' })
|
||||
expect(modeMenu).toHaveClass('runtime-picker__menu')
|
||||
fireEvent.pointerDown(screen.getByLabelText('向 GoodBuddy 提问'))
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '工作模式' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups composer tools and exposes clear control descriptions', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -1930,11 +2007,19 @@ describe('App', () => {
|
||||
{ name: '对话设置' }
|
||||
)
|
||||
expect(
|
||||
within(conversationSettings).getByLabelText('专家角色')
|
||||
within(conversationSettings).getByRole('button', {
|
||||
name: '专家角色:通用助手'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(conversationSettings).getByLabelText('工作模式')
|
||||
within(conversationSettings).getByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
|
||||
)
|
||||
expect(
|
||||
within(conversationSettings).getByRole('button', {
|
||||
name: /默认模型/u
|
||||
@@ -1954,8 +2039,10 @@ describe('App', () => {
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('ask')
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
expect(mode).toBeEnabled()
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '制定发布方案' }
|
||||
})
|
||||
@@ -1993,7 +2080,11 @@ describe('App', () => {
|
||||
expect(await screen.findByLabelText('当前项目')).toHaveValue(
|
||||
secondProject.id
|
||||
)
|
||||
expect(screen.getByLabelText('工作模式')).toHaveValue('execute')
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '工作模式:Execute · 受控执行'
|
||||
})
|
||||
).toBeEnabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('当前项目'), {
|
||||
target: { value: project.id }
|
||||
@@ -2151,8 +2242,9 @@ describe('App', () => {
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('ask')
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
expect(mode).toBeEnabled()
|
||||
expect(mode.closest('.composer')).not.toBeNull()
|
||||
expect(
|
||||
@@ -2160,7 +2252,10 @@ describe('App', () => {
|
||||
new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`)
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.change(mode, { target: { value: 'execute' } })
|
||||
selectComposerOption('工作模式', 'Execute · 受控执行')
|
||||
expect(mode).toHaveAccessibleName(
|
||||
'工作模式:Execute · 受控执行'
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '执行任务' }
|
||||
@@ -2203,11 +2298,14 @@ describe('App', () => {
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('ask')
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
expect(mode).toBeEnabled()
|
||||
fireEvent.change(mode, { target: { value: 'execute' } })
|
||||
expect(mode).toHaveValue('execute')
|
||||
selectComposerOption('工作模式', 'Execute · 受控执行')
|
||||
expect(mode).toHaveAccessibleName(
|
||||
'工作模式:Execute · 受控执行'
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
|
||||
fireEvent.click(
|
||||
@@ -2217,7 +2315,7 @@ describe('App', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mode).toHaveValue('ask')
|
||||
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
|
||||
expect(mode).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -2232,13 +2330,16 @@ describe('App', () => {
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
const modeMenu = openComposerMenu('工作模式')
|
||||
expect(
|
||||
within(mode).getByRole('option', {
|
||||
name: 'Execute · 受控执行'
|
||||
within(modeMenu).getByRole('menuitemradio', {
|
||||
name: /^Execute · 受控执行/u
|
||||
})
|
||||
).toBeDisabled()
|
||||
expect(mode).toHaveValue('ask')
|
||||
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
|
||||
})
|
||||
|
||||
it('allows a direct model to submit Execute with GoodBuddy approvals', async () => {
|
||||
@@ -2251,8 +2352,10 @@ describe('App', () => {
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
fireEvent.change(mode, { target: { value: 'execute' } })
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
selectComposerOption('工作模式', 'Execute · 受控执行')
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '读取项目文件' }
|
||||
})
|
||||
@@ -3116,7 +3219,7 @@ describe('App', () => {
|
||||
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'描述你想生成的图片…'
|
||||
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenCalled()
|
||||
@@ -3195,9 +3298,7 @@ describe('App', () => {
|
||||
it('can dispatch a request to the parallel expert team', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('专家角色'), {
|
||||
target: { value: 'team' }
|
||||
})
|
||||
selectComposerOption('专家角色', '专家团队(并行)')
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '制定发布计划' }
|
||||
})
|
||||
@@ -3260,10 +3361,14 @@ describe('App', () => {
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
await screen.findByRole('option', { name: '发布专家' })
|
||||
fireEvent.change(screen.getByLabelText('专家角色'), {
|
||||
target: { value: expertId }
|
||||
})
|
||||
await waitFor(() => expect(api.experts.list).toHaveBeenCalled())
|
||||
const expertMenu = openComposerMenu('专家角色')
|
||||
fireEvent.click(
|
||||
(await within(expertMenu).findByText('发布专家', {
|
||||
selector: 'span'
|
||||
}))
|
||||
.closest<HTMLButtonElement>('button')!
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '检查发布方案' }
|
||||
})
|
||||
@@ -4021,7 +4126,7 @@ describe('App', () => {
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('专家角色')
|
||||
screen.queryByRole('button', { name: /^专家角色:/u })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
+355
-179
@@ -19,6 +19,7 @@ import {
|
||||
Mic,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
PanelLeft,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
PanelsTopLeft,
|
||||
Sparkles,
|
||||
Square,
|
||||
Sun,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
UserRound,
|
||||
@@ -42,7 +44,8 @@ import {
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
@@ -1032,6 +1035,186 @@ function WindowControls({
|
||||
)
|
||||
}
|
||||
|
||||
type ComposerMenuOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
description: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ComposerMenuSelect<T extends string>({
|
||||
ariaLabel,
|
||||
className,
|
||||
describedBy,
|
||||
disabled = false,
|
||||
icon,
|
||||
menuOpen,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
options,
|
||||
value
|
||||
}: {
|
||||
ariaLabel: string
|
||||
className: string
|
||||
describedBy?: string
|
||||
disabled?: boolean
|
||||
icon: ReactNode
|
||||
menuOpen: boolean
|
||||
onChange: (value: T) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
options: readonly ComposerMenuOption<T>[]
|
||||
value: T
|
||||
}): React.JSX.Element {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === value) ?? options[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) {
|
||||
return
|
||||
}
|
||||
const menu = menuRef.current
|
||||
if (!menu) {
|
||||
return
|
||||
}
|
||||
const menuItems = Array.from(
|
||||
menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
).filter((item) => !item.disabled)
|
||||
const initialItem =
|
||||
menuItems.find(
|
||||
(item) => item.getAttribute('aria-checked') === 'true'
|
||||
) ?? menuItems[0]
|
||||
menuItems.forEach((item) => {
|
||||
item.tabIndex = item === initialItem ? 0 : -1
|
||||
})
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
initialItem?.focus()
|
||||
})
|
||||
const isMenuTarget = (target: EventTarget | null): boolean =>
|
||||
target instanceof Node &&
|
||||
(menu.contains(target) ||
|
||||
buttonRef.current?.contains(target) === true)
|
||||
const dismissOnOutsidePointer = (event: PointerEvent): void => {
|
||||
if (!isMenuTarget(event.target)) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
const dismissOnOutsideFocus = (event: FocusEvent): void => {
|
||||
if (!isMenuTarget(event.target)) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', dismissOnOutsidePointer)
|
||||
document.addEventListener('focusin', dismissOnOutsideFocus)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener(
|
||||
'pointerdown',
|
||||
dismissOnOutsidePointer
|
||||
)
|
||||
document.removeEventListener('focusin', dismissOnOutsideFocus)
|
||||
}
|
||||
}, [menuOpen, onOpenChange, value])
|
||||
|
||||
return (
|
||||
<div className={`runtime-picker composer-picker ${className}`}>
|
||||
<button
|
||||
aria-describedby={describedBy}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`${ariaLabel}:${selectedOption?.label ?? ''}`}
|
||||
className="model-button composer-picker__button"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenChange(!menuOpen)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!menuOpen &&
|
||||
(event.key === 'ArrowDown' ||
|
||||
event.key === 'Enter' ||
|
||||
event.key === ' ')
|
||||
) {
|
||||
event.preventDefault()
|
||||
onOpenChange(true)
|
||||
}
|
||||
}}
|
||||
ref={buttonRef}
|
||||
title={`${ariaLabel}:${selectedOption?.label ?? ''}`}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
<span className="model-button__label">
|
||||
{selectedOption?.label}
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className="runtime-picker__menu composer-picker__menu"
|
||||
onKeyDown={(event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitemradio"]'
|
||||
)
|
||||
).filter((item) => !item.disabled)
|
||||
const currentIndex = items.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
let nextIndex: number | undefined
|
||||
if (event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % items.length
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex =
|
||||
(currentIndex - 1 + items.length) % items.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = items.length - 1
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onOpenChange(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
const nextItem =
|
||||
nextIndex === undefined ? undefined : items.at(nextIndex)
|
||||
if (nextItem) {
|
||||
event.preventDefault()
|
||||
items.forEach((item) => {
|
||||
item.tabIndex = item === nextItem ? 0 : -1
|
||||
})
|
||||
nextItem.focus()
|
||||
}
|
||||
}}
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
aria-checked={option.value === value}
|
||||
disabled={option.disabled}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
onOpenChange(false)
|
||||
requestAnimationFrame(() => {
|
||||
buttonRef.current?.focus()
|
||||
})
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={option.value === value ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<small>{option.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const [conversations, setConversations] = useState(loadConversations)
|
||||
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
|
||||
@@ -1105,9 +1288,11 @@ function App(): React.JSX.Element {
|
||||
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||
const [composerMenuOpen, setComposerMenuOpen] = useState<
|
||||
'expert' | 'mode' | undefined
|
||||
>()
|
||||
const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const runtimeMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [topbarMenuOpen, setTopbarMenuOpen] = useState(false)
|
||||
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
||||
const [appearanceTheme, setAppearanceTheme] =
|
||||
useState<AppearanceTheme>(loadAppearanceTheme)
|
||||
@@ -1120,12 +1305,67 @@ function App(): React.JSX.Element {
|
||||
appearanceTheme,
|
||||
systemPrefersDark
|
||||
)
|
||||
const toggleAppearanceTheme = useCallback((): void => {
|
||||
setAppearanceTheme(
|
||||
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
|
||||
)
|
||||
}, [resolvedAppearanceTheme])
|
||||
const agentRuntimeSelected = isAgentRuntime(runtime)
|
||||
const effectiveWorkMode =
|
||||
workMode === 'execute' &&
|
||||
runtime?.supportsToolExecution === false
|
||||
? 'ask'
|
||||
: workMode
|
||||
const setExpertMenuOpen = useCallback((open: boolean): void => {
|
||||
setComposerMenuOpen(open ? 'expert' : undefined)
|
||||
if (open) {
|
||||
setRuntimeMenuOpen(false)
|
||||
}
|
||||
}, [])
|
||||
const setModeMenuOpen = useCallback((open: boolean): void => {
|
||||
setComposerMenuOpen(open ? 'mode' : undefined)
|
||||
if (open) {
|
||||
setRuntimeMenuOpen(false)
|
||||
}
|
||||
}, [])
|
||||
const assistantExpertOptions = useMemo<
|
||||
ComposerMenuOption<string>[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
value: '',
|
||||
label: '通用助手',
|
||||
description: '默认单助手'
|
||||
},
|
||||
{
|
||||
value: 'team',
|
||||
label: '专家团队(并行)',
|
||||
description: '多个专家并行协作'
|
||||
},
|
||||
...assistantExperts.map((expert) => ({
|
||||
value: expert.id,
|
||||
label: expert.name,
|
||||
description: expert.description || '自定义专家角色'
|
||||
}))
|
||||
],
|
||||
[assistantExperts]
|
||||
)
|
||||
const workModeOptions = useMemo<
|
||||
ComposerMenuOption<InteractiveWorkMode>[]
|
||||
>(
|
||||
() =>
|
||||
interactiveWorkModes.map((value) => ({
|
||||
value,
|
||||
label: workModeLabels[value],
|
||||
description:
|
||||
value === 'execute'
|
||||
? '通过审批后执行工具操作'
|
||||
: '只读问答,不修改文件',
|
||||
disabled:
|
||||
value === 'execute' && !runtime?.supportsToolExecution
|
||||
})),
|
||||
[runtime?.supportsToolExecution]
|
||||
)
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||
const [narrowWindow, setNarrowWindow] = useState(
|
||||
() => window.innerWidth < 900
|
||||
@@ -1193,10 +1433,12 @@ function App(): React.JSX.Element {
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
evidence: [],
|
||||
tasks: []
|
||||
})
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
|
||||
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
|
||||
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
|
||||
const knowledgeLoadRequestRef = useRef(0)
|
||||
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
|
||||
undefined
|
||||
@@ -1216,8 +1458,6 @@ function App(): React.JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const topbarMenuRef = useRef<HTMLDivElement>(null)
|
||||
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
)
|
||||
@@ -1279,66 +1519,6 @@ function App(): React.JSX.Element {
|
||||
resizeComposerTextarea(inputRef.current)
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
if (!topbarMenuOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
topbarMenuRef.current
|
||||
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
|
||||
?.focus()
|
||||
})
|
||||
const closeOnOutsidePointer = (event: PointerEvent): void => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!topbarMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
setTopbarMenuOpen(false)
|
||||
}
|
||||
}
|
||||
const handleMenuKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setTopbarMenuOpen(false)
|
||||
topbarMenuTriggerRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const menuItems = Array.from(
|
||||
topbarMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitem"]'
|
||||
) ?? []
|
||||
)
|
||||
if (menuItems.length === 0) {
|
||||
return
|
||||
}
|
||||
const currentIndex = menuItems.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? menuItems.length - 1
|
||||
: event.key === 'ArrowDown'
|
||||
? (currentIndex + 1) % menuItems.length
|
||||
: event.key === 'ArrowUp'
|
||||
? (currentIndex - 1 + menuItems.length) %
|
||||
menuItems.length
|
||||
: -1
|
||||
if (targetIndex >= 0) {
|
||||
event.preventDefault()
|
||||
menuItems[targetIndex]?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
document.addEventListener('keydown', handleMenuKeyDown)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
document.removeEventListener('keydown', handleMenuKeyDown)
|
||||
}
|
||||
}, [topbarMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
saveAppearanceTheme(appearanceTheme)
|
||||
}, [appearanceTheme])
|
||||
@@ -2999,6 +3179,25 @@ function App(): React.JSX.Element {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [refreshKnowledge])
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'knowledge' && knowledgeOperationCount === 0) {
|
||||
return
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void refreshKnowledge(
|
||||
knowledgeSnapshot.selectedLibraryId
|
||||
).catch(() => {
|
||||
// The task center keeps the last successful snapshot while polling.
|
||||
})
|
||||
}, knowledgeOperationCount > 0 ? 350 : 1_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
knowledgeOperationCount,
|
||||
knowledgeSnapshot.selectedLibraryId,
|
||||
refreshKnowledge,
|
||||
view
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
window.goodbuddy.settings.getRuntime(),
|
||||
@@ -4078,11 +4277,20 @@ function App(): React.JSX.Element {
|
||||
await refreshKnowledge()
|
||||
}
|
||||
|
||||
const runKnowledgeSourceAction = async (
|
||||
action: () => Promise<void>
|
||||
): Promise<void> => {
|
||||
await action()
|
||||
await refreshSelectedKnowledge()
|
||||
const runKnowledgeSourceAction = async <T,>(
|
||||
action: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
setKnowledgeOperationCount((count) => count + 1)
|
||||
try {
|
||||
const result = await action()
|
||||
await refreshSelectedKnowledge()
|
||||
return result
|
||||
} catch (error) {
|
||||
await refreshSelectedKnowledge().catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
setKnowledgeOperationCount((count) => Math.max(0, count - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const openActivityConversation = (conversationId: string): void => {
|
||||
@@ -4627,55 +4835,28 @@ function App(): React.JSX.Element {
|
||||
<PanelRightOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar-menu" ref={topbarMenuRef}>
|
||||
<button
|
||||
aria-expanded={topbarMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="应用菜单"
|
||||
className="icon-button"
|
||||
onClick={() =>
|
||||
setTopbarMenuOpen((current) => !current)
|
||||
}
|
||||
ref={topbarMenuTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
{topbarMenuOpen && (
|
||||
<div
|
||||
aria-label="应用操作"
|
||||
className="topbar-menu__popover"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTopbarMenuOpen(false)
|
||||
setView('settings')
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
安全与 Runtime 设置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTopbarMenuOpen(false)
|
||||
notify({
|
||||
tone: 'info',
|
||||
message:
|
||||
'输入问题后按 Enter 发送,Shift+Enter 换行。附件只会在你明确选择后发送。'
|
||||
})
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<CircleHelp size={16} />
|
||||
使用帮助
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label={
|
||||
resolvedAppearanceTheme === 'dark'
|
||||
? '切换浅色主题'
|
||||
: '切换深色主题'
|
||||
}
|
||||
aria-pressed={resolvedAppearanceTheme === 'dark'}
|
||||
className="icon-button theme-toggle-button"
|
||||
onClick={toggleAppearanceTheme}
|
||||
title={
|
||||
resolvedAppearanceTheme === 'dark'
|
||||
? '切换浅色主题'
|
||||
: '切换深色主题'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{resolvedAppearanceTheme === 'dark' ? (
|
||||
<Sun aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<Moon aria-hidden="true" size={18} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<WindowControls
|
||||
onError={handleWindowControlError}
|
||||
@@ -5260,11 +5441,11 @@ function App(): React.JSX.Element {
|
||||
<div className="composer__input">
|
||||
<textarea
|
||||
aria-label="向 GoodBuddy 提问"
|
||||
placeholder={
|
||||
placeholder={`${
|
||||
runtime?.capability === 'image-generation'
|
||||
? '描述你想生成的图片…'
|
||||
: '给 GoodBuddy 发消息…'
|
||||
}
|
||||
}\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送`}
|
||||
ref={inputRef}
|
||||
rows={3}
|
||||
value={input}
|
||||
@@ -5418,68 +5599,46 @@ function App(): React.JSX.Element {
|
||||
className="composer__configuration"
|
||||
role="group"
|
||||
>
|
||||
<label
|
||||
className="composer__expert"
|
||||
title="选择参与本次对话的专家角色"
|
||||
>
|
||||
<Bot aria-hidden="true" size={15} />
|
||||
<select
|
||||
aria-label="专家角色"
|
||||
disabled={runtime?.capability === 'image-generation'}
|
||||
onChange={(event) =>
|
||||
setSelectedExpertId(event.target.value)
|
||||
}
|
||||
value={selectedExpertId}
|
||||
>
|
||||
<option value="">通用助手</option>
|
||||
<option value="team">专家团队(并行)</option>
|
||||
{assistantExperts.map((expert) => (
|
||||
<option key={expert.id} value={expert.id}>
|
||||
{expert.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
className={`composer__mode composer__mode--${effectiveWorkMode}`}
|
||||
title={`工作模式:${workModeLabels[effectiveWorkMode]}`}
|
||||
>
|
||||
{effectiveWorkMode === 'execute' ? (
|
||||
<ShieldCheck aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<CircleHelp aria-hidden="true" size={15} />
|
||||
)}
|
||||
<select
|
||||
aria-describedby="work-mode-hint"
|
||||
aria-label="工作模式"
|
||||
onChange={(event) =>
|
||||
setWorkMode(
|
||||
event.target.value as InteractiveWorkMode
|
||||
)
|
||||
}
|
||||
value={effectiveWorkMode}
|
||||
>
|
||||
{interactiveWorkModes.map((value) => (
|
||||
<option
|
||||
disabled={
|
||||
value === 'execute' &&
|
||||
!runtime?.supportsToolExecution
|
||||
}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{workModeLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ComposerMenuSelect
|
||||
ariaLabel="专家角色"
|
||||
className="composer-picker--expert"
|
||||
disabled={
|
||||
runtime?.capability === 'image-generation'
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === 'expert'}
|
||||
onChange={setSelectedExpertId}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
<ComposerMenuSelect
|
||||
ariaLabel="工作模式"
|
||||
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
|
||||
describedBy="work-mode-hint"
|
||||
icon={
|
||||
effectiveWorkMode === 'execute' ? (
|
||||
<ShieldCheck aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<CircleHelp aria-hidden="true" size={15} />
|
||||
)
|
||||
}
|
||||
menuOpen={composerMenuOpen === 'mode'}
|
||||
onChange={setWorkMode}
|
||||
onOpenChange={setModeMenuOpen}
|
||||
options={workModeOptions}
|
||||
value={effectiveWorkMode}
|
||||
/>
|
||||
<div className="runtime-picker">
|
||||
<button
|
||||
aria-expanded={runtimeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="model-button"
|
||||
disabled={isRunning || runtimeSwitching}
|
||||
onClick={() => setRuntimeMenuOpen(!runtimeMenuOpen)}
|
||||
onClick={() => {
|
||||
setComposerMenuOpen(undefined)
|
||||
setRuntimeMenuOpen(!runtimeMenuOpen)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!runtimeMenuOpen &&
|
||||
@@ -5488,6 +5647,7 @@ function App(): React.JSX.Element {
|
||||
event.key === ' ')
|
||||
) {
|
||||
event.preventDefault()
|
||||
setComposerMenuOpen(undefined)
|
||||
setRuntimeMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
@@ -5782,14 +5942,29 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
onDeleteLibrary={deleteKnowledgeLibrary}
|
||||
onUpdateLibrary={(libraryId, update) =>
|
||||
runKnowledgeSourceAction(async () => {
|
||||
onReextractGraph={async (libraryId) => {
|
||||
await runKnowledgeSourceAction(() =>
|
||||
window.goodbuddy.knowledge.reextractGraph(libraryId)
|
||||
)
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: '知识图谱已重新抽取',
|
||||
dedupeKey: `knowledge-graph:${libraryId}`
|
||||
})
|
||||
}}
|
||||
onUpdateLibrary={async (libraryId, update) => {
|
||||
await runKnowledgeSourceAction(async () => {
|
||||
await window.goodbuddy.knowledge.updateLibrary(
|
||||
libraryId,
|
||||
update
|
||||
)
|
||||
})
|
||||
}
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: '知识库设置已更新',
|
||||
dedupeKey: `knowledge-library:${libraryId}`
|
||||
})
|
||||
}}
|
||||
onDeleteRelation={(relationId) =>
|
||||
runKnowledgeSourceAction(() =>
|
||||
window.goodbuddy.knowledge.deleteRelation(relationId)
|
||||
@@ -5892,6 +6067,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
|
||||
sources={knowledgeSnapshot.sources}
|
||||
tasks={knowledgeSnapshot.tasks}
|
||||
/>
|
||||
</PageShell>
|
||||
) : view === 'heartbeat' ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type EChartsCoreOption
|
||||
} from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
KnowledgeGraphNode,
|
||||
KnowledgeGraphRelation
|
||||
@@ -59,6 +59,42 @@ function readToken(name: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function graphTypeStyles(nodes: readonly ChartKnowledgeGraphNode[]): Map<
|
||||
string,
|
||||
{ color: string; borderColor: string }
|
||||
> {
|
||||
const palette = Array.from({ length: 8 }, (_, index) => ({
|
||||
color: readToken(`--graph-node-${index + 1}`),
|
||||
borderColor: readToken(`--graph-node-${index + 1}-border`)
|
||||
}))
|
||||
return new Map(
|
||||
[...new Set(nodes.map((node) => node.type))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map((type, index) => [type, palette[index % palette.length]!])
|
||||
)
|
||||
}
|
||||
|
||||
function graphRevision(
|
||||
nodes: readonly ChartKnowledgeGraphNode[],
|
||||
relations: readonly ChartKnowledgeGraphRelation[]
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
nodes: nodes.map((node) => [
|
||||
node.id,
|
||||
node.label,
|
||||
node.type,
|
||||
node.x,
|
||||
node.y
|
||||
]),
|
||||
relations: relations.map((relation) => [
|
||||
relation.id,
|
||||
relation.sourceId,
|
||||
relation.targetId,
|
||||
relation.type
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
function createOption({
|
||||
nodes,
|
||||
relations,
|
||||
@@ -72,17 +108,50 @@ function createOption({
|
||||
const textSecondary = readToken('--text-secondary')
|
||||
const textMuted = readToken('--text-muted')
|
||||
const accent = readToken('--accent')
|
||||
const accentSelected = readToken('--accent-selected')
|
||||
const accentSubtle = readToken('--accent-subtle')
|
||||
const surfaceRaised = readToken('--surface-raised')
|
||||
const borderDefault = readToken('--border-default')
|
||||
const typeStyles = graphTypeStyles(nodes)
|
||||
const dense = nodes.length > 24
|
||||
const veryDense = nodes.length > 60
|
||||
const degreeByNodeId = new Map(nodes.map((node) => [node.id, 0]))
|
||||
for (const relation of relations) {
|
||||
degreeByNodeId.set(
|
||||
relation.sourceId,
|
||||
(degreeByNodeId.get(relation.sourceId) ?? 0) + 1
|
||||
)
|
||||
degreeByNodeId.set(
|
||||
relation.targetId,
|
||||
(degreeByNodeId.get(relation.targetId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
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: !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||||
animation: !reducedMotion,
|
||||
animationDuration: 220,
|
||||
animationDurationUpdate: 160,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
renderMode: 'richText',
|
||||
@@ -113,49 +182,54 @@ function createOption({
|
||||
},
|
||||
force: {
|
||||
repulsion: dense
|
||||
? Math.min(520, 130 + nodes.length * 3)
|
||||
: 220,
|
||||
gravity: dense ? 0.14 : 0.08,
|
||||
edgeLength: dense
|
||||
? veryDense
|
||||
? [45, 80]
|
||||
: [60, 110]
|
||||
: [110, 190],
|
||||
friction: dense ? 0.5 : 0.6,
|
||||
layoutAnimation:
|
||||
!window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
.matches
|
||||
? 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,
|
||||
...(dense ? {} : { x: node.x, y: node.y }),
|
||||
value: degree,
|
||||
category: node.type,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
draggable: true,
|
||||
selected,
|
||||
symbolSize: selected
|
||||
? dense
|
||||
? 34
|
||||
: 60
|
||||
: dense
|
||||
? veryDense
|
||||
? 18
|
||||
: 24
|
||||
: 52,
|
||||
symbolSize: selected ? symbolSize + 4 : symbolSize,
|
||||
itemStyle: {
|
||||
color: selected ? accentSelected : accentSubtle,
|
||||
borderColor: accent,
|
||||
borderWidth: selected ? 3 : 2
|
||||
color: typeStyle.color,
|
||||
borderColor: selected ? accent : typeStyle.borderColor,
|
||||
borderWidth: selected ? 2.5 : 1.5
|
||||
},
|
||||
label: {
|
||||
show: !dense || selected,
|
||||
show: showLabel || selected,
|
||||
color: textPrimary,
|
||||
fontSize: dense ? 11 : 12,
|
||||
fontWeight: 700,
|
||||
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)}…`
|
||||
@@ -163,15 +237,19 @@ function createOption({
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
itemStyle: {
|
||||
borderColor: accent,
|
||||
borderWidth: 2.5
|
||||
},
|
||||
label: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
select: {
|
||||
itemStyle: {
|
||||
color: accentSelected,
|
||||
color: typeStyle.color,
|
||||
borderColor: accent,
|
||||
borderWidth: 3
|
||||
borderWidth: 2.5
|
||||
},
|
||||
label: {
|
||||
show: true
|
||||
@@ -186,14 +264,14 @@ function createOption({
|
||||
value: relation.type,
|
||||
description: relation.description,
|
||||
lineStyle: {
|
||||
color: textMuted,
|
||||
width: 1.5,
|
||||
curveness: 0.08
|
||||
color: borderDefault,
|
||||
width: 1.2,
|
||||
opacity: 0.72,
|
||||
curveness: 0.06
|
||||
}
|
||||
})),
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: 8,
|
||||
autoCurveness: true,
|
||||
edgeSymbolSize: 6,
|
||||
edgeLabel: {
|
||||
show: showEdgeLabels,
|
||||
color: textSecondary,
|
||||
@@ -229,12 +307,23 @@ export function KnowledgeGraphChart({
|
||||
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 zoomRef = useRef(zoom)
|
||||
const appliedZoomRef = useRef<number | undefined>(undefined)
|
||||
const dataRevision = useMemo(
|
||||
() => graphRevision(nodes, relations),
|
||||
[nodes, relations]
|
||||
)
|
||||
const [themeRevision, setThemeRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
nodesRef.current = nodes
|
||||
relationsRef.current = relations
|
||||
}, [nodes, relations])
|
||||
|
||||
useEffect(() => {
|
||||
onMoveNodeRef.current = onMoveNode
|
||||
onSelectNodeRef.current = onSelectNode
|
||||
@@ -417,8 +506,8 @@ export function KnowledgeGraphChart({
|
||||
return
|
||||
}
|
||||
const option = createOption({
|
||||
nodes,
|
||||
relations,
|
||||
nodes: nodesRef.current,
|
||||
relations: relationsRef.current,
|
||||
selectedNodeId: undefined,
|
||||
zoom: zoomRef.current
|
||||
})
|
||||
@@ -437,7 +526,7 @@ export function KnowledgeGraphChart({
|
||||
{ notMerge: true }
|
||||
)
|
||||
appliedZoomRef.current = zoomRef.current
|
||||
}, [nodes, relations, themeRevision])
|
||||
}, [dataRevision, themeRevision])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current
|
||||
@@ -466,7 +555,7 @@ export function KnowledgeGraphChart({
|
||||
seriesIndex: 0
|
||||
})
|
||||
const dataIndex = selectedNodeId
|
||||
? nodes.findIndex((node) => node.id === selectedNodeId)
|
||||
? nodesRef.current.findIndex((node) => node.id === selectedNodeId)
|
||||
: -1
|
||||
if (dataIndex >= 0) {
|
||||
chart.dispatchAction({
|
||||
@@ -475,7 +564,7 @@ export function KnowledgeGraphChart({
|
||||
dataIndex
|
||||
})
|
||||
}
|
||||
}, [nodes, selectedNodeId, themeRevision])
|
||||
}, [dataRevision, selectedNodeId, themeRevision])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -129,6 +129,7 @@ function createProps(
|
||||
onCreateLibrary: vi.fn(),
|
||||
onDeleteLibrary: vi.fn(),
|
||||
onUpdateLibrary: vi.fn(),
|
||||
onReextractGraph: vi.fn(),
|
||||
onImportFiles: vi.fn(),
|
||||
onImportDirectory: vi.fn(),
|
||||
onImportUrl: vi.fn(),
|
||||
@@ -223,6 +224,91 @@ describe('KnowledgeWorkspace', () => {
|
||||
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses shared tabs and keeps graph configuration in settings', () => {
|
||||
const onUpdateLibrary = vi.fn()
|
||||
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
|
||||
|
||||
const tabs = screen.getByRole('tablist', { name: '知识库视图' })
|
||||
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
|
||||
.toEqual(['文档与来源', '知识图谱', '任务中心', '设置'])
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '知识图谱' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /启用知识图谱/u }))
|
||||
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
|
||||
graphEnabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows parsing, embedding, and graph progress in the task center', () => {
|
||||
render(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
libraryId: 'library-1',
|
||||
documentId: 'document-1',
|
||||
documentName: '架构说明.md',
|
||||
kind: 'graph',
|
||||
status: 'running',
|
||||
progress: 40,
|
||||
message: '正在重新抽取知识图谱',
|
||||
createdAt: '2026-08-10T08:00:00.000Z',
|
||||
startedAt: '2026-08-10T08:00:01.000Z'
|
||||
}
|
||||
]
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: /^任务中心/u })
|
||||
)
|
||||
expect(screen.getByText('图谱抽取')).toBeInTheDocument()
|
||||
expect(screen.getByText('正在重新抽取知识图谱')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: '架构说明.md 图谱抽取进度'
|
||||
})
|
||||
).toHaveValue(40)
|
||||
})
|
||||
|
||||
it('edits library metadata from the detail header', async () => {
|
||||
const onUpdateLibrary = vi.fn()
|
||||
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
fireEvent.change(screen.getByLabelText('名称'), {
|
||||
target: { value: '研发知识' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: '研发资料与设计说明' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
|
||||
name: '研发知识',
|
||||
description: '研发资料与设计说明'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reextracts the graph from the graph tab', async () => {
|
||||
const onReextractGraph = vi.fn()
|
||||
render(<KnowledgeWorkspace {...createProps({ onReextractGraph })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新抽取' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onReextractGraph).toHaveBeenCalledWith('library-1')
|
||||
)
|
||||
})
|
||||
|
||||
it('renders and filters graph nodes with their relationships', async () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
|
||||
@@ -335,7 +421,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
|
||||
it('manages the graph chart, zoom, selection, movement, and cleanup', () => {
|
||||
const onMoveNode = vi.fn()
|
||||
const { unmount } = render(
|
||||
const { rerender, unmount } = render(
|
||||
<KnowledgeWorkspace {...createProps({ onMoveNode })} />
|
||||
)
|
||||
|
||||
@@ -351,15 +437,27 @@ describe('KnowledgeWorkspace', () => {
|
||||
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',
|
||||
@@ -371,6 +469,12 @@ describe('KnowledgeWorkspace', () => {
|
||||
}),
|
||||
{ notMerge: true }
|
||||
)
|
||||
const stableOptionCallCount =
|
||||
echartsMock.chart.setOption.mock.calls.length
|
||||
rerender(<KnowledgeWorkspace {...createProps({ onMoveNode })} />)
|
||||
expect(echartsMock.chart.setOption).toHaveBeenCalledTimes(
|
||||
stableOptionCallCount
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
|
||||
expect(screen.getByText('115%')).toBeInTheDocument()
|
||||
@@ -491,7 +595,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
delete document.documentElement.dataset.theme
|
||||
})
|
||||
|
||||
it('reduces labels and node size for dense graphs', () => {
|
||||
it('sizes dense nodes by degree and labels key entities', () => {
|
||||
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `entity-${index}`,
|
||||
label: `实体 ${index}`,
|
||||
@@ -501,7 +605,23 @@ describe('KnowledgeWorkspace', () => {
|
||||
}))
|
||||
render(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ graphNodes, graphRelations: [] })}
|
||||
{...createProps({
|
||||
graphNodes,
|
||||
graphRelations: [
|
||||
{
|
||||
id: 'relation-dense-1',
|
||||
sourceId: 'entity-0',
|
||||
targetId: 'entity-1',
|
||||
type: '关联'
|
||||
},
|
||||
{
|
||||
id: 'relation-dense-2',
|
||||
sourceId: 'entity-0',
|
||||
targetId: 'entity-2',
|
||||
type: '关联'
|
||||
}
|
||||
]
|
||||
})}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
|
||||
@@ -513,13 +633,27 @@ describe('KnowledgeWorkspace', () => {
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'entity-0',
|
||||
symbolSize: 24,
|
||||
symbolSize: 32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
label: expect.objectContaining({
|
||||
position: 'right',
|
||||
show: true
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'entity-29',
|
||||
symbolSize: 16,
|
||||
label: expect.objectContaining({ show: false })
|
||||
})
|
||||
]),
|
||||
edgeLabel: expect.objectContaining({ show: false }),
|
||||
force: expect.objectContaining({
|
||||
repulsion: 220
|
||||
edgeLength: [70, 130],
|
||||
friction: 0.08,
|
||||
gravity: 0.06,
|
||||
layoutAnimation: true,
|
||||
repulsion: 280
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -529,8 +663,8 @@ describe('KnowledgeWorkspace', () => {
|
||||
const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as {
|
||||
series?: Array<{ data?: Array<Record<string, unknown>> }>
|
||||
}
|
||||
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('x')
|
||||
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('y')
|
||||
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 () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FolderOpen,
|
||||
GitMerge,
|
||||
Link2,
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
Network,
|
||||
Pencil,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
X,
|
||||
@@ -136,6 +138,21 @@ export type KnowledgeEvidence = {
|
||||
location?: string
|
||||
}
|
||||
|
||||
export type KnowledgeTaskItem = {
|
||||
id: string
|
||||
libraryId: string
|
||||
sourceId?: string
|
||||
documentId?: string
|
||||
documentName: string
|
||||
kind: 'parsing' | 'embedding' | 'graph'
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped'
|
||||
progress: number
|
||||
message?: string
|
||||
createdAt: string
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export type KnowledgeEntityUpdate = {
|
||||
label: string
|
||||
type: string
|
||||
@@ -158,6 +175,7 @@ export type KnowledgeWorkspaceProps = {
|
||||
graphNodes: readonly KnowledgeGraphNode[]
|
||||
graphRelations: readonly KnowledgeGraphRelation[]
|
||||
evidence: readonly KnowledgeEvidence[]
|
||||
tasks?: readonly KnowledgeTaskItem[]
|
||||
loading?: boolean
|
||||
loadError?: string
|
||||
onRetryLoad: () => void | Promise<void>
|
||||
@@ -169,10 +187,13 @@ export type KnowledgeWorkspaceProps = {
|
||||
onUpdateLibrary: (
|
||||
libraryId: string,
|
||||
update: {
|
||||
graphEnabled: boolean
|
||||
graphStrategy: KnowledgeGraphStrategy
|
||||
name?: string
|
||||
description?: string
|
||||
graphEnabled?: boolean
|
||||
graphStrategy?: KnowledgeGraphStrategy
|
||||
}
|
||||
) => void | Promise<void>
|
||||
onReextractGraph: (libraryId: string) => void | Promise<void>
|
||||
onImportFiles: (
|
||||
libraryId: string,
|
||||
files: File[],
|
||||
@@ -219,7 +240,7 @@ export type KnowledgeWorkspaceProps = {
|
||||
onOpenEvidence?: (evidence: KnowledgeEvidence) => void
|
||||
}
|
||||
|
||||
type WorkspaceTab = 'documents' | 'graph'
|
||||
type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings'
|
||||
|
||||
const storageModeLabels: Record<KnowledgeStorageMode, string> = {
|
||||
reference: '引用原文件',
|
||||
@@ -590,6 +611,147 @@ function CreateLibraryWizard({
|
||||
)
|
||||
}
|
||||
|
||||
function EditLibraryDialog({
|
||||
library,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
library: KnowledgeLibrary
|
||||
onCancel: () => void
|
||||
onConfirm: (update: {
|
||||
name: string
|
||||
description: string
|
||||
}) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [name, setName] = useState(library.name)
|
||||
const [description, setDescription] = useState(library.description ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = async (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const normalizedName = name.trim()
|
||||
if (!normalizedName) {
|
||||
setError('请输入知识库名称')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm({
|
||||
name: normalizedName,
|
||||
description: description.trim()
|
||||
})
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="编辑知识库"
|
||||
aria-modal="true"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !saving) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 20,
|
||||
background: 'var(--overlay-backdrop)'
|
||||
}}
|
||||
>
|
||||
<form
|
||||
aria-label="编辑知识库表单"
|
||||
onSubmit={(event) => void submit(event)}
|
||||
style={{
|
||||
...styles.surface,
|
||||
display: 'grid',
|
||||
width: 'min(480px, 100%)',
|
||||
padding: 20,
|
||||
boxShadow: 'var(--shadow-dialog)',
|
||||
gap: 14
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>编辑知识库</h2>
|
||||
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
|
||||
修改名称和说明不会改变来源、索引或知识图谱。
|
||||
</p>
|
||||
</div>
|
||||
<label style={styles.label}>
|
||||
名称
|
||||
<input
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
ref={nameRef}
|
||||
style={styles.input}
|
||||
value={name}
|
||||
/>
|
||||
</label>
|
||||
<label style={styles.label}>
|
||||
描述
|
||||
<textarea
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
rows={4}
|
||||
style={{ ...styles.input, resize: 'vertical' }}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving}
|
||||
style={styles.button}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<Check aria-hidden="true" size={15} />
|
||||
)}
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteLibraryDialog({
|
||||
library,
|
||||
onCancel,
|
||||
@@ -1488,10 +1650,192 @@ function RelationForm({
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeSettingsView({
|
||||
library,
|
||||
onUpdateLibrary
|
||||
}: {
|
||||
library: KnowledgeLibrary
|
||||
onUpdateLibrary: KnowledgeWorkspaceProps['onUpdateLibrary']
|
||||
}): React.JSX.Element {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const update = async (
|
||||
change: Parameters<KnowledgeWorkspaceProps['onUpdateLibrary']>[1]
|
||||
): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onUpdateLibrary(library.id, change)
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="knowledge-settings">
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-settings-title"
|
||||
style={{ ...styles.surface, padding: 16 }}
|
||||
>
|
||||
<div>
|
||||
<h3 id="knowledge-graph-settings-title" style={{ margin: 0 }}>
|
||||
知识图谱
|
||||
</h3>
|
||||
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
|
||||
控制是否从知识库文档中抽取实体、关系和证据。
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className="knowledge-settings__toggle"
|
||||
style={{ ...styles.surface, cursor: saving ? 'wait' : 'pointer' }}
|
||||
>
|
||||
<input
|
||||
checked={library.graphEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void update({ graphEnabled: event.currentTarget.checked })
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>启用知识图谱</strong>
|
||||
<span style={styles.muted}>
|
||||
启用后,新导入和重新同步的文档会按所选策略抽取图谱。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={styles.label}>
|
||||
图谱抽取策略
|
||||
<select
|
||||
aria-label="知识图谱抽取策略"
|
||||
disabled={!library.graphEnabled || saving}
|
||||
onChange={(event) =>
|
||||
void update({
|
||||
graphStrategy:
|
||||
event.currentTarget.value as KnowledgeGraphStrategy
|
||||
})
|
||||
}
|
||||
style={styles.input}
|
||||
value={library.graphStrategy}
|
||||
>
|
||||
{Object.entries(strategyLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.muted}>
|
||||
“按需询问”不会自动生成图谱,也不能执行重新抽取。
|
||||
</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const taskKindLabels: Record<KnowledgeTaskItem['kind'], string> = {
|
||||
parsing: '文档解析',
|
||||
embedding: '向量化',
|
||||
graph: '图谱抽取'
|
||||
}
|
||||
|
||||
const taskStatusLabels: Record<KnowledgeTaskItem['status'], string> = {
|
||||
queued: '等待中',
|
||||
running: '进行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
skipped: '已跳过'
|
||||
}
|
||||
|
||||
function KnowledgeTasksView({
|
||||
tasks
|
||||
}: {
|
||||
tasks: readonly KnowledgeTaskItem[]
|
||||
}): React.JSX.Element {
|
||||
const activeCount = tasks.filter(
|
||||
(task) => task.status === 'queued' || task.status === 'running'
|
||||
).length
|
||||
const failedCount = tasks.filter(
|
||||
(task) => task.status === 'failed'
|
||||
).length
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="导入或同步文档后,可以在这里查看解析、向量化和图谱抽取进度。"
|
||||
icon={<ListChecks size={30} />}
|
||||
level="section"
|
||||
title="还没有知识任务"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="knowledge-tasks-title">
|
||||
<div className="knowledge-tasks__summary">
|
||||
<div>
|
||||
<h3 id="knowledge-tasks-title" style={{ margin: 0 }}>
|
||||
任务中心
|
||||
</h3>
|
||||
<p style={{ ...styles.muted, margin: '5px 0 0' }}>
|
||||
最近 {tasks.length} 个任务
|
||||
</p>
|
||||
</div>
|
||||
<div className="knowledge-tasks__metrics">
|
||||
<span>进行中 {activeCount}</span>
|
||||
<span>失败 {failedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="knowledge-task-list">
|
||||
{tasks.map((task) => (
|
||||
<li className="knowledge-task" key={task.id}>
|
||||
<div className="knowledge-task__heading">
|
||||
<div>
|
||||
<strong>{task.documentName}</strong>
|
||||
<span>{taskKindLabels[task.kind]}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`knowledge-task__status knowledge-task__status--${task.status}`}
|
||||
>
|
||||
{taskStatusLabels[task.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="knowledge-task__progress">
|
||||
<progress
|
||||
aria-label={`${task.documentName} ${taskKindLabels[task.kind]}进度`}
|
||||
max={100}
|
||||
value={task.progress}
|
||||
/>
|
||||
<span>{task.progress}%</span>
|
||||
</div>
|
||||
<div className="knowledge-task__meta">
|
||||
<span>{task.message || '等待处理'}</span>
|
||||
<time dateTime={task.completedAt ?? task.startedAt ?? task.createdAt}>
|
||||
{new Date(
|
||||
task.completedAt ?? task.startedAt ?? task.createdAt
|
||||
).toLocaleString('zh-CN')}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GraphView({
|
||||
evidence,
|
||||
graphNodes,
|
||||
graphRelations,
|
||||
libraryId,
|
||||
onCreateEntity,
|
||||
onCreateRelation,
|
||||
onDeleteEntity,
|
||||
@@ -1499,6 +1843,7 @@ function GraphView({
|
||||
onMergeEntities,
|
||||
onMoveNode,
|
||||
onOpenEvidence,
|
||||
onReextractGraph,
|
||||
onUpdateEntity,
|
||||
onUpdateRelation
|
||||
}: Pick<
|
||||
@@ -1513,9 +1858,12 @@ function GraphView({
|
||||
| 'onMergeEntities'
|
||||
| 'onMoveNode'
|
||||
| 'onOpenEvidence'
|
||||
| 'onReextractGraph'
|
||||
| 'onUpdateEntity'
|
||||
| 'onUpdateRelation'
|
||||
>): React.JSX.Element {
|
||||
> & {
|
||||
libraryId: string
|
||||
}): React.JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>()
|
||||
@@ -1526,6 +1874,8 @@ function GraphView({
|
||||
const [mergeTargetId, setMergeTargetId] = useState('')
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [relationsExpanded, setRelationsExpanded] = useState(false)
|
||||
const [reextracting, setReextracting] = useState(false)
|
||||
const [reextractError, setReextractError] = useState<string>()
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => new Map(graphNodes.map((node) => [node.id, node])),
|
||||
@@ -1658,6 +2008,22 @@ function GraphView({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={reextracting}
|
||||
onClick={() => {
|
||||
setReextracting(true)
|
||||
setReextractError(undefined)
|
||||
void Promise.resolve(onReextractGraph(libraryId))
|
||||
.catch((reason) => setReextractError(toErrorMessage(reason)))
|
||||
.finally(() => setReextracting(false))
|
||||
}}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
{reextracting ? '重新抽取中…' : '重新抽取'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
@@ -1704,6 +2070,14 @@ function GraphView({
|
||||
>
|
||||
<ZoomIn aria-hidden="true" size={16} />
|
||||
</button>
|
||||
{reextractError && (
|
||||
<span
|
||||
className="knowledge-graph__toolbar-error"
|
||||
role="alert"
|
||||
>
|
||||
{reextractError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{graphNodes.length === 0 ? (
|
||||
<div
|
||||
@@ -2117,6 +2491,7 @@ export function KnowledgeWorkspace({
|
||||
graphNodes,
|
||||
graphRelations,
|
||||
evidence,
|
||||
tasks = [],
|
||||
loading = false,
|
||||
loadError,
|
||||
onRetryLoad,
|
||||
@@ -2124,6 +2499,7 @@ export function KnowledgeWorkspace({
|
||||
onCreateLibrary,
|
||||
onDeleteLibrary,
|
||||
onUpdateLibrary,
|
||||
onReextractGraph,
|
||||
onImportFiles,
|
||||
onImportDirectory,
|
||||
onImportUrl,
|
||||
@@ -2144,8 +2520,11 @@ export function KnowledgeWorkspace({
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [mobileListOpen, setMobileListOpen] = useState(false)
|
||||
const [tab, setTab] = useState<WorkspaceTab>('documents')
|
||||
const [editingLibrary, setEditingLibrary] =
|
||||
useState<KnowledgeLibrary>()
|
||||
const [deletingLibrary, setDeletingLibrary] =
|
||||
useState<KnowledgeLibrary>()
|
||||
const editLibraryTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const deleteLibraryTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const selectedLibrary =
|
||||
libraries.find((library) => library.id === selectedLibraryId) ??
|
||||
@@ -2158,6 +2537,9 @@ export function KnowledgeWorkspace({
|
||||
(document) => document.libraryId === selectedLibrary.id
|
||||
)
|
||||
: []
|
||||
const libraryTasks = selectedLibrary
|
||||
? tasks.filter((task) => task.libraryId === selectedLibrary.id)
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -2167,24 +2549,35 @@ export function KnowledgeWorkspace({
|
||||
onSelectLibrary(selectedLibrary.id)
|
||||
}
|
||||
}, [onSelectLibrary, selectedLibrary, selectedLibraryId])
|
||||
const visibleTab =
|
||||
selectedLibrary?.graphEnabled === false ? 'documents' : tab
|
||||
const visibleTab = tab
|
||||
const workspaceTabs: ReadonlyArray<PageTab<WorkspaceTab>> = [
|
||||
{
|
||||
id: 'documents',
|
||||
label: '文档与来源',
|
||||
icon: <FileText aria-hidden="true" size={15} />
|
||||
},
|
||||
...(selectedLibrary?.graphEnabled
|
||||
? [
|
||||
{
|
||||
id: 'graph' as const,
|
||||
label: '知识图谱',
|
||||
icon: <Network aria-hidden="true" size={15} />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
{
|
||||
id: 'graph',
|
||||
label: '知识图谱',
|
||||
icon: <Network aria-hidden="true" size={15} />
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
label: '任务中心',
|
||||
icon: <ListChecks aria-hidden="true" size={15} />
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '设置',
|
||||
icon: <Settings2 aria-hidden="true" size={15} />
|
||||
}
|
||||
]
|
||||
const closeEditDialog = (): void => {
|
||||
setEditingLibrary(undefined)
|
||||
requestAnimationFrame(() =>
|
||||
editLibraryTriggerRef.current?.focus()
|
||||
)
|
||||
}
|
||||
const closeDeleteDialog = (): void => {
|
||||
setDeletingLibrary(undefined)
|
||||
requestAnimationFrame(() =>
|
||||
@@ -2445,49 +2838,16 @@ export function KnowledgeWorkspace({
|
||||
<div
|
||||
className="knowledge-workspace__header-actions"
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
gap: 6
|
||||
}}
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setEditingLibrary(selectedLibrary)}
|
||||
ref={editLibraryTriggerRef}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
<input
|
||||
checked={selectedLibrary.graphEnabled}
|
||||
onChange={(event) =>
|
||||
void onUpdateLibrary(selectedLibrary.id, {
|
||||
graphEnabled: event.currentTarget.checked,
|
||||
graphStrategy: selectedLibrary.graphStrategy
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
知识图谱
|
||||
</label>
|
||||
{selectedLibrary.graphEnabled && (
|
||||
<select
|
||||
aria-label="知识图谱抽取策略"
|
||||
className="knowledge-workspace__strategy"
|
||||
onChange={(event) =>
|
||||
void onUpdateLibrary(selectedLibrary.id, {
|
||||
graphEnabled: true,
|
||||
graphStrategy:
|
||||
event.currentTarget
|
||||
.value as KnowledgeGraphStrategy
|
||||
})
|
||||
}
|
||||
style={styles.input}
|
||||
value={selectedLibrary.graphStrategy}
|
||||
>
|
||||
{Object.entries(strategyLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除知识库 ${selectedLibrary.name}`}
|
||||
className="danger-button danger-button--quiet"
|
||||
@@ -2497,7 +2857,7 @@ export function KnowledgeWorkspace({
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
删除知识库
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -2529,11 +2889,13 @@ export function KnowledgeWorkspace({
|
||||
onSyncSource={onSyncSource}
|
||||
sources={librarySources}
|
||||
/>
|
||||
) : (
|
||||
) : visibleTab === 'graph' &&
|
||||
selectedLibrary.graphEnabled ? (
|
||||
<GraphView
|
||||
evidence={evidence}
|
||||
graphNodes={graphNodes}
|
||||
graphRelations={graphRelations}
|
||||
libraryId={selectedLibrary.id}
|
||||
onCreateEntity={onCreateEntity}
|
||||
onCreateRelation={onCreateRelation}
|
||||
onDeleteEntity={onDeleteEntity}
|
||||
@@ -2541,14 +2903,49 @@ export function KnowledgeWorkspace({
|
||||
onMergeEntities={onMergeEntities}
|
||||
onMoveNode={onMoveNode}
|
||||
onOpenEvidence={onOpenEvidence}
|
||||
onReextractGraph={onReextractGraph}
|
||||
onUpdateEntity={onUpdateEntity}
|
||||
onUpdateRelation={onUpdateRelation}
|
||||
/>
|
||||
) : visibleTab === 'graph' ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setTab('settings')}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
<Settings2 aria-hidden="true" size={15} />
|
||||
前往设置
|
||||
</button>
|
||||
}
|
||||
description="在“设置”中启用知识图谱后,可以查看实体关系并重新抽取。"
|
||||
icon={<Network size={30} />}
|
||||
level="section"
|
||||
title="知识图谱未启用"
|
||||
/>
|
||||
) : visibleTab === 'tasks' ? (
|
||||
<KnowledgeTasksView tasks={libraryTasks} />
|
||||
) : (
|
||||
<KnowledgeSettingsView
|
||||
library={selectedLibrary}
|
||||
onUpdateLibrary={onUpdateLibrary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{editingLibrary && (
|
||||
<EditLibraryDialog
|
||||
library={editingLibrary}
|
||||
onCancel={closeEditDialog}
|
||||
onConfirm={(update) =>
|
||||
onUpdateLibrary(editingLibrary.id, update)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{deletingLibrary && (
|
||||
<DeleteLibraryDialog
|
||||
library={deletingLibrary}
|
||||
|
||||
@@ -497,6 +497,30 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(content).toHaveClass('settings-panel__content')
|
||||
})
|
||||
|
||||
it('omits the redundant close-only footer on passive settings pages', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
presentation="page"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '关闭设置' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen
|
||||
.getByRole('region', { name: '设置中心' })
|
||||
.querySelector('.settings-panel__footer')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses one first-level heading for the settings page', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -346,6 +346,11 @@ export function SettingsPanel({
|
||||
activeTab === 'runtime' ||
|
||||
activeTab === 'security' ||
|
||||
activeTab === 'roles'
|
||||
const showFooter =
|
||||
presentation !== 'page' ||
|
||||
configurationTab ||
|
||||
Boolean(error) ||
|
||||
saved
|
||||
|
||||
const handleTabKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
@@ -2297,49 +2302,55 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')) && (
|
||||
{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="secondary-button"
|
||||
className="primary-button"
|
||||
disabled={saving || testing}
|
||||
onClick={() => void testConnection()}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{testing
|
||||
? '测试中…'
|
||||
: activeTab === 'model'
|
||||
? '保存并测试模型'
|
||||
: agentRuntimeType === 'opencode'
|
||||
? '保存并测试 OpenCode'
|
||||
: '保存并测试 Continue'}
|
||||
{saving ? '保存中…' : '保存设置'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving || testing}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{saving ? '保存中…' : '保存设置'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
+740
-583
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user