mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
866 lines
36 KiB
Vue
866 lines
36 KiB
Vue
<template>
|
|
<div class="raw-panel">
|
|
<!-- Upload + Add text row -->
|
|
<div class="upload-row">
|
|
<div
|
|
class="upload-zone"
|
|
:class="{ 'is-dragging': isDragging, 'is-uploading': uploadingFiles.length > 0 }"
|
|
@click="triggerFileInput"
|
|
@dragover.prevent
|
|
@dragenter.prevent="onDragEnter"
|
|
@dragleave.prevent="onDragLeave"
|
|
@drop.prevent="handleDrop"
|
|
>
|
|
<!-- Spinner while uploading -->
|
|
<svg v-if="uploadingFiles.length > 0" class="upload-spinner" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
</svg>
|
|
<!-- Arrow-up icon in drag-over state -->
|
|
<svg v-else-if="isDragging" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<polyline points="17 8 12 3 7 8"/>
|
|
<line x1="12" y1="3" x2="12" y2="21"/>
|
|
</svg>
|
|
<!-- Default upload icon -->
|
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="17 8 12 3 7 8"/>
|
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
<div class="upload-text">
|
|
<span class="upload-label">
|
|
<template v-if="uploadingFiles.length > 0">{{ t('wiki.uploading') }}</template>
|
|
<template v-else-if="isDragging">{{ t('wiki.dropToUpload') }}</template>
|
|
<template v-else>{{ t('wiki.dropFiles') }}</template>
|
|
</span>
|
|
<span class="upload-hint">.txt, .md, .pdf, .docx</span>
|
|
</div>
|
|
</div>
|
|
<input ref="fileInput" type="file" style="display:none" accept=".txt,.md,.pdf,.docx,.doc" multiple @change="handleFileSelect" />
|
|
<button class="btn-secondary add-text-btn" @click="showAddText = true">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
{{ t('wiki.addText') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Directory scan -->
|
|
<div class="dir-scan-row">
|
|
<div class="dir-input-wrap">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
<input
|
|
v-model="dirPath"
|
|
type="text"
|
|
class="dir-input"
|
|
:placeholder="t('wiki.dirPlaceholder')"
|
|
@keyup.enter="handleScanDir"
|
|
/>
|
|
</div>
|
|
<button class="btn-secondary" @click="handleScanDir" :disabled="scanning || !dirPath.trim()">
|
|
<svg v-if="!scanning" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
{{ scanning ? t('wiki.scanning') : t('wiki.scan') }}
|
|
</button>
|
|
</div>
|
|
<div v-if="scanResult" class="scan-result">
|
|
{{ t('wiki.scanResult', { scanned: scanResult.scanned, added: scanResult.added, skipped: scanResult.skipped }) }}
|
|
</div>
|
|
|
|
<!-- Raw materials list -->
|
|
<div class="raw-list">
|
|
<h4 class="raw-list-title">
|
|
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
|
|
</h4>
|
|
<div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint">
|
|
{{ t('wiki.noRawMaterials') }}
|
|
</div>
|
|
|
|
<!-- Optimistic uploading items shown at the top -->
|
|
<div
|
|
v-for="uf in uploadingFiles"
|
|
:key="uf.tempId"
|
|
class="raw-item raw-item--uploading"
|
|
>
|
|
<div class="raw-item-row">
|
|
<div class="raw-item-info">
|
|
<span class="raw-item-title">{{ uf.name }}</span>
|
|
</div>
|
|
<div class="raw-item-meta">
|
|
<span v-if="uf.status === 'error'" class="status-badge failed">{{ t('wiki.status.failed') }}</span>
|
|
<span v-else class="status-badge uploading">{{ t('wiki.status.uploading') }}</span>
|
|
<span
|
|
v-if="uf.status === 'error' && uf.errorMsg"
|
|
class="error-hint" :title="uf.errorMsg"
|
|
>{{ uf.errorMsg }}</span>
|
|
</div>
|
|
<div class="raw-item-actions">
|
|
<!-- Dismiss error item -->
|
|
<button
|
|
v-if="uf.status === 'error'"
|
|
class="btn-icon btn-icon-danger"
|
|
:title="t('common.delete')"
|
|
@click="removeUploadingFile(uf.tempId)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- HTTP upload progress bar -->
|
|
<div v-if="uf.status !== 'error'" class="raw-progress">
|
|
<div class="raw-progress-track">
|
|
<div
|
|
class="raw-progress-fill"
|
|
:class="{ indeterminate: uf.httpPct === 0 }"
|
|
:style="uf.httpPct > 0 ? { width: uf.httpPct + '%' } : {}"
|
|
></div>
|
|
</div>
|
|
<span class="raw-progress-label">
|
|
{{ uf.httpPct > 0 ? t('wiki.progress.uploading', { pct: uf.httpPct }) : t('wiki.progress.preparing') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-for="raw in store.rawMaterials"
|
|
:key="raw.id"
|
|
class="raw-item"
|
|
:class="{ 'raw-item--active': store.selectedRawId === raw.id }"
|
|
@click="toggleRawFilter(raw.id)"
|
|
>
|
|
<div class="raw-item-row">
|
|
<div class="raw-item-info">
|
|
<span class="raw-item-title">{{ raw.title }}</span>
|
|
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
|
</div>
|
|
<div class="raw-item-meta">
|
|
<span
|
|
class="status-badge"
|
|
:class="cancellingIds.has(raw.id) && raw.processingStatus === 'processing' ? 'cancelling' : raw.processingStatus"
|
|
>
|
|
{{ cancellingIds.has(raw.id) && raw.processingStatus === 'processing'
|
|
? t('wiki.status.cancelling')
|
|
: t(`wiki.status.${raw.processingStatus}`) }}
|
|
</span>
|
|
<span v-if="raw.pageCount != null && raw.pageCount > 0" class="page-count-chip">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
{{ raw.pageCount }}
|
|
</span>
|
|
<span
|
|
v-if="raw.processingStatus === 'cancelled'"
|
|
class="error-hint" :title="raw.errorMessage || ''"
|
|
>
|
|
{{ t('wiki.cancelledHint') }}
|
|
</span>
|
|
<span
|
|
v-else-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
|
|
class="error-hint" :title="raw.errorMessage"
|
|
>
|
|
{{ raw.errorMessage }}
|
|
</span>
|
|
</div>
|
|
<div class="raw-item-actions">
|
|
<button
|
|
v-if="raw.processingStatus === 'processing' && !cancellingIds.has(raw.id)"
|
|
class="btn-icon btn-icon-danger" :title="t('wiki.cancel')"
|
|
@click="cancelRaw(raw.id)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
<line x1="6" y1="18" x2="18" y2="6"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
v-else-if="raw.processingStatus === 'processing' && cancellingIds.has(raw.id)"
|
|
class="btn-icon btn-icon-cancelling" :title="t('wiki.cancelling')" disabled
|
|
>
|
|
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
v-else-if="raw.processingStatus === 'partial'"
|
|
class="btn-icon btn-icon-resume" :title="t('wiki.resume')"
|
|
@click="reprocess(raw.id)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
|
<polygon points="6 4 20 12 6 20 6 4"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed' || raw.processingStatus === 'cancelled'"
|
|
class="btn-icon" :title="t('wiki.reprocess')"
|
|
@click="reprocess(raw.id)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="23 4 23 10 17 10"/>
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
v-if="raw.processingStatus !== 'uploading'"
|
|
class="btn-icon" :title="t('wiki.download')"
|
|
@click="downloadRaw(raw)"
|
|
>
|
|
<el-icon :size="14"><Download /></el-icon>
|
|
</button>
|
|
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- RFC-033: Job stage bar — show when job has progressed past 'queued' or reached terminal -->
|
|
<JobStageBar
|
|
v-if="rawJobs[raw.id] && (rawJobs[raw.id].stage !== 'queued' || rawJobs[raw.id].status !== 'queued')"
|
|
:stage="rawJobs[raw.id].stage"
|
|
:status="rawJobs[raw.id].status"
|
|
:current-model="rawJobs[raw.id].currentModelName ?? (rawJobs[raw.id].currentModelId ? `Model #${rawJobs[raw.id].currentModelId}` : undefined)"
|
|
:is-fallback-active="rawJobs[raw.id].currentModelId != null && rawJobs[raw.id].currentModelId !== rawJobs[raw.id].primaryModelId"
|
|
:error-code="rawJobs[raw.id].errorCode ?? undefined"
|
|
:error-message="rawJobs[raw.id].errorMessage ?? undefined"
|
|
:done="rawJobs[raw.id].done ?? raw.progressDone"
|
|
:total="rawJobs[raw.id].total ?? raw.progressTotal"
|
|
:started-at="rawJobs[raw.id].startedAt ?? undefined"
|
|
@reprocess="reprocess(raw.id)"
|
|
@repair="handleLocalRepair(raw.id)"
|
|
/>
|
|
<!-- Original progress bar: shown during processing when job data is not active -->
|
|
<div v-else-if="raw.processingStatus === 'processing'" class="raw-progress">
|
|
<div class="raw-progress-track">
|
|
<div
|
|
class="raw-progress-fill"
|
|
:class="{ indeterminate: !raw.progressTotal }"
|
|
:style="raw.progressTotal
|
|
? { width: Math.min(100, Math.round((raw.progressDone / raw.progressTotal) * 100)) + '%' }
|
|
: {}"
|
|
></div>
|
|
</div>
|
|
<span class="raw-progress-label">
|
|
{{ raw.progressTotal
|
|
? `${raw.progressDone} / ${raw.progressTotal}`
|
|
: t('wiki.progress.preparing') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Process all button -->
|
|
<button
|
|
v-if="store.currentKB && store.rawMaterials.some(r => r.processingStatus === 'pending')"
|
|
class="btn-primary process-btn"
|
|
@click="processAll"
|
|
>
|
|
{{ t('wiki.processAll') }}
|
|
</button>
|
|
|
|
<!-- Add Text Modal -->
|
|
<div v-if="showAddText" class="modal-overlay">
|
|
<div class="modal-content">
|
|
<h3 class="modal-title">{{ t('wiki.addText') }}</h3>
|
|
<div class="form-group">
|
|
<label>{{ t('wiki.materialTitle') }}</label>
|
|
<input v-model="textTitle" type="text" class="form-input" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t('wiki.materialContent') }}</label>
|
|
<textarea v-model="textContent" class="form-input" rows="12" :placeholder="t('wiki.pasteContent')"></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-secondary" @click="showAddText = false">{{ t('common.cancel') }}</button>
|
|
<button class="btn-primary" @click="handleAddText" :disabled="!textTitle.trim() || !textContent.trim()">
|
|
{{ t('common.add') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { mcToast } from '@/composables/useMcToast'
|
|
import { Download } from '@element-plus/icons-vue'
|
|
import { useWikiStore } from '@/stores/useWikiStore'
|
|
import { wikiApi } from '@/api/index'
|
|
import JobStageBar from './JobStageBar.vue'
|
|
import type { WikiProcessingJob } from '@/composables/useWikiJobPoller'
|
|
|
|
const { t } = useI18n()
|
|
const store = useWikiStore()
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
// While raw materials are active, subscribe to the backend SSE progress stream.
|
|
// A slower polling fallback keeps the UI in sync if SSE reconnects or misses a
|
|
// terminal event. The database remains the source of truth.
|
|
let sse: EventSource | null = null
|
|
let fallbackTimer: number | null = null
|
|
let activeKbId: number | null = null
|
|
|
|
const hasProcessing = computed(() =>
|
|
store.rawMaterials.some(r => r.processingStatus === 'processing' || r.processingStatus === 'pending')
|
|
)
|
|
|
|
function applyProgressEvent(payload: any) {
|
|
if (!payload || payload.rawId == null) return
|
|
const raw = store.rawMaterials.find(r => r.id === payload.rawId)
|
|
if (!raw) return
|
|
if (typeof payload.done === 'number') raw.progressDone = payload.done
|
|
if (typeof payload.total === 'number') raw.progressTotal = payload.total
|
|
}
|
|
|
|
function openSse(kbId: number) {
|
|
closeSse()
|
|
activeKbId = kbId
|
|
// Vite proxies /api to the backend, so EventSource can use a relative URL.
|
|
const es = new EventSource(`/api/v1/wiki/knowledge-bases/${kbId}/progress`)
|
|
sse = es
|
|
|
|
es.addEventListener('raw.started', (ev: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(ev.data)
|
|
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
|
if (raw) {
|
|
raw.processingStatus = 'processing'
|
|
raw.progressDone = 0
|
|
raw.progressTotal = 0
|
|
}
|
|
} catch { /* ignore */ }
|
|
})
|
|
es.addEventListener('route.done', (ev: MessageEvent) => {
|
|
try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ }
|
|
})
|
|
es.addEventListener('chunk.done', (ev: MessageEvent) => {
|
|
try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ }
|
|
})
|
|
es.addEventListener('raw.completed', (ev: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(ev.data)
|
|
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
|
if (raw) {
|
|
raw.processingStatus = data.status === 'partial' ? 'partial' : 'completed'
|
|
if (typeof data.totalPages === 'number') {
|
|
raw.progressDone = data.totalPages
|
|
raw.progressTotal = data.totalPages
|
|
}
|
|
}
|
|
// Clear stale job entry so JobStageBar hides
|
|
delete rawJobs[data.rawId]
|
|
if (store.currentKB) void store.refreshCurrentKB()
|
|
} catch { /* ignore */ }
|
|
})
|
|
es.addEventListener('raw.failed', (ev: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(ev.data)
|
|
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
|
if (raw) raw.processingStatus = 'failed'
|
|
// Clear stale job entry
|
|
delete rawJobs[data.rawId]
|
|
if (store.currentKB) void store.refreshCurrentKB()
|
|
} catch { /* ignore */ }
|
|
})
|
|
es.onerror = () => {
|
|
// Browser EventSource auto-reconnects; just log
|
|
// console.debug('Wiki SSE error/reconnect', kbId)
|
|
}
|
|
}
|
|
|
|
function closeSse() {
|
|
if (sse) {
|
|
sse.close()
|
|
sse = null
|
|
}
|
|
activeKbId = null
|
|
}
|
|
|
|
watch(
|
|
() => [hasProcessing.value, store.currentKB?.id] as const,
|
|
([active, kbId]) => {
|
|
if (active && kbId != null) {
|
|
// SSE main channel
|
|
if (activeKbId !== kbId) openSse(kbId)
|
|
// 60s fallback polling
|
|
if (fallbackTimer == null) {
|
|
fallbackTimer = window.setInterval(() => {
|
|
if (store.currentKB) void store.refreshCurrentKB()
|
|
}, 60000)
|
|
}
|
|
} else {
|
|
closeSse()
|
|
if (fallbackTimer != null) {
|
|
clearInterval(fallbackTimer)
|
|
fallbackTimer = null
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
closeSse()
|
|
if (fallbackTimer != null) {
|
|
clearInterval(fallbackTimer)
|
|
fallbackTimer = null
|
|
}
|
|
})
|
|
|
|
// RFC-033: Job polling per raw material
|
|
const rawJobs = reactive<Record<number, WikiProcessingJob>>({})
|
|
let jobPoller: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'partial', 'cancelled'])
|
|
|
|
// Local optimistic state: rows the user has just clicked "cancel" on.
|
|
// Backend cancellation is observed at the next abort checkpoint (which can
|
|
// take 10+ seconds while a route-phase LLM call is in flight), so without
|
|
// this set the click looks unresponsive — the button stays the same and
|
|
// the badge keeps reading "处理中". Cleared as soon as the row's status
|
|
// transitions out of "processing" via the next fetchRawMaterials.
|
|
const cancellingIds = ref(new Set<number>())
|
|
|
|
async function pollJobs() {
|
|
if (!store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
const processingRaws = store.rawMaterials.filter(
|
|
r => r.processingStatus === 'processing' || r.processingStatus === 'pending'
|
|
)
|
|
let anyTerminal = false
|
|
for (const raw of processingRaws) {
|
|
try {
|
|
const res: any = await wikiApi.getWikiJobs(kbId, raw.id)
|
|
const list = res.data || res || []
|
|
if (list.length > 0) {
|
|
const job = list[0]
|
|
rawJobs[raw.id] = job
|
|
if (TERMINAL_STATUSES.has(job.status)) {
|
|
anyTerminal = true
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
// When any job reaches terminal, refresh wiki metadata, pages, and raw badges.
|
|
if (anyTerminal) {
|
|
await store.refreshCurrentKB()
|
|
}
|
|
// Continue polling while there are still processing/pending raws
|
|
const stillActive = store.rawMaterials.some(
|
|
r => r.processingStatus === 'processing' || r.processingStatus === 'pending'
|
|
)
|
|
if (stillActive) {
|
|
jobPoller = setTimeout(pollJobs, 3000)
|
|
}
|
|
}
|
|
|
|
watch(hasProcessing, (active) => {
|
|
if (active) pollJobs()
|
|
else if (jobPoller) { clearTimeout(jobPoller); jobPoller = null }
|
|
}, { immediate: true })
|
|
|
|
// Clear the optimistic "cancelling" flag for any row that has left the
|
|
// 'processing' state — the backend has written the terminal status, so
|
|
// the badge and action buttons can now reflect reality.
|
|
watch(() => store.rawMaterials, (rows) => {
|
|
if (cancellingIds.value.size === 0) return
|
|
const next = new Set(cancellingIds.value)
|
|
for (const r of rows) {
|
|
if (r.processingStatus !== 'processing' && next.has(r.id)) {
|
|
next.delete(r.id)
|
|
}
|
|
}
|
|
if (next.size !== cancellingIds.value.size) {
|
|
cancellingIds.value = next
|
|
}
|
|
}, { deep: true })
|
|
|
|
async function handleLocalRepair(rawId: number) {
|
|
if (!store.currentKB) return
|
|
// For local repair, we'd need a page slug. For now, reprocess the raw material.
|
|
await reprocess(rawId)
|
|
}
|
|
|
|
const showAddText = ref(false)
|
|
const textTitle = ref('')
|
|
const textContent = ref('')
|
|
const dirPath = ref(store.currentKB?.sourceDirectory || '')
|
|
const scanning = ref(false)
|
|
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
|
|
|
|
// ─── Drag-over state ──────────────────────────────────────────────────────────
|
|
// Use a counter to handle nested dragenter/dragleave without flickering.
|
|
const isDragging = ref(false)
|
|
let dragCounter = 0
|
|
|
|
function onDragEnter() {
|
|
dragCounter++
|
|
isDragging.value = true
|
|
}
|
|
|
|
function onDragLeave() {
|
|
dragCounter--
|
|
if (dragCounter <= 0) {
|
|
dragCounter = 0
|
|
isDragging.value = false
|
|
}
|
|
}
|
|
|
|
// ─── Optimistic upload items ──────────────────────────────────────────────────
|
|
interface UploadingFile {
|
|
tempId: string
|
|
name: string
|
|
httpPct: number
|
|
status: 'uploading' | 'error'
|
|
errorMsg?: string
|
|
}
|
|
|
|
const uploadingFiles = ref<UploadingFile[]>([])
|
|
let tempIdCounter = 0
|
|
|
|
function addUploadingFile(name: string): UploadingFile {
|
|
const item: UploadingFile = {
|
|
tempId: `upload-${++tempIdCounter}`,
|
|
name,
|
|
httpPct: 0,
|
|
status: 'uploading',
|
|
}
|
|
uploadingFiles.value.push(item)
|
|
return item
|
|
}
|
|
|
|
function removeUploadingFile(tempId: string) {
|
|
const idx = uploadingFiles.value.findIndex(f => f.tempId === tempId)
|
|
if (idx >= 0) uploadingFiles.value.splice(idx, 1)
|
|
}
|
|
|
|
// ─── Upload helpers ───────────────────────────────────────────────────────────
|
|
async function uploadFile(kbId: number, file: File) {
|
|
const item = addUploadingFile(file.name)
|
|
try {
|
|
await store.uploadRawFile(kbId, file, (pct) => {
|
|
item.httpPct = pct
|
|
})
|
|
// Success: real item was added to store.rawMaterials, remove the optimistic placeholder
|
|
removeUploadingFile(item.tempId)
|
|
} catch (err: any) {
|
|
item.status = 'error'
|
|
item.errorMsg = err?.response?.data?.message || err?.message || t('wiki.uploadFailed', { name: file.name })
|
|
mcToast.error(t('wiki.uploadFailed', { name: file.name }))
|
|
}
|
|
}
|
|
|
|
function triggerFileInput() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function handleFileSelect(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
if (!input.files || !store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
// Upload all files concurrently
|
|
await Promise.all(Array.from(input.files).map(f => uploadFile(kbId, f)))
|
|
input.value = ''
|
|
}
|
|
|
|
async function handleDrop(event: DragEvent) {
|
|
// Reset drag state
|
|
dragCounter = 0
|
|
isDragging.value = false
|
|
if (!event.dataTransfer?.files || !store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
|
|
}
|
|
|
|
async function handleAddText() {
|
|
if (!store.currentKB) return
|
|
await store.addRawText(store.currentKB.id, textTitle.value, textContent.value)
|
|
showAddText.value = false
|
|
textTitle.value = ''
|
|
textContent.value = ''
|
|
}
|
|
|
|
async function reprocess(rawId: number) {
|
|
if (!store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
await wikiApi.reprocessRaw(kbId, rawId)
|
|
// Immediately mark local state as processing so SSE connects and progress bar shows
|
|
const raw = store.rawMaterials.find(r => r.id === rawId)
|
|
if (raw) {
|
|
raw.processingStatus = 'processing'
|
|
raw.progressDone = 0
|
|
raw.progressTotal = 0
|
|
}
|
|
// Clear stale job entry
|
|
delete rawJobs[rawId]
|
|
await store.fetchRawMaterials(kbId)
|
|
// Delayed re-fetch to catch final status if processing finishes before SSE connects
|
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
|
|
}
|
|
|
|
async function deleteRaw(rawId: number) {
|
|
if (!store.currentKB) return
|
|
await wikiApi.deleteRaw(store.currentKB.id, rawId)
|
|
await store.fetchRawMaterials(store.currentKB.id)
|
|
}
|
|
|
|
async function cancelRaw(rawId: number) {
|
|
if (!store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
// Optimistic flag — drives the spinner button and "正在取消…" badge text
|
|
// so the click is visibly registered even if the pipeline is currently
|
|
// mid-LLM call and won't reach its next abort checkpoint for several seconds.
|
|
cancellingIds.value.add(rawId)
|
|
try {
|
|
await wikiApi.cancelRaw(kbId, rawId)
|
|
} catch (e) {
|
|
// Roll back the optimistic state if the call itself failed (auth /
|
|
// network error). Without this the button would stay stuck in the
|
|
// cancelling state forever.
|
|
cancellingIds.value.delete(rawId)
|
|
throw e
|
|
}
|
|
// Re-fetch periodically so the row's status flips from 'processing' to
|
|
// 'cancelled' as soon as the pipeline observes the flag and writes its
|
|
// terminal status — at which point the watch below clears the flag.
|
|
await store.fetchRawMaterials(kbId)
|
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
|
|
}
|
|
|
|
async function downloadRaw(raw: { id: number; title?: string }) {
|
|
if (!store.currentKB) return
|
|
try {
|
|
// The http interceptor returns the raw body for non-R-shaped responses,
|
|
// so this resolves directly to the Blob (no .data unwrap needed).
|
|
const blob = (await wikiApi.downloadRaw(store.currentKB.id, raw.id)) as unknown as Blob
|
|
let filename = raw.title && raw.title.trim().length > 0 ? raw.title : `raw-${raw.id}`
|
|
if (!filename.includes('.')) filename += '.txt'
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
// Revoke on next tick — some browsers cancel the in-flight download if we
|
|
// revoke synchronously before the click handler returns.
|
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
mcToast.error(`${t('wiki.downloadFailed')}: ${msg}`)
|
|
}
|
|
}
|
|
|
|
async function processAll() {
|
|
if (!store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
await wikiApi.processKB(kbId)
|
|
// Mark all pending materials as processing so SSE connects
|
|
store.rawMaterials
|
|
.filter(r => r.processingStatus === 'pending')
|
|
.forEach(r => { r.processingStatus = 'processing'; r.progressDone = 0; r.progressTotal = 0 })
|
|
await store.fetchRawMaterials(kbId)
|
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
|
}
|
|
|
|
function toggleRawFilter(rawId: number) {
|
|
if (!store.currentKB) return
|
|
const kbId = store.currentKB.id
|
|
if (store.selectedRawId === rawId) {
|
|
store.clearRawFilter(kbId)
|
|
} else {
|
|
store.filterPagesByRaw(kbId, rawId)
|
|
}
|
|
}
|
|
|
|
async function handleScanDir() {
|
|
if (!store.currentKB || !dirPath.value.trim()) return
|
|
scanning.value = true
|
|
scanResult.value = null
|
|
try {
|
|
// Save directory path first
|
|
await wikiApi.setSourceDirectory(store.currentKB.id, dirPath.value.trim())
|
|
// Trigger scan
|
|
const result = await store.scanDirectory(store.currentKB.id)
|
|
scanResult.value = result
|
|
} catch (e: any) {
|
|
console.error('Scan failed', e)
|
|
} finally {
|
|
scanning.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.raw-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
|
.btn-primary:hover { background: var(--mc-primary-hover); }
|
|
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
|
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
|
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
|
|
|
/* Directory scan */
|
|
.dir-scan-row { display: flex; gap: 10px; align-items: center; }
|
|
.dir-input-wrap { flex: 1; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-elevated); color: var(--mc-text-tertiary); }
|
|
.dir-input-wrap:focus-within { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
|
.dir-input { flex: 1; border: none; background: transparent; font-size: 13px; color: var(--mc-text-primary); outline: none; }
|
|
.dir-input::placeholder { color: var(--mc-text-tertiary); }
|
|
.scan-result { font-size: 12px; color: var(--mc-text-secondary); padding: 8px 10px; background: rgba(90,138,90,0.1); border-radius: 10px; }
|
|
|
|
/* Upload row: zone + add text side by side */
|
|
.upload-row { display: flex; gap: 12px; align-items: stretch; }
|
|
.upload-zone {
|
|
flex: 1;
|
|
border: 1px dashed var(--mc-border);
|
|
border-radius: 16px;
|
|
padding: 18px 20px;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: var(--mc-text-tertiary);
|
|
}
|
|
.upload-zone:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
|
|
.upload-zone.is-dragging {
|
|
border-color: var(--mc-primary);
|
|
background: var(--mc-primary-bg);
|
|
box-shadow: 0 0 0 3px rgba(217,119,87,0.15);
|
|
color: var(--mc-primary);
|
|
}
|
|
.upload-zone.is-uploading {
|
|
border-color: var(--mc-primary);
|
|
background: var(--mc-primary-bg);
|
|
cursor: default;
|
|
pointer-events: none;
|
|
}
|
|
.upload-zone svg { flex-shrink: 0; }
|
|
.upload-text { display: flex; flex-direction: column; gap: 2px; }
|
|
.upload-label { font-size: 14px; color: var(--mc-text-secondary); }
|
|
.upload-hint { font-size: 12px; color: var(--mc-text-tertiary); }
|
|
.add-text-btn { flex-shrink: 0; }
|
|
|
|
/* Spinner animation for uploading state */
|
|
.upload-spinner {
|
|
flex-shrink: 0;
|
|
animation: spin 1s linear infinite;
|
|
color: var(--mc-primary);
|
|
}
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Raw list */
|
|
.raw-list { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
|
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
|
|
.empty-hint { text-align: center; padding: 24px 0; font-size: 14px; color: var(--mc-text-tertiary); }
|
|
|
|
.raw-item { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; cursor: pointer; }
|
|
.raw-item:hover { border-color: var(--mc-border); transform: translateY(-1px); }
|
|
.raw-item--active { border-color: var(--mc-primary) !important; background: var(--mc-primary-bg) !important; transform: translateY(-1px); }
|
|
.raw-item--uploading { cursor: default; opacity: 0.85; }
|
|
.raw-item--uploading:hover { transform: none; border-color: var(--mc-border-light); }
|
|
.raw-item-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
|
|
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
|
.raw-item-title { font-weight: 500; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.raw-item-type { font-size: 10px; padding: 2px 6px; background: var(--mc-bg-sunken); border-radius: 4px; text-transform: uppercase; color: var(--mc-text-tertiary); letter-spacing: 0.02em; }
|
|
.raw-item-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
.raw-item-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
|
.error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.page-count-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 500; color: var(--mc-text-secondary); background: var(--mc-bg-sunken); border-radius: 9999px; padding: 2px 7px; }
|
|
|
|
/* Two-phase digest progress bar (RFC-012 M2 v2 UI) */
|
|
.raw-progress { display: flex; align-items: center; gap: 10px; padding-top: 2px; }
|
|
.raw-progress-track { flex: 1; height: 4px; background: var(--mc-bg-sunken); border-radius: 9999px; overflow: hidden; position: relative; }
|
|
.raw-progress-fill { height: 100%; background: var(--mc-primary); border-radius: 9999px; transition: width 0.3s ease; }
|
|
.raw-progress-fill.indeterminate {
|
|
width: 30%;
|
|
position: absolute;
|
|
left: 0;
|
|
animation: raw-progress-slide 1.6s ease-in-out infinite;
|
|
}
|
|
@keyframes raw-progress-slide {
|
|
0% { transform: translateX(-100%); }
|
|
50% { transform: translateX(170%); }
|
|
100% { transform: translateX(330%); }
|
|
}
|
|
.raw-progress-label { font-size: 11px; color: var(--mc-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; min-width: 56px; text-align: right; }
|
|
|
|
/* Icon button */
|
|
.btn-icon { width: 30px; height: 30px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 8px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
|
|
.btn-icon:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); border-color: var(--mc-border); }
|
|
.btn-icon-danger:hover { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
|
|
.btn-icon-resume { color: var(--mc-primary); border-color: var(--mc-primary); background: var(--mc-primary-bg); }
|
|
.btn-icon-resume:hover { background: var(--mc-primary); color: #fff; border-color: var(--mc-primary); }
|
|
|
|
/* Status badges */
|
|
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
|
|
.status-badge.pending { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
|
.status-badge.uploading { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
|
.status-badge.processing { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
|
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
|
|
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }
|
|
.status-badge.failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
|
.status-badge.cancelled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
|
.status-badge.cancelling { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
|
|
|
.btn-icon.btn-icon-cancelling { cursor: default; opacity: 0.7; }
|
|
.btn-icon.btn-icon-cancelling .spinner { animation: rmp-spin 0.9s linear infinite; }
|
|
@keyframes rmp-spin { to { transform: rotate(360deg); } }
|
|
|
|
/* Process button */
|
|
.process-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
|
|
|
/* Modal */
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
|
.modal-content { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 640px; padding: 24px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
|
.modal-title { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 16px; }
|
|
|
|
/* Form */
|
|
.form-group { margin-bottom: 16px; }
|
|
.form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: var(--mc-text-secondary); }
|
|
.form-input { width: 100%; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; font-family: inherit; }
|
|
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
|
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
|
|
|
@media (max-width: 768px) {
|
|
.upload-row,
|
|
.dir-scan-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.add-text-btn,
|
|
.dir-scan-row > .btn-secondary,
|
|
.process-btn {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.raw-item {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.raw-item-meta,
|
|
.raw-item-actions {
|
|
width: 100%;
|
|
justify-content: space-between;
|
|
}
|
|
}
|
|
</style>
|