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.
- desktop_ai_core-0.1.0/.github/workflows/pypi.yml +35 -0
- desktop_ai_core-0.1.0/.gitignore +35 -0
- desktop_ai_core-0.1.0/LICENSE +21 -0
- desktop_ai_core-0.1.0/PKG-INFO +42 -0
- desktop_ai_core-0.1.0/README.md +3 -0
- desktop_ai_core-0.1.0/desktop_ai_core/__init__.py +3 -0
- desktop_ai_core-0.1.0/desktop_ai_core/_version.py +24 -0
- desktop_ai_core-0.1.0/desktop_ai_core/frontends/__init__.py +17 -0
- desktop_ai_core-0.1.0/desktop_ai_core/frontends/abstract.py +55 -0
- desktop_ai_core-0.1.0/desktop_ai_core/frontends/dialog.py +25 -0
- desktop_ai_core-0.1.0/desktop_ai_core/frontends/terminal.py +101 -0
- desktop_ai_core-0.1.0/desktop_ai_core/frontends/tray.py +117 -0
- desktop_ai_core-0.1.0/desktop_ai_core/install.py +42 -0
- desktop_ai_core-0.1.0/desktop_ai_core/providers/__init__.py +35 -0
- desktop_ai_core-0.1.0/desktop_ai_core/providers/base.py +68 -0
- desktop_ai_core-0.1.0/desktop_ai_core/providers/errors.py +23 -0
- desktop_ai_core-0.1.0/desktop_ai_core/providers/registry.py +71 -0
- desktop_ai_core-0.1.0/desktop_ai_core.egg-info/PKG-INFO +42 -0
- desktop_ai_core-0.1.0/desktop_ai_core.egg-info/SOURCES.txt +21 -0
- desktop_ai_core-0.1.0/desktop_ai_core.egg-info/dependency_links.txt +1 -0
- desktop_ai_core-0.1.0/desktop_ai_core.egg-info/top_level.txt +1 -0
- desktop_ai_core-0.1.0/pyproject.toml +34 -0
- desktop_ai_core-0.1.0/setup.cfg +4 -0
|
@@ -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,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
|
+
|
|
@@ -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"
|