apirrone
commited on
Commit
·
c2d2d9e
1
Parent(s):
0c3901b
starting to add personality edition capabilities
Browse files- .gitignore +3 -0
- README.md +8 -0
- src/reachy_mini_conversation_app/main.py +393 -1
- src/reachy_mini_conversation_app/openai_realtime.py +35 -0
.gitignore
CHANGED
|
@@ -56,3 +56,6 @@ cache/
|
|
| 56 |
.directory
|
| 57 |
.Trash-*
|
| 58 |
.nfs*
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
.directory
|
| 57 |
.Trash-*
|
| 58 |
.nfs*
|
| 59 |
+
|
| 60 |
+
# User-created personalities (managed by UI)
|
| 61 |
+
src/reachy_mini_conversation_app/profiles/user_personalities/
|
README.md
CHANGED
|
@@ -196,6 +196,14 @@ Tools are resolved first from Python files in the profile folder (custom tools),
|
|
| 196 |
On top of built-in tools found in the shared library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
|
| 197 |
Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
|
|
|
|
| 196 |
On top of built-in tools found in the shared library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
|
| 197 |
Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
|
| 198 |
|
| 199 |
+
### Edit personalities from the UI
|
| 200 |
+
When running with `--gradio`, open the “Personality” accordion:
|
| 201 |
+
- Select among available profiles (folders under `src/reachy_mini_conversation_app/profiles/`) or the built‑in default.
|
| 202 |
+
- Click “Apply” to update the current session instructions live.
|
| 203 |
+
- Create a new personality by entering a name and instructions text; it stores files under `profiles/<name>/` and copies `tools.txt` from the `default` profile.
|
| 204 |
+
|
| 205 |
+
Note: The “Personality” panel updates the conversation instructions. Tool sets are loaded at startup from `tools.txt` and are not hot‑reloaded.
|
| 206 |
+
|
| 207 |
|
| 208 |
|
| 209 |
|
src/reachy_mini_conversation_app/main.py
CHANGED
|
@@ -19,6 +19,8 @@ from reachy_mini_conversation_app.utils import (
|
|
| 19 |
setup_logger,
|
| 20 |
handle_vision_stuff,
|
| 21 |
)
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def update_chatbot(chatbot: List[Dict[str, Any]], response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
@@ -102,11 +104,142 @@ def run(
|
|
| 102 |
type="password",
|
| 103 |
value=os.getenv("OPENAI_API_KEY") if not get_space() else "",
|
| 104 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
stream = Stream(
|
| 106 |
handler=handler,
|
| 107 |
mode="send-receive",
|
| 108 |
modality="audio",
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
additional_outputs=[chatbot],
|
| 111 |
additional_outputs_handler=update_chatbot,
|
| 112 |
ui_args={"title": "Talk with Reachy Mini"},
|
|
@@ -117,6 +250,265 @@ def run(
|
|
| 117 |
else:
|
| 118 |
app = settings_app
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
app = gr.mount_gradio_app(app, stream.ui, path="/")
|
| 121 |
else:
|
| 122 |
stream_manager = LocalStream(handler, robot)
|
|
|
|
| 19 |
setup_logger,
|
| 20 |
handle_vision_stuff,
|
| 21 |
)
|
| 22 |
+
from reachy_mini_conversation_app.config import config
|
| 23 |
+
from pathlib import Path
|
| 24 |
|
| 25 |
|
| 26 |
def update_chatbot(chatbot: List[Dict[str, Any]], response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
|
| 104 |
type="password",
|
| 105 |
value=os.getenv("OPENAI_API_KEY") if not get_space() else "",
|
| 106 |
)
|
| 107 |
+
# Helpers for personalities management (profiles folder)
|
| 108 |
+
profiles_root = Path(__file__).parent / "profiles"
|
| 109 |
+
|
| 110 |
+
def _list_personalities() -> list[str]:
|
| 111 |
+
names: list[str] = []
|
| 112 |
+
try:
|
| 113 |
+
if profiles_root.exists():
|
| 114 |
+
# Built-in profiles (exclude holder for user profiles)
|
| 115 |
+
for p in sorted(profiles_root.iterdir()):
|
| 116 |
+
if p.name == "user_personalities":
|
| 117 |
+
continue
|
| 118 |
+
if p.is_dir() and (p / "instructions.txt").exists():
|
| 119 |
+
names.append(p.name)
|
| 120 |
+
# User-created profiles live under profiles/user_personalities/<name>
|
| 121 |
+
user_dir = profiles_root / "user_personalities"
|
| 122 |
+
if user_dir.exists():
|
| 123 |
+
for p in sorted(user_dir.iterdir()):
|
| 124 |
+
if p.is_dir() and (p / "instructions.txt").exists():
|
| 125 |
+
# Encode value as path segment so tools can resolve folder
|
| 126 |
+
names.append(f"user_personalities/{p.name}")
|
| 127 |
+
except Exception:
|
| 128 |
+
pass
|
| 129 |
+
return names
|
| 130 |
+
|
| 131 |
+
DEFAULT_OPTION = "(built-in default)"
|
| 132 |
+
|
| 133 |
+
def _current_selection_value() -> str:
|
| 134 |
+
return config.REACHY_MINI_CUSTOM_PROFILE or DEFAULT_OPTION
|
| 135 |
+
|
| 136 |
+
def _resolve_profile_dir(selection: str) -> Path:
|
| 137 |
+
return profiles_root / selection
|
| 138 |
+
|
| 139 |
+
def _read_instructions_for(name: str) -> str:
|
| 140 |
+
try:
|
| 141 |
+
if name == DEFAULT_OPTION:
|
| 142 |
+
# Show baked-in default prompt
|
| 143 |
+
default_file = Path(__file__).parent / "prompts" / "default_prompt.txt"
|
| 144 |
+
if default_file.exists():
|
| 145 |
+
return default_file.read_text(encoding="utf-8").strip()
|
| 146 |
+
return ""
|
| 147 |
+
target = _resolve_profile_dir(name) / "instructions.txt"
|
| 148 |
+
if target.exists():
|
| 149 |
+
return target.read_text(encoding="utf-8").strip()
|
| 150 |
+
return ""
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return f"Could not load instructions: {e}"
|
| 153 |
+
|
| 154 |
+
async def _apply_personality(selected: str) -> tuple[str, str]:
|
| 155 |
+
profile = None if selected == DEFAULT_OPTION else selected
|
| 156 |
+
status = await handler.apply_personality(profile)
|
| 157 |
+
preview = _read_instructions_for(selected)
|
| 158 |
+
return status, preview
|
| 159 |
+
|
| 160 |
+
def _sanitize_name(name: str) -> str:
|
| 161 |
+
import re
|
| 162 |
+
|
| 163 |
+
s = name.strip()
|
| 164 |
+
s = re.sub(r"\s+", "_", s)
|
| 165 |
+
s = re.sub(r"[^a-zA-Z0-9_-]", "", s)
|
| 166 |
+
return s
|
| 167 |
+
|
| 168 |
+
def _create_personality(name: str, instructions: str, tools_text: str): # type: ignore[no-untyped-def]
|
| 169 |
+
name_s = _sanitize_name(name)
|
| 170 |
+
if not name_s:
|
| 171 |
+
return gr.update(), gr.update(), "Please enter a valid name."
|
| 172 |
+
try:
|
| 173 |
+
target_dir = profiles_root / "user_personalities" / name_s
|
| 174 |
+
target_dir.mkdir(parents=True, exist_ok=False)
|
| 175 |
+
# Write instructions
|
| 176 |
+
(target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
|
| 177 |
+
# Write tools.txt
|
| 178 |
+
(target_dir / "tools.txt").write_text(tools_text.strip() + "\n", encoding="utf-8")
|
| 179 |
+
|
| 180 |
+
choices = _list_personalities()
|
| 181 |
+
value = f"user_personalities/{name_s}"
|
| 182 |
+
if value not in choices:
|
| 183 |
+
choices.append(value)
|
| 184 |
+
return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Created personality '{name_s}'."
|
| 185 |
+
except FileExistsError:
|
| 186 |
+
choices = _list_personalities()
|
| 187 |
+
value = f"user_personalities/{name_s}"
|
| 188 |
+
if value not in choices:
|
| 189 |
+
choices.append(value)
|
| 190 |
+
return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Personality '{name_s}' already exists."
|
| 191 |
+
except Exception as e:
|
| 192 |
+
return gr.update(), gr.update(), f"Failed to create personality: {e}"
|
| 193 |
+
|
| 194 |
+
# Build personality UI components to place in the side panel
|
| 195 |
+
personalities_dropdown = gr.Dropdown(
|
| 196 |
+
label="Select personality",
|
| 197 |
+
choices=[DEFAULT_OPTION, *(_list_personalities())],
|
| 198 |
+
value=_current_selection_value(),
|
| 199 |
+
)
|
| 200 |
+
apply_btn = gr.Button("Apply personality")
|
| 201 |
+
status_md = gr.Markdown(visible=True)
|
| 202 |
+
preview_md = gr.Markdown(value=_read_instructions_for(_current_selection_value()))
|
| 203 |
+
person_name_tb = gr.Textbox(label="Personality name")
|
| 204 |
+
person_instr_ta = gr.TextArea(label="Personality instructions", lines=10)
|
| 205 |
+
tools_txt_ta = gr.TextArea(label="tools.txt", lines=10)
|
| 206 |
+
new_personality_btn = gr.Button("New personality")
|
| 207 |
+
# Convenience: discovered tools (shared + profile-local)
|
| 208 |
+
available_tools_cg = gr.CheckboxGroup(label="Available tools (helper)", choices=[], value=[])
|
| 209 |
+
load_file_dropdown = gr.Dropdown(label="Custom tool files (*.py)", choices=[], value=None)
|
| 210 |
+
file_content_ta = gr.TextArea(label="Selected file content", lines=12)
|
| 211 |
+
new_file_name_tb = gr.Textbox(label="New custom tool filename (e.g., my_tool.py)")
|
| 212 |
+
create_file_btn = gr.Button("Create file")
|
| 213 |
+
save_file_btn = gr.Button("Save file")
|
| 214 |
+
delete_file_btn = gr.Button("Delete file")
|
| 215 |
+
save_btn = gr.Button("Save personality (instructions + tools)")
|
| 216 |
+
|
| 217 |
+
# Build the streaming UI first
|
| 218 |
stream = Stream(
|
| 219 |
handler=handler,
|
| 220 |
mode="send-receive",
|
| 221 |
modality="audio",
|
| 222 |
+
# Keep original order for first two to preserve expected arg indices
|
| 223 |
+
additional_inputs=[
|
| 224 |
+
chatbot,
|
| 225 |
+
api_key_textbox,
|
| 226 |
+
personalities_dropdown,
|
| 227 |
+
apply_btn,
|
| 228 |
+
status_md,
|
| 229 |
+
preview_md,
|
| 230 |
+
person_name_tb,
|
| 231 |
+
person_instr_ta,
|
| 232 |
+
tools_txt_ta,
|
| 233 |
+
new_personality_btn,
|
| 234 |
+
available_tools_cg,
|
| 235 |
+
load_file_dropdown,
|
| 236 |
+
file_content_ta,
|
| 237 |
+
new_file_name_tb,
|
| 238 |
+
create_file_btn,
|
| 239 |
+
save_file_btn,
|
| 240 |
+
delete_file_btn,
|
| 241 |
+
save_btn,
|
| 242 |
+
],
|
| 243 |
additional_outputs=[chatbot],
|
| 244 |
additional_outputs_handler=update_chatbot,
|
| 245 |
ui_args={"title": "Talk with Reachy Mini"},
|
|
|
|
| 250 |
else:
|
| 251 |
app = settings_app
|
| 252 |
|
| 253 |
+
# Wire events for side panel controls inside the Blocks context
|
| 254 |
+
with stream_manager:
|
| 255 |
+
apply_btn.click(
|
| 256 |
+
fn=_apply_personality,
|
| 257 |
+
inputs=[personalities_dropdown],
|
| 258 |
+
outputs=[status_md, preview_md],
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
def _available_tools_for(selected: str): # type: ignore[no-untyped-def]
|
| 262 |
+
# Shared tools list
|
| 263 |
+
tools_dir = Path(__file__).parent / "tools"
|
| 264 |
+
shared = []
|
| 265 |
+
try:
|
| 266 |
+
for py in tools_dir.glob("*.py"):
|
| 267 |
+
if py.stem in {"__init__", "core_tools"}:
|
| 268 |
+
continue
|
| 269 |
+
shared.append(py.stem)
|
| 270 |
+
except Exception:
|
| 271 |
+
pass
|
| 272 |
+
# Profile-local tools
|
| 273 |
+
local = []
|
| 274 |
+
try:
|
| 275 |
+
if selected != DEFAULT_OPTION:
|
| 276 |
+
for py in (profiles_root / selected).glob("*.py"):
|
| 277 |
+
local.append(py.stem)
|
| 278 |
+
except Exception:
|
| 279 |
+
pass
|
| 280 |
+
all_tools = sorted(set(shared + local))
|
| 281 |
+
return gr.update(choices=all_tools)
|
| 282 |
+
|
| 283 |
+
def _parse_enabled_tools(text: str) -> list[str]:
|
| 284 |
+
enabled: list[str] = []
|
| 285 |
+
for line in text.splitlines():
|
| 286 |
+
s = line.strip()
|
| 287 |
+
if not s or s.startswith("#"):
|
| 288 |
+
continue
|
| 289 |
+
enabled.append(s)
|
| 290 |
+
return enabled
|
| 291 |
+
|
| 292 |
+
def _load_profile_for_edit(selected: str): # type: ignore[no-untyped-def]
|
| 293 |
+
instr = _read_instructions_for(selected)
|
| 294 |
+
# tools.txt
|
| 295 |
+
tools_txt = ""
|
| 296 |
+
if selected != DEFAULT_OPTION:
|
| 297 |
+
tp = _resolve_profile_dir(selected) / "tools.txt"
|
| 298 |
+
if tp.exists():
|
| 299 |
+
tools_txt = tp.read_text(encoding="utf-8")
|
| 300 |
+
# available tools and enabled tools
|
| 301 |
+
tools_dir = Path(__file__).parent / "tools"
|
| 302 |
+
shared = [py.stem for py in tools_dir.glob("*.py") if py.stem not in {"__init__", "core_tools"}]
|
| 303 |
+
local = []
|
| 304 |
+
if selected != DEFAULT_OPTION:
|
| 305 |
+
local = [py.stem for py in (profiles_root / selected).glob("*.py")]
|
| 306 |
+
all_tools = sorted(set(shared + local))
|
| 307 |
+
enabled = _parse_enabled_tools(tools_txt)
|
| 308 |
+
|
| 309 |
+
# files under profile (py only)
|
| 310 |
+
files = []
|
| 311 |
+
if selected != DEFAULT_OPTION:
|
| 312 |
+
files = [p.name for p in (profiles_root / selected).glob("*.py")]
|
| 313 |
+
file_value = files[0] if files else None
|
| 314 |
+
file_content = ""
|
| 315 |
+
if file_value:
|
| 316 |
+
file_content = (profiles_root / selected / file_value).read_text(encoding="utf-8")
|
| 317 |
+
|
| 318 |
+
# Name textbox
|
| 319 |
+
from pathlib import Path as _P
|
| 320 |
+
name_for_edit = "" if selected == DEFAULT_OPTION else _P(selected).name
|
| 321 |
+
|
| 322 |
+
return (
|
| 323 |
+
instr,
|
| 324 |
+
tools_txt,
|
| 325 |
+
gr.update(choices=all_tools, value=[t for t in enabled if t in all_tools]),
|
| 326 |
+
gr.update(choices=files, value=file_value),
|
| 327 |
+
file_content,
|
| 328 |
+
name_for_edit,
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
personalities_dropdown.change(
|
| 332 |
+
fn=_load_profile_for_edit,
|
| 333 |
+
inputs=[personalities_dropdown],
|
| 334 |
+
outputs=[person_instr_ta, tools_txt_ta, available_tools_cg, load_file_dropdown, file_content_ta, person_name_tb],
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
# Keep the name field in sync with selection (basename for user profiles)
|
| 338 |
+
def _selected_name_for_edit(selected: str) -> str:
|
| 339 |
+
if selected == DEFAULT_OPTION:
|
| 340 |
+
return ""
|
| 341 |
+
p = Path(selected)
|
| 342 |
+
return p.name
|
| 343 |
+
|
| 344 |
+
# Initial tools choices for current selection
|
| 345 |
+
personalities_dropdown.change(
|
| 346 |
+
fn=_available_tools_for,
|
| 347 |
+
inputs=[personalities_dropdown],
|
| 348 |
+
outputs=[available_tools_cg],
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
# Start a new personality from scratch
|
| 352 |
+
def _new_personality(): # type: ignore[no-untyped-def]
|
| 353 |
+
# Empty name and instructions
|
| 354 |
+
name_val = ""
|
| 355 |
+
instr_val = ""
|
| 356 |
+
# Blank tools.txt with a helpful header
|
| 357 |
+
tools_txt_val = "# tools enabled for this profile\n"
|
| 358 |
+
# Shared tools list (no local tools for a new profile yet)
|
| 359 |
+
tools_dir = Path(__file__).parent / "tools"
|
| 360 |
+
try:
|
| 361 |
+
shared = [py.stem for py in tools_dir.glob("*.py") if py.stem not in {"__init__", "core_tools"}]
|
| 362 |
+
except Exception:
|
| 363 |
+
shared = []
|
| 364 |
+
# No files yet for a new personality
|
| 365 |
+
files_dd = gr.update(choices=[], value=None)
|
| 366 |
+
file_content_val = ""
|
| 367 |
+
# Update status to guide the user
|
| 368 |
+
status_text = "Creating a new personality. Fill the fields and click 'Save'."
|
| 369 |
+
return (
|
| 370 |
+
gr.update(value=name_val),
|
| 371 |
+
gr.update(value=instr_val),
|
| 372 |
+
gr.update(value=tools_txt_val),
|
| 373 |
+
gr.update(choices=sorted(shared), value=[]),
|
| 374 |
+
files_dd,
|
| 375 |
+
gr.update(value=file_content_val),
|
| 376 |
+
status_text,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
new_personality_btn.click(
|
| 380 |
+
fn=_new_personality,
|
| 381 |
+
inputs=[],
|
| 382 |
+
outputs=[person_name_tb, person_instr_ta, tools_txt_ta, available_tools_cg, load_file_dropdown, file_content_ta, status_md],
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
def _save_personality(name: str, instructions: str, tools_text: str): # type: ignore[no-untyped-def]
|
| 386 |
+
name_s = _sanitize_name(name)
|
| 387 |
+
if not name_s:
|
| 388 |
+
return gr.update(), gr.update(), "Please enter a valid name."
|
| 389 |
+
try:
|
| 390 |
+
target_dir = profiles_root / "user_personalities" / name_s
|
| 391 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 392 |
+
# Write instructions
|
| 393 |
+
(target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
|
| 394 |
+
# Write tools.txt
|
| 395 |
+
(target_dir / "tools.txt").write_text(tools_text.strip() + "\n", encoding="utf-8")
|
| 396 |
+
# Ensure tools.txt exists (copy from default once)
|
| 397 |
+
tools_target = target_dir / "tools.txt"
|
| 398 |
+
if not tools_target.exists():
|
| 399 |
+
tools_target.write_text("# tools enabled for this profile\n", encoding="utf-8")
|
| 400 |
+
|
| 401 |
+
# Refresh choices and select the saved profile
|
| 402 |
+
choices = _list_personalities()
|
| 403 |
+
value = f"user_personalities/{name_s}"
|
| 404 |
+
if value not in choices:
|
| 405 |
+
choices.append(value)
|
| 406 |
+
return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Saved personality '{name_s}'."
|
| 407 |
+
except Exception as e:
|
| 408 |
+
return gr.update(), gr.update(), f"Failed to save personality: {e}"
|
| 409 |
+
|
| 410 |
+
save_btn.click(
|
| 411 |
+
fn=_save_personality,
|
| 412 |
+
inputs=[person_name_tb, person_instr_ta, tools_txt_ta],
|
| 413 |
+
outputs=[personalities_dropdown, person_instr_ta, status_md],
|
| 414 |
+
).then(
|
| 415 |
+
fn=_apply_personality,
|
| 416 |
+
inputs=[personalities_dropdown],
|
| 417 |
+
outputs=[status_md, preview_md],
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
def _sync_tools_from_checks(selected: list[str], current_text: str): # type: ignore[no-untyped-def]
|
| 421 |
+
# Keep comments from current_text at the top, then list selected tools
|
| 422 |
+
comments = [ln for ln in current_text.splitlines() if ln.strip().startswith("#")]
|
| 423 |
+
body = "\n".join(selected)
|
| 424 |
+
out = ("\n".join(comments) + ("\n" if comments else "") + body).strip() + "\n"
|
| 425 |
+
return gr.update(value=out)
|
| 426 |
+
|
| 427 |
+
available_tools_cg.change(
|
| 428 |
+
fn=_sync_tools_from_checks,
|
| 429 |
+
inputs=[available_tools_cg, tools_txt_ta],
|
| 430 |
+
outputs=[tools_txt_ta],
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
def _refresh_file_content(selected_profile: str, filename: str | None): # type: ignore[no-untyped-def]
|
| 434 |
+
if not filename:
|
| 435 |
+
return ""
|
| 436 |
+
path = profiles_root / selected_profile / filename
|
| 437 |
+
try:
|
| 438 |
+
if path.exists():
|
| 439 |
+
return path.read_text(encoding="utf-8")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
return f"Error loading file: {e}"
|
| 442 |
+
return ""
|
| 443 |
+
|
| 444 |
+
load_file_dropdown.change(
|
| 445 |
+
fn=_refresh_file_content,
|
| 446 |
+
inputs=[personalities_dropdown, load_file_dropdown],
|
| 447 |
+
outputs=[file_content_ta],
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
def _create_file(selected_profile: str, filename: str): # type: ignore[no-untyped-def]
|
| 451 |
+
name = filename.strip()
|
| 452 |
+
if not name.endswith(".py"):
|
| 453 |
+
return gr.update(), "Filename must end with .py"
|
| 454 |
+
# Always create in user_personalities
|
| 455 |
+
base = Path(selected_profile).name if selected_profile != DEFAULT_OPTION else (person_name_tb.value or "new_profile") # type: ignore[attr-defined]
|
| 456 |
+
target_dir = profiles_root / "user_personalities" / base
|
| 457 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 458 |
+
path = target_dir / name
|
| 459 |
+
if path.exists():
|
| 460 |
+
# do nothing
|
| 461 |
+
pass
|
| 462 |
+
else:
|
| 463 |
+
path.write_text("# custom tool\n", encoding="utf-8")
|
| 464 |
+
# refresh list to show new file
|
| 465 |
+
files = [p.name for p in target_dir.glob("*.py")]
|
| 466 |
+
return gr.update(choices=files, value=name), f"Created {name}"
|
| 467 |
+
|
| 468 |
+
create_file_btn.click(
|
| 469 |
+
fn=_create_file,
|
| 470 |
+
inputs=[personalities_dropdown, new_file_name_tb],
|
| 471 |
+
outputs=[load_file_dropdown, status_md],
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
def _save_file(selected_profile: str, filename: str | None, content: str): # type: ignore[no-untyped-def]
|
| 475 |
+
if not filename:
|
| 476 |
+
return "No file selected."
|
| 477 |
+
base = Path(selected_profile).name if selected_profile != DEFAULT_OPTION else (person_name_tb.value or "new_profile") # type: ignore[attr-defined]
|
| 478 |
+
target_dir = profiles_root / "user_personalities" / base
|
| 479 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 480 |
+
path = target_dir / filename
|
| 481 |
+
path.write_text(content, encoding="utf-8")
|
| 482 |
+
return f"Saved {filename}"
|
| 483 |
+
|
| 484 |
+
save_file_btn.click(
|
| 485 |
+
fn=_save_file,
|
| 486 |
+
inputs=[personalities_dropdown, load_file_dropdown, file_content_ta],
|
| 487 |
+
outputs=[status_md],
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
def _delete_file(selected_profile: str, filename: str | None): # type: ignore[no-untyped-def]
|
| 491 |
+
if not filename:
|
| 492 |
+
return gr.update(), "No file selected."
|
| 493 |
+
# Only delete from user profiles
|
| 494 |
+
if not selected_profile.startswith("user_personalities/"):
|
| 495 |
+
return gr.update(), "Cannot delete from official profile. Save to user profile first."
|
| 496 |
+
path = profiles_root / selected_profile / filename
|
| 497 |
+
try:
|
| 498 |
+
if path.exists():
|
| 499 |
+
path.unlink()
|
| 500 |
+
files = [p.name for p in (profiles_root / selected_profile).glob("*.py")]
|
| 501 |
+
new_value = files[0] if files else None
|
| 502 |
+
return gr.update(choices=files, value=new_value), f"Deleted {filename}"
|
| 503 |
+
except Exception as e:
|
| 504 |
+
return gr.update(), f"Failed to delete: {e}"
|
| 505 |
+
|
| 506 |
+
delete_file_btn.click(
|
| 507 |
+
fn=_delete_file,
|
| 508 |
+
inputs=[personalities_dropdown, load_file_dropdown],
|
| 509 |
+
outputs=[load_file_dropdown, status_md],
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
app = gr.mount_gradio_app(app, stream.ui, path="/")
|
| 513 |
else:
|
| 514 |
stream_manager = LocalStream(handler, robot)
|
src/reachy_mini_conversation_app/openai_realtime.py
CHANGED
|
@@ -72,6 +72,41 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
|
|
| 72 |
"""Create a copy of the handler."""
|
| 73 |
return OpenaiRealtimeHandler(self.deps, self.gradio_mode)
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
async def _emit_debounced_partial(self, transcript: str, sequence: int) -> None:
|
| 76 |
"""Emit partial transcript after debounce delay."""
|
| 77 |
try:
|
|
|
|
| 72 |
"""Create a copy of the handler."""
|
| 73 |
return OpenaiRealtimeHandler(self.deps, self.gradio_mode)
|
| 74 |
|
| 75 |
+
async def apply_personality(self, profile: str | None) -> str:
|
| 76 |
+
"""Apply a new personality (profile) at runtime if possible.
|
| 77 |
+
|
| 78 |
+
- Updates the global config's selected profile for subsequent calls.
|
| 79 |
+
- If a realtime connection is active, sends a session.update with the
|
| 80 |
+
freshly resolved instructions so the change takes effect immediately.
|
| 81 |
+
|
| 82 |
+
Returns a short status message for UI feedback.
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
# Update the in-process config value (env is not re-read automatically)
|
| 86 |
+
from reachy_mini_conversation_app.config import config as _config
|
| 87 |
+
|
| 88 |
+
_config.REACHY_MINI_CUSTOM_PROFILE = profile
|
| 89 |
+
|
| 90 |
+
instructions = get_session_instructions()
|
| 91 |
+
|
| 92 |
+
if self.connection is not None:
|
| 93 |
+
try:
|
| 94 |
+
await self.connection.session.update(
|
| 95 |
+
session={
|
| 96 |
+
"instructions": instructions,
|
| 97 |
+
},
|
| 98 |
+
)
|
| 99 |
+
logger.info("Applied personality: %s (live session updated)", profile or "built-in default")
|
| 100 |
+
return "Applied personality. New instructions active."
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.warning("Failed to live-update session instructions: %s", e)
|
| 103 |
+
# Fall through: instructions will take effect on next reconnect
|
| 104 |
+
logger.info("Applied personality recorded: %s (will apply on next session)", profile or "built-in default")
|
| 105 |
+
return "Applied personality. Will take effect on next connection."
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error("Error applying personality '%s': %s", profile, e)
|
| 108 |
+
return f"Failed to apply personality: {e}"
|
| 109 |
+
|
| 110 |
async def _emit_debounced_partial(self, transcript: str, sequence: int) -> None:
|
| 111 |
"""Emit partial transcript after debounce delay."""
|
| 112 |
try:
|