fix(projects): project_id im Chat-Backup persistieren + 1 Block pro Projekt
Zwei Stefan-Reports nach dem ersten Live-Test:
1. App-Reload verlor die Projekt-Bloecke
- chat_backup.jsonl hatte keine project_id-Felder, also kamen die
Bubbles als Hauptchat zurueck wenn die App ueber chat_history_response
ihre History neu lud.
- Fix: aria_bridge schreibt jetzt project_id in jeden Backup-Eintrag.
Assistant-Reply via turn_pid (aus ChatOut.project_id); User-Message
via payload.projectId (oder Brain-Status-Query als Fallback fuer
Trigger-Replies / Diagnostic-Sends).
- App: chat_history_response-Mapper liest m.project_id → ChatMessage.projectId.
2. Raus + rein in ein Projekt erzeugte einen zweiten Block am Ende
- Vorher: Gruppierung bei aufeinanderfolgenden gleich-getaggten Bubbles.
Hauptchat dazwischen hat den Block "unterbrochen", neuer Block.
- Fix: neue reorderedMessages-Stufe sortiert Messages so um, dass alle
eines Projekts contiguous werden, verankert am LATEST-Activity-
Timestamp des Projekts. Genau EIN Block pro projectId — bei
Re-Enter wandert der existierende Block ans Zeitende der Liste,
die neue Bubble haengt unten in der Gruppe.
- Hauptchat-Bubbles bleiben chronologisch zwischen den Projekt-Blöcken.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -769,6 +769,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
timestamp: m.ts || Date.now(),
|
timestamp: m.ts || Date.now(),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
||||||
|
projectId: typeof m.project_id === 'string' ? m.project_id : '',
|
||||||
...(cmid && { clientMsgId: cmid }),
|
...(cmid && { clientMsgId: cmid }),
|
||||||
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
||||||
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
||||||
@@ -1628,39 +1629,71 @@ const ChatScreen: React.FC = () => {
|
|||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Projekt-Bloecke: aufeinanderfolgende Nachrichten mit gleicher projectId
|
// Projekt-Bloecke: alle Nachrichten eines Projekts erscheinen als EIN Block
|
||||||
// werden visuell gruppiert. Erstes Message-Item einer Gruppe bekommt einen
|
// — auch wenn der User zwischenzeitlich raus + wieder rein gegangen ist.
|
||||||
// Header darueber (tappable für collapse). Collapsed → restliche Messages
|
// Dafuer ordnen wir die Nachrichten so um, dass alle Messages eines Projekts
|
||||||
// der Gruppe werden ausgefiltert; nur Header bleibt sichtbar.
|
// contiguous werden, verankert am LETZTEN Aktivitaets-Timestamp des Projekts.
|
||||||
// Hauptchat-Nachrichten (projectId leer) bleiben ungruppiert dazwischen.
|
// Hauptchat-Nachrichten bleiben chronologisch dazwischen.
|
||||||
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
|
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const reorderedMessages = useMemo(() => {
|
||||||
|
// Hauptchat-Nachrichten + Projekt-Gruppen trennen
|
||||||
|
const hauptchat: ChatMessage[] = [];
|
||||||
|
const groups = new Map<string, ChatMessage[]>();
|
||||||
|
for (const m of chatVisibleMessages) {
|
||||||
|
const pid = m.projectId || '';
|
||||||
|
if (!pid) hauptchat.push(m);
|
||||||
|
else {
|
||||||
|
const g = groups.get(pid);
|
||||||
|
if (g) g.push(m); else groups.set(pid, [m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Events: jede Hauptchat-Bubble + jede Projekt-Gruppe als 1 Event
|
||||||
|
type Event =
|
||||||
|
| { ts: number; kind: 'msg'; m: ChatMessage }
|
||||||
|
| { ts: number; kind: 'group'; msgs: ChatMessage[] };
|
||||||
|
const events: Event[] = [];
|
||||||
|
for (const m of hauptchat) events.push({ ts: m.timestamp, kind: 'msg', m });
|
||||||
|
for (const [, msgs] of groups) {
|
||||||
|
const sorted = [...msgs].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const anchorTs = sorted[sorted.length - 1].timestamp;
|
||||||
|
events.push({ ts: anchorTs, kind: 'group', msgs: sorted });
|
||||||
|
}
|
||||||
|
events.sort((a, b) => a.ts - b.ts);
|
||||||
|
const out: ChatMessage[] = [];
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.kind === 'msg') out.push(e.m);
|
||||||
|
else out.push(...e.msgs);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [chatVisibleMessages]);
|
||||||
|
|
||||||
const projectMeta = useMemo(() => {
|
const projectMeta = useMemo(() => {
|
||||||
// Pre-compute: welche Message ist Erst-Element ihrer Projekt-Gruppe?
|
// Pre-compute: welche Message ist Erst-Element ihrer Projekt-Gruppe?
|
||||||
// Plus: wieviele Messages pro Projekt insgesamt (fuer Header-Count).
|
// Plus: wieviele Messages pro Projekt insgesamt (fuer Header-Count).
|
||||||
const firstOfGroup = new Set<string>();
|
const firstOfGroup = new Set<string>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
let lastPid: string | null = null;
|
let lastPid: string | null = null;
|
||||||
for (const m of chatVisibleMessages) {
|
for (const m of reorderedMessages) {
|
||||||
const pid = m.projectId || '';
|
const pid = m.projectId || '';
|
||||||
if (pid) counts.set(pid, (counts.get(pid) || 0) + 1);
|
if (pid) counts.set(pid, (counts.get(pid) || 0) + 1);
|
||||||
if (pid && pid !== lastPid) firstOfGroup.add(m.id);
|
if (pid && pid !== lastPid) firstOfGroup.add(m.id);
|
||||||
lastPid = pid || null;
|
lastPid = pid || null;
|
||||||
}
|
}
|
||||||
return { firstOfGroup, counts };
|
return { firstOfGroup, counts };
|
||||||
}, [chatVisibleMessages]);
|
}, [reorderedMessages]);
|
||||||
|
|
||||||
// Render-Filter: bei collapsed Projekten zeigen wir NUR das erste Message-
|
// Render-Filter: bei collapsed Projekten zeigen wir NUR das erste Message-
|
||||||
// Item der Gruppe (das traegt den Header). Restliche Messages werden ausge-
|
// Item der Gruppe (das traegt den Header). Restliche Messages werden ausge-
|
||||||
// blendet — Header allein steht dann zwischen Hauptchat-Bubbles.
|
// blendet — Header allein steht dann zwischen Hauptchat-Bubbles.
|
||||||
const messagesForRender = useMemo(() => {
|
const messagesForRender = useMemo(() => {
|
||||||
return chatVisibleMessages.filter(m => {
|
return reorderedMessages.filter(m => {
|
||||||
const pid = m.projectId || '';
|
const pid = m.projectId || '';
|
||||||
if (!pid) return true;
|
if (!pid) return true;
|
||||||
if (!collapsedProjects.has(pid)) return true;
|
if (!collapsedProjects.has(pid)) return true;
|
||||||
return projectMeta.firstOfGroup.has(m.id);
|
return projectMeta.firstOfGroup.has(m.id);
|
||||||
});
|
});
|
||||||
}, [chatVisibleMessages, collapsedProjects, projectMeta]);
|
}, [reorderedMessages, collapsedProjects, projectMeta]);
|
||||||
|
|
||||||
// Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen.
|
// Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen.
|
||||||
const prevActiveIdRef = useRef<string>('');
|
const prevActiveIdRef = useRef<string>('');
|
||||||
|
|||||||
+16
-2
@@ -1237,6 +1237,7 @@ class ARIABridge:
|
|||||||
"text": display_text,
|
"text": display_text,
|
||||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||||
|
"project_id": turn_pid,
|
||||||
})
|
})
|
||||||
|
|
||||||
metadata = payload.get("metadata", {})
|
metadata = payload.get("metadata", {})
|
||||||
@@ -1509,7 +1510,9 @@ class ARIABridge:
|
|||||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
|
async def send_to_core(self, text: str, source: str = "bridge",
|
||||||
|
client_msg_id: Optional[str] = None,
|
||||||
|
project_id: str = "") -> None:
|
||||||
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
||||||
|
|
||||||
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
||||||
@@ -1520,15 +1523,25 @@ class ARIABridge:
|
|||||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
url = f"{brain_url}/chat"
|
url = f"{brain_url}/chat"
|
||||||
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
|
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
|
||||||
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
logger.info("[brain] chat ← %s '%s' project=%s", source, text[:80], project_id or "(main)")
|
||||||
|
|
||||||
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
||||||
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
||||||
# damit die App beim chat_history_response ihre lokale Bubble
|
# damit die App beim chat_history_response ihre lokale Bubble
|
||||||
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
|
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
|
||||||
|
# project_id: pre-turn-State (was App geschickt hat). Wenn leer, vom
|
||||||
|
# Brain-Status nachholen — z.B. bei Trigger-Replies oder Diagnostic-Send.
|
||||||
entry: dict = {"role": "user", "text": text, "source": source}
|
entry: dict = {"role": "user", "text": text, "source": source}
|
||||||
if client_msg_id:
|
if client_msg_id:
|
||||||
entry["clientMsgId"] = client_msg_id
|
entry["clientMsgId"] = client_msg_id
|
||||||
|
if not project_id:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=3) as r:
|
||||||
|
project_id = (json.loads(r.read()).get("active_id") or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if project_id:
|
||||||
|
entry["project_id"] = project_id
|
||||||
self._append_chat_backup(entry)
|
self._append_chat_backup(entry)
|
||||||
|
|
||||||
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
||||||
@@ -1957,6 +1970,7 @@ class ARIABridge:
|
|||||||
core_text,
|
core_text,
|
||||||
source="app" + (" [barge-in]" if interrupted else ""),
|
source="app" + (" [barge-in]" if interrupted else ""),
|
||||||
client_msg_id=client_msg_id,
|
client_msg_id=client_msg_id,
|
||||||
|
project_id=str(payload.get("projectId") or ""),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user