feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入
支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
1 parent
4ff934ad98
commit
7937de5d7d
23 files changed
+7545
No files matched your search
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
|
||||
|
||||
const mountModal = async (url: string) => {
|
||||
const wrapper = mount(KnowledgeAddSourceModal)
|
||||
await wrapper.find('#kb-add-url').setValue(url)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('.scope__title').map((node) => node.text())
|
||||
|
||||
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
|
||||
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
|
||||
|
||||
describe('KnowledgeAddSourceModal crawl scope', () => {
|
||||
it('offers no section option for a homepage URL', async () => {
|
||||
// "Pages under / " is not a section — it is the whole site, which the next
|
||||
// option already covers.
|
||||
const wrapper = await mountModal('https://paywithatoa.co.uk')
|
||||
expect(scopeTitles(wrapper)).toEqual(['仅此页面', '整个站点', '整个域名'])
|
||||
expect(scopeOption(wrapper, '整个站点')!.text()).toContain('paywithatoa.co.uk 上的所有页面')
|
||||
})
|
||||
|
||||
it('names the section when the URL has a path', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
expect(scopeTitles(wrapper)).toContain('本栏目')
|
||||
expect(scopeOption(wrapper, '本栏目')!.text()).toContain(
|
||||
'/hc/en/ 下的页面',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to this-site when the section option disappears', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
|
||||
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
|
||||
type: 'website',
|
||||
url: 'https://help.example.com',
|
||||
scope: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the selected scope', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/services/knowledge', () => ({
|
||||
knowledgeService: {
|
||||
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
|
||||
getKnowledgeByOrganization: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
|
||||
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] })
|
||||
}
|
||||
}))
|
||||
|
||||
import { useKnowledgeManagement } from '@/composables/useKnowledgeManagement'
|
||||
|
||||
const setup = () => useKnowledgeManagement('agent-1', 'org-1')
|
||||
|
||||
describe('useKnowledgeManagement - URL handling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('withScheme', () => {
|
||||
const { withScheme } = setup()
|
||||
|
||||
it.each([
|
||||
['docs.company.com', 'https://docs.company.com'],
|
||||
['company.co.uk', 'https://company.co.uk'],
|
||||
[' spaced.com ', 'https://spaced.com'],
|
||||
['//protocol-relative.com', 'https://protocol-relative.com']
|
||||
])('defaults the scheme: %s -> %s', (input, expected) => {
|
||||
expect(withScheme(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://already.com',
|
||||
'http://plain.com',
|
||||
'HTTPS://shouty.com'
|
||||
])('leaves an existing scheme alone: %s', (input) => {
|
||||
expect(withScheme(input)).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves an empty value empty', () => {
|
||||
expect(withScheme(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidUrl', () => {
|
||||
const { isValidUrl } = setup()
|
||||
|
||||
it.each(['https://docs.company.com', 'http://a.co', 'https://x.com/path?q=1'])(
|
||||
'accepts %s',
|
||||
(url) => expect(isValidUrl(url)).toBe(true)
|
||||
)
|
||||
|
||||
it.each(['mailto:someone@x.com', 'foo:bar', 'not a url', 'https://'])(
|
||||
'rejects %s',
|
||||
(url) => expect(isValidUrl(url)).toBe(false)
|
||||
)
|
||||
|
||||
it.each(['https://localhost', 'https://wiki', 'http://intranet:8080'])(
|
||||
'accepts the intranet hostnames a self-hosted install indexes: %s',
|
||||
(url) => expect(isValidUrl(url)).toBe(true)
|
||||
)
|
||||
})
|
||||
|
||||
describe('handleUrlAdd', () => {
|
||||
it('stages a scheme-less URL instead of rejecting it', () => {
|
||||
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
|
||||
newUrl.value = 'docs.company.com'
|
||||
|
||||
handleUrlAdd()
|
||||
|
||||
expect(urls.value).toEqual(['https://docs.company.com'])
|
||||
expect(urlFormError.value).toBeNull()
|
||||
expect(newUrl.value).toBe('')
|
||||
})
|
||||
|
||||
it('still rejects input that is not a URL at all', () => {
|
||||
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
|
||||
newUrl.value = 'not a url'
|
||||
|
||||
handleUrlAdd()
|
||||
|
||||
expect(urls.value).toEqual([])
|
||||
expect(urlFormError.value).toBe('请输入有效的 URL')
|
||||
})
|
||||
|
||||
it('clears a previous error when a later attempt succeeds', () => {
|
||||
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
|
||||
|
||||
newUrl.value = 'not a url'
|
||||
handleUrlAdd()
|
||||
expect(urlFormError.value).toBe('请输入有效的 URL')
|
||||
|
||||
newUrl.value = 'docs.company.com'
|
||||
handleUrlAdd()
|
||||
|
||||
expect(urlFormError.value).toBeNull()
|
||||
expect(urls.value).toEqual(['https://docs.company.com'])
|
||||
})
|
||||
|
||||
it('does not stage the same URL twice', () => {
|
||||
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
|
||||
|
||||
newUrl.value = 'docs.company.com'
|
||||
handleUrlAdd()
|
||||
newUrl.value = 'https://docs.company.com'
|
||||
handleUrlAdd()
|
||||
|
||||
expect(urls.value).toEqual(['https://docs.company.com'])
|
||||
expect(urlFormError.value).toBe('该 URL 已添加到当前批次')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const planLimits = vi.fn()
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
subscriptionStorage: { getPlanLimits: () => planLimits() }
|
||||
}))
|
||||
|
||||
import { useStagedSourceCap } from '@/composables/useStagedSourceCap'
|
||||
|
||||
const file = (name: string) => new File(['x'], name, { type: 'application/pdf' })
|
||||
|
||||
const setup = (cap: number, indexed: number, urls: string[], files: File[]) => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: cap })
|
||||
const u = ref(urls)
|
||||
const f = ref(files)
|
||||
return { u, f, ...useStagedSourceCap(ref(indexed), u, f) }
|
||||
}
|
||||
|
||||
describe('useStagedSourceCap', () => {
|
||||
beforeEach(() => planLimits.mockReset())
|
||||
|
||||
it('does nothing when the plan sets no cap', () => {
|
||||
const { u, f, fitToCap } = setup(0, 3, ['a', 'b'], [file('x.pdf')])
|
||||
expect(fitToCap('urls')).toBeNull()
|
||||
expect(u.value).toEqual(['a', 'b'])
|
||||
expect(f.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops staged files before older URLs, oldest first', () => {
|
||||
const { u, f, fitToCap } = setup(2, 0, ['a', 'b'], [file('x.pdf')])
|
||||
expect(fitToCap('urls')).toBeNull()
|
||||
expect(f.value).toEqual([])
|
||||
expect(u.value).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('drops the oldest URL when only URLs are staged', () => {
|
||||
const { u, fitToCap } = setup(2, 0, ['a', 'b', 'c'], [])
|
||||
expect(fitToCap('urls')).toBeNull()
|
||||
expect(u.value).toEqual(['b', 'c'])
|
||||
})
|
||||
|
||||
it('unstages new files that cannot fit and says how many can', () => {
|
||||
const { u, f, fitToCap } = setup(3, 1, ['a'], [file('x.pdf'), file('y.pdf'), file('z.pdf')])
|
||||
expect(fitToCap('files', 3)).toBe('你的套餐包含 3 个知识库来源,最多可选 2 个。')
|
||||
expect(f.value).toEqual([])
|
||||
expect(u.value).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('reports a cap already filled by indexed sources', () => {
|
||||
const { u, fitToCap } = setup(1, 1, ['a'], [])
|
||||
expect(fitToCap('urls')).toBe('你的套餐包含 1 个知识库来源,且已在用。')
|
||||
expect(u.value).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,597 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
submitting?: boolean
|
||||
}>(),
|
||||
{ submitting: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'submit', payload: AddSourcePayload): void
|
||||
}>()
|
||||
|
||||
const kind = ref<SourceKind>('website')
|
||||
const url = ref('')
|
||||
const scope = ref<CrawlScope>('host')
|
||||
const files = ref<File[]>([])
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const types: { key: SourceKind; title: string; sub: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
|
||||
]
|
||||
|
||||
const willQueue = computed(() => kind.value !== 'text')
|
||||
const submitLabel = computed(() => (kind.value === 'text' ? '添加页面' : '加入抓取队列'))
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.submitting) return false
|
||||
if (kind.value === 'website' || kind.value === 'sitemap') return url.value.trim().length > 0
|
||||
if (kind.value === 'pdf') return files.value.length > 0
|
||||
return title.value.trim().length > 0 && content.value.trim().length > 0
|
||||
})
|
||||
|
||||
function normalizeUrl(raw: string): string {
|
||||
const v = raw.trim()
|
||||
// Leave an explicit scheme alone; otherwise assume https.
|
||||
return v.includes('://') ? v : `https://${v}`
|
||||
}
|
||||
|
||||
// Parsed form of what's typed so far, used to name the host/section in the
|
||||
// scope options. Null while the URL is empty or not yet valid.
|
||||
const parsedUrl = computed<URL | null>(() => {
|
||||
if (!url.value.trim()) return null
|
||||
try {
|
||||
return new URL(normalizeUrl(url.value))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const seedHost = computed(() => parsedUrl.value?.hostname ?? '')
|
||||
// The section is the URL's own path. Empty for a homepage URL — there is no
|
||||
// section to speak of, and "pages under /" would just be "This site" again.
|
||||
const seedSection = computed(() => parsedUrl.value?.pathname.replace(/\/$/, '') ?? '')
|
||||
|
||||
const scopes = computed<{ key: CrawlScope; title: string; sub: string }[]>(() => {
|
||||
const host = seedHost.value
|
||||
const section = seedSection.value
|
||||
return [
|
||||
{ key: 'page', title: '仅此页面', sub: '仅索引上方这个 URL。' },
|
||||
...(section
|
||||
? [
|
||||
{
|
||||
key: 'path' as CrawlScope,
|
||||
title: '本栏目',
|
||||
sub: `仅 ${host} 上 ${section}/ 下的页面。`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'host',
|
||||
title: '整个站点',
|
||||
sub: host ? `${host} 上的所有页面。` : '同一主机上的所有页面。',
|
||||
},
|
||||
{
|
||||
key: 'domain',
|
||||
// No domain name in the copy on purpose: working out that
|
||||
// paywithatoa.co.uk is one registrable domain (not 'co.uk') needs the
|
||||
// ccTLD list the backend keeps, and a second copy of it would drift.
|
||||
title: '整个域名',
|
||||
sub: '也包括其他子域名,如 blog、docs、app。',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// Editing the URL down to a homepage removes the section option; don't leave a
|
||||
// scope selected that is no longer on screen.
|
||||
watch(scopes, (options) => {
|
||||
if (!options.some((option) => option.key === scope.value)) scope.value = 'host'
|
||||
})
|
||||
|
||||
function pickFiles() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const isPdf = (file: File) => file.type === 'application/pdf' || /\.pdf$/i.test(file.name)
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) files.value = Array.from(input.files).filter(isPdf)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragOver.value = false
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
// Only accept PDFs — the backend upload endpoint validates PDF magic bytes.
|
||||
const pdfs = Array.from(dropped).filter(isPdf)
|
||||
if (pdfs.length) files.value = pdfs
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (kind.value === 'website') {
|
||||
emit('submit', { type: 'website', url: normalizeUrl(url.value), scope: scope.value })
|
||||
} else if (kind.value === 'sitemap') {
|
||||
emit('submit', { type: 'sitemap', url: normalizeUrl(url.value) })
|
||||
} else if (kind.value === 'pdf') {
|
||||
emit('submit', { type: 'pdf', files: files.value })
|
||||
} else {
|
||||
emit('submit', { type: 'text', title: title.value.trim(), content: content.value })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="添加知识来源">
|
||||
<div class="modal__head">
|
||||
<div>
|
||||
<h3 class="modal__title">添加知识来源</h3>
|
||||
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="types">
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="type"
|
||||
:class="{ 'type--active': kind === t.key }"
|
||||
@click="kind = t.key"
|
||||
>
|
||||
<span class="type__title">{{ t.title }}</span>
|
||||
<span class="type__sub">{{ t.sub }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- website -->
|
||||
<template v-if="kind === 'website'">
|
||||
<label class="field-label" for="kb-add-url">页面 URL</label>
|
||||
<input id="kb-add-url" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
|
||||
<label class="field-label">抓取范围</label>
|
||||
<div class="scope">
|
||||
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
|
||||
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
|
||||
<span>
|
||||
<span class="scope__title">{{ opt.title }}</span>
|
||||
<span class="scope__sub">{{ opt.sub }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="scope !== 'page'" class="scope__hint">
|
||||
系统会发现并排队抓取关联页面,数量以套餐上限为准。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- sitemap -->
|
||||
<template v-else-if="kind === 'sitemap'">
|
||||
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
|
||||
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
|
||||
<div class="note note--teal">
|
||||
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- document -->
|
||||
<template v-else-if="kind === 'pdf'">
|
||||
<div
|
||||
class="drop"
|
||||
:class="{ 'drop--over': dragOver }"
|
||||
@click="pickFiles"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
|
||||
@change="onFilesSelected" />
|
||||
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
|
||||
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
|
||||
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
|
||||
</div>
|
||||
<div class="note note--coral">
|
||||
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- text -->
|
||||
<template v-else>
|
||||
<label class="field-label" for="kb-add-title">标题</label>
|
||||
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
|
||||
<label class="field-label" for="kb-add-content">内容</label>
|
||||
<textarea id="kb-add-content" v-model="content" class="textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="foot__note">
|
||||
<span class="foot__dot" :class="willQueue ? 'foot__dot--queue' : 'foot__dot--instant'"></span>
|
||||
<template v-if="willQueue">已加入抓取队列,索引完成后会通知你。</template>
|
||||
<template v-else>立即添加,无需抓取。</template>
|
||||
</span>
|
||||
<div class="foot__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
{{ submitting ? '添加中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
padding: 18px 24px 4px;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
}
|
||||
|
||||
.type:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.type--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.type__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.type__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 24px 4px;
|
||||
min-height: 172px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-input:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope__opt {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
}
|
||||
|
||||
.scope__opt--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
}
|
||||
|
||||
.radio--on {
|
||||
border-color: var(--accent-solid);
|
||||
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
|
||||
}
|
||||
|
||||
.scope__title {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.scope__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin-top: 1px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.scope__hint {
|
||||
margin: 8px 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o20);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop--over {
|
||||
border-color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drop__hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.note--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
}
|
||||
|
||||
.note--coral {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.foot__note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.foot__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.foot__dot--queue {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.foot__dot--instant {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.foot__actions {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
useKnowledgeExplorer,
|
||||
type ExplorerSource,
|
||||
type AddSourcePayload,
|
||||
} from '@/composables/useKnowledgeExplorer'
|
||||
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
|
||||
import KnowledgePageDetail from './KnowledgePageDetail.vue'
|
||||
import KnowledgePageEditor from './KnowledgePageEditor.vue'
|
||||
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
|
||||
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
|
||||
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'agent' | 'org'
|
||||
organizationId: string
|
||||
agentId?: string
|
||||
showPlanMeters?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'page' | 'section'
|
||||
}>(),
|
||||
{
|
||||
agentId: undefined,
|
||||
showPlanMeters: false,
|
||||
title: '',
|
||||
description: '',
|
||||
variant: 'section',
|
||||
},
|
||||
)
|
||||
|
||||
const ex = useKnowledgeExplorer(props.mode, props.agentId, props.organizationId)
|
||||
|
||||
// Add-source modal state.
|
||||
const addOpen = ref(false)
|
||||
const isSubmittingSource = ref(false)
|
||||
|
||||
// A single, reusable confirm dialog for the destructive actions.
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
message: string
|
||||
actionLabel: string
|
||||
busyLabel: string
|
||||
action: () => Promise<void>
|
||||
}
|
||||
const confirmState = ref<ConfirmState | null>(null)
|
||||
|
||||
const largestSource = computed(() =>
|
||||
ex.sources.value.reduce<ExplorerSource | null>((max, s) => {
|
||||
if (!max || (s.pages ?? s.pageStubs).length > (max.pages ?? max.pageStubs).length) return s
|
||||
return max
|
||||
}, null),
|
||||
)
|
||||
const largestSubpageCount = computed(() => {
|
||||
const s = largestSource.value
|
||||
return s ? (s.pages ?? s.pageStubs).length : 0
|
||||
})
|
||||
|
||||
function openAdd() {
|
||||
addOpen.value = true
|
||||
}
|
||||
|
||||
async function onSubmitSource(payload: AddSourcePayload) {
|
||||
if (isSubmittingSource.value) return
|
||||
isSubmittingSource.value = true
|
||||
try {
|
||||
const ok = await ex.submitSource(payload)
|
||||
if (ok) addOpen.value = false
|
||||
} finally {
|
||||
isSubmittingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function askDeleteSource(source: ExplorerSource) {
|
||||
if (source.queued && source.queuedStatus === 'error') {
|
||||
// A failed crawl — not an in-flight one to "cancel".
|
||||
confirmState.value = {
|
||||
title: '忽略来源',
|
||||
message: `忽略失败的来源“${source.name}”?`,
|
||||
actionLabel: '忽略',
|
||||
busyLabel: '正在忽略…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
} else if (source.queued) {
|
||||
confirmState.value = {
|
||||
title: '取消抓取',
|
||||
message: `取消对“${source.name}”的排队抓取?`,
|
||||
actionLabel: '取消抓取',
|
||||
busyLabel: '正在取消…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
} else if (props.mode === 'agent') {
|
||||
// In an agent's tab, "Remove" detaches the source from this agent but keeps
|
||||
// it in the organization's knowledge — it does not delete the source.
|
||||
confirmState.value = {
|
||||
title: '从智能体移除',
|
||||
message: `从该智能体移除“${source.name}”?它仍会保留在组织的知识库中。`,
|
||||
actionLabel: '移除',
|
||||
busyLabel: '正在移除…',
|
||||
action: () => ex.unlinkSource(source.id),
|
||||
}
|
||||
} else {
|
||||
confirmState.value = {
|
||||
title: '删除来源',
|
||||
message: `删除“${source.name}”及其所有页面?此操作无法撤销。`,
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function askDeletePage() {
|
||||
const page = ex.selectedPage.value
|
||||
if (!page) return
|
||||
confirmState.value = {
|
||||
title: '删除页面',
|
||||
message: `从该来源删除“${page.title}”?此操作无法撤销。`,
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
action: () => ex.deletePage(),
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const state = confirmState.value
|
||||
if (!state) return
|
||||
await state.action()
|
||||
confirmState.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ex.refresh()
|
||||
ex.startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ex.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<header class="explorer__header">
|
||||
<div class="explorer__heading">
|
||||
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="description" class="explorer__desc">{{ description }}</p>
|
||||
</div>
|
||||
<div class="explorer__actions">
|
||||
<slot name="actions" />
|
||||
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
|
||||
关联已有
|
||||
</button>
|
||||
<button class="btn btn--primary" type="button" @click="openAdd">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<KnowledgePlanMeters
|
||||
v-if="showPlanMeters"
|
||||
class="explorer__meters"
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
|
||||
<div v-if="ex.error.value" class="banner" role="alert">
|
||||
{{ ex.error.value }}
|
||||
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<KnowledgeSourceTree
|
||||
class="grid__tree"
|
||||
:sources="ex.filteredSources.value"
|
||||
:selected-page-id="ex.selectedPageId.value"
|
||||
:selected-source-id="ex.selectedSourceId.value"
|
||||
:query="ex.query.value"
|
||||
:status-of="ex.sourceStatus"
|
||||
:page-rows-of="ex.pageRows"
|
||||
@update:query="ex.query.value = $event"
|
||||
@toggle="ex.toggleSource"
|
||||
@select="(source, pageId) => ex.selectPage(source, pageId)"
|
||||
@delete-source="askDeleteSource"
|
||||
@add-page="ex.startAddPage"
|
||||
/>
|
||||
|
||||
<div class="grid__detail">
|
||||
<KnowledgePageEditor
|
||||
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
|
||||
:title="ex.draftTitle.value"
|
||||
:content="ex.draftContent.value"
|
||||
:saving="ex.isSaving.value"
|
||||
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
|
||||
:url="ex.draftUrl.value"
|
||||
:show-url="ex.isAddingPage.value"
|
||||
@update:title="ex.draftTitle.value = $event"
|
||||
@update:content="ex.draftContent.value = $event"
|
||||
@update:url="ex.draftUrl.value = $event"
|
||||
@save="ex.savePage"
|
||||
@cancel="ex.cancelEdit"
|
||||
/>
|
||||
<KnowledgePageDetail
|
||||
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
|
||||
:page="ex.selectedPage.value"
|
||||
:source-name="ex.selectedSource.value.name"
|
||||
:source-type="ex.selectedSource.value.type"
|
||||
:agents="ex.selectedSource.value.agents"
|
||||
:status="ex.sourceStatus(ex.selectedSource.value)"
|
||||
:deleting="ex.isDeleting.value"
|
||||
@edit="ex.startEdit"
|
||||
@delete="askDeletePage"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<div class="empty__orb"></div>
|
||||
<div class="empty__title">选择一个页面查看</div>
|
||||
<p class="empty__text">
|
||||
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm__card">
|
||||
<h3 class="confirm__title">{{ confirmState.title }}</h3>
|
||||
<p class="confirm__msg">{{ confirmState.message }}</p>
|
||||
<div class="confirm__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
|
||||
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
|
||||
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KnowledgeAddSourceModal
|
||||
v-if="addOpen"
|
||||
:submitting="isSubmittingSource"
|
||||
@close="addOpen = false"
|
||||
@submit="onSubmitSource"
|
||||
/>
|
||||
|
||||
<KnowledgeLinkModal
|
||||
v-if="ex.linkPickerOpen.value"
|
||||
:sources="ex.orgSources.value"
|
||||
:linked-ids="ex.linkedSourceIds.value"
|
||||
:busy-ids="ex.linkingIds.value"
|
||||
:loading="ex.isLoadingOrgSources.value"
|
||||
:error="ex.orgSourcesError.value"
|
||||
@close="ex.linkPickerOpen.value = false"
|
||||
@link="ex.linkSource"
|
||||
@unlink="ex.unlinkSource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.explorer__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.explorer__title--page {
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.explorer__title--section {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.explorer__desc {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.explorer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer__meters {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 47px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
|
||||
|
||||
.btn--danger-solid {
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 11px;
|
||||
color: var(--c-coral);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.banner__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 344px 1fr;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.grid__tree,
|
||||
.grid__detail {
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.grid__detail {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty__orb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.empty__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty__text {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
max-width: 320px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.confirm__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.confirm__msg {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.confirm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KnowledgeItem } from '@/types/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
sources: KnowledgeItem[]
|
||||
linkedIds: Set<number>
|
||||
busyIds: Set<number>
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'link', knowledgeId: number): void
|
||||
(e: 'unlink', knowledgeId: number): void
|
||||
}>()
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const filteredSources = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.sources
|
||||
return props.sources.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="关联已有知识">
|
||||
<div class="modal__head">
|
||||
<div>
|
||||
<h3 class="modal__title">关联已有知识</h3>
|
||||
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="loading" class="state">正在加载知识来源…</div>
|
||||
<div v-else-if="error" class="state state--error">{{ error }}</div>
|
||||
<div v-else-if="!filteredSources.length" class="state">
|
||||
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
|
||||
</div>
|
||||
<ul v-else class="list">
|
||||
<li v-for="item in filteredSources" :key="item.id" class="row">
|
||||
<div class="row__main">
|
||||
<span class="row__name">{{ item.name }}</span>
|
||||
<span class="row__type">{{ item.type }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="linkedIds.has(item.id)"
|
||||
class="btn btn--linked"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('unlink', item.id)"
|
||||
>
|
||||
{{ busyIds.has(item.id) ? '正在移除…' : '取消关联' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('link', item.id)"
|
||||
>
|
||||
{{ busyIds.has(item.id) ? '正在关联…' : '关联' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 16px 24px 4px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 24px 4px;
|
||||
min-height: 180px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state--error {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.row__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row__name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.row__type {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn--linked {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--linked:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
|
||||
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
const props = defineProps<{
|
||||
page: KnowledgeSubPage
|
||||
sourceName: string
|
||||
sourceType: string
|
||||
agents?: KnowledgeLinkedAgent[]
|
||||
status: SourceStatus
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const paragraphs = computed(() =>
|
||||
(props.page.content || '')
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const href = computed(() => {
|
||||
const url = props.page.url || ''
|
||||
return /^https?:/i.test(url) ? url : `https://${url}`
|
||||
})
|
||||
|
||||
const updatedLabel = computed(() => {
|
||||
if (!props.page.updated_at) return '未知'
|
||||
const d = new Date(props.page.updated_at)
|
||||
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div class="detail__head">
|
||||
<div class="crumbs">
|
||||
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="crumbs__sep">/</span>
|
||||
<span class="crumbs__type">{{ sourceType }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail__title-row">
|
||||
<div class="detail__title-wrap">
|
||||
<h2 class="detail__title">{{ page.title }}</h2>
|
||||
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
|
||||
</svg>
|
||||
<span class="detail__url-text">{{ page.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
|
||||
@click="$emit('delete')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="meta__item">{{ page.word_count }} 词</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="meta__item">更新于 {{ updatedLabel }}</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="pill" :class="`pill--${status}`">
|
||||
<span class="pill__dot"></span>{{ statusText[status] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="agents && agents.length" class="usedby">
|
||||
<span class="usedby__label">使用方</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
|
||||
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-chip__name">{{ agent.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
<div v-if="paragraphs.length === 0" class="doc__empty">
|
||||
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail__head {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs__src {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.crumbs__sep { color: var(--faint); }
|
||||
.crumbs__type { color: var(--text3); }
|
||||
|
||||
.detail__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail__title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail__url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail__url-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--o08); }
|
||||
|
||||
.btn--danger {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.meta__sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--teal-border);
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.usedby {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.usedby__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.agent-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.agent-chip__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-chip__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill--crawling {
|
||||
border-color: var(--warning-border, var(--o12));
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.pill--queued {
|
||||
border-color: var(--purple-border, var(--o12));
|
||||
background: var(--purple-bg, var(--o06));
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.pill--error {
|
||||
border-color: var(--coral-border);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.doc {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
color: var(--text3);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.doc p {
|
||||
margin: 0 0 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.doc__empty {
|
||||
text-align: center;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
content: string
|
||||
saving: boolean
|
||||
submitLabel?: string
|
||||
url?: string
|
||||
showUrl?: boolean
|
||||
}>(),
|
||||
{ submitLabel: '保存页面', url: '', showUrl: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:title', value: string): void
|
||||
(e: 'update:content', value: string): void
|
||||
(e: 'update:url', value: string): void
|
||||
(e: 'save'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor__body">
|
||||
<label class="editor__label" for="kb-page-title">页面标题</label>
|
||||
<input
|
||||
id="kb-page-title"
|
||||
class="editor__title"
|
||||
:value="title"
|
||||
type="text"
|
||||
placeholder="页面标题"
|
||||
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<template v-if="showUrl">
|
||||
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
|
||||
<input
|
||||
id="kb-page-url"
|
||||
class="editor__title editor__url"
|
||||
:value="url"
|
||||
type="url"
|
||||
placeholder="https://example.com/page"
|
||||
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<label class="editor__label" for="kb-page-content">页面内容</label>
|
||||
<textarea
|
||||
id="kb-page-content"
|
||||
class="editor__content"
|
||||
:value="content"
|
||||
placeholder="在此输入页面内容,段落之间留一个空行。"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor__foot">
|
||||
<span class="editor__status">
|
||||
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
|
||||
</span>
|
||||
<div class="editor__buttons">
|
||||
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
{{ saving ? '保存中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor__title {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.editor__url {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.editor__title:focus,
|
||||
.editor__content:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.editor__content {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text3);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.editor__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--o10);
|
||||
background: var(--bg2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
}
|
||||
|
||||
.editor__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: 9px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const props = defineProps<{
|
||||
sourceCount: number
|
||||
largestSourceName: string | null
|
||||
largestSubpageCount: number
|
||||
}>()
|
||||
|
||||
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore } =
|
||||
useEnterpriseFeatures()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!hasEnterpriseModule) return
|
||||
await initializeSubscriptionStore()
|
||||
if (!subscriptionStore.value.currentPlan) {
|
||||
await subscriptionStore.value.fetchCurrentPlan()
|
||||
}
|
||||
})
|
||||
|
||||
const plan = computed(() => subscriptionStore.value.currentPlan?.plan ?? null)
|
||||
const maxSources = computed<number | null>(() => plan.value?.max_knowledge_sources ?? null)
|
||||
const maxSubpages = computed<number | null>(() => plan.value?.max_sub_pages ?? null)
|
||||
|
||||
// Clamp a usage ratio to a readable meter width (min 6% so a tiny bar still shows).
|
||||
function pct(used: number, limit: number | null): number {
|
||||
if (!limit || limit <= 0) return 0
|
||||
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
|
||||
}
|
||||
|
||||
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
|
||||
const subpagesOver = computed(
|
||||
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meters">
|
||||
<div class="meter" :class="{ 'meter--over': sourcesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">知识来源</span>
|
||||
<span class="meter__value">
|
||||
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSources !== null" class="meter__track">
|
||||
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
|
||||
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
|
||||
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meter" :class="{ 'meter--over': subpagesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">每个来源的子页面</span>
|
||||
<span class="meter__value">
|
||||
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSubpages !== null" class="meter__track">
|
||||
<div class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="largestSourceName">
|
||||
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
|
||||
</template>
|
||||
<template v-else>还没有来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.meters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.meter {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.meter__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.meter__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.meter__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.meter--over .meter__value {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.meter__track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--o07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--c-teal);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.meter__fill--alt {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--c-coral);
|
||||
}
|
||||
|
||||
.meter__note {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meter--over .meter__note {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,501 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ExplorerSource, SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
interface PageRow {
|
||||
page_id: string
|
||||
title: string
|
||||
words: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sources: ExplorerSource[]
|
||||
selectedPageId: string | null
|
||||
selectedSourceId: number | null
|
||||
query: string
|
||||
statusOf: (source: ExplorerSource) => SourceStatus
|
||||
pageRowsOf: (source: ExplorerSource) => PageRow[]
|
||||
}>()
|
||||
|
||||
// Group page rows once per render (the template reads them twice per source).
|
||||
const rowsBySource = computed(() => {
|
||||
const map = new Map<number, PageRow[]>()
|
||||
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
|
||||
return map
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:query', value: string): void
|
||||
(e: 'toggle', source: ExplorerSource): void
|
||||
(e: 'select', source: ExplorerSource, pageId: string): void
|
||||
(e: 'delete-source', source: ExplorerSource): void
|
||||
(e: 'add-page', source: ExplorerSource): void
|
||||
}>()
|
||||
|
||||
const statusLabel: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
// A queued placeholder's error state is a failed crawl, not a source that
|
||||
// merely "needs sync".
|
||||
function queuedLabel(source: ExplorerSource): string {
|
||||
const status = props.statusOf(source)
|
||||
return status === 'error' ? '失败' : statusLabel[status]
|
||||
}
|
||||
|
||||
function sourceGlyph(type: string): string {
|
||||
const t = (type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t === 'file') return 'P'
|
||||
if (t.includes('site')) return 'S'
|
||||
if (t.includes('web')) return 'W'
|
||||
return 'T'
|
||||
}
|
||||
|
||||
// First-letter avatar for a linked agent.
|
||||
const agentInitial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
// Show at most this many agent avatars on a source row; the rest fold into "+N".
|
||||
const MAX_AVATARS = 3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tree">
|
||||
<div class="tree__search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
:value="query"
|
||||
type="search"
|
||||
placeholder="搜索页面…"
|
||||
aria-label="搜索页面"
|
||||
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tree__body">
|
||||
<div v-for="source in sources" :key="source.id" class="src">
|
||||
<div class="src__row">
|
||||
<button
|
||||
class="src__toggle"
|
||||
type="button"
|
||||
:aria-expanded="source.queued ? undefined : source.expanded"
|
||||
:disabled="source.queued"
|
||||
@click="!source.queued && emit('toggle', source)"
|
||||
>
|
||||
<svg v-if="!source.queued" class="chev" :class="{ 'chev--open': source.expanded }" viewBox="0 0 24 24"
|
||||
width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
<span v-else class="chev-spacer"></span>
|
||||
<span class="src__glyph" :class="`src__glyph--${sourceGlyph(source.type).toLowerCase()}`">{{ sourceGlyph(source.type) }}</span>
|
||||
<span class="src__meta">
|
||||
<span class="src__name" :title="source.name">{{ source.name }}</span>
|
||||
<span class="src__count" :class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }">
|
||||
{{ source.queued ? queuedLabel(source) : (source.pages ?? source.pageStubs).length + ' 个子页面' }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="!source.queued && source.agents.length"
|
||||
class="agents"
|
||||
:title="`使用方:${source.agents.map((a) => a.name).join('、')}`"
|
||||
>
|
||||
<span
|
||||
v-for="agent in source.agents.slice(0, MAX_AVATARS)"
|
||||
:key="agent.id"
|
||||
class="agents__avatar"
|
||||
>{{ agentInitial(agent.name) }}</span>
|
||||
<span v-if="source.agents.length > MAX_AVATARS" class="agents__more">
|
||||
+{{ source.agents.length - MAX_AVATARS }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dot" :class="`dot--${statusOf(source)}`" :title="statusLabel[statusOf(source)]"></span>
|
||||
<button
|
||||
v-if="!source.queued"
|
||||
class="src__add"
|
||||
type="button"
|
||||
title="为该来源添加子页面"
|
||||
@click="emit('add-page', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="src__del"
|
||||
type="button"
|
||||
:title="!source.queued ? '删除此来源' : (statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取')"
|
||||
@click="emit('delete-source', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="source.queued && source.queuedError" class="src__error">
|
||||
{{ source.queuedError }}
|
||||
</div>
|
||||
|
||||
<div v-if="source.expanded && !source.queued" class="src__pages">
|
||||
<div v-if="source.loadingContent" class="src__hint">正在加载页面…</div>
|
||||
<div v-else-if="source.contentError" class="src__hint src__hint--error">{{ source.contentError }}</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="row in (rowsBySource.get(source.id) || [])"
|
||||
:key="row.page_id"
|
||||
type="button"
|
||||
class="pg"
|
||||
:class="{ 'pg--active': source.id === selectedSourceId && row.page_id === selectedPageId }"
|
||||
@click="emit('select', source, row.page_id)"
|
||||
>
|
||||
<span class="pg__dot"></span>
|
||||
<span class="pg__title" :title="row.title">{{ row.title }}</span>
|
||||
<span v-if="row.words !== null" class="pg__words">{{ row.words }} 词</span>
|
||||
</button>
|
||||
<div v-if="(rowsBySource.get(source.id) || []).length === 0" class="src__hint">尚未提取任何页面。</div>
|
||||
<button type="button" class="pg pg--add" @click="emit('add-page', source)">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加子页面
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sources.length === 0" class="tree__empty">
|
||||
<template v-if="query">没有匹配“{{ query }}”的页面。</template>
|
||||
<template v-else>还没有知识来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 14px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tree__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.src {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.src__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 9px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.src__row:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.src__toggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chev-spacer {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.src__toggle:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chev--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.src__glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.src__glyph--p { background: var(--coral-bg); color: var(--c-coral); }
|
||||
.src__glyph--s { background: var(--teal-bg); color: var(--c-teal); }
|
||||
.src__glyph--t { background: var(--purple-bg, var(--o08)); color: var(--c-purple); }
|
||||
|
||||
.src__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.src__name {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.src__count {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.src__count--error {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.agents {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--surface);
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.agents__avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.agents__more {
|
||||
margin-left: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--muted2);
|
||||
}
|
||||
|
||||
.dot--synced { background: var(--c-teal); box-shadow: 0 0 6px var(--c-teal); }
|
||||
.dot--crawling { background: var(--warning-color); }
|
||||
.dot--queued { background: var(--c-purple); }
|
||||
.dot--error { background: var(--c-coral); }
|
||||
|
||||
.src__add,
|
||||
.src__del {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.src__row:hover .src__add,
|
||||
.src__row:hover .src__del { opacity: 1; }
|
||||
|
||||
.src__add:hover {
|
||||
background: var(--accent-bg-08);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent-border, var(--o12));
|
||||
}
|
||||
|
||||
.src__del:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.src__pages {
|
||||
padding: 2px 0 6px 18px;
|
||||
}
|
||||
|
||||
.pg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin: 1px 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pg:hover { background: var(--o04); }
|
||||
.pg--active { background: var(--accent-bg-08); }
|
||||
|
||||
.pg--add {
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.pg--add:hover {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.pg__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.pg__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.pg--active .pg__title {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg__words {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__hint {
|
||||
padding: 8px 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.src__hint--error { color: var(--c-coral); }
|
||||
|
||||
.src__error {
|
||||
margin: 2px 10px 6px 44px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tree__empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,669 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { showPlanLimitError } from '@/utils/planLimitToast'
|
||||
import type {
|
||||
KnowledgeItem,
|
||||
KnowledgeLinkedAgent,
|
||||
KnowledgePage,
|
||||
KnowledgeSubPage,
|
||||
QueueItem,
|
||||
} from '@/types/knowledge'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
import { basePageId, groupChunksIntoPages, titleFromId } from '@/utils/knowledgePages'
|
||||
|
||||
export type ExplorerMode = 'agent' | 'org'
|
||||
export type SourceStatus = 'queued' | 'crawling' | 'synced' | 'error'
|
||||
|
||||
// How far a website crawl may follow links from the URL it starts at.
|
||||
// 'page' indexes only that URL; the rest map to the backend's crawl scopes.
|
||||
export type CrawlScope = 'page' | 'path' | 'host' | 'domain'
|
||||
|
||||
// What the add-source modal submits; discriminated by `type`.
|
||||
export type AddSourcePayload =
|
||||
| { type: 'website'; url: string; scope: CrawlScope }
|
||||
| { type: 'sitemap'; url: string }
|
||||
| { type: 'pdf'; files: File[] }
|
||||
| { type: 'text'; title: string; content: string }
|
||||
|
||||
/** A knowledge source enriched with lazily-loaded, grouped sub-page content. */
|
||||
export interface ExplorerSource {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
// Agents this source is linked to (for the "Used by" chips).
|
||||
agents: KnowledgeLinkedAgent[]
|
||||
pageStubs: KnowledgePage[]
|
||||
expanded: boolean
|
||||
loadingContent: boolean
|
||||
contentError: string | null
|
||||
pages: KnowledgeSubPage[] | null
|
||||
// True for a placeholder built from an in-flight queue item that has not yet
|
||||
// produced a real Knowledge source (id is the negated queue-item id).
|
||||
queued?: boolean
|
||||
// Status carried from the queue item for a placeholder (its display name may
|
||||
// differ from the queue `source`, so it can't be re-derived by name lookup).
|
||||
queuedStatus?: SourceStatus
|
||||
// Failure reason for a failed queue item (e.g. blocked by bot protection).
|
||||
queuedError?: string | null
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000
|
||||
|
||||
/**
|
||||
* State engine for the master-detail knowledge explorer, shared by the
|
||||
* agent-editor tab (`mode: 'agent'`) and the dedicated knowledge page
|
||||
* (`mode: 'org'`). Source list is fetched eagerly; each source's sub-page
|
||||
* content is grouped from its chunks lazily on first expand/selection.
|
||||
*/
|
||||
export function useKnowledgeExplorer(
|
||||
mode: ExplorerMode,
|
||||
agentId: string | undefined,
|
||||
organizationId: string,
|
||||
) {
|
||||
const sources = ref<ExplorerSource[]>([])
|
||||
const queueItems = ref<QueueItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const query = ref('')
|
||||
|
||||
const selectedSourceId = ref<number | null>(null)
|
||||
const selectedPageId = ref<string | null>(null)
|
||||
|
||||
const editing = ref(false)
|
||||
const isAddingPage = ref(false)
|
||||
const draftTitle = ref('')
|
||||
const draftContent = ref('')
|
||||
const draftUrl = ref('')
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
// Source names that were crawling on the previous poll tick, so we can force a
|
||||
// final content refresh once a crawl finishes.
|
||||
let crawlingNames = new Set<string>()
|
||||
// Queue-item ids seen on the previous tick, so we detect a crawl finishing
|
||||
// (an item leaving the queue) even if it was never observed as active.
|
||||
let lastQueueIds = new Set<number>()
|
||||
|
||||
const queueIds = () => new Set(queueItems.value.map((q) => q.id))
|
||||
const activeCrawlNames = () =>
|
||||
new Set(
|
||||
queueItems.value
|
||||
.filter((q) => q.status === 'pending' || q.status === 'processing')
|
||||
.map((q) => q.source),
|
||||
)
|
||||
const setsDiffer = (a: Set<number>, b: Set<number>) =>
|
||||
a.size !== b.size || [...a].some((id) => !b.has(id))
|
||||
|
||||
const findSource = (id: number) => sources.value.find((s) => s.id === id)
|
||||
|
||||
const toExplorerSource = (item: KnowledgeItem): ExplorerSource => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
agents: item.agents || [],
|
||||
pageStubs: item.pages || [],
|
||||
expanded: false,
|
||||
loadingContent: false,
|
||||
contentError: null,
|
||||
pages: null,
|
||||
})
|
||||
|
||||
// `background` refetches (from the crawl poll) skip the loading flag so any
|
||||
// bound spinner doesn't flicker every tick.
|
||||
const fetchSources = async (background = false) => {
|
||||
if (!background) isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response =
|
||||
mode === 'agent' && agentId
|
||||
? await knowledgeService.getKnowledgeByAgent(agentId, 1, 100)
|
||||
: await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
|
||||
|
||||
const items: KnowledgeItem[] = response.knowledge || []
|
||||
// Preserve expand/loaded state across refreshes so a running crawl poll
|
||||
// does not collapse the tree or drop already-fetched content.
|
||||
const previous = new Map(sources.value.map((s) => [s.id, s]))
|
||||
sources.value = items.map((item) => {
|
||||
const prev = previous.get(item.id)
|
||||
const next = toExplorerSource(item)
|
||||
if (prev) {
|
||||
next.expanded = prev.expanded
|
||||
next.pages = prev.pages
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load knowledge sources:', err)
|
||||
error.value = '加载知识库来源失败'
|
||||
} finally {
|
||||
if (!background) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const response =
|
||||
mode === 'agent' && agentId
|
||||
? await knowledgeService.getAgentQueueItems(agentId)
|
||||
: await knowledgeService.getOrgQueueItems(organizationId)
|
||||
queueItems.value = response.queue_items || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load knowledge queue:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// A queue item's source_type ('website' | 'pdf_url' | 'pdf_file') mapped to the
|
||||
// tree's source-kind glyph.
|
||||
const queueKind = (sourceType: string): string => {
|
||||
if (sourceType.includes('pdf')) return 'pdf'
|
||||
if (sourceType.includes('site')) return 'sitemap'
|
||||
if (sourceType.includes('web')) return 'web'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
// A queue item's `source` is a URL for websites but an internal temp path or
|
||||
// S3 signed URL for PDFs — show a readable filename for the placeholder.
|
||||
const queueSourceName = (item: QueueItem): string => {
|
||||
if (!item.source_type.includes('pdf')) return item.source
|
||||
const path = item.source.split(/[?#]/)[0]
|
||||
const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1))
|
||||
.replace(/\.[^.]+$/, '') // drop extension
|
||||
.replace(/^[0-9a-f-]{8,}[_-]/i, '') // drop a leading uuid_ prefix
|
||||
return base || item.source
|
||||
}
|
||||
|
||||
const queueStatusToSource = (status: string): SourceStatus =>
|
||||
status === 'failed' ? 'error' : status === 'processing' ? 'crawling' : 'queued'
|
||||
|
||||
// Placeholder source for an in-flight queue item (negative id = queue-item id).
|
||||
const toSyntheticSource = (item: QueueItem): ExplorerSource => ({
|
||||
id: -item.id,
|
||||
name: queueSourceName(item),
|
||||
type: queueKind(item.source_type),
|
||||
agents: [],
|
||||
pageStubs: [],
|
||||
expanded: false,
|
||||
loadingContent: false,
|
||||
contentError: null,
|
||||
pages: [],
|
||||
queued: true,
|
||||
queuedStatus: queueStatusToSource(item.status),
|
||||
queuedError: item.status === 'failed' ? item.error ?? null : null,
|
||||
})
|
||||
|
||||
// Real sources plus a placeholder for every in-flight queue item that has not
|
||||
// yet produced a real source — so a just-queued crawl shows immediately and is
|
||||
// replaced by the real source once indexing completes and the poll refetches.
|
||||
const allSources = computed<ExplorerSource[]>(() => {
|
||||
const realNames = new Set(sources.value.map((s) => s.name))
|
||||
const seen = new Set<string>()
|
||||
const placeholders: ExplorerSource[] = []
|
||||
for (const item of queueItems.value) {
|
||||
if (realNames.has(item.source) || seen.has(item.source)) continue
|
||||
seen.add(item.source)
|
||||
placeholders.push(toSyntheticSource(item))
|
||||
}
|
||||
return [...placeholders, ...sources.value]
|
||||
})
|
||||
|
||||
// Resolve the source by id on every mutation. A concurrent (poll-driven)
|
||||
// fetchSources() replaces the objects in `sources.value`, so writing to the
|
||||
// originally-captured `source` would be lost — always re-find the live one.
|
||||
const loadSourceContent = async (sourceId: number, force = false) => {
|
||||
const source = findSource(sourceId)
|
||||
if (!source || source.loadingContent) return
|
||||
if (source.pages && !force) return
|
||||
source.loadingContent = true
|
||||
source.contentError = null
|
||||
try {
|
||||
const content = await knowledgeService.getKnowledgeContent(sourceId)
|
||||
const pages = groupChunksIntoPages(content.chunks || [])
|
||||
const live = findSource(sourceId)
|
||||
if (live) live.pages = pages
|
||||
} catch (err) {
|
||||
console.error(`Failed to load content for source ${sourceId}:`, err)
|
||||
const live = findSource(sourceId)
|
||||
if (live) {
|
||||
live.contentError = '加载页面失败'
|
||||
live.pages = []
|
||||
}
|
||||
} finally {
|
||||
const live = findSource(sourceId)
|
||||
if (live) live.loadingContent = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSource = async (source: ExplorerSource) => {
|
||||
source.expanded = !source.expanded
|
||||
if (source.expanded && source.pages === null) {
|
||||
await loadSourceContent(source.id)
|
||||
}
|
||||
}
|
||||
|
||||
const selectPage = async (source: ExplorerSource, pageId: string) => {
|
||||
selectedSourceId.value = source.id
|
||||
selectedPageId.value = pageId
|
||||
editing.value = false
|
||||
isAddingPage.value = false
|
||||
if (source.pages === null) {
|
||||
await loadSourceContent(source.id)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSource = computed(
|
||||
() => sources.value.find((s) => s.id === selectedSourceId.value) ?? null,
|
||||
)
|
||||
|
||||
const selectedPage = computed<KnowledgeSubPage | null>(() => {
|
||||
const source = selectedSource.value
|
||||
if (!source || !source.pages || selectedPageId.value === null) return null
|
||||
return source.pages.find((p) => p.page_id === selectedPageId.value) ?? null
|
||||
})
|
||||
|
||||
const sourceStatus = (source: ExplorerSource): SourceStatus => {
|
||||
// Placeholders carry their own status (their display name differs from the
|
||||
// queue source, so a name lookup wouldn't find the item).
|
||||
if (source.queued) return source.queuedStatus ?? 'queued'
|
||||
const item = queueItems.value.find((q) => q.source === source.name)
|
||||
if (item?.status === 'failed') return 'error'
|
||||
if (item?.status === 'processing') return 'crawling'
|
||||
if (item?.status === 'pending') return 'queued'
|
||||
return 'synced'
|
||||
}
|
||||
|
||||
// Page rows for the tree: grouped pages once loaded, else lightweight stubs.
|
||||
const pageRows = (source: ExplorerSource) => {
|
||||
if (source.pages) {
|
||||
return source.pages.map((p) => ({
|
||||
page_id: p.page_id,
|
||||
title: p.title,
|
||||
words: p.word_count,
|
||||
}))
|
||||
}
|
||||
return source.pageStubs.map((stub: KnowledgePage) => ({
|
||||
page_id: stub.subpage,
|
||||
title: titleFromId(stub.subpage),
|
||||
words: null as number | null,
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
|
||||
|
||||
const filteredSources = computed(() => {
|
||||
const q = normalizedQuery.value
|
||||
if (!q) return allSources.value
|
||||
return allSources.value.filter((source) => {
|
||||
if (source.name.toLowerCase().includes(q)) return true
|
||||
const rows = pageRows(source)
|
||||
return rows.some((r) => r.title.toLowerCase().includes(q) || r.page_id.toLowerCase().includes(q))
|
||||
})
|
||||
})
|
||||
|
||||
const startEdit = () => {
|
||||
const page = selectedPage.value
|
||||
if (!page) return
|
||||
draftTitle.value = page.title
|
||||
draftContent.value = page.content
|
||||
isAddingPage.value = false
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
// Open the editor with empty fields to add a new sub-page to `source`.
|
||||
const startAddPage = async (source: ExplorerSource) => {
|
||||
if (source.queued) return
|
||||
selectedSourceId.value = source.id
|
||||
selectedPageId.value = null
|
||||
draftTitle.value = ''
|
||||
draftContent.value = ''
|
||||
draftUrl.value = ''
|
||||
isAddingPage.value = true
|
||||
editing.value = true
|
||||
source.expanded = true
|
||||
if (source.pages === null) await loadSourceContent(source.id)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editing.value = false
|
||||
isAddingPage.value = false
|
||||
}
|
||||
|
||||
const addNewPage = async (source: ExplorerSource) => {
|
||||
const title = draftTitle.value.trim()
|
||||
const content = draftContent.value.trim()
|
||||
if (!title) {
|
||||
error.value = '请为子页面填写标题'
|
||||
toast.error('请为子页面填写标题')
|
||||
return
|
||||
}
|
||||
if (!content) {
|
||||
error.value = '子页面内容不能为空'
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await knowledgeService.addSubpage(source.id, title, content, draftUrl.value.trim() || undefined)
|
||||
await loadSourceContent(source.id, true)
|
||||
selectedPageId.value = basePageId(title)
|
||||
isAddingPage.value = false
|
||||
editing.value = false
|
||||
toast.success('子页面已添加')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '添加子页面失败'
|
||||
console.error('Failed to add sub-page:', err)
|
||||
error.value = msg
|
||||
void showPlanLimitError('无法添加子页面', msg)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const savePage = async () => {
|
||||
const source = selectedSource.value
|
||||
if (!source) return
|
||||
if (isAddingPage.value) return addNewPage(source)
|
||||
|
||||
const page = selectedPage.value
|
||||
if (!page) return
|
||||
const content = draftContent.value.trim()
|
||||
if (!content) {
|
||||
error.value = '页面内容不能为空'
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await knowledgeService.updatePage(source.id, page.page_id, content, draftTitle.value.trim())
|
||||
await loadSourceContent(source.id, true)
|
||||
editing.value = false
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save page:', err)
|
||||
error.value = err instanceof Error ? err.message : '保存页面失败'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePage = async () => {
|
||||
const source = selectedSource.value
|
||||
const page = selectedPage.value
|
||||
if (!source || !page) return
|
||||
isDeleting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await knowledgeService.deletePage(source.id, page.page_id)
|
||||
selectedPageId.value = null
|
||||
await loadSourceContent(source.id, true)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete page:', err)
|
||||
error.value = err instanceof Error ? err.message : '删除页面失败'
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSource = async (source: ExplorerSource) => {
|
||||
isDeleting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// A queued placeholder isn't a real source yet — cancel its queue item.
|
||||
if (source.queued) {
|
||||
await knowledgeService.deleteQueueItem(-source.id)
|
||||
} else {
|
||||
await knowledgeService.deleteKnowledge(source.id)
|
||||
}
|
||||
if (selectedSourceId.value === source.id) {
|
||||
selectedSourceId.value = null
|
||||
selectedPageId.value = null
|
||||
}
|
||||
await Promise.all([fetchSources(), fetchQueue()])
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete source:', err)
|
||||
error.value = err instanceof Error ? err.message : '删除来源失败'
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Link existing org-level knowledge to this agent (agent mode only) ----
|
||||
// Org sources are created with agent_id=NULL, so they never auto-link to an
|
||||
// agent; this picker restores the ability to attach them from the agent tab.
|
||||
const linkPickerOpen = ref(false)
|
||||
const orgSources = ref<KnowledgeItem[]>([])
|
||||
const isLoadingOrgSources = ref(false)
|
||||
const orgSourcesError = ref<string | null>(null)
|
||||
// Knowledge ids with a link/unlink request in flight (per-row busy state).
|
||||
const linkingIds = ref<Set<number>>(new Set())
|
||||
|
||||
// Ids already linked to this agent — drives the Link/Unlink toggle per row.
|
||||
const linkedSourceIds = computed(() => new Set(sources.value.map((s) => s.id)))
|
||||
|
||||
const loadOrgSources = async () => {
|
||||
isLoadingOrgSources.value = true
|
||||
orgSourcesError.value = null
|
||||
try {
|
||||
const response = await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
|
||||
orgSources.value = response.knowledge || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load organization knowledge:', err)
|
||||
orgSourcesError.value = '加载组织知识库失败'
|
||||
} finally {
|
||||
isLoadingOrgSources.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openLinkPicker = async () => {
|
||||
if (mode !== 'agent' || !agentId) return
|
||||
linkPickerOpen.value = true
|
||||
await loadOrgSources()
|
||||
}
|
||||
|
||||
// Shared link/unlink runner: guards concurrent clicks per row, keeps the
|
||||
// busy-state Set in sync, refetches the agent's linked sources, and toasts.
|
||||
const runLinkOp = async (
|
||||
knowledgeId: number,
|
||||
op: (id: number, agentId: string) => Promise<unknown>,
|
||||
{ successMsg, failMsg, onSuccess }: {
|
||||
successMsg: string
|
||||
failMsg: string
|
||||
onSuccess?: () => void
|
||||
},
|
||||
) => {
|
||||
if (mode !== 'agent' || !agentId || linkingIds.value.has(knowledgeId)) return
|
||||
linkingIds.value.add(knowledgeId)
|
||||
try {
|
||||
await op(knowledgeId, agentId)
|
||||
onSuccess?.()
|
||||
await fetchSources()
|
||||
toast.success(successMsg)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : failMsg
|
||||
console.error(`${failMsg}:`, err)
|
||||
toast.error(failMsg, { description: msg })
|
||||
} finally {
|
||||
linkingIds.value.delete(knowledgeId)
|
||||
}
|
||||
}
|
||||
|
||||
const linkSource = (knowledgeId: number) =>
|
||||
runLinkOp(knowledgeId, (id, aid) => knowledgeService.linkToAgent(id, aid), {
|
||||
successMsg: '知识已关联到此客服',
|
||||
failMsg: '无法关联知识',
|
||||
})
|
||||
|
||||
const unlinkSource = (knowledgeId: number) =>
|
||||
runLinkOp(knowledgeId, (id, aid) => knowledgeService.unlinkFromAgent(id, aid), {
|
||||
successMsg: '知识已从此客服移除',
|
||||
failMsg: '无法移除知识',
|
||||
// Clear the detail pane if the removed source was selected.
|
||||
onSuccess: () => {
|
||||
if (selectedSourceId.value === knowledgeId) {
|
||||
selectedSourceId.value = null
|
||||
selectedPageId.value = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const linkedAgentId = mode === 'agent' ? agentId : undefined
|
||||
|
||||
// Handle add/urls responses that report duplicates instead of throwing.
|
||||
const throwIfError = (res: { error?: string } | undefined) => {
|
||||
if (res?.error) throw new Error(res.error)
|
||||
}
|
||||
|
||||
// Submit a new source from the add-source modal. Resolves true on success.
|
||||
// Crawl sources (website/sitemap/pdf) are queued and indexed in the
|
||||
// background; text is indexed immediately. Toasts announce the outcome.
|
||||
const submitSource = async (payload: AddSourcePayload): Promise<boolean> => {
|
||||
error.value = null
|
||||
try {
|
||||
if (payload.type === 'website') {
|
||||
throwIfError(
|
||||
await knowledgeService.addUrls(
|
||||
organizationId,
|
||||
[payload.url],
|
||||
linkedAgentId,
|
||||
undefined,
|
||||
// "This page only" is a one-page cap; the others are crawl scopes.
|
||||
payload.scope === 'page' ? 1 : undefined,
|
||||
payload.scope === 'page' ? undefined : payload.scope,
|
||||
),
|
||||
)
|
||||
toast.success('已加入抓取队列', {
|
||||
description: `${payload.url},索引完成后我们会通知你。`,
|
||||
})
|
||||
} else if (payload.type === 'sitemap') {
|
||||
throwIfError(await knowledgeService.addSitemap(organizationId, payload.url, linkedAgentId))
|
||||
toast.success('已加入抓取队列', {
|
||||
description: `${payload.url},正在从站点地图中发现页面。`,
|
||||
})
|
||||
} else if (payload.type === 'pdf') {
|
||||
await knowledgeService.uploadPdfFiles(payload.files, organizationId, linkedAgentId)
|
||||
toast.success('已加入抓取队列', {
|
||||
description: '文档正在解析和索引中。',
|
||||
})
|
||||
} else {
|
||||
await knowledgeService.addText(organizationId, payload.title, payload.content, linkedAgentId)
|
||||
toast.success('页面已添加', {
|
||||
description: `${payload.title} 已加入知识库。`,
|
||||
})
|
||||
}
|
||||
await Promise.all([fetchSources(), fetchQueue()])
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
// Surface add-source failures via toast only (the inline error banner is
|
||||
// reserved for the read/edit pane), so the user doesn't see it twice.
|
||||
const msg = err instanceof Error ? err.message : '添加来源失败'
|
||||
console.error('Failed to add source:', err)
|
||||
// Plan caps get the offer toast when one applies; other failures the plain one
|
||||
void showPlanLimitError('无法添加来源', msg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([fetchSources(), fetchQueue()])
|
||||
// Baseline the queue snapshot so the first poll can detect a crawl that
|
||||
// finishes before it is ever observed as active.
|
||||
lastQueueIds = queueIds()
|
||||
crawlingNames = activeCrawlNames()
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollTimer) return
|
||||
pollTimer = setInterval(async () => {
|
||||
await fetchQueue()
|
||||
const active = activeCrawlNames()
|
||||
const currentIds = queueIds()
|
||||
// Reconcile when a crawl is in flight OR the queue changed since the last
|
||||
// tick (an item was added or finished/removed) — the latter catches a
|
||||
// crawl that completed within a single poll window.
|
||||
if (active.size || setsDiffer(currentIds, lastQueueIds)) {
|
||||
await fetchSources(true)
|
||||
// Force-refresh the content of any expanded source that is (or just was)
|
||||
// crawling, so newly-extracted pages appear without a manual re-expand.
|
||||
const touched = new Set<string>([...active, ...crawlingNames])
|
||||
for (const source of sources.value) {
|
||||
if (source.expanded && touched.has(source.name)) {
|
||||
await loadSourceContent(source.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
crawlingNames = active
|
||||
lastQueueIds = currentIds
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
sources,
|
||||
queueItems,
|
||||
isLoading,
|
||||
error,
|
||||
query,
|
||||
selectedSourceId,
|
||||
selectedPageId,
|
||||
editing,
|
||||
isAddingPage,
|
||||
draftTitle,
|
||||
draftContent,
|
||||
draftUrl,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
linkPickerOpen,
|
||||
orgSources,
|
||||
isLoadingOrgSources,
|
||||
orgSourcesError,
|
||||
linkingIds,
|
||||
// computed
|
||||
selectedSource,
|
||||
selectedPage,
|
||||
filteredSources,
|
||||
linkedSourceIds,
|
||||
// helpers for the view
|
||||
sourceStatus,
|
||||
pageRows,
|
||||
// actions
|
||||
fetchSources,
|
||||
fetchQueue,
|
||||
refresh,
|
||||
toggleSource,
|
||||
selectPage,
|
||||
startEdit,
|
||||
startAddPage,
|
||||
cancelEdit,
|
||||
savePage,
|
||||
deletePage,
|
||||
deleteSource,
|
||||
submitSource,
|
||||
openLinkPicker,
|
||||
linkSource,
|
||||
unlinkSource,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { KnowledgeItem, KnowledgePage, QueueItem, KnowledgeContent } from '@/types/knowledge'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
|
||||
export function useKnowledgeManagement(agentId: string, organizationId: string) {
|
||||
// Knowledge list state
|
||||
const knowledgeItems = ref<KnowledgeItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalPages = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Queue state
|
||||
const queueItems = ref<QueueItem[]>([])
|
||||
const isLoadingQueue = ref(false)
|
||||
|
||||
// Content viewing/editing state
|
||||
const selectedKnowledge = ref<number | null>(null)
|
||||
const knowledgeContent = ref<KnowledgeContent | null>(null)
|
||||
const isLoadingContent = ref(false)
|
||||
const isEditingContent = ref(false)
|
||||
const editedContent = ref('')
|
||||
const isSavingContent = ref(false)
|
||||
const showContentModal = ref(false)
|
||||
|
||||
// Modal and upload state
|
||||
const showKnowledgeModal = ref(false)
|
||||
const activeTab = ref('pdf')
|
||||
const files = ref<File[]>([])
|
||||
const urls = ref<string[]>([])
|
||||
const newUrl = ref('')
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const successMessage = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Link state
|
||||
const showLinkModal = ref(false)
|
||||
const orgKnowledgeItems = ref<KnowledgeItem[]>([])
|
||||
const orgCurrentPage = ref(1)
|
||||
const orgTotalPages = ref(0)
|
||||
// Every source the org has indexed (all pages), for plan-cap checks.
|
||||
const orgTotalCount = ref(0)
|
||||
const isLoadingOrg = ref(false)
|
||||
|
||||
// Delete state
|
||||
const showDeleteConfirm = ref(false)
|
||||
const knowledgeToDelete = ref<number | null>(null)
|
||||
|
||||
// Add new state for URL form errors
|
||||
const urlFormError = ref<string | null>(null)
|
||||
|
||||
// Add upload error state
|
||||
const uploadError = ref<string | null>(null)
|
||||
|
||||
|
||||
|
||||
// Fetch knowledge data
|
||||
const fetchKnowledge = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
isLoadingOrg.value = true
|
||||
error.value = null
|
||||
|
||||
// Fetch agent knowledge, org knowledge, and queue items in parallel
|
||||
const [agentResponse, orgResponse, queueResponse] = await Promise.all([
|
||||
knowledgeService.getKnowledgeByAgent(agentId, currentPage.value, pageSize.value),
|
||||
knowledgeService.getKnowledgeByOrganization(
|
||||
organizationId,
|
||||
orgCurrentPage.value,
|
||||
pageSize.value,
|
||||
),
|
||||
fetchQueueItems(),
|
||||
])
|
||||
|
||||
// Default to empty: a partial response used to leave these undefined,
|
||||
// and every later .some()/.length on them threw.
|
||||
knowledgeItems.value = agentResponse?.knowledge || []
|
||||
totalPages.value = agentResponse?.pagination?.total_pages || 0
|
||||
|
||||
// Update org knowledge
|
||||
orgKnowledgeItems.value = orgResponse?.knowledge || []
|
||||
orgTotalPages.value = orgResponse?.pagination?.total_pages || 0
|
||||
orgTotalCount.value = orgResponse?.pagination?.total_count || 0
|
||||
} catch (err) {
|
||||
error.value = '加载知识库来源失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingOrg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch queue items
|
||||
const fetchQueueItems = async () => {
|
||||
try {
|
||||
isLoadingQueue.value = true
|
||||
const response = await knowledgeService.getAgentQueueItems(agentId)
|
||||
queueItems.value = response.queue_items || []
|
||||
return response
|
||||
} catch (err) {
|
||||
console.error('Failed to load queue items:', err)
|
||||
queueItems.value = []
|
||||
return { queue_items: [] }
|
||||
} finally {
|
||||
isLoadingQueue.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteQueueItem = async (queueId: number) => {
|
||||
try {
|
||||
await knowledgeService.deleteQueueItem(queueId)
|
||||
await fetchQueueItems() // Refresh queue
|
||||
} catch (err) {
|
||||
console.error('Failed to delete queue item:', err)
|
||||
error.value = '删除队列项失败'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Pagination handler
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchKnowledge()
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
const formatDate = (dateString: string | null): string => {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// Get earliest creation date
|
||||
const getFirstCreated = (pages: KnowledgePage[]): string | null => {
|
||||
return pages.reduce(
|
||||
(earliest, page) => {
|
||||
if (!page.created_at) return earliest
|
||||
if (!earliest) return page.created_at
|
||||
return page.created_at < earliest ? page.created_at : earliest
|
||||
},
|
||||
null as string | null,
|
||||
)
|
||||
}
|
||||
|
||||
// People type "docs.company.com", not "https://docs.company.com". Without a
|
||||
// scheme new URL() throws and they get "Please enter a valid URL" for an
|
||||
// address that is perfectly fine, so default the scheme before validating.
|
||||
const withScheme = (url: string): string => {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
|
||||
// Leading slashes would produce https:////host — drop them first.
|
||||
return `https://${trimmed.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
// URL validation
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// new URL() accepts things like "mailto:x" and "foo:bar"; a crawlable
|
||||
// source has to be http(s). Deliberately not requiring a dot in the host:
|
||||
// self-hosted installs index intranet names like https://wiki.
|
||||
return /^https?:$/.test(parsed.protocol) && !!parsed.hostname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// File handling methods
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
files.value = Array.from(input.files)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves true when the batch was accepted (or there was nothing to send),
|
||||
// so a caller like the onboarding wizard can stop instead of moving on.
|
||||
const handleFileUpload = async (): Promise<boolean> => {
|
||||
if (!files.value.length) return true
|
||||
|
||||
try {
|
||||
isUploading.value = true
|
||||
uploadError.value = null // Clear previous errors
|
||||
|
||||
const response = await knowledgeService.uploadPdfFiles(
|
||||
files.value,
|
||||
organizationId,
|
||||
agentId,
|
||||
(progress) => {
|
||||
uploadProgress.value = progress
|
||||
},
|
||||
)
|
||||
if (response.message) {
|
||||
successMessage.value = response.message
|
||||
await fetchKnowledge() // Refresh knowledge list
|
||||
}
|
||||
files.value = []
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('Upload failed:', error)
|
||||
uploadError.value = error.message || '上传文件失败'
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// URL handling methods
|
||||
const handleUrlAdd = () => {
|
||||
if (!newUrl.value) return
|
||||
|
||||
// Start clean so the outcome of this attempt is the only thing on screen,
|
||||
// and so callers can read urlFormError to tell whether it succeeded.
|
||||
urlFormError.value = null
|
||||
|
||||
// Clean the URL, defaulting the scheme when the user left it off
|
||||
const cleanUrl = withScheme(newUrl.value)
|
||||
|
||||
// Basic URL validation
|
||||
if (!isValidUrl(cleanUrl)) {
|
||||
urlFormError.value = '请输入有效的 URL'
|
||||
return
|
||||
}
|
||||
|
||||
// Check if URL already exists in knowledgeItems or orgKnowledgeItems
|
||||
const urlExistsInAgent = knowledgeItems.value.some((item) => item.name === cleanUrl)
|
||||
const urlExistsInOrg = orgKnowledgeItems.value.some((item) => item.name === cleanUrl)
|
||||
|
||||
if (urlExistsInAgent || urlExistsInOrg) {
|
||||
urlFormError.value = '该 URL 已存在于你的知识库中'
|
||||
newUrl.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check if URL is already in the current batch
|
||||
if (urls.value.includes(cleanUrl)) {
|
||||
urlFormError.value = '该 URL 已添加到当前批次'
|
||||
newUrl.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Add URL to batch
|
||||
urls.value.push(cleanUrl)
|
||||
newUrl.value = ''
|
||||
urlFormError.value = null
|
||||
}
|
||||
|
||||
const removeUrl = (index: number) => {
|
||||
urls.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Same contract as handleFileUpload: true when the batch was accepted.
|
||||
const handleUrlUpload = async (): Promise<boolean> => {
|
||||
if (!urls.value.length) return true
|
||||
|
||||
try {
|
||||
isUploading.value = true
|
||||
uploadError.value = null // Clear previous errors
|
||||
|
||||
const response = await knowledgeService.addUrls(
|
||||
organizationId,
|
||||
urls.value,
|
||||
agentId,
|
||||
(progress) => {
|
||||
uploadProgress.value = progress
|
||||
},
|
||||
)
|
||||
// A duplicate comes back as a 200 with `error` and nothing queued; keep
|
||||
// the batch staged so the user sees which URL to drop.
|
||||
if (response.error) {
|
||||
uploadError.value = response.error
|
||||
return false
|
||||
}
|
||||
if (response.message) {
|
||||
successMessage.value = response.message
|
||||
await fetchKnowledge() // Refresh knowledge list
|
||||
}
|
||||
urls.value = []
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('URL upload failed:', error)
|
||||
uploadError.value = error.message || '上传 URL 失败'
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOrgKnowledge = async () => {
|
||||
try {
|
||||
isLoadingOrg.value = true
|
||||
const response = await knowledgeService.getKnowledgeByOrganization(
|
||||
organizationId,
|
||||
orgCurrentPage.value,
|
||||
pageSize.value,
|
||||
)
|
||||
orgKnowledgeItems.value = response.knowledge
|
||||
orgTotalPages.value = response.pagination.total_pages
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoadingOrg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleOrgPageChange = (page: number) => {
|
||||
orgCurrentPage.value = page
|
||||
fetchOrgKnowledge()
|
||||
}
|
||||
|
||||
const linkKnowledge = async (knowledgeId: number) => {
|
||||
try {
|
||||
await knowledgeService.linkToAgent(knowledgeId, agentId)
|
||||
await fetchKnowledge() // Refresh agent knowledge
|
||||
} catch (error) {
|
||||
console.error('Error linking knowledge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const unlinkKnowledge = async (knowledgeId: number) => {
|
||||
try {
|
||||
await knowledgeService.unlinkFromAgent(knowledgeId, agentId)
|
||||
await fetchKnowledge() // Refresh agent knowledge
|
||||
} catch (error) {
|
||||
console.error('Error unlinking knowledge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete methods
|
||||
const confirmDelete = (knowledgeId: number) => {
|
||||
knowledgeToDelete.value = knowledgeId
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!knowledgeToDelete.value) return
|
||||
|
||||
try {
|
||||
await knowledgeService.deleteKnowledge(knowledgeToDelete.value)
|
||||
await fetchKnowledge() // Refresh the list
|
||||
showDeleteConfirm.value = false
|
||||
knowledgeToDelete.value = null
|
||||
} catch (err) {
|
||||
console.error('Error deleting knowledge:', err)
|
||||
error.value = '删除知识来源失败'
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false
|
||||
knowledgeToDelete.value = null
|
||||
}
|
||||
|
||||
// Content management methods
|
||||
const viewKnowledgeContent = async (knowledgeId: number) => {
|
||||
try {
|
||||
selectedKnowledge.value = knowledgeId
|
||||
isLoadingContent.value = true
|
||||
showContentModal.value = true
|
||||
|
||||
const response = await knowledgeService.getKnowledgeContent(knowledgeId)
|
||||
knowledgeContent.value = response
|
||||
|
||||
// Do not combine chunks - keep them separate for individual editing
|
||||
editedContent.value = ''
|
||||
} catch (err) {
|
||||
console.error('Error loading knowledge content:', err)
|
||||
error.value = '加载知识内容失败'
|
||||
} finally {
|
||||
isLoadingContent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const enableContentEditing = () => {
|
||||
isEditingContent.value = true
|
||||
}
|
||||
|
||||
const cancelContentEditing = () => {
|
||||
isEditingContent.value = false
|
||||
}
|
||||
|
||||
const saveChunkContent = async (chunkId: string, content: string) => {
|
||||
if (!selectedKnowledge.value) return
|
||||
|
||||
try {
|
||||
isSavingContent.value = true
|
||||
await knowledgeService.updateChunkContent(selectedKnowledge.value, chunkId, content)
|
||||
|
||||
successMessage.value = '内容块已更新'
|
||||
|
||||
// Reload the content to show updated chunk
|
||||
await viewKnowledgeContent(selectedKnowledge.value)
|
||||
} catch (err: any) {
|
||||
console.error('Error saving chunk content:', err)
|
||||
error.value = err.message || '保存内容块失败'
|
||||
} finally {
|
||||
isSavingContent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeContentModal = () => {
|
||||
showContentModal.value = false
|
||||
selectedKnowledge.value = null
|
||||
knowledgeContent.value = null
|
||||
isEditingContent.value = false
|
||||
editedContent.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
knowledgeItems,
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
showKnowledgeModal,
|
||||
activeTab,
|
||||
files,
|
||||
urls,
|
||||
newUrl,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
successMessage,
|
||||
fileInput,
|
||||
showLinkModal,
|
||||
orgKnowledgeItems,
|
||||
orgCurrentPage,
|
||||
orgTotalPages,
|
||||
orgTotalCount,
|
||||
isLoadingOrg,
|
||||
showDeleteConfirm,
|
||||
knowledgeToDelete,
|
||||
urlFormError,
|
||||
uploadError,
|
||||
queueItems,
|
||||
isLoadingQueue,
|
||||
selectedKnowledge,
|
||||
knowledgeContent,
|
||||
isLoadingContent,
|
||||
isEditingContent,
|
||||
editedContent,
|
||||
isSavingContent,
|
||||
showContentModal,
|
||||
|
||||
// Methods
|
||||
fetchKnowledge,
|
||||
fetchQueueItems,
|
||||
deleteQueueItem,
|
||||
handlePageChange,
|
||||
formatDate,
|
||||
|
||||
getFirstCreated,
|
||||
isValidUrl,
|
||||
withScheme,
|
||||
triggerFileInput,
|
||||
handleFileSelect,
|
||||
handleFileUpload,
|
||||
handleUrlAdd,
|
||||
removeUrl,
|
||||
handleUrlUpload,
|
||||
fetchOrgKnowledge,
|
||||
handleOrgPageChange,
|
||||
linkKnowledge,
|
||||
unlinkKnowledge,
|
||||
confirmDelete,
|
||||
handleDelete,
|
||||
cancelDelete,
|
||||
viewKnowledgeContent,
|
||||
enableContentEditing,
|
||||
cancelContentEditing,
|
||||
saveChunkContent,
|
||||
closeContentModal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { subscriptionStorage } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* Keeps a staged batch of knowledge sources within the plan's source cap.
|
||||
*
|
||||
* The server rejects a whole batch that would go over the cap, so staging one
|
||||
* source too many loses all of them. On a one-source plan the onboarding wizard
|
||||
* pre-stages the signup domain; a second add then swaps the staged source
|
||||
* rather than queuing a batch that is bound to fail.
|
||||
*
|
||||
* `inUseCount` is every source already counting against the cap (indexed or
|
||||
* still crawling), mirroring the server's count.
|
||||
*
|
||||
* A cap of 0 means the plan sets none we know of (self-hosted, or limits not
|
||||
* loaded yet) and the server stays the only check.
|
||||
*/
|
||||
export function useStagedSourceCap(
|
||||
inUseCount: Ref<number>,
|
||||
urls: Ref<string[]>,
|
||||
files: Ref<File[]>,
|
||||
) {
|
||||
// Read on every call: the subscription can land in storage after mount.
|
||||
const cap = () => subscriptionStorage.getPlanLimits().maxKnowledgeSources
|
||||
const room = () => cap() - inUseCount.value
|
||||
const capMessage = (detail: string) =>
|
||||
`你的套餐包含 ${cap()} 个知识库来源${detail}`
|
||||
|
||||
/**
|
||||
* Call right after staging `added` sources onto the end of `newest`. Older
|
||||
* staged sources (the other list first, then the front of `newest`) are
|
||||
* dropped until the batch fits the cap. If the new sources can't fit on
|
||||
* their own they are unstaged instead and the message to show is returned;
|
||||
* null means the batch fits.
|
||||
*/
|
||||
const fitToCap = (newest: 'urls' | 'files', added = 1): string | null => {
|
||||
if (cap() <= 0) return null
|
||||
const latest: Ref<unknown[]> = newest === 'urls' ? urls : files
|
||||
const other: Ref<unknown[]> = newest === 'urls' ? files : urls
|
||||
if (added > room()) {
|
||||
latest.value = latest.value.slice(0, -added)
|
||||
return room() > 0 ? capMessage(`,最多可选 ${room()} 个。`) : capMessage(',且已在用。')
|
||||
}
|
||||
let overflow = latest.value.length + other.value.length - room()
|
||||
const fromOther = Math.min(Math.max(overflow, 0), other.value.length)
|
||||
other.value = other.value.slice(fromOther)
|
||||
overflow -= fromOther
|
||||
if (overflow > 0) latest.value = latest.value.slice(overflow)
|
||||
return null
|
||||
}
|
||||
|
||||
return { fitToCap }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { KnowledgeContentChunk, KnowledgeSubPage } from '@/types/knowledge'
|
||||
|
||||
/** `<page>::<n>` — chunk ids written by app/knowledge/chunking.py. */
|
||||
const CHUNK_ID_SUFFIX = /::\d+$/
|
||||
/** `<page>_<n>` — chunk ids agno's PDF reader used to write. */
|
||||
const LEGACY_CHUNK_ID_SUFFIX = /_\d+$/
|
||||
|
||||
/**
|
||||
* Map a chunk id back to its page id by stripping the trailing chunk suffix:
|
||||
* `<page>::<n>` (app/knowledge/chunking.py) or the legacy `<page>_<n>` that
|
||||
* agno's PDF reader produced.
|
||||
*
|
||||
* Only a numeric suffix is stripped, so real underscores in a page name (e.g.
|
||||
* `getting_started`) are preserved. Mirrors the backend `PAGE_ID_EXPR`
|
||||
* (app/knowledge/page_editor.py) exactly, so the id derived here is the same
|
||||
* `page_id` the backend expects for edit/delete and the `subpage` value it
|
||||
* returns when listing sources — keep the two in sync.
|
||||
*/
|
||||
export function basePageId(id: string): string {
|
||||
const chunked = id.replace(CHUNK_ID_SUFFIX, '')
|
||||
if (chunked !== id) return chunked
|
||||
return id.replace(LEGACY_CHUNK_ID_SUFFIX, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes between a chunk and the one before it when a page is rebuilt.
|
||||
* `::n` chunks (app/knowledge/chunking.py) are verbatim slices of the page, so
|
||||
* nothing: inserting a break would change the text that gets saved back.
|
||||
* Legacy `_n` chunks were cut on whitespace that was dropped, so a paragraph
|
||||
* break is the closest reconstruction.
|
||||
*/
|
||||
function chunkJoiner(chunkId: string): string {
|
||||
return CHUNK_ID_SUFFIX.test(chunkId) ? '' : '\n\n'
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const trimmed = text.trim()
|
||||
return trimmed ? trimmed.split(/\s+/).length : 0
|
||||
}
|
||||
|
||||
function laterIso(a: string | null, b: string | null): string | null {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a > b ? a : b
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a source's content chunks into sub-pages. Chunks are concatenated in
|
||||
* their existing order (the backend returns them ordered by `created_at`), and
|
||||
* page-level url/title/updated_at are taken from the chunk metadata.
|
||||
*/
|
||||
export function groupChunksIntoPages(chunks: KnowledgeContentChunk[]): KnowledgeSubPage[] {
|
||||
const byPage = new Map<string, KnowledgeSubPage>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const pageId = basePageId(chunk.id)
|
||||
const meta = chunk.metadata || {}
|
||||
const content = chunk.content || ''
|
||||
|
||||
let page = byPage.get(pageId)
|
||||
if (!page) {
|
||||
page = {
|
||||
page_id: pageId,
|
||||
url: typeof meta.url === 'string' && meta.url ? meta.url : pageId,
|
||||
title: typeof meta.title === 'string' && meta.title ? meta.title : titleFromId(pageId),
|
||||
content: '',
|
||||
word_count: 0,
|
||||
chunk_count: 0,
|
||||
updated_at: chunk.created_at ?? null,
|
||||
chunk_ids: [],
|
||||
}
|
||||
byPage.set(pageId, page)
|
||||
order.push(pageId)
|
||||
}
|
||||
|
||||
page.content = page.content ? `${page.content}${chunkJoiner(chunk.id)}${content}` : content
|
||||
page.chunk_count += 1
|
||||
page.chunk_ids.push(chunk.id)
|
||||
page.updated_at = laterIso(page.updated_at, chunk.created_at ?? null)
|
||||
}
|
||||
|
||||
for (const pageId of order) {
|
||||
const page = byPage.get(pageId)!
|
||||
page.word_count = countWords(page.content)
|
||||
}
|
||||
|
||||
return order.map((pageId) => byPage.get(pageId)!)
|
||||
}
|
||||
|
||||
/** Human-ish title fallback: last non-empty URL/path segment of the page id. */
|
||||
export function titleFromId(pageId: string): string {
|
||||
const cleaned = pageId.replace(/[#?].*$/, '').replace(/\/+$/, '')
|
||||
const segment = cleaned.split('/').filter(Boolean).pop()
|
||||
return segment || pageId
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
|
||||
import { userService } from '@/services/user'
|
||||
|
||||
const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '')
|
||||
|
||||
const description =
|
||||
'这里汇集 AI 智能体学习的所有页面。你可以查看并编辑已提取的内容、添加自己的页面,' +
|
||||
'并决定哪些内容保留在知识库中。'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="knowledge-view">
|
||||
<KnowledgeExplorer
|
||||
v-if="organizationId"
|
||||
mode="org"
|
||||
variant="page"
|
||||
title="知识库"
|
||||
:description="description"
|
||||
:organization-id="organizationId"
|
||||
:show-plan-meters="true"
|
||||
/>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-view {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 60px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user