fix: error hints, Italian in the language menu and dev fallbacks (docs review) (#1457)

* fix: error hints, Italian menu entry and dev fallbacks found by the docs review

- error_classifier: read timeouts get their own message naming
  ESPERANTO_LLM_TIMEOUT; connect failures keep "Could not connect".
  Auth hint points to Manage -> Models; provision's "Please go to
  Manage" message is recognized again.
- Podcast voice hint and the empty-episodes text point to real screens.
- LanguageToggle renders the registered languages list, so it-IT shows
  up (and future locales can't be missed); it-IT date locale added.
  The per-language common.* labels it used are removed.
- make start-all uses the shipped compose file.
- Legacy SURREAL_ADDRESS/SURREAL_PORT fallback builds ws://host:port/rpc.
- .env.example uses the real OPEN_NOTEBOOK_CHUNK_* names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: address review (IPv6 legacy address, connection-timed-out wording, stronger tests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: assert exception class for OS connection timeouts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Luis Novo 2026-10-04 22:51:31 -03:00 • committed by GitHub
parent a66c6ae412
commit 37217b01de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 189 additions and 290 deletions

View file

@ -62,9 +62,9 @@ SURREAL_DATABASE=open_notebook
# OMLX_API_BASE=http://localhost:11435/v1
# OMLX_API_KEY= # optional; only if oMLX was started with --api-key
# Content processing
# CHUNK_SIZE=1500
# CHUNK_OVERLAP=150
# Content processing: embedding chunk size, in tokens
# OPEN_NOTEBOOK_CHUNK_SIZE=400
# OPEN_NOTEBOOK_CHUNK_OVERLAP=60 # default: 15% of the chunk size
# Remote Crawl4AI server for the crawl4ai URL engine (no local install needed).
# Crawl4AI Docker >= 0.9.0 requires a bearer token for external connections.

View file

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Docs no longer recommend settings that do nothing.** `API_CLIENT_TIMEOUT` was never read by Open Notebook; the timeout guidance now covers what actually applies: `ESPERANTO_LLM_TIMEOUT` per model call (keep it below 600) and the web UI's 10-minute request limit (`NEXT_PUBLIC_API_TIMEOUT_MS`, build time only). Remaining `SURREAL_COMMANDS_MAX_TASKS` advice now uses `OPEN_NOTEBOOK_WORKER_MAX_TASKS`. Docker instructions no longer say to edit `.env` and `docker compose restart`: the shipped compose file doesn't load `.env` into the container and `restart` doesn't reload environment changes, so they now point to the `open_notebook` service's `environment:` block and `docker compose up -d`
- **Timeouts no longer read as connection failures.** A model that doesn't answer within `ESPERANTO_LLM_TIMEOUT` used to report "Could not connect to the AI provider"; it now says the provider took too long and how to raise the limit. Connection failures (including connect timeouts) keep the old message
- Error hints point to screens that exist: an authentication failure sends you to Manage → Models (not "Settings → Credentials"), and an unsupported podcast voice to Podcasts → Profiles. The empty Podcasts page points to the Generate Podcast button instead of chat screens that can't start a podcast
- **Italian is selectable in the language menu**, and dates are formatted in Italian. The menu now lists every registered language, so a new translation can't be left out again
- `make start-all` starts SurrealDB again (it pointed to a compose file removed long ago)
- The legacy `SURREAL_ADDRESS` / `SURREAL_PORT` fallback builds a valid database URL (`ws://host:port/rpc`, was `ws://host/rpc:port`); an address that already includes the port is kept as is
- `.env.example` names the chunking variables Open Notebook actually reads (`OPEN_NOTEBOOK_CHUNK_SIZE` / `OPEN_NOTEBOOK_CHUNK_OVERLAP`)
## [1.15.0] - 2026-10-04

View file

@ -176,7 +176,7 @@ worker-restart: worker-stop
start-all:
@echo "🚀 Starting Open Notebook (Database + API + Worker + Frontend)..."
@echo "📊 Starting SurrealDB..."
@docker compose -f docker-compose.dev.yml up -d surrealdb
@docker compose up -d surrealdb
@sleep 3
@echo "🔧 Starting API backend..."
@uv run run_api.py &

View file

@ -63,7 +63,7 @@ def explain_generation_failure(error_msg: str) -> Optional[str]:
if "Voice name" in error_msg and "not supported" in error_msg:
return (
"The speaker profile's voice_id is not valid for its TTS model. "
"Check the voices in Settings -> Speaker Profiles against the ones "
"Check the speaker profile voices in Podcasts -> Profiles against the ones "
"your voice model provides (the profiles seeded on install use "
"OpenAI voice names)."
)

View file

@ -9,6 +9,15 @@ import {
} from '@/components/ui/dropdown-menu'
import { Languages } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { languages, type LanguageCode } from '@/lib/locales'
// Every language registered in lib/locales is listed, so a new locale can't be
// left out of the menu. Browser-detected codes ("pt", "zh-Hant") match by prefix.
function isActive(code: LanguageCode, current: string): boolean {
if (code === 'zh-CN') return current === 'zh-CN' || current === 'zh' || current.startsWith('zh-Hans')
if (code === 'zh-TW') return current === 'zh-TW' || current.startsWith('zh-Hant')
return current === code || current.startsWith(code.split('-')[0])
}
interface LanguageToggleProps {
iconOnly?: boolean
@ -34,84 +43,15 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setLanguage('en-US')}
className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''}
>
<span>{t('common.english')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ca-ES')}
className={currentLang === 'ca-ES' || currentLang.startsWith('ca') ? 'bg-accent' : ''}
>
<span>{t('common.catalan')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-CN')}
className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''}
>
<span>{t('common.chinese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-TW')}
className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''}
>
<span>{t('common.traditionalChinese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('pt-BR')}
className={currentLang === 'pt-BR' || currentLang.startsWith('pt') ? 'bg-accent' : ''}
>
<span>{t('common.portuguese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ja-JP')}
className={currentLang === 'ja-JP' || currentLang.startsWith('ja') ? 'bg-accent' : ''}
>
<span>{t('common.japanese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('fr-FR')}
className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''}
>
<span>{t('common.french')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ru-RU')}
className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''}
>
<span>{t('common.russian')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('bn-IN')}
className={currentLang === 'bn-IN' || currentLang.startsWith('bn') ? 'bg-accent' : ''}
>
<span>{t('common.bengali')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('es-ES')}
className={currentLang === 'es-ES' || currentLang.startsWith('es') ? 'bg-accent' : ''}
>
<span>{t('common.spanish')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('de-DE')}
className={currentLang === 'de-DE' || currentLang.startsWith('de') ? 'bg-accent' : ''}
>
<span>{t('common.german')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('pl-PL')}
className={currentLang === 'pl-PL' || currentLang.startsWith('pl') ? 'bg-accent' : ''}
>
<span>{t('common.polish')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('tr-TR')}
className={currentLang === 'tr-TR' || currentLang.startsWith('tr') ? 'bg-accent' : ''}
>
<span>{t('common.turkish')}</span>
</DropdownMenuItem>
{languages.map(({ code, label }) => (
<DropdownMenuItem
key={code}
onClick={() => setLanguage(code)}
className={isActive(code, currentLang) ? 'bg-accent' : ''}
>
<span>{label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)

View file

@ -22,17 +22,6 @@ export const bnIN = {
newNotebook: "নতুন নোটবুক",
newPodcast: "নতুন পডকাস্ট",
language: "ভাষা",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "উৎস",
notebook: "নোটবুক",
podcast: "পডকাস্ট",
@ -132,8 +121,6 @@ export const bnIN = {
errorDetails: "ত্রুটির বিবরণ",
editTransformation: "ট্রান্সফরমেশন সম্পাদনা",
retry: "আবার চেষ্টা করুন",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "সম্পন্ন",
saveSuccess: "সফলভাবে সংরক্ষিত",
contextModes: {
@ -586,7 +573,7 @@ export const bnIN = {
loadErrorTitle: "এপিসোড লোড করতে ব্যর্থ",
loadErrorDesc: "আমরা সর্বশেষ পডকাস্ট এপিসোড আনতে পারিনি। শীঘ্রই আবার চেষ্টা করুন।",
loadingEpisodes: "এপিসোড লোড হচ্ছে...",
noEpisodesYet: "এখনও কোন পডকাস্ট এপিসোড নেই। নোটবুক বা সোর্স চ্যাট ইন্টারফেস থেকে আপনার প্রথমটি তৈরি করুন।",
noEpisodesYet: "এখনও কোন পডকাস্ট এপিসোড নেই। আপনার প্রথমটি তৈরি করতে \"পডকাস্ট তৈরি করুন\" ক্লিক করুন।",
statusRunningTitle: "বর্তমানে প্রক্রিয়াকরণ",
statusRunningDesc: "এপিসোড যা সক্রিয়ভাবে সম্পদ তৈরি করছে।",
statusPendingTitle: "কিউ / অপেক্ষমাণ",

View file

@ -22,17 +22,6 @@ export const caES = {
newNotebook: "Quadern nou",
newPodcast: "Podcast nou",
language: "Idioma",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Font",
notebook: "Quadern",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const caES = {
errorDetails: "Detalls de l'error",
editTransformation: "Edita la transformació",
retry: "Torna-ho a provar",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "completat",
saveSuccess: "S'ha desat correctament",
contextModes: {
@ -586,7 +573,7 @@ export const caES = {
loadErrorTitle: "Ha fallat la càrrega dels episodis",
loadErrorDesc: "No s'han pogut obtenir els darrers episodis de podcast. Torna-ho a provar en breu.",
loadingEpisodes: "S'estan carregant els episodis…",
noEpisodesYet: "Encara no hi ha episodis de podcast. Genera el primer des de les interfícies de xat del quadern o la font.",
noEpisodesYet: "Encara no hi ha episodis de podcast. Fes clic a Genera un podcast per crear el primer.",
statusRunningTitle: "S'està processant",
statusRunningDesc: "Episodis que estan generant recursos activament.",
statusPendingTitle: "En cua / Pendent",

View file

@ -25,17 +25,6 @@ export const deDE = {
newNotebook: "Neues Notebook",
newPodcast: "Neuer Podcast",
language: "Sprache",
english: "Englisch",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
spanish: "Español",
catalan: "Català",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Quelle",
notebook: "Notebook",
podcast: "Podcast",
@ -135,8 +124,6 @@ export const deDE = {
errorDetails: "Fehlerdetails",
editTransformation: "Transformation bearbeiten",
retry: "Erneut versuchen",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "abgeschlossen",
saveSuccess: "Erfolgreich gespeichert",
contextModes: {
@ -589,7 +576,7 @@ export const deDE = {
loadErrorTitle: "Episoden konnten nicht geladen werden",
loadErrorDesc: "Die neuesten Podcast-Episoden konnten nicht abgerufen werden. Versuche es gleich erneut.",
loadingEpisodes: "Episoden werden geladen…",
noEpisodesYet: "Noch keine Podcast-Episoden. Erzeuge deine erste Episode über die Chat-Oberflächen eines Notebooks oder einer Quelle.",
noEpisodesYet: "Noch keine Podcast-Episoden. Klicke auf Podcast erzeugen, um deine erste Episode zu erstellen.",
statusRunningTitle: "Aktuell in Verarbeitung",
statusRunningDesc: "Episoden, deren Inhalte gerade erzeugt werden.",
statusPendingTitle: "In Warteschlange / ausstehend",

View file

@ -20,17 +20,6 @@ export const enUS = {
newNotebook: "New Notebook",
newPodcast: "New Podcast",
language: "Language",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Source",
notebook: "Notebook",
podcast: "Podcast",
@ -130,8 +119,6 @@ export const enUS = {
errorDetails: "Error Details",
editTransformation: "Edit Transformation",
retry: "Try Again",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "completed",
saveSuccess: "Saved successfully",
contextModes: {
@ -584,7 +571,7 @@ export const enUS = {
loadErrorTitle: "Failed to load episodes",
loadErrorDesc: "We could not fetch the latest podcast episodes. Try again shortly.",
loadingEpisodes: "Loading episodes…",
noEpisodesYet: "No podcast episodes yet. Generate your first one from the notebook or source chat interfaces.",
noEpisodesYet: "No podcast episodes yet. Click Generate Podcast to create your first one.",
statusRunningTitle: "Currently Processing",
statusRunningDesc: "Episodes that are actively generating assets.",
statusPendingTitle: "Queued / Pending",

View file

@ -22,17 +22,6 @@ export const esES = {
newNotebook: "Nuevo cuaderno",
newPodcast: "Nuevo podcast",
language: "Idioma",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Fuente",
notebook: "Cuaderno",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const esES = {
errorDetails: "Detalles del error",
editTransformation: "Editar transformación",
retry: "Reintentar",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "completado",
saveSuccess: "Guardado exitosamente",
contextModes: {
@ -586,7 +573,7 @@ export const esES = {
loadErrorTitle: "Error al cargar episodios",
loadErrorDesc: "No pudimos obtener los episodios más recientes. Intenta de nuevo en un momento.",
loadingEpisodes: "Cargando episodios…",
noEpisodesYet: "Aún no hay episodios de podcast. Genera tu primero desde las interfaces de chat de cuadernos o fuentes.",
noEpisodesYet: "Aún no hay episodios de podcast. Haz clic en Generar podcast para crear el primero.",
statusRunningTitle: "Procesando actualmente",
statusRunningDesc: "Episodios que están generando activos activamente.",
statusPendingTitle: "En cola / Pendientes",

View file

@ -22,17 +22,6 @@ export const frFR = {
newNotebook: "Nouveau Carnet",
newPodcast: "Nouveau Podcast",
language: "Langue",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Source",
notebook: "Carnet",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const frFR = {
errorDetails: "Détails de l'erreur",
editTransformation: "Modifier la transformation",
retry: "Réessayer",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "terminé",
saveSuccess: "Enregistré avec succès",
contextModes: {
@ -586,7 +573,7 @@ export const frFR = {
loadErrorTitle: "Échec du chargement des épisodes",
loadErrorDesc: "Nous n'avons pas pu récupérer les derniers épisodes. Réessayez dans un instant.",
loadingEpisodes: "Chargement des épisodes…",
noEpisodesYet: "Aucun épisode de podcast pour le moment. Générez votre premier depuis le carnet ou les interfaces de chat.",
noEpisodesYet: "Aucun épisode de podcast pour le moment. Cliquez sur Générer un podcast pour créer le premier.",
statusRunningTitle: "En cours de traitement",
statusRunningDesc: "Épisodes dont les ressources sont activement en cours de génération.",
statusPendingTitle: "En file d'attente / En attente",

View file

@ -22,17 +22,6 @@ export const itIT = {
newNotebook: "Nuovo quaderno",
newPodcast: "Nuovo podcast",
language: "Lingua",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Fonte",
notebook: "Quaderno",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const itIT = {
errorDetails: "Dettagli errore",
editTransformation: "Modifica trasformazione",
retry: "Riprova",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "completato",
saveSuccess: "Salvato con successo",
contextModes: {
@ -586,7 +573,7 @@ export const itIT = {
loadErrorTitle: "Impossibile caricare gli episodi",
loadErrorDesc: "Non siamo riusciti a recuperare gli ultimi episodi podcast. Riprova tra poco.",
loadingEpisodes: "Caricamento episodi…",
noEpisodesYet: "Ancora nessun episodio podcast. Genera il tuo primo dalle interfacce chat di quaderni o fonti.",
noEpisodesYet: "Ancora nessun episodio podcast. Fai clic su Genera Podcast per creare il primo.",
statusRunningTitle: "In elaborazione",
statusRunningDesc: "Episodi che stanno attivamente generando risorse.",
statusPendingTitle: "In coda / In attesa",

View file

@ -22,17 +22,6 @@ export const jaJP = {
newNotebook: "新規ノートブック",
newPodcast: "新規ポッドキャスト",
language: "言語",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "ソース",
notebook: "ノートブック",
podcast: "ポッドキャスト",
@ -132,8 +121,6 @@ export const jaJP = {
errorDetails: "エラー詳細",
editTransformation: "変換を編集",
retry: "再試行",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "完了",
saveSuccess: "保存しました",
contextModes: {
@ -586,7 +573,7 @@ export const jaJP = {
loadErrorTitle: "エピソードの読み込みに失敗しました",
loadErrorDesc: "最新のポッドキャストエピソードを取得できませんでした。しばらくしてから再試行してください。",
loadingEpisodes: "エピソードを読み込み中...",
noEpisodesYet: "ポッドキャストエピソードがまだありません。ノートブックまたはソースのチャットインターフェースから最初のエピソードを生成してください。",
noEpisodesYet: "ポッドキャストエピソードがまだありません。「ポッドキャストを生成」をクリックして最初のエピソードを作成してください。",
statusRunningTitle: "処理中",
statusRunningDesc: "現在アセットを生成中のエピソード。",
statusPendingTitle: "キュー待ち / 保留中",

View file

@ -22,17 +22,6 @@ export const plPL = {
newNotebook: "Nowy notatnik",
newPodcast: "Nowy podcast",
language: "Język",
english: "Angielski",
chinese: "Chiński (uproszczony)",
japanese: "Japoński",
french: "Francuski",
russian: "Rosyjski",
bengali: "Bengalski",
spanish: "Hiszpański",
catalan: "Kataloński",
german: "Niemiecki",
polish: "Polski",
turkish: "Türkçe",
source: "Źródło",
notebook: "Notatnik",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const plPL = {
errorDetails: "Szczegóły błędu",
editTransformation: "Edytuj transformację",
retry: "Spróbuj ponownie",
traditionalChinese: "Chiński (tradycyjny)",
portuguese: "Portugalski",
completed: "zakończono",
saveSuccess: "Zapisano pomyślnie",
contextModes: {
@ -586,7 +573,7 @@ export const plPL = {
loadErrorTitle: "Nie udało się załadować odcinków",
loadErrorDesc: "Nie mogliśmy pobrać najnowszych odcinków podcastu. Spróbuj ponownie za chwilę.",
loadingEpisodes: "Ładowanie odcinków…",
noEpisodesYet: "Brak odcinków podcastu. Wygeneruj pierwszy z poziomu notatnika lub interfejsu czatu źródła.",
noEpisodesYet: "Brak odcinków podcastu. Kliknij Wygeneruj podcast, aby utworzyć pierwszy.",
statusRunningTitle: "Obecnie przetwarzane",
statusRunningDesc: "Odcinki, dla których aktywnie generowane są materiały.",
statusPendingTitle: "W kolejce / Oczekujące",

View file

@ -22,17 +22,6 @@ export const ptBR = {
newNotebook: "Novo Caderno",
newPodcast: "Novo Podcast",
language: "Idioma",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Fonte",
notebook: "Caderno",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const ptBR = {
errorDetails: "Detalhes do Erro",
editTransformation: "Editar Transformação",
retry: "Tentar Novamente",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "concluído",
saveSuccess: "Salvo com sucesso",
contextModes: {
@ -586,7 +573,7 @@ export const ptBR = {
loadErrorTitle: "Falha ao carregar episódios",
loadErrorDesc: "Não foi possível buscar os episódios de podcast mais recentes. Tente novamente em breve.",
loadingEpisodes: "Carregando episódios…",
noEpisodesYet: "Nenhum episódio de podcast ainda. Gere seu primeiro a partir das interfaces de chat de caderno ou fonte.",
noEpisodesYet: "Nenhum episódio de podcast ainda. Clique em Gerar Podcast para criar o primeiro.",
statusRunningTitle: "Processando Atualmente",
statusRunningDesc: "Episódios que estão gerando ativos ativamente.",
statusPendingTitle: "Na Fila / Pendentes",

View file

@ -22,17 +22,6 @@ export const ruRU = {
newNotebook: "Новый блокнот",
newPodcast: "Новый подкаст",
language: "Язык",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Источник",
notebook: "Блокнот",
podcast: "Подкаст",
@ -132,8 +121,6 @@ export const ruRU = {
errorDetails: "Детали ошибки",
editTransformation: "Редактировать трансформацию",
retry: "Повторить",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "завершено",
saveSuccess: "Успешно сохранено",
contextModes: {
@ -586,7 +573,7 @@ export const ruRU = {
loadErrorTitle: "Не удалось загрузить эпизоды",
loadErrorDesc: "Не удалось получить последние эпизоды подкастов. Попробуйте позже.",
loadingEpisodes: "Загрузка эпизодов…",
noEpisodesYet: "Пока нет эпизодов подкастов. Сгенерируйте первый из интерфейса чата блокнота или источника.",
noEpisodesYet: "Пока нет эпизодов подкастов. Нажмите «Сгенерировать подкаст», чтобы создать первый.",
statusRunningTitle: "В процессе",
statusRunningDesc: "Эпизоды, для которых активно генерируются материалы.",
statusPendingTitle: "В очереди / Ожидание",

View file

@ -22,17 +22,6 @@ export const trTR = {
newNotebook: "Yeni Defter",
newPodcast: "Yeni Podcast",
language: "Dil",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "Kaynak",
notebook: "Defter",
podcast: "Podcast",
@ -132,8 +121,6 @@ export const trTR = {
errorDetails: "Hata Ayrıntıları",
editTransformation: "Dönüşümü Düzenle",
retry: "Tekrar Dene",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "tamamlandı",
saveSuccess: "Başarıyla kaydedildi",
contextModes: {
@ -586,7 +573,7 @@ export const trTR = {
loadErrorTitle: "Bölümler yüklenemedi",
loadErrorDesc: "En son podcast bölümleri alınamadı. Kısa süre sonra tekrar deneyin.",
loadingEpisodes: "Bölümler yükleniyor…",
noEpisodesYet: "Henüz podcast bölümü yok. Defter veya kaynak sohbet arayüzlerinden ilk bölümünüzü oluşturun.",
noEpisodesYet: "Henüz podcast bölümü yok. İlk bölümünüzü oluşturmak için Podcast Oluştur'a tıklayın.",
statusRunningTitle: "Şu An İşleniyor",
statusRunningDesc: "Aktif olarak içerik oluşturan bölümler.",
statusPendingTitle: "Kuyrukta / Beklemede",

View file

@ -22,17 +22,6 @@ export const zhCN = {
newNotebook: "新建笔记本",
newPodcast: "新建播客",
language: "语言",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "来源",
notebook: "笔记本",
podcast: "播客",
@ -132,8 +121,6 @@ export const zhCN = {
errorDetails: "错误详情",
editTransformation: "编辑转换规则",
retry: "重试",
traditionalChinese: "繁体中文",
portuguese: "葡萄牙语",
completed: "已完成",
saveSuccess: "保存成功",
contextModes: {
@ -586,7 +573,7 @@ export const zhCN = {
loadErrorTitle: "加载单集失败",
loadErrorDesc: "无法获取最新的播客单集。请稍后重试。",
loadingEpisodes: "正在加载单集...",
noEpisodesYet: "暂无播客单集。从笔记本或来源聊天界面开始生成您的第一个单集。",
noEpisodesYet: "暂无播客单集。点击“生成播客”创建您的第一个单集。",
statusRunningTitle: "正在处理中",
statusRunningDesc: "正在活跃生成资产的单集。",
statusPendingTitle: "排队中 / 待处理",

View file

@ -22,17 +22,6 @@ export const zhTW = {
newNotebook: "新增筆記本",
newPodcast: "新增播客",
language: "語言",
english: "English",
chinese: "簡體中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
bengali: "বাংলা",
catalan: "Català",
spanish: "Español",
german: "Deutsch",
polish: "Polski",
turkish: "Türkçe",
source: "來源",
notebook: "筆記本",
podcast: "播客",
@ -132,8 +121,6 @@ export const zhTW = {
errorDetails: "錯誤詳情",
editTransformation: "編輯轉換規則",
retry: "重試",
traditionalChinese: "繁體中文",
portuguese: "葡萄牙語",
completed: "已完成",
saveSuccess: "儲存成功",
contextModes: {
@ -586,7 +573,7 @@ export const zhTW = {
loadErrorTitle: "載入單集失敗",
loadErrorDesc: "無法獲取最新的播客單集。請稍後重試。",
loadingEpisodes: "正在載入單集...",
noEpisodesYet: "暫無播客單集。從筆記本或來源聊天介面開始生成您的第一個單集。",
noEpisodesYet: "暫無播客單集。點擊「生成播客」建立您的第一個單集。",
statusRunningTitle: "正在處理中",
statusRunningDesc: "正在活躍生成資產的單集。",
statusPendingTitle: "排隊中 / 待處理",

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { enUS, it as itLocale } from 'date-fns/locale'
import { languages } from '@/lib/locales'
import { getDateLocale } from './date-locale'
describe('getDateLocale', () => {
it('formats Italian dates in Italian', () => {
expect(getDateLocale('it-IT')).toBe(itLocale)
})
it('has a date locale for every registered language', () => {
for (const { code } of languages) {
if (code === 'en-US') continue
expect(getDateLocale(code), code).not.toBe(enUS)
}
})
it('falls back to English for unknown codes', () => {
expect(getDateLocale('xx-XX')).toBe(enUS)
})
})

View file

@ -1,4 +1,4 @@
import { zhCN, enUS, zhTW, ptBR, ja, fr, ru, bn, ca, es, de, pl, tr, Locale } from 'date-fns/locale'
import { zhCN, enUS, zhTW, ptBR, ja, it, fr, ru, bn, ca, es, de, pl, tr, Locale } from 'date-fns/locale'
/**
* Mapping of language codes to date-fns locales.
@ -10,6 +10,7 @@ const LOCALE_MAP: Record<string, Locale> = {
'en-US': enUS,
'pt-BR': ptBR,
'ja-JP': ja,
'it-IT': it,
'fr-FR': fr,
'ru-RU': ru,
'bn-IN': bn,

View file

@ -43,10 +43,12 @@ def get_database_url() -> str:
if surreal_url:
return surreal_url
# Fallback to old format - WebSocket URL format
# Fallback to the legacy variables. SURREAL_ADDRESS may already carry the
# port ("host:8000", "[::1]:8000"); SURREAL_PORT only applies when it doesn't.
address = os.getenv("SURREAL_ADDRESS", "localhost")
port = os.getenv("SURREAL_PORT", "8000")
return f"ws://{address}/rpc:{port}"
if ":" not in address.rsplit("]", 1)[-1]:
address = f"{address}:{os.getenv('SURREAL_PORT', '8000')}"
return f"ws://{address}/rpc"
def get_database_password() -> str:

View file

@ -23,7 +23,7 @@ _CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None
(
["authentication", "unauthorized", "invalid api key", "invalid_api_key", "401"],
AuthenticationError,
"Authentication failed. Please check your API key in Settings -> Credentials.",
"Authentication failed. Please check your API key in Manage -> Models.",
),
# Rate limit errors
(
@ -39,23 +39,29 @@ _CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None
),
# Configuration errors from provision.py (pass through)
(
["no model configured", "please go to settings"],
["no model configured", "please go to settings", "please go to manage"],
ConfigurationError,
None,
),
# Network errors
# Network errors (connect timeouts included: the provider was never reached)
(
[
"connecterror",
"timeoutexception",
"connecttimeout",
"connection timed out",
"connection refused",
"connection error",
"timed out",
"timeout",
],
NetworkError,
"Could not connect to the AI provider. Please check your network connection and provider URL.",
),
# Read timeouts: the provider was reached but didn't answer within
# ESPERANTO_LLM_TIMEOUT (180 s unless set)
(
["timeoutexception", "timed out", "timeout"],
NetworkError,
"The AI provider took too long to respond. Try again, use a faster model, or raise ESPERANTO_LLM_TIMEOUT (180 seconds by default).",
),
# Context length errors
(
[

View file

@ -0,0 +1,44 @@
"""get_database_url() fallback to the legacy SURREAL_ADDRESS / SURREAL_PORT variables."""
import pytest
from open_notebook.database.repository import get_database_url
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for name in ("SURREAL_URL", "SURREAL_ADDRESS", "SURREAL_PORT"):
monkeypatch.delenv(name, raising=False)
def test_surreal_url_wins(monkeypatch):
monkeypatch.setenv("SURREAL_URL", "ws://db:8000/rpc")
monkeypatch.setenv("SURREAL_ADDRESS", "other")
assert get_database_url() == "ws://db:8000/rpc"
def test_defaults():
assert get_database_url() == "ws://localhost:8000/rpc"
def test_address_and_port(monkeypatch):
monkeypatch.setenv("SURREAL_ADDRESS", "db.internal")
monkeypatch.setenv("SURREAL_PORT", "8018")
assert get_database_url() == "ws://db.internal:8018/rpc"
def test_bracketed_ipv6_without_port_gets_surreal_port(monkeypatch):
monkeypatch.setenv("SURREAL_ADDRESS", "[::1]")
monkeypatch.setenv("SURREAL_PORT", "8018")
assert get_database_url() == "ws://[::1]:8018/rpc"
def test_bracketed_ipv6_with_port(monkeypatch):
monkeypatch.setenv("SURREAL_ADDRESS", "[::1]:8000")
assert get_database_url() == "ws://[::1]:8000/rpc"
def test_address_with_port_ignores_surreal_port(monkeypatch):
monkeypatch.setenv("SURREAL_ADDRESS", "10.1.2.3:8000")
monkeypatch.setenv("SURREAL_PORT", "9999")
assert get_database_url() == "ws://10.1.2.3:8000/rpc"

View file

@ -0,0 +1,60 @@
"""classify_error() tells a slow provider apart from an unreachable one."""
import httpx
from open_notebook.exceptions import (
AuthenticationError,
ConfigurationError,
NetworkError,
)
from open_notebook.utils.error_classifier import classify_error
def test_read_timeout_says_the_provider_was_slow():
exc_class, message = classify_error(
httpx.ReadTimeout("The read operation timed out")
)
assert exc_class is NetworkError
assert "took too long" in message
assert "ESPERANTO_LLM_TIMEOUT" in message
def test_openai_style_timeout_says_the_provider_was_slow():
class APITimeoutError(Exception):
pass
_, message = classify_error(APITimeoutError("Request timed out."))
assert "took too long" in message
def test_connect_timeout_is_a_connection_problem():
exc_class, message = classify_error(httpx.ConnectTimeout("timed out"))
assert exc_class is NetworkError
assert "Could not connect" in message
def test_connection_refused_is_a_connection_problem():
exc_class, message = classify_error(
httpx.ConnectError("[Errno 111] Connection refused")
)
assert exc_class is NetworkError
assert "Could not connect" in message
def test_os_connection_timed_out_is_a_connection_problem():
exc_class, message = classify_error(OSError("[Errno 110] Connection timed out"))
assert exc_class is NetworkError
assert "Could not connect" in message
def test_auth_error_points_to_the_models_page():
exc_class, message = classify_error(Exception("401 invalid api key"))
assert exc_class is AuthenticationError
assert "Manage -> Models" in message
def test_missing_default_model_passes_through():
raw = "No model configured for default for type=chat. Please go to Manage → Models and configure a default model for 'chat'."
exc_class, message = classify_error(ValueError(raw))
assert exc_class is ConfigurationError
assert message == raw

View file

@ -50,7 +50,7 @@ class TestExplainGenerationFailure:
)
assert hint is not None
assert "voice_id" in hint
assert "Speaker Profiles" in hint
assert "Podcasts -> Profiles" in hint
@pytest.mark.parametrize(
"message",