L16 Lotse-Chat für Monteure: Datenmodell, Provider mit Tool-Schleife, Vorschläge und Bestätigen über bestehende Services

Chatverlauf (LotseConversation/-Message) und Aktionskarten (LotseActionProposal) als
Mandantentabellen mit RLS; Schalter lotseChatEnabled. Tool-Use-Schleife mit Runden- und
Tokengrenze, Datenminimierung per Platzhalter, Auftragszuordnung im Sichtbarkeits-Scope,
Bestätigen/Verwerfen/Alle bestätigen mit Hash, Ablauf und Idempotenz, Transkriptions-API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:57:20 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit 68f4eb32dc
26 changed files with 2363 additions and 6 deletions
+99
View File
@@ -0,0 +1,99 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ChatView, LotseChatActionState } from "@/lib/lotse/chat";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, type ServiceCtx } from "@/server/services/context";
import { confirmAllProposals, confirmProposal, discardProposal } from "@/server/services/lotse/chat/confirm";
import { getChatView, startNewConversation } from "@/server/services/lotse/chat/conversations";
import { sendLotseMessage } from "@/server/services/lotse/chat/engine";
import { chatErrorState, field } from "./_chat-state";
/**
* Lotse chat for technicians (lane L16) — thin adapters: moduleGuard("lotse") with DB-authoritative
* permissions → service → fresh chat view. Every action needs `lotse:use` + `field:execute`; the
* services check the tenant switch, ownership and scope again.
*/
const guard = moduleGuard("lotse");
async function viewOrUndefined(ctx: ServiceCtx, conversationId: string | null): Promise<ChatView | undefined> {
try {
return conversationId ? await getChatView(ctx, { conversationId }) : undefined;
} catch {
return undefined;
}
}
function revalidateOrder(view: ChatView) {
if (view.workOrder) revalidatePath(`/m/orders/${view.workOrder.id}`);
revalidatePath("/m", "layout");
}
/** Send a chat message (fields: text, conversationId?, workOrderId?). */
export async function sendChatMessageAction(_prev: LotseChatActionState, fd: FormData): Promise<LotseChatActionState> {
let ctx: ServiceCtx | null = null;
try {
ctx = ctxFromGuard(await guard("lotse:use", "field:execute"));
const view = await sendLotseMessage(ctx, { text: fd.get("text")?.toString() ?? "", conversationId: field(fd, "conversationId"), workOrderId: field(fd, "workOrderId") });
return { status: "ok", at: Date.now(), view };
} catch (err) {
return chatErrorState(err, ctx ? await viewOrUndefined(ctx, field(fd, "conversationId")) : undefined);
}
}
/** Confirm one card (fields: proposalId, conversationId, edits? = JSON object of changed values). */
export async function confirmProposalAction(_prev: LotseChatActionState, fd: FormData): Promise<LotseChatActionState> {
let ctx: ServiceCtx | null = null;
try {
ctx = ctxFromGuard(await guard("lotse:use", "field:execute"));
const rawEdits = field(fd, "edits");
let edits: Record<string, unknown> | null = null;
if (rawEdits) {
const parsed: unknown = JSON.parse(rawEdits);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) edits = parsed as Record<string, unknown>;
}
await confirmProposal(ctx, { proposalId: field(fd, "proposalId") ?? "", edits });
const view = await getChatView(ctx, { conversationId: field(fd, "conversationId") });
revalidateOrder(view);
return { status: "ok", at: Date.now(), view };
} catch (err) {
return chatErrorState(err, ctx ? await viewOrUndefined(ctx, field(fd, "conversationId")) : undefined);
}
}
/** Discard one card (fields: proposalId, conversationId). */
export async function discardProposalAction(_prev: LotseChatActionState, fd: FormData): Promise<LotseChatActionState> {
let ctx: ServiceCtx | null = null;
try {
ctx = ctxFromGuard(await guard("lotse:use", "field:execute"));
await discardProposal(ctx, { proposalId: field(fd, "proposalId") ?? "" });
return { status: "ok", at: Date.now(), view: await getChatView(ctx, { conversationId: field(fd, "conversationId") }) };
} catch (err) {
return chatErrorState(err, ctx ? await viewOrUndefined(ctx, field(fd, "conversationId")) : undefined);
}
}
/** „Alle bestätigen" for the cards of one message (fields: messageId, conversationId). */
export async function confirmAllProposalsAction(_prev: LotseChatActionState, fd: FormData): Promise<LotseChatActionState> {
let ctx: ServiceCtx | null = null;
try {
ctx = ctxFromGuard(await guard("lotse:use", "field:execute"));
await confirmAllProposals(ctx, { messageId: field(fd, "messageId") ?? "" });
const view = await getChatView(ctx, { conversationId: field(fd, "conversationId") });
revalidateOrder(view);
return { status: "ok", at: Date.now(), view };
} catch (err) {
return chatErrorState(err, ctx ? await viewOrUndefined(ctx, field(fd, "conversationId")) : undefined);
}
}
/** „Neuer Chat" (field: workOrderId?). */
export async function newConversationAction(_prev: LotseChatActionState, fd: FormData): Promise<LotseChatActionState> {
try {
const ctx = ctxFromGuard(await guard("lotse:use", "field:execute"));
return { status: "ok", at: Date.now(), view: await startNewConversation(ctx, { workOrderId: field(fd, "workOrderId") }) };
} catch (err) {
return chatErrorState(err);
}
}