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
4 changes: 2 additions & 2 deletions BillNote_extension/src/components/MarkdownView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const html = computed(() => md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))))

async function copy() {
await navigator.clipboard.writeText(props.markdown)
await navigator.clipboard.writeText(absolutizeMarkdownImages(props.markdown))
}

function download() {
const blob = new Blob([props.markdown], { type: 'text/markdown;charset=utf-8' })
const blob = new Blob([absolutizeMarkdownImages(props.markdown)], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
Expand Down
4 changes: 2 additions & 2 deletions BillNote_extension/src/logic/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,10 @@ export async function ping(): Promise<boolean> {
}
}

// markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址
// markdown 里的根相对路径图片(如 /static/screenshots/xxx)需要拼绝对地址
export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
return md.replace(/!\[([^\]]*)\]\((?!https?:\/\/|data:|\/\/)(\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
}

// backend 用 note_helper 在笔记开头插一行 '> 来源链接:URL'。侧边栏顶部已经有原片链接卡片,
Expand Down
6 changes: 3 additions & 3 deletions 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, resolveImageUrl, absolutizeMarkdownImages } from '~/logic/api'
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'

Expand Down Expand Up @@ -72,15 +72,15 @@ function openOptions() {
async function copyMarkdown() {
const md = activeTask.value?.result?.markdown
if (md)
await navigator.clipboard.writeText(md)
await navigator.clipboard.writeText(absolutizeMarkdownImages(md))
}

function downloadMarkdown() {
const md = activeTask.value?.result?.markdown
if (!md)
return
const title = (activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || 'bilinote'
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const blob = new Blob([absolutizeMarkdownImages(md)], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
Expand Down
1 change: 1 addition & 0 deletions BillNote_frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ FROM ${BASE_REGISTRY}/library/node:20-alpine AS builder
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
ARG VITE_APP_VERSION=
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
ENV DOCKER_BUILD=1

# pnpm pin 到 9.x:lockfile 是 v9 生成;pnpm 11 要求 Node 22+ 与 node:20 不兼容
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { useTaskStore } from '@/store/taskStore'
import { toPortableMarkdown, getBackendOrigin } from '@/utils/index'
import { noteStyles } from '@/constant/note.ts'
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx'
Expand Down Expand Up @@ -320,8 +321,7 @@ const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
const [modelName, setModelName] = useState<string>('')
const [style, setStyle] = useState<string>('')
const [createTime, setCreateTime] = useState<string>('')
// 确保baseURL没有尾部斜杠
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || '').replace('/api','') || '').replace(/\/$/, '')
const baseURL = getBackendOrigin()
const getCurrentTask = useTaskStore.getState().getCurrentTask
const currentTask = useTaskStore(state => state.getCurrentTask())
const taskStatus = currentTask?.status || 'PENDING'
Expand Down Expand Up @@ -368,7 +368,8 @@ const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
}, [currentVerId, currentTask?.id])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(selectedContent)
const portable = toPortableMarkdown(selectedContent, baseURL)
await navigator.clipboard.writeText(portable)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
Expand Down Expand Up @@ -406,7 +407,8 @@ const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
const handleDownload = () => {
const task = getCurrentTask()
const name = task?.audioMeta.title || 'note'
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
const portable = toPortableMarkdown(selectedContent, baseURL)
const blob = new Blob([portable], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${name}.md`
Expand Down
31 changes: 31 additions & 0 deletions BillNote_frontend/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
/**
* 安全获取后端 origin,用于将 Markdown 中的根相对图片路径转为绝对 URL。
*
* 解析策略(按优先级):
* 1. VITE_API_BASE_URL 是完整 URL → 取 origin(如 https://api.example.com)
* 2. VITE_API_BASE_URL 是根相对路径(如 /api)→ 拼 window.location.origin
* 3. 未配置 → window.location.origin(Docker/nginx 代理场景,前后端同源)
*/
export function getBackendOrigin(): string {
const raw = import.meta.env.VITE_API_BASE_URL
if (!raw) return window.location.origin
try {
// 完整 URL(http://...、https://...)→ 直接取 origin
const url = new URL(raw)
return url.origin
} catch {
// 根相对路径(/api、/static 等)→ 拼当前页面 origin
return window.location.origin
}
}

/**
* 将 Markdown 中根相对路径的图片(如 /static/screenshots/xxx.jpg)
* 转换为绝对 URL,使复制/下载后的 Markdown 在外部工具中也能正常显示图片。
* 已经是 http://、https://、data: 的图片不做处理。
*/
export function toPortableMarkdown(md: string, backendBaseUrl: string): string {
const base = backendBaseUrl.replace(/\/$/, '')
return md.replace(/!\[([^\]]*)\]\((?!https?:\/\/|data:|\/\/)(\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
}

// 解析URL
export function parseUrl(url: string): { protocol: string, host: string, path: string } {
const urlObj = new URL(url);
Expand Down
2 changes: 1 addition & 1 deletion BillNote_frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default defineConfig(({ mode }) => {
const appVersion = env.VITE_APP_VERSION || process.env.VITE_APP_VERSION || readAppVersion()

return {
base: './',
base: process.env.DOCKER_BUILD ? '/' : './',
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
Expand Down
7 changes: 5 additions & 2 deletions backend/app/gpt/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,11 @@ def get_link_format():

def get_screenshot_format():
return '''
11. **原片截图**:你收到的截图一般是一个网格,网格的每张图片就是一个时间点,左上角会包含时间mm:ss的格式,请你结合我发你的图片插入截图提示,请你帮助用户更好的理解视频内容,请你认真的分析每个图片和对应的转写文案,插入最合适的内容来备注用户理解,请一定按照这个格式 返回否则系统无法解析:
- 格式:`*Screenshot-[mm:ss]`
11. **原片截图**: 你收到的截图一般是一个网格,每张图片就是一个时间点,左上角会包含时间 mm:ss 格式。请结合图片和转写文案,在最合适的位置插入截图提示,帮助用户理解视频内容。

**必须严格遵循以下格式**(否则系统无法解析):
- 格式:`*Screenshot-[mm:ss]`(注意:时间必须使用两位数字,如 `*Screenshot-[01:05]`,而非 `*Screenshot-[1:5]`)
- 只在对理解内容真正有帮助的地方插入

'''

Expand Down
13 changes: 10 additions & 3 deletions backend/app/services/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ def generate(

markdown = prepend_source_link(markdown, str(video_url))

# 4.1 用最终版本(含截图 URL、来源链接)覆盖缓存,
# 避免刷新页面时前端从缓存读到未经 post-process 的原始 Markdown
try:
markdown_cache_file.write_text(markdown, encoding="utf-8")
logger.info(f"已用后处理结果覆盖缓存 ({markdown_cache_file})")
except Exception as exc:
logger.warning(f"覆盖 Markdown 缓存失败:{exc}")

# 5. 保存记录到数据库
self._update_status(task_id, TaskStatus.SAVING)
self._save_metadata(video_id=audio_meta.video_id, platform=platform, task_id=task_id)
Expand Down Expand Up @@ -667,9 +675,8 @@ def _insert_screenshots(self, markdown: str, video_path: Path) -> str | None | A
img_url = f"{IMAGE_BASE_URL.rstrip('/')}/{filename}"
markdown = markdown.replace(marker, f"![]({img_url})", 1)
except Exception as exc:
logger.error(f"生成截图失败 (timestamp={ts}):{exc}")
# self._handle_exception(task_id, exc)
return None
logger.error(f"生成截图失败 (timestamp={ts}, marker={marker}):{exc}")
continue
return markdown

@staticmethod
Expand Down
2 changes: 1 addition & 1 deletion backend/app/utils/screenshot_marker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


def extract_screenshot_timestamps(markdown: str) -> List[Tuple[str, int]]:
pattern = r"(\*?Screenshot-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2})))"
pattern = r"(\*?Screenshot-(?:\[(\d{1,2}):(\d{1,2})\]|(\d{1,2}):(\d{1,2})))"
results: List[Tuple[str, int]] = []
for match in re.finditer(pattern, markdown):
mm = match.group(2) or match.group(4)
Expand Down
43 changes: 43 additions & 0 deletions backend/tests/test_screenshot_marker.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,49 @@ def test_extract_accepts_legacy_formats(self):
],
)

def test_extract_accepts_single_digit_minutes(self):
"""LLM 有时输出 1-digit 分钟(如 *Screenshot-[1:05]),本应兼容"""
markdown = "*Screenshot-[1:05]"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(matches, [("*Screenshot-[1:05]", 65)])

def test_extract_accepts_single_digit_both(self):
"""兼容分钟和秒都是 1-digit 的情况(如 *Screenshot-[1:5])"""
markdown = "*Screenshot-[1:5]"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(matches, [("*Screenshot-[1:5]", 65)])

def test_extract_accepts_mixed_digits(self):
"""混合 1-digit 和 2-digit 格式"""
markdown = "A *Screenshot-[01:05] B *Screenshot-[1:05] C *Screenshot-[1:5]"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(
matches,
[
("*Screenshot-[01:05]", 65),
("*Screenshot-[1:05]", 65),
("*Screenshot-[1:5]", 65),
],
)

def test_extract_without_asterisk_single_digit(self):
"""Screenshot- 不带星号 + 1-digit 分钟"""
markdown = "Screenshot-[1:05]"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(matches, [("Screenshot-[1:05]", 65)])

def test_extract_long_timestamp(self):
"""10 分钟以上时间戳(如 10:30),确保多位数字正常匹配"""
markdown = "*Screenshot-[10:30]"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(matches, [("*Screenshot-[10:30]", 630)])

def test_extract_no_match(self):
"""不含截图标记的文本应返回空列表"""
markdown = "这是一段普通文本,没有截图标记"
matches = extract_screenshot_timestamps(markdown)
self.assertEqual(matches, [])


if __name__ == "__main__":
unittest.main()