KI-AGENT: Live-Sync und Nachrichtenaktionen im Chat ergänzen

This commit is contained in:
2026-05-20 20:27:38 +02:00
parent a671ae392d
commit 1a5c69fcfb
3 changed files with 496 additions and 14 deletions

View File

@@ -20,6 +20,7 @@ const matrixMessagesViewport = ref(null)
const matrixAttachmentInput = ref(null)
const matrixAttachmentObjectUrls = ref({})
const matrixReplyTarget = ref(null)
const matrixEditingMessage = ref(null)
const matrixDragActive = ref(false)
const memberInviteOpen = ref(false)
const memberInviteUserId = ref("")
@@ -62,13 +63,14 @@ const matrixAttachmentUploading = ref(false)
const matrixAttachmentUploadCount = ref(0)
const matrixAutoRefreshActive = ref(false)
const lastUpdated = ref(null)
let matrixRefreshInterval = null
let matrixCallDurationInterval = null
let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false
let matrixLiveKitRoom = null
let matrixLiveSyncRunId = 0
const matrixCallVideoElements = new Map()
const matrixAttachmentPreviewRequests = new Set()
const matrixSyncSince = ref("")
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
@@ -248,6 +250,7 @@ const setActiveRoom = async (room) => {
matrixSearchResults.value = []
memberInviteOpen.value = false
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
}
const mergeRoomIntoLists = (room) => {
@@ -286,6 +289,7 @@ const provisionRoomFromList = async (room) => {
})
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
@@ -311,9 +315,11 @@ const mergeMatrixMessages = (incomingMessages) => {
}
for (const message of incomingMessages || []) {
const member = matrixMembers.value.find((item) => item.matrixUserId === message.sender)
byId.set(message.id, {
...byId.get(message.id),
...message,
senderDisplayName: member?.displayName || message.senderDisplayName,
pending: false,
failed: false
})
@@ -324,6 +330,64 @@ const mergeMatrixMessages = (incomingMessages) => {
loadAttachmentPreviews()
}
const applyMatrixReplacements = (replacements) => {
if (!replacements?.length) return
const replacementByTarget = new Map()
for (const replacement of replacements) {
if (!replacement.targetEventId) continue
const previous = replacementByTarget.get(replacement.targetEventId)
if (!previous || (replacement.timestamp || 0) >= (previous.timestamp || 0)) {
replacementByTarget.set(replacement.targetEventId, replacement)
}
}
matrixMessages.value = matrixMessages.value.map((message) => {
const replacement = replacementByTarget.get(message.id)
if (!replacement) return message
return {
...message,
body: replacement.body,
edited: true,
timestamp: replacement.timestamp || message.timestamp
}
})
}
const applyMatrixReactions = (reactions) => {
if (!reactions?.length) return
matrixMessages.value = matrixMessages.value.map((message) => {
const messageReactions = reactions.filter((reaction) => reaction.targetEventId === message.id)
if (!messageReactions.length) return message
let nextReactions = [...(message.reactions || [])]
for (const reaction of messageReactions) {
const current = nextReactions.find((item) => item.key === reaction.key)
nextReactions = current
? nextReactions.map((item) => item.key === reaction.key ? {
...item,
count: item.count + 1,
own: item.own || reaction.own
} : item)
: [...nextReactions, { key: reaction.key, count: 1, own: reaction.own }]
}
return {
...message,
reactions: nextReactions
}
})
}
const applyMatrixRedactions = (redactions) => {
const redactedIds = new Set((redactions || []).map((redaction) => redaction.targetEventId).filter(Boolean))
if (!redactedIds.size) return
matrixMessages.value = matrixMessages.value.filter((message) => !redactedIds.has(message.id))
}
const findMessage = (messageId) =>
matrixMessages.value.find((message) => message.id === messageId)
@@ -333,6 +397,7 @@ const replyPreview = (message) => {
}
const setReplyTarget = (message) => {
matrixEditingMessage.value = null
matrixReplyTarget.value = {
id: message.id,
senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender,
@@ -344,6 +409,22 @@ const clearReplyTarget = () => {
matrixReplyTarget.value = null
}
const beginEditMessage = (message) => {
if (!message?.id || !message.own || message.attachment) return
matrixEditingMessage.value = {
id: message.id,
body: message.body || ""
}
matrixMessageDraft.value = message.body || ""
clearReplyTarget()
}
const cancelEditMessage = () => {
matrixEditingMessage.value = null
matrixMessageDraft.value = ""
}
const scrollMessagesToBottom = async () => {
await nextTick()
if (!matrixMessagesViewport.value) return
@@ -485,6 +566,7 @@ const createRoom = async () => {
})
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
@@ -963,6 +1045,29 @@ const sendMatrixMessage = async () => {
const text = matrixMessageDraft.value.trim()
if (!text || !canUseMatrixChat.value || !activeRoom.value?.exists) return
if (matrixEditingMessage.value?.id) {
const editingId = matrixEditingMessage.value.id
matrixMessageSending.value = true
try {
const replacement = await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(editingId)}`, {
method: "PUT",
body: { text }
})
applyMatrixReplacements([replacement])
matrixMessageDraft.value = ""
matrixEditingMessage.value = null
} catch (error) {
toast.add({
title: "Nachricht konnte nicht bearbeitet werden",
color: "error"
})
} finally {
matrixMessageSending.value = false
}
return
}
const optimisticId = `pending-${Date.now()}`
const optimisticMessage = {
id: optimisticId,
@@ -1150,26 +1255,92 @@ const reactToMatrixMessage = async (message, key) => {
}
}
const deleteMatrixMessage = async (message) => {
if (!message?.id || !message.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return
try {
await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(message.id)}`, {
method: "DELETE"
})
matrixMessages.value = matrixMessages.value.filter((item) => item.id !== message.id)
if (matrixEditingMessage.value?.id === message.id) {
cancelEditMessage()
}
} catch (error) {
toast.add({
title: "Nachricht konnte nicht gelöscht werden",
color: "error"
})
}
}
const applyMatrixSync = async (syncResult) => {
if (syncResult.nextBatch) {
matrixSyncSince.value = syncResult.nextBatch
}
mergeMatrixMessages(syncResult.messages || [])
applyMatrixReplacements(syncResult.replacements)
applyMatrixReactions(syncResult.reactions)
applyMatrixRedactions(syncResult.redactions)
if ((syncResult.messages || []).length) {
await markActiveRoomRead()
await scrollMessagesToBottom()
}
if (syncResult.membersChanged) {
await loadRoomMembers({ silent: true })
}
}
const runMatrixLiveSync = async (runId) => {
while (matrixLiveSyncRunId === runId) {
if (!canUseMatrixChat.value || !activeRoom.value?.exists || document.hidden) {
await new Promise((resolve) => window.setTimeout(resolve, 3000))
continue
}
try {
const params = new URLSearchParams()
if (matrixSyncSince.value) {
params.set("since", matrixSyncSince.value)
} else {
params.set("initial", "1")
}
const syncResult = await $api(`${activeRoomEndpoint.value}/sync?${params.toString()}`)
if (matrixLiveSyncRunId !== runId) return
await applyMatrixSync(syncResult)
await loadUnreadCounts()
lastUpdated.value = new Date()
} catch (error) {
if (matrixLiveSyncRunId !== runId) return
await new Promise((resolve) => window.setTimeout(resolve, 5000))
}
}
}
const startMatrixAutoRefresh = () => {
if (matrixRefreshInterval) return
if (matrixAutoRefreshActive.value) return
matrixAutoRefreshActive.value = true
matrixRefreshInterval = window.setInterval(() => {
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
loadRoomChat({ silent: true })
loadUnreadCounts()
}
}, 15000)
matrixLiveSyncRunId += 1
runMatrixLiveSync(matrixLiveSyncRunId)
}
const stopMatrixAutoRefresh = () => {
if (!matrixRefreshInterval) return
window.clearInterval(matrixRefreshInterval)
matrixRefreshInterval = null
matrixLiveSyncRunId += 1
matrixAutoRefreshActive.value = false
}
const restartMatrixLiveSync = () => {
matrixSyncSince.value = ""
stopMatrixAutoRefresh()
startMatrixAutoRefresh()
}
const formatMessageTime = (timestamp) => {
if (!timestamp) return ""
@@ -1749,11 +1920,47 @@ onBeforeUnmount(() => {
>
Antworten
</button>
<button
v-if="message.own && !message.attachment && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="beginEditMessage(message)"
>
Bearbeiten
</button>
<button
v-if="message.own && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="deleteMatrixMessage(message)"
>
Löschen
</button>
</div>
</div>
</div>
</div>
<div
v-if="matrixEditingMessage"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-pencil-square" class="size-4 text-muted" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted">Nachricht bearbeiten</p>
<p class="truncate text-highlighted">{{ matrixEditingMessage.body }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
@click="cancelEditMessage"
/>
</div>
<div
v-if="matrixReplyTarget"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
@@ -1795,7 +2002,7 @@ onBeforeUnmount(() => {
<UInput
v-model="matrixMessageDraft"
class="min-w-0 flex-1"
:placeholder="matrixAttachmentUploading ? `${matrixAttachmentUploadCount} Anhang/Anhänge werden hochgeladen` : 'Nachricht schreiben'"
:placeholder="matrixEditingMessage ? 'Änderung schreiben' : (matrixAttachmentUploading ? `${matrixAttachmentUploadCount} Anhang/Anhänge werden hochgeladen` : 'Nachricht schreiben')"
:disabled="!canSendChatInput"
@keydown.enter.exact.prevent="sendMatrixMessage"
/>
@@ -1805,7 +2012,7 @@ onBeforeUnmount(() => {
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canSendChatInput"
>
Senden
{{ matrixEditingMessage ? "Speichern" : "Senden" }}
</UButton>
</form>
</main>