desktop-ai-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ permissions:
14
+ id-token: write # This is required for OIDC
15
+ contents: read
16
+
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v2
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v2
23
+ with:
24
+ python-version: '3.x'
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install setuptools setuptools-scm[toml] wheel build
30
+
31
+ - name: Build distribution
32
+ run: python -m build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ .venv/
12
+ venv/
13
+ env/
14
+ .env
15
+
16
+ # setuptools_scm
17
+ desktop_ai_core/_version.py
18
+
19
+ # Distribution / packaging
20
+ *.tar.gz
21
+ *.whl
22
+ MANIFEST
23
+
24
+ # Testing
25
+ .tox/
26
+ .pytest_cache/
27
+ htmlcov/
28
+ .coverage
29
+ coverage.xml
30
+
31
+ # IDE
32
+ .idea/
33
+ .vscode/
34
+ *.swp
35
+ *.swo
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Mahé Perrette
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: desktop-ai-core
3
+ Version: 0.1.0
4
+ Summary: Shared provider abstractions and frontend scaffolding for desktop AI apps (Bard, Scribe, etc.)
5
+ Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2024 Mahé Perrette
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/perrette/desktop-ai-core
29
+ Classifier: Programming Language :: Python :: 3.9
30
+ Classifier: Programming Language :: Python :: 3.10
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.9
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Dynamic: license-file
39
+
40
+ # desktop-ai-core
41
+
42
+ Shared provider abstractions and frontend scaffolding for desktop AI applications. This package supplies the common primitives consumed by [Bard](https://github.com/perrette/bard) (TTS) and [Scribe](https://github.com/perrette/scribe) (STT): a `TTSBackend` / `STTBackend` / `Voice` / `LanguageModel` type hierarchy, a provider registry, a generic `AbstractFrontendApp` lifecycle class, a terminal menu mini-framework, tray-icon helpers, and a cross-platform desktop-file installer — so that each app can focus on its domain-specific logic rather than re-implementing the shared shell.
@@ -0,0 +1,3 @@
1
+ # desktop-ai-core
2
+
3
+ Shared provider abstractions and frontend scaffolding for desktop AI applications. This package supplies the common primitives consumed by [Bard](https://github.com/perrette/bard) (TTS) and [Scribe](https://github.com/perrette/scribe) (STT): a `TTSBackend` / `STTBackend` / `Voice` / `LanguageModel` type hierarchy, a provider registry, a generic `AbstractFrontendApp` lifecycle class, a terminal menu mini-framework, tray-icon helpers, and a cross-platform desktop-file installer — so that each app can focus on its domain-specific logic rather than re-implementing the shared shell.
@@ -0,0 +1,3 @@
1
+ from .install import install_desktop_file
2
+
3
+ __all__ = ["install_desktop_file"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'gc08712694'
@@ -0,0 +1,17 @@
1
+ from desktop_ai_core.frontends.abstract import AbstractFrontendApp
2
+ from desktop_ai_core.frontends.terminal import Item, SetValueItem, Menu
3
+ from desktop_ai_core.frontends.tray import flag_for, MultiStateTrayIcon, write_pidfile, remove_pidfile, register_signal_toggle
4
+ from desktop_ai_core.frontends.dialog import show_error_dialog
5
+
6
+ __all__ = [
7
+ "AbstractFrontendApp",
8
+ "Item",
9
+ "SetValueItem",
10
+ "Menu",
11
+ "flag_for",
12
+ "MultiStateTrayIcon",
13
+ "write_pidfile",
14
+ "remove_pidfile",
15
+ "register_signal_toggle",
16
+ "show_error_dialog",
17
+ ]
@@ -0,0 +1,55 @@
1
+ import logging
2
+ from typing import Callable
3
+
4
+ _default_logger = logging.getLogger("desktop_ai_core")
5
+
6
+
7
+ class AbstractFrontendApp:
8
+ """Generic frontend application scaffolding.
9
+
10
+ Holds the lifecycle pieces that are shared across desktop AI apps:
11
+ a ``view`` reference, a parameter dict, a logger, and an
12
+ ``error_callback`` hook surfaced through :meth:`notify_error`.
13
+ Audio, clipboard, and chunk-rendering concerns belong to subclasses.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ params=None,
19
+ view=None,
20
+ logger=_default_logger,
21
+ error_callback: Callable[[str, str], None] | None = None,
22
+ ):
23
+ self.params = params or {}
24
+ self.view = view
25
+ self.logger = logger
26
+ self.error_callback = error_callback
27
+
28
+ def notify_error(self, title: str, message: str) -> None:
29
+ self.logger.error(f"{title}: {message}")
30
+ if self.error_callback is not None:
31
+ try:
32
+ self.error_callback(title, message)
33
+ except Exception as cb_exc:
34
+ self.logger.error(f"error_callback raised: {cb_exc}")
35
+
36
+ def set_param(self, item, value=None):
37
+ self.params[str(item)] = item.value if hasattr(item, "value") and value is None else value
38
+
39
+ def get_param(self, item):
40
+ return self.params.get(str(item))
41
+
42
+ def checked(self, item):
43
+ return self.get_param(str(item))
44
+
45
+ def callback_toggle_option(self, view, item):
46
+ self.set_param(str(item), not self.get_param(str(item)))
47
+
48
+ def set_audioplayer(self, view, player):
49
+ """Protocol hook for subclasses that own an audio player.
50
+
51
+ Subclasses with audio concerns override this to wire the player
52
+ to the view. The base implementation is a no-op so generic
53
+ callers can invoke it unconditionally.
54
+ """
55
+ return None
@@ -0,0 +1,25 @@
1
+ """Error dialog helper — safe to call from any thread."""
2
+
3
+
4
+ def show_error_dialog(title: str, message: str) -> None:
5
+ """Pop a modal error dialog. Safe to call from any thread.
6
+
7
+ A fresh Tk root is created inside a daemon thread on each call so it never
8
+ contends with the pystray/GTK main loop that runs in app mode. Degrades
9
+ gracefully if Tk is unavailable (logs to stdout instead).
10
+ """
11
+ import threading
12
+
13
+ def _run():
14
+ try:
15
+ import tkinter as tk
16
+ from tkinter import messagebox
17
+ root = tk.Tk()
18
+ root.withdraw()
19
+ root.attributes("-topmost", True)
20
+ messagebox.showerror(title, message)
21
+ root.destroy()
22
+ except Exception as exc:
23
+ print(f"[error dialog failed: {exc!r}] {title}: {message}")
24
+
25
+ threading.Thread(target=_run, daemon=True).start()
@@ -0,0 +1,101 @@
1
+ class Item:
2
+ def __init__(self, name, callback, checked=None, checkable=False, visible=True, help=""):
3
+ self.name = name
4
+ self._callback = callback
5
+ self.checkable = checkable or (checked is not None)
6
+ self.checked = (checked if callable(checked) else lambda item: checked)
7
+ self.help = help
8
+ self.visible = visible if callable(visible) else lambda item: visible
9
+
10
+ def __call__(self, app, item):
11
+ return self._callback(app, self)
12
+
13
+ def __str__(self):
14
+ return self.name
15
+
16
+ class SetValueItem(Item):
17
+ def __init__(self, name, callback, value=None, choices=None, type=None, **kwargs):
18
+ super().__init__(name, callback, **kwargs)
19
+ self.value = value
20
+ self.choices = choices
21
+ self.type = type
22
+
23
+ def _isvalid(self, value):
24
+
25
+ if self.type:
26
+ try:
27
+ value = self.type(value)
28
+ except ValueError as error:
29
+ print(f"Invalid type: {str(error)}")
30
+ return False
31
+
32
+ if self.choices and value not in self.choices:
33
+ print(f"Valid choices are {', '.join(map(str, self.choices))}. Got: {str(error)}")
34
+ return False
35
+
36
+ return True
37
+
38
+ def __call__(self, app, item):
39
+ self.is_active = True
40
+ while self.is_active:
41
+ value = self.value(item)
42
+ ans = input(f"Enter value for {self.name} (current {value}): ")
43
+ if not ans.strip():
44
+ ans = value
45
+ if self._isvalid(ans):
46
+ self.value = lambda item: ans
47
+ self.is_active = False
48
+ return self._callback(app, item)
49
+
50
+ class Menu:
51
+ def __init__(self, items, name=None, help=""):
52
+ self.items = items
53
+ self.name = name
54
+ self.help = help
55
+ self.choices = {}
56
+ self.is_active_menu = False
57
+
58
+ def __call__(self, app, _):
59
+ self.is_active_menu = True
60
+ while app.is_running and self.is_active_menu:
61
+ self.show(app)
62
+ self.prompt(app)
63
+
64
+ def show(self, app):
65
+ print(f"\n{self.name or 'Options:'}")
66
+
67
+ count = 0
68
+ for item in self.items:
69
+ if not item.visible(item):
70
+ continue
71
+ count += 1
72
+ ticked = " "
73
+ if item.checkable and item.checked(item):
74
+ ticked = "✓"
75
+ if hasattr(item, "value") and item.value(item):
76
+ suffix = f"({item.value(item)})"
77
+ else:
78
+ suffix = ""
79
+ print(f"{ticked} {count}. {item.help or item.name} {suffix}")
80
+ self.choices[str(count)] = item
81
+ self.choices[item.name] = item
82
+
83
+ def prompt(self, app, title=None):
84
+
85
+ if getattr(app, "_player", None):
86
+ app.update_progress(app._player)
87
+
88
+ choice = input("\nChoose an option: ")
89
+
90
+ if choice in self.choices:
91
+ item = self.choices[choice]
92
+ print(item)
93
+ ans = item(app, item)
94
+ if isinstance(ans, bool):
95
+ self.is_active_menu = ans
96
+
97
+ elif choice in ("quit", "q"):
98
+ self.is_active_menu = False
99
+
100
+ else:
101
+ return print(f"Invalid choice: {choice}")
@@ -0,0 +1,117 @@
1
+ import time as _time
2
+ import os as _os
3
+ import signal as _signal
4
+ import logging as _logging
5
+ from pathlib import Path
6
+
7
+
8
+ _FLAGS = {
9
+ "en-US": "🇺🇸",
10
+ "en-GB": "🇬🇧",
11
+ "fr-FR": "🇫🇷",
12
+ "de-DE": "🇩🇪",
13
+ "es-ES": "🇪🇸",
14
+ "it-IT": "🇮🇹",
15
+ "ja-JP": "🇯🇵",
16
+ "zh-CN": "🇨🇳",
17
+ "hi-IN": "🇮🇳",
18
+ "pt-BR": "🇧🇷",
19
+ "pt-PT": "🇵🇹",
20
+ }
21
+
22
+
23
+ def flag_for(language: str | None) -> str:
24
+ if language is None:
25
+ return ""
26
+ return _FLAGS.get(language, "")
27
+
28
+
29
+ def write_pidfile(name: str) -> Path:
30
+ """Write a PID file for the named application. Returns the file path."""
31
+ runtime_dir = _os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
32
+ pid_path = Path(runtime_dir) / f"{name}.pid"
33
+ with open(pid_path, "w") as f:
34
+ f.write(str(_os.getpid()))
35
+ pid_path.chmod(0o600)
36
+ return pid_path
37
+
38
+
39
+ def remove_pidfile(name: str) -> None:
40
+ """Remove the PID file for the named application. Silently ignores missing files."""
41
+ runtime_dir = _os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
42
+ pid_path = Path(runtime_dir) / f"{name}.pid"
43
+ try:
44
+ pid_path.unlink()
45
+ except FileNotFoundError:
46
+ pass
47
+
48
+
49
+ def register_signal_toggle(signal_number: int, callback) -> None:
50
+ """Register *callback* as the handler for *signal_number*.
51
+
52
+ On platforms where the signal is unavailable the call is a no-op logged at
53
+ DEBUG level instead of raising.
54
+ """
55
+ try:
56
+ _signal.signal(signal_number, lambda *_: callback())
57
+ except (OSError, ValueError) as exc:
58
+ _logging.debug("register_signal_toggle: signal %d not available: %s", signal_number, exc)
59
+
60
+
61
+ _UNSET = object()
62
+
63
+
64
+ class MultiStateTrayIcon:
65
+ """Drive state-based image swaps on a pystray-style tray icon.
66
+
67
+ The helper wraps an existing icon and a mapping of state-name -> PIL
68
+ image. A caller-supplied ``get_state`` callable is queried on each
69
+ ``update`` to derive the current logical state (e.g. ``"recording"``,
70
+ ``"busy"``, or ``None`` for idle); when the value differs from the
71
+ previously applied state, the icon's image is swapped and
72
+ ``icon.update_menu()`` is called so visibility predicates re-evaluate.
73
+
74
+ A ``start_monitoring`` helper runs a poll loop (intended to be invoked
75
+ from a background thread) that keeps calling ``update`` while a
76
+ ``should_continue`` callable returns truthy, then performs one final
77
+ update before exiting so the icon settles to whatever the current state
78
+ is when the loop ends.
79
+
80
+ Parameters
81
+ ----------
82
+ icon:
83
+ A pystray ``Icon``-compatible object exposing assignable ``.icon``
84
+ and a callable ``.update_menu()``.
85
+ images:
86
+ Mapping of state name -> ``PIL.Image``. Use ``None`` as the key for
87
+ the idle state. Every value that ``get_state`` can return must be a
88
+ key.
89
+ get_state:
90
+ Zero-argument callable returning the current state name (or
91
+ ``None``).
92
+ poll_interval:
93
+ Seconds between polls in ``start_monitoring``. Defaults to 0.1.
94
+ """
95
+
96
+ def __init__(self, icon, images, get_state, poll_interval=0.1):
97
+ self.icon = icon
98
+ self.images = images
99
+ self.get_state = get_state
100
+ self.poll_interval = poll_interval
101
+ self._current = _UNSET
102
+
103
+ def update(self, force=False):
104
+ state = self.get_state()
105
+ if not force and state == self._current:
106
+ return
107
+ self.icon.icon = self.images[state]
108
+ self._current = state
109
+ self.icon.update_menu()
110
+
111
+ def start_monitoring(self, should_continue):
112
+ try:
113
+ while should_continue():
114
+ self.update()
115
+ _time.sleep(self.poll_interval)
116
+ finally:
117
+ self.update()
@@ -0,0 +1,42 @@
1
+ import os
2
+ import platform
3
+
4
+
5
+ def install_desktop_file(
6
+ template: str,
7
+ name: str,
8
+ icon_folder: str,
9
+ bin_folder: str,
10
+ terminal: bool,
11
+ startup_wm_class: str | None,
12
+ options: str = "",
13
+ ) -> str:
14
+ """Write a .desktop entry under XDG_DATA_HOME/applications/ and return its path.
15
+
16
+ Raises NotImplementedError on non-Linux platforms.
17
+ """
18
+ if platform.system() != "Linux":
19
+ raise NotImplementedError("Desktop-file installation is only supported on Linux.")
20
+
21
+ simple_name = name.lower().replace(" ", "-").replace(os.path.sep, "-")
22
+ resolved_wm_class = startup_wm_class or f"crx_mpnasdandanpmm_{simple_name}"
23
+
24
+ home = os.environ.get("HOME", os.path.expanduser("~"))
25
+ xdg_share = os.environ.get("XDG_DATA_HOME", os.path.join(home, ".local", "share"))
26
+ xdg_app_data = os.path.join(xdg_share, "applications")
27
+ os.makedirs(xdg_app_data, exist_ok=True)
28
+
29
+ content = template.format(
30
+ icon_folder=icon_folder,
31
+ bin_folder=bin_folder,
32
+ name=name,
33
+ terminal=str(terminal).lower(),
34
+ StartupWMClass=resolved_wm_class,
35
+ options=options,
36
+ )
37
+
38
+ desktop_filepath = os.path.join(xdg_app_data, f"{simple_name}.desktop")
39
+ with open(desktop_filepath, "w") as f:
40
+ f.write(content)
41
+
42
+ return desktop_filepath
@@ -0,0 +1,35 @@
1
+ from desktop_ai_core.providers.base import (
2
+ Backend,
3
+ LanguageModel,
4
+ STTBackend,
5
+ TTSBackend,
6
+ Voice,
7
+ )
8
+ from desktop_ai_core.providers.errors import format_openai_error
9
+ from desktop_ai_core.providers.registry import (
10
+ available_stt,
11
+ available_tts,
12
+ get_stt,
13
+ get_tts,
14
+ probe_stt,
15
+ probe_tts,
16
+ register_stt,
17
+ register_tts,
18
+ )
19
+
20
+ __all__ = [
21
+ "Backend",
22
+ "LanguageModel",
23
+ "STTBackend",
24
+ "TTSBackend",
25
+ "Voice",
26
+ "available_stt",
27
+ "available_tts",
28
+ "format_openai_error",
29
+ "get_stt",
30
+ "get_tts",
31
+ "probe_stt",
32
+ "probe_tts",
33
+ "register_stt",
34
+ "register_tts",
35
+ ]
@@ -0,0 +1,68 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import ClassVar, Iterator
5
+
6
+
7
+ class Backend(ABC):
8
+ name: str
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Voice:
13
+ id: str
14
+ language: str | None = None
15
+ gender: str | None = None
16
+ display: str | None = None
17
+
18
+ def __str__(self) -> str:
19
+ return self.id
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LanguageModel:
24
+ id: str
25
+ display: str | None = None
26
+ description: str | None = None
27
+
28
+ def __str__(self) -> str:
29
+ return self.id
30
+
31
+
32
+ class TTSBackend(Backend):
33
+ name: str
34
+ default_voice: str
35
+ default_model: str | None
36
+ output_format: str
37
+ sample_rate: int | None
38
+ supports_streaming: bool = False
39
+ is_local: ClassVar[bool] = False
40
+ install_hint: ClassVar[str | None] = None
41
+
42
+ @abstractmethod
43
+ def synthesize(self, text: str, out_path: Path) -> Path:
44
+ ...
45
+
46
+ @abstractmethod
47
+ def list_voices(self) -> list[str]:
48
+ ...
49
+
50
+ def list_voices_meta(self) -> list["Voice"]:
51
+ return [Voice(id=v) for v in self.list_voices()]
52
+
53
+ def list_models(self) -> list[str]:
54
+ return [self.default_model] if self.default_model else []
55
+
56
+ def synthesize_stream(self, text: str) -> Iterator[bytes]:
57
+ raise NotImplementedError
58
+
59
+
60
+ class STTBackend(Backend):
61
+ name: str
62
+ default_model: str | None = None
63
+ is_local: ClassVar[bool] = False
64
+ install_hint: ClassVar[str | None] = None
65
+
66
+ @abstractmethod
67
+ def transcribe(self, audio_path: Path) -> str:
68
+ ...
@@ -0,0 +1,23 @@
1
+ def format_openai_error(exc):
2
+ """Turn an openai exception into a (title, message) tuple suited for a user dialog."""
3
+ import openai
4
+ body = getattr(exc, "body", None) or {}
5
+ err = body.get("error") if isinstance(body, dict) else None
6
+ code = (err or {}).get("code") if isinstance(err, dict) else None
7
+ api_message = (err or {}).get("message") if isinstance(err, dict) else None
8
+ detail = api_message or str(exc) or exc.__class__.__name__
9
+
10
+ if isinstance(exc, openai.AuthenticationError):
11
+ return "Authentication failed", f"Check your API key.\n\n{detail}"
12
+ if isinstance(exc, openai.PermissionDeniedError):
13
+ return "Permission denied", detail
14
+ if isinstance(exc, openai.RateLimitError):
15
+ if code == "insufficient_quota" or "quota" in detail.lower() or "credit" in detail.lower():
16
+ return ("Credits exhausted",
17
+ f"Your account is out of credits or has hit its quota.\n\n{detail}")
18
+ return "Rate limit", detail
19
+ if isinstance(exc, openai.APIConnectionError):
20
+ return "Connection error", f"Could not reach the API.\n\n{detail}"
21
+ if isinstance(exc, openai.BadRequestError):
22
+ return "Bad request", detail
23
+ return f"API error ({exc.__class__.__name__})", detail
@@ -0,0 +1,71 @@
1
+ from typing import Callable
2
+
3
+ from desktop_ai_core.providers.base import STTBackend, TTSBackend
4
+
5
+
6
+ _TTS_REGISTRY: dict[str, type[TTSBackend]] = {}
7
+ _TTS_PROBES: dict[str, Callable[[], tuple[bool, str | None]]] = {}
8
+
9
+
10
+ def register_tts(
11
+ name: str,
12
+ backend_cls: type[TTSBackend],
13
+ *,
14
+ probe: Callable[[], tuple[bool, str | None]] | None = None,
15
+ ) -> type[TTSBackend]:
16
+ _TTS_REGISTRY[name] = backend_cls
17
+ if probe is not None:
18
+ _TTS_PROBES[name] = probe
19
+ return backend_cls
20
+
21
+
22
+ def get_tts(name: str, **kwargs) -> TTSBackend:
23
+ if name not in _TTS_REGISTRY:
24
+ raise KeyError(name)
25
+ return _TTS_REGISTRY[name](**kwargs)
26
+
27
+
28
+ def available_tts() -> list[str]:
29
+ return list(_TTS_REGISTRY)
30
+
31
+
32
+ def probe_tts(name: str) -> tuple[bool, str | None]:
33
+ if name not in _TTS_REGISTRY:
34
+ raise KeyError(name)
35
+ if name in _TTS_PROBES:
36
+ return _TTS_PROBES[name]()
37
+ return True, None
38
+
39
+
40
+ _STT_REGISTRY: dict[str, type[STTBackend]] = {}
41
+ _STT_PROBES: dict[str, Callable[[], tuple[bool, str | None]]] = {}
42
+
43
+
44
+ def register_stt(
45
+ name: str,
46
+ backend_cls: type[STTBackend],
47
+ *,
48
+ probe: Callable[[], tuple[bool, str | None]] | None = None,
49
+ ) -> type[STTBackend]:
50
+ _STT_REGISTRY[name] = backend_cls
51
+ if probe is not None:
52
+ _STT_PROBES[name] = probe
53
+ return backend_cls
54
+
55
+
56
+ def get_stt(name: str, **kwargs) -> STTBackend:
57
+ if name not in _STT_REGISTRY:
58
+ raise KeyError(name)
59
+ return _STT_REGISTRY[name](**kwargs)
60
+
61
+
62
+ def available_stt() -> list[str]:
63
+ return list(_STT_REGISTRY)
64
+
65
+
66
+ def probe_stt(name: str) -> tuple[bool, str | None]:
67
+ if name not in _STT_REGISTRY:
68
+ raise KeyError(name)
69
+ if name in _STT_PROBES:
70
+ return _STT_PROBES[name]()
71
+ return True, None
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: desktop-ai-core
3
+ Version: 0.1.0
4
+ Summary: Shared provider abstractions and frontend scaffolding for desktop AI apps (Bard, Scribe, etc.)
5
+ Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2024 Mahé Perrette
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/perrette/desktop-ai-core
29
+ Classifier: Programming Language :: Python :: 3.9
30
+ Classifier: Programming Language :: Python :: 3.10
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.9
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Dynamic: license-file
39
+
40
+ # desktop-ai-core
41
+
42
+ Shared provider abstractions and frontend scaffolding for desktop AI applications. This package supplies the common primitives consumed by [Bard](https://github.com/perrette/bard) (TTS) and [Scribe](https://github.com/perrette/scribe) (STT): a `TTSBackend` / `STTBackend` / `Voice` / `LanguageModel` type hierarchy, a provider registry, a generic `AbstractFrontendApp` lifecycle class, a terminal menu mini-framework, tray-icon helpers, and a cross-platform desktop-file installer — so that each app can focus on its domain-specific logic rather than re-implementing the shared shell.
@@ -0,0 +1,21 @@
1
+ .gitignore
2
+ LICENSE
3
+ README.md
4
+ pyproject.toml
5
+ .github/workflows/pypi.yml
6
+ desktop_ai_core/__init__.py
7
+ desktop_ai_core/_version.py
8
+ desktop_ai_core/install.py
9
+ desktop_ai_core.egg-info/PKG-INFO
10
+ desktop_ai_core.egg-info/SOURCES.txt
11
+ desktop_ai_core.egg-info/dependency_links.txt
12
+ desktop_ai_core.egg-info/top_level.txt
13
+ desktop_ai_core/frontends/__init__.py
14
+ desktop_ai_core/frontends/abstract.py
15
+ desktop_ai_core/frontends/dialog.py
16
+ desktop_ai_core/frontends/terminal.py
17
+ desktop_ai_core/frontends/tray.py
18
+ desktop_ai_core/providers/__init__.py
19
+ desktop_ai_core/providers/base.py
20
+ desktop_ai_core/providers/errors.py
21
+ desktop_ai_core/providers/registry.py
@@ -0,0 +1 @@
1
+ desktop_ai_core
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "desktop-ai-core"
7
+ dynamic = ["version"]
8
+ description = "Shared provider abstractions and frontend scaffolding for desktop AI apps (Bard, Scribe, etc.)"
9
+ authors = [
10
+ { name="Mahé Perrette", email="mahe.perrette@gmail.com" }
11
+ ]
12
+ readme = "README.md"
13
+ license = { file="LICENSE" }
14
+ requires-python = ">=3.9"
15
+ dependencies = []
16
+
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/perrette/desktop-ai-core"
28
+
29
+ [tool.setuptools]
30
+ packages = ["desktop_ai_core", "desktop_ai_core.providers", "desktop_ai_core.frontends"]
31
+
32
+ [tool.setuptools_scm]
33
+ write_to = "desktop_ai_core/_version.py"
34
+ fallback_version = "0.1.0.dev0"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+