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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user