From 633443ecaa0b6f967ab3a51e540277370e8b8d43 Mon Sep 17 00:00:00 2001 From: tuzi <1753710808@qq.com> Date: Tue, 16 Jun 2026 12:46:30 +0800 Subject: [PATCH 1/2] ui change --- apps/desktop/src/main.rs | 26 ++ backend/content_cleaner/router.py | 9 +- backend/content_cleaner/schemas.py | 8 + backend/content_cleaner/service.py | 145 ++++++++- backend/tests/test_content_cleaner.py | 183 +++++++++++ packages/ipc-client/src/content.ts | 4 + packages/shared-types/src/generated.ts | 430 +++++++++++++++++++------ packages/ui/src/App.tsx | 95 +++++- packages/ui/src/i18n/messages.ts | 6 + packages/ui/src/services/api/index.ts | 5 + packages/ui/src/services/desktop.ts | 30 ++ packages/ui/src/styles/app.css | 15 + 12 files changed, 846 insertions(+), 110 deletions(-) create mode 100644 packages/ui/src/services/desktop.ts diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 4112d32..2856ab3 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -20,6 +20,7 @@ const HEALTHCHECK_INTERVAL: Duration = Duration::from_millis(250); fn main() { tauri::Builder::default() .manage(BackendState(Mutex::new(None))) + .invoke_handler(tauri::generate_handler![open_external_url]) .setup(|app| { // Anything that can fail is handled here so we never let an error // propagate out of the setup hook (that would abort() the process @@ -44,6 +45,31 @@ fn main() { }); } +#[tauri::command] +fn open_external_url(url: String) -> Result<(), String> { + let trimmed = url.trim(); + if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { + return Err("only HTTP(S) URLs can be opened externally".to_string()); + } + + let status = if cfg!(target_os = "windows") { + Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", trimmed]) + .status() + } else if cfg!(target_os = "macos") { + Command::new("open").arg(trimmed).status() + } else { + Command::new("xdg-open").arg(trimmed).status() + } + .map_err(|err| err.to_string())?; + + if status.success() { + Ok(()) + } else { + Err(format!("failed to open URL externally: {status}")) + } +} + fn start_backend_and_window(app: &tauri::App) -> Result<(), String> { let port = reserve_port()?; let mut child = spawn_backend(app.handle(), port)?; diff --git a/backend/content_cleaner/router.py b/backend/content_cleaner/router.py index 7dee2cf..087b3df 100644 --- a/backend/content_cleaner/router.py +++ b/backend/content_cleaner/router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from .schemas import CleanContentRequest, CleanContentResponse -from .service import clean_html, clean_stored_article +from .schemas import CleanContentRequest, CleanContentResponse, WebPageResponse +from .service import clean_html, clean_stored_article, fetch_entry_web_page router = APIRouter(prefix="/content", tags=["content"]) @@ -14,3 +14,8 @@ async def clean_content(request: CleanContentRequest) -> CleanContentResponse: @router.get("/entries/{article_id}/clean", response_model=CleanContentResponse) async def clean_stored_content(article_id: str) -> CleanContentResponse: return clean_stored_article(article_id) + + +@router.get("/entries/{article_id}/web", response_model=WebPageResponse) +async def fetch_stored_web_page(article_id: str) -> WebPageResponse: + return fetch_entry_web_page(article_id) diff --git a/backend/content_cleaner/schemas.py b/backend/content_cleaner/schemas.py index 218e499..217ceeb 100644 --- a/backend/content_cleaner/schemas.py +++ b/backend/content_cleaner/schemas.py @@ -16,3 +16,11 @@ class CleanContentResponse(BaseModel): content_hash: str | None = None word_count: int reading_time_minutes: int + + +class WebPageResponse(BaseModel): + article_id: str + url: str + final_url: str + content_type: str | None = None + html: str diff --git a/backend/content_cleaner/service.py b/backend/content_cleaner/service.py index d7a3c8b..d67cebd 100644 --- a/backend/content_cleaner/service.py +++ b/backend/content_cleaner/service.py @@ -3,7 +3,9 @@ import math import sqlite3 from pathlib import Path -from urllib.parse import urljoin +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin, urlsplit +from urllib.request import Request, urlopen from bleach import clean as bleach_clean from bs4 import BeautifulSoup @@ -13,7 +15,7 @@ from app.schemas.content import ArticleContent from db import get_article, get_article_content, save_article_content -from .schemas import CleanContentRequest, CleanContentResponse +from .schemas import CleanContentRequest, CleanContentResponse, WebPageResponse logger = logging.getLogger(__name__) @@ -102,8 +104,9 @@ def clean_html(request: CleanContentRequest) -> CleanContentResponse: """ raw_html = request.raw_html - # 1. Parse + # 1. Parse and prefer the actual article container when the source is a full web page. soup = BeautifulSoup(raw_html, "lxml") + soup = _extract_reader_candidate(soup) # 2. Sanitize — strip scripts, event handlers, dangerous markup sanitized = bleach_clean( @@ -152,7 +155,7 @@ def clean_html(request: CleanContentRequest) -> CleanContentResponse: def clean_stored_article(article_id: str) -> CleanContentResponse: - """Read raw HTML from storage, clean it, and persist the result.""" + """Read article HTML, preferring the canonical URL when feed HTML is only metadata.""" try: content = get_article_content(article_id) except sqlite3.OperationalError as err: @@ -169,10 +172,16 @@ def clean_stored_article(article_id: str) -> CleanContentResponse: entry = get_article(article_id) source_url = entry.url if entry else "" + raw_html = content.raw_html + if entry is not None and _should_fetch_reader_source(content.raw_html): + fetched = _try_fetch_web_page(entry.url) + if fetched is not None: + raw_html = fetched.html + source_url = fetched.final_url result = clean_html( CleanContentRequest( - raw_html=content.raw_html, + raw_html=raw_html, source_url=source_url, article_id=article_id, ) @@ -180,7 +189,7 @@ def clean_stored_article(article_id: str) -> CleanContentResponse: save_article_content( article_id=article_id, - raw_html=content.raw_html, + raw_html=raw_html, cleaned_html=result.cleaned_html, cleaned_markdown=result.cleaned_markdown, plain_text=result.plain_text, @@ -195,6 +204,62 @@ def clean_stored_article(article_id: str) -> CleanContentResponse: return result +def fetch_entry_web_page(article_id: str, timeout_seconds: float = 15.0) -> WebPageResponse: + """Fetch the article's canonical URL and return HTML suitable for iframe srcDoc.""" + entry = get_article(article_id) + if entry is None: + raise HTTPException( + status_code=404, + detail=f"Article '{article_id}' not found", + ) + + fetched = _try_fetch_web_page(entry.url, timeout_seconds=timeout_seconds) + if fetched is None: + raise HTTPException(status_code=502, detail="Web page request failed") + + html = _inject_base_url(fetched.html, fetched.final_url) + return WebPageResponse( + article_id=article_id, + url=fetched.url, + final_url=fetched.final_url, + content_type=fetched.content_type, + html=html, + ) + + +def _try_fetch_web_page(url: str, timeout_seconds: float = 15.0) -> WebPageResponse | None: + url = _validate_web_url(url) + headers = { + "User-Agent": "Lumen/0.1 web-preview", + "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + } + request = Request(url, headers=headers) + + try: + with urlopen(request, timeout=timeout_seconds) as response: + body = response.read() + content_type = response.headers.get("Content-Type") + final_url = response.url + except HTTPError as exc: + logger.info("Web page server returned HTTP %s for %s", exc.code, url) + return None + except TimeoutError: + logger.info("Web page request timed out for %s", url) + return None + except (URLError, OSError) as exc: + logger.info("Web page request failed for %s: %s", url, exc) + return None + + html = _decode_response_body(body, content_type) + return WebPageResponse( + article_id="", + url=url, + final_url=final_url, + content_type=content_type, + html=html, + ) + + def load_article_content( article_id: str, db_path: Path | str | None = None, @@ -224,6 +289,74 @@ def save_cleaned_article_content( # ── Private helpers ────────────────────────────────────────────── +def _should_fetch_reader_source(raw_html: str) -> bool: + text = BeautifulSoup(raw_html or "", "lxml").get_text(separator="\n", strip=True) + if not text: + return True + + lower = text.lower() + hnrss_markers = [ + "article url:", + "comments url:", + "points:", + "# comments:", + ] + if sum(1 for marker in hnrss_markers if marker in lower) >= 2: + return True + + return False + +def _validate_web_url(url: str) -> str: + value = url.strip() + parts = urlsplit(value) + if parts.scheme not in {"http", "https"} or not parts.netloc: + raise HTTPException(status_code=409, detail="Article URL is not an absolute HTTP(S) URL") + return value + + +def _decode_response_body(body: bytes, content_type: str | None) -> str: + encoding = "utf-8" + if content_type: + for part in content_type.split(";"): + key, _, value = part.strip().partition("=") + if key.lower() == "charset" and value.strip(): + encoding = value.strip().strip('"') + break + try: + return body.decode(encoding, errors="replace") + except LookupError: + return body.decode("utf-8", errors="replace") + + +def _inject_base_url(html: str, final_url: str) -> str: + soup = BeautifulSoup(html, "lxml") + base = soup.new_tag("base", href=final_url) + if soup.head: + existing = soup.head.find("base") + if existing: + existing["href"] = final_url + else: + soup.head.insert(0, base) + elif soup.html: + head = soup.new_tag("head") + head.append(base) + soup.html.insert(0, head) + else: + return f'\n{html}' + return str(soup) + + +def _extract_reader_candidate(soup: BeautifulSoup) -> BeautifulSoup: + for selector in ("article", "main", "[role='main']"): + candidate = soup.select_one(selector) + if candidate is not None and candidate.get_text(strip=True): + return BeautifulSoup(str(candidate), "lxml") + + for tag in soup.select("script, style, noscript, nav, header, footer, aside"): + tag.decompose() + return soup + + def _count_words(text: str) -> int: """Count words in plain text by splitting on whitespace.""" return len(text.split()) if text.strip() else 0 diff --git a/backend/tests/test_content_cleaner.py b/backend/tests/test_content_cleaner.py index 1d68161..f3fcd09 100644 --- a/backend/tests/test_content_cleaner.py +++ b/backend/tests/test_content_cleaner.py @@ -497,3 +497,186 @@ def test_clean_stored_article_empty_body( assert data["word_count"] == 0 assert data["reading_time_minutes"] == 1 # floor assert data["plain_text"] == "" + + def test_clean_stored_article_fetches_full_page_for_hnrss_metadata( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + import content_cleaner.service as cleaner_service + from app.schemas.content import ArticleContent + from app.schemas.entry import Entry + + fake_content = ArticleContent( + article_id="art-hn", + raw_html=""" +

Article URL: https://example.com/full

+

Comments URL: HN

+

Points: 74

+

# Comments: 17

+ """, + cleaned_html="", + cleaned_markdown="", + plain_text="", + ) + fake_entry = Entry( + id="art-hn", + feed_id="feed-001", + title="HN Article", + summary="", + author="", + url="https://example.com/full", + published_at="2026-01-01T00:00:00Z", + is_read=False, + is_starred=False, + tag_ids=[], + reader_html="", + web_preview="", + related_entry_ids=[], + note="", + summary_text="", + translation_html=None, + translation_status="idle", + ) + + class FakeHeaders: + def get(self, name: str) -> str | None: + return "text/html; charset=utf-8" if name == "Content-Type" else None + + class FakeResponse: + url = "https://example.com/full" + headers = FakeHeaders() + + def __enter__(self): + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return ( + b"
" + b"

Real article title

Real article body text.

" + b"
" + ) + + saved: dict = {} + monkeypatch.setattr( + cleaner_service, + "get_article_content", + lambda aid: fake_content if aid == "art-hn" else None, + ) + monkeypatch.setattr( + cleaner_service, + "get_article", + lambda aid: fake_entry if aid == "art-hn" else None, + ) + monkeypatch.setattr(cleaner_service, "urlopen", lambda *args, **kwargs: FakeResponse()) + monkeypatch.setattr(cleaner_service, "save_article_content", lambda **kw: saved.update(kw)) + + response = client.get("/content/entries/art-hn/clean") + assert response.status_code == 200 + data = response.json() + assert "Real article title" in data["cleaned_html"] + assert "Real article body text." in data["cleaned_markdown"] + assert "Article URL:" not in data["plain_text"] + assert "Real article title" in saved["raw_html"] + + +class TestFetchStoredWebPageEndpoint: + def test_get_nonexistent_article_returns_404(self, client: TestClient) -> None: + response = client.get("/content/entries/nonexistent-id/web") + assert response.status_code == 404 + + def test_rejects_non_http_article_url( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + import content_cleaner.service as cleaner_service + from app.schemas.entry import Entry + + fake_entry = Entry( + id="art-file", + feed_id="feed-001", + title="Local Article", + summary="", + author="", + url="file:///tmp/article.html", + published_at="2026-01-01T00:00:00Z", + is_read=False, + is_starred=False, + tag_ids=[], + reader_html="", + web_preview="", + related_entry_ids=[], + note="", + summary_text="", + translation_html=None, + translation_status="idle", + ) + + monkeypatch.setattr( + cleaner_service, + "get_article", + lambda aid: fake_entry if aid == "art-file" else None, + ) + + response = client.get("/content/entries/art-file/web") + assert response.status_code == 409 + + def test_fetches_html_and_injects_base_url( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + import content_cleaner.service as cleaner_service + from app.schemas.entry import Entry + + fake_entry = Entry( + id="art-web", + feed_id="feed-001", + title="Web Article", + summary="", + author="", + url="https://example.com/articles/one", + published_at="2026-01-01T00:00:00Z", + is_read=False, + is_starred=False, + tag_ids=[], + reader_html="", + web_preview="", + related_entry_ids=[], + note="", + summary_text="", + translation_html=None, + translation_status="idle", + ) + + class FakeHeaders: + def get(self, name: str) -> str | None: + return "text/html; charset=utf-8" if name == "Content-Type" else None + + class FakeResponse: + url = "https://example.com/articles/one?utm=no" + headers = FakeHeaders() + + def __enter__(self): + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return ( + b"Hi" + b"" + ) + + monkeypatch.setattr( + cleaner_service, + "get_article", + lambda aid: fake_entry if aid == "art-web" else None, + ) + monkeypatch.setattr(cleaner_service, "urlopen", lambda *args, **kwargs: FakeResponse()) + + response = client.get("/content/entries/art-web/web") + assert response.status_code == 200 + data = response.json() + assert data["article_id"] == "art-web" + assert data["final_url"] == "https://example.com/articles/one?utm=no" + assert '' in data["html"] diff --git a/packages/ipc-client/src/content.ts b/packages/ipc-client/src/content.ts index f722b17..d1d9235 100644 --- a/packages/ipc-client/src/content.ts +++ b/packages/ipc-client/src/content.ts @@ -5,3 +5,7 @@ import type { IpcClient } from "./index"; export function cleanStoredContent(client: IpcClient, articleId: string) { return client.request("GET", `/content/entries/${articleId}/clean`); } + +export function getEntryWebPage(client: IpcClient, articleId: string) { + return client.request("GET", `/content/entries/${articleId}/web`); +} diff --git a/packages/shared-types/src/generated.ts b/packages/shared-types/src/generated.ts index 7b4e278..cbb2a95 100644 --- a/packages/shared-types/src/generated.ts +++ b/packages/shared-types/src/generated.ts @@ -158,37 +158,97 @@ export interface paths { patch?: never; trace?: never; }; - "/providers": { + "/providers/": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Providers */ - get: operations["get_providers_providers_get"]; + /** + * Get Providers + * @description 获取所有提供商列表(不包含敏感信息) + */ + get: operations["get_providers_providers__get"]; put?: never; - /** Create Provider */ - post: operations["create_provider_providers_post"]; + /** + * Create Provider + * @description 创建新的提供商 + */ + post: operations["create_provider_providers__post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/providers/{provider_name}": { + "/providers/{name}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Edit Provider */ - put: operations["edit_provider_providers__provider_name__put"]; + /** + * Get Provider By Name + * @description 获取指定提供商 + */ + get: operations["get_provider_by_name_providers__name__get"]; + /** + * Update Provider By Name + * @description 更新提供商配置 + */ + put: operations["update_provider_by_name_providers__name__put"]; post?: never; - /** Delete Provider */ - delete: operations["delete_provider_providers__provider_name__delete"]; + /** + * Delete Provider + * @description 删除提供商 + */ + delete: operations["delete_provider_providers__name__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/providers/default": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Default Provider + * @description 获取默认提供商 + */ + get: operations["get_default_provider_providers_default_get"]; + put?: never; + /** + * Set Default Provider + * @description 设置默认提供商 + */ + post: operations["set_default_provider_providers_default_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/providers/test/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Test Provider + * @description 测试提供商连接 + */ + post: operations["test_provider_providers_test__name__post"]; + delete?: never; options?: never; head?: never; patch?: never; @@ -297,6 +357,23 @@ export interface paths { patch?: never; trace?: never; }; + "/content/entries/{article_id}/web": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch Stored Web Page */ + get: operations["fetch_stored_web_page_content_entries__article_id__web_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/agents/summary/generate": { parameters: { query?: never; @@ -428,6 +505,32 @@ export interface components { /** Reading Time Minutes */ reading_time_minutes: number; }; + /** CreateProviderRequest */ + CreateProviderRequest: { + /** Name */ + name: string; + /** + * Kind + * @default openai_compatible + */ + kind: string; + /** + * Model + * @default + */ + model: string; + /** Base Url */ + base_url?: string | null; + /** Api Key */ + api_key?: string | null; + /** Api Key Header */ + api_key_header?: string | null; + /** + * Is Default + * @default false + */ + is_default: boolean; + }; /** Entry */ Entry: { /** Id */ @@ -600,8 +703,8 @@ export interface components { * @enum {string} */ ProviderKind: "openai_compatible" | "anthropic" | "ollama"; - /** ProviderSummaryResponse */ - ProviderSummaryResponse: { + /** ProviderSummary */ + ProviderSummary: { /** Name */ name: string; kind: components["schemas"]["ProviderKind"]; @@ -622,29 +725,10 @@ export interface components { */ has_api_key: boolean; }; - /** ProviderUpsertRequest */ - ProviderUpsertRequest: { + /** SetDefaultRequest */ + SetDefaultRequest: { /** Name */ name: string; - kind: components["schemas"]["ProviderKind"]; - /** Model */ - model: string; - /** Base Url */ - base_url?: string | null; - /** Api Key */ - api_key?: string | null; - /** Api Key Header */ - api_key_header?: string | null; - /** - * Is Default - * @default false - */ - is_default: boolean; - /** - * Clear Api Key - * @default false - */ - clear_api_key: boolean; }; /** SubscribeFeedRequest */ SubscribeFeedRequest: { @@ -681,6 +765,40 @@ export interface components { /** Model */ model: string; }; + /** SyncResult */ + SyncResult: { + /** Feed Id */ + feed_id: string; + /** Status */ + status: string; + /** Fetched */ + fetched: number; + /** Saved */ + saved: number; + /** + * Skipped + * @default 0 + */ + skipped: number; + /** + * Not Modified + * @default false + */ + not_modified: boolean; + }; + /** Tag */ + Tag: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Aliases */ + aliases: string[]; + /** Usage Count */ + usage_count: number; + /** Unread Count */ + unread_count: number; + }; /** TranslationRequest */ TranslationRequest: { /** Entry Id */ @@ -710,59 +828,22 @@ export interface components { /** Model */ model: string; }; - /** ProviderSummary */ - ProviderSummary: { + /** UpdateProviderRequest */ + UpdateProviderRequest: { /** Name */ name: string; - /** - * Kind - * @enum {string} - */ - kind: "openai_compatible" | "anthropic" | "ollama"; + /** Kind */ + kind?: string | null; /** Model */ - model: string; + model?: string | null; /** Base Url */ base_url?: string | null; + /** Api Key */ + api_key?: string | null; /** Api Key Header */ api_key_header?: string | null; /** Is Default */ - is_default: boolean; - /** Has Api Key */ - has_api_key: boolean; - }; - /** SyncResult */ - SyncResult: { - /** Feed Id */ - feed_id: string; - /** Status */ - status: string; - /** Fetched */ - fetched: number; - /** Saved */ - saved: number; - /** - * Skipped - * @default 0 - */ - skipped: number; - /** - * Not Modified - * @default false - */ - not_modified: boolean; - }; - /** Tag */ - Tag: { - /** Id */ - id: string; - /** Name */ - name: string; - /** Aliases */ - aliases: string[]; - /** Usage Count */ - usage_count: number; - /** Unread Count */ - unread_count: number; + is_default?: boolean | null; }; /** ValidationError */ ValidationError: { @@ -777,6 +858,19 @@ export interface components { /** Context */ ctx?: Record; }; + /** WebPageResponse */ + WebPageResponse: { + /** Article Id */ + article_id: string; + /** Url */ + url: string; + /** Final Url */ + final_url: string; + /** Content Type */ + content_type?: string | null; + /** Html */ + html: string; + }; }; responses: never; parameters: never; @@ -1058,7 +1152,7 @@ export interface operations { }; }; }; - get_providers_providers_get: { + get_providers_providers__get: { parameters: { query?: never; header?: never; @@ -1073,12 +1167,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProviderSummaryResponse"][]; + "application/json": components["schemas"]["ProviderSummary"][]; }; }; }; }; - create_provider_providers_post: { + create_provider_providers__post: { parameters: { query?: never; header?: never; @@ -1087,7 +1181,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ProviderUpsertRequest"]; + "application/json": components["schemas"]["CreateProviderRequest"]; }; }; responses: { @@ -1097,7 +1191,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProviderSummaryResponse"]; + "application/json": { + [key: string]: unknown; + }; }; }; /** @description Validation Error */ @@ -1111,18 +1207,49 @@ export interface operations { }; }; }; - edit_provider_providers__provider_name__put: { + get_provider_by_name_providers__name__get: { parameters: { query?: never; header?: never; path: { - provider_name: string; + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderSummary"] | null; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_provider_by_name_providers__name__put: { + parameters: { + query?: never; + header?: never; + path: { + name: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ProviderUpsertRequest"]; + "application/json": components["schemas"]["UpdateProviderRequest"]; }; }; responses: { @@ -1132,7 +1259,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProviderSummaryResponse"]; + "application/json": { + [key: string]: unknown; + }; }; }; /** @description Validation Error */ @@ -1146,12 +1275,12 @@ export interface operations { }; }; }; - delete_provider_providers__provider_name__delete: { + delete_provider_providers__name__delete: { parameters: { query?: never; header?: never; path: { - provider_name: string; + name: string; }; cookie?: never; }; @@ -1164,7 +1293,95 @@ export interface operations { }; content: { "application/json": { - [key: string]: boolean; + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_default_provider_providers_default_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderSummary"] | null; + }; + }; + }; + }; + set_default_provider_providers_default_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetDefaultRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + test_provider_providers_test__name__post: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; }; }; }; @@ -1409,6 +1626,37 @@ export interface operations { }; }; }; + fetch_stored_web_page_content_entries__article_id__web_get: { + parameters: { + query?: never; + header?: never; + path: { + article_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; generate_summary_agents_summary_generate_post: { parameters: { query?: never; diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index afbefa5..348f3b8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -57,6 +57,7 @@ import { getApiErrorMessage, requestTranslation } from "./services/api"; +import { openExternalUrl } from "./services/desktop"; import { readStoredBoolean, readStoredNumber, writeStoredBoolean, writeStoredNumber } from "./services/storage"; const maxSidebarTags = 5; @@ -1002,7 +1003,45 @@ function ReaderDetail(props: { const articleHtml = translationMode === "translation" && entry.translationHtml ? renderBilingualMarkdown(entry.translationHtml) : entry.readerHtml; - const webHtml = entry.webPreview.trim().startsWith("<") ? entry.webPreview : articleHtml; + const webUrl = normalizeWebUrl(entry.url); + + function openEntryExternally() { + if (!webUrl) { + props.onNotice(t("webPageUnavailable")); + return; + } + void openExternalUrl(webUrl).catch((error) => { + props.onNotice(error instanceof Error ? error.message : t("requestFailed")); + }); + } + + function handleReaderArticleClick(event: React.MouseEvent) { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const link = target.closest("a[href]"); + if (!(link instanceof HTMLAnchorElement) || !event.currentTarget.contains(link)) { + return; + } + + const href = link.getAttribute("href")?.trim(); + if (!href || href.startsWith("#")) { + return; + } + + event.preventDefault(); + const resolvedUrl = resolveExternalUrl(href, webUrl ?? ""); + if (!resolvedUrl) { + props.onNotice(t("webPageUnavailable")); + return; + } + + void openExternalUrl(resolvedUrl).catch((error) => { + props.onNotice(error instanceof Error ? error.message : t("requestFailed")); + }); + } async function toggleTranslation() { if (!entry) { @@ -1137,7 +1176,7 @@ function ReaderDetail(props: { icon={} items={[ { label: t("copyLink"), action: () => void navigator.clipboard?.writeText(entry.url) }, - { label: t("openBrowser"), action: () => window.open(entry.url, "_blank", "noopener") }, + { label: t("openBrowser"), action: openEntryExternally }, { label: t("shareDigest"), action: () => props.onModal({ type: "shareDigest", entryId: entry.id, exportMode: "share" }) }, { label: t("exportDigest"), action: () => props.onModal({ type: "shareDigest", entryId: entry.id, exportMode: "single" }) } ]} @@ -1187,6 +1226,7 @@ function ReaderDetail(props: { {showReader && (
void navigator.clipboard?.writeText(entry.url)} title={t("copyUrl")}> - {entry.url} + {webUrl} + -
+ {webUrl ? ( +