chore: linting

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-06-11 15:31:43 -07:00
parent 27218304ae
commit 05190da0a9
30 changed files with 148 additions and 123 deletions

View file

@ -52,7 +52,9 @@ def upgrade() -> None:
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;")
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")

View file

@ -9,6 +9,8 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422.
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
@ -27,10 +29,10 @@ from app.db import (
from app.podcasts.generation.brief import propose_brief
from app.podcasts.persistence import Podcast, PodcastRepository
from app.podcasts.service import (
InvalidTransition,
InvalidTransitionError,
PodcastService,
PreconditionFailed,
SpecConflict,
PreconditionFailedError,
SpecConflictError,
)
from app.podcasts.storage import open_audio_stream, purge_audio
from app.podcasts.tasks import draft_transcript_task
@ -324,19 +326,12 @@ async def _load(
return podcast
class _lifecycle_errors:
@asynccontextmanager
async def _lifecycle_errors() -> AsyncIterator[None]:
"""Map service lifecycle errors onto HTTP responses."""
async def __aenter__(self) -> None:
return None
async def __aexit__(self, exc_type, exc, tb) -> bool:
if exc is None:
return False
if isinstance(exc, SpecConflict):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, PreconditionFailed):
raise HTTPException(status_code=422, detail=str(exc)) from exc
return False
try:
yield
except (SpecConflictError, InvalidTransitionError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except PreconditionFailedError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc

View file

@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError):
"""The model reply could not be parsed into the expected shape."""
async def invoke_json(llm, messages: list[BaseMessage], model: type[T]) -> T:
async def invoke_json[T: BaseModel](
llm, messages: list[BaseMessage], model: type[T]
) -> T:
"""Invoke ``llm`` and validate its reply as ``model``."""
response = await llm.ainvoke(messages)
content = strip_markdown_fences(extract_text_content(response.content))

View file

@ -18,7 +18,7 @@ from app.services.llm_service import get_agent_llm
from ..prompts import draft_segment_prompt, plan_outline_prompt
from ..structured import invoke_json
from .config import TranscriptConfig
from .planning import Outline, OutlineSegment, SegmentDraft
from .planning import Outline, SegmentDraft
from .state import TranscriptState
# Average speaking rate; converts target minutes to a target word count.

View file

@ -32,7 +32,7 @@ async def concat_to_mp3(segment_paths: list[Path], output_path: Path) -> None:
.output(str(output_path), {"c:a": "libmp3lame"})
)
await ffmpeg.execute()
except Exception as exc: # noqa: BLE001 - normalise ffmpeg failures
except Exception as exc:
raise RenderError(f"audio merge failed: {exc}") from exc
finally:
list_file.unlink(missing_ok=True)

View file

@ -77,9 +77,7 @@ class PodcastRenderer:
await concat_to_mp3(list(segment_paths), output_path)
return RenderedPodcast(data=output_path.read_bytes(), container="mp3")
def _request_for(
self, spec: PodcastSpec, turn: TranscriptTurn
) -> SynthesisRequest:
def _request_for(self, spec: PodcastSpec, turn: TranscriptTurn) -> SynthesisRequest:
try:
speaker = spec.speaker_for(turn.speaker)
except KeyError as exc:
@ -132,7 +130,7 @@ class _SegmentSynthesizer:
if owner:
try:
path = await self._synthesize(request, key)
except BaseException as exc: # noqa: BLE001 - relayed to all waiters
except BaseException as exc:
future.set_exception(exc)
else:
future.set_result(path)

View file

@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel):
model_config = ConfigDict(extra="forbid")
slot: int = Field(..., ge=0, description="Stable index a transcript turn references")
slot: int = Field(
..., ge=0, description="Stable index a transcript turn references"
)
name: str = Field(..., min_length=1, max_length=120)
role: SpeakerRole
voice_id: str = Field(

View file

@ -59,11 +59,11 @@ class PodcastError(RuntimeError):
"""Base class for lifecycle errors."""
class InvalidTransition(PodcastError):
class InvalidTransitionError(PodcastError):
"""A requested status change is not permitted from the current state."""
class SpecConflict(PodcastError):
class SpecConflictError(PodcastError):
"""A spec edit raced another: the expected version is stale."""
def __init__(self, expected: int, actual: int) -> None:
@ -74,7 +74,7 @@ class SpecConflict(PodcastError):
self.actual = actual
class PreconditionFailed(PodcastError):
class PreconditionFailedError(PodcastError):
"""A transition's data precondition (brief/transcript present) is unmet."""
@ -110,12 +110,12 @@ class PodcastService:
) -> Podcast:
"""Edit the brief at the gate, guarded by optimistic concurrency."""
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
raise InvalidTransition(
raise InvalidTransitionError(
f"the brief can only be edited while awaiting_brief, "
f"not {_status(podcast).value}"
)
if expected_version != podcast.spec_version:
raise SpecConflict(expected_version, podcast.spec_version)
raise SpecConflictError(expected_version, podcast.spec_version)
podcast.spec = spec.model_dump(mode="json")
podcast.spec_version += 1
await self._session.flush()
@ -124,7 +124,7 @@ class PodcastService:
async def begin_drafting(self, podcast: Podcast) -> Podcast:
"""Approve the brief and start transcript drafting."""
if podcast.spec is None:
raise PreconditionFailed("cannot draft without a brief")
raise PreconditionFailedError("cannot draft without a brief")
self._transition(podcast, PodcastStatus.DRAFTING)
await self._session.flush()
return podcast
@ -145,13 +145,13 @@ class PodcastService:
async def regenerate(self, podcast: Podcast) -> Podcast:
"""Reopen the brief gate; the saved spec becomes the new starting point."""
if _status(podcast) not in self._REGENERABLE:
raise InvalidTransition(
raise InvalidTransitionError(
f"nothing to regenerate from {_status(podcast).value}"
)
# Legacy episodes finished before briefs existed; a gate with nothing
# to review would strand them.
if podcast.spec is None:
raise PreconditionFailed("cannot regenerate without a brief")
raise PreconditionFailedError("cannot regenerate without a brief")
self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
await self._session.flush()
return podcast
@ -164,7 +164,7 @@ class PodcastService:
has no regeneration to revert and is rejected.
"""
if not has_stored_episode(podcast):
raise InvalidTransition("no finished episode to fall back to")
raise InvalidTransitionError("no finished episode to fall back to")
self._transition(podcast, PodcastStatus.READY)
await self._session.flush()
return podcast
@ -200,7 +200,7 @@ class PodcastService:
backing out goes through revert_regeneration instead.
"""
if has_stored_episode(podcast):
raise InvalidTransition(
raise InvalidTransitionError(
"a finished episode exists; revert the regeneration instead"
)
self._transition(podcast, PodcastStatus.CANCELLED)
@ -210,7 +210,7 @@ class PodcastService:
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
current = _status(podcast)
if target not in _ALLOWED[current]:
raise InvalidTransition(
raise InvalidTransitionError(
f"{current.value} -> {target.value} is not allowed"
)
podcast.status = target

View file

@ -36,9 +36,10 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict:
return run_async_celery_task(
lambda: _draft_transcript(podcast_id, search_space_id)
)
except Exception as exc: # noqa: BLE001 - record and report, never crash worker
except Exception as exc:
logger.error("Podcast %s drafting failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc)))
message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id}

View file

@ -15,7 +15,7 @@ from app.celery_app import celery_app
from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import (
InvalidTransition,
InvalidTransitionError,
PodcastService,
read_spec,
read_transcript,
@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts"
def render_audio_task(self, podcast_id: int) -> dict:
try:
return run_async_celery_task(lambda: _render_audio(podcast_id))
except Exception as exc: # noqa: BLE001 - record and report, never crash worker
except Exception as exc:
logger.error("Podcast %s render failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc)))
message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id}
@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
except InvalidTransition:
except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone.
await purge_audio_object(key)

View file

@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech):
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if not isinstance(request.voice, str):
raise TextToSpeechError(
"Kokoro voices are named by string, not a mapping"
)
raise TextToSpeechError("Kokoro voices are named by string, not a mapping")
pipeline = self._pipeline_for(request.language)
loop = asyncio.get_event_loop()
@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech):
),
)
segments = [audio for _gs, _ps, audio in generator]
except Exception as exc: # noqa: BLE001 - normalise provider errors
except Exception as exc:
raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc
if not segments:

View file

@ -57,10 +57,8 @@ class LiteLlmTextToSpeech(TextToSpeech):
try:
response = await aspeech(**kwargs)
except Exception as exc: # noqa: BLE001 - normalise provider errors
raise TextToSpeechError(
f"{self._model} synthesis failed: {exc}"
) from exc
except Exception as exc:
raise TextToSpeechError(f"{self._model} synthesis failed: {exc}") from exc
data = getattr(response, "content", None)
if not data:

View file

@ -36,9 +36,7 @@ class VoiceCatalog:
"""All voices offered by ``provider``, in catalog order."""
return list(self._by_provider.get(provider, ()))
def for_language(
self, provider: TtsProvider, language: str
) -> list[CatalogVoice]:
def for_language(self, provider: TtsProvider, language: str) -> list[CatalogVoice]:
"""``provider`` voices that can render ``language``, in catalog order."""
return [v for v in self.for_provider(provider) if v.speaks(language)]
@ -50,6 +48,4 @@ class VoiceCatalog:
@lru_cache(maxsize=1)
def get_voice_catalog() -> VoiceCatalog:
"""The process-wide catalog assembled from every provider's roster."""
return VoiceCatalog(
(*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES)
)
return VoiceCatalog((*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES))

View file

@ -30,10 +30,52 @@ def _voice(
VERTEX_VOICES: tuple[CatalogVoice, ...] = (
_voice("en-US-Studio-O", "en-US", "en-US", "en-US-Studio-O", "Studio O (US)", VoiceGender.FEMALE),
_voice("en-US-Studio-M", "en-US", "en-US", "en-US-Studio-M", "Studio M (US)", VoiceGender.MALE),
_voice("en-GB-Studio-A", "en-GB", "en-UK", "en-UK-Studio-A", "Studio A (UK)", VoiceGender.FEMALE),
_voice("en-GB-Studio-B", "en-GB", "en-UK", "en-UK-Studio-B", "Studio B (UK)", VoiceGender.MALE),
_voice("en-AU-Studio-A", "en-AU", "en-AU", "en-AU-Studio-A", "Studio A (AU)", VoiceGender.FEMALE),
_voice("en-AU-Studio-B", "en-AU", "en-AU", "en-AU-Studio-B", "Studio B (AU)", VoiceGender.MALE),
_voice(
"en-US-Studio-O",
"en-US",
"en-US",
"en-US-Studio-O",
"Studio O (US)",
VoiceGender.FEMALE,
),
_voice(
"en-US-Studio-M",
"en-US",
"en-US",
"en-US-Studio-M",
"Studio M (US)",
VoiceGender.MALE,
),
_voice(
"en-GB-Studio-A",
"en-GB",
"en-UK",
"en-UK-Studio-A",
"Studio A (UK)",
VoiceGender.FEMALE,
),
_voice(
"en-GB-Studio-B",
"en-GB",
"en-UK",
"en-UK-Studio-B",
"Studio B (UK)",
VoiceGender.MALE,
),
_voice(
"en-AU-Studio-A",
"en-AU",
"en-AU",
"en-AU-Studio-A",
"Studio A (AU)",
VoiceGender.FEMALE,
),
_voice(
"en-AU-Studio-B",
"en-AU",
"en-AU",
"en-AU-Studio-B",
"Studio B (AU)",
VoiceGender.MALE,
),
)

View file

@ -30,7 +30,7 @@ _SAMPLE_TEXTS = {
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
"zh": "你好!这就是我为你播报播客时的声音。",
"zh": "你好!这就是我为你播报播客时的声音。", # noqa: RUF001
}
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
@ -40,9 +40,7 @@ async def render_voice_preview(
voice: CatalogVoice, tts: TextToSpeech
) -> tuple[bytes, str]:
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
language = (
_FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
)
language = _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
request = SynthesisRequest(
text=_sample_text(language), voice=voice.native_ref, language=language
)

View file

@ -36,7 +36,11 @@ def iter_completion_emission_frames(
"success",
)
elif status in ("failed", "error"):
error_msg = out.get("error", "Unknown error") if isinstance(out, dict) else "Unknown error"
error_msg = (
out.get("error", "Unknown error")
if isinstance(out, dict)
else "Unknown error"
)
yield ctx.streaming_service.format_terminal_info(
f"Podcast generation failed: {error_msg}",
"error",

View file

@ -26,7 +26,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.app import app, limiter
from app.config import config as app_config
from app.db import SearchSpace, User, get_async_session
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import (
DurationTarget,
@ -39,6 +38,7 @@ from app.podcasts.schemas import (
)
from app.podcasts.service import PodcastService
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.users import current_active_user
pytestmark = pytest.mark.integration
@ -128,12 +128,8 @@ class FakeStorageBackend:
def fake_storage(monkeypatch) -> FakeStorageBackend:
"""Route audio storage to an in-memory backend for the stream routes."""
backend = FakeStorageBackend()
monkeypatch.setattr(
"app.podcasts.storage.get_storage_backend", lambda: backend
)
monkeypatch.setattr(
"app.file_storage.factory.get_storage_backend", lambda: backend
)
monkeypatch.setattr("app.podcasts.storage.get_storage_backend", lambda: backend)
monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend)
return backend
@ -159,9 +155,7 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession:
"app.podcasts.tasks.render",
"app.podcasts.tasks.runtime",
):
monkeypatch.setattr(
f"{module}.get_celery_session_maker", lambda: _make_session
)
monkeypatch.setattr(f"{module}.get_celery_session_maker", lambda: _make_session)
return db_session
@ -213,8 +207,12 @@ def build_spec(
language=language,
style=PodcastStyle.CONVERSATIONAL,
speakers=[
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]),
SpeakerSpec(slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]),
SpeakerSpec(
slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]
),
SpeakerSpec(
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
),
],
duration=DurationTarget(min_minutes=10, max_minutes=20),
)
@ -237,7 +235,7 @@ def make_podcast(db_session: AsyncSession):
session, so the endpoint under test reads a realistically-built row.
"""
_LADDER = [
ladder = [
PodcastStatus.AWAITING_BRIEF,
PodcastStatus.DRAFTING,
PodcastStatus.RENDERING,
@ -259,7 +257,7 @@ def make_podcast(db_session: AsyncSession):
await db_session.flush()
return podcast
targets = _LADDER[: _LADDER.index(status) + 1]
targets = ladder[: ladder.index(status) + 1]
for target in targets:
if target is PodcastStatus.AWAITING_BRIEF:
await service.attach_brief(podcast, build_spec())

View file

@ -15,9 +15,7 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_cancel_from_a_live_state_succeeds(
client, db_search_space, make_podcast
):
async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
)

View file

@ -23,18 +23,12 @@ BASE = "/api/v1/podcasts"
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
"""Route preview synthesis to the fake provider and an isolated cache."""
provider = FakeTextToSpeech()
monkeypatch.setattr(
"app.podcasts.api.routes.get_text_to_speech", lambda: provider
)
monkeypatch.setattr(
"app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path
)
monkeypatch.setattr("app.podcasts.api.routes.get_text_to_speech", lambda: provider)
monkeypatch.setattr("app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path)
return provider
async def test_preview_returns_playable_audio_for_a_catalog_voice(
client, preview_tts
):
async def test_preview_returns_playable_audio_for_a_catalog_voice(client, preview_tts):
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert resp.status_code == 200
@ -42,9 +36,7 @@ async def test_preview_returns_playable_audio_for_a_catalog_voice(
assert resp.content == b"segment-audio"
async def test_preview_is_synthesised_once_then_served_from_cache(
client, preview_tts
):
async def test_preview_is_synthesised_once_then_served_from_cache(client, preview_tts):
first = await client.get(f"{BASE}/voices/openai:alloy/preview")
second = await client.get(f"{BASE}/voices/openai:alloy/preview")
@ -69,9 +61,7 @@ async def test_preview_voice_of_inactive_provider_is_404(client, preview_tts):
assert preview_tts.requests == []
async def test_preview_without_tts_provider_is_503(
client, preview_tts, monkeypatch
):
async def test_preview_without_tts_provider_is_503(client, preview_tts, monkeypatch):
monkeypatch.setattr(app_config, "TTS_SERVICE", None)
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")

View file

@ -38,7 +38,10 @@ def make_spec():
if speakers is None:
speakers = [
SpeakerSpec(
slot=0, name="Host", role=SpeakerRole.HOST, voice_id="kokoro:am_adam"
slot=0,
name="Host",
role=SpeakerRole.HOST,
voice_id="kokoro:am_adam",
),
SpeakerSpec(
slot=1,

View file

@ -71,7 +71,9 @@ def _spec(voice_id: str) -> PodcastSpec:
async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam"))
renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")])
with pytest.raises(RenderError):
@ -81,7 +83,9 @@ async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam"))
renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")])
with pytest.raises(RenderError):

View file

@ -64,7 +64,9 @@ def test_a_preferred_voice_invalid_for_the_language_is_replaced():
speaker_count=1,
preferred=["kokoro:does-not-exist"],
)
assert voices[0].voice_id in {v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)}
assert voices[0].voice_id in {
v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)
}
def test_resolution_fails_when_no_voice_speaks_the_language():

View file

@ -52,7 +52,9 @@ def test_for_provider_returns_only_that_providers_voices():
def test_for_language_matches_on_the_primary_subtag():
"""A request for 'en' should match an 'en-US' voice (region-insensitive)."""
catalog = VoiceCatalog([_voice("k1", language="en-US")])
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == ["k1"]
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == [
"k1"
]
def test_for_language_excludes_other_languages():

View file

@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types";
import type { Metadata } from "next";
import type { ComponentType } from "react";
import { changelog } from "@/.source/server";
import {
ChangelogTimeline,
type ChangelogTimelineEntry,
} from "@/components/ui/changelog-timeline";
import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";

View file

@ -13,11 +13,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import type {
CreditPurchase,
PagePurchase,
PurchaseStatus,
} from "@/contracts/types/stripe.types";
import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils";

View file

@ -48,8 +48,8 @@ import {
isCommentReplyMetadata,
isConnectorIndexingMetadata,
isDocumentProcessingMetadata,
isNewMentionMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox";

View file

@ -1,9 +1,9 @@
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -2,9 +2,9 @@
import {
FilePlus2,
type LucideIcon,
Search,
Settings2,
type LucideIcon,
WandSparkles,
Workflow,
X,

View file

@ -286,8 +286,8 @@ function PricingFAQ() {
Frequently Asked Questions
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense credits and billing.
Can&apos;t find what you need? Reach out at{" "}
Everything you need to know about SurfSense credits and billing. Can&apos;t find what you
need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com
</a>

View file

@ -77,9 +77,7 @@ export function EarnCreditsContent() {
<div className="w-full space-y-5">
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Earn bonus credits by completing tasks
</p>
<p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
</div>
<div className="space-y-2">