Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1205eb6b | ||
|
|
d12f67320b | ||
|
|
24a1b4d837 | ||
|
|
db96258f7d | ||
|
|
becc83d9e3 | ||
|
|
44220edbd1 | ||
|
|
fc13957000 | ||
|
|
60580fd67e | ||
|
|
2d83e4ad8b | ||
|
|
629a3d82ac | ||
|
|
9bb90a777a | ||
|
|
c3bb7985a2 | ||
|
|
debd96b16f |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20102
|
versionCode 20104
|
||||||
versionName "0.2.1.2"
|
versionName "0.2.1.4"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.1.2",
|
"version": "0.2.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{hasPermission ? (
|
{hasPermission ? (
|
||||||
<>
|
<>
|
||||||
|
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
|
||||||
|
CameraScreen-Props faelschlich als required (defaultProps fuellen
|
||||||
|
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
|
||||||
|
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
|
||||||
|
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
|
||||||
|
zu umgehen, ohne echten Code zu veraendern. */}
|
||||||
<CameraScreen
|
<CameraScreen
|
||||||
scanBarcode={true}
|
{...({
|
||||||
onReadCode={handleBarcodeScan}
|
scanBarcode: true,
|
||||||
showFrame={true}
|
onReadCode: handleBarcodeScan,
|
||||||
frameColor="#0096FF"
|
showFrame: true,
|
||||||
laserColor="#0096FF"
|
frameColor: '#0096FF',
|
||||||
colorForScannerFrame="#0096FF"
|
laserColor: '#0096FF',
|
||||||
|
} as any)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay oben */}
|
{/* Overlay oben */}
|
||||||
|
|||||||
@@ -994,6 +994,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
||||||
if (message.type === 'file_from_aria') {
|
if (message.type === 'file_from_aria') {
|
||||||
const p = message.payload || {};
|
const p = message.payload || {};
|
||||||
|
const sp = (p.serverPath as string) || '';
|
||||||
const ariaMsg: ChatMessage = {
|
const ariaMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
@@ -1004,10 +1005,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
name: (p.name as string) || 'datei',
|
name: (p.name as string) || 'datei',
|
||||||
size: (p.size as number) || 0,
|
size: (p.size as number) || 0,
|
||||||
mimeType: (p.mimeType as string) || '',
|
mimeType: (p.mimeType as string) || '',
|
||||||
serverPath: (p.serverPath as string) || '',
|
serverPath: sp,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, ariaMsg]));
|
setMessages(prev => {
|
||||||
|
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
|
||||||
|
// folge kann variieren, und die chat-Payload traegt die files jetzt
|
||||||
|
// selbst), keine doppelte Solo-Bubble anlegen.
|
||||||
|
if (sp && prev.some(m =>
|
||||||
|
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
|
||||||
|
)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return capMessages([...prev, ariaMsg]);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,26 +1167,53 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const text = (message.payload.text as string) || '';
|
const text = (message.payload.text as string) || '';
|
||||||
const ts = message.timestamp;
|
const ts = message.timestamp;
|
||||||
|
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
|
||||||
|
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
|
||||||
|
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
|
||||||
|
// erschien, sondern erst nach einem Seitenwechsel/Reload).
|
||||||
|
const incomingFiles = Array.isArray((message.payload as any).files)
|
||||||
|
? (message.payload as any).files as Array<any> : [];
|
||||||
|
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
|
||||||
|
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
|
||||||
|
name: f.name || 'datei',
|
||||||
|
size: f.size || 0,
|
||||||
|
mimeType: f.mimeType || '',
|
||||||
|
serverPath: f.serverPath || '',
|
||||||
|
}));
|
||||||
|
const incomingPaths = new Set(
|
||||||
|
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
|
||||||
|
);
|
||||||
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
|
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const isDuplicate = prev.some(m =>
|
const isDuplicate = prev.some(m =>
|
||||||
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
|
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
|
||||||
);
|
);
|
||||||
if (isDuplicate) return prev;
|
if (isDuplicate) return prev;
|
||||||
|
const payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
|
||||||
|
const mergedAtts = [...payloadAtts, ...fileAttachments];
|
||||||
const ariaMsg: ChatMessage = {
|
const ariaMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
text,
|
text,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
attachments: mergedAtts.length ? mergedAtts : undefined,
|
||||||
messageId: (message.payload.messageId as string) || undefined,
|
messageId: (message.payload.messageId as string) || undefined,
|
||||||
backupTs: (message.payload.backupTs as number) || undefined,
|
backupTs: (message.payload.backupTs as number) || undefined,
|
||||||
projectId: ((message.payload as any).projectId as string) || '',
|
projectId: ((message.payload as any).projectId as string) || '',
|
||||||
answeredBy: ((message.payload as any).answeredBy as string) || '',
|
answeredBy: ((message.payload as any).answeredBy as string) || '',
|
||||||
};
|
};
|
||||||
|
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
|
||||||
|
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
|
||||||
|
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
|
||||||
|
const deduped = incomingPaths.size
|
||||||
|
? prev.filter(m => !(
|
||||||
|
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
|
||||||
|
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
|
||||||
|
))
|
||||||
|
: prev;
|
||||||
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
||||||
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
||||||
return capMessages([...prev, ariaMsg]).map(m =>
|
return capMessages([...deduped, ariaMsg]).map(m =>
|
||||||
m.sender === 'user'
|
m.sender === 'user'
|
||||||
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
|
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
|
||||||
? { ...m, deliveryStatus: 'delivered' }
|
? { ...m, deliveryStatus: 'delivered' }
|
||||||
@@ -1537,9 +1575,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Kein Speech im Window → Konversation beenden
|
// Kein Speech im Window → Konversation beenden
|
||||||
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
|
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
|
||||||
// Placeholder-Bubble wieder weg
|
// NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
|
||||||
|
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
|
||||||
|
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
|
||||||
|
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||||
|
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||||
if (ev.audioRequestId) {
|
if (ev.audioRequestId) {
|
||||||
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
|
setMessages(prev => prev.filter(m =>
|
||||||
|
!(m.audioRequestId === ev.audioRequestId
|
||||||
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
||||||
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
||||||
@@ -1951,49 +1995,6 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendTextMessage = useCallback(async () => {
|
|
||||||
const text = inputText.trim();
|
|
||||||
|
|
||||||
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
|
|
||||||
if (pendingAttachments.length > 0) {
|
|
||||||
sendPendingAttachments(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
setInputText('');
|
|
||||||
|
|
||||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
|
||||||
const location = await getCurrentLocation();
|
|
||||||
|
|
||||||
const cmid = nextClientMsgId();
|
|
||||||
const activePid = focusedProjectIdRef.current;
|
|
||||||
const userMsg: ChatMessage = {
|
|
||||||
id: nextId(),
|
|
||||||
sender: 'user',
|
|
||||||
text,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
clientMsgId: cmid,
|
|
||||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
||||||
sendAttempts: 1,
|
|
||||||
projectId: activePid,
|
|
||||||
};
|
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
|
||||||
|
|
||||||
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
|
|
||||||
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
|
|
||||||
dispatchWithAck(cmid, 'chat', {
|
|
||||||
text,
|
|
||||||
voice: localXttsVoiceRef.current,
|
|
||||||
speed: ttsSpeedRef.current,
|
|
||||||
interrupted: wasInterrupted,
|
|
||||||
projectId: activePid,
|
|
||||||
...(location && { location }),
|
|
||||||
});
|
|
||||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
|
||||||
|
|
||||||
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
|
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
|
||||||
const cancelRequest = useCallback(() => {
|
const cancelRequest = useCallback(() => {
|
||||||
const pid = focusedProjectIdRef.current || '';
|
const pid = focusedProjectIdRef.current || '';
|
||||||
@@ -2200,6 +2201,53 @@ const ChatScreen: React.FC = () => {
|
|||||||
setInputText('');
|
setInputText('');
|
||||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||||
|
|
||||||
|
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||||
|
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||||
|
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||||
|
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
||||||
|
const sendTextMessage = useCallback(async () => {
|
||||||
|
const text = inputText.trim();
|
||||||
|
|
||||||
|
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
|
||||||
|
if (pendingAttachments.length > 0) {
|
||||||
|
sendPendingAttachments(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
setInputText('');
|
||||||
|
|
||||||
|
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
||||||
|
const wasInterrupted = interruptAriaIfBusy();
|
||||||
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
|
const cmid = nextClientMsgId();
|
||||||
|
const activePid = focusedProjectIdRef.current;
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: nextId(),
|
||||||
|
sender: 'user',
|
||||||
|
text,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
clientMsgId: cmid,
|
||||||
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||||
|
sendAttempts: 1,
|
||||||
|
projectId: activePid,
|
||||||
|
};
|
||||||
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
|
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
|
||||||
|
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
|
||||||
|
dispatchWithAck(cmid, 'chat', {
|
||||||
|
text,
|
||||||
|
voice: localXttsVoiceRef.current,
|
||||||
|
speed: ttsSpeedRef.current,
|
||||||
|
interrupted: wasInterrupted,
|
||||||
|
projectId: activePid,
|
||||||
|
...(location && { location }),
|
||||||
|
});
|
||||||
|
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
|
|
||||||
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
||||||
|
|||||||
+109
-6
@@ -284,6 +284,27 @@ META_TOOLS = [
|
|||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_get",
|
||||||
|
"description": (
|
||||||
|
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
|
||||||
|
"args, fast_patterns, speak/converse) + kompletter entry_code "
|
||||||
|
"(der echte Python-Code) + README. IMMER vor `skill_update` "
|
||||||
|
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
|
||||||
|
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
|
||||||
|
"leicht funktionierende Teile!)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Skill-Name."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1110,6 +1131,39 @@ def _looks_like_skill_action(text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _claims_live_media_state(text: str) -> bool:
|
||||||
|
"""Erkennt eine BEHAUPTETE Live-Zustands-Auskunft, die NUR ein Werkzeug
|
||||||
|
wissen kann: aktueller Song/Interpret, Restzeit, was gerade laeuft, welches
|
||||||
|
Geraet spielt. local erfindet das gern (mal 'Midnight City von M83', mal
|
||||||
|
'The House Of House') ohne run_spotify zu rufen. Ohne echten Skill-Aufruf ist
|
||||||
|
das reine Halluzination → eskalieren (Claude ruft das Tool zuverlaessig).
|
||||||
|
|
||||||
|
Bewusst auf Medien-/Wiedergabe-Zustand begrenzt, damit normale Musik-
|
||||||
|
Konversation ('ich mag M83') NICHT getroffen wird — es muss nach einer
|
||||||
|
konkreten Ist-Zustands-Auskunft klingen."""
|
||||||
|
t = (text or "").strip().lower()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
# Restzeit/Abspielposition ('noch 52 Sekunden', 'Restzeit 2:34').
|
||||||
|
if "restzeit" in t or "restlaufzeit" in t:
|
||||||
|
return True
|
||||||
|
if re.search(r"\bnoch\s+\d+\s*(sekunde|minute|sek|min)", t):
|
||||||
|
return True
|
||||||
|
# 'aktueller Song/Titel/Lied heisst/ist …' / 'es laeuft gerade …' /
|
||||||
|
# 'spielt gerade …' / 'jetzt laeuft …' — Ist-Zustands-Auskunft.
|
||||||
|
if re.search(r"(aktuell\w*\s+(song|titel|lied|stueck|track))", t):
|
||||||
|
return True
|
||||||
|
if re.search(r"(l(ae|ä)uft|spielt)\s+(gerade|jetzt|aktuell|zurzeit|grade)", t):
|
||||||
|
return True
|
||||||
|
if re.search(r"(gerade|jetzt|zurzeit|grade)\s+(l(ae|ä)uft|spielt)", t):
|
||||||
|
return True
|
||||||
|
# Skip-Quittung, die einen konkreten Folgetitel behauptet ('… ist jetzt „X"').
|
||||||
|
if ("ueberspring" in t or "übersprung" in t or "uebersprung" in t) and (
|
||||||
|
"song" in t or "titel" in t or "lied" in t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_fast_match(text: str) -> str:
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
norm = _strip_leading_hint_blocks(text).lower()
|
norm = _strip_leading_hint_blocks(text).lower()
|
||||||
norm = _fold_umlauts(norm)
|
norm = _fold_umlauts(norm)
|
||||||
@@ -1225,8 +1279,12 @@ class Agent:
|
|||||||
continue
|
continue
|
||||||
args = pat.get("args") or {}
|
args = pat.get("args") or {}
|
||||||
reply = pat.get("reply") or f"{skill_name}: ok"
|
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||||
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
|
# Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
|
||||||
|
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
|
||||||
|
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
|
||||||
|
# Skill darf ein Dialog-Skill sein.
|
||||||
self._fast_path_speak = bool(skill.get("speak", False))
|
self._fast_path_speak = bool(skill.get("speak", False))
|
||||||
|
self._fast_path_converse = bool(skill.get("converse", False))
|
||||||
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||||
skill_name, rx, user_message[:60])
|
skill_name, rx, user_message[:60])
|
||||||
try:
|
try:
|
||||||
@@ -1239,6 +1297,16 @@ class Agent:
|
|||||||
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
||||||
hint = (tail[-1] if tail else "")[:120]
|
hint = (tail[-1] if tail else "")[:120]
|
||||||
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
||||||
|
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
|
||||||
|
try:
|
||||||
|
_j = json.loads((res.get("stdout") or "").strip())
|
||||||
|
if isinstance(_j, dict):
|
||||||
|
if isinstance(_j.get("speak"), bool):
|
||||||
|
self._fast_path_speak = _j["speak"]
|
||||||
|
if isinstance(_j.get("converse"), bool):
|
||||||
|
self._fast_path_converse = _j["converse"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return reply
|
return reply
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1350,7 +1418,17 @@ class Agent:
|
|||||||
self._local_ran_skill = False
|
self._local_ran_skill = False
|
||||||
local_only = bool(cfg.get("localOnly"))
|
local_only = bool(cfg.get("localOnly"))
|
||||||
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
||||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
# B1a-Rueckbau (bewusst): local ist TOOL-LOS — nur reines Reden. Ein 8B
|
||||||
|
# ist ein unzuverlaessiger Tool-Caller: mit Werkzeug in der Hand erfindet
|
||||||
|
# er lieber eine plausible Antwort ('der Song ist X'), statt das Tool zu
|
||||||
|
# rufen — das war die Halo-Quelle UND zwang zu per-Skill-Guards. Ohne
|
||||||
|
# Tools KANN er kein Skill-Ergebnis faelschen. Alles mit Grundwahrheit
|
||||||
|
# (Fakt/Live-Zustand/Gedaechtnis/Aktion) gehoert an Claude oder an den
|
||||||
|
# deterministischen Fast-Path — nicht an den 8B. WIE es dorthin kommt:
|
||||||
|
# das Modell eskaliert SELBST (<<ESCALATE>>, siehe Prompt) — bewusst KEINE
|
||||||
|
# Input-Wortliste im Router. Der Output-Guard unten faengt ab, wenn der
|
||||||
|
# 8B doch mal statt zu eskalieren einen Live-Zustand behauptet.
|
||||||
|
tools = []
|
||||||
|
|
||||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
||||||
has_tools=bool(tools))
|
has_tools=bool(tools))
|
||||||
@@ -1451,8 +1529,9 @@ class Agent:
|
|||||||
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
|
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
|
||||||
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
|
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
|
||||||
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
|
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
|
||||||
if not self._local_ran_skill and _looks_like_skill_action(final):
|
if not self._local_ran_skill and (
|
||||||
logger.info("[router] lokal: Skill-Aktion behauptet ohne Tool-Call → Claude")
|
_looks_like_skill_action(final) or _claims_live_media_state(final)):
|
||||||
|
logger.info("[router] lokal: Aktion/Live-Zustand behauptet ohne Tool-Call → Claude")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
|
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
|
||||||
@@ -1507,8 +1586,9 @@ class Agent:
|
|||||||
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
||||||
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
||||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||||
# Fast-Path = reiner Steuerbefehl → nie ins 30s-Gespraech (converse=False).
|
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||||
return fast_reply, "fast-path", speak, False
|
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||||
|
return fast_reply, "fast-path", speak, converse
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 1. User-Turn an die Konversation
|
||||||
self.conversation.add("user", user_message, source=source,
|
self.conversation.add("user", user_message, source=source,
|
||||||
@@ -1817,6 +1897,29 @@ class Agent:
|
|||||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||||
for s in items
|
for s in items
|
||||||
)
|
)
|
||||||
|
if name == "skill_get":
|
||||||
|
sk_name = (arguments.get("name") or "").strip()
|
||||||
|
if not sk_name:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
src = skills_mod.read_skill_source(sk_name)
|
||||||
|
if src is None:
|
||||||
|
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
|
||||||
|
m = src["manifest"]
|
||||||
|
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
|
||||||
|
meta = {
|
||||||
|
"name": m.get("name"), "description": m.get("description"),
|
||||||
|
"execution": m.get("execution"), "entry": m.get("entry"),
|
||||||
|
"active": m.get("active"), "args": m.get("args"),
|
||||||
|
"requires": m.get("requires"),
|
||||||
|
"config_schema": m.get("config_schema"),
|
||||||
|
"fast_patterns": m.get("fast_patterns"),
|
||||||
|
"speak": m.get("speak"), "converse": m.get("converse"),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
|
||||||
|
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
|
||||||
|
f"README:\n{src.get('readme') or '(leer)'}"
|
||||||
|
)
|
||||||
if name == "skill_update":
|
if name == "skill_update":
|
||||||
skill_name = (arguments.get("name") or "").strip()
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
|
|||||||
+42
-3
@@ -75,6 +75,16 @@ _TOOL_HINTS = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
|
||||||
|
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
|
||||||
|
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
|
||||||
|
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
|
||||||
|
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
|
||||||
|
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
|
||||||
|
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
|
||||||
|
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
|
||||||
|
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
|
||||||
|
|
||||||
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
|
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
|
||||||
_HARD_HINTS = re.compile(
|
_HARD_HINTS = re.compile(
|
||||||
r"```|" # Codeblock
|
r"```|" # Codeblock
|
||||||
@@ -86,6 +96,18 @@ _HARD_HINTS = re.compile(
|
|||||||
|
|
||||||
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
||||||
|
|
||||||
|
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
|
||||||
|
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
|
||||||
|
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
|
||||||
|
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
|
||||||
|
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
|
||||||
|
_FORCE_CLAUDE = re.compile(
|
||||||
|
r"\b(clodi|clody)\b"
|
||||||
|
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
|
||||||
|
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _strip_leading_hint_blocks(text: str) -> str:
|
def _strip_leading_hint_blocks(text: str) -> str:
|
||||||
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
||||||
@@ -108,6 +130,10 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
|||||||
msg = _strip_leading_hint_blocks(user_message)
|
msg = _strip_leading_hint_blocks(user_message)
|
||||||
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
||||||
return False
|
return False
|
||||||
|
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
|
||||||
|
if _FORCE_CLAUDE.search(msg):
|
||||||
|
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
|
||||||
|
return False
|
||||||
if _TOOL_HINTS.search(msg):
|
if _TOOL_HINTS.search(msg):
|
||||||
return False
|
return False
|
||||||
if _HARD_HINTS.search(msg):
|
if _HARD_HINTS.search(msg):
|
||||||
@@ -122,10 +148,16 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
|||||||
# kein volles Memory (B1a).
|
# kein volles Memory (B1a).
|
||||||
|
|
||||||
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
||||||
|
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
|
||||||
|
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
|
||||||
|
# Aktion braucht, gehoert ans grosse Modell.
|
||||||
_AWARENESS = (
|
_AWARENESS = (
|
||||||
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
|
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
|
||||||
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
|
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
|
||||||
"sowie tiefe/technische Analysen und langen Code."
|
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
|
||||||
|
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
|
||||||
|
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
|
||||||
|
"und langen Code. Fuer ALL das eskalierst du."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +198,13 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
|
|||||||
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
|
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
|
||||||
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
|
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
|
||||||
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
|
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
|
||||||
|
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
|
||||||
|
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
|
||||||
|
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
|
||||||
|
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
|
||||||
|
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
|
||||||
|
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
|
||||||
|
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
|
||||||
]
|
]
|
||||||
parts += [
|
parts += [
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_skill_source(name: str) -> Optional[dict]:
|
||||||
|
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
|
||||||
|
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
|
||||||
|
der bestehende Funktionen killt)."""
|
||||||
|
m = read_manifest(name)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
d = _skill_dir(name)
|
||||||
|
entry = m.get("entry", "run.sh")
|
||||||
|
try:
|
||||||
|
code = (d / entry).read_text(encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
|
||||||
|
try:
|
||||||
|
readme = (d / "README.md").read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
readme = ""
|
||||||
|
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
|
||||||
|
|
||||||
|
|
||||||
def write_manifest(name: str, manifest: dict) -> None:
|
def write_manifest(name: str, manifest: dict) -> None:
|
||||||
d = _skill_dir(name)
|
d = _skill_dir(name)
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+16
-2
@@ -295,10 +295,17 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# <voice>...</voice> wenn vorhanden → nur das nehmen
|
# <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
|
||||||
|
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
|
||||||
|
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
|
||||||
|
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
|
||||||
|
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
|
||||||
|
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
|
||||||
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
||||||
if voice_match:
|
if voice_match and voice_match.group(1).strip():
|
||||||
text = voice_match.group(1)
|
text = voice_match.group(1)
|
||||||
|
else:
|
||||||
|
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
|
||||||
|
|
||||||
t = text
|
t = text
|
||||||
|
|
||||||
@@ -1553,6 +1560,13 @@ class ARIABridge:
|
|||||||
"text": display_text,
|
"text": display_text,
|
||||||
"sender": "aria",
|
"sender": "aria",
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
|
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
|
||||||
|
# der Anhang live nur als separates file_from_aria-Event (eigene
|
||||||
|
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
|
||||||
|
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
|
||||||
|
# Struktur wie im Backup, damit App live == Reload rendert.
|
||||||
|
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||||
|
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||||
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
||||||
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
|
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
|
||||||
"backupTs": assistant_backup_ts,
|
"backupTs": assistant_backup_ts,
|
||||||
|
|||||||
Reference in New Issue
Block a user