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>
100 lines
4.6 KiB
TypeScript
100 lines
4.6 KiB
TypeScript
"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);
|
|
}
|
|
}
|