feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

128
coworker/web/providers.py Normal file
View File

@@ -0,0 +1,128 @@
"""Web search providers — a keyless default + pluggable third-party services.
`duckduckgo` works with no API key (our "starting version of our own"). `tavily` and `brave`
give better results but need a key (configured via the SecretStore / env). All providers
return a uniform `list[SearchResult]`; the heavy client libs are lazy-imported.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
_TIMEOUT = 20.0
@dataclass
class SearchResult:
title: str
url: str
snippet: str
def to_dict(self) -> dict:
return {"title": self.title, "url": self.url, "snippet": self.snippet}
class WebSearchProvider(ABC):
name: str = "base"
requires_key: bool = False
@abstractmethod
def search(self, query: str, max_results: int = 5) -> list[SearchResult]: ...
class DuckDuckGoProvider(WebSearchProvider):
"""Keyless default via the `ddgs` library."""
name = "duckduckgo"
requires_key = False
def search(self, query: str, max_results: int = 5) -> list[SearchResult]:
from ddgs import DDGS
rows = DDGS().text(query, max_results=max_results) or []
return [
SearchResult(
title=r.get("title", ""),
url=r.get("href", "") or r.get("url", ""),
snippet=r.get("body", "") or r.get("snippet", ""),
)
for r in rows
]
class TavilyProvider(WebSearchProvider):
name = "tavily"
requires_key = True
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def search(self, query: str, max_results: int = 5) -> list[SearchResult]:
import httpx
resp = httpx.post(
"https://api.tavily.com/search",
json={"api_key": self.api_key, "query": query, "max_results": max_results},
timeout=_TIMEOUT,
)
data = resp.json()
return [
SearchResult(
title=r.get("title", ""),
url=r.get("url", ""),
snippet=r.get("content", ""),
)
for r in data.get("results", [])
]
class BraveProvider(WebSearchProvider):
name = "brave"
requires_key = True
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def search(self, query: str, max_results: int = 5) -> list[SearchResult]:
import httpx
resp = httpx.get(
"https://api.search.brave.com/res/v1/web/search",
headers={
"X-Subscription-Token": self.api_key,
"Accept": "application/json",
},
params={"q": query, "count": max_results},
timeout=_TIMEOUT,
)
data = resp.json()
return [
SearchResult(
title=r.get("title", ""),
url=r.get("url", ""),
snippet=r.get("description", ""),
)
for r in (data.get("web", {}) or {}).get("results", [])
]
_PROVIDERS = {
"duckduckgo": DuckDuckGoProvider,
"tavily": TavilyProvider,
"brave": BraveProvider,
}
def build_provider(name: str, api_key: Optional[str] = None) -> WebSearchProvider:
cls = _PROVIDERS.get(name, DuckDuckGoProvider)
if cls.requires_key:
if not api_key:
raise ValueError(f"web search provider '{name}' needs an API key")
return cls(api_key) # type: ignore[call-arg]
return cls() # type: ignore[call-arg]
def provider_names() -> list[str]:
return list(_PROVIDERS)