mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-07-09 22:41:52 +08:00
feat(extension): P2 视频页悬浮按钮 + 右键菜单 + cookie 直通;P3 侧边栏首版
- contentScripts: 仅在支持的视频平台(B 站 / YouTube / 抖音 / 快手)注入悬浮 BiliNote 按钮,点击通过 webext-bridge 发 'bilinote-start' 给 background - background: 处理 bilinote-start 与右键菜单点击;调 /api/generate_note;写 chrome.storage;自动打开侧边栏。logic/storage 是 Vue 反应式版本,service worker 不能 import,因此把常量抽到 logic/constants.ts - contextMenus: onInstalled 时注册"用 BiliNote 总结此视频",限定 4 个支持平台域名 - 浏览器 Cookie 同步:options 页加按钮,按平台读 chrome.cookies.getAll,序列化为 'name=value; ...' 后 POST 给后端 /api/update_downloader_cookie。chrome.cookies + contextMenus 权限补到 manifest - 侧边栏(P3 首版):从 storage 读最近任务并轮询,复用 TaskProgress + MarkdownView。markmap 思维导图与 RAG 问答推到后续 - 修 P1 endpoint 拼错的 bug:/api/get_models_by_provider 实际是 /api/model_enable,404 来自这里 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
import { onMessage } from 'webext-bridge/background'
|
import { onMessage } from 'webext-bridge/background'
|
||||||
|
import type { Settings, TaskRecord } from '~/logic/types'
|
||||||
|
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||||
|
import { detectPlatform } from '~/logic/platform'
|
||||||
|
|
||||||
// only on dev mode
|
// only on dev mode
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
@@ -8,16 +11,162 @@ if (import.meta.hot) {
|
|||||||
import('./contentScriptHMR')
|
import('./contentScriptHMR')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工具栏图标点击 → 打开 popup(默认行为,无需配置)
|
// ---------- 直接操作 chrome.storage(service worker 里别用 Vue 反应式)----------
|
||||||
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
|
|
||||||
// 此处不开启 openPanelOnActionClick,否则会绕过 default_popup
|
|
||||||
|
|
||||||
browser.runtime.onInstalled.addListener((): void => {
|
async function readSettings(): Promise<Settings> {
|
||||||
// eslint-disable-next-line no-console
|
const obj = await browser.storage.local.get(SETTINGS_KEY)
|
||||||
console.log('BiliNote extension installed')
|
const raw = obj[SETTINGS_KEY] as string | undefined
|
||||||
|
if (!raw)
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTasks(): Promise<TaskRecord[]> {
|
||||||
|
const obj = await browser.storage.local.get(TASKS_KEY)
|
||||||
|
const raw = obj[TASKS_KEY] as string | undefined
|
||||||
|
if (!raw)
|
||||||
|
return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as TaskRecord[]
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTasks(tasks: TaskRecord[]) {
|
||||||
|
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTask(record: TaskRecord) {
|
||||||
|
const tasks = await readTasks()
|
||||||
|
const idx = tasks.findIndex(t => t.taskId === record.taskId)
|
||||||
|
if (idx >= 0)
|
||||||
|
tasks.splice(idx, 1, { ...tasks[idx], ...record })
|
||||||
|
else
|
||||||
|
tasks.unshift(record)
|
||||||
|
await writeTasks(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 启动任务 ----------
|
||||||
|
|
||||||
|
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||||
|
const platform = detectPlatform(url)
|
||||||
|
if (!platform)
|
||||||
|
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||||
|
|
||||||
|
const settings = await readSettings()
|
||||||
|
if (!settings.providerId || !settings.modelName)
|
||||||
|
return { ok: false, error: '请先在设置页选择供应商与模型' }
|
||||||
|
|
||||||
|
const backend = settings.backendUrl.replace(/\/$/, '')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${backend}/api/generate_note`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
video_url: url,
|
||||||
|
platform,
|
||||||
|
quality: settings.quality,
|
||||||
|
provider_id: settings.providerId,
|
||||||
|
model_name: settings.modelName,
|
||||||
|
screenshot: settings.screenshot,
|
||||||
|
link: settings.link,
|
||||||
|
style: settings.style || undefined,
|
||||||
|
format: [
|
||||||
|
...(settings.screenshot ? ['screenshot'] : []),
|
||||||
|
...(settings.link ? ['link'] : []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok)
|
||||||
|
return { ok: false, error: `HTTP ${res.status}` }
|
||||||
|
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
|
||||||
|
if (body.code !== 0)
|
||||||
|
return { ok: false, error: body.msg }
|
||||||
|
|
||||||
|
await upsertTask({
|
||||||
|
taskId: body.data.task_id,
|
||||||
|
videoUrl: url,
|
||||||
|
platform,
|
||||||
|
status: 'PENDING',
|
||||||
|
message: '已提交',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
return { ok: true, taskId: body.data.task_id }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidePanelInTab(tabId?: number) {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
|
||||||
|
// @ts-expect-error see above
|
||||||
|
await chrome.sidePanel.open({ tabId })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn('打开侧边栏失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 消息桥 ----------
|
||||||
|
|
||||||
|
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||||
|
const result = await startTask(data.url)
|
||||||
|
// 成功就把侧边栏拉起来给用户看进度
|
||||||
|
if (result.ok)
|
||||||
|
await openSidePanelInTab(sender?.tabId)
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
|
// ---------- 安装时事件 ----------
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log('BiliNote extension installed')
|
||||||
|
|
||||||
|
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
|
||||||
|
try {
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: 'bilinote-summarize-page',
|
||||||
|
title: '用 BiliNote 总结此视频',
|
||||||
|
contexts: ['page', 'link', 'video'],
|
||||||
|
documentUrlPatterns: [
|
||||||
|
'*://*.bilibili.com/*',
|
||||||
|
'*://*.youtube.com/*',
|
||||||
|
'*://youtu.be/*',
|
||||||
|
'*://*.douyin.com/*',
|
||||||
|
'*://*.kuaishou.com/*',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn('注册右键菜单失败:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
||||||
|
if (info.menuItemId !== 'bilinote-summarize-page')
|
||||||
|
return
|
||||||
|
const url = info.linkUrl || tab?.url
|
||||||
|
if (!url)
|
||||||
|
return
|
||||||
|
const result = await startTask(url)
|
||||||
|
if (result.ok)
|
||||||
|
await openSidePanelInTab(tab?.id)
|
||||||
|
else
|
||||||
|
console.warn('右键启动失败:', result.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// content script 占位握手 —— 未来可扩展为查询当前任务等
|
||||||
onMessage('get-current-tab', async () => {
|
onMessage('get-current-tab', async () => {
|
||||||
try {
|
try {
|
||||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||||
@@ -27,4 +176,3 @@ onMessage('get-current-tab', async () => {
|
|||||||
return { title: undefined, url: undefined }
|
return { title: undefined, url: undefined }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
import { onMessage } from 'webext-bridge/content-script'
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './views/App.vue'
|
import App from './views/App.vue'
|
||||||
import { setupApp } from '~/logic/common-setup'
|
import { setupApp } from '~/logic/common-setup'
|
||||||
|
import { detectPlatform } from '~/logic/platform'
|
||||||
|
|
||||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
|
||||||
(() => {
|
(() => {
|
||||||
console.info('[vitesse-webext] Hello world from content script')
|
if (!detectPlatform(window.location.href))
|
||||||
|
return
|
||||||
|
|
||||||
// communication example: send previous tab title from background page
|
|
||||||
onMessage('tab-prev', ({ data }) => {
|
|
||||||
console.log(`[vitesse-webext] Navigate from page "${data.title}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// mount component to context window
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
container.id = __NAME__
|
container.id = __NAME__
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
|
|||||||
@@ -1,9 +1,57 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// P2 计划:在视频页注入悬浮按钮 → 一键调起 BiliNote 任务。
|
|
||||||
// MVP 阶段无注入;保留组件外壳以便编译与未来扩展。
|
|
||||||
import 'uno.css'
|
import 'uno.css'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { sendMessage } from 'webext-bridge/content-script'
|
||||||
|
import { detectPlatform, PLATFORM_LABELS } from '~/logic/platform'
|
||||||
|
|
||||||
|
const platform = detectPlatform(window.location.href)
|
||||||
|
const busy = ref(false)
|
||||||
|
const toast = ref<{ kind: 'ok' | 'err', text: string } | null>(null)
|
||||||
|
|
||||||
|
const label = computed(() => platform ? `用 BiliNote 总结这个${PLATFORM_LABELS[platform]}视频` : '')
|
||||||
|
|
||||||
|
async function trigger() {
|
||||||
|
if (!platform || busy.value)
|
||||||
|
return
|
||||||
|
busy.value = true
|
||||||
|
toast.value = null
|
||||||
|
try {
|
||||||
|
const res = await sendMessage('bilinote-start', {
|
||||||
|
url: window.location.href,
|
||||||
|
platform,
|
||||||
|
}, 'background')
|
||||||
|
const ok = res && (res as any).ok
|
||||||
|
toast.value = ok
|
||||||
|
? { kind: 'ok', text: '已开始生成笔记,可在侧边栏 / popup 查看进度' }
|
||||||
|
: { kind: 'err', text: (res as any)?.error || '提交失败,请打开设置检查后端与供应商' }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.value = { kind: 'err', text: (e as Error).message }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
busy.value = false
|
||||||
|
setTimeout(() => { toast.value = null }, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- intentionally empty in P1 MVP -->
|
<div v-if="platform" class="bilinote-fab fixed bottom-24 right-6 z-[2147483647] flex flex-col items-end gap-2 font-sans select-none">
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="text-xs px-3 py-2 rounded shadow max-w-[260px]"
|
||||||
|
:class="toast.kind === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'"
|
||||||
|
>
|
||||||
|
{{ toast.text }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-full shadow-lg cursor-pointer border-none text-white text-sm font-medium bg-pink-600 hover:bg-pink-700 disabled:bg-pink-300"
|
||||||
|
:disabled="busy"
|
||||||
|
:title="label"
|
||||||
|
@click="trigger"
|
||||||
|
>
|
||||||
|
<span class="text-base">📝</span>
|
||||||
|
<span>{{ busy ? '提交中…' : 'BiliNote' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ export async function getProviders(): Promise<Provider[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
|
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
|
||||||
return request<Model[]>(`/api/get_models_by_provider/${providerId}`)
|
return request<Model[]>(`/api/model_enable/${providerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
|
||||||
|
await request('/api/update_downloader_cookie', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ platform, cookie }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
||||||
|
|||||||
18
BillNote_extension/src/logic/constants.ts
Normal file
18
BillNote_extension/src/logic/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Settings } from './types'
|
||||||
|
|
||||||
|
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
backendUrl: DEFAULT_BACKEND_URL,
|
||||||
|
providerId: '',
|
||||||
|
modelName: '',
|
||||||
|
quality: 'medium',
|
||||||
|
screenshot: false,
|
||||||
|
link: false,
|
||||||
|
style: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_TASKS = 30
|
||||||
|
|
||||||
|
export const SETTINGS_KEY = 'bilinote-settings'
|
||||||
|
export const TASKS_KEY = 'bilinote-tasks'
|
||||||
38
BillNote_extension/src/logic/cookies.ts
Normal file
38
BillNote_extension/src/logic/cookies.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { setDownloaderCookie } from './api'
|
||||||
|
import type { Platform } from './types'
|
||||||
|
|
||||||
|
// 后端期望的 cookie 字符串格式:name=value; name=value; ...
|
||||||
|
// 见 backend/app/downloaders/bilibili_downloader.py 的 split("; ")
|
||||||
|
const COOKIE_DOMAINS: Record<Exclude<Platform, 'local'>, string> = {
|
||||||
|
bilibili: '.bilibili.com',
|
||||||
|
youtube: '.youtube.com',
|
||||||
|
douyin: '.douyin.com',
|
||||||
|
kuaishou: '.kuaishou.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_COOKIE_PLATFORMS: Array<Exclude<Platform, 'local'>> = [
|
||||||
|
'bilibili',
|
||||||
|
'douyin',
|
||||||
|
'kuaishou',
|
||||||
|
'youtube',
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function readBrowserCookies(platform: Exclude<Platform, 'local'>): Promise<string> {
|
||||||
|
const domain = COOKIE_DOMAINS[platform]
|
||||||
|
const list = await browser.cookies.getAll({ domain })
|
||||||
|
return list.map(c => `${c.name}=${c.value}`).join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncCookieToBackend(platform: Exclude<Platform, 'local'>): Promise<{ ok: boolean, count: number, error?: string }> {
|
||||||
|
try {
|
||||||
|
const cookieStr = await readBrowserCookies(platform)
|
||||||
|
if (!cookieStr)
|
||||||
|
return { ok: false, count: 0, error: '当前浏览器没有该域名的 cookie,先在浏览器内登录目标站点' }
|
||||||
|
const count = cookieStr.split('; ').length
|
||||||
|
await setDownloaderCookie(platform, cookieStr)
|
||||||
|
return { ok: true, count }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, count: 0, error: (e as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,22 @@
|
|||||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
|
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
|
||||||
import type { Settings, TaskRecord } from './types'
|
import type { Settings, TaskRecord } from './types'
|
||||||
|
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from './constants'
|
||||||
|
|
||||||
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
export { DEFAULT_BACKEND_URL, DEFAULT_SETTINGS, MAX_TASKS } from './constants'
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
// 全局共享设置(popup / options / sidepanel 三个 Vue 上下文都读这一份)
|
||||||
backendUrl: DEFAULT_BACKEND_URL,
|
// 注意:background service worker 不要 import 这个文件,改用 chrome.storage 直读
|
||||||
providerId: '',
|
|
||||||
modelName: '',
|
|
||||||
quality: 'medium',
|
|
||||||
screenshot: false,
|
|
||||||
link: false,
|
|
||||||
style: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全局共享设置(popup / options / sidepanel / background 都读这一份)
|
|
||||||
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
|
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
|
||||||
'bilinote-settings',
|
SETTINGS_KEY,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
{ mergeDefaults: true },
|
{ mergeDefaults: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 历史任务列表,最近的在前
|
|
||||||
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
|
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
|
||||||
'bilinote-tasks',
|
TASKS_KEY,
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const MAX_TASKS = 30
|
|
||||||
|
|
||||||
export function upsertTask(record: TaskRecord) {
|
export function upsertTask(record: TaskRecord) {
|
||||||
const list = tasks.value ?? []
|
const list = tasks.value ?? []
|
||||||
const idx = list.findIndex(t => t.taskId === record.taskId)
|
const idx = list.findIndex(t => t.taskId === record.taskId)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export async function getManifest() {
|
|||||||
'storage',
|
'storage',
|
||||||
'activeTab',
|
'activeTab',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
|
'contextMenus',
|
||||||
|
'cookies',
|
||||||
],
|
],
|
||||||
host_permissions: ['*://*/*'],
|
host_permissions: ['*://*/*'],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { getModelsByProvider, getProviders, ping } from '~/logic/api'
|
import { getModelsByProvider, getProviders, ping } from '~/logic/api'
|
||||||
import { settings, settingsReady } from '~/logic/storage'
|
import { settings, settingsReady } from '~/logic/storage'
|
||||||
import type { Model, Provider } from '~/logic/types'
|
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
|
||||||
|
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||||
|
import type { Model, Platform, Provider } from '~/logic/types'
|
||||||
|
|
||||||
const providers = ref<Provider[]>([])
|
const providers = ref<Provider[]>([])
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
@@ -49,6 +51,19 @@ async function testConnection() {
|
|||||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
|
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookieStatus = ref<Record<string, { kind: 'ok' | 'err' | 'idle', text: string }>>({})
|
||||||
|
const cookieBusy = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
async function syncCookie(platform: Exclude<Platform, 'local'>) {
|
||||||
|
cookieBusy.value[platform] = true
|
||||||
|
cookieStatus.value[platform] = { kind: 'idle', text: '同步中…' }
|
||||||
|
const res = await syncCookieToBackend(platform)
|
||||||
|
cookieStatus.value[platform] = res.ok
|
||||||
|
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
|
||||||
|
: { kind: 'err', text: res.error || '同步失败' }
|
||||||
|
cookieBusy.value[platform] = false
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => settings.value?.providerId, (id) => {
|
watch(() => settings.value?.providerId, (id) => {
|
||||||
if (id)
|
if (id)
|
||||||
refreshModels(id)
|
refreshModels(id)
|
||||||
@@ -146,6 +161,36 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||||
|
<h2 class="font-semibold">浏览器 Cookie 同步</h2>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
从当前浏览器读取你已登录站点的 cookie,并写入后端 (POST /api/update_downloader_cookie)。
|
||||||
|
Bilibili / Douyin / Kuaishou 这类需要登录态的下载尤其需要这一步。
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
|
||||||
|
:key="p"
|
||||||
|
class="flex items-center justify-between gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="w-20">{{ PLATFORM_LABELS[p] }}</span>
|
||||||
|
<button class="btn-secondary" :disabled="cookieBusy[p]" @click="syncCookie(p)">
|
||||||
|
{{ cookieBusy[p] ? '同步中…' : '同步 Cookie' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="flex-1 text-xs"
|
||||||
|
:class="{
|
||||||
|
'text-green-700': cookieStatus[p]?.kind === 'ok',
|
||||||
|
'text-red-600': cookieStatus[p]?.kind === 'err',
|
||||||
|
'text-gray-500': cookieStatus[p]?.kind === 'idle',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ cookieStatus[p]?.text || '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
所有设置自动保存。不在桌面端管理供应商/模型?请在 BiliNote web 端 (http://localhost:3015) 完成。
|
所有设置自动保存。不在桌面端管理供应商/模型?请在 BiliNote web 端 (http://localhost:3015) 完成。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,18 +1,114 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
function openOptionsPage() {
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { getTaskStatus } from '~/logic/api'
|
||||||
|
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||||
|
import type { TaskRecord } from '~/logic/types'
|
||||||
|
|
||||||
|
const activeTaskId = ref<string>('')
|
||||||
|
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function poll(taskId: string) {
|
||||||
|
try {
|
||||||
|
const res = await getTaskStatus(taskId)
|
||||||
|
const cur = tasks.value?.find(t => t.taskId === taskId)
|
||||||
|
if (cur) {
|
||||||
|
upsertTask({
|
||||||
|
...cur,
|
||||||
|
status: res.status,
|
||||||
|
message: res.message,
|
||||||
|
result: res.result ?? cur.result,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id: string) {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
activeTaskId.value = id
|
||||||
|
const t = tasks.value?.find(x => x.taskId === id)
|
||||||
|
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||||
|
poll(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOptions() {
|
||||||
browser.runtime.openOptionsPage()
|
browser.runtime.openOptionsPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([settingsReady, tasksReady])
|
||||||
|
// 默认选中最近的任务(无论是否完成)
|
||||||
|
const latest = tasks.value?.[0]
|
||||||
|
if (latest) {
|
||||||
|
activeTaskId.value = latest.taskId
|
||||||
|
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
|
||||||
|
poll(latest.taskId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer)
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="w-full h-full p-4 flex flex-col gap-3 text-sm text-gray-700">
|
<main class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
|
||||||
<h1 class="text-base font-semibold">BiliNote 侧边栏</h1>
|
<header class="flex items-center justify-between px-3 py-2 border-b">
|
||||||
<p class="text-gray-500">
|
<div class="font-semibold">BiliNote 侧边栏</div>
|
||||||
P3 计划:在这里展示任务进度、思维导图与 RAG 问答。
|
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||||
当前 MVP 仅启用工具栏 popup。
|
</header>
|
||||||
</p>
|
|
||||||
<button class="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded w-fit" @click="openOptionsPage">
|
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-2 break-words bg-red-50">
|
||||||
打开设置
|
{{ errorMsg }}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
||||||
|
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="flex-1 flex flex-col gap-3 p-3 overflow-hidden">
|
||||||
|
<div class="text-xs text-gray-500 truncate" :title="activeTask.videoUrl">
|
||||||
|
{{ activeTask.videoUrl }}
|
||||||
|
</div>
|
||||||
|
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<MarkdownView
|
||||||
|
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
|
||||||
|
:markdown="activeTask.result.markdown"
|
||||||
|
:title="activeTask.result.audio_meta?.title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details v-if="(tasks?.length ?? 0) > 1" class="text-xs border-t px-3 py-2">
|
||||||
|
<summary class="cursor-pointer text-gray-500">
|
||||||
|
历史任务({{ tasks!.length }})
|
||||||
|
</summary>
|
||||||
|
<ul class="mt-1 flex flex-col gap-1 max-h-40 overflow-auto">
|
||||||
|
<li
|
||||||
|
v-for="t in tasks"
|
||||||
|
:key="t.taskId"
|
||||||
|
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
|
||||||
|
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
||||||
|
@click="selectTask(t.taskId)"
|
||||||
|
>
|
||||||
|
<span class="truncate flex-1" :title="t.videoUrl">{{ t.result?.audio_meta?.title || t.videoUrl }}</span>
|
||||||
|
<span class="text-gray-500">{{ t.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user