personality: bool API, i18n across the app

- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
This commit is contained in:
Jamie Pine
2026-04-23 17:31:23 -07:00
parent 7c50e189cb
commit abf5dfda8c
41 changed files with 2146 additions and 1193 deletions
+14 -46
View File
@@ -1,26 +1,22 @@
"""
Personality-driven text generation — lets a voice profile "speak" or "reply"
using an LLM that takes on the character described by the profile's
``personality`` prompt.
Personality-driven text generation — lets a voice profile "speak" or
restate text using an LLM that takes on the character described by the
profile's ``personality`` prompt.
Three entry points:
Two entry points:
- :func:`compose_as_profile` — zero-input, the character produces a fresh
utterance. Wired to the "Compose" UI button (fill an empty generate box)
and to the ``/profiles/{id}/compose`` endpoint.
utterance. Wired to the Compose button in the generate box and to the
``/profiles/{id}/compose`` endpoint.
- :func:`rewrite_as_profile` — takes user text, restates it in the
character's voice while keeping every idea. Wired to the "Rewrite"
button and the ``/profiles/{id}/rewrite`` endpoint.
- :func:`respond_as_profile` — takes user text and produces the
character's reply to it (new content, not a rewrite). API-only via
``/profiles/{id}/respond`` and the ``/profiles/{id}/speak`` endpoint
when ``intent="respond"``.
character's voice while keeping every idea. Invoked by ``POST /generate``
(and ``POST /speak``) when ``personality=true`` and the profile has a
personality prompt set.
All three reuse the same local Qwen3 instance that refinement uses — no
extra model downloads, no extra warm-up. Temperature is tuned per mode:
compose runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to
the user's ideas, respond mid-range (0.7) so the character feels alive
without drifting.
Both reuse the same local Qwen3 instance that refinement uses — no extra
model downloads, no extra warm-up. Temperature is tuned per mode: compose
runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to the user's
ideas.
"""
from dataclasses import dataclass
@@ -47,9 +43,6 @@ _COMPOSE_TASK = """Task: Produce one short utterance — one or two sentences at
_REWRITE_TASK = """Task: The user's next message is a piece of text. Restate every idea in it using your character's voice — keep the meaning, change the wording. Do not add new ideas, do not drop any, do not reply to the text. Output only the restated version."""
_RESPOND_TASK = """Task: The user's next message is spoken to your character. Reply in character. Produce new content — do not echo or paraphrase the user's words, do not narrate back what they said. One to three sentences of natural speech the character would say in reply."""
@dataclass
class PersonalityResult:
"""What the three service functions return."""
@@ -71,7 +64,7 @@ def _build_system_prompt(personality: str, task: str) -> str:
def _require_personality(personality: str | None) -> str:
if not personality or not personality.strip():
raise ValueError(
"This profile has no personality set. Add one on the profile to use compose, rewrite, respond, or speak."
"This profile has no personality set. Add one on the profile to use compose or personality-rewrite."
)
return personality
@@ -125,28 +118,3 @@ async def rewrite_as_profile(
model_size=resolved_size,
)
return PersonalityResult(text=output.strip(), model_size=resolved_size)
async def respond_as_profile(
personality: str | None,
user_text: str,
model_size: str | None = None,
) -> PersonalityResult:
"""Produce the character's in-character reply to the user's text."""
character = _require_personality(personality)
cleaned = collapse_repetitive_artifacts(user_text)
if not cleaned.strip():
raise ValueError("Respond needs non-empty text to reply to.")
backend = llm_service.get_llm_model()
resolved_size = model_size or backend.model_size
system_prompt = _build_system_prompt(character, _RESPOND_TASK)
output = await backend.generate(
prompt=cleaned,
system=system_prompt,
max_tokens=512,
temperature=0.7,
model_size=resolved_size,
)
return PersonalityResult(text=output.strip(), model_size=resolved_size)