Compare commits
3 Commits
213edac3a7
...
2ad1f57382
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ad1f57382 | |||
| 58e3cfd3e6 | |||
| 7de4ee8f5b |
@@ -96,6 +96,7 @@ const ChatScreen: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const messageIdCounter = useRef(0);
|
||||
@@ -250,6 +251,13 @@ const ChatScreen: React.FC = () => {
|
||||
if (message.type === 'audio' && message.payload.base64) {
|
||||
audioService.playAudio(message.payload.base64 as string);
|
||||
}
|
||||
|
||||
// Thinking-Indicator Status von der Bridge
|
||||
if (message.type === 'agent_activity') {
|
||||
const activity = (message.payload.activity as string) || 'idle';
|
||||
const tool = (message.payload.tool as string) || '';
|
||||
setAgentActivity({ activity, tool });
|
||||
}
|
||||
});
|
||||
|
||||
const unsubState = rvs.onStateChange((state) => {
|
||||
@@ -424,6 +432,12 @@ const ChatScreen: React.FC = () => {
|
||||
});
|
||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
|
||||
|
||||
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
||||
const cancelRequest = useCallback(() => {
|
||||
setAgentActivity({ activity: 'idle', tool: '' });
|
||||
rvs.send('cancel_request' as any, {});
|
||||
}, []);
|
||||
|
||||
// Sprachaufnahme abgeschlossen
|
||||
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
||||
const location = await getCurrentLocation();
|
||||
@@ -674,6 +688,22 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Thinking-Indicator */}
|
||||
{agentActivity.activity !== 'idle' && (
|
||||
<View style={styles.thinkingBar}>
|
||||
<Text style={styles.thinkingText}>
|
||||
{agentActivity.activity === 'tool' && agentActivity.tool
|
||||
? `\uD83D\uDD27 ${agentActivity.tool}`
|
||||
: agentActivity.activity === 'assistant'
|
||||
? '\u270D\uFE0F ARIA schreibt...'
|
||||
: '\uD83D\uDCAD ARIA denkt...'}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
|
||||
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pending Anhaenge Vorschau */}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<View style={styles.pendingBar}>
|
||||
@@ -970,6 +1000,33 @@ const styles = StyleSheet.create({
|
||||
wakeWordIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
thinkingBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#1E1E2E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#2A2A3E',
|
||||
},
|
||||
thinkingText: {
|
||||
color: '#FFD60A',
|
||||
fontSize: 12,
|
||||
flex: 1,
|
||||
},
|
||||
thinkingCancel: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FF3B30',
|
||||
borderRadius: 4,
|
||||
},
|
||||
thinkingCancelText: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
pendingBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
+52
-1
@@ -530,6 +530,9 @@ class ARIABridge:
|
||||
self.ws_core: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
|
||||
|
||||
# Letzter gesendeter agent_activity-State (zum Entduplizieren)
|
||||
self._last_activity_state: Optional[tuple] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialisiert alle Komponenten.
|
||||
|
||||
@@ -734,8 +737,18 @@ class ARIABridge:
|
||||
if event_name == "agent":
|
||||
data = payload.get("data", {})
|
||||
delta = data.get("delta", "")
|
||||
if delta and payload.get("stream") == "assistant":
|
||||
stream = payload.get("stream", "")
|
||||
if delta and stream == "assistant":
|
||||
logger.debug("[core] Delta: '%s'", delta[:40])
|
||||
# Activity-Signal zur App (entdupliziert)
|
||||
tool_name = data.get("name") or data.get("tool") or payload.get("tool") or ""
|
||||
if stream == "tool_use" or data.get("type") == "tool_use":
|
||||
activity = "tool"
|
||||
elif stream == "assistant":
|
||||
activity = "assistant"
|
||||
else:
|
||||
activity = "thinking"
|
||||
await self._emit_activity(activity, tool_name)
|
||||
return
|
||||
|
||||
# ── chat Events: Snapshots mit state=delta|final|error ──
|
||||
@@ -744,6 +757,7 @@ class ARIABridge:
|
||||
|
||||
if state == "final":
|
||||
text = self._extract_chat_text(payload)
|
||||
await self._emit_activity("idle", "")
|
||||
if not text:
|
||||
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
||||
return
|
||||
@@ -754,6 +768,7 @@ class ARIABridge:
|
||||
if state == "error":
|
||||
error = payload.get("error", "Unbekannt")
|
||||
logger.error("[core] Chat-Fehler: %s", error)
|
||||
await self._emit_activity("idle", "")
|
||||
await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": {
|
||||
@@ -1063,6 +1078,12 @@ class ARIABridge:
|
||||
await self.send_to_core(text, source="app")
|
||||
return
|
||||
|
||||
if msg_type == "cancel_request":
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
elif msg_type == "xtts_response":
|
||||
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
|
||||
audio_b64 = payload.get("base64", "")
|
||||
@@ -1396,6 +1417,36 @@ class ARIABridge:
|
||||
|
||||
# ── Log-Streaming an die App ─────────────────────────────
|
||||
|
||||
async def _cancel_via_diagnostic(self) -> None:
|
||||
"""Ruft das Diagnostic /api/cancel an — dort laeuft die volle Abbruch-Logik
|
||||
(openclaw doctor --fix mit Docker-Socket)."""
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self._diagnostic_url}/api/cancel",
|
||||
method="POST",
|
||||
data=b"",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status
|
||||
except Exception as e:
|
||||
return f"error: {e}"
|
||||
|
||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "") -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat."""
|
||||
state = (activity, tool)
|
||||
if state == self._last_activity_state:
|
||||
return
|
||||
self._last_activity_state = state
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": activity, "tool": tool},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
|
||||
async def send_log_to_app(self, source: str, message: str, level: str = "info") -> None:
|
||||
"""Sendet einen Log-Eintrag an die App (erscheint im Log-Viewer)."""
|
||||
await self._send_to_rvs({
|
||||
|
||||
+18
-1
@@ -891,6 +891,18 @@
|
||||
else alert('Loeschen fehlgeschlagen: ' + (msg.error || '?'));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'session_export') {
|
||||
if (!msg.ok) { alert('Export fehlgeschlagen: ' + (msg.error || '?')); return; }
|
||||
const blob = new Blob([msg.markdown], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = msg.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'active_session') {
|
||||
updateActiveSessionBar(msg.sessionKey);
|
||||
loadSessions(); // Tabelle neu rendern
|
||||
@@ -1679,7 +1691,8 @@
|
||||
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
||||
+ `<td style="padding:4px 6px;white-space:nowrap;">`
|
||||
+ (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;" title="Loeschen">X</button>`
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`
|
||||
+ `</td></tr>`;
|
||||
}
|
||||
html += '</table>';
|
||||
@@ -1743,6 +1756,10 @@
|
||||
send({ action: 'delete_session', sessionPath: path });
|
||||
}
|
||||
|
||||
function exportSession(path, sessionKey) {
|
||||
send({ action: 'export_session', sessionPath: path, sessionKey });
|
||||
}
|
||||
|
||||
function activateSession(sessionKey) {
|
||||
send({ action: 'set_active_session', sessionKey });
|
||||
}
|
||||
|
||||
+83
-1
@@ -117,6 +117,9 @@ function pipelineEnd(ok, detail) {
|
||||
}
|
||||
plog(`━━━ Pipeline Ende ━━━`);
|
||||
pipelineActive = false;
|
||||
// Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
pendingMessageTime = 0;
|
||||
}
|
||||
|
||||
// ── Auto-Restart bei Netzwerk-Namespace-Verlust ──────
|
||||
@@ -283,8 +286,10 @@ async function connectGateway() {
|
||||
state.gateway.handshakeOk = false;
|
||||
gatewayWs = null;
|
||||
broadcastState();
|
||||
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt
|
||||
if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
checkGatewayHealth();
|
||||
// Auto-Reconnect nach 5s
|
||||
setTimeout(connectGateway, 5000);
|
||||
});
|
||||
|
||||
@@ -398,6 +403,7 @@ function handleGatewayMessage(msg) {
|
||||
const error = payload.error || text || "Unbekannt";
|
||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||
if (pipelineActive) pipelineEnd(false, error);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_error", error, payload });
|
||||
return;
|
||||
}
|
||||
@@ -419,6 +425,7 @@ function handleGatewayMessage(msg) {
|
||||
const text = extractChatText(payload) || payload.text || "";
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_final", text, payload });
|
||||
return;
|
||||
}
|
||||
@@ -426,6 +433,7 @@ function handleGatewayMessage(msg) {
|
||||
const error = payload.error || payload.message || "Unbekannt";
|
||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||
if (pipelineActive) pipelineEnd(false, error);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_error", error, payload });
|
||||
return;
|
||||
}
|
||||
@@ -1135,6 +1143,16 @@ const server = http.createServer((req, res) => {
|
||||
} else if (req.url === "/api/session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
||||
} else if (req.url === "/api/cancel" && req.method === "POST") {
|
||||
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)");
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} else if (req.url.startsWith("/shared/")) {
|
||||
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
|
||||
const filePath = decodeURIComponent(req.url);
|
||||
@@ -1266,6 +1284,8 @@ wss.on("connection", (ws) => {
|
||||
handleListSessions(ws);
|
||||
} else if (msg.action === "read_session") {
|
||||
handleReadSession(ws, msg.sessionPath);
|
||||
} else if (msg.action === "export_session") {
|
||||
handleExportSession(ws, msg.sessionPath, msg.sessionKey);
|
||||
} else if (msg.action === "delete_session") {
|
||||
handleDeleteSession(ws, msg.sessionPath);
|
||||
} else if (msg.action === "set_active_session") {
|
||||
@@ -1648,6 +1668,68 @@ async function handleReadSession(clientWs, sessionPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportSession(clientWs, sessionPath, sessionKey) {
|
||||
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: "Ungueltiger Pfad" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const safePath = sessionPath.replace(/'/g, "");
|
||||
const raw = await dockerExec("aria-core", `cat '${safePath}'`);
|
||||
const lines = raw.split("\n").filter(l => l.trim());
|
||||
|
||||
const blocks = [];
|
||||
for (const line of lines) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.type !== "message" || !obj.message) continue;
|
||||
const role = obj.message.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
let text = "";
|
||||
const content = obj.message.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "user") {
|
||||
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||
} else {
|
||||
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||
}
|
||||
|
||||
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
const title = sessionKey || sessionPath.split("/").pop().replace(".jsonl", "");
|
||||
const markdown = [
|
||||
`# Session: ${title}`,
|
||||
``,
|
||||
`Exportiert: ${exportedAt} `,
|
||||
`Quelle: ${sessionPath}`,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
blocks.join("\n\n---\n\n"),
|
||||
``,
|
||||
].join("\n");
|
||||
|
||||
const safeKey = (sessionKey || "session").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const filename = `${exportedAt.slice(0, 10)}_${safeKey}.md`;
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: true, filename, markdown }));
|
||||
log("info", "server", `Session exportiert: ${filename} (${blocks.length} Nachrichten)`);
|
||||
} catch (err) {
|
||||
log("error", "server", `Session-Export fehlgeschlagen: ${err.message}`);
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSession(clientWs, sessionPath) {
|
||||
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||
clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" }));
|
||||
|
||||
@@ -30,11 +30,15 @@
|
||||
- [x] Paste-Support fuer Bilder in Diagnostic Chat
|
||||
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
||||
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
||||
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt (verhindert dass Umgebungsgeraeusche an Whisper gehen)
|
||||
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten (sessionFromFile-Flag, atomic write)
|
||||
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen (pipelineEnd broadcastet immer idle, auch bei Timeout/Fehler/Disconnect)
|
||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||
|
||||
## Offen
|
||||
|
||||
### Bugs (Prioritaet)
|
||||
- [ ] Session-Persistenz: Bei Container-Restart wird immer aria-bridge geladen statt die zuletzt gewaehlte Session
|
||||
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
|
||||
|
||||
### App Features
|
||||
|
||||
@@ -16,6 +16,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
Reference in New Issue
Block a user