Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ NOTE_OUTPUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
DATA_DIR=data

# 自托管访问鉴权(默认关闭)
# 公网部署建议开启。开启后 Web 端会显示访问密码登录页,浏览器插件需要在设置页登录。
BILINOTE_AUTH_ENABLED=false
# BILINOTE_AUTH_PASSWORD=please-change-me
# 可选:固定 token 签名密钥;不填时会从访问密码派生,改密码会让旧登录失效。
# BILINOTE_AUTH_SECRET=
# 登录有效期(天)
BILINOTE_AUTH_TOKEN_EXPIRE_DAYS=30

# FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径)
FFMPEG_BIN_PATH=

Expand Down
5 changes: 4 additions & 1 deletion BillNote_extension/src/background/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ async function startTask(url: string, title?: string): Promise<{ ok: boolean, ta
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(settings.authToken ? { Authorization: `Bearer ${settings.authToken}` } : {}),
},
body: JSON.stringify({
video_url: url,
platform,
Expand Down
86 changes: 79 additions & 7 deletions BillNote_extension/src/logic/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type {
Provider,
ProviderCreatePayload,
ProviderUpdatePayload,
ServerNote,
TaskStatusResponse,
TranscriberConfig,
TranscriberModelsStatus,
TranscriberType,
WhisperModelSize,
} from './types'
import { DEFAULT_BACKEND_URL } from './constants'
import { settings } from './storage'

interface ApiEnvelope<T> {
Expand All @@ -20,12 +22,27 @@ interface ApiEnvelope<T> {
}

function backendUrl(): string {
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
const raw = (settings.value?.backendUrl || DEFAULT_BACKEND_URL).trim()
let url = raw || DEFAULT_BACKEND_URL

// Allow users to type shorthand values in the extension options:
// 3015 -> http://localhost:3015
// localhost:3015 -> http://localhost:3015
// http://host:3015/api -> http://host:3015
if (/^\d+$/.test(url))
url = `http://localhost:${url}`
else if (!/^https?:\/\//i.test(url))
url = `http://${url}`

url = url.replace(/\/+$/, '')
if (url.endsWith('/api'))
url = url.slice(0, -4)
return url
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${backendUrl()}${path}`, {
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(init?.headers || {}) },
...init,
})
if (!res.ok)
Expand All @@ -41,6 +58,35 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return body as T
}

function authHeaders(): Record<string, string> {
const token = settings.value?.authToken
return token ? { Authorization: `Bearer ${token}` } : {}
}

function withAccessToken(url: string): string {
const token = settings.value?.authToken
if (!token)
return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}access_token=${encodeURIComponent(token)}`
}

export async function getAuthStatus(): Promise<{ enabled: boolean, authenticated: boolean }> {
return request('/api/auth/status')
}

export async function login(password: string): Promise<void> {
const data = await request<{ token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
settings.value.authToken = data.token || ''
}

export async function listNotes(): Promise<ServerNote[]> {
return request('/api/notes')
}

export async function getProviders(): Promise<Provider[]> {
return request<Provider[]>('/api/get_all_providers')
}
Expand Down Expand Up @@ -189,7 +235,9 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse>
// 成功:{code:0, data:{status, message, task_id, result?}}
// 任务失败:{code:500, msg:'xxx', data:null}
// 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`, {
headers: authHeaders(),
})
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null }
Expand All @@ -200,7 +248,7 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse>

export async function ping(): Promise<boolean> {
try {
await getProviders()
await getAuthStatus()
return true
}
catch {
Expand All @@ -211,7 +259,10 @@ export async function ping(): Promise<boolean> {
// markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址
export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
return md.replace(
/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g,
(_, alt, path) => `![${alt}](${withAccessToken(`${base}${path}`)})`,
)
}

// backend 用 note_helper 在笔记开头插一行 '> 来源链接:URL'。侧边栏顶部已经有原片链接卡片,
Expand All @@ -227,9 +278,30 @@ export function resolveImageUrl(url: string | undefined | null): string {
return ''
const base = backendUrl()
if (url.startsWith('/'))
return `${base}${url}`
return withAccessToken(`${base}${url}`)
// B 站封面、抖音封面等会做 referer 校验;走后端代理
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
return withAccessToken(`${base}/api/image_proxy?url=${encodeURIComponent(url)}`)
return url
}

export function serverNoteToTask(note: ServerNote): import('./types').TaskRecord {
const formData = note.form_data || {}
return {
taskId: note.task_id,
videoUrl: formData.video_url || '',
platform: (formData.platform || note.audio_meta?.platform || 'bilibili') as import('./types').Platform,
status: note.status,
message: note.message || '',
createdAt: new Date(note.created_at || Date.now()).getTime(),
updatedAt: new Date(note.updated_at || note.created_at || Date.now()).getTime(),
result: note.markdown
? {
markdown: note.markdown,
transcript: note.transcript,
audio_meta: note.audio_meta,
}
: undefined,
title: note.audio_meta?.title,
}
}
3 changes: 2 additions & 1 deletion BillNote_extension/src/logic/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Settings } from './types'

export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
export const DEFAULT_BACKEND_URL = 'http://localhost:3015'

export const DEFAULT_SETTINGS: Settings = {
backendUrl: DEFAULT_BACKEND_URL,
authToken: '',
providerId: '',
modelName: '',
quality: 'medium',
Expand Down
14 changes: 14 additions & 0 deletions BillNote_extension/src/logic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ export interface TaskRecord {
title?: string
}

export interface ServerNote {
id: string
task_id: string
status: TaskStatus
message?: string
created_at: string
updated_at: string
markdown: string
transcript?: unknown
audio_meta?: NoteResult['audio_meta']
form_data?: Partial<GenerateRequest>
}

// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
export type NoteStyle =
| 'minimal' | 'detailed' | 'academic' | 'tutorial'
Expand Down Expand Up @@ -113,6 +126,7 @@ export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [

export interface Settings {
backendUrl: string
authToken: string
providerId: string
modelName: string
quality: Quality
Expand Down
90 changes: 82 additions & 8 deletions BillNote_extension/src/options/pages/General.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getProviders, ping } from '~/logic/api'
import { getAuthStatus, getProviders, login } from '~/logic/api'
import { settings, settingsReady } from '~/logic/storage'
import { getModelsByProvider } from '~/logic/api'
import { NOTE_FORMATS, NOTE_STYLES, type Model, type NoteFormat, type Provider } from '~/logic/types'
Expand All @@ -17,6 +17,9 @@ const providers = ref<Provider[]>([])
const models = ref<Model[]>([])
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
const loading = ref(false)
const authPassword = ref('')
const authStatus = ref<{ enabled: boolean, authenticated: boolean } | null>(null)
const authMsg = ref('')

async function refresh() {
loading.value = true
Expand All @@ -28,7 +31,13 @@ async function refresh() {
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
}
catch (e) {
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
const msg = (e as Error).message
status.value = {
kind: 'err',
text: msg.includes('401') || msg.includes('未登录')
? '后端已开启鉴权,请先在上方输入访问密码登录'
: `加载失败:${msg}`,
}
providers.value = []
models.value = []
}
Expand All @@ -52,10 +61,45 @@ async function refreshModels(providerId: string) {

async function testConnection() {
status.value = { kind: 'idle', text: '正在测试…' }
const ok = await ping()
status.value = ok
? { kind: 'ok', text: '后端连通 ✓' }
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS' }
try {
authStatus.value = await getAuthStatus()
status.value = authStatus.value.enabled && !authStatus.value.authenticated
? { kind: 'ok', text: '后端连通 ✓,但已开启鉴权,请先登录' }
: { kind: 'ok', text: '后端连通 ✓' }
authMsg.value = authStatus.value.enabled
? (authStatus.value.authenticated ? '鉴权已通过 ✓' : '后端已开启鉴权,请登录')
: '后端未开启鉴权'
}
catch (e) {
status.value = { kind: 'err', text: `无法连接后端:${(e as Error).message}` }
}
}

async function refreshAuthStatus() {
authMsg.value = ''
try {
authStatus.value = await getAuthStatus()
authMsg.value = authStatus.value.enabled
? (authStatus.value.authenticated ? '鉴权已通过 ✓' : '后端已开启鉴权,请登录')
: '后端未开启鉴权'
}
catch (e) {
authStatus.value = null
authMsg.value = `鉴权状态获取失败:${(e as Error).message}`
}
}

async function doLogin() {
authMsg.value = '正在登录…'
try {
await login(authPassword.value)
authPassword.value = ''
await refreshAuthStatus()
await refresh()
}
catch (e) {
authMsg.value = `登录失败:${(e as Error).message}`
}
}

watch(() => settings.value?.providerId, (id) => {
Expand All @@ -65,6 +109,7 @@ watch(() => settings.value?.providerId, (id) => {

onMounted(async () => {
await settingsReady
await refreshAuthStatus()
if (settings.value.backendUrl)
await refresh()
})
Expand All @@ -77,7 +122,7 @@ onMounted(async () => {
<section class="section-card">
<h2 class="font-semibold">后端地址</h2>
<div class="flex gap-2">
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:8483">
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:3015">
<button class="btn-secondary" @click="testConnection">测试连通</button>
<button class="btn-secondary" :disabled="loading" @click="refresh">
{{ loading ? '加载中…' : '刷新' }}
Expand All @@ -95,8 +140,37 @@ onMounted(async () => {
{{ status.text }}
</div>
<p class="text-xs text-gray-500">
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端
Docker/自托管默认填 http://localhost:3015;本地直连后端开发环境可填 http://localhost:8483。
</p>
</section>

<section class="section-card">
<h2 class="font-semibold">访问鉴权</h2>
<p class="text-xs text-gray-500">
如果后端开启了 <code>BILINOTE_AUTH_ENABLED</code>,插件需要在这里登录后才能提交任务和轮询结果。
</p>
<div class="flex gap-2">
<input
v-model="authPassword"
type="password"
class="input flex-1"
placeholder="访问密码"
@keyup.enter="doLogin"
>
<button class="btn-secondary" @click="doLogin">登录</button>
<button class="btn-secondary" @click="refreshAuthStatus">检查</button>
</div>
<div
v-if="authMsg"
class="text-xs"
:class="{
'text-green-700': authStatus?.authenticated || authStatus?.enabled === false,
'text-amber-700': authStatus?.enabled && !authStatus?.authenticated,
'text-red-600': !authStatus && authMsg.includes('失败'),
}"
>
{{ authMsg }}
</div>
</section>

<section class="section-card">
Expand Down
9 changes: 8 additions & 1 deletion BillNote_extension/src/popup/Popup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { detectPlatform } from '~/logic/platform'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
import { generateNote, getTaskStatus, listNotes, resolveImageUrl, serverNoteToTask } from '~/logic/api'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'

Expand Down Expand Up @@ -158,6 +158,13 @@ function fmtTime(ts?: number) {

onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
try {
const notes = await listNotes()
notes.forEach(n => upsertTask(serverNoteToTask(n)))
}
catch {
// 未登录或旧版后端不支持同步时,不影响 popup 基本使用;设置页会展示具体错误。
}
await loadActiveTab()
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
if (running) {
Expand Down
9 changes: 8 additions & 1 deletion BillNote_extension/src/sidepanel/Sidepanel.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
import { getTaskStatus, listNotes, resolveImageUrl, serverNoteToTask } from '~/logic/api'
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'

Expand Down Expand Up @@ -100,6 +100,13 @@ const activeCover = computed(() =>

onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
try {
const notes = await listNotes()
notes.forEach(n => upsertTask(serverNoteToTask(n)))
}
catch {
// 未登录或旧版后端不支持同步时,保留本地历史。
}
const latest = tasks.value?.[0]
if (latest) {
activeTaskId.value = latest.taskId
Expand Down
Loading
Loading