feat: redesign knowledge workspace
This commit is contained in:
+200
-30
@@ -530,6 +530,89 @@ const api: DesktopApi = {
|
||||
retrySource: vi.fn(async () => {}),
|
||||
removeSource: vi.fn(async () => {}),
|
||||
search: vi.fn(async () => []),
|
||||
retrieve: vi.fn(async (input) => ({
|
||||
query: input.query,
|
||||
durationMs: 0,
|
||||
settings: input.settings ?? {
|
||||
version: 1,
|
||||
topK: 6,
|
||||
minimumVectorSimilarity: 0,
|
||||
ftsWeight: 1,
|
||||
vectorWeight: 1,
|
||||
graphWeight: 0.8,
|
||||
candidateMultiplier: 4,
|
||||
contextMaxCharacters: 16_000,
|
||||
adjacentChunkCount: 0,
|
||||
localRerankEnabled: false,
|
||||
rerankMode: 'none'
|
||||
},
|
||||
diagnostics: {
|
||||
requestedChannels: [],
|
||||
usedChannels: [],
|
||||
degradedChannels: [],
|
||||
candidateCounts: {},
|
||||
channelDurationMs: {},
|
||||
vectorScannedCount: 0,
|
||||
filteredByThresholdCount: 0,
|
||||
filteredByBudgetCount: 0,
|
||||
rerank: {
|
||||
requested: 'none' as const,
|
||||
used: 'none' as const,
|
||||
status: 'skipped' as const,
|
||||
candidateCount: 0,
|
||||
durationMs: 0
|
||||
}
|
||||
},
|
||||
results: [],
|
||||
context: {
|
||||
characterCount: 0,
|
||||
truncated: false,
|
||||
groups: []
|
||||
}
|
||||
})),
|
||||
updateSettings: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
listChunks: vi.fn(async () => ({
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalItems: 0
|
||||
})),
|
||||
updateChunk: vi.fn(async () => {}),
|
||||
deleteChunk: vi.fn(async () => {}),
|
||||
rebuildDocument: vi.fn(async () => ({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
})),
|
||||
rebuildLibrary: vi.fn(async () => ({
|
||||
rebuilt: 0,
|
||||
failed: 0
|
||||
})),
|
||||
cancelRebuild: vi.fn(async () => true),
|
||||
getEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({
|
||||
knowledgeBaseId,
|
||||
enabled: false,
|
||||
coverage: { total: 0, indexed: 0, missing: 0, error: 0 },
|
||||
indexStatus: { job: null }
|
||||
})),
|
||||
rebuildEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({
|
||||
knowledgeBaseId,
|
||||
enabled: false,
|
||||
coverage: { total: 0, indexed: 0, missing: 0, error: 0 },
|
||||
indexStatus: { job: null }
|
||||
})),
|
||||
cancelEmbeddingIndex: vi.fn(async () => false),
|
||||
cancelTask: vi.fn(async () => false),
|
||||
retryTask: vi.fn(async () => {}),
|
||||
getReferenceContext: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
openReferenceSource: vi.fn(async () => {}),
|
||||
createEntity: vi.fn(async () => {}),
|
||||
updateEntity: vi.fn(async () => {}),
|
||||
moveEntity: vi.fn(async () => {}),
|
||||
@@ -1422,7 +1505,8 @@ describe('App', () => {
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
expect(request).toMatchObject({
|
||||
prompt: '发布流程是什么?',
|
||||
knowledgeLibraryIds: [libraryId]
|
||||
knowledgeLibraryIds: [libraryId],
|
||||
knowledgeRetrievalMode: 'auto'
|
||||
})
|
||||
expect(api.knowledge.search).not.toHaveBeenCalled()
|
||||
expect(
|
||||
@@ -1441,6 +1525,7 @@ describe('App', () => {
|
||||
libraryId,
|
||||
libraryName: '产品知识',
|
||||
documentId: crypto.randomUUID(),
|
||||
chunkId: crypto.randomUUID(),
|
||||
documentName: `发布手册 ${batch}-${index}`,
|
||||
sourceName: `release-${batch}-${index}.md`,
|
||||
locator: `第 ${batch}-${index} 节`,
|
||||
@@ -1457,6 +1542,35 @@ describe('App', () => {
|
||||
expect(
|
||||
await screen.findByText('查看 20 条证据引用')
|
||||
).toBeInTheDocument()
|
||||
vi.mocked(api.knowledge.getReferenceContext).mockResolvedValueOnce({
|
||||
knowledgeBaseId: libraryId,
|
||||
documentId: 'document-context',
|
||||
chunkId: 'chunk-context',
|
||||
documentTitle: '发布手册',
|
||||
sourceDisplayName: 'release.md',
|
||||
locator: '第 4 节',
|
||||
matchedContent: '命中分块内容',
|
||||
contextContent: '上文\n\n命中分块内容\n\n下文',
|
||||
contextChunkIds: ['chunk-context'],
|
||||
truncated: false
|
||||
})
|
||||
fireEvent.click(screen.getByText('查看 20 条证据引用'))
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '查看上下文' })[0]!
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '引用上下文' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.knowledge.getReferenceContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
knowledgeBaseId: libraryId
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', {
|
||||
name: '关闭引用上下文'
|
||||
})[0]!
|
||||
)
|
||||
await waitFor(
|
||||
() => {
|
||||
const persistedMessages = vi
|
||||
@@ -1472,12 +1586,96 @@ describe('App', () => {
|
||||
(message) => message.sourceReferences?.length === 20
|
||||
)
|
||||
expect(persisted?.sourceReferences).toHaveLength(20)
|
||||
expect(persisted?.sources).toHaveLength(100)
|
||||
expect(persisted?.sources).toBeUndefined()
|
||||
},
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('persists and submits always-retrieve mode for the active conversation', async () => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
|
||||
libraries: [
|
||||
{
|
||||
id: libraryId,
|
||||
name: '产品知识',
|
||||
description: '',
|
||||
storageMode: 'managed',
|
||||
graphEnabled: false,
|
||||
graphStrategy: 'rules',
|
||||
sourceCount: 1,
|
||||
documentCount: 1,
|
||||
indexedDocumentCount: 1
|
||||
}
|
||||
],
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
})
|
||||
render(<App />)
|
||||
const knowledgeScope = await screen.findByRole('button', {
|
||||
name: '选择知识库,本次已启用 1 个'
|
||||
})
|
||||
fireEvent.click(knowledgeScope)
|
||||
const retrievalMode = screen.getByRole('group', {
|
||||
name: '知识检索方式'
|
||||
})
|
||||
fireEvent.click(
|
||||
within(retrievalMode).getByRole('button', {
|
||||
name: '每次先检索'
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '必须查询发布流程' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({
|
||||
prompt: '必须查询发布流程',
|
||||
knowledgeLibraryIds: [libraryId],
|
||||
knowledgeRetrievalMode: 'always'
|
||||
})
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'knowledge-retrieval',
|
||||
mode: 'always',
|
||||
state: 'degraded',
|
||||
libraryCount: 1,
|
||||
resultCount: 2,
|
||||
durationMs: 18,
|
||||
usedChannels: ['fts', 'cjk'],
|
||||
warnings: ['向量模型未配置']
|
||||
})
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('知识检索已降级')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/已检索 1 个知识库,获得 2 条结果/u)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('向量模型未配置')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
const snapshots = vi
|
||||
.mocked(api.conversations.replace)
|
||||
.mock.calls.at(-1)?.[0]
|
||||
expect(
|
||||
snapshots?.some(
|
||||
(conversation) =>
|
||||
conversation.knowledgeRetrievalMode === 'always'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a running response visible when cancellation fails', async () => {
|
||||
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
|
||||
new Error('cancel failed')
|
||||
@@ -2358,34 +2556,6 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes a legacy Plan project default to Ask', async () => {
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
{
|
||||
...project,
|
||||
defaultWorkMode: 'plan'
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByRole('button', {
|
||||
name: '工作模式:Ask · 只读问答'
|
||||
})
|
||||
expect(mode).toBeEnabled()
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '制定发布方案' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '制定发布方案',
|
||||
workMode: 'ask'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('restores and persists the last active project', async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
|
||||
+366
-16
@@ -56,6 +56,7 @@ import type {
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
ContextFileSelectionProgress,
|
||||
KnowledgeRetrievalMode,
|
||||
KnowledgeSearchReference,
|
||||
KnowledgeSnapshot,
|
||||
RuntimeSettings
|
||||
@@ -104,6 +105,10 @@ import {
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
|
||||
import {
|
||||
KnowledgeCitationDialog,
|
||||
type KnowledgeCitationContextView
|
||||
} from './KnowledgeCitationDialog'
|
||||
import { HeartbeatCenter } from './HeartbeatCenter'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
@@ -111,6 +116,7 @@ import {
|
||||
DestructiveConfirmActions,
|
||||
EmptyState,
|
||||
PageShell,
|
||||
SegmentedControl,
|
||||
ScopeBadge
|
||||
} from './WorkspacePrimitives'
|
||||
import {
|
||||
@@ -289,7 +295,7 @@ function isAgentRuntime(
|
||||
function supportsSubagentSmartRouting(
|
||||
workMode: string
|
||||
): boolean {
|
||||
return workMode === 'ask' || ['plan'].includes(workMode)
|
||||
return workMode === 'ask'
|
||||
}
|
||||
|
||||
type ToolActivity = ConversationToolActivity
|
||||
@@ -304,6 +310,11 @@ type SubagentActivity = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type KnowledgeRetrievalStatus = Omit<
|
||||
Extract<AgentEvent, { type: 'knowledge-retrieval' }>,
|
||||
'requestId' | 'type'
|
||||
>
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
@@ -326,6 +337,7 @@ type Message = {
|
||||
question?: Extract<AgentEvent, { type: 'question' }>
|
||||
sources?: string[]
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
knowledgeRetrieval?: KnowledgeRetrievalStatus
|
||||
artifactIds?: string[]
|
||||
attachments?: ConversationAttachment[]
|
||||
}
|
||||
@@ -334,6 +346,7 @@ type Conversation = {
|
||||
id: string
|
||||
projectId?: string
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
knowledgeRetrievalMode?: KnowledgeRetrievalMode
|
||||
remote?: ConversationSnapshot['remote']
|
||||
title: string
|
||||
updatedAt: number
|
||||
@@ -586,6 +599,7 @@ function createConversation(
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
runtimeSelection,
|
||||
knowledgeRetrievalMode: 'auto',
|
||||
title: '新对话',
|
||||
updatedAt: now,
|
||||
messages: [
|
||||
@@ -693,6 +707,9 @@ function isConversation(value: unknown): value is Conversation {
|
||||
(item.runtimeSelection === undefined ||
|
||||
agentRuntimeSelectionSchema.safeParse(item.runtimeSelection)
|
||||
.success) &&
|
||||
(item.knowledgeRetrievalMode === undefined ||
|
||||
item.knowledgeRetrievalMode === 'auto' ||
|
||||
item.knowledgeRetrievalMode === 'always') &&
|
||||
(item.remote === undefined ||
|
||||
(typeof item.remote === 'object' &&
|
||||
item.remote !== null &&
|
||||
@@ -744,6 +761,7 @@ function toConversationSnapshots(
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
remote: conversation.remote,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
@@ -759,6 +777,7 @@ function toConversationSnapshots(
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
attachments: message.attachments
|
||||
}))
|
||||
@@ -1507,6 +1526,12 @@ function App(): React.JSX.Element {
|
||||
const selectingContextFilesRef = useRef(false)
|
||||
const [imageViewerItem, setImageViewerItem] =
|
||||
useState<ImageViewerItem>()
|
||||
const [citationDialog, setCitationDialog] = useState<{
|
||||
reference: KnowledgeSearchReference
|
||||
context?: KnowledgeCitationContextView
|
||||
loading: boolean
|
||||
error?: string
|
||||
}>()
|
||||
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
@@ -2663,6 +2688,23 @@ function App(): React.JSX.Element {
|
||||
].slice(-8),
|
||||
status: tRef.current('chat.status.savingImage')
|
||||
}))
|
||||
} else if (event.type === 'knowledge-retrieval') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
knowledgeRetrieval: {
|
||||
mode: event.mode,
|
||||
state: event.state,
|
||||
libraryCount: event.libraryCount,
|
||||
resultCount: event.resultCount,
|
||||
durationMs: event.durationMs,
|
||||
usedChannels: event.usedChannels,
|
||||
warnings: event.warnings
|
||||
},
|
||||
status:
|
||||
event.state === 'searching'
|
||||
? tRef.current('chat.knowledgeRetrieval.searching')
|
||||
: undefined
|
||||
}))
|
||||
} else if (event.type === 'source-references') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const referenceKey = (
|
||||
@@ -2671,6 +2713,7 @@ function App(): React.JSX.Element {
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? '',
|
||||
reference.snippet
|
||||
].join('\0')
|
||||
@@ -2689,21 +2732,9 @@ function App(): React.JSX.Element {
|
||||
(reference) => !incomingKeys.has(referenceKey(reference))
|
||||
)
|
||||
].slice(0, 20)
|
||||
const referenceSources = references.map(
|
||||
(reference) =>
|
||||
`${reference.libraryName} / ${reference.documentName}${
|
||||
reference.locator ? ` (${reference.locator})` : ''
|
||||
}`
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
sourceReferences: references,
|
||||
sources: [
|
||||
...new Set([
|
||||
...referenceSources,
|
||||
...(message.sources ?? [])
|
||||
])
|
||||
].slice(0, 100)
|
||||
sourceReferences: references
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -3937,6 +3968,53 @@ function App(): React.JSX.Element {
|
||||
})
|
||||
}
|
||||
|
||||
const openCitationContext = async (
|
||||
reference: KnowledgeSearchReference
|
||||
): Promise<void> => {
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: true
|
||||
})
|
||||
if (!reference.chunkId) {
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
error: t('chat.citations.contextUnavailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const context =
|
||||
await window.goodbuddy.knowledge.getReferenceContext({
|
||||
knowledgeBaseId: reference.libraryId,
|
||||
documentId: reference.documentId,
|
||||
chunkId: reference.chunkId
|
||||
})
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
context: {
|
||||
libraryName: reference.libraryName,
|
||||
documentName: context.documentTitle,
|
||||
sourceName: context.sourceDisplayName,
|
||||
locator: context.locator,
|
||||
matchedContent: context.matchedContent,
|
||||
contextContent: context.contextContent,
|
||||
truncated: context.truncated
|
||||
}
|
||||
})
|
||||
} catch (reason) {
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('chat.citations.contextUnavailable')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImage = (item: ImageViewerItem): void => {
|
||||
if (!imageDataUrlPattern.test(item.src)) {
|
||||
notify({ tone: 'error', message: t('notices.imageUnavailable') })
|
||||
@@ -4011,6 +4089,8 @@ function App(): React.JSX.Element {
|
||||
const attachmentSnapshot = attachments.slice(0, 8)
|
||||
const historySnapshot = activeConversation.messages
|
||||
const projectIdSnapshot = activeProjectId || undefined
|
||||
const knowledgeRetrievalModeSnapshot =
|
||||
activeConversation.knowledgeRetrievalMode ?? 'auto'
|
||||
const runtimeSelectionSnapshot = activeRuntimeSelection
|
||||
if (!runtimeSelectionSnapshot) {
|
||||
notify({ tone: 'info', message: t('runtime.notSelected') })
|
||||
@@ -4132,6 +4212,7 @@ function App(): React.JSX.Element {
|
||||
workMode: workModeSnapshot,
|
||||
prompt: executionPrompt,
|
||||
knowledgeLibraryIds: enabledKnowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
|
||||
contextIds: attachmentSnapshot.map(
|
||||
(attachment) => attachment.id
|
||||
),
|
||||
@@ -4678,7 +4759,7 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
<div className="brand__copy">
|
||||
<strong>GoodBuddy</strong>
|
||||
<span>AI desktop companion</span>
|
||||
<span>Desktop workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5500,6 +5581,51 @@ function App(): React.JSX.Element {
|
||||
</figure>
|
||||
) : null
|
||||
})}
|
||||
{message.knowledgeRetrieval && (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className={`message-retrieval-status message-retrieval-status--${message.knowledgeRetrieval.state}`}
|
||||
>
|
||||
<Library aria-hidden="true" size={14} />
|
||||
<div>
|
||||
<strong>
|
||||
{t(
|
||||
`chat.knowledgeRetrieval.states.${message.knowledgeRetrieval.state}`
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{t('chat.knowledgeRetrieval.summary', {
|
||||
libraries:
|
||||
message.knowledgeRetrieval.libraryCount,
|
||||
results:
|
||||
message.knowledgeRetrieval.resultCount,
|
||||
duration:
|
||||
message.knowledgeRetrieval.durationMs ?? 0
|
||||
})}
|
||||
</small>
|
||||
{message.knowledgeRetrieval.usedChannels.length >
|
||||
0 && (
|
||||
<small>
|
||||
{t('chat.knowledgeRetrieval.channels', {
|
||||
channels:
|
||||
message.knowledgeRetrieval.usedChannels
|
||||
.map((channel) =>
|
||||
t(
|
||||
`chat.knowledgeRetrieval.channelNames.${channel}`
|
||||
)
|
||||
)
|
||||
.join(' + ')
|
||||
})}
|
||||
</small>
|
||||
)}
|
||||
{message.knowledgeRetrieval.warnings.map(
|
||||
(warning) => (
|
||||
<p key={warning}>{warning}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{message.sources && message.sources.length > 0 && (
|
||||
<div className="message-sources">
|
||||
<Library size={14} />
|
||||
@@ -5524,7 +5650,7 @@ function App(): React.JSX.Element {
|
||||
{message.sourceReferences.map(
|
||||
(reference, referenceIndex) => (
|
||||
<li
|
||||
key={`${reference.documentId}:${reference.locator ?? referenceIndex}`}
|
||||
key={`${reference.documentId}:${reference.chunkId ?? reference.locator ?? referenceIndex}`}
|
||||
>
|
||||
<strong>
|
||||
[{referenceIndex + 1}]{' '}
|
||||
@@ -5541,6 +5667,8 @@ function App(): React.JSX.Element {
|
||||
.map((channel) =>
|
||||
channel === 'fts'
|
||||
? t('chat.citations.fullText')
|
||||
: channel === 'cjk'
|
||||
? t('chat.citations.cjk')
|
||||
: channel === 'vector'
|
||||
? t('chat.citations.vector')
|
||||
: t('chat.citations.graph')
|
||||
@@ -5548,6 +5676,55 @@ function App(): React.JSX.Element {
|
||||
.join(' + ')}
|
||||
</small>
|
||||
)}
|
||||
{reference.score !== undefined && (
|
||||
<small>
|
||||
{t('chat.citations.score', {
|
||||
score: reference.score.toFixed(4)
|
||||
})}
|
||||
</small>
|
||||
)}
|
||||
<div className="message-citations__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
void openCitationContext(reference)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.citations.viewContext')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={!reference.chunkId}
|
||||
onClick={() => {
|
||||
if (!reference.chunkId) {
|
||||
return
|
||||
}
|
||||
void window.goodbuddy.knowledge
|
||||
.openReferenceSource({
|
||||
knowledgeBaseId:
|
||||
reference.libraryId,
|
||||
documentId:
|
||||
reference.documentId,
|
||||
chunkId: reference.chunkId
|
||||
})
|
||||
.catch((reason) =>
|
||||
notify({
|
||||
tone: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t(
|
||||
'chat.citations.openFailed'
|
||||
)
|
||||
})
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
@@ -5998,6 +6175,47 @@ function App(): React.JSX.Element {
|
||||
</small>
|
||||
</label>
|
||||
))}
|
||||
<div className="knowledge-scope__retrieval-mode">
|
||||
<strong>
|
||||
{t('composer.knowledge.modeLabel')}
|
||||
</strong>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('composer.knowledge.modeLabel')}
|
||||
onChange={(mode) =>
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeId
|
||||
? {
|
||||
...conversation,
|
||||
knowledgeRetrievalMode: mode,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: conversation
|
||||
)
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('composer.knowledge.auto')
|
||||
},
|
||||
{
|
||||
value: 'always',
|
||||
label: t('composer.knowledge.always')
|
||||
}
|
||||
]}
|
||||
value={
|
||||
activeConversation?.knowledgeRetrievalMode ??
|
||||
'auto'
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{activeConversation?.knowledgeRetrievalMode ===
|
||||
'always'
|
||||
? t('composer.knowledge.alwaysDescription')
|
||||
: t('composer.knowledge.autoDescription')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -6460,11 +6678,123 @@ function App(): React.JSX.Element {
|
||||
window.goodbuddy.knowledge.removeSource(sourceId)
|
||||
)
|
||||
}
|
||||
onRetrieve={(libraryId, query, settings) =>
|
||||
window.goodbuddy.knowledge.retrieve({
|
||||
knowledgeBaseId: libraryId,
|
||||
query,
|
||||
settings
|
||||
})
|
||||
}
|
||||
onUpdateKnowledgeSettings={async (
|
||||
libraryId,
|
||||
settings
|
||||
) => {
|
||||
await runKnowledgeSourceAction(() =>
|
||||
window.goodbuddy.knowledge.updateSettings({
|
||||
knowledgeBaseId: libraryId,
|
||||
...settings
|
||||
})
|
||||
)
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: t('notices.knowledgeSettingsUpdated'),
|
||||
dedupeKey: `knowledge-retrieval-settings:${libraryId}`
|
||||
})
|
||||
}}
|
||||
onListChunks={({
|
||||
libraryId,
|
||||
documentId,
|
||||
page,
|
||||
pageSize,
|
||||
search
|
||||
}) =>
|
||||
window.goodbuddy.knowledge.listChunks({
|
||||
knowledgeBaseId: libraryId,
|
||||
documentId,
|
||||
page,
|
||||
pageSize,
|
||||
search
|
||||
})
|
||||
}
|
||||
onUpdateChunk={(input) =>
|
||||
window.goodbuddy.knowledge.updateChunk(input)
|
||||
}
|
||||
onDeleteChunk={(input) =>
|
||||
window.goodbuddy.knowledge.deleteChunk(input)
|
||||
}
|
||||
onRebuildDocument={(libraryId, documentId) =>
|
||||
runKnowledgeSourceAction(async () => {
|
||||
await window.goodbuddy.knowledge.rebuildDocument({
|
||||
knowledgeBaseId: libraryId,
|
||||
documentId
|
||||
})
|
||||
})
|
||||
}
|
||||
onRebuildLibrary={(libraryId) =>
|
||||
runKnowledgeSourceAction(async () => {
|
||||
const result =
|
||||
await window.goodbuddy.knowledge.rebuildLibrary({
|
||||
knowledgeBaseId: libraryId
|
||||
})
|
||||
if (result.failed > 0) {
|
||||
throw new Error(
|
||||
t('notices.knowledgeRebuildPartial', {
|
||||
rebuilt: result.rebuilt,
|
||||
failed: result.failed
|
||||
})
|
||||
)
|
||||
}
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: t('notices.knowledgeRebuildCompleted', {
|
||||
count: result.rebuilt
|
||||
}),
|
||||
dedupeKey: `knowledge-rebuild:${libraryId}`
|
||||
})
|
||||
})
|
||||
}
|
||||
onCancelRebuild={async (libraryId) => {
|
||||
const cancelled =
|
||||
await window.goodbuddy.knowledge.cancelRebuild(
|
||||
libraryId
|
||||
)
|
||||
if (!cancelled) {
|
||||
throw new Error(
|
||||
t('notices.knowledgeRebuildNotRunning')
|
||||
)
|
||||
}
|
||||
}}
|
||||
onGetEmbeddingIndex={(libraryId) =>
|
||||
window.goodbuddy.knowledge.getEmbeddingIndex(libraryId)
|
||||
}
|
||||
onRebuildEmbeddingIndex={(libraryId) =>
|
||||
window.goodbuddy.knowledge.rebuildEmbeddingIndex(
|
||||
libraryId
|
||||
)
|
||||
}
|
||||
onCancelTask={async (taskId) => {
|
||||
const cancelled =
|
||||
await window.goodbuddy.knowledge.cancelTask(taskId)
|
||||
if (!cancelled) {
|
||||
throw new Error(
|
||||
t('notices.knowledgeTaskNotRunning')
|
||||
)
|
||||
}
|
||||
await refreshSelectedKnowledge()
|
||||
}}
|
||||
onOpenReferenceSource={(input) =>
|
||||
window.goodbuddy.knowledge.openReferenceSource(input)
|
||||
}
|
||||
onRetrySource={(sourceId) =>
|
||||
runKnowledgeSourceAction(() =>
|
||||
window.goodbuddy.knowledge.retrySource(sourceId)
|
||||
)
|
||||
}
|
||||
onRetryTask={(taskId) =>
|
||||
runKnowledgeSourceAction(() =>
|
||||
window.goodbuddy.knowledge.retryTask(taskId)
|
||||
)
|
||||
}
|
||||
onRetryLoad={retryKnowledgeLoad}
|
||||
onSelectLibrary={(libraryId) => {
|
||||
void refreshKnowledge(libraryId).catch(() => {
|
||||
@@ -6579,6 +6909,26 @@ function App(): React.JSX.Element {
|
||||
snapshot={releaseNotes}
|
||||
/>
|
||||
)}
|
||||
{citationDialog && (
|
||||
<KnowledgeCitationDialog
|
||||
context={citationDialog.context}
|
||||
error={citationDialog.error}
|
||||
loading={citationDialog.loading}
|
||||
onClose={() => setCitationDialog(undefined)}
|
||||
onOpenSource={async () => {
|
||||
const { reference } = citationDialog
|
||||
if (!reference.chunkId) {
|
||||
throw new Error(t('chat.citations.contextUnavailable'))
|
||||
}
|
||||
await window.goodbuddy.knowledge.openReferenceSource({
|
||||
knowledgeBaseId: reference.libraryId,
|
||||
documentId: reference.documentId,
|
||||
chunkId: reference.chunkId
|
||||
})
|
||||
}}
|
||||
reference={citationDialog.reference}
|
||||
/>
|
||||
)}
|
||||
{imageViewerItem && (
|
||||
<div
|
||||
className="image-viewer-backdrop"
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
EmbeddingConfigurationSummary,
|
||||
EmbeddingIndexStatus
|
||||
EmbeddingConfigurationSummary
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -19,76 +18,52 @@ const configuration: EmbeddingConfigurationSummary = {
|
||||
credentialConfigured: true
|
||||
}
|
||||
|
||||
const idleIndex: EmbeddingIndexStatus = {
|
||||
job: null
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('EmbeddingSettingsSection', () => {
|
||||
it('renders embedding settings in English without translating model data', async () => {
|
||||
it('keeps model settings limited to connection details and diagnostics', () => {
|
||||
const onTest = vi.fn()
|
||||
render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
onTest={onTest}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '向量模型连接' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
|
||||
expect(screen.getByText('已配置凭据')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /重建向量索引/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/知识向量索引/u)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
|
||||
expect(onTest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders connection copy in English without translating model data', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={idleIndex}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: 'Embeddings and knowledge retrieval'
|
||||
name: 'Embedding model connection'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Current embedding model' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
|
||||
expect(screen.getByText('Provider: openai-compatible'))
|
||||
.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Test embedding model' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('No rebuild history yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses supplied callbacks without depending on a preload API', () => {
|
||||
const onTest = vi.fn()
|
||||
const onRebuild = vi.fn()
|
||||
render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={idleIndex}
|
||||
onRebuild={onRebuild}
|
||||
onTest={onTest}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '向量与知识检索' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '当前向量模型' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
|
||||
expect(screen.getByText('已配置凭据')).toBeInTheDocument()
|
||||
expect(screen.getByText('还没有重建记录')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'点击“重建向量索引”,为知识文档生成可用于检索的向量。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText(/快照/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/当前检索索引/)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
|
||||
expect(onTest).toHaveBeenCalledOnce()
|
||||
expect(onRebuild).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows dimensions and latency from a real diagnostic result', () => {
|
||||
@@ -103,8 +78,6 @@ describe('EmbeddingSettingsSection', () => {
|
||||
latencyMs: 126,
|
||||
dimensions: 1_536
|
||||
}}
|
||||
indexStatus={idleIndex}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
@@ -132,8 +105,6 @@ describe('EmbeddingSettingsSection', () => {
|
||||
remedy: '请确认模型名称正确。'
|
||||
}
|
||||
}}
|
||||
indexStatus={idleIndex}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
@@ -145,142 +116,4 @@ describe('EmbeddingSettingsSection', () => {
|
||||
'处理建议:请确认模型名称正确。'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows document progress and atomic availability while rebuilding', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={{
|
||||
job: {
|
||||
id: 'job-new',
|
||||
status: 'running',
|
||||
provider: 'openai-compatible',
|
||||
model: 'embed-v2',
|
||||
progress: {
|
||||
completed: 10,
|
||||
total: 40,
|
||||
percent: 25
|
||||
},
|
||||
createdAt: 1_700_000_000_100,
|
||||
startedAt: 1_700_000_000_200
|
||||
}
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('value', '25')
|
||||
expect(screen.getByText('已完成 10 / 40 篇文档')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/每篇文档会一次性更新,处理完成后立即可用于检索。/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/其余文档的原有或缺失状态不变。/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '重建进行中…' })
|
||||
).toBeDisabled()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '取消向量索引重建' })
|
||||
)
|
||||
expect(onCancel).toHaveBeenCalledWith('job-new')
|
||||
})
|
||||
|
||||
it('shows a failed rebuild remedy and retries from the rebuild button', () => {
|
||||
const onRebuild = vi.fn()
|
||||
render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={{
|
||||
job: {
|
||||
id: 'job-failed',
|
||||
status: 'failed',
|
||||
provider: 'provider',
|
||||
model: 'model',
|
||||
progress: { completed: 2, total: 4, percent: 50 },
|
||||
createdAt: 1,
|
||||
completedAt: 2,
|
||||
error: {
|
||||
code: 'rate_limited',
|
||||
message: '向量服务当前请求过多。',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
}}
|
||||
onRebuild={onRebuild}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('最近一次重建失败')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'向量服务当前请求过多。'
|
||||
)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'已完成 2 / 4 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。'
|
||||
)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'请检查向量模型配置和网络连接。修复后点击“重建向量索引”重试。'
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
|
||||
expect(onRebuild).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports successful and cancelled rebuilds distinctly', () => {
|
||||
const { rerender } = render(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={{
|
||||
job: {
|
||||
id: 'job-completed',
|
||||
status: 'completed',
|
||||
provider: 'provider',
|
||||
model: 'model',
|
||||
progress: { completed: 4, total: 4, percent: 100 },
|
||||
createdAt: 1,
|
||||
completedAt: 2
|
||||
}
|
||||
}}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('最近一次重建成功')).toBeInTheDocument()
|
||||
expect(screen.getByText('已完成 4 / 4 篇文档', { exact: false }))
|
||||
.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<EmbeddingSettingsSection
|
||||
configuration={configuration}
|
||||
indexStatus={{
|
||||
job: {
|
||||
id: 'job-cancelled',
|
||||
status: 'cancelled',
|
||||
provider: 'provider',
|
||||
model: 'model',
|
||||
progress: { completed: 2, total: 4, percent: 50 },
|
||||
createdAt: 1,
|
||||
completedAt: 2
|
||||
}
|
||||
}}
|
||||
onRebuild={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('最近一次重建已取消')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('已完成 2 / 4 篇文档。')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/已完成文档保留新向量;其余文档保留原有向量/)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/原本没有向量的仍保持缺失。/))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.queryByText(/索引未更改/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
import {
|
||||
Activity,
|
||||
Database,
|
||||
FlaskConical,
|
||||
RefreshCw,
|
||||
XCircle
|
||||
FlaskConical
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
EmbeddingConfigurationSummary,
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingIndexJob,
|
||||
EmbeddingIndexStatus
|
||||
EmbeddingDiagnosticResult
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts'
|
||||
import { formatMediumDateTime } from './locale-formatters'
|
||||
|
||||
export interface EmbeddingSettingsSectionProps {
|
||||
configuration: EmbeddingConfigurationSummary
|
||||
diagnostic?: EmbeddingDiagnosticResult | null
|
||||
diagnosticRunning?: boolean
|
||||
indexStatus: EmbeddingIndexStatus
|
||||
disabled?: boolean
|
||||
onTest: () => void
|
||||
onRebuild: () => void
|
||||
onCancel?: (jobId: string) => void
|
||||
}
|
||||
|
||||
function formatCheckedAt(timestamp: number, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
function DiagnosticResult({
|
||||
@@ -51,7 +36,7 @@ function DiagnosticResult({
|
||||
</p>
|
||||
<small>
|
||||
{t('embedding.diagnostic.checkedAt', {
|
||||
date: formatCheckedAt(result.checkedAt, locale)
|
||||
date: formatMediumDateTime(result.checkedAt, locale)
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
@@ -76,123 +61,14 @@ function DiagnosticResult({
|
||||
)
|
||||
}
|
||||
|
||||
function IndexJobStatus({
|
||||
job,
|
||||
disabled,
|
||||
onCancel
|
||||
}: {
|
||||
job: EmbeddingIndexJob
|
||||
disabled: boolean
|
||||
onCancel?: (jobId: string) => void
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('settingsSections')
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language
|
||||
const active = isEmbeddingIndexJobActive(job)
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="embedding-settings__job"
|
||||
data-status={job.status}
|
||||
>
|
||||
<div className="embedding-settings__job-header">
|
||||
<div>
|
||||
<strong>{t(`embedding.index.statuses.${job.status}`)}</strong>
|
||||
<small>
|
||||
{job.provider} · {job.model}
|
||||
</small>
|
||||
</div>
|
||||
{active && onCancel && (
|
||||
<button
|
||||
aria-label={t('embedding.index.cancelAria')}
|
||||
className="secondary-button"
|
||||
disabled={disabled}
|
||||
onClick={() => onCancel(job.id)}
|
||||
type="button"
|
||||
>
|
||||
<XCircle aria-hidden="true" size={13} />
|
||||
{t('embedding.index.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<>
|
||||
<progress
|
||||
aria-label={t('embedding.index.progressAria')}
|
||||
max={100}
|
||||
{...(job.progress.total > 0
|
||||
? { value: job.progress.percent }
|
||||
: {})}
|
||||
/>
|
||||
<p>
|
||||
{job.progress.total > 0
|
||||
? t('embedding.index.completed', {
|
||||
completed: job.progress.completed,
|
||||
total: job.progress.total
|
||||
})
|
||||
: t('embedding.index.preparing')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('embedding.index.atomicNotice')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{job.status === 'completed' && (
|
||||
<p>
|
||||
{job.completedAt
|
||||
? t('embedding.index.completedAt', {
|
||||
completed: job.progress.completed,
|
||||
total: job.progress.total,
|
||||
date: formatCheckedAt(job.completedAt, locale)
|
||||
})
|
||||
: t('embedding.index.completedWithPeriod', {
|
||||
completed: job.progress.completed,
|
||||
total: job.progress.total
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{job.status === 'cancelled' && (
|
||||
<>
|
||||
<p>
|
||||
{t('embedding.index.completedWithPeriod', {
|
||||
completed: job.progress.completed,
|
||||
total: job.progress.total
|
||||
})}
|
||||
</p>
|
||||
<p>{t('embedding.index.cancelledNotice')}</p>
|
||||
</>
|
||||
)}
|
||||
{job.status === 'failed' && job.error && (
|
||||
<div role="alert">
|
||||
<p>{job.error.message}</p>
|
||||
<p>
|
||||
{t('embedding.index.failedNotice', {
|
||||
completed: job.progress.completed,
|
||||
total: job.progress.total
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('embedding.index.remedyPrefix')}
|
||||
{job.error.remedy ?? t('embedding.index.defaultRemedy')}
|
||||
{t('embedding.index.retrySuffix')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmbeddingSettingsSection({
|
||||
configuration,
|
||||
diagnostic,
|
||||
diagnosticRunning = false,
|
||||
indexStatus,
|
||||
disabled = false,
|
||||
onTest,
|
||||
onRebuild,
|
||||
onCancel
|
||||
onTest
|
||||
}: EmbeddingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const active = isEmbeddingIndexJobActive(indexStatus.job)
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -262,43 +138,6 @@ export function EmbeddingSettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-labelledby="embedding-index-heading"
|
||||
className="embedding-settings__group"
|
||||
>
|
||||
<div className="embedding-settings__subheading">
|
||||
<div>
|
||||
<Database aria-hidden="true" size={15} />
|
||||
<h3 id="embedding-index-heading">
|
||||
{t('embedding.index.heading')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={disabled || active}
|
||||
onClick={onRebuild}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
{active
|
||||
? t('embedding.index.rebuildRunning')
|
||||
: t('embedding.index.rebuild')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{indexStatus.job ? (
|
||||
<IndexJobStatus
|
||||
disabled={disabled}
|
||||
job={indexStatus.job}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : (
|
||||
<div className="embedding-settings__empty">
|
||||
<strong>{t('embedding.index.emptyTitle')}</strong>
|
||||
<p>{t('embedding.index.emptyDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
KnowledgeChunkManager,
|
||||
type KnowledgeChunkManagerProps,
|
||||
type KnowledgeChunkPage
|
||||
} from './KnowledgeChunkManager'
|
||||
import { changeUiLocale } from './i18n'
|
||||
|
||||
const chunkPage: KnowledgeChunkPage = {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalItems: 21,
|
||||
items: [
|
||||
{
|
||||
id: 'chunk-1',
|
||||
ordinal: 1,
|
||||
role: 'child',
|
||||
parentChunkId: 'parent-1',
|
||||
heading: '安装模型',
|
||||
locator: '第 3 节',
|
||||
characterCount: 18,
|
||||
enabled: true,
|
||||
content: '可以导入已经校验的模型 ZIP。',
|
||||
manuallyEdited: true
|
||||
},
|
||||
{
|
||||
id: 'chunk-2',
|
||||
ordinal: 2,
|
||||
role: 'standalone',
|
||||
locator: '第 4 节',
|
||||
characterCount: 12,
|
||||
enabled: false,
|
||||
content: '服务不可用时使用本地回退。',
|
||||
manuallyEdited: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function createProps(
|
||||
overrides: Partial<KnowledgeChunkManagerProps> = {}
|
||||
): KnowledgeChunkManagerProps {
|
||||
return {
|
||||
documentId: 'document-1',
|
||||
documentName: '离线部署.md',
|
||||
page: chunkPage,
|
||||
onList: vi.fn(),
|
||||
onUpdateChunk: vi.fn(),
|
||||
onDeleteChunk: vi.fn(),
|
||||
onRebuildDocument: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
|
||||
describe('KnowledgeChunkManager', () => {
|
||||
it('provides dialog semantics, focuses search, traps focus, and returns focus on Escape', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<button onClick={() => setOpen(true)} type="button">
|
||||
查看分块
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<KnowledgeChunkManager
|
||||
{...createProps({ onClose: () => setOpen(false) })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
const trigger = screen.getByRole('button', { name: '查看分块' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '文档分块' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(screen.getByLabelText('搜索文档内分块')).toHaveFocus()
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(2)
|
||||
expect(
|
||||
screen.getByText('人工修改可能被替换。')
|
||||
).toBeInTheDocument()
|
||||
|
||||
const rebuild = screen.getByRole('button', { name: '重建文档' })
|
||||
rebuild.focus()
|
||||
fireEvent.keyDown(rebuild, { key: 'Tab' })
|
||||
expect(
|
||||
screen.getByRole('button', { name: '关闭文档分块' })
|
||||
).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('searches and paginates with a bounded list request', () => {
|
||||
const onList = vi.fn()
|
||||
render(<KnowledgeChunkManager {...createProps({ onList })} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('搜索文档内分块'), {
|
||||
target: { value: '模型' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索' }))
|
||||
expect(onList).toHaveBeenCalledWith({
|
||||
documentId: 'document-1',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: '模型'
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页分块' }))
|
||||
expect(onList).toHaveBeenLastCalledWith({
|
||||
documentId: 'document-1',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
query: '模型'
|
||||
})
|
||||
})
|
||||
|
||||
it('switches enablement, edits content, validates, and preserves a failed draft', () => {
|
||||
const onUpdateChunk = vi.fn()
|
||||
const { rerender } = render(
|
||||
<KnowledgeChunkManager {...createProps({ onUpdateChunk })} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: '启用分块 1' }))
|
||||
expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', {
|
||||
enabled: false
|
||||
})
|
||||
|
||||
const editor = screen.getByLabelText(/^分块内容/)
|
||||
fireEvent.change(editor, { target: { value: '保留的人工修正文稿' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存分块' }))
|
||||
expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', {
|
||||
content: '保留的人工修正文稿'
|
||||
})
|
||||
|
||||
rerender(
|
||||
<KnowledgeChunkManager
|
||||
{...createProps({
|
||||
error: '向量重建失败,请重试。',
|
||||
onUpdateChunk
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'向量重建失败,请重试。'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '启用分块 1' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByLabelText(/^分块内容/)).toHaveValue(
|
||||
'保留的人工修正文稿'
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^分块内容/), {
|
||||
target: { value: '' }
|
||||
})
|
||||
expect(screen.getByText('分块内容不能为空。')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '保存分块' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('confirms a specific deletion, restores trigger focus on cancel, and invokes rebuild', () => {
|
||||
const onDeleteChunk = vi.fn()
|
||||
const onRebuildDocument = vi.fn()
|
||||
render(
|
||||
<KnowledgeChunkManager
|
||||
{...createProps({ onDeleteChunk, onRebuildDocument })}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteTrigger = screen.getByRole('button', {
|
||||
name: '删除分块 1'
|
||||
})
|
||||
fireEvent.click(deleteTrigger)
|
||||
const confirmation = screen.getByRole('alertdialog', {
|
||||
name: '确认删除分块 1'
|
||||
})
|
||||
expect(confirmation).toHaveAccessibleDescription(
|
||||
'删除分块 1 会移除其全文、中文、向量和图谱证据。来源同步或重建可能重新创建此分块;原始文件不会被删除。'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(
|
||||
screen.getByRole('button', { name: '删除分块 1' })
|
||||
).toHaveFocus()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除分块 1' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '确认删除分块 1' })
|
||||
)
|
||||
expect(onDeleteChunk).toHaveBeenCalledWith('chunk-1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重建文档' }))
|
||||
expect(onRebuildDocument).toHaveBeenCalledWith('document-1')
|
||||
})
|
||||
|
||||
it('renders loading and zero states without a selected editor', () => {
|
||||
const { rerender } = render(
|
||||
<KnowledgeChunkManager {...createProps({ loading: true })} />
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在加载分块')
|
||||
|
||||
rerender(
|
||||
<KnowledgeChunkManager
|
||||
{...createProps({
|
||||
page: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalItems: 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'没有符合条件的分块'
|
||||
)
|
||||
expect(screen.getByText('选择一个分块')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FilePenLine,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
KnowledgeChunkPage as SharedKnowledgeChunkPage,
|
||||
KnowledgeChunkRole as SharedKnowledgeChunkRole,
|
||||
KnowledgeChunkUpdateInput,
|
||||
KnowledgeManagedChunk as SharedKnowledgeManagedChunk
|
||||
} from '../../shared/knowledge-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { DestructiveConfirmActions } from './WorkspacePrimitives'
|
||||
|
||||
export type KnowledgeChunkRole = SharedKnowledgeChunkRole
|
||||
export type KnowledgeManagedChunk = SharedKnowledgeManagedChunk
|
||||
export type KnowledgeChunkPage = SharedKnowledgeChunkPage
|
||||
export type KnowledgeChunkUpdate = Pick<
|
||||
KnowledgeChunkUpdateInput,
|
||||
'content' | 'enabled'
|
||||
>
|
||||
|
||||
export type KnowledgeChunkManagerProps = {
|
||||
documentId: string
|
||||
documentName: string
|
||||
page: KnowledgeChunkPage
|
||||
query?: string
|
||||
selectedChunkId?: string
|
||||
loading?: boolean
|
||||
error?: string
|
||||
savingChunkId?: string
|
||||
deletingChunkId?: string
|
||||
rebuilding?: boolean
|
||||
maxChunkCharacters?: number
|
||||
onList: (request: {
|
||||
documentId: string
|
||||
page: number
|
||||
pageSize: number
|
||||
query: string
|
||||
}) => void | Promise<void>
|
||||
onSelectChunk?: (chunkId: string) => void
|
||||
onUpdateChunk: (
|
||||
chunkId: string,
|
||||
update: KnowledgeChunkUpdate
|
||||
) => void | Promise<void>
|
||||
onDeleteChunk: (chunkId: string) => void | Promise<void>
|
||||
onRebuildDocument: (documentId: string) => void | Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function KnowledgeChunkManager({
|
||||
deletingChunkId,
|
||||
documentId,
|
||||
documentName,
|
||||
error,
|
||||
loading = false,
|
||||
maxChunkCharacters = 48_000,
|
||||
onClose,
|
||||
onDeleteChunk,
|
||||
onList,
|
||||
onRebuildDocument,
|
||||
onSelectChunk,
|
||||
onUpdateChunk,
|
||||
page,
|
||||
query = '',
|
||||
rebuilding = false,
|
||||
savingChunkId,
|
||||
selectedChunkId
|
||||
}: KnowledgeChunkManagerProps): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [searchDraft, setSearchDraft] = useState(query)
|
||||
const [internalSelectedId, setInternalSelectedId] = useState(
|
||||
selectedChunkId ?? page.items[0]?.id
|
||||
)
|
||||
const [contentDrafts, setContentDrafts] = useState<Record<string, string>>({})
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string>()
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const contentErrorId = useId()
|
||||
|
||||
const requestedSelectedId = selectedChunkId ?? internalSelectedId
|
||||
const selectedChunk =
|
||||
page.items.find((chunk) => chunk.id === requestedSelectedId) ??
|
||||
page.items[0]
|
||||
const effectiveSelectedId = selectedChunk?.id
|
||||
const draftContent = selectedChunk
|
||||
? (contentDrafts[selectedChunk.id] ?? selectedChunk.content)
|
||||
: ''
|
||||
const isSavingSelected = savingChunkId === selectedChunk?.id
|
||||
const totalPages = Math.max(1, Math.ceil(page.totalItems / page.pageSize))
|
||||
|
||||
useEffect(() => {
|
||||
return activateModalFocus(() => searchRef.current)
|
||||
}, [])
|
||||
|
||||
const contentError = useMemo(() => {
|
||||
if (!selectedChunk) {
|
||||
return undefined
|
||||
}
|
||||
if (draftContent.trim().length === 0) {
|
||||
return t('chunks.validation.contentRequired')
|
||||
}
|
||||
if (draftContent.length > maxChunkCharacters) {
|
||||
return t('chunks.validation.contentTooLong', {
|
||||
count: maxChunkCharacters
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}, [draftContent, maxChunkCharacters, selectedChunk, t])
|
||||
|
||||
const selectChunk = (chunkId: string): void => {
|
||||
setInternalSelectedId(chunkId)
|
||||
onSelectChunk?.(chunkId)
|
||||
}
|
||||
|
||||
const list = (nextPage: number, nextQuery = searchDraft.trim()): void => {
|
||||
void onList({
|
||||
documentId,
|
||||
page: nextPage,
|
||||
pageSize: page.pageSize,
|
||||
query: nextQuery
|
||||
})
|
||||
}
|
||||
|
||||
const submitSearch = (event: FormEvent): void => {
|
||||
event.preventDefault()
|
||||
list(1)
|
||||
}
|
||||
|
||||
const updateEnabled = (chunk: KnowledgeManagedChunk, enabled: boolean): void => {
|
||||
void onUpdateChunk(chunk.id, { enabled })
|
||||
}
|
||||
|
||||
const saveContent = (): void => {
|
||||
if (!selectedChunk || contentError || isSavingSelected) {
|
||||
return
|
||||
}
|
||||
void onUpdateChunk(selectedChunk.id, { content: draftContent })
|
||||
}
|
||||
|
||||
const deleteChunk = (chunkId: string): void => {
|
||||
void Promise.resolve(onDeleteChunk(chunkId)).then(
|
||||
() => setConfirmingDeleteId(undefined),
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="knowledge-dialog-backdrop">
|
||||
<section
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="knowledge-dialog knowledge-chunk-manager"
|
||||
onKeyDown={(event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!savingChunkId &&
|
||||
!deletingChunkId &&
|
||||
!rebuilding
|
||||
) {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<header className="knowledge-dialog__header">
|
||||
<div>
|
||||
<span className="knowledge-dialog__eyebrow">{documentName}</span>
|
||||
<h2 id={titleId}>{t('chunks.title')}</h2>
|
||||
<p id={descriptionId}>{t('chunks.description')}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('chunks.close')}
|
||||
className="icon-button"
|
||||
disabled={Boolean(savingChunkId || deletingChunkId || rebuilding)}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="knowledge-chunk-manager__warning" role="note">
|
||||
<FilePenLine aria-hidden="true" size={18} />
|
||||
<p>
|
||||
<strong>{t('chunks.syncWarningTitle')}</strong>{' '}
|
||||
{t('chunks.syncWarning')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-dialog__content knowledge-chunk-manager__content">
|
||||
<aside
|
||||
aria-label={t('chunks.listAriaLabel')}
|
||||
className="knowledge-chunk-manager__list"
|
||||
>
|
||||
<form className="knowledge-chunk-search" onSubmit={submitSearch}>
|
||||
<label className="field">
|
||||
<span>{t('chunks.search.label')}</span>
|
||||
<span className="knowledge-input-with-icon">
|
||||
<Search aria-hidden="true" size={15} />
|
||||
<input
|
||||
onChange={(event) =>
|
||||
setSearchDraft(event.currentTarget.value)
|
||||
}
|
||||
placeholder={t('chunks.search.placeholder')}
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<button className="secondary-button" type="submit">
|
||||
{t('chunks.search.action')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="knowledge-operation-state knowledge-operation-state--error" role="alert">
|
||||
<div>
|
||||
<strong>{t('chunks.loadErrorTitle')}</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="knowledge-zero-state" role="status">
|
||||
<strong>{t('chunks.loadingTitle')}</strong>
|
||||
<p>{t('chunks.loadingDescription')}</p>
|
||||
</div>
|
||||
) : page.items.length === 0 ? (
|
||||
<div className="knowledge-zero-state" role="status">
|
||||
<strong>{t('chunks.zeroTitle')}</strong>
|
||||
<p>{t('chunks.zeroDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{page.items.map((chunk) => {
|
||||
const saving = savingChunkId === chunk.id
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
chunk.id === effectiveSelectedId
|
||||
? 'knowledge-chunk-list-item knowledge-chunk-list-item--selected'
|
||||
: 'knowledge-chunk-list-item'
|
||||
}
|
||||
key={chunk.id}
|
||||
>
|
||||
<button
|
||||
aria-current={
|
||||
chunk.id === effectiveSelectedId
|
||||
? 'true'
|
||||
: undefined
|
||||
}
|
||||
className="knowledge-chunk-list-item__select"
|
||||
onClick={() => selectChunk(chunk.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{t('chunks.ordinal', { count: chunk.ordinal })}
|
||||
{chunk.heading
|
||||
? t('chunks.headingSeparator', {
|
||||
heading: chunk.heading
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
<small>
|
||||
{t(`chunks.roles.${chunk.role}`)}
|
||||
{chunk.parentChunkId
|
||||
? t('chunks.parentMetadata', {
|
||||
parentId: chunk.parentChunkId
|
||||
})
|
||||
: ''}
|
||||
</small>
|
||||
<small>
|
||||
{chunk.locator ?? t('chunks.unknownLocator')} ·{' '}
|
||||
{t('chunks.characterCount', {
|
||||
count: chunk.characterCount
|
||||
})}
|
||||
</small>
|
||||
</button>
|
||||
<label className="toggle-row knowledge-chunk-list-item__switch">
|
||||
<span>{t('chunks.enabled')}</span>
|
||||
<input
|
||||
aria-label={t('chunks.enabledAriaLabel', {
|
||||
count: chunk.ordinal
|
||||
})}
|
||||
checked={chunk.enabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
updateEnabled(chunk, event.currentTarget.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<nav
|
||||
aria-label={t('chunks.pagination.ariaLabel')}
|
||||
className="knowledge-pagination"
|
||||
>
|
||||
<button
|
||||
aria-label={t('chunks.pagination.previous')}
|
||||
className="icon-button"
|
||||
disabled={loading || page.page <= 1}
|
||||
onClick={() => list(page.page - 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<span>
|
||||
{t('chunks.pagination.summary', {
|
||||
page: page.page,
|
||||
total: totalPages,
|
||||
count: page.totalItems
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t('chunks.pagination.next')}
|
||||
className="icon-button"
|
||||
disabled={loading || page.page >= totalPages}
|
||||
onClick={() => list(page.page + 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
aria-labelledby={`${titleId}-editor`}
|
||||
className="knowledge-chunk-manager__editor"
|
||||
>
|
||||
{selectedChunk ? (
|
||||
<>
|
||||
<div className="knowledge-workbench-section__heading">
|
||||
<div>
|
||||
<h3 id={`${titleId}-editor`}>
|
||||
{t('chunks.editor.title', {
|
||||
count: selectedChunk.ordinal
|
||||
})}
|
||||
</h3>
|
||||
<p>
|
||||
{t('chunks.editor.metadata', {
|
||||
role: t(`chunks.roles.${selectedChunk.role}`),
|
||||
locator:
|
||||
selectedChunk.locator ?? t('chunks.unknownLocator')
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{selectedChunk.manuallyEdited && (
|
||||
<span className="knowledge-status-badge">
|
||||
{t('chunks.editor.manuallyEdited')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedChunk.parentChunkId && (
|
||||
<dl className="knowledge-chunk-parent">
|
||||
<div>
|
||||
<dt>{t('chunks.editor.role')}</dt>
|
||||
<dd>{t(`chunks.roles.${selectedChunk.role}`)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('chunks.editor.parent')}</dt>
|
||||
<dd>{selectedChunk.parentChunkId}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<label className="field knowledge-chunk-editor-field">
|
||||
<span>{t('chunks.editor.content')}</span>
|
||||
<textarea
|
||||
aria-describedby={contentError ? contentErrorId : undefined}
|
||||
aria-invalid={Boolean(contentError)}
|
||||
disabled={isSavingSelected}
|
||||
onChange={(event) => {
|
||||
const nextContent = event.currentTarget.value
|
||||
const chunkId = selectedChunk.id
|
||||
setContentDrafts((current) => ({
|
||||
...current,
|
||||
[chunkId]: nextContent
|
||||
}))
|
||||
}}
|
||||
rows={16}
|
||||
value={draftContent}
|
||||
/>
|
||||
<small>
|
||||
{t('chunks.editor.count', {
|
||||
count: draftContent.length,
|
||||
max: maxChunkCharacters
|
||||
})}
|
||||
</small>
|
||||
</label>
|
||||
{contentError && (
|
||||
<p className="knowledge-inline-error" id={contentErrorId}>
|
||||
{contentError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="knowledge-chunk-manager__editor-actions">
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel={t('chunks.delete.confirmAriaLabel', {
|
||||
count: selectedChunk.ordinal
|
||||
})}
|
||||
confirmLabel={
|
||||
deletingChunkId === selectedChunk.id
|
||||
? t('chunks.delete.deleting')
|
||||
: t('chunks.delete.confirm')
|
||||
}
|
||||
confirming={confirmingDeleteId === selectedChunk.id}
|
||||
disabled={deletingChunkId === selectedChunk.id}
|
||||
icon={<Trash2 size={14} />}
|
||||
message={t('chunks.delete.message', {
|
||||
count: selectedChunk.ordinal
|
||||
})}
|
||||
onCancel={() => setConfirmingDeleteId(undefined)}
|
||||
onConfirm={() => deleteChunk(selectedChunk.id)}
|
||||
onRequestConfirm={() =>
|
||||
setConfirmingDeleteId(selectedChunk.id)
|
||||
}
|
||||
triggerAriaLabel={t('chunks.delete.triggerAriaLabel', {
|
||||
count: selectedChunk.ordinal
|
||||
})}
|
||||
triggerLabel={t('chunks.delete.trigger')}
|
||||
/>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={
|
||||
Boolean(contentError) ||
|
||||
isSavingSelected ||
|
||||
draftContent === selectedChunk.content
|
||||
}
|
||||
onClick={saveContent}
|
||||
type="button"
|
||||
>
|
||||
{isSavingSelected
|
||||
? t('chunks.editor.saving')
|
||||
: t('chunks.editor.save')}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<div className="knowledge-zero-state">
|
||||
<strong>{t('chunks.editor.noSelectionTitle')}</strong>
|
||||
<p>{t('chunks.editor.noSelectionDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="knowledge-dialog__footer">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={rebuilding}
|
||||
onClick={() => void onRebuildDocument(documentId)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
{rebuilding
|
||||
? t('chunks.rebuild.running')
|
||||
: t('chunks.rebuild.action')}
|
||||
</button>
|
||||
<span>{t('chunks.rebuild.description')}</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
||||
import { KnowledgeCitationDialog } from './KnowledgeCitationDialog'
|
||||
import i18n from './i18n'
|
||||
|
||||
const reference: KnowledgeSearchReference = {
|
||||
libraryId: '11111111-1111-4111-8111-111111111111',
|
||||
libraryName: '产品知识',
|
||||
documentId: '22222222-2222-4222-8222-222222222222',
|
||||
chunkId: '33333333-3333-4333-8333-333333333333',
|
||||
documentName: '发布手册',
|
||||
sourceName: 'release.md',
|
||||
locator: '发布流程',
|
||||
snippet: '先验证,再发布。',
|
||||
rank: -0.03,
|
||||
score: 0.82,
|
||||
retrievalChannels: ['fts', 'vector']
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
void i18n.changeLanguage('zh-CN')
|
||||
})
|
||||
|
||||
describe('KnowledgeCitationDialog', () => {
|
||||
it('shows matched and surrounding context and restores close behavior', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onOpenSource = vi.fn(async () => undefined)
|
||||
render(
|
||||
<KnowledgeCitationDialog
|
||||
context={{
|
||||
libraryName: '产品知识',
|
||||
documentName: '发布手册',
|
||||
sourceName: 'release.md',
|
||||
locator: '发布流程',
|
||||
matchedContent: '先验证,再发布。',
|
||||
contextContent: '准备发布。先验证,再发布。发布后观察指标。'
|
||||
}}
|
||||
onClose={onClose}
|
||||
onOpenSource={onOpenSource}
|
||||
reference={reference}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '引用上下文' }))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.getByText('先验证,再发布。')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('准备发布。先验证,再发布。发布后观察指标。')
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: '关闭引用上下文' })[0]
|
||||
)
|
||||
.toHaveFocus()
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
|
||||
await waitFor(() =>
|
||||
expect(onOpenSource).toHaveBeenCalledWith(reference.documentId)
|
||||
)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an actionable source-opening error in the dialog', async () => {
|
||||
render(
|
||||
<KnowledgeCitationDialog
|
||||
context={{
|
||||
libraryName: '产品知识',
|
||||
documentName: '发布手册',
|
||||
sourceName: 'release.md',
|
||||
matchedContent: '证据',
|
||||
contextContent: '证据上下文'
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onOpenSource={async () => {
|
||||
throw new Error('原文件已移动')
|
||||
}}
|
||||
reference={reference}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'原文件已移动'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { ExternalLink, FileSearch, LoaderCircle, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
|
||||
export type KnowledgeCitationContextView = {
|
||||
libraryName: string
|
||||
documentName: string
|
||||
sourceName: string
|
||||
locator?: string
|
||||
matchedContent: string
|
||||
contextContent: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export type KnowledgeCitationDialogProps = {
|
||||
reference: KnowledgeSearchReference
|
||||
context?: KnowledgeCitationContextView
|
||||
loading?: boolean
|
||||
error?: string
|
||||
onClose: () => void
|
||||
onOpenSource: (documentId: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function KnowledgeCitationDialog({
|
||||
reference,
|
||||
context,
|
||||
loading = false,
|
||||
error,
|
||||
onClose,
|
||||
onOpenSource
|
||||
}: KnowledgeCitationDialogProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [openError, setOpenError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
return activateModalFocus(() => closeRef.current)
|
||||
}, [])
|
||||
|
||||
const openSource = async (): Promise<void> => {
|
||||
setOpening(true)
|
||||
setOpenError(undefined)
|
||||
try {
|
||||
await onOpenSource(reference.documentId)
|
||||
} catch (reason) {
|
||||
setOpenError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('chat.citations.contextUnavailable')
|
||||
)
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-labelledby="knowledge-citation-dialog-title"
|
||||
aria-modal="true"
|
||||
className="knowledge-citation-dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !opening) {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<section className="knowledge-citation-dialog__surface">
|
||||
<header className="knowledge-citation-dialog__header">
|
||||
<div>
|
||||
<span className="knowledge-citation-dialog__eyebrow">
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{reference.libraryName}
|
||||
</span>
|
||||
<h2 id="knowledge-citation-dialog-title">
|
||||
{t('chat.citations.contextTitle')}
|
||||
</h2>
|
||||
<p>{t('chat.citations.contextDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('chat.citations.closeContext')}
|
||||
className="secondary-button"
|
||||
disabled={opening}
|
||||
onClick={onClose}
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<dl className="knowledge-citation-dialog__metadata">
|
||||
<div>
|
||||
<dt>{reference.documentName}</dt>
|
||||
<dd>{context?.sourceName ?? reference.sourceName}</dd>
|
||||
</div>
|
||||
{(context?.locator ?? reference.locator) && (
|
||||
<div>
|
||||
<dt>{context?.locator ?? reference.locator}</dt>
|
||||
{reference.score !== undefined && (
|
||||
<dd>
|
||||
{t('chat.citations.score', {
|
||||
score: reference.score.toFixed(3)
|
||||
})}
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="knowledge-citation-dialog__state"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircle aria-hidden="true" size={20} />
|
||||
{t('chat.citations.contextLoading')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="knowledge-citation-dialog__state knowledge-citation-dialog__state--error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : context ? (
|
||||
<div className="knowledge-citation-dialog__content">
|
||||
<section>
|
||||
<h3>{t('chat.citations.matchedChunk')}</h3>
|
||||
<p>{context.matchedContent}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>{t('chat.citations.surroundingContext')}</h3>
|
||||
<p>{context.contextContent}</p>
|
||||
{context.truncated && (
|
||||
<small
|
||||
className="knowledge-citation-dialog__truncated"
|
||||
role="note"
|
||||
>
|
||||
{t('chat.citations.contextTruncated')}
|
||||
</small>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="knowledge-citation-dialog__state"
|
||||
role="alert"
|
||||
>
|
||||
{t('chat.citations.contextUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openError && (
|
||||
<p className="knowledge-citation-dialog__open-error" role="alert">
|
||||
{openError}
|
||||
</p>
|
||||
)}
|
||||
<footer className="knowledge-citation-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={opening}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.citations.closeContext')}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={opening}
|
||||
onClick={() => void openSource()}
|
||||
type="button"
|
||||
>
|
||||
{opening ? (
|
||||
<LoaderCircle aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<ExternalLink aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
KnowledgeEmbeddingIndexSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
|
||||
|
||||
const snapshot: KnowledgeEmbeddingIndexSnapshot = {
|
||||
knowledgeBaseId: '11111111-1111-4111-8111-111111111111',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
provider: 'openai-compatible',
|
||||
model: 'qwen3-embedding:latest',
|
||||
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
||||
credentialConfigured: false
|
||||
},
|
||||
coverage: {
|
||||
total: 12,
|
||||
indexed: 8,
|
||||
missing: 3,
|
||||
error: 1
|
||||
},
|
||||
indexStatus: { job: null }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('KnowledgeEmbeddingIndexSection', () => {
|
||||
it('shows current-library coverage and starts a scoped rebuild', () => {
|
||||
const onRebuild = vi.fn()
|
||||
render(
|
||||
<KnowledgeEmbeddingIndexSection
|
||||
onRebuild={onRebuild}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '向量索引' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('qwen3-embedding:latest')).toBeInTheDocument()
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '重建向量索引' })
|
||||
)
|
||||
expect(onRebuild).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes active job details to the task center without duplicating progress', () => {
|
||||
const onViewTasks = vi.fn()
|
||||
render(
|
||||
<KnowledgeEmbeddingIndexSection
|
||||
onRebuild={vi.fn()}
|
||||
onViewTasks={onViewTasks}
|
||||
snapshot={{
|
||||
...snapshot,
|
||||
indexStatus: {
|
||||
job: {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
status: 'running',
|
||||
provider: 'openai-compatible',
|
||||
model: 'qwen3-embedding:latest',
|
||||
progress: { completed: 2, total: 12, percent: 100 / 6 },
|
||||
createdAt: 1,
|
||||
startedAt: 2
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('已完成 2 / 12 篇文档')).not
|
||||
.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '重建向量索引' })
|
||||
).toBeDisabled()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '任务中心查看详情'
|
||||
})
|
||||
)
|
||||
expect(onViewTasks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('directs users to model connections when embeddings are disabled', () => {
|
||||
render(
|
||||
<KnowledgeEmbeddingIndexSection
|
||||
onRebuild={vi.fn()}
|
||||
snapshot={{
|
||||
knowledgeBaseId: snapshot.knowledgeBaseId,
|
||||
enabled: false,
|
||||
coverage: { total: 12, indexed: 0, missing: 12, error: 0 },
|
||||
indexStatus: { job: null }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('向量模型未启用')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '重建向量索引' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Database, ListChecks, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
KnowledgeEmbeddingIndexSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts'
|
||||
|
||||
export type KnowledgeEmbeddingIndexSectionProps = {
|
||||
snapshot?: KnowledgeEmbeddingIndexSnapshot
|
||||
loading?: boolean
|
||||
onRebuild: () => void
|
||||
onViewTasks?: () => void
|
||||
}
|
||||
|
||||
export function KnowledgeEmbeddingIndexSection({
|
||||
snapshot,
|
||||
loading = false,
|
||||
onRebuild,
|
||||
onViewTasks
|
||||
}: KnowledgeEmbeddingIndexSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const active = isEmbeddingIndexJobActive(snapshot?.indexStatus.job)
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="knowledge-vector-index-title"
|
||||
className="knowledge-embedding-index"
|
||||
>
|
||||
<div className="knowledge-embedding-index__heading">
|
||||
<div>
|
||||
<Database aria-hidden="true" size={16} />
|
||||
<div>
|
||||
<h3 id="knowledge-vector-index-title">
|
||||
{t('settings.vectorIndex.title')}
|
||||
</h3>
|
||||
<p>{t('settings.vectorIndex.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={loading || !snapshot?.enabled || active}
|
||||
onClick={onRebuild}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
{t('settings.vectorIndex.rebuild')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading || !snapshot ? (
|
||||
<p aria-live="polite">{t('settings.vectorIndex.loading')}</p>
|
||||
) : !snapshot.enabled || !snapshot.configuration ? (
|
||||
<div className="knowledge-embedding-index__empty">
|
||||
<strong>{t('settings.vectorIndex.disabledTitle')}</strong>
|
||||
<p>{t('settings.vectorIndex.disabledDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="knowledge-embedding-index__model">
|
||||
<span>{t('settings.vectorIndex.currentModel')}</span>
|
||||
<strong>{snapshot.configuration.model}</strong>
|
||||
<small>
|
||||
{snapshot.configuration.provider}
|
||||
{snapshot.configuration.endpoint
|
||||
? ` · ${snapshot.configuration.endpoint}`
|
||||
: ''}
|
||||
</small>
|
||||
</div>
|
||||
<dl className="knowledge-embedding-index__coverage">
|
||||
{(['indexed', 'missing', 'error', 'total'] as const).map(
|
||||
(key) => (
|
||||
<div key={key}>
|
||||
<dt>{t(`settings.vectorIndex.coverage.${key}`)}</dt>
|
||||
<dd>{snapshot.coverage[key]}</dd>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</dl>
|
||||
{active && (
|
||||
<div className="knowledge-embedding-index__task-guidance">
|
||||
<div>
|
||||
<strong>{t('settings.vectorIndex.activeTitle')}</strong>
|
||||
<p>{t('settings.vectorIndex.activeDescription')}</p>
|
||||
</div>
|
||||
{onViewTasks && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onViewTasks}
|
||||
type="button"
|
||||
>
|
||||
<ListChecks aria-hidden="true" size={13} />
|
||||
{t('settings.vectorIndex.viewTasks')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type KnowledgeGraphChartProps = {
|
||||
nodes: readonly ChartKnowledgeGraphNode[]
|
||||
relations: readonly ChartKnowledgeGraphRelation[]
|
||||
selectedNodeId?: string
|
||||
fitViewRequest: number
|
||||
zoom: number
|
||||
onMoveNode: (nodeId: string, position: { x: number; y: number }) => void
|
||||
onSelectNode: (nodeId: string) => void
|
||||
@@ -269,7 +270,10 @@ function createPresentation(
|
||||
behaviors: [
|
||||
'drag-canvas',
|
||||
'zoom-canvas',
|
||||
'drag-element',
|
||||
{
|
||||
type: 'drag-element-force',
|
||||
fixed: true
|
||||
},
|
||||
{
|
||||
type: 'auto-adapt-label',
|
||||
sortNode: { type: 'degree' },
|
||||
@@ -313,6 +317,7 @@ export function KnowledgeGraphChart({
|
||||
nodes,
|
||||
relations,
|
||||
selectedNodeId,
|
||||
fitViewRequest,
|
||||
zoom,
|
||||
onMoveNode,
|
||||
onSelectNode,
|
||||
@@ -331,6 +336,7 @@ export function KnowledgeGraphChart({
|
||||
const relationsRef = useRef(relations)
|
||||
const selectedNodeIdRef = useRef(selectedNodeId)
|
||||
const zoomRef = useRef(zoom)
|
||||
const fitViewRequestRef = useRef(fitViewRequest)
|
||||
const appliedZoomRef = useRef<number | undefined>(undefined)
|
||||
const renderVersionRef = useRef(0)
|
||||
const renderedRevisionRef = useRef<string | undefined>(undefined)
|
||||
@@ -363,6 +369,44 @@ export function KnowledgeGraphChart({
|
||||
zoomRef.current = zoom
|
||||
}, [zoom])
|
||||
|
||||
useEffect(() => {
|
||||
if (fitViewRequestRef.current === fitViewRequest) {
|
||||
return
|
||||
}
|
||||
fitViewRequestRef.current = fitViewRequest
|
||||
const graph = graphRef.current
|
||||
if (
|
||||
!graph ||
|
||||
renderedRevisionRef.current !== dataRevision
|
||||
) {
|
||||
return
|
||||
}
|
||||
void graph
|
||||
.fitView(
|
||||
{
|
||||
when: 'always',
|
||||
direction: 'both'
|
||||
},
|
||||
false
|
||||
)
|
||||
.then(() => {
|
||||
if (graphRef.current !== graph) {
|
||||
return
|
||||
}
|
||||
const nextZoom = graph.getZoom()
|
||||
if (Number.isFinite(nextZoom)) {
|
||||
zoomRef.current = nextZoom
|
||||
appliedZoomRef.current = nextZoom
|
||||
onZoomChangeRef.current(nextZoom)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (graphRef.current === graph) {
|
||||
setRenderError(graphErrorMessage(error, renderErrorFallback))
|
||||
}
|
||||
})
|
||||
}, [dataRevision, fitViewRequest, renderErrorFallback])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof MutationObserver !== 'function') {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
KnowledgeRetrievalWorkbench,
|
||||
type KnowledgeRetrievalWorkbenchProps,
|
||||
type KnowledgeRetrievalWorkbenchResponse,
|
||||
type KnowledgeRetrievalWorkbenchSettings
|
||||
} from './KnowledgeRetrievalWorkbench'
|
||||
import { changeUiLocale } from './i18n'
|
||||
|
||||
const settings: KnowledgeRetrievalWorkbenchSettings = {
|
||||
topK: 6,
|
||||
minimumVectorSimilarity: 0,
|
||||
ftsWeight: 1,
|
||||
vectorWeight: 1,
|
||||
graphWeight: 0.8,
|
||||
candidateMultiplier: 4,
|
||||
contextMaxCharacters: 16_000,
|
||||
adjacentChunkCount: 1,
|
||||
localRerankEnabled: false,
|
||||
rerankMode: 'none'
|
||||
}
|
||||
|
||||
const response: KnowledgeRetrievalWorkbenchResponse = {
|
||||
diagnostics: {
|
||||
durationMs: 43,
|
||||
requestedChannels: ['fts', 'cjk', 'vector', 'graph'],
|
||||
usedChannels: ['fts', 'cjk'],
|
||||
degradedChannels: [
|
||||
{
|
||||
channel: 'vector',
|
||||
reason: '查询向量服务不可用'
|
||||
}
|
||||
],
|
||||
candidateCounts: { fts: 4, cjk: 7, vector: 0, graph: 0 },
|
||||
channelDurationsMs: { fts: 8, cjk: 12, vector: 20, graph: 3 },
|
||||
vectorScannedCount: 10_240
|
||||
},
|
||||
context: {
|
||||
characterCount: 312,
|
||||
budget: 16_000,
|
||||
truncated: false
|
||||
},
|
||||
results: [
|
||||
{
|
||||
chunkId: 'chunk-1',
|
||||
documentId: 'document-1',
|
||||
rank: 1,
|
||||
documentName: '离线部署.md',
|
||||
sourceName: '产品文档',
|
||||
locator: '第 3 节',
|
||||
snippet: '离线环境可以导入已经校验的模型 ZIP。',
|
||||
fusedScore: 0.0328,
|
||||
relevance: 0.91,
|
||||
channels: ['fts', 'cjk'],
|
||||
channelDetails: {
|
||||
fts: { rank: 1, score: 0.84 },
|
||||
cjk: { rank: 2, score: 0.71 }
|
||||
},
|
||||
rankBeforeRerank: 2,
|
||||
contextText: '离线环境可以导入已经校验的模型 ZIP,并在本机运行。',
|
||||
contextCharacterCount: 312,
|
||||
contextTruncated: false,
|
||||
diagnostics: ['标题短语命中']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function createProps(
|
||||
overrides: Partial<KnowledgeRetrievalWorkbenchProps> = {}
|
||||
): KnowledgeRetrievalWorkbenchProps {
|
||||
return {
|
||||
libraryName: '产品知识',
|
||||
settings,
|
||||
onTest: vi.fn(),
|
||||
onViewContext: vi.fn(),
|
||||
onOpenSource: vi.fn(),
|
||||
onSaveDefaults: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
|
||||
describe('KnowledgeRetrievalWorkbench', () => {
|
||||
it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<button onClick={() => setOpen(true)} type="button">
|
||||
打开检索测试
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({ onClose: () => setOpen(false) })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
const trigger = screen.getByRole('button', { name: '打开检索测试' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '检索测试' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(screen.getByLabelText('检索问题')).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('.app-shell')?.inert
|
||||
).toBe(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('检索问题'), {
|
||||
target: { value: '这段查询会被保留' }
|
||||
})
|
||||
expect(screen.getByLabelText('检索问题')).toHaveValue(
|
||||
'这段查询会被保留'
|
||||
)
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('.app-shell')?.inert
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('validates the query and settings before invoking retrieval', () => {
|
||||
const onTest = vi.fn()
|
||||
render(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
onTest,
|
||||
settings: {
|
||||
...settings,
|
||||
topK: 0,
|
||||
ftsWeight: 0,
|
||||
vectorWeight: 0,
|
||||
graphWeight: 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
|
||||
|
||||
expect(screen.getByText('请输入测试问题。')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Top K 必须是 1 至 20 的整数。')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('alert')
|
||||
).toHaveTextContent('至少一个当前可用的检索通道权重必须大于 0。')
|
||||
expect(onTest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires available channel shares to total one hundred percent', () => {
|
||||
const onTest = vi.fn()
|
||||
render(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
initialQuery: '检查占比',
|
||||
onTest
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^全文占比/), {
|
||||
target: { value: '40' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'当前可用检索通道的融合占比合计必须为 100%。'
|
||||
)
|
||||
expect(onTest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('presents legacy negative thresholds as zero', () => {
|
||||
const onTest = vi.fn()
|
||||
render(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
initialQuery: '兼容旧阈值',
|
||||
onTest,
|
||||
settings: {
|
||||
...settings,
|
||||
minimumVectorSimilarity: -1
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(/^最低向量相似度/)).toHaveValue(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
|
||||
expect(onTest).toHaveBeenCalledWith({
|
||||
query: '兼容旧阈值',
|
||||
settings: {
|
||||
...settings,
|
||||
minimumVectorSimilarity: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('submits temporary settings and exposes result actions and diagnostics', () => {
|
||||
const onTest = vi.fn()
|
||||
const onSaveDefaults = vi.fn()
|
||||
const onViewContext = vi.fn()
|
||||
const onOpenSource = vi.fn()
|
||||
render(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
initialQuery: '如何离线部署?',
|
||||
onOpenSource,
|
||||
onSaveDefaults,
|
||||
onTest,
|
||||
onViewContext,
|
||||
response,
|
||||
status: 'success'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('最多 24 个融合候选')).toBeInTheDocument()
|
||||
const recallMultiplier = screen.getByLabelText(/^召回倍数/)
|
||||
fireEvent.change(recallMultiplier, { target: { value: '5' } })
|
||||
expect(screen.getByText('最多 30 个融合候选')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^全文占比/)).toHaveValue(35.7)
|
||||
expect(screen.getByLabelText(/^向量占比/)).toHaveValue(35.7)
|
||||
expect(screen.getByLabelText(/^图谱占比/)).toHaveValue(28.6)
|
||||
fireEvent.change(screen.getByLabelText(/^最低向量相似度/), {
|
||||
target: { value: '25' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(/^全文占比/), {
|
||||
target: { value: '50' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(/^向量占比/), {
|
||||
target: { value: '30' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(/^图谱占比/), {
|
||||
target: { value: '20' }
|
||||
})
|
||||
const topK = screen.getByLabelText(/^最终结果数/)
|
||||
fireEvent.change(topK, { target: { value: '8' } })
|
||||
expect(screen.getByText('最多 40 个融合候选')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本地规则' }))
|
||||
expect(screen.getByText('重排最多 40 个候选')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
|
||||
|
||||
expect(onTest).toHaveBeenCalledWith({
|
||||
query: '如何离线部署?',
|
||||
settings: {
|
||||
...settings,
|
||||
topK: 8,
|
||||
candidateMultiplier: 5,
|
||||
minimumVectorSimilarity: 0.25,
|
||||
ftsWeight: 1,
|
||||
vectorWeight: 0.6,
|
||||
graphWeight: 0.4,
|
||||
localRerankEnabled: true,
|
||||
rerankMode: 'local'
|
||||
}
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存为默认值' }))
|
||||
expect(onSaveDefaults).toHaveBeenCalledWith({
|
||||
...settings,
|
||||
topK: 8,
|
||||
candidateMultiplier: 5,
|
||||
minimumVectorSimilarity: 0.25,
|
||||
ftsWeight: 1,
|
||||
vectorWeight: 0.6,
|
||||
graphWeight: 0.4,
|
||||
localRerankEnabled: true,
|
||||
rerankMode: 'local'
|
||||
})
|
||||
|
||||
expect(screen.getByText('本次检索已降级')).toBeInTheDocument()
|
||||
expect(screen.getByText('已扫描向量')).toBeInTheDocument()
|
||||
expect(screen.getByText('91%')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('article', {
|
||||
name: '第 1 条结果,离线部署.md'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看分块' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
|
||||
expect(onViewContext).toHaveBeenCalledWith(response.results[0])
|
||||
expect(onOpenSource).toHaveBeenCalledWith(response.results[0])
|
||||
})
|
||||
|
||||
it('distinguishes running, error, and zero-result states', () => {
|
||||
const { rerender } = render(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
initialQuery: '没有答案的问题',
|
||||
status: 'running'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'正在检索当前知识库'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '正在检索…' })).toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
error: '索引校验失败',
|
||||
initialQuery: '没有答案的问题',
|
||||
status: 'error'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('索引校验失败')
|
||||
|
||||
rerender(
|
||||
<KnowledgeRetrievalWorkbench
|
||||
{...createProps({
|
||||
initialQuery: '没有答案的问题',
|
||||
response: {
|
||||
...response,
|
||||
results: [],
|
||||
zeroReason: 'filtered'
|
||||
},
|
||||
status: 'success'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('结果已被阈值过滤')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('检索问题')).toHaveValue('没有答案的问题')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1980
-304
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import type {
|
||||
ReleaseNote,
|
||||
ReleaseNotesSnapshot
|
||||
} from '../../shared/release-notes-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { UiLocale } from './i18n'
|
||||
|
||||
type ReleaseNotesDialogProps = {
|
||||
@@ -80,29 +80,18 @@ export function ReleaseNotesDialog({
|
||||
}: ReleaseNotesDialogProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
restoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
const appShell = document.querySelector<HTMLElement>('.app-shell')
|
||||
const wasInert = appShell?.inert ?? false
|
||||
if (appShell) {
|
||||
appShell.inert = true
|
||||
}
|
||||
return () => {
|
||||
if (appShell) {
|
||||
appShell.inert = wasInert
|
||||
}
|
||||
restoreFocusRef.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
useEffect(
|
||||
() =>
|
||||
activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLElement>('button') ?? null
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
if (closing) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { CapabilitySnapshot } from '../../shared/capability-contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingIndexStatus,
|
||||
EmbeddingSettingsSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
|
||||
@@ -51,6 +50,11 @@ const runtimeSettings: RuntimeSettings = {
|
||||
knowledgeEmbeddingModel: 'nomic-embed-text',
|
||||
knowledgeEmbeddingApiKeyConfigured: false,
|
||||
knowledgeEmbeddingCredentialSource: 'none',
|
||||
knowledgeRerankEnabled: false,
|
||||
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
|
||||
knowledgeRerankModel: 'rerank-v3.5',
|
||||
knowledgeRerankApiKeyConfigured: false,
|
||||
knowledgeRerankCredentialSource: 'none',
|
||||
workspacePath: 'C:\\Workspace',
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'none',
|
||||
@@ -298,17 +302,13 @@ const updateExpert = vi.fn<DesktopApi['experts']['update']>(
|
||||
const removeExpert = vi.fn<DesktopApi['experts']['remove']>(
|
||||
async () => {}
|
||||
)
|
||||
const embeddingIndexStatus: EmbeddingIndexStatus = {
|
||||
job: null
|
||||
}
|
||||
const embeddingSnapshot: EmbeddingSettingsSnapshot = {
|
||||
configuration: {
|
||||
provider: 'openai-compatible',
|
||||
model: 'nomic-embed-text',
|
||||
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
||||
credentialConfigured: false
|
||||
},
|
||||
indexStatus: embeddingIndexStatus
|
||||
}
|
||||
}
|
||||
const getEmbeddingSnapshot = vi.fn(async () => embeddingSnapshot)
|
||||
const diagnoseEmbedding = vi.fn(
|
||||
@@ -321,33 +321,6 @@ const diagnoseEmbedding = vi.fn(
|
||||
checkedAt: Date.UTC(2026, 7, 5, 12, 0, 0)
|
||||
})
|
||||
)
|
||||
const rebuildEmbeddingIndex = vi.fn(
|
||||
async (): Promise<EmbeddingIndexStatus> => ({
|
||||
...embeddingIndexStatus,
|
||||
job: {
|
||||
id: 'job-1',
|
||||
status: 'running',
|
||||
provider: 'openai-compatible',
|
||||
model: 'nomic-embed-text',
|
||||
progress: { completed: 3, total: 42, percent: (3 / 42) * 100 },
|
||||
createdAt: Date.UTC(2026, 7, 5, 12, 0, 0),
|
||||
startedAt: Date.UTC(2026, 7, 5, 12, 0, 1)
|
||||
}
|
||||
})
|
||||
)
|
||||
const cancelEmbeddingIndex = vi.fn(async () => true)
|
||||
const embeddingStatusListeners: ((status: EmbeddingIndexStatus) => void)[] = []
|
||||
const onEmbeddingStatus = vi.fn(
|
||||
(listener: (status: EmbeddingIndexStatus) => void) => {
|
||||
embeddingStatusListeners.push(listener)
|
||||
return () => {
|
||||
const index = embeddingStatusListeners.indexOf(listener)
|
||||
if (index >= 0) {
|
||||
embeddingStatusListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: false,
|
||||
@@ -442,7 +415,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
}
|
||||
speechModelSnapshot = createSpeechModelSnapshot()
|
||||
embeddingStatusListeners.splice(0)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -489,10 +461,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
},
|
||||
embeddings: {
|
||||
getSnapshot: getEmbeddingSnapshot,
|
||||
diagnose: diagnoseEmbedding,
|
||||
rebuild: rebuildEmbeddingIndex,
|
||||
cancel: cancelEmbeddingIndex,
|
||||
onStatus: onEmbeddingStatus
|
||||
diagnose: diagnoseEmbedding
|
||||
},
|
||||
speechModels: {
|
||||
getSnapshot: getSpeechModelSnapshot,
|
||||
@@ -1031,10 +1000,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
name: '启用 Subagent 智能路由'
|
||||
})
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
expect(screen.getByText(/仅在 Ask 或 Plan 模式/)).toHaveTextContent(
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
|
||||
'自动选择 1 位专家'
|
||||
)
|
||||
expect(screen.getByText(/仅在 Ask 或 Plan 模式/)).toHaveTextContent(
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
|
||||
'只读运行且不使用工具'
|
||||
)
|
||||
|
||||
@@ -2011,7 +1980,54 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tests vector generation and rebuilds the index by document', async () => {
|
||||
it('configures learned reranking as an accessible model subtype', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重排模型' }))
|
||||
|
||||
const rerankSwitch = screen.getByRole('switch', {
|
||||
name: '启用学习型重排'
|
||||
})
|
||||
expect(rerankSwitch).not.toBeChecked()
|
||||
fireEvent.click(rerankSwitch)
|
||||
fireEvent.change(screen.getByLabelText('重排接口 URL'), {
|
||||
target: { value: 'https://rerank.example/v1/rerank' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('模型名称'), {
|
||||
target: { value: 'vendor/rerank-large' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('API Key(可选)'), {
|
||||
target: { value: 'rerank-secret' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
knowledgeRerankEnabled: true,
|
||||
knowledgeRerankEndpoint:
|
||||
'https://rerank.example/v1/rerank',
|
||||
knowledgeRerankModel: 'vendor/rerank-large',
|
||||
knowledgeRerankApiKey: {
|
||||
action: 'replace',
|
||||
value: 'rerank-secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('tests vector generation without exposing index controls', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
@@ -2042,52 +2058,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(
|
||||
await within(section).findByText(/服务返回 768 维向量/u)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
within(section).getByRole('button', { name: '重建向量索引' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(rebuildEmbeddingIndex).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
expect(
|
||||
await within(section).findByText('已完成 3 / 42 篇文档')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(section).getByText(
|
||||
/每篇文档会一次性更新,处理完成后立即可用于检索/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
within(section).getByRole('button', { name: '取消向量索引重建' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(cancelEmbeddingIndex).toHaveBeenCalledWith('job-1')
|
||||
)
|
||||
|
||||
expect(embeddingStatusListeners).toHaveLength(1)
|
||||
act(() => {
|
||||
embeddingStatusListeners[0]?.({
|
||||
job: {
|
||||
id: 'job-1',
|
||||
status: 'cancelled',
|
||||
provider: 'openai-compatible',
|
||||
model: 'nomic-embed-text',
|
||||
progress: { completed: 3, total: 42, percent: (3 / 42) * 100 },
|
||||
createdAt: Date.UTC(2026, 7, 5, 12, 0, 0),
|
||||
startedAt: Date.UTC(2026, 7, 5, 12, 0, 1),
|
||||
completedAt: Date.UTC(2026, 7, 5, 12, 0, 9)
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(
|
||||
within(section).getByText('已完成 3 / 42 篇文档。')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(section).getByText(
|
||||
/已完成文档保留新向量;其余文档保留原有向量/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
within(section).queryByRole('button', { name: '重建向量索引' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('manages heartbeat automation from Settings', async () => {
|
||||
|
||||
@@ -49,11 +49,11 @@ import type { AppearanceTheme } from './theme'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingSettingsSnapshot
|
||||
EmbeddingConfigurationSummary
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||
|
||||
type ModelType = 'llm' | 'embedding' | 'speech'
|
||||
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
||||
type AgentRuntimeType = RuntimeConfigActionInput['runtime']
|
||||
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
||||
supportsImageInput: boolean
|
||||
@@ -301,6 +301,16 @@ export function SettingsPanel({
|
||||
clearKnowledgeEmbeddingApiKey,
|
||||
setClearKnowledgeEmbeddingApiKey
|
||||
] = useState(false)
|
||||
const [knowledgeRerankEnabled, setKnowledgeRerankEnabled] =
|
||||
useState<boolean>(defaultRuntimeSettings.knowledgeRerankEnabled)
|
||||
const [knowledgeRerankEndpoint, setKnowledgeRerankEndpoint] =
|
||||
useState<string>(defaultRuntimeSettings.knowledgeRerankEndpoint)
|
||||
const [knowledgeRerankModel, setKnowledgeRerankModel] =
|
||||
useState<string>(defaultRuntimeSettings.knowledgeRerankModel)
|
||||
const [knowledgeRerankApiKey, setKnowledgeRerankApiKey] =
|
||||
useState('')
|
||||
const [clearKnowledgeRerankApiKey, setClearKnowledgeRerankApiKey] =
|
||||
useState(false)
|
||||
const [workspacePath, setWorkspacePath] = useState<string>(
|
||||
defaultRuntimeSettings.workspacePath
|
||||
)
|
||||
@@ -314,8 +324,8 @@ export function SettingsPanel({
|
||||
] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [embeddingSnapshot, setEmbeddingSnapshot] =
|
||||
useState<EmbeddingSettingsSnapshot>()
|
||||
const [embeddingConfiguration, setEmbeddingConfiguration] =
|
||||
useState<EmbeddingConfigurationSummary>()
|
||||
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
|
||||
useState<EmbeddingDiagnosticResult>()
|
||||
const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] =
|
||||
@@ -424,6 +434,20 @@ export function SettingsPanel({
|
||||
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
||||
setKnowledgeEmbeddingApiKey('')
|
||||
setClearKnowledgeEmbeddingApiKey(false)
|
||||
setKnowledgeRerankEnabled(
|
||||
value.knowledgeRerankEnabled ??
|
||||
defaultRuntimeSettings.knowledgeRerankEnabled
|
||||
)
|
||||
setKnowledgeRerankEndpoint(
|
||||
value.knowledgeRerankEndpoint ??
|
||||
defaultRuntimeSettings.knowledgeRerankEndpoint
|
||||
)
|
||||
setKnowledgeRerankModel(
|
||||
value.knowledgeRerankModel ??
|
||||
defaultRuntimeSettings.knowledgeRerankModel
|
||||
)
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
setWorkspacePath(value.workspacePath)
|
||||
setToolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
@@ -470,7 +494,7 @@ export function SettingsPanel({
|
||||
.then((snapshot) => {
|
||||
if (active) {
|
||||
setEmbeddingDiagnostic(undefined)
|
||||
setEmbeddingSnapshot(snapshot)
|
||||
setEmbeddingConfiguration(snapshot.configuration)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
@@ -485,16 +509,8 @@ export function SettingsPanel({
|
||||
)
|
||||
}
|
||||
})
|
||||
const unsubscribe = embeddings.onStatus((indexStatus) => {
|
||||
if (active) {
|
||||
setEmbeddingSnapshot((snapshot) =>
|
||||
snapshot ? { ...snapshot, indexStatus } : snapshot
|
||||
)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [i18n, open])
|
||||
|
||||
@@ -512,6 +528,8 @@ export function SettingsPanel({
|
||||
)
|
||||
setKnowledgeEmbeddingApiKey('')
|
||||
setClearKnowledgeEmbeddingApiKey(false)
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
setSpeechModelDraftId(undefined)
|
||||
setPersistedSpeechModelId(undefined)
|
||||
setSpeechModelSelectionDirty(false)
|
||||
@@ -577,6 +595,17 @@ export function SettingsPanel({
|
||||
value: knowledgeEmbeddingApiKey.trim()
|
||||
}
|
||||
: { action: 'keep' },
|
||||
knowledgeRerankEnabled,
|
||||
knowledgeRerankEndpoint,
|
||||
knowledgeRerankModel,
|
||||
knowledgeRerankApiKey: clearKnowledgeRerankApiKey
|
||||
? { action: 'clear' }
|
||||
: knowledgeRerankApiKey.trim()
|
||||
? {
|
||||
action: 'replace',
|
||||
value: knowledgeRerankApiKey.trim()
|
||||
}
|
||||
: { action: 'keep' },
|
||||
workspacePath,
|
||||
apiKey: profileInputs.find(
|
||||
(profile) => profile.id === defaultProfile.id
|
||||
@@ -621,6 +650,20 @@ export function SettingsPanel({
|
||||
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
||||
setKnowledgeEmbeddingApiKey('')
|
||||
setClearKnowledgeEmbeddingApiKey(false)
|
||||
setKnowledgeRerankEnabled(
|
||||
value.knowledgeRerankEnabled ??
|
||||
defaultRuntimeSettings.knowledgeRerankEnabled
|
||||
)
|
||||
setKnowledgeRerankEndpoint(
|
||||
value.knowledgeRerankEndpoint ??
|
||||
defaultRuntimeSettings.knowledgeRerankEndpoint
|
||||
)
|
||||
setKnowledgeRerankModel(
|
||||
value.knowledgeRerankModel ??
|
||||
defaultRuntimeSettings.knowledgeRerankModel
|
||||
)
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
setToolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
)
|
||||
@@ -635,7 +678,9 @@ export function SettingsPanel({
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (embeddings) {
|
||||
try {
|
||||
setEmbeddingSnapshot(await embeddings.getSnapshot())
|
||||
setEmbeddingConfiguration(
|
||||
(await embeddings.getSnapshot()).configuration
|
||||
)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
settingsErrorMessage(
|
||||
@@ -730,7 +775,9 @@ export function SettingsPanel({
|
||||
}
|
||||
const diagnostic = await embeddings.diagnose()
|
||||
setEmbeddingDiagnostic(diagnostic)
|
||||
setEmbeddingSnapshot(await embeddings.getSnapshot())
|
||||
setEmbeddingConfiguration(
|
||||
(await embeddings.getSnapshot()).configuration
|
||||
)
|
||||
} catch (reason) {
|
||||
setError(settingsErrorMessage(reason, t('errors.testEmbedding')))
|
||||
} finally {
|
||||
@@ -738,44 +785,6 @@ export function SettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildEmbeddingIndex = async (): Promise<void> => {
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (!embeddings) {
|
||||
setError(t('errors.embeddingIndexUnavailable'))
|
||||
return
|
||||
}
|
||||
setError(undefined)
|
||||
try {
|
||||
if (!(await save(false))) {
|
||||
return
|
||||
}
|
||||
const indexStatus = await embeddings.rebuild()
|
||||
setEmbeddingSnapshot((snapshot) =>
|
||||
snapshot ? { ...snapshot, indexStatus } : snapshot
|
||||
)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
settingsErrorMessage(reason, t('errors.rebuildEmbeddingIndex'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEmbeddingIndex = async (jobId: string): Promise<void> => {
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (!embeddings) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!(await embeddings.cancel(jobId))) {
|
||||
throw new Error(t('errors.embeddingJobFinished'))
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(
|
||||
settingsErrorMessage(reason, t('errors.cancelEmbeddingIndex'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const selectRuntimeFile = async (
|
||||
kind: RuntimeFileSelectionKind,
|
||||
setValue: (value: string) => void
|
||||
@@ -1730,6 +1739,10 @@ export function SettingsPanel({
|
||||
label: t('model.types.embedding.label'),
|
||||
value: 'embedding'
|
||||
},
|
||||
{
|
||||
label: t('model.types.rerank.label'),
|
||||
value: 'rerank'
|
||||
},
|
||||
{
|
||||
label: t('model.types.speech.label'),
|
||||
value: 'speech'
|
||||
@@ -1742,7 +1755,9 @@ export function SettingsPanel({
|
||||
? t('model.types.llm.description')
|
||||
: modelType === 'embedding'
|
||||
? t('model.types.embedding.description')
|
||||
: t('model.types.speech.description')}
|
||||
: modelType === 'rerank'
|
||||
? t('model.types.rerank.description')
|
||||
: t('model.types.speech.description')}
|
||||
</small>
|
||||
</div>
|
||||
{modelType === 'llm' && (
|
||||
@@ -2225,24 +2240,115 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelType === 'embedding' && embeddingSnapshot && (
|
||||
{modelType === 'embedding' && embeddingConfiguration && (
|
||||
<EmbeddingSettingsSection
|
||||
configuration={embeddingSnapshot.configuration}
|
||||
configuration={embeddingConfiguration}
|
||||
diagnostic={embeddingDiagnostic}
|
||||
diagnosticRunning={embeddingDiagnosticRunning}
|
||||
disabled={saving || !knowledgeEmbeddingEnabled}
|
||||
indexStatus={embeddingSnapshot.indexStatus}
|
||||
onCancel={(jobId) => {
|
||||
void cancelEmbeddingIndex(jobId)
|
||||
}}
|
||||
onRebuild={() => {
|
||||
void rebuildEmbeddingIndex()
|
||||
}}
|
||||
onTest={() => {
|
||||
void runEmbeddingDiagnostic()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modelType === 'rerank' && (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<KeyRound size={17} />
|
||||
<div>
|
||||
<strong>{t('model.rerank.title')}</strong>
|
||||
<small>{t('model.rerank.description')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={knowledgeRerankEnabled}
|
||||
onChange={(event) =>
|
||||
setKnowledgeRerankEnabled(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('model.rerank.enabled')}</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('model.rerank.endpoint')}</span>
|
||||
<input
|
||||
aria-label={t('model.rerank.endpoint')}
|
||||
disabled={!knowledgeRerankEnabled}
|
||||
inputMode="url"
|
||||
onChange={(event) =>
|
||||
setKnowledgeRerankEndpoint(event.target.value)
|
||||
}
|
||||
placeholder="https://api.cohere.com/v1/rerank"
|
||||
value={knowledgeRerankEndpoint}
|
||||
/>
|
||||
<small>{t('model.rerank.endpointDescription')}</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('model.rerank.modelName')}</span>
|
||||
<input
|
||||
aria-label={t('model.rerank.modelName')}
|
||||
disabled={!knowledgeRerankEnabled}
|
||||
onChange={(event) =>
|
||||
setKnowledgeRerankModel(event.target.value)
|
||||
}
|
||||
value={knowledgeRerankModel}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('model.rerank.optionalApiKey')}</span>
|
||||
<input
|
||||
aria-label={t('model.rerank.optionalApiKey')}
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
!knowledgeRerankEnabled ||
|
||||
settings?.knowledgeRerankCredentialSource ===
|
||||
'environment' ||
|
||||
!settings?.secureStorageAvailable
|
||||
}
|
||||
onChange={(event) => {
|
||||
setKnowledgeRerankApiKey(event.target.value)
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
}}
|
||||
placeholder={
|
||||
settings?.knowledgeRerankApiKeyConfigured
|
||||
? t('credentials.configuredPlaceholder')
|
||||
: t('model.rerank.optionalApiKeyPlaceholder')
|
||||
}
|
||||
type="password"
|
||||
value={knowledgeRerankApiKey}
|
||||
/>
|
||||
</label>
|
||||
<div className="credential-state" aria-live="polite">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{settings
|
||||
? t(
|
||||
`credentials.${settings.knowledgeRerankCredentialSource ?? 'none'}`
|
||||
)
|
||||
: t('credentials.none')}
|
||||
</span>
|
||||
{settings?.knowledgeRerankCredentialSource ===
|
||||
'encrypted' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{clearKnowledgeRerankApiKey
|
||||
? t('actions.clearAfterSave')
|
||||
: t('actions.clearCredential')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<small>{t('model.rerank.privacyDescription')}</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelType === 'speech' && (
|
||||
<SpeechModelSettingsSection
|
||||
onNotify={onNotify}
|
||||
|
||||
@@ -102,6 +102,12 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/button\s*>\s*svg,\s*button\s*>\s*svg\s+\*\s*\{[^}]*pointer-events:\s*none;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/button\s*>\s*svg\s*\{[^}]*display:\s*block;[^}]*flex:\s*0 0 auto;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.primary-button,\s*\.secondary-button,\s*\.danger-button\s*\{[^}]*display:\s*inline-flex;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;[^}]*gap:\s*var\(--space-2\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/button:focus-visible,\s*input:focus-visible,\s*select:focus-visible,\s*textarea:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);/u
|
||||
)
|
||||
@@ -141,6 +147,24 @@ describe('WorkspacePrimitives', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps knowledge settings cards separated as page sections', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-settings--index\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-settings--graph\s*\{[^}]*grid-template-columns:\s*minmax\(280px,\s*0\.7fr\)\s*minmax\(0,\s*1\.3fr\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-settings\s*>\s*section\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-4\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-settings__toggle\s*\{[^}]*display:\s*grid;[^}]*align-items:\s*flex-start;[^}]*justify-content:\s*initial;[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\);/u
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a consistent page shell and scoped header', () => {
|
||||
render(
|
||||
<PageShell variant="dashboard">
|
||||
|
||||
@@ -34,3 +34,24 @@ export function trapTabFocus(
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
export function activateModalFocus(
|
||||
initialFocus: () => HTMLElement | null
|
||||
): () => void {
|
||||
const restoreFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
const appShell = document.querySelector<HTMLElement>('.app-shell')
|
||||
const wasInert = appShell?.inert ?? false
|
||||
if (appShell) {
|
||||
appShell.inert = true
|
||||
}
|
||||
initialFocus()?.focus()
|
||||
return () => {
|
||||
if (appShell) {
|
||||
appShell.inert = wasInert
|
||||
}
|
||||
restoreFocus?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +181,43 @@ export const app = {
|
||||
view: 'View {{count}} evidence references',
|
||||
retrieval: 'Retrieved by: ',
|
||||
fullText: 'Full text',
|
||||
cjk: 'CJK terms',
|
||||
vector: 'Vector',
|
||||
graph: 'Graph'
|
||||
graph: 'Graph',
|
||||
viewContext: 'View context',
|
||||
openSource: 'Open source',
|
||||
openFailed: 'Could not open the citation source',
|
||||
contextTitle: 'Citation context',
|
||||
contextDescription:
|
||||
'Review the matched chunk and its surrounding content.',
|
||||
contextLoading: 'Loading citation context…',
|
||||
contextUnavailable: 'Citation context is unavailable',
|
||||
contextTruncated:
|
||||
'Context exceeded the safe display limit and was truncated.',
|
||||
closeContext: 'Close citation context',
|
||||
matchedChunk: 'Matched chunk',
|
||||
surroundingContext: 'Full context',
|
||||
score: 'Relevance {{score}}'
|
||||
},
|
||||
knowledgeRetrieval: {
|
||||
searching: 'Searching the enabled knowledge bases',
|
||||
states: {
|
||||
searching: 'Searching knowledge bases',
|
||||
succeeded: 'Knowledge retrieval completed',
|
||||
zero: 'No relevant knowledge found',
|
||||
degraded: 'Knowledge retrieval was degraded',
|
||||
failed: 'Knowledge retrieval failed',
|
||||
cancelled: 'Knowledge retrieval was cancelled'
|
||||
},
|
||||
summary:
|
||||
'Searched {{libraries}} libraries, found {{results}} results in {{duration}} ms',
|
||||
channels: 'Channels used: {{channels}}',
|
||||
channelNames: {
|
||||
fts: 'Full text',
|
||||
cjk: 'CJK terms',
|
||||
vector: 'Vector',
|
||||
graph: 'Graph'
|
||||
}
|
||||
},
|
||||
retry: 'Edit and send again',
|
||||
status: {
|
||||
@@ -342,7 +377,14 @@ export const app = {
|
||||
select: 'Select knowledge bases, {{count}} enabled',
|
||||
title: 'Select knowledge bases for this conversation',
|
||||
scope: 'Retrieval scope for this conversation',
|
||||
documents: '{{count}} documents'
|
||||
documents: '{{count}} documents',
|
||||
modeLabel: 'Knowledge retrieval mode',
|
||||
auto: 'Model decides',
|
||||
always: 'Always retrieve first',
|
||||
autoDescription:
|
||||
'Let the model decide whether the current question needs knowledge.',
|
||||
alwaysDescription:
|
||||
'GoodBuddy searches the enabled knowledge bases before the model answers.'
|
||||
},
|
||||
hints: {
|
||||
configureRuntime: 'Configure an available model or Agent Runtime first.',
|
||||
@@ -421,6 +463,13 @@ export const app = {
|
||||
selectKnowledgeBase: 'Select a knowledge base first',
|
||||
knowledgeGraphRebuilt: 'Knowledge graph extracted again',
|
||||
knowledgeSettingsUpdated: 'Knowledge base settings updated',
|
||||
knowledgeRebuildCompleted: 'Rebuilt {{count}} documents',
|
||||
knowledgeRebuildPartial:
|
||||
'Library rebuild was incomplete: {{rebuilt}} succeeded, {{failed}} failed',
|
||||
knowledgeRebuildNotRunning:
|
||||
'There is no active library rebuild to cancel',
|
||||
knowledgeTaskNotRunning:
|
||||
'This task has finished or cannot currently be cancelled',
|
||||
evidenceExcerpt: '{{source}}: {{excerpt}}'
|
||||
},
|
||||
markdown: {
|
||||
|
||||
@@ -243,7 +243,7 @@ export const integrations = {
|
||||
title: 'Web search',
|
||||
subtitle: 'Direct-model tool · Exa MCP · Ask / Execute',
|
||||
description:
|
||||
'Provides web_search and web_fetch for public web search and reading only. The tools are unavailable in Plan mode.',
|
||||
'Provides web_search and web_fetch for public web search and reading only. The tools are available in Ask and Execute.',
|
||||
privacy:
|
||||
'Queries and public webpage addresses are sent to the third-party Exa service. Model API keys, local files, and knowledge content are not sent.',
|
||||
enableAriaLabel: 'Enable direct-model web search',
|
||||
|
||||
@@ -5,8 +5,7 @@ export const knowledge = {
|
||||
page: {
|
||||
eyebrow: 'Knowledge',
|
||||
title: 'Knowledge Base',
|
||||
description:
|
||||
'Organize files, folders, and web sources into traceable indexes and graphs that work across projects.'
|
||||
description: 'Manage files, folders, and web sources, then inspect their indexes and graph.'
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Cancel',
|
||||
@@ -34,6 +33,7 @@ export const knowledge = {
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
viewTasks: 'View tasks',
|
||||
backToLibraryList: 'Back to library list',
|
||||
goToSettings: 'Go to settings'
|
||||
},
|
||||
@@ -82,16 +82,40 @@ export const knowledge = {
|
||||
failed: 'Processing failed'
|
||||
},
|
||||
taskKinds: {
|
||||
sourceSync: 'Source sync',
|
||||
documentProcess: 'Document processing',
|
||||
documentRebuild: 'Document rebuild',
|
||||
libraryRebuild: 'Library rebuild',
|
||||
embeddingRebuild: 'Embedding index rebuild',
|
||||
graphRebuild: 'Knowledge graph rebuild',
|
||||
parsing: 'Document parsing',
|
||||
embedding: 'Embedding',
|
||||
graph: 'Graph extraction'
|
||||
},
|
||||
taskStages: {
|
||||
queued: 'Waiting to start',
|
||||
syncing: 'Syncing source',
|
||||
reading: 'Reading content',
|
||||
parsing: 'Parsing document',
|
||||
chunking: 'Creating chunks',
|
||||
indexing: 'Building index',
|
||||
embedding: 'Creating embeddings',
|
||||
graph: 'Extracting graph',
|
||||
finalizing: 'Finalizing'
|
||||
},
|
||||
taskStatuses: {
|
||||
queued: 'Waiting',
|
||||
running: 'In progress',
|
||||
succeeded: 'Completed',
|
||||
failed: 'Failed',
|
||||
skipped: 'Skipped'
|
||||
cancelled: 'Cancelled',
|
||||
skipped: 'Skipped',
|
||||
interrupted: 'Interrupted'
|
||||
},
|
||||
taskScopes: {
|
||||
library: 'Library scope',
|
||||
source: 'Source scope',
|
||||
document: 'Document scope'
|
||||
},
|
||||
format: {
|
||||
neverSynced: 'Never synced',
|
||||
@@ -133,6 +157,262 @@ export const knowledge = {
|
||||
'This library references original files. Deleting it removes only indexes and graphs, not the original files on disk.',
|
||||
triggerAriaLabel: 'Delete library {{name}}'
|
||||
},
|
||||
retrieval: {
|
||||
eyebrow: 'Current library: {{libraryName}}',
|
||||
title: 'Retrieval test',
|
||||
description:
|
||||
'Validate retrieval from this library with temporary settings. This test does not create a conversation, call an LLM, or modify knowledge content.',
|
||||
close: 'Close retrieval test',
|
||||
query: {
|
||||
title: 'Test question',
|
||||
help: 'Enter a real question to inspect channels, ranking, and final context.',
|
||||
label: 'Retrieval question',
|
||||
placeholder: 'For example: How do I configure document parsing offline?',
|
||||
count: '{{count}} / 4000 characters'
|
||||
},
|
||||
pipeline: {
|
||||
recall: {
|
||||
title: 'Recall candidates',
|
||||
summary: 'Up to {{count}} fused candidates',
|
||||
pending: 'Enter valid settings to calculate'
|
||||
},
|
||||
rerank: {
|
||||
title: 'Local reranking',
|
||||
enabled: 'Rerank up to {{count}} candidates',
|
||||
disabled: 'Off; keep fused ranking'
|
||||
},
|
||||
select: {
|
||||
title: 'Final results',
|
||||
summary: 'Keep the top {{count}} chunks'
|
||||
},
|
||||
context: {
|
||||
title: 'Assemble context',
|
||||
summary: '{{count}} character budget'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings for this test',
|
||||
temporary:
|
||||
'These changes apply only to this test. Save them as the current library defaults to keep using them.',
|
||||
groups: {
|
||||
recall: {
|
||||
title: 'Candidate recall',
|
||||
description:
|
||||
'Control the initial search pool, filtering threshold, and each channel’s influence on fused ranking.'
|
||||
},
|
||||
output: {
|
||||
title: 'Reranking and context',
|
||||
description:
|
||||
'Control candidate ordering, final result count, and the context sent to the model.'
|
||||
}
|
||||
},
|
||||
candidateMultiplier: 'Recall multiplier',
|
||||
candidateMultiplierHelp:
|
||||
'From 2 to 10; currently recalls up to {{count}} fused candidates',
|
||||
channelWeights: 'Channel fusion share (100% total)',
|
||||
topK: 'Final result count',
|
||||
topKHelp: 'Keep 1 to 20 results after reranking',
|
||||
vectorSimilarity: 'Minimum vector similarity (%)',
|
||||
vectorSimilarityHelp:
|
||||
'From 0% to 100%; 0% keeps all non-negative similarities',
|
||||
ftsWeight: 'Full-text share',
|
||||
vectorWeight: 'Vector share',
|
||||
graphWeight: 'Graph share',
|
||||
weightHelp:
|
||||
'Used as a relative fusion share; available channels should normally total 100%',
|
||||
graphUnavailable:
|
||||
'The graph is disabled for this library, so this weight is not currently used.',
|
||||
contextBudget: 'Context character budget',
|
||||
contextBudgetHelp: 'From 2,000 to 48,000',
|
||||
adjacentCount: 'Adjacent chunk count',
|
||||
adjacentCountHelp: 'Merge 0 to 2 chunks before and after each match',
|
||||
localRerank: 'Enable local reranking',
|
||||
localRerankHelp:
|
||||
'Deterministically rerank every fused candidate recalled for this run without calling another AI model, so no separate rerank count is needed.',
|
||||
rerankMode: 'Reranking method',
|
||||
rerankModeHelp:
|
||||
'Learned reranking calls the configured Cohere/Jina-compatible model and reports safe fallback details.',
|
||||
rerankModes: {
|
||||
none: 'No reranking',
|
||||
local: 'Local rules',
|
||||
learned: 'Learned model'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
queryRequired: 'Enter a test question.',
|
||||
queryTooLong: 'The test question cannot exceed 4,000 characters.',
|
||||
topK: 'Top K must be an integer from 1 to 20.',
|
||||
candidateMultiplier:
|
||||
'The recall multiplier must be an integer from 2 to 10.',
|
||||
vectorSimilarity:
|
||||
'Minimum vector similarity must be between 0% and 100%.',
|
||||
weight: 'Channel fusion shares must be between 0% and 100%.',
|
||||
weightTotal:
|
||||
'Fusion shares for the available retrieval channels must total 100%.',
|
||||
activeWeight:
|
||||
'At least one currently available retrieval channel must have a weight greater than 0.',
|
||||
contextBudget:
|
||||
'The context budget must be an integer from 2,000 to 48,000.',
|
||||
adjacentCount:
|
||||
'The adjacent chunk count must be an integer from 0 to 2.'
|
||||
},
|
||||
actions: {
|
||||
test: 'Test retrieval',
|
||||
running: 'Retrieving…',
|
||||
saveDefaults: 'Save as defaults',
|
||||
savingDefaults: 'Saving…',
|
||||
viewContext: 'View chunk',
|
||||
openSource: 'Open source'
|
||||
},
|
||||
states: {
|
||||
runningTitle: 'Searching the current library',
|
||||
runningDescription:
|
||||
'Scanning bounded candidates and assembling context.',
|
||||
errorTitle: 'Retrieval test failed',
|
||||
errorDescription:
|
||||
'Check the library index status or adjust the settings, then try again.'
|
||||
},
|
||||
channels: {
|
||||
fts: 'Full text',
|
||||
cjk: 'CJK',
|
||||
vector: 'Vector',
|
||||
graph: 'Graph'
|
||||
},
|
||||
diagnostics: {
|
||||
duration: 'Total duration',
|
||||
milliseconds: '{{count}} ms',
|
||||
requested: 'Requested channels',
|
||||
used: 'Used channels',
|
||||
none: 'None',
|
||||
vectorScanned: 'Vectors scanned',
|
||||
channelSummary: '{{candidates}} candidates · {{duration}} ms',
|
||||
degradedTitle: 'This retrieval was degraded',
|
||||
rerank: 'Reranking',
|
||||
rerankSummary:
|
||||
'Requested {{requested}}, used {{used}} · {{status}} · {{count}} candidates · {{duration}} ms',
|
||||
rerankStatuses: {
|
||||
skipped: 'Skipped',
|
||||
applied: 'Applied',
|
||||
fallback: 'Fallback',
|
||||
failed: 'Failed'
|
||||
}
|
||||
},
|
||||
zero: {
|
||||
'empty-library': {
|
||||
title: 'This library has no searchable content',
|
||||
description:
|
||||
'Import and finish indexing a document before testing again.'
|
||||
},
|
||||
'index-unavailable': {
|
||||
title: 'The current index is unavailable',
|
||||
description:
|
||||
'Check parsing and index status, repair failed items, and try again.'
|
||||
},
|
||||
'no-match': {
|
||||
title: 'No relevant content found',
|
||||
description:
|
||||
'Try different keywords, use a specific name from the source, or increase Top K.'
|
||||
},
|
||||
filtered: {
|
||||
title: 'Results were removed by the threshold',
|
||||
description:
|
||||
'Lower the minimum vector similarity or check the channel weights, then try again.'
|
||||
}
|
||||
},
|
||||
results: {
|
||||
title: 'Retrieval results ({{count}})',
|
||||
contextSummary:
|
||||
'Final context: {{count}} / {{budget}} characters',
|
||||
truncated: 'Truncated',
|
||||
listAriaLabel: 'Retrieval results',
|
||||
resultAriaLabel: 'Result {{rank}}, {{documentName}}',
|
||||
unknownLocator: 'Unknown location',
|
||||
relevance: 'Relevance',
|
||||
fusedScore: 'Fused score',
|
||||
channelDetail:
|
||||
'Rank {{rank}} · raw score {{score}} · similarity {{similarity}}',
|
||||
beforeRerank: 'Rank before reranking',
|
||||
context: 'Context',
|
||||
contextDetail: '{{count}} characters · {{truncated}}',
|
||||
complete: 'Complete',
|
||||
diagnostics: 'Result diagnostics',
|
||||
actualContext: 'View actual context'
|
||||
}
|
||||
},
|
||||
chunks: {
|
||||
title: 'Document chunks',
|
||||
documentUnavailable:
|
||||
'The document for this retrieval result is currently unavailable.',
|
||||
description:
|
||||
'Search, preview, and maintain the bounded chunk list for this document.',
|
||||
close: 'Close document chunks',
|
||||
listAriaLabel: 'Document chunk list',
|
||||
syncWarningTitle: 'Manual changes may be replaced.',
|
||||
syncWarning:
|
||||
'Syncing the source or rebuilding the document may recreate chunks from the original content. Deleting a chunk does not delete the original file.',
|
||||
search: {
|
||||
label: 'Search chunks in this document',
|
||||
placeholder: 'Search headings, locations, or content',
|
||||
action: 'Search'
|
||||
},
|
||||
loadErrorTitle: 'The chunk operation did not finish',
|
||||
loadingTitle: 'Loading chunks',
|
||||
loadingDescription: 'Reading chunks on the current page.',
|
||||
zeroTitle: 'No chunks match',
|
||||
zeroDescription:
|
||||
'Change the search query or rebuild the document to regenerate chunks.',
|
||||
ordinal: 'Chunk {{count}}',
|
||||
headingSeparator: ' · {{heading}}',
|
||||
parentMetadata: ' · parent {{parentId}}',
|
||||
unknownLocator: 'Unknown location',
|
||||
characterCount: '{{count}} characters',
|
||||
enabled: 'Include in retrieval',
|
||||
enabledAriaLabel: 'Enable chunk {{count}}',
|
||||
roles: {
|
||||
standalone: 'Standalone',
|
||||
parent: 'Parent',
|
||||
child: 'Child'
|
||||
},
|
||||
pagination: {
|
||||
ariaLabel: 'Chunk pagination',
|
||||
previous: 'Previous chunk page',
|
||||
next: 'Next chunk page',
|
||||
summary: 'Page {{page}} of {{total}}, {{count}} total'
|
||||
},
|
||||
editor: {
|
||||
title: 'Edit chunk {{count}}',
|
||||
metadata: '{{role}} · {{locator}}',
|
||||
manuallyEdited: 'Manually edited',
|
||||
role: 'Chunk role',
|
||||
parent: 'Parent chunk ID',
|
||||
content: 'Chunk content',
|
||||
count: '{{count}} / {{max}} characters',
|
||||
save: 'Save chunk',
|
||||
saving: 'Saving…',
|
||||
noSelectionTitle: 'Select a chunk',
|
||||
noSelectionDescription:
|
||||
'Select a chunk from the list to view its complete content and parent-child relationship.'
|
||||
},
|
||||
validation: {
|
||||
contentRequired: 'Chunk content cannot be empty.',
|
||||
contentTooLong: 'Chunk content cannot exceed {{count}} characters.'
|
||||
},
|
||||
delete: {
|
||||
trigger: 'Delete chunk',
|
||||
triggerAriaLabel: 'Delete chunk {{count}}',
|
||||
confirmAriaLabel: 'Confirm deletion of chunk {{count}}',
|
||||
confirm: 'Delete chunk',
|
||||
deleting: 'Deleting…',
|
||||
message:
|
||||
'Deleting chunk {{count}} removes its full-text, CJK, vector, and graph evidence. A source sync or rebuild may recreate it; the original file is not deleted.'
|
||||
},
|
||||
rebuild: {
|
||||
action: 'Rebuild document',
|
||||
running: 'Rebuilding…',
|
||||
description:
|
||||
'Rebuilding reads the source again; a failed rebuild should preserve the last usable index.'
|
||||
}
|
||||
},
|
||||
graph: {
|
||||
title: 'Knowledge graph',
|
||||
enable: 'Enable knowledge graph',
|
||||
@@ -153,11 +433,14 @@ export const knowledge = {
|
||||
entityPickerAriaLabel: 'Select graph entity',
|
||||
zoomOutAriaLabel: 'Zoom out graph',
|
||||
zoomInAriaLabel: 'Zoom in graph',
|
||||
fitView: 'Show all',
|
||||
interactionHint:
|
||||
'Drag nodes to arrange them. Drag the canvas to pan, and use the wheel to zoom.',
|
||||
empty: 'This library does not have any generated entity relations yet.',
|
||||
topologyAriaLabel: 'Graph topology',
|
||||
visibleRelations: {
|
||||
title: 'Visible relations',
|
||||
description: 'Updates with the current search and type filter.',
|
||||
description: 'Shows relations in the current filter results.',
|
||||
count: '{{count}} relations',
|
||||
empty: 'No relations are visible with the current filters.',
|
||||
listAriaLabel: 'Visible relations list'
|
||||
@@ -182,15 +465,19 @@ export const knowledge = {
|
||||
},
|
||||
detailsAriaLabel: 'Graph details',
|
||||
detailsPrompt: 'Select a graph node to view entity details.',
|
||||
disabledTitle: 'Knowledge graph is disabled',
|
||||
disabledDescription:
|
||||
'Enable the knowledge graph in Settings to view entity relations and re-extract them.'
|
||||
workspace: {
|
||||
tabsAriaLabel: 'Knowledge graph workspace',
|
||||
explore: 'Graph explorer',
|
||||
settings: 'Graph settings'
|
||||
}
|
||||
},
|
||||
documents: {
|
||||
sources: {
|
||||
title: 'Content sources',
|
||||
description:
|
||||
'Imported content is parsed, indexed, and added to the graph automatically.',
|
||||
'Imported content is parsed and added to the retrieval index automatically.',
|
||||
descriptionWithGraph:
|
||||
'Imported content is parsed, indexed for retrieval, and added to the knowledge graph with the current strategy.',
|
||||
emptyTitle: 'No content sources connected',
|
||||
emptyDescription:
|
||||
'Choose files, a folder, or a URL, or drag files into the area above.',
|
||||
@@ -225,8 +512,10 @@ export const knowledge = {
|
||||
document: 'Document',
|
||||
status: 'Status',
|
||||
indexProgress: 'Index progress',
|
||||
processingStatus: 'Processing status',
|
||||
chunks: 'Chunks',
|
||||
size: 'Size'
|
||||
size: 'Size',
|
||||
actions: 'Actions'
|
||||
}
|
||||
},
|
||||
search: {
|
||||
@@ -241,7 +530,10 @@ export const knowledge = {
|
||||
},
|
||||
relationEditor: {
|
||||
editAriaLabel: 'Edit relation',
|
||||
addAriaLabel: 'Add relation'
|
||||
addAriaLabel: 'Add relation',
|
||||
selectType: 'Select a relation type',
|
||||
noCompatibleTypes:
|
||||
'No relation type allows the current source and target types.'
|
||||
},
|
||||
settings: {
|
||||
description:
|
||||
@@ -250,7 +542,111 @@ export const knowledge = {
|
||||
'When enabled, newly imported and resynced documents use the selected graph strategy.',
|
||||
strategyAriaLabel: 'Knowledge graph extraction strategy',
|
||||
askDescription:
|
||||
'“Ask when needed” does not generate a graph automatically and cannot run re-extraction.'
|
||||
'“Ask when needed” does not generate a graph automatically and cannot run re-extraction.',
|
||||
graphCapability: {
|
||||
title: 'Optional capabilities',
|
||||
description:
|
||||
'Enable extra capabilities when needed. Disabled capabilities stay out of the workspace.',
|
||||
enabledDescription:
|
||||
'The knowledge graph is enabled. Explore relations and manage graph settings from Knowledge graph.',
|
||||
disabledDescription:
|
||||
'Enable it to expose graph exploration, extraction strategy, and ontology definitions.'
|
||||
},
|
||||
graphConfiguration: {
|
||||
title: 'Extraction method',
|
||||
description:
|
||||
'Control how new imports, resyncs, and explicit rebuilds generate entities, relations, and evidence.'
|
||||
},
|
||||
chunking: {
|
||||
title: 'Chunking strategy',
|
||||
description:
|
||||
'Configure chunking for future imports and rebuilds. Saving does not immediately rewrite existing documents.',
|
||||
mode: 'Chunking mode',
|
||||
modes: {
|
||||
fixed: 'Fixed length',
|
||||
structure: 'Document structure',
|
||||
parentChild: 'Parent-child'
|
||||
},
|
||||
targetCharacters: 'Target characters',
|
||||
overlapCharacters: 'Overlap characters',
|
||||
contextualIndexing: 'Enable contextual indexing',
|
||||
contextualIndexingDescription:
|
||||
'Add the document title, heading path, page, and block type to retrieval and embedding text while keeping citations source-faithful.',
|
||||
parentCharacters: 'Parent chunk characters',
|
||||
childCharacters: 'Child chunk characters',
|
||||
rebuildRequired:
|
||||
'Settings changed. Rebuild existing documents to apply them everywhere.',
|
||||
save: 'Save chunking settings',
|
||||
saving: 'Saving…',
|
||||
rebuild: 'Rebuild entire library',
|
||||
rebuilding: 'Rebuilding…',
|
||||
cancelRebuild: 'Cancel rebuild'
|
||||
},
|
||||
ontology: {
|
||||
title: 'Ontology definitions',
|
||||
description:
|
||||
'Control entity types, relation types, and relation endpoint constraints for this library. IDs use uppercase letters, numbers, and underscores.',
|
||||
entityTypes: 'Entity types',
|
||||
relationTypes: 'Relation types',
|
||||
id: 'Canonical ID',
|
||||
nameZh: 'Chinese name',
|
||||
nameEn: 'English name',
|
||||
aliases: 'Aliases (comma-separated, up to 32)',
|
||||
sourceTypes: 'Allowed source types',
|
||||
targetTypes: 'Allowed target types',
|
||||
anyEndpoint: 'Allow any entity type',
|
||||
anyEndpointHelp: 'No endpoint constraint is set.',
|
||||
save: 'Save ontology definitions',
|
||||
saving: 'Saving…',
|
||||
validation:
|
||||
'Fix duplicate IDs, duplicate aliases, empty names, or invalid endpoint constraints.',
|
||||
rebuildRequired:
|
||||
'Ontology definitions changed. Explicitly rebuild existing documents to normalize the current graph again.',
|
||||
noImplicitRebuild:
|
||||
'Saving updates only this library’s settings. It does not rebuild documents or the graph automatically.'
|
||||
},
|
||||
vectorIndex: {
|
||||
title: 'Embedding index',
|
||||
description:
|
||||
'Review coverage for this library with the current embedding model and rebuild only this library.',
|
||||
rebuild: 'Rebuild embedding index',
|
||||
rebuilding: 'Rebuilding…',
|
||||
cancel: 'Cancel rebuild',
|
||||
cancelAria: 'Cancel this library embedding index rebuild',
|
||||
loading: 'Loading embedding index status…',
|
||||
disabledTitle: 'Embedding model is disabled',
|
||||
disabledDescription:
|
||||
'Enable and save an embedding model under Model connections, then return here to rebuild this library.',
|
||||
currentModel: 'Current embedding model',
|
||||
coverage: {
|
||||
indexed: 'Indexed',
|
||||
missing: 'Missing',
|
||||
error: 'Error',
|
||||
total: 'Total documents'
|
||||
},
|
||||
statuses: {
|
||||
queued: 'Waiting to rebuild',
|
||||
running: 'Rebuilding',
|
||||
completed: 'Last rebuild succeeded',
|
||||
failed: 'Last rebuild failed',
|
||||
cancelled: 'Last rebuild was cancelled'
|
||||
},
|
||||
progressAria: 'Current library embedding index rebuild progress',
|
||||
progress: '{{completed}} / {{total}} documents completed',
|
||||
preparing: 'Preparing documents…',
|
||||
completedAt:
|
||||
'{{completed}} / {{total}} documents completed. {{date}}',
|
||||
atomicNotice:
|
||||
'Each document is updated atomically. If cancelled, completed documents keep new embeddings and all others remain unchanged.',
|
||||
cancelledNotice:
|
||||
'{{completed}} / {{total}} documents completed. All others remain unchanged.',
|
||||
defaultRemedy:
|
||||
'Check the embedding model configuration and network connection, then retry.',
|
||||
activeTitle: 'An embedding index task is running',
|
||||
activeDescription:
|
||||
'Open the Task center for details, progress, and available actions.',
|
||||
viewTasks: 'View details in Task center'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
emptyDescription:
|
||||
@@ -258,16 +654,50 @@ export const knowledge = {
|
||||
emptyTitle: 'No knowledge tasks yet',
|
||||
title: 'Task center',
|
||||
recentCount: '{{count}} recent tasks',
|
||||
totalCount: '{{count}} tasks',
|
||||
activeCount: '{{count}} in progress',
|
||||
failedCount: '{{count}} failed',
|
||||
historyCount: '{{count}} in history',
|
||||
progressAriaLabel: '{{name}} {{kind}} progress',
|
||||
waiting: 'Waiting to process'
|
||||
waiting: 'Waiting to process',
|
||||
currentStage: 'Current stage',
|
||||
itemProgress: '{{completed}} / {{total}} items',
|
||||
errorTitle: 'Task failed',
|
||||
defaultRemedy: 'Check the related configuration or source, then retry.',
|
||||
noResultsTitle: 'No tasks match',
|
||||
noResultsDescription:
|
||||
'Choose another filter or clear the current object filter.',
|
||||
filters: {
|
||||
ariaLabel: 'Filter knowledge tasks',
|
||||
all: 'All',
|
||||
active: 'Active',
|
||||
failed: 'Failed',
|
||||
history: 'History'
|
||||
},
|
||||
context: {
|
||||
active: 'Showing tasks related to the current source or document',
|
||||
clear: 'Clear object filter'
|
||||
},
|
||||
actionErrors: {
|
||||
cancelTitle: 'Could not cancel task',
|
||||
retryTitle: 'Could not retry task',
|
||||
recovery:
|
||||
'The task and filters were preserved. Resolve the issue and try again.'
|
||||
},
|
||||
actions: {
|
||||
expand: 'Expand stage tasks for {{name}}',
|
||||
collapse: 'Collapse stage tasks for {{name}}',
|
||||
cancel: 'Cancel task',
|
||||
cancelling: 'Cancelling…',
|
||||
retry: 'Retry task',
|
||||
retrying: 'Retrying…'
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
documents: 'Documents and sources',
|
||||
graph: 'Knowledge graph',
|
||||
tasks: 'Task center',
|
||||
settings: 'Settings'
|
||||
settings: 'Index and retrieval'
|
||||
},
|
||||
workspace: {
|
||||
ariaLabel: 'Knowledge workspace',
|
||||
|
||||
@@ -23,8 +23,9 @@ export const settings = {
|
||||
},
|
||||
model: {
|
||||
label: 'Model connections',
|
||||
navigationDescription: 'LLMs, embedding models, and credentials',
|
||||
description: 'LLMs, embedding models, and credentials'
|
||||
navigationDescription:
|
||||
'LLMs, embedding and rerank models, and credentials',
|
||||
description: 'LLMs, embedding and rerank models, and credentials'
|
||||
},
|
||||
documentParsing: {
|
||||
label: 'Document parsing',
|
||||
@@ -103,7 +104,7 @@ export const settings = {
|
||||
errors: {
|
||||
readSettings: 'Could not load settings',
|
||||
detectRuntimes: 'Could not detect Agent Runtimes',
|
||||
readEmbeddingStatus: 'Could not load vector index status',
|
||||
readEmbeddingStatus: 'Could not load embedding model status',
|
||||
requireModelConnection: 'Configure at least one model connection',
|
||||
refreshEmbeddingAfterSave:
|
||||
'Settings were saved, but the vector model status could not be refreshed',
|
||||
@@ -115,10 +116,6 @@ export const settings = {
|
||||
embeddingDiagnosticUnavailable:
|
||||
'Vector diagnostics are unavailable',
|
||||
testEmbedding: 'Vector model test failed',
|
||||
embeddingIndexUnavailable: 'The vector index service is unavailable',
|
||||
rebuildEmbeddingIndex: 'Could not start rebuilding the vector index',
|
||||
embeddingJobFinished: 'The current vector indexing job has ended',
|
||||
cancelEmbeddingIndex: 'Could not cancel rebuilding the vector index',
|
||||
selectFile: 'Could not select the file',
|
||||
openRuntimeConfig: 'Could not open the Runtime configuration',
|
||||
selectWorkspace: 'Could not select the workspace folder',
|
||||
@@ -395,6 +392,11 @@ export const settings = {
|
||||
description:
|
||||
'Configure the embedding model used for knowledge base semantic retrieval and GraphRAG.'
|
||||
},
|
||||
rerank: {
|
||||
label: 'Rerank model',
|
||||
description:
|
||||
'Configure learned relevance reranking for knowledge retrieval candidates.'
|
||||
},
|
||||
speech: {
|
||||
label: 'Speech model',
|
||||
description:
|
||||
@@ -461,6 +463,21 @@ export const settings = {
|
||||
'Leave blank for a local service without authentication',
|
||||
privacyDescription:
|
||||
'Only chunks from enabled knowledge bases are sent to this endpoint. The API Key is encrypted in secure system storage. If the embedding service fails, retrieval falls back to FTS5 and the evidence graph.'
|
||||
},
|
||||
rerank: {
|
||||
title: 'Rerank model connection',
|
||||
description:
|
||||
'Uses a Cohere-compatible Rerank API to improve knowledge retrieval ordering',
|
||||
enabled: 'Enable learned reranking',
|
||||
endpoint: 'Rerank API URL',
|
||||
endpointDescription:
|
||||
'Enter the complete Cohere-compatible Rerank endpoint.',
|
||||
modelName: 'Model name',
|
||||
optionalApiKey: 'API Key (optional)',
|
||||
optionalApiKeyPlaceholder:
|
||||
'Leave blank for a local service without authentication',
|
||||
privacyDescription:
|
||||
'Only retrieval queries and candidate knowledge chunks are sent to this endpoint. The API Key is encrypted in secure system storage. If reranking fails, the original retrieval order is preserved.'
|
||||
}
|
||||
},
|
||||
security: {
|
||||
@@ -494,7 +511,7 @@ export const settings = {
|
||||
'Automatically choose the expert role that best matches the question',
|
||||
enabled: 'Enable Smart Subagent routing',
|
||||
help:
|
||||
'Off by default. In Ask or Plan mode, when no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model in read-only mode without tools.'
|
||||
'Off by default. In Ask mode, when no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model in read-only mode without tools.'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -127,9 +127,8 @@ export const settingsSections = {
|
||||
},
|
||||
embedding: {
|
||||
label: 'Embedding model',
|
||||
title: 'Embeddings and knowledge retrieval',
|
||||
description:
|
||||
'Verify the model and manage the embedding index used for knowledge retrieval',
|
||||
title: 'Embedding model connection',
|
||||
description: 'Review the current configuration and verify the connection',
|
||||
model: {
|
||||
heading: 'Current embedding model',
|
||||
configured: 'Configured model',
|
||||
@@ -149,40 +148,6 @@ export const settingsSections = {
|
||||
test: 'Test embedding model',
|
||||
notice:
|
||||
'The test sends one real request to the current service and does not change the knowledge index.'
|
||||
},
|
||||
index: {
|
||||
heading: 'Knowledge embedding index',
|
||||
rebuildRunning: 'Rebuild in progress…',
|
||||
rebuild: 'Rebuild embedding index',
|
||||
emptyTitle: 'No rebuild history yet',
|
||||
emptyDescription:
|
||||
'Select “Rebuild embedding index” to generate retrievable embeddings for knowledge documents.',
|
||||
statuses: {
|
||||
queued: 'Rebuild waiting to start',
|
||||
running: 'Rebuilding',
|
||||
completed: 'Last rebuild succeeded',
|
||||
failed: 'Last rebuild failed',
|
||||
cancelled: 'Last rebuild was cancelled'
|
||||
},
|
||||
cancelAria: 'Cancel embedding index rebuild',
|
||||
cancel: 'Cancel rebuild',
|
||||
progressAria: 'Embedding index rebuild progress',
|
||||
completed: '{{completed}} / {{total}} documents completed',
|
||||
completedWithPeriod: '{{completed}} / {{total}} documents completed.',
|
||||
completedAt:
|
||||
'{{completed}} / {{total}} documents completed at {{date}}.',
|
||||
preparing: 'Preparing documents…',
|
||||
atomicNotice:
|
||||
'Each document is updated atomically and becomes available for retrieval immediately. If cancelled, completed documents are kept while all others retain their previous or missing state.',
|
||||
cancelledNotice:
|
||||
'Completed documents keep their new embeddings. All other documents retain their previous embeddings, and documents without embeddings remain missing.',
|
||||
failedNotice:
|
||||
'{{completed}} / {{total}} documents completed. Documents with errors were marked as failed, while completed documents remain available for retrieval.',
|
||||
remedyPrefix: 'Suggested action: ',
|
||||
defaultRemedy:
|
||||
'Check the embedding model configuration and network connection.',
|
||||
retrySuffix:
|
||||
' After fixing the issue, select “Rebuild embedding index” to retry.'
|
||||
}
|
||||
},
|
||||
roles: {
|
||||
|
||||
@@ -176,8 +176,41 @@ export const app = {
|
||||
view: '查看 {{count}} 条证据引用',
|
||||
retrieval: '检索:',
|
||||
fullText: '全文',
|
||||
cjk: '中文词组',
|
||||
vector: '向量',
|
||||
graph: '图谱'
|
||||
graph: '图谱',
|
||||
viewContext: '查看上下文',
|
||||
openSource: '打开来源',
|
||||
openFailed: '无法打开引用来源',
|
||||
contextTitle: '引用上下文',
|
||||
contextDescription: '查看本次命中的分块及其相邻内容。',
|
||||
contextLoading: '正在读取引用上下文…',
|
||||
contextUnavailable: '引用上下文不可用',
|
||||
contextTruncated: '上下文超过安全展示上限,已截断。',
|
||||
closeContext: '关闭引用上下文',
|
||||
matchedChunk: '命中分块',
|
||||
surroundingContext: '完整上下文',
|
||||
score: '相关度 {{score}}'
|
||||
},
|
||||
knowledgeRetrieval: {
|
||||
searching: '正在检索已启用的知识库',
|
||||
states: {
|
||||
searching: '正在检索知识库',
|
||||
succeeded: '知识检索完成',
|
||||
zero: '未找到相关知识',
|
||||
degraded: '知识检索已降级',
|
||||
failed: '知识检索失败',
|
||||
cancelled: '知识检索已取消'
|
||||
},
|
||||
summary:
|
||||
'已检索 {{libraries}} 个知识库,获得 {{results}} 条结果,用时 {{duration}} 毫秒',
|
||||
channels: '使用通道:{{channels}}',
|
||||
channelNames: {
|
||||
fts: '全文',
|
||||
cjk: '中文词组',
|
||||
vector: '向量',
|
||||
graph: '图谱'
|
||||
}
|
||||
},
|
||||
retry: '重新编辑并发送',
|
||||
status: {
|
||||
@@ -324,7 +357,12 @@ export const app = {
|
||||
select: '选择知识库,本次已启用 {{count}} 个',
|
||||
title: '选择本次对话检索的知识库',
|
||||
scope: '本次对话检索范围',
|
||||
documents: '{{count}} 个文档'
|
||||
documents: '{{count}} 个文档',
|
||||
modeLabel: '知识检索方式',
|
||||
auto: '模型决定',
|
||||
always: '每次先检索',
|
||||
autoDescription: '由模型判断当前问题是否需要查询知识库。',
|
||||
alwaysDescription: '在模型回答前,由 GoodBuddy 先查询一次已启用知识库。'
|
||||
},
|
||||
hints: {
|
||||
configureRuntime: '请先配置可用的模型或 Agent Runtime。',
|
||||
@@ -393,6 +431,11 @@ export const app = {
|
||||
selectKnowledgeBase: '请先选择知识库',
|
||||
knowledgeGraphRebuilt: '知识图谱已重新抽取',
|
||||
knowledgeSettingsUpdated: '知识库设置已更新',
|
||||
knowledgeRebuildCompleted: '已重建 {{count}} 个文档',
|
||||
knowledgeRebuildPartial:
|
||||
'知识库重建未全部完成:成功 {{rebuilt}} 个,失败 {{failed}} 个',
|
||||
knowledgeRebuildNotRunning: '当前没有可取消的知识库重建任务',
|
||||
knowledgeTaskNotRunning: '该任务已结束或当前无法取消',
|
||||
evidenceExcerpt: '{{source}}:{{excerpt}}'
|
||||
},
|
||||
markdown: {
|
||||
|
||||
@@ -228,7 +228,7 @@ export const integrations = {
|
||||
title: '联网搜索',
|
||||
subtitle: '直连模型工具 · Exa MCP · Ask / Execute',
|
||||
description:
|
||||
'提供 web_search 和 web_fetch,只允许搜索及读取公开网页;Plan 模式不会加载。',
|
||||
'提供 web_search 和 web_fetch,只允许搜索及读取公开网页;Ask 和 Execute 均可使用。',
|
||||
privacy:
|
||||
'查询词和公开网页地址会发送给第三方 Exa 服务,不会发送模型 API Key、本地文件或知识库内容。',
|
||||
enableAriaLabel: '启用直连模型联网搜索',
|
||||
|
||||
@@ -2,8 +2,7 @@ export const knowledge = {
|
||||
page: {
|
||||
eyebrow: '知识',
|
||||
title: '知识库',
|
||||
description:
|
||||
'集中组织文件、目录和网页来源,建立可追溯、可跨项目使用的索引与图谱。'
|
||||
description: '管理文件、目录和网页来源,并查看索引与图谱。'
|
||||
},
|
||||
actions: {
|
||||
cancel: '取消',
|
||||
@@ -31,6 +30,7 @@ export const knowledge = {
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
add: '新增',
|
||||
viewTasks: '查看任务',
|
||||
backToLibraryList: '返回知识库列表',
|
||||
goToSettings: '前往设置'
|
||||
},
|
||||
@@ -78,16 +78,40 @@ export const knowledge = {
|
||||
failed: '处理失败'
|
||||
},
|
||||
taskKinds: {
|
||||
sourceSync: '来源同步',
|
||||
documentProcess: '文档处理',
|
||||
documentRebuild: '文档重建',
|
||||
libraryRebuild: '知识库重建',
|
||||
embeddingRebuild: '向量索引重建',
|
||||
graphRebuild: '知识图谱重建',
|
||||
parsing: '文档解析',
|
||||
embedding: '向量化',
|
||||
graph: '图谱抽取'
|
||||
},
|
||||
taskStages: {
|
||||
queued: '等待调度',
|
||||
syncing: '同步来源',
|
||||
reading: '读取内容',
|
||||
parsing: '解析文档',
|
||||
chunking: '生成分块',
|
||||
indexing: '建立索引',
|
||||
embedding: '生成向量',
|
||||
graph: '抽取图谱',
|
||||
finalizing: '完成收尾'
|
||||
},
|
||||
taskStatuses: {
|
||||
queued: '等待中',
|
||||
running: '进行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
skipped: '已跳过'
|
||||
cancelled: '已取消',
|
||||
skipped: '已跳过',
|
||||
interrupted: '已中断'
|
||||
},
|
||||
taskScopes: {
|
||||
library: '知识库范围',
|
||||
source: '来源范围',
|
||||
document: '文档范围'
|
||||
},
|
||||
format: {
|
||||
neverSynced: '尚未同步',
|
||||
@@ -128,6 +152,236 @@ export const knowledge = {
|
||||
'此知识库引用原文件。删除后只会移除索引和图谱,不会删除磁盘上的原文件。',
|
||||
triggerAriaLabel: '删除知识库 {{name}}'
|
||||
},
|
||||
retrieval: {
|
||||
eyebrow: '当前知识库:{{libraryName}}',
|
||||
title: '检索测试',
|
||||
description:
|
||||
'使用临时参数直接验证当前知识库的召回结果。本测试不会创建对话、调用大模型或修改知识内容。',
|
||||
close: '关闭检索测试',
|
||||
query: {
|
||||
title: '测试问题',
|
||||
help: '输入一个真实问题,查看各检索通道、排名和最终上下文。',
|
||||
label: '检索问题',
|
||||
placeholder: '例如:如何为离线环境配置文档解析?',
|
||||
count: '{{count}} / 4000 字'
|
||||
},
|
||||
pipeline: {
|
||||
recall: {
|
||||
title: '召回候选',
|
||||
summary: '最多 {{count}} 个融合候选',
|
||||
pending: '设置有效参数后计算'
|
||||
},
|
||||
rerank: {
|
||||
title: '本地重排',
|
||||
enabled: '重排最多 {{count}} 个候选',
|
||||
disabled: '已关闭,保留融合排名'
|
||||
},
|
||||
select: {
|
||||
title: '最终结果',
|
||||
summary: '保留前 {{count}} 个分块'
|
||||
},
|
||||
context: {
|
||||
title: '组装上下文',
|
||||
summary: '预算 {{count}} 字符'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: '本次测试参数',
|
||||
temporary: '这些调整只影响本次测试。需要长期使用时,请保存为当前知识库默认值。',
|
||||
groups: {
|
||||
recall: {
|
||||
title: '候选召回',
|
||||
description: '控制首轮检索范围、过滤阈值和各检索通道对融合排名的影响。'
|
||||
},
|
||||
output: {
|
||||
title: '重排与上下文',
|
||||
description: '控制候选排序、最终保留数量,以及实际送入模型的上下文范围。'
|
||||
}
|
||||
},
|
||||
candidateMultiplier: '召回倍数',
|
||||
candidateMultiplierHelp: '范围 2 至 10;当前最多召回 {{count}} 个融合候选',
|
||||
channelWeights: '通道融合占比(合计 100%)',
|
||||
topK: '最终结果数',
|
||||
topKHelp: '重排后保留 1 至 20 个结果',
|
||||
vectorSimilarity: '最低向量相似度(%)',
|
||||
vectorSimilarityHelp: '范围 0% 至 100%;0% 表示不过滤低相似度结果',
|
||||
ftsWeight: '全文占比',
|
||||
vectorWeight: '向量占比',
|
||||
graphWeight: '图谱占比',
|
||||
weightHelp: '按相对占比参与融合;可用通道建议合计 100%',
|
||||
graphUnavailable: '当前知识库未启用图谱,此权重暂不生效。',
|
||||
contextBudget: '上下文字符预算',
|
||||
contextBudgetHelp: '范围 2,000 至 48,000',
|
||||
adjacentCount: '相邻分块数',
|
||||
adjacentCountHelp: '向前和向后各合并 0 至 2 个分块',
|
||||
localRerank: '启用本地重排',
|
||||
localRerankHelp:
|
||||
'对本次召回的全部融合候选进行确定性重排,不调用额外 AI 模型,因此无需单独设置重排数量。',
|
||||
rerankMode: '重排方式',
|
||||
rerankModeHelp:
|
||||
'学习型重排调用已配置的 Cohere/Jina 兼容模型,并显示安全的降级原因。',
|
||||
rerankModes: {
|
||||
none: '不重排',
|
||||
local: '本地规则',
|
||||
learned: '学习型模型'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
queryRequired: '请输入测试问题。',
|
||||
queryTooLong: '测试问题不能超过 4,000 字符。',
|
||||
topK: 'Top K 必须是 1 至 20 的整数。',
|
||||
candidateMultiplier: '召回倍数必须是 2 至 10 的整数。',
|
||||
vectorSimilarity: '最低向量相似度必须在 0% 至 100% 之间。',
|
||||
weight: '通道融合占比必须在 0% 至 100% 之间。',
|
||||
weightTotal: '当前可用检索通道的融合占比合计必须为 100%。',
|
||||
activeWeight: '至少一个当前可用的检索通道权重必须大于 0。',
|
||||
contextBudget: '上下文预算必须是 2,000 至 48,000 的整数。',
|
||||
adjacentCount: '相邻分块数必须是 0 至 2 的整数。'
|
||||
},
|
||||
actions: {
|
||||
test: '测试检索',
|
||||
running: '正在检索…',
|
||||
saveDefaults: '保存为默认值',
|
||||
savingDefaults: '正在保存…',
|
||||
viewContext: '查看分块',
|
||||
openSource: '打开来源'
|
||||
},
|
||||
states: {
|
||||
runningTitle: '正在检索当前知识库',
|
||||
runningDescription: '正在扫描有界候选并拼装上下文,请稍候。',
|
||||
errorTitle: '检索测试失败',
|
||||
errorDescription: '请检查知识库索引状态或调整参数后重试。'
|
||||
},
|
||||
channels: {
|
||||
fts: '全文',
|
||||
cjk: '中文',
|
||||
vector: '向量',
|
||||
graph: '图谱'
|
||||
},
|
||||
diagnostics: {
|
||||
duration: '总耗时',
|
||||
milliseconds: '{{count}} 毫秒',
|
||||
requested: '请求通道',
|
||||
used: '实际通道',
|
||||
none: '无',
|
||||
vectorScanned: '已扫描向量',
|
||||
channelSummary: '{{candidates}} 个候选 · {{duration}} 毫秒',
|
||||
degradedTitle: '本次检索已降级',
|
||||
rerank: '重排',
|
||||
rerankSummary:
|
||||
'请求 {{requested}},使用 {{used}} · {{status}} · {{count}} 个候选 · {{duration}} 毫秒',
|
||||
rerankStatuses: {
|
||||
skipped: '已跳过',
|
||||
applied: '已应用',
|
||||
fallback: '已降级',
|
||||
failed: '失败'
|
||||
}
|
||||
},
|
||||
zero: {
|
||||
'empty-library': {
|
||||
title: '当前知识库没有可检索内容',
|
||||
description: '请先导入并完成文档索引,然后再次测试。'
|
||||
},
|
||||
'index-unavailable': {
|
||||
title: '当前索引不可用',
|
||||
description: '请检查文档的解析与索引状态,修复失败项后重试。'
|
||||
},
|
||||
'no-match': {
|
||||
title: '未找到相关内容',
|
||||
description: '请尝试更换关键词、使用资料中的具体名称或扩大 Top K。'
|
||||
},
|
||||
filtered: {
|
||||
title: '结果已被阈值过滤',
|
||||
description: '请降低最低向量相似度或检查各通道权重后重试。'
|
||||
}
|
||||
},
|
||||
results: {
|
||||
title: '检索结果({{count}})',
|
||||
contextSummary: '最终上下文 {{count}} / {{budget}} 字符',
|
||||
truncated: '已截断',
|
||||
listAriaLabel: '检索结果列表',
|
||||
resultAriaLabel: '第 {{rank}} 条结果,{{documentName}}',
|
||||
unknownLocator: '定位未知',
|
||||
relevance: '相关度',
|
||||
fusedScore: '融合分数',
|
||||
channelDetail: '排名 {{rank}} · 原始分数 {{score}} · 相似度 {{similarity}}',
|
||||
beforeRerank: '重排前排名',
|
||||
context: '上下文',
|
||||
contextDetail: '{{count}} 字符 · {{truncated}}',
|
||||
complete: '完整',
|
||||
diagnostics: '结果诊断',
|
||||
actualContext: '查看实际上下文'
|
||||
}
|
||||
},
|
||||
chunks: {
|
||||
title: '文档分块',
|
||||
documentUnavailable: '该检索结果对应的文档当前不可用。',
|
||||
description: '搜索、预览和维护该文档的有界分块列表。',
|
||||
close: '关闭文档分块',
|
||||
listAriaLabel: '文档分块列表',
|
||||
syncWarningTitle: '人工修改可能被替换。',
|
||||
syncWarning:
|
||||
'来源再次同步或重建文档时,可能按原始内容重新创建分块。删除分块不会删除原始文件。',
|
||||
search: {
|
||||
label: '搜索文档内分块',
|
||||
placeholder: '搜索标题、定位或内容',
|
||||
action: '搜索'
|
||||
},
|
||||
loadErrorTitle: '分块操作未完成',
|
||||
loadingTitle: '正在加载分块',
|
||||
loadingDescription: '正在读取当前页的分块内容。',
|
||||
zeroTitle: '没有符合条件的分块',
|
||||
zeroDescription: '请修改搜索词,或重建文档以重新生成分块。',
|
||||
ordinal: '分块 {{count}}',
|
||||
headingSeparator: ' · {{heading}}',
|
||||
parentMetadata: ' · 父块 {{parentId}}',
|
||||
unknownLocator: '定位未知',
|
||||
characterCount: '{{count}} 字符',
|
||||
enabled: '参与检索',
|
||||
enabledAriaLabel: '启用分块 {{count}}',
|
||||
roles: {
|
||||
standalone: '独立块',
|
||||
parent: '父块',
|
||||
child: '子块'
|
||||
},
|
||||
pagination: {
|
||||
ariaLabel: '分块分页',
|
||||
previous: '上一页分块',
|
||||
next: '下一页分块',
|
||||
summary: '第 {{page}} / {{total}} 页,共 {{count}} 个'
|
||||
},
|
||||
editor: {
|
||||
title: '编辑分块 {{count}}',
|
||||
metadata: '{{role}} · {{locator}}',
|
||||
manuallyEdited: '人工修改',
|
||||
role: '分块角色',
|
||||
parent: '父块 ID',
|
||||
content: '分块内容',
|
||||
count: '{{count}} / {{max}} 字符',
|
||||
save: '保存分块',
|
||||
saving: '正在保存…',
|
||||
noSelectionTitle: '选择一个分块',
|
||||
noSelectionDescription: '从左侧列表选择分块以查看完整内容和父子关系。'
|
||||
},
|
||||
validation: {
|
||||
contentRequired: '分块内容不能为空。',
|
||||
contentTooLong: '分块内容不能超过 {{count}} 字符。'
|
||||
},
|
||||
delete: {
|
||||
trigger: '删除分块',
|
||||
triggerAriaLabel: '删除分块 {{count}}',
|
||||
confirmAriaLabel: '确认删除分块 {{count}}',
|
||||
confirm: '确认删除分块',
|
||||
deleting: '正在删除…',
|
||||
message:
|
||||
'删除分块 {{count}} 会移除其全文、中文、向量和图谱证据。来源同步或重建可能重新创建此分块;原始文件不会被删除。'
|
||||
},
|
||||
rebuild: {
|
||||
action: '重建文档',
|
||||
running: '正在重建…',
|
||||
description: '重建会重新读取来源;失败时应保留上一版可用索引。'
|
||||
}
|
||||
},
|
||||
graph: {
|
||||
title: '知识图谱',
|
||||
enable: '启用知识图谱',
|
||||
@@ -147,11 +401,13 @@ export const knowledge = {
|
||||
entityPickerAriaLabel: '选择图谱实体',
|
||||
zoomOutAriaLabel: '缩小图谱',
|
||||
zoomInAriaLabel: '放大图谱',
|
||||
fitView: '显示全部',
|
||||
interactionHint: '拖动节点调整位置;拖动画布平移,滚轮缩放。',
|
||||
empty: '当前知识库尚未生成实体关系。',
|
||||
topologyAriaLabel: '图谱拓扑',
|
||||
visibleRelations: {
|
||||
title: '可见关系',
|
||||
description: '随当前搜索和类型筛选更新。',
|
||||
description: '仅显示当前筛选结果中的关系。',
|
||||
count: '{{count}} 条',
|
||||
empty: '当前筛选下没有可见关系。',
|
||||
listAriaLabel: '可见关系列表'
|
||||
@@ -176,14 +432,18 @@ export const knowledge = {
|
||||
},
|
||||
detailsAriaLabel: '图谱详情',
|
||||
detailsPrompt: '点击图谱节点查看实体详情。',
|
||||
disabledTitle: '知识图谱未启用',
|
||||
disabledDescription:
|
||||
'在“设置”中启用知识图谱后,可以查看实体关系并重新抽取。'
|
||||
workspace: {
|
||||
tabsAriaLabel: '知识图谱工作区',
|
||||
explore: '图谱探索',
|
||||
settings: '图谱设置'
|
||||
}
|
||||
},
|
||||
documents: {
|
||||
sources: {
|
||||
title: '内容来源',
|
||||
description: '导入内容后会自动解析、建立索引并更新图谱。',
|
||||
description: '导入内容后会自动解析并建立检索索引。',
|
||||
descriptionWithGraph:
|
||||
'导入内容后会自动解析、建立检索索引并按当前策略更新知识图谱。',
|
||||
emptyTitle: '尚未连接内容来源',
|
||||
emptyDescription:
|
||||
'可选择文件、目录或 URL;也可以直接将文件拖入上方区域。',
|
||||
@@ -217,8 +477,10 @@ export const knowledge = {
|
||||
document: '文档',
|
||||
status: '状态',
|
||||
indexProgress: '索引进度',
|
||||
processingStatus: '处理状态',
|
||||
chunks: '分块',
|
||||
size: '大小'
|
||||
size: '大小',
|
||||
actions: '操作'
|
||||
}
|
||||
},
|
||||
search: {
|
||||
@@ -233,14 +495,111 @@ export const knowledge = {
|
||||
},
|
||||
relationEditor: {
|
||||
editAriaLabel: '编辑关系',
|
||||
addAriaLabel: '新增关系'
|
||||
addAriaLabel: '新增关系',
|
||||
selectType: '选择关系类型',
|
||||
noCompatibleTypes: '当前起点和终点类型之间没有可用的关系类型。'
|
||||
},
|
||||
settings: {
|
||||
description: '控制是否从知识库文档中抽取实体、关系和证据。',
|
||||
enableDescription:
|
||||
'启用后,新导入和重新同步的文档会按所选策略抽取图谱。',
|
||||
strategyAriaLabel: '知识图谱抽取策略',
|
||||
askDescription: '“按需询问”不会自动生成图谱,也不能执行重新抽取。'
|
||||
askDescription: '“按需询问”不会自动生成图谱,也不能执行重新抽取。',
|
||||
graphCapability: {
|
||||
title: '可选能力',
|
||||
description: '按需开启额外能力;未启用的能力不会出现在工作区中。',
|
||||
enabledDescription: '知识图谱已启用,可在“知识图谱”中探索关系和管理图谱设置。',
|
||||
disabledDescription: '启用后才会显示图谱探索、抽取策略和本体定义。'
|
||||
},
|
||||
graphConfiguration: {
|
||||
title: '抽取方式',
|
||||
description: '控制新导入、重新同步和显式重建时如何生成实体、关系与证据。'
|
||||
},
|
||||
chunking: {
|
||||
title: '分块策略',
|
||||
description:
|
||||
'配置后续导入和重建使用的分块方式。保存设置不会立即改写现有文档。',
|
||||
mode: '分块方式',
|
||||
modes: {
|
||||
fixed: '固定长度',
|
||||
structure: '按文档结构',
|
||||
parentChild: '父子分块'
|
||||
},
|
||||
targetCharacters: '目标字符数',
|
||||
overlapCharacters: '重叠字符数',
|
||||
contextualIndexing: '启用上下文索引',
|
||||
contextualIndexingDescription:
|
||||
'将文档标题、标题层级、页码和块类型加入检索与向量文本;引用仍只显示原文。',
|
||||
parentCharacters: '父块字符数',
|
||||
childCharacters: '子块字符数',
|
||||
rebuildRequired: '设置已变化,需要重建现有文档后才能全部生效。',
|
||||
save: '保存分块设置',
|
||||
saving: '正在保存…',
|
||||
rebuild: '重建整个知识库',
|
||||
rebuilding: '正在重建…',
|
||||
cancelRebuild: '取消重建'
|
||||
},
|
||||
ontology: {
|
||||
title: '本体定义',
|
||||
description:
|
||||
'为当前知识库控制实体类型、关系类型及关系端点约束。标识符使用大写字母、数字和下划线。',
|
||||
entityTypes: '实体类型',
|
||||
relationTypes: '关系类型',
|
||||
id: '规范标识符',
|
||||
nameZh: '中文名称',
|
||||
nameEn: '英文名称',
|
||||
aliases: '别名(使用逗号分隔,最多 32 个)',
|
||||
sourceTypes: '允许的起点类型',
|
||||
targetTypes: '允许的终点类型',
|
||||
anyEndpoint: '允许任意实体类型',
|
||||
anyEndpointHelp: '未设置端点限制。',
|
||||
save: '保存本体定义',
|
||||
saving: '正在保存…',
|
||||
validation: '请修正重复标识符、重复别名、空名称或无效端点约束。',
|
||||
rebuildRequired:
|
||||
'本体定义已变化,需要显式重建现有文档后才能重新规范化已有图谱。',
|
||||
noImplicitRebuild: '保存只更新当前知识库设置,不会自动重建文档或图谱。'
|
||||
},
|
||||
vectorIndex: {
|
||||
title: '向量索引',
|
||||
description:
|
||||
'查看当前知识库在现用向量模型下的覆盖情况,并只重建这个知识库。',
|
||||
rebuild: '重建向量索引',
|
||||
rebuilding: '正在重建…',
|
||||
cancel: '取消重建',
|
||||
cancelAria: '取消当前知识库的向量索引重建',
|
||||
loading: '正在读取向量索引状态…',
|
||||
disabledTitle: '向量模型未启用',
|
||||
disabledDescription:
|
||||
'请先在“模型连接”中启用并保存向量模型,再返回此知识库重建索引。',
|
||||
currentModel: '当前向量模型',
|
||||
coverage: {
|
||||
indexed: '已索引',
|
||||
missing: '缺失',
|
||||
error: '错误',
|
||||
total: '文档总数'
|
||||
},
|
||||
statuses: {
|
||||
queued: '等待重建',
|
||||
running: '正在重建',
|
||||
completed: '最近一次重建成功',
|
||||
failed: '最近一次重建失败',
|
||||
cancelled: '最近一次重建已取消'
|
||||
},
|
||||
progressAria: '当前知识库向量索引重建进度',
|
||||
progress: '已完成 {{completed}} / {{total}} 篇文档',
|
||||
preparing: '正在准备待处理文档…',
|
||||
completedAt:
|
||||
'已完成 {{completed}} / {{total}} 篇文档。{{date}}',
|
||||
atomicNotice:
|
||||
'每篇文档会一次性更新;取消后,已完成文档保留新向量,其余文档保持原状。',
|
||||
cancelledNotice:
|
||||
'已完成 {{completed}} / {{total}} 篇文档,其余文档保持原状。',
|
||||
defaultRemedy: '请检查向量模型配置和网络连接后重试。',
|
||||
activeTitle: '向量索引任务正在运行',
|
||||
activeDescription: '任务中心查看详情、进度以及可用操作。',
|
||||
viewTasks: '任务中心查看详情'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
emptyDescription:
|
||||
@@ -248,16 +607,48 @@ export const knowledge = {
|
||||
emptyTitle: '还没有知识任务',
|
||||
title: '任务中心',
|
||||
recentCount: '最近 {{count}} 个任务',
|
||||
totalCount: '共 {{count}} 个任务',
|
||||
activeCount: '进行中 {{count}}',
|
||||
failedCount: '失败 {{count}}',
|
||||
historyCount: '历史 {{count}}',
|
||||
progressAriaLabel: '{{name}} {{kind}}进度',
|
||||
waiting: '等待处理'
|
||||
waiting: '等待处理',
|
||||
currentStage: '当前阶段',
|
||||
itemProgress: '{{completed}} / {{total}} 项',
|
||||
errorTitle: '任务失败',
|
||||
defaultRemedy: '请检查相关配置或来源后重试。',
|
||||
noResultsTitle: '没有符合条件的任务',
|
||||
noResultsDescription: '请选择其他筛选条件或清除当前对象筛选。',
|
||||
filters: {
|
||||
ariaLabel: '筛选知识任务',
|
||||
all: '全部',
|
||||
active: '进行中',
|
||||
failed: '失败',
|
||||
history: '历史'
|
||||
},
|
||||
context: {
|
||||
active: '正在显示当前来源或文档的相关任务',
|
||||
clear: '清除对象筛选'
|
||||
},
|
||||
actionErrors: {
|
||||
cancelTitle: '取消任务失败',
|
||||
retryTitle: '重试任务失败',
|
||||
recovery: '任务和筛选已保留,请检查问题后再次操作。'
|
||||
},
|
||||
actions: {
|
||||
expand: '展开 {{name}} 的阶段任务',
|
||||
collapse: '收起 {{name}} 的阶段任务',
|
||||
cancel: '取消任务',
|
||||
cancelling: '正在取消…',
|
||||
retry: '重试任务',
|
||||
retrying: '正在重试…'
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
documents: '文档与来源',
|
||||
graph: '知识图谱',
|
||||
tasks: '任务中心',
|
||||
settings: '设置'
|
||||
settings: '索引与检索'
|
||||
},
|
||||
workspace: {
|
||||
ariaLabel: '知识工作区',
|
||||
|
||||
@@ -19,8 +19,8 @@ export const settings = {
|
||||
},
|
||||
model: {
|
||||
label: '模型连接',
|
||||
navigationDescription: 'LLM、向量模型与凭据',
|
||||
description: 'LLM、向量模型与凭据'
|
||||
navigationDescription: 'LLM、向量、重排模型与凭据',
|
||||
description: 'LLM、向量、重排模型与凭据'
|
||||
},
|
||||
documentParsing: {
|
||||
label: '文档解析',
|
||||
@@ -95,7 +95,7 @@ export const settings = {
|
||||
errors: {
|
||||
readSettings: '读取设置失败',
|
||||
detectRuntimes: 'Runtime 自动检测失败',
|
||||
readEmbeddingStatus: '读取向量索引状态失败',
|
||||
readEmbeddingStatus: '读取向量模型状态失败',
|
||||
requireModelConnection: '请至少配置一个模型连接',
|
||||
refreshEmbeddingAfterSave: '设置已保存,但刷新向量模型状态失败',
|
||||
speechModelsUnavailable: '当前版本未提供语音模型服务',
|
||||
@@ -104,10 +104,6 @@ export const settings = {
|
||||
testRuntime: 'Runtime 连接测试失败',
|
||||
embeddingDiagnosticUnavailable: '向量诊断服务不可用',
|
||||
testEmbedding: '向量模型测试失败',
|
||||
embeddingIndexUnavailable: '向量索引服务不可用',
|
||||
rebuildEmbeddingIndex: '启动向量索引重建失败',
|
||||
embeddingJobFinished: '当前向量索引任务已结束',
|
||||
cancelEmbeddingIndex: '取消向量索引重建失败',
|
||||
selectFile: '选择文件失败',
|
||||
openRuntimeConfig: '打开 Runtime 配置失败',
|
||||
selectWorkspace: '选择工作区目录失败',
|
||||
@@ -362,6 +358,10 @@ export const settings = {
|
||||
label: '向量模型',
|
||||
description: '配置知识库语义检索与 GraphRAG 使用的向量模型。'
|
||||
},
|
||||
rerank: {
|
||||
label: '重排模型',
|
||||
description: '配置知识检索候选结果的学习型相关性重排模型。'
|
||||
},
|
||||
speech: {
|
||||
label: '语音模型',
|
||||
description:
|
||||
@@ -423,6 +423,18 @@ export const settings = {
|
||||
optionalApiKeyPlaceholder: '本地无认证服务可留空',
|
||||
privacyDescription:
|
||||
'仅向所填接口发送已启用知识库的分块文本。API Key 由系统安全存储加密;向量服务失败时自动回退到 FTS5 与证据图谱。'
|
||||
},
|
||||
rerank: {
|
||||
title: '重排模型连接',
|
||||
description: '使用 Cohere 兼容 Rerank 接口提升知识检索排序质量',
|
||||
enabled: '启用学习型重排',
|
||||
endpoint: '重排接口 URL',
|
||||
endpointDescription: '填写完整的 Cohere 兼容 Rerank 端点。',
|
||||
modelName: '模型名称',
|
||||
optionalApiKey: 'API Key(可选)',
|
||||
optionalApiKeyPlaceholder: '本地无认证服务可留空',
|
||||
privacyDescription:
|
||||
'仅向所填接口发送检索查询和候选知识片段。API Key 由系统安全存储加密;重排服务失败时保留原始检索排序。'
|
||||
}
|
||||
},
|
||||
security: {
|
||||
@@ -455,7 +467,7 @@ export const settings = {
|
||||
description: '按问题内容自动选择最匹配的专家角色',
|
||||
enabled: '启用 Subagent 智能路由',
|
||||
help:
|
||||
'默认关闭。仅在 Ask 或 Plan 模式且未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型,只读运行且不使用工具。'
|
||||
'默认关闭。仅在 Ask 模式且未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型,只读运行且不使用工具。'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -119,8 +119,8 @@ export const settingsSections = {
|
||||
},
|
||||
embedding: {
|
||||
label: '向量模型',
|
||||
title: '向量与知识检索',
|
||||
description: '确认模型可用,并管理知识检索使用的向量索引',
|
||||
title: '向量模型连接',
|
||||
description: '查看当前配置并确认向量模型连接可用',
|
||||
model: {
|
||||
heading: '当前向量模型',
|
||||
configured: '已配置模型',
|
||||
@@ -138,38 +138,6 @@ export const settingsSections = {
|
||||
testing: '正在测试…',
|
||||
test: '测试向量模型',
|
||||
notice: '测试会向当前服务发送一次实际请求,不会更改知识索引。'
|
||||
},
|
||||
index: {
|
||||
heading: '知识向量索引',
|
||||
rebuildRunning: '重建进行中…',
|
||||
rebuild: '重建向量索引',
|
||||
emptyTitle: '还没有重建记录',
|
||||
emptyDescription:
|
||||
'点击“重建向量索引”,为知识文档生成可用于检索的向量。',
|
||||
statuses: {
|
||||
queued: '重建等待开始',
|
||||
running: '正在重建',
|
||||
completed: '最近一次重建成功',
|
||||
failed: '最近一次重建失败',
|
||||
cancelled: '最近一次重建已取消'
|
||||
},
|
||||
cancelAria: '取消向量索引重建',
|
||||
cancel: '取消重建',
|
||||
progressAria: '向量索引重建进度',
|
||||
completed: '已完成 {{completed}} / {{total}} 篇文档',
|
||||
completedWithPeriod: '已完成 {{completed}} / {{total}} 篇文档。',
|
||||
completedAt:
|
||||
'已完成 {{completed}} / {{total}} 篇文档,完成于 {{date}}。',
|
||||
preparing: '正在准备待处理文档…',
|
||||
atomicNotice:
|
||||
'每篇文档会一次性更新,处理完成后立即可用于检索。取消后,已完成文档会保留,其余文档的原有或缺失状态不变。',
|
||||
cancelledNotice:
|
||||
'已完成文档保留新向量;其余文档保留原有向量,原本没有向量的仍保持缺失。',
|
||||
failedNotice:
|
||||
'已完成 {{completed}} / {{total}} 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。',
|
||||
remedyPrefix: '处理建议:',
|
||||
defaultRemedy: '请检查向量模型配置和网络连接。',
|
||||
retrySuffix: '修复后点击“重建向量索引”重试。'
|
||||
}
|
||||
},
|
||||
roles: {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
|
||||
|
||||
export function formatMediumDateTime(
|
||||
timestamp: number,
|
||||
locale: string
|
||||
): string {
|
||||
let formatter = dateTimeFormatters.get(locale)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
dateTimeFormatters.set(locale, formatter)
|
||||
}
|
||||
return formatter.format(timestamp)
|
||||
}
|
||||
+1521
-72
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,14 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { beforeEach, vi } from 'vitest'
|
||||
import i18n from './i18n'
|
||||
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
if (typeof localStorage === 'undefined' || typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
localStorage.removeItem('goodbuddy.ui-locale')
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
document.documentElement.lang = 'zh-CN'
|
||||
|
||||
Reference in New Issue
Block a user