Skip to content
Merged
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 @@ -43,3 +43,12 @@ TRANSCRIBER_TYPE=fast-whisper
WHISPER_MODEL_SIZE=tiny

GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo

# whisper 本地模型从 HuggingFace 下载。镜像默认 HF_ENDPOINT=https://hf-mirror.com(国内友好)。
# 若下载失败(容器连不上镜像站),可在此覆盖为官方源或其它镜像,例如:
# HF_ENDPOINT=https://huggingface.co
# 注意:宿主机的 VPN/代理默认不会进入容器。若要让模型下载走代理,请在前端「设置」里
# 配置代理(会自动应用到 HuggingFace 下载),或在下面用标准环境变量指定:
# HTTP_PROXY=http://host.docker.internal:7890
# HTTPS_PROXY=http://host.docker.internal:7890
# HF_ENDPOINT=https://hf-mirror.com
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y

直接访问 **[www.bilinote.app](https://www.bilinote.app/)** 即可使用 BiliNote Pro 在线版,无需本地部署。

## 🌟 搭配使用:KaCutAI

做 BiliNote 的时候,我发现不少用户不只是看别人的视频做笔记,自己也在拍、在剪、在攒素材。当本地素材越堆越多,找个画面翻半天——这个问题 BiliNote 解决不了。

所以我做了 **[KaCutAI](https://www.kacut.app)**:一个跑在 Mac 本地的 AI 视频素材搜索引擎。用中文自然语言搜片段,不用上传云端,不按月付费。

> 💡 BiliNote 帮你「看视频做笔记」,KaCutAI 帮你「找自己的视频素材」。一个看别人的,一个找自己的,搭配使用更香。

**核心能力:**

- 🇨🇳 中文原生:说人话就能搜,「穿白衬衫的人在说话」「有鸟叫的黄昏海滩」直接出结果
- 🔒 全本地:素材不上云,AI 在你 Mac 上跑,隐私零泄露
- 🧠 六模联动:画面 + 人物 + 动作 + 对话 + OCR + 音频,复合查询是它的强项
- 💰 买断制:一次付费,永久使用

**适合谁:** 有大量视频素材需要管理的创作者、剪辑师、自媒体、影像工作者。

## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)
## 📦 桌面版下载
Expand Down
6 changes: 4 additions & 2 deletions backend/app/db/builtin_providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
"type": "built-in",
"logo": "DeepSeek",
"api_key": "",
"base_url": "https://api.deepseek.com"
"base_url": "https://api.deepseek.com",
"models": ["deepseek-chat", "deepseek-reasoner"]
},
{
"id": "qwen",
"name": "Qwen",
"type": "built-in",
"logo": "Qwen",
"api_key": "",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-plus", "qwen-turbo", "qwen-max", "qwen-long"]
},
{
"id": "Claude",
Expand Down
49 changes: 47 additions & 2 deletions backend/app/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,38 @@ class ModelDownloadRequest(BaseModel):
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"


def _friendly_download_error(e: Exception) -> str:
"""把 HuggingFace 的网络类报错翻译成用户能照着做的提示(issue #417)。

典型原文:'An error happened while trying to locate the file on the Hub and we
cannot find the requested files in the local cache...' —— 本质是连不上 Hub。
用户大概率不知道:默认走 hf-mirror.com 镜像,可配代理或改 HF_ENDPOINT。
"""
raw = str(e)
lowered = raw.lower()
network_markers = (
"locate the file on the hub",
"couldn't connect",
"connection error",
"connecttimeout",
"read timed out",
"max retries exceeded",
"failed to establish",
"name or service not known",
"temporary failure in name resolution",
)
if any(m in lowered for m in network_markers):
endpoint = os.getenv("HF_ENDPOINT", "https://huggingface.co")
return (
f"{raw}\n"
f"——连不上模型仓库(当前 HF_ENDPOINT={endpoint})。可尝试:"
f"1) 在「设置」里配置可用代理;"
f"2) 设置环境变量 HF_ENDPOINT 切换镜像(国内可用 https://hf-mirror.com);"
f"3) 确认容器能访问外网/镜像站后重试。"
)
return raw


def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型(支持内置 size / 自定义 repo_id / 本地路径)。

Expand All @@ -250,9 +282,14 @@ def _do_download_whisper(model_size: str):
"""
from huggingface_hub import snapshot_download
from app.transcriber.whisper_models import resolve_whisper_model, is_local_target
from app.services.proxy_config_manager import ProxyConfigManager

try:
dl_state.mark_downloading(model_size)
# 让 UI 配的代理对 HuggingFace 下载也生效(issue #417:容器里代理没生效)
proxy = ProxyConfigManager().apply_to_env()
if proxy:
logger.info(f"whisper 下载走代理: {proxy}")
model_dir = get_model_dir("whisper")

# 已经下好就不重复下
Expand Down Expand Up @@ -289,8 +326,9 @@ def _do_download_whisper(model_size: str):
logger.info(f"whisper 模型下载完成: {model_size}")
dl_state.mark_done(model_size)
except Exception as e:
msg = _friendly_download_error(e)
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
dl_state.mark_failed(model_size, str(e))
dl_state.mark_failed(model_size, msg)


def _do_download_mlx_whisper(model_size: str):
Expand All @@ -300,6 +338,12 @@ def _do_download_mlx_whisper(model_size: str):
dl_state.mark_downloading(key)
from huggingface_hub import snapshot_download as hf_download
from app.transcriber.mlx_whisper_transcriber import resolve_mlx_repo_id
from app.services.proxy_config_manager import ProxyConfigManager

# 让 UI 配的代理对 HuggingFace 下载也生效(issue #417)
proxy = ProxyConfigManager().apply_to_env()
if proxy:
logger.info(f"mlx-whisper 下载走代理: {proxy}")

try:
repo_id = resolve_mlx_repo_id(model_size)
Expand All @@ -319,8 +363,9 @@ def _do_download_mlx_whisper(model_size: str):
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
dl_state.mark_done(key)
except Exception as e:
msg = _friendly_download_error(e)
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
dl_state.mark_failed(key, str(e))
dl_state.mark_failed(key, msg)


@router.post("/transcriber_download")
Expand Down
60 changes: 47 additions & 13 deletions backend/app/services/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,56 @@ def get_enabled_models_by_provider( provider_id: str|int,):
return enabled_models
@staticmethod
def get_all_models_by_id(provider_id: str, verbose: bool = False):
try:
provider = ProviderService.get_provider_by_id(provider_id)
"""拉取某供应商的可选模型列表,用于设置页下拉。

历史坑(issue #417):旧实现对 get_model_list 的返回值直接取 `.data`,但
get_model_list 在 /models 调用失败时会吞掉异常返回 `[]`,于是 `[].data`
触发 AttributeError,又被这里的 except 吞成 `[]` —— 最终接口返回
`{"code":0,"msg":"success","data":[]}`,把「DeepSeek /models 取不到」伪装成
成功的空列表,用户完全看不到原因。

现在:
1. 直接捕获 /models 的真实异常(不再二次吞);
2. normalize_models 兼容 SyncPage / list / dict,绝不再 `.data` 崩;
3. 动态拿不到(失败或空)时退回内置已知清单,保证下拉非空;
4. 仍然为空且确有报错时,把报错带回去(前端可提示,不再假装成功)。
"""
from app.services.model_fallback import (
builtin_fallback_models,
normalize_models,
as_model_dicts,
)

models = ModelService.get_model_list(provider["id"], verbose=verbose)
print(type(models))
serializable_models = [m.dict() for m in models.data]
model_list = {
"models": serializable_models
}
provider = ProviderService.get_provider_by_id(provider_id)
if not provider:
logger.warning(f"[{provider_id}] 供应商不存在")
return {"models": []}

logger.info(f"[{provider['name']}] 获取模型成功")
return model_list
models: list = []
error: str | None = None
try:
config = ModelService._build_model_config(provider)
gpt = GPTFactory().from_config(config)
models = normalize_models(gpt.list_models())
if verbose:
print(f"[{provider['name']}] 动态模型列表: {models}")
except Exception as e:
# print(f"[{provider_id}] 获取模型失败: {e}")
logger.error(f"[{provider_id}] 获取模型失败: {e}")
return []
error = str(e)
logger.warning(f"[{provider['name']}] 动态获取模型失败,尝试回退内置清单: {e}")

if not models:
fallback = builtin_fallback_models(provider)
if fallback:
logger.info(f"[{provider['name']}] /models 为空,回退内置清单: {fallback}")
models = as_model_dicts(fallback, owned_by=provider.get("name", ""))

result = {"models": models}
if not models and error:
# 既没动态结果也没兜底清单:把真实报错带回去,别再伪装成功
result["error"] = error
else:
logger.info(f"[{provider['name']}] 获取模型成功,共 {len(models)} 个")
return result
@staticmethod
def connect_test(id: str, model: str | None = None) -> bool:
"""连通性测试:发一条最小化 chat completion。
Expand Down
86 changes: 86 additions & 0 deletions backend/app/services/model_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""内置供应商的回退模型清单 + 模型对象归一化(issue #417)。

背景:设置页的「模型下拉」依赖 provider 的 `/v1/models` 动态列表。但这个接口
并不可靠——

- DeepSeek 的 `/models` 在部分账号/网络下取不到,下拉直接空白;
- 不少自建 OpenAI 兼容网关压根不实现 `/models`;
- key 没有 inference 权限时也可能返回异常。

(`OpenAI_compatible_provider.test_connection` 的注释里已经记录过这个不可靠性。)

所以对**内置供应商**额外维护一份已知可用清单兜底:动态拿不到时退回这份清单,
保证下拉永远有内容,用户不至于卡在空列表。清单数据写在
`app/db/builtin_providers.json` 的 `models` 字段里,单一数据源,方便维护。

本模块只依赖标准库,便于单测隔离加载(不触发 app 包的重依赖导入链)。
"""
import json
from pathlib import Path
from typing import Any, List, Optional

# builtin_providers.json 与本文件同属 backend/app 下:app/services/ -> app/db/
_BUILTIN_JSON = Path(__file__).resolve().parent.parent / "db" / "builtin_providers.json"


def _load_builtin() -> List[dict]:
try:
return json.loads(_BUILTIN_JSON.read_text(encoding="utf-8"))
except Exception:
return []


def builtin_fallback_models(provider: Optional[dict]) -> List[str]:
"""按 provider 的 id 或 name(忽略大小写)匹配内置清单里的 models 字段。

自定义供应商(DB 里 id 是 uuid)通常 name 也对得上内置名,所以 id / name 都试。
匹配不到或没配 models 返回空列表。
"""
if not provider:
return []
keys = {str(provider.get("id", "")).strip().lower(), str(provider.get("name", "")).strip().lower()}
keys.discard("")
if not keys:
return []
for p in _load_builtin():
candidate = {str(p.get("id", "")).strip().lower(), str(p.get("name", "")).strip().lower()}
if keys & candidate:
models = p.get("models") or []
return [str(m) for m in models if m]
return []


def normalize_models(raw: Any) -> List[dict]:
"""把 SDK 返回值统一成 [{'id', 'object', 'owned_by', ...}] 列表。

兼容三种形态:
- openai SDK 的 SyncPage(取 .data)
- 普通 list(含旧代码失败时返回的 [],绝不能再 .data)
- list 里既可能是 pydantic Model 也可能是 dict
"""
if raw is None:
return []
data = getattr(raw, "data", raw) # SyncPage -> .data;list/tuple 原样
if not isinstance(data, (list, tuple)):
return []
out: List[dict] = []
for m in data:
if isinstance(m, dict):
d = m
elif hasattr(m, "model_dump"):
d = m.model_dump()
elif hasattr(m, "dict"):
d = m.dict()
else:
d = {"id": getattr(m, "id", None)}
if d.get("id"):
out.append(d)
return out


def as_model_dicts(model_ids: List[str], owned_by: str = "") -> List[dict]:
"""把模型名列表包成与 SDK Model 一致的 dict,前端下拉直接复用同一套渲染。"""
return [
{"id": mid, "object": "model", "created": None, "owned_by": owned_by}
for mid in model_ids
]
19 changes: 19 additions & 0 deletions backend/app/services/proxy_config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,22 @@ def get_proxy_url(self) -> Optional[str]:
if val:
return val
return None

def apply_to_env(self) -> Optional[str]:
"""把当前生效的代理 URL 写进进程环境变量,返回生效的 url(无则 None)。

为什么需要(issue #417):huggingface_hub / requests 这类库**只认**环境变量
HTTP_PROXY / HTTPS_PROXY / ALL_PROXY,不读我们 UI 配置文件。whisper 模型用
snapshot_download 从 HuggingFace 拉取,如果用户只在设置页填了代理,下载根本
不走代理 —— 就是用户说的「Docker 容器里代理没生效」。在下载前/启动时调用本
方法,把 UI 配的代理 export 到环境变量,HF 下载就能复用同一个代理。

大小写别名都写,覆盖不同库的读取习惯。
"""
url = self.get_proxy_url()
if not url:
return None
for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy"):
os.environ[key] = url
return url
7 changes: 7 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ async def lifespan(app: FastAPI):
logger.info("[startup 4/5] seed_default_providers() — 初始化默认 LLM 供应商")
seed_default_providers()

# 把已配置的代理 export 到环境变量,让 huggingface_hub(whisper 模型下载)
# 也能走代理——含转写时的按需下载(issue #417)。
from app.services.proxy_config_manager import ProxyConfigManager
_proxy = ProxyConfigManager().apply_to_env()
if _proxy:
logger.info(f" 已应用全局代理到环境变量: {_proxy}")

logger.info("[startup 5/5] 启动完成,等待请求")
except Exception:
logger.exception("[startup FAILED] 后端启动期异常,详见堆栈;容器会退出并由 restart 策略决定是否重试")
Expand Down
Loading
Loading