Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cf75644d8 | ||
|
|
2c1de6705b | ||
|
|
2716bc62ff | ||
|
|
a9e1a46a2f | ||
|
|
245dfc4d73 | ||
|
|
6a94b574f2 | ||
|
|
a5e2256a44 | ||
|
|
9fb29aa517 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20009
|
||||
versionName "0.2.0.9"
|
||||
versionCode 20101
|
||||
versionName "0.2.1.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.0.9",
|
||||
"version": "0.2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -1209,14 +1209,16 @@ const ChatScreen: React.FC = () => {
|
||||
// grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie
|
||||
// bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume),
|
||||
// sonst → Konversation beenden (re-arm).
|
||||
const _isSilent = (message.payload as any).speak === false
|
||||
|| ((message.payload as any).answeredBy === 'fast-path');
|
||||
// Stumm = die Bridge sagt speak=false (Steuerbefehl-Skill via Fast-Path
|
||||
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
|
||||
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
|
||||
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||
const _isSilent = (message.payload as any).speak === false;
|
||||
if (_isSilent && wakeWordService.isConversing()) {
|
||||
if (AppState.currentState === 'active') {
|
||||
wakeWordService.resume();
|
||||
} else {
|
||||
wakeWordService.endConversation().catch(() => {});
|
||||
}
|
||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
||||
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
||||
// kein 30s-Fenster (skipPassive=true).
|
||||
wakeWordService.endConversation(true).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1446,12 +1448,11 @@ const ChatScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const unsubPlayback = audioService.onPlaybackFinished(() => {
|
||||
if (!wakeWordService.isActive()) return;
|
||||
if (AppState.currentState === 'active') {
|
||||
wakeWordService.resume();
|
||||
} else {
|
||||
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
|
||||
wakeWordService.endConversation().catch(() => {});
|
||||
}
|
||||
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
|
||||
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
|
||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
||||
const bg = AppState.currentState !== 'active';
|
||||
wakeWordService.endConversation(bg).catch(() => {});
|
||||
});
|
||||
return () => unsubPlayback();
|
||||
}, []);
|
||||
@@ -2072,7 +2073,7 @@ const ChatScreen: React.FC = () => {
|
||||
await audioService.stopStreamingRecording('user');
|
||||
if (wakeWordService.isConversing()) {
|
||||
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
||||
await wakeWordService.endConversation();
|
||||
await wakeWordService.endConversation(true); // explizit gestoppt → STOP (kein 30s-Passiv)
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -105,6 +105,14 @@ import wakeWordService, {
|
||||
KEYWORD_LABELS,
|
||||
DEFAULT_KEYWORD,
|
||||
WAKE_KEYWORD_STORAGE,
|
||||
WAKE_THRESHOLD_DEFAULT,
|
||||
WAKE_THRESHOLD_MIN,
|
||||
WAKE_THRESHOLD_MAX,
|
||||
loadWakeThreshold,
|
||||
saveWakeThreshold,
|
||||
PASSIVE_LISTEN_DEFAULT_MS,
|
||||
loadPassiveListenMs,
|
||||
savePassiveListenMs,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -200,6 +208,8 @@ const SettingsScreen: React.FC = () => {
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
|
||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
@@ -322,6 +332,8 @@ const SettingsScreen: React.FC = () => {
|
||||
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
@@ -1862,6 +1874,40 @@ const SettingsScreen: React.FC = () => {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Empfindlichkeit</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie leicht das Wake-Word anspringt. Hoeher = strenger = weniger
|
||||
Fehlauslösung (z.B. durch Musik/Radio, die das Mikro mithoert — der
|
||||
Echo-Canceler kann nur ARIAs eigene Stimme rausrechnen, nicht Spotify),
|
||||
aber du musst evtl. deutlicher sprechen. Default: {WAKE_THRESHOLD_DEFAULT.toFixed(2)}.
|
||||
Wird beim „Speichern + Aktivieren" uebernommen.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(WAKE_THRESHOLD_MIN, Math.round((wakeThreshold - 0.05) * 100) / 100);
|
||||
setWakeThreshold(next);
|
||||
saveWakeThreshold(next);
|
||||
}}
|
||||
disabled={wakeThreshold <= WAKE_THRESHOLD_MIN}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.05</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{wakeThreshold.toFixed(2)}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(WAKE_THRESHOLD_MAX, Math.round((wakeThreshold + 0.05) * 100) / 100);
|
||||
setWakeThreshold(next);
|
||||
saveWakeThreshold(next);
|
||||
}}
|
||||
disabled={wakeThreshold >= WAKE_THRESHOLD_MAX}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.05</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
@@ -1907,6 +1953,39 @@ const SettingsScreen: React.FC = () => {
|
||||
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
|
||||
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
|
||||
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
|
||||
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(10, passiveSec - 5);
|
||||
setPassiveSec(next);
|
||||
savePassiveListenMs(next * 1000);
|
||||
}}
|
||||
disabled={passiveSec <= 10}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{passiveSec} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(60, passiveSec + 5);
|
||||
setPassiveSec(next);
|
||||
savePassiveListenMs(next * 1000);
|
||||
}}
|
||||
disabled={passiveSec >= 60}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
|
||||
@@ -1731,11 +1731,39 @@ class AudioService {
|
||||
this._stoppedMessageId = activeMsgId;
|
||||
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
|
||||
}
|
||||
this.stopPlayback();
|
||||
// NUR die hoerbare Wiedergabe stoppen — Cache-Buffer behalten, damit die
|
||||
// Nachricht spaeter ueber das Lautsprecher-Symbol nachgehoert werden kann.
|
||||
this._silenceAudibleOutput();
|
||||
}
|
||||
}
|
||||
isMuted(): boolean { return this._muted; }
|
||||
|
||||
/** Mund-Button: laufendes Vorlesen SOFORT verstummen lassen, aber den
|
||||
* PCM-Cache NICHT verwerfen (der Stream cached zu Ende → Nachhoeren via
|
||||
* Lautsprecher-Symbol bleibt moeglich). Unterschied zu stopPlayback():
|
||||
* - stopt den AudioTrack IMMER (auch wenn pcmStreamActive schon false ist,
|
||||
* weil der Stream fertig empfangen wurde aber der AudioTrack seinen Buffer
|
||||
* noch sekundenlang ausspielt — genau da war der Mund-Button wirkungslos),
|
||||
* - laesst pcmBuffer/pcmMessageId/pcmStreamActive stehen (Cache laeuft weiter). */
|
||||
private _silenceAudibleOutput(): void {
|
||||
console.log('[Audio] _silenceAudibleOutput: AudioTrack+WAV stoppen, Cache behalten');
|
||||
this.audioQueue = [];
|
||||
this.isPlaying = false;
|
||||
if (this.currentSound) {
|
||||
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
|
||||
this.currentSound = null;
|
||||
}
|
||||
if (this.resumeSound) {
|
||||
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
|
||||
this.resumeSound = null;
|
||||
}
|
||||
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
|
||||
PcmStreamPlayer?.stop().catch(() => {});
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.release().catch(() => {});
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||
stopPlayback(): void {
|
||||
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
|
||||
|
||||
@@ -53,6 +53,30 @@ export async function savePassiveListenMs(ms: number): Promise<void> {
|
||||
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
|
||||
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
|
||||
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
|
||||
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
|
||||
export const WAKE_THRESHOLD_DEFAULT = 0.6;
|
||||
export const WAKE_THRESHOLD_MIN = 0.3;
|
||||
export const WAKE_THRESHOLD_MAX = 0.9;
|
||||
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
|
||||
|
||||
export async function loadWakeThreshold(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(WAKE_THRESHOLD_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= WAKE_THRESHOLD_MIN && n <= WAKE_THRESHOLD_MAX) return n;
|
||||
}
|
||||
} catch {}
|
||||
return WAKE_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
export async function saveWakeThreshold(v: number): Promise<void> {
|
||||
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
|
||||
}
|
||||
|
||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||
@@ -76,8 +100,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||
hey_rhasspy: 'Hey Rhasspy',
|
||||
};
|
||||
|
||||
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
|
||||
const DEFAULT_THRESHOLD = 0.5;
|
||||
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
|
||||
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
|
||||
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
|
||||
const DEFAULT_PATIENCE = 2;
|
||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||
|
||||
@@ -197,7 +222,9 @@ class WakeWordService {
|
||||
if (this.initInProgress) return this.initInProgress;
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
const threshold = await loadWakeThreshold();
|
||||
console.log('[WakeWord] init mit threshold=%s', threshold);
|
||||
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||
@@ -441,7 +468,10 @@ class WakeWordService {
|
||||
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
|
||||
* ist, und unseren frisch re-armierten Listener killen.
|
||||
*/
|
||||
async endConversation(): Promise<void> {
|
||||
/** @param skipPassive true = KEIN passives Lauschen, direkt zurueck aufs
|
||||
* Wake-Word (armed). Fuer klare Steuerbefehle (Fast-Path) — nach
|
||||
* "nächster Titel" will Stefan kein 30s-Fenster, sondern Stop. */
|
||||
async endConversation(skipPassive: boolean = false): Promise<void> {
|
||||
if (this.state !== 'conversing') {
|
||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
|
||||
@@ -461,7 +491,7 @@ class WakeWordService {
|
||||
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
|
||||
// Anrede weitersprechen.
|
||||
const passiveMs = await loadPassiveListenMs();
|
||||
if (passiveMs > 0 && this.nativeReady) {
|
||||
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
|
||||
this.enterPassiveListening(passiveMs);
|
||||
return;
|
||||
}
|
||||
|
||||
+76
-5
@@ -234,6 +234,24 @@ META_TOOLS = [
|
||||
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
|
||||
),
|
||||
},
|
||||
"speak": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Soll die Antwort dieses Skills VORGELESEN werden (TTS)? "
|
||||
"Default false. \n"
|
||||
"- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, "
|
||||
"Rollade): es gibt nichts vorzulesen, die App beendet direkt "
|
||||
"und lauscht wieder aufs Wake-Word (knackig, wie ein "
|
||||
"Kommando).\n"
|
||||
"- true = ANTWORT-Skill: das Ergebnis ist eine Information, "
|
||||
"die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein "
|
||||
"Nachschlage-Wert, ein Status). Dann wird die Antwort "
|
||||
"vorgelesen und das Gespraechs-Fenster bleibt offen.\n"
|
||||
"Gilt fuer Fast-Path UND wenn das lokale LLM den Skill "
|
||||
"aufruft. Im Zweifel bei Kommandos false, bei "
|
||||
"Frage-Antwort-Skills true."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "entry_code"],
|
||||
},
|
||||
@@ -310,6 +328,13 @@ META_TOOLS = [
|
||||
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
|
||||
),
|
||||
},
|
||||
"speak": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Vorlesen ja/nein (siehe skill_create). false = "
|
||||
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
@@ -1154,6 +1179,8 @@ class Agent:
|
||||
continue
|
||||
args = pat.get("args") or {}
|
||||
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
|
||||
self._fast_path_speak = bool(skill.get("speak", False))
|
||||
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||
skill_name, rx, user_message[:60])
|
||||
try:
|
||||
@@ -1221,11 +1248,31 @@ class Agent:
|
||||
break
|
||||
return tools
|
||||
|
||||
def _skill_speak_flag(self, tname: str) -> bool:
|
||||
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False =
|
||||
Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche)."""
|
||||
if not tname.startswith("run_"):
|
||||
return True
|
||||
suffix = tname[len("run_"):]
|
||||
m = skills_mod.read_manifest(suffix)
|
||||
if m is None:
|
||||
for cand in skills_mod.list_skills(active_only=False):
|
||||
cn = cand.get("name") or ""
|
||||
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
|
||||
m = cand
|
||||
break
|
||||
return bool((m or {}).get("speak", False))
|
||||
|
||||
def _try_local_fast_lane(self, user_message: str,
|
||||
active_project_id: str) -> Optional[str]:
|
||||
cfg = router_mod.load_config()
|
||||
if not router_mod.should_try_local(user_message, cfg):
|
||||
return None
|
||||
# Vorlesen ja/nein fuer diesen lokalen Turn. Default True (Info-Antwort).
|
||||
# Fuehrt local einen Skill (run_*) aus, uebernimmt dessen speak-Flag aus
|
||||
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
|
||||
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
|
||||
self._local_turn_speak = True
|
||||
local_only = bool(cfg.get("localOnly"))
|
||||
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
||||
@@ -1273,6 +1320,11 @@ class Agent:
|
||||
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
|
||||
", ".join(targs.keys()))
|
||||
tresult = self._dispatch_tool(tname, targs)
|
||||
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
|
||||
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
|
||||
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
|
||||
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
|
||||
self._local_turn_speak = self._skill_speak_flag(tname)
|
||||
if self._local_tool_failed(tname, tresult):
|
||||
had_error = True
|
||||
messages.append({"role": "tool",
|
||||
@@ -1367,9 +1419,10 @@ class Agent:
|
||||
metrics.log_fast_path(fast_reply)
|
||||
except Exception:
|
||||
pass
|
||||
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
|
||||
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
|
||||
return fast_reply, "fast-path", False
|
||||
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
||||
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||
return fast_reply, "fast-path", speak
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
@@ -1383,7 +1436,10 @@ class Agent:
|
||||
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
||||
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
||||
if local_reply is not None:
|
||||
return local_reply, "local", True # ARIA-Antwort → vorlesen ok
|
||||
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm +
|
||||
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech.
|
||||
speak = getattr(self, "_local_turn_speak", True)
|
||||
return local_reply, "local", speak
|
||||
|
||||
# 2. Hot Memory (alle pinned Punkte)
|
||||
hot = self.store.list_pinned()
|
||||
@@ -1499,6 +1555,12 @@ class Agent:
|
||||
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
|
||||
# zu 400-Errors fuehren kann.
|
||||
final_reply = ""
|
||||
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
|
||||
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
|
||||
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
|
||||
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
|
||||
# Konversation bleiben gesprochen.
|
||||
self._claude_turn_speak = True
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools,
|
||||
@@ -1516,6 +1578,11 @@ class Agent:
|
||||
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
||||
for tc in result.tool_calls:
|
||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
||||
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
|
||||
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
|
||||
_tn = tc.get("name") or ""
|
||||
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
|
||||
self._claude_turn_speak = self._skill_speak_flag(_tn)
|
||||
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
|
||||
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
|
||||
# viel zu wenig: Spotify _all=true mit 90 Playlists
|
||||
@@ -1584,7 +1651,8 @@ class Agent:
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=active_project_id)
|
||||
return final_reply, "claude", True # ARIA-Antwort → vorlesen ok
|
||||
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True.
|
||||
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True))
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
@@ -1605,6 +1673,7 @@ class Agent:
|
||||
pip_packages=arguments.get("pip_packages", []),
|
||||
config_schema=arguments.get("config_schema") or None,
|
||||
fast_patterns=arguments.get("fast_patterns") or None,
|
||||
speak=bool(arguments.get("speak", False)),
|
||||
author="aria",
|
||||
)
|
||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||
@@ -1664,6 +1733,8 @@ class Agent:
|
||||
for k in ("entry_code", "readme", "description", "args", "active"):
|
||||
if k in arguments and arguments[k] is not None:
|
||||
patch[k] = arguments[k]
|
||||
if "speak" in arguments and arguments["speak"] is not None:
|
||||
patch["speak"] = bool(arguments["speak"])
|
||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||
patch["pip_packages"] = arguments["pip_packages"]
|
||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||
|
||||
@@ -165,6 +165,7 @@ def create_skill(
|
||||
author: str = "aria",
|
||||
config_schema: Optional[list] = None,
|
||||
fast_patterns: Optional[list] = None,
|
||||
speak: bool = False,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -215,6 +216,11 @@ def create_skill(
|
||||
"author": author,
|
||||
"config_schema": _normalize_config_schema(config_schema),
|
||||
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
|
||||
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
|
||||
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen +
|
||||
# Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung.
|
||||
"speak": bool(speak),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
@@ -335,10 +341,12 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||
manifest = read_manifest(name) or manifest
|
||||
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry", "speak"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
if "speak" in patch:
|
||||
manifest["speak"] = bool(patch["speak"])
|
||||
if "config_schema" in patch:
|
||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||
if "fast_patterns" in patch:
|
||||
|
||||
Reference in New Issue
Block a user