mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-19 22:50:22 +08:00
feat(extension): 侧边栏接入思维导图(markmap)与 RAG 问答(P3 + P4)
任务完成(status === SUCCESS)后,侧边栏顶部出现 Markdown / 思维导图 / AI 问答 三个 tab: - 思维导图:用 markmap-lib + markmap-view 把 markdown 转成可缩放思维导图 - AI 问答: · 进入 tab 自动调 /api/chat/index 触发后台索引,按 2s 间隔轮询 /api/chat/status · 索引完成后开放输入框;调 /api/chat/ask 时带上 settings 里的默认 provider/model + 完整 history · Cmd/Ctrl + Enter 发送 · 回答用 markdown-it 渲染,user 气泡用纯文本 - 切换任务时清空对话历史并重新检查索引 logic/api.ts 补 indexChatTask / getChatStatus / askChat 三件套。 依赖新增:markmap-lib, markmap-view(生产依赖) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
156
BillNote_extension/src/components/ChatPanel.vue
Normal file
156
BillNote_extension/src/components/ChatPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { askChat, getChatStatus, indexChatTask, type ChatMessage } from '~/logic/api'
|
||||
import { settings } from '~/logic/storage'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const sending = ref(false)
|
||||
const indexState = ref<'idle' | 'indexing' | 'indexed' | 'failed' | 'unknown'>('unknown')
|
||||
const error = ref('')
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const ready = computed(() => indexState.value === 'indexed')
|
||||
const canSend = computed(() => ready.value && draft.value.trim().length > 0 && !sending.value && !!settings.value.providerId && !!settings.value.modelName)
|
||||
|
||||
async function pollIndex() {
|
||||
try {
|
||||
const res = await getChatStatus(props.taskId)
|
||||
indexState.value = res.status
|
||||
if (res.status === 'indexing')
|
||||
pollTimer = setTimeout(pollIndex, 2000)
|
||||
}
|
||||
catch (e) {
|
||||
error.value = (e as Error).message
|
||||
indexState.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIndexed() {
|
||||
error.value = ''
|
||||
indexState.value = 'unknown'
|
||||
try {
|
||||
const status = await getChatStatus(props.taskId)
|
||||
indexState.value = status.status
|
||||
if (status.indexed)
|
||||
return
|
||||
indexState.value = 'indexing'
|
||||
await indexChatTask(props.taskId)
|
||||
pollIndex()
|
||||
}
|
||||
catch (e) {
|
||||
error.value = (e as Error).message
|
||||
indexState.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value)
|
||||
return
|
||||
const question = draft.value.trim()
|
||||
draft.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
await scrollDown()
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await askChat({
|
||||
task_id: props.taskId,
|
||||
question,
|
||||
history: messages.value.slice(0, -1),
|
||||
provider_id: settings.value.providerId,
|
||||
model_name: settings.value.modelName,
|
||||
}) as { answer?: string, content?: string, message?: string } | string
|
||||
const reply = typeof res === 'string'
|
||||
? res
|
||||
: (res.answer ?? res.content ?? res.message ?? JSON.stringify(res))
|
||||
messages.value.push({ role: 'assistant', content: reply })
|
||||
await scrollDown()
|
||||
}
|
||||
catch (e) {
|
||||
messages.value.push({ role: 'assistant', content: `❌ 调用失败:${(e as Error).message}` })
|
||||
}
|
||||
finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollDown() {
|
||||
await nextTick()
|
||||
if (scrollEl.value)
|
||||
scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||
}
|
||||
|
||||
watch(() => props.taskId, () => {
|
||||
messages.value = []
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
ensureIndexed()
|
||||
}, { immediate: false })
|
||||
|
||||
onMounted(ensureIndexed)
|
||||
onUnmounted(() => {
|
||||
if (pollTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-white">
|
||||
<header class="px-2 py-1 text-xs border-b flex items-center gap-2">
|
||||
<span v-if="indexState === 'indexed'" class="tag bg-green-100 text-green-700">已索引</span>
|
||||
<span v-else-if="indexState === 'indexing'" class="tag bg-yellow-100 text-yellow-700">索引中…</span>
|
||||
<span v-else-if="indexState === 'failed'" class="tag bg-red-100 text-red-700">索引失败</span>
|
||||
<span v-else class="tag bg-gray-100 text-gray-500">检查中…</span>
|
||||
<button class="ml-auto text-xs text-gray-500 hover:text-gray-800" @click="ensureIndexed">
|
||||
重新索引
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="text-xs text-red-600 px-2 py-1">{{ error }}</div>
|
||||
|
||||
<div ref="scrollEl" class="flex-1 overflow-auto px-2 py-2 flex flex-col gap-2">
|
||||
<div v-if="messages.length === 0 && ready" class="text-xs text-gray-400 italic">
|
||||
基于这条笔记的全文 + 视频元信息提问,例如:「这个视频的核心论点是什么?」
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in messages"
|
||||
:key="i"
|
||||
class="text-sm"
|
||||
>
|
||||
<div
|
||||
class="inline-block max-w-[90%] px-3 py-2 rounded"
|
||||
:class="m.role === 'user'
|
||||
? 'bg-blue-600 text-white ml-auto block'
|
||||
: 'bg-gray-100 text-gray-800'"
|
||||
>
|
||||
<div v-if="m.role === 'assistant'" v-html="md.render(m.content)" class="prose prose-sm max-w-none" />
|
||||
<div v-else class="whitespace-pre-wrap break-words">{{ m.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sending" class="text-xs text-gray-500 italic">思考中…</div>
|
||||
</div>
|
||||
|
||||
<footer class="border-t p-2 flex gap-2">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
class="input flex-1 resize-none"
|
||||
rows="2"
|
||||
:placeholder="ready ? '问点什么…(Cmd/Ctrl + Enter 发送)' : '索引完成后才能问答'"
|
||||
:disabled="!ready"
|
||||
@keydown.enter.exact.meta.prevent="send"
|
||||
@keydown.enter.exact.ctrl.prevent="send"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="!canSend" @click="send">
|
||||
{{ sending ? '…' : '发送' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
32
BillNote_extension/src/components/MindMap.vue
Normal file
32
BillNote_extension/src/components/MindMap.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Transformer } from 'markmap-lib'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { absolutizeMarkdownImages } from '~/logic/api'
|
||||
|
||||
const props = defineProps<{ markdown: string }>()
|
||||
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
let mm: Markmap | null = null
|
||||
const transformer = new Transformer()
|
||||
|
||||
function render() {
|
||||
if (!svgRef.value)
|
||||
return
|
||||
const md = absolutizeMarkdownImages(props.markdown || '')
|
||||
const { root } = transformer.transform(md)
|
||||
if (!mm)
|
||||
mm = Markmap.create(svgRef.value, undefined, root)
|
||||
else
|
||||
mm.setData(root).then(() => mm?.fit())
|
||||
}
|
||||
|
||||
onMounted(render)
|
||||
watch(() => props.markdown, render)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full bg-white rounded border overflow-hidden">
|
||||
<svg ref="svgRef" class="w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -132,6 +132,36 @@ export async function downloadTranscriberModel(modelSize: WhisperModelSize, tran
|
||||
})
|
||||
}
|
||||
|
||||
// ---- RAG Chat ----
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function indexChatTask(taskId: string): Promise<void> {
|
||||
await request('/api/chat/index', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getChatStatus(taskId: string): Promise<{ status: 'idle' | 'indexing' | 'indexed' | 'failed', indexed: boolean }> {
|
||||
return request(`/api/chat/status?task_id=${encodeURIComponent(taskId)}`)
|
||||
}
|
||||
|
||||
export async function askChat(payload: {
|
||||
task_id: string
|
||||
question: string
|
||||
history: ChatMessage[]
|
||||
provider_id: string
|
||||
model_name: string
|
||||
}): Promise<unknown> {
|
||||
return request('/api/chat/ask', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Monitor ----
|
||||
export async function getDeployStatus(): Promise<DeployStatus> {
|
||||
return request<DeployStatus>('/api/deploy_status')
|
||||
|
||||
@@ -4,9 +4,12 @@ import { getTaskStatus } from '~/logic/api'
|
||||
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||
import type { TaskRecord } from '~/logic/types'
|
||||
|
||||
type ViewMode = 'markdown' | 'mindmap' | 'chat'
|
||||
|
||||
const activeTaskId = ref<string>('')
|
||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||
const errorMsg = ref('')
|
||||
const viewMode = ref<ViewMode>('markdown')
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -84,12 +87,41 @@ onUnmounted(() => {
|
||||
{{ activeTask.videoUrl }}
|
||||
</div>
|
||||
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||
<div class="flex-1 overflow-auto">
|
||||
|
||||
<div v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown" class="flex gap-1 text-xs">
|
||||
<button
|
||||
class="px-2 py-1 rounded"
|
||||
:class="viewMode === 'markdown' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
@click="viewMode = 'markdown'"
|
||||
>Markdown</button>
|
||||
<button
|
||||
class="px-2 py-1 rounded"
|
||||
:class="viewMode === 'mindmap' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
@click="viewMode = 'mindmap'"
|
||||
>思维导图</button>
|
||||
<button
|
||||
class="px-2 py-1 rounded"
|
||||
:class="viewMode === 'chat' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
@click="viewMode = 'chat'"
|
||||
>AI 问答</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<MarkdownView
|
||||
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
|
||||
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown && viewMode === 'markdown'"
|
||||
:markdown="activeTask.result.markdown"
|
||||
:title="activeTask.result.audio_meta?.title"
|
||||
/>
|
||||
<MindMap
|
||||
v-else-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown && viewMode === 'mindmap'"
|
||||
:markdown="activeTask.result.markdown"
|
||||
class="h-full"
|
||||
/>
|
||||
<ChatPanel
|
||||
v-else-if="activeTask.status === 'SUCCESS' && viewMode === 'chat'"
|
||||
:task-id="activeTask.taskId"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user