Kör Claude som AI-modell i Guidad Felsökning
- AI-motor (ai.ts): Claude Opus 5 via officiella TypeScript-SDK:n. Teknikerns bekräftade dokumentation skickas med ärendebriefen som kontext; svaret är schema-bundet (structured outputs) och strikt klassificerat enligt AI-reglerna: observation, verifierat, hypotes (kräver alltid verifiering) och rekommendation, plus ETT konkret nästa steg. Fryst systemprompt med promptcache; automatisk server-side fallback till Anthropics rekommenderade reservmodell när säkerhetsklassificerarna avböjer; refusal hanteras innan innehållet läses. - Onboarding enligt direktivet: organisationens Claude API-nyckel läggs in på startsidan (maskerat fält). Utan nyckel guidar den deterministiska metodiken ensam. MVP-varning: nyckeln lagras på enheten och anropet görs från webbläsaren — i produktion via backend. - AI-svaren loggas som händelser med modellnamn och visas i guiden; de är arbetsmaterial och filtreras ur kundrapport, Live Share och databasens delningsfunktion, precis som hypoteser. - Master Prompt uppdaterad: AI-wrapper ovanpå Claude API, standard- modell claude-opus-5. Verifierat: 25 vitest-tester gröna (inkl. promptbygge, svarstolkning och systemprompt-regler), produktionsbygge ok, Playwright-röktest genom hela flödet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -18,7 +18,7 @@ export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: strin
|
||||
const bilder = foton(arende);
|
||||
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||
const kundposter = arende.handelser.filter(
|
||||
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
|
||||
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
||||
);
|
||||
const pagaende = b.rekommenderatNastaSteg[0];
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AI_MODELL, SYSTEM_PROMPT, byggAnvandarPrompt, tolkaAiSvar } from "../ai";
|
||||
import { VIBRATION_METODIK } from "../metodik";
|
||||
import { brief } from "../projektioner";
|
||||
import { byggDemoArende } from "../demo";
|
||||
|
||||
describe("AI-motorn", () => {
|
||||
it("använder Claude Opus 5", () => {
|
||||
expect(AI_MODELL).toBe("claude-opus-5");
|
||||
});
|
||||
|
||||
it("systemprompten kodar AI-reglerna", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("Hitta aldrig på fakta");
|
||||
expect(SYSTEM_PROMPT).toContain("aldrig en hypotes som ett konstaterat fel");
|
||||
expect(SYSTEM_PROMPT).toContain("KRÄVER verifiering");
|
||||
expect(SYSTEM_PROMPT).toContain("kortfattat");
|
||||
});
|
||||
|
||||
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
|
||||
const arende = byggDemoArende(1);
|
||||
const prompt = byggAnvandarPrompt(
|
||||
brief(arende, VIBRATION_METODIK),
|
||||
VIBRATION_METODIK.namn,
|
||||
"Reläet klickar inte.",
|
||||
);
|
||||
expect(prompt).toContain("Volvo XC60 D4 2019");
|
||||
expect(prompt).toContain("Felbeskrivning:");
|
||||
expect(prompt).toContain("Kontrollera lufttryck");
|
||||
expect(prompt).toContain("Ej kontrollerat enligt metodiken:");
|
||||
expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte.");
|
||||
});
|
||||
|
||||
it("tolkar giltiga svar och kastar på ogiltiga", () => {
|
||||
const svar = tolkaAiSvar({
|
||||
rader: [
|
||||
{ typ: "verifierat", text: "Matningsspänning finns på stift 14." },
|
||||
{ typ: "okand_typ", text: "ska filtreras bort" },
|
||||
],
|
||||
nastaSteg: "Kontrollera jordanslutningen på stift 7.",
|
||||
});
|
||||
expect(svar.rader).toHaveLength(1);
|
||||
expect(svar.rader[0].typ).toBe("verifierat");
|
||||
expect(svar.nastaSteg).toContain("stift 7");
|
||||
|
||||
expect(() => tolkaAiSvar({ rader: "inte en lista", nastaSteg: "x" })).toThrow();
|
||||
expect(() => tolkaAiSvar(null)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// AI-motor: Claude som diagnostikhandledare.
|
||||
//
|
||||
// Kommunikationsmodellen är text in, text ut: teknikerns (bekräftade)
|
||||
// inmatning skickas tillsammans med ärendebriefen, och Claude svarar
|
||||
// kortfattat och klassificerat enligt AI-reglerna i Master Prompt —
|
||||
// observation / verifierat / hypotes / rekommendation. Svaret är
|
||||
// schema-bundet (structured outputs), så det kan aldrig komma tillbaka
|
||||
// som fritext som råkar läsas som ett konstaterat fel.
|
||||
//
|
||||
// MVP: tenantens egen API-nyckel används direkt från klienten
|
||||
// (dangerouslyAllowBrowser). I produktionsversionen flyttas anropet
|
||||
// bakom plattformens backend så att nycklar aldrig lämnar servern.
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { Brief } from "./projektioner";
|
||||
|
||||
export const AI_MODELL = "claude-opus-5";
|
||||
|
||||
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
|
||||
|
||||
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
|
||||
observation: "Observation",
|
||||
verifierat: "Verifierat",
|
||||
hypotes: "🔴 Hypotes",
|
||||
rekommendation: "Rekommendation",
|
||||
};
|
||||
|
||||
export interface AiRad {
|
||||
typ: AiRadTyp;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AiSvar {
|
||||
rader: AiRad[];
|
||||
nastaSteg: string;
|
||||
}
|
||||
|
||||
const SVARS_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
rader: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
typ: { type: "string", enum: ["observation", "verifierat", "hypotes", "rekommendation"] },
|
||||
text: { type: "string" },
|
||||
},
|
||||
required: ["typ", "text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
nastaSteg: { type: "string" },
|
||||
},
|
||||
required: ["rader", "nastaSteg"],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
// Fryst systemprompt (cachas mellan anrop) — all ärendespecifik kontext
|
||||
// ligger i användarmeddelandet.
|
||||
export const SYSTEM_PROMPT = `Du är en digital felsökningshandledare för professionella tekniker i plattformen Guidad Felsökning. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation — du säkerställer att arbetet sker metodiskt och dokumenteras korrekt.
|
||||
|
||||
Absoluta regler:
|
||||
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig.
|
||||
- Presentera aldrig en hypotes som ett konstaterat fel.
|
||||
- Skilj strikt mellan raderna du returnerar:
|
||||
- "verifierat": endast det som är belagt av mätvärden eller dokumenterade kontroller i underlaget.
|
||||
- "observation": det som konstaterats utan slutsats.
|
||||
- "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
|
||||
- "rekommendation": nästa verifierbara kontroll eller mätning.
|
||||
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
|
||||
|
||||
Arbetssätt: en kontroll i taget, inte långa utläggningar. Svara på svenska, konsekvent, kortfattat och metodiskt — max fyra rader plus nästa steg. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
|
||||
|
||||
export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning: string): string {
|
||||
const rader: string[] = [];
|
||||
rader.push(`Metodik: ${metodikNamn}`);
|
||||
if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`);
|
||||
if (brief.felbeskrivning) rader.push(`Felbeskrivning: ${brief.felbeskrivning}`);
|
||||
if (brief.utfordaKontroller.length > 0) {
|
||||
rader.push("Utförda kontroller:");
|
||||
for (const k of brief.utfordaKontroller) rader.push(`- ${k.text}${k.resultat ? `: ${k.resultat}` : ""}`);
|
||||
}
|
||||
if (brief.observationer.length > 0) {
|
||||
rader.push("Observationer och mätvärden:");
|
||||
for (const o of brief.observationer) rader.push(`- ${o}`);
|
||||
}
|
||||
if (brief.ejKontrollerat.length > 0) {
|
||||
rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`);
|
||||
}
|
||||
rader.push("");
|
||||
rader.push(`Teknikerns nya inmatning: ${inmatning}`);
|
||||
return rader.join("\n");
|
||||
}
|
||||
|
||||
export function tolkaAiSvar(data: unknown): AiSvar {
|
||||
const svar = data as AiSvar;
|
||||
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") {
|
||||
throw new Error("Oväntat AI-svarsformat");
|
||||
}
|
||||
const giltiga: AiRadTyp[] = ["observation", "verifierat", "hypotes", "rekommendation"];
|
||||
const rader = svar.rader.filter(
|
||||
(rad): rad is AiRad => !!rad && giltiga.includes(rad.typ) && typeof rad.text === "string",
|
||||
);
|
||||
return { rader, nastaSteg: svar.nastaSteg };
|
||||
}
|
||||
|
||||
export async function fragaAi(
|
||||
apiNyckel: string,
|
||||
brief: Brief,
|
||||
metodikNamn: string,
|
||||
inmatning: string,
|
||||
): Promise<AiSvar> {
|
||||
const klient = new Anthropic({ apiKey: apiNyckel, dangerouslyAllowBrowser: true });
|
||||
const svar = await klient.beta.messages.create({
|
||||
model: AI_MODELL,
|
||||
max_tokens: 1024,
|
||||
// Verkstadsgolvet är latenskänsligt och uppgiften är väl avgränsad
|
||||
// av briefen — medium balanserar svarstid mot kvalitet.
|
||||
output_config: {
|
||||
effort: "medium",
|
||||
format: { type: "json_schema", schema: SVARS_SCHEMA },
|
||||
},
|
||||
// Avböjer säkerhetsklassificerarna faller anropet automatiskt
|
||||
// tillbaka till Anthropics rekommenderade reservmodell.
|
||||
betas: ["server-side-fallback-2026-07-01"],
|
||||
fallbacks: "default",
|
||||
system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: byggAnvandarPrompt(brief, metodikNamn, inmatning) }],
|
||||
} as Parameters<typeof klient.beta.messages.create>[0]);
|
||||
|
||||
if (svar.stop_reason === "refusal") {
|
||||
throw new Error("AI-tjänsten avböjde förfrågan.");
|
||||
}
|
||||
const textBlock = svar.content.find((block) => block.type === "text");
|
||||
if (!textBlock || textBlock.type !== "text") {
|
||||
throw new Error("AI-svaret saknade innehåll.");
|
||||
}
|
||||
return tolkaAiSvar(JSON.parse(textBlock.text));
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export type Handelse =
|
||||
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||
| { typ: "overlamning"; fran: string; till?: string }
|
||||
| { typ: "export_skapad"; format: string; version: number }
|
||||
| {
|
||||
typ: "ai_svar";
|
||||
rader: { typ: "observation" | "verifierat" | "hypotes" | "rekommendation"; text: string }[];
|
||||
nastaSteg: string;
|
||||
modell: string;
|
||||
}
|
||||
| { typ: "arende_avslutat" };
|
||||
|
||||
export interface LoggPost {
|
||||
@@ -112,6 +118,10 @@ export function handelseRubrik(post: LoggPost): string {
|
||||
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
|
||||
case "export_skapad":
|
||||
return `Export skapad: ${h.format}, version ${h.version}`;
|
||||
case "ai_svar": {
|
||||
const forsta = h.rader[0];
|
||||
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
|
||||
}
|
||||
case "arende_avslutat":
|
||||
return "Felsökning avslutad";
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
|
||||
|
||||
interface FelsokningState {
|
||||
anvandare: string;
|
||||
// Tenantens Claude API-nyckel (MVP: lagras endast på enheten; i
|
||||
// produktionsversionen hanteras nycklar av backend, aldrig i klienten).
|
||||
aiNyckel: string;
|
||||
arenden: Record<string, Arende>;
|
||||
nastaNummer: number;
|
||||
sattAnvandare: (namn: string) => void;
|
||||
sattAiNyckel: (nyckel: string) => void;
|
||||
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
||||
laggTill: (arendeId: string, handelse: Handelse) => void;
|
||||
// Används endast av synken: ersätter listan med den ihopflätade versionen.
|
||||
@@ -34,10 +38,12 @@ export const useFelsokning = create<FelsokningState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
anvandare: "",
|
||||
aiNyckel: "",
|
||||
arenden: {},
|
||||
nastaNummer: 1,
|
||||
|
||||
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
|
||||
sattAiNyckel: (nyckel) => set({ aiNyckel: nyckel.trim() }),
|
||||
|
||||
skapaArende: (objekt, felbeskrivningText) => {
|
||||
const { anvandare, nastaNummer } = get();
|
||||
|
||||
@@ -92,6 +92,7 @@ export function TextFalt({
|
||||
platshallare,
|
||||
flerRad,
|
||||
rost,
|
||||
losenord,
|
||||
}: {
|
||||
label: string;
|
||||
varde: string;
|
||||
@@ -99,6 +100,7 @@ export function TextFalt({
|
||||
platshallare?: string;
|
||||
flerRad?: boolean;
|
||||
rost?: boolean;
|
||||
losenord?: boolean;
|
||||
}) {
|
||||
// Tal-till-text skriver in i samma redigerbara fält — användaren
|
||||
// granskar och bekräftar alltid innan något sparas i loggen.
|
||||
@@ -119,7 +121,13 @@ export function TextFalt({
|
||||
{flerRad ? (
|
||||
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
||||
) : (
|
||||
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
|
||||
<input
|
||||
type={losenord ? "password" : "text"}
|
||||
value={varde}
|
||||
onChange={(e) => satt(e.target.value)}
|
||||
placeholder={platshallare}
|
||||
className={klass}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/felsokning/projektioner";
|
||||
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
|
||||
import { AI_MODELL, AI_RADTYP_LABEL, fragaAi } from "@/felsokning/ai";
|
||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
||||
|
||||
@@ -184,6 +185,25 @@ function GuideFlik({
|
||||
}) {
|
||||
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
|
||||
const [visaOverlamning, setVisaOverlamning] = useState(false);
|
||||
const aiNyckel = useFelsokning((s) => s.aiNyckel);
|
||||
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
|
||||
|
||||
// AI:n svarar skriftligt på varje bekräftad dokumentation: klassificerat
|
||||
// (observation/verifierat/hypotes/rekommendation) och loggat som händelse.
|
||||
// Utan nyckel guidar den deterministiska metodiken ensam.
|
||||
const fragaAiOmDokumentation = async (inmatning: string) => {
|
||||
if (!aiNyckel) return;
|
||||
setAiStatus("arbetar");
|
||||
try {
|
||||
const svar = await fragaAi(aiNyckel, brief(arende, metodik, nu), metodik.namn, inmatning);
|
||||
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL });
|
||||
setAiStatus("vilar");
|
||||
} catch {
|
||||
setAiStatus("fel");
|
||||
}
|
||||
};
|
||||
|
||||
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
|
||||
|
||||
if (avslutat) {
|
||||
return (
|
||||
@@ -217,7 +237,29 @@ function GuideFlik({
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<SnabbDokumentation skicka={skicka} />
|
||||
{(aiStatus !== "vilar" || senasteAiSvar) && (
|
||||
<Panel rubrik={`AI-handledning (${AI_MODELL})`}>
|
||||
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar …</p>}
|
||||
{aiStatus === "fel" && (
|
||||
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas — kontrollera nyckel och nät. Metodiken fortsätter som vanligt.</p>
|
||||
)}
|
||||
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
|
||||
<>
|
||||
{senasteAiSvar.handelse.rader.map((rad, i) => (
|
||||
<p key={i} className="py-0.5 text-lg">
|
||||
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
|
||||
</p>
|
||||
))}
|
||||
<p className="mt-2 text-lg">
|
||||
<span className="font-extrabold text-amber-400">Nästa steg:</span>{" "}
|
||||
{senasteAiSvar.handelse.nastaSteg}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||
@@ -380,7 +422,15 @@ const DOKTYPER = [
|
||||
|
||||
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
|
||||
// ej verifierade — de kan aldrig loggas som konstaterade fel.
|
||||
function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
||||
// paSparad anropas med en textsammanfattning EFTER att användaren bekräftat
|
||||
// med Spara — det är den texten AI:n får (aldrig obekräftad inmatning).
|
||||
function SnabbDokumentation({
|
||||
skicka,
|
||||
paSparad,
|
||||
}: {
|
||||
skicka: (h: Handelse) => void;
|
||||
paSparad?: (sammanfattning: string) => void;
|
||||
}) {
|
||||
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [varde, setVarde] = useState("");
|
||||
@@ -394,10 +444,22 @@ function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
||||
|
||||
const spara = () => {
|
||||
const t = text.trim();
|
||||
if (typ === "observation" && t) skicka({ typ: "observation", text: t });
|
||||
if (typ === "kommentar" && t) skicka({ typ: "kommentar", text: t });
|
||||
if (typ === "hypotes" && t) skicka({ typ: "hypotes", text: t, niva: "lag" });
|
||||
if (typ === "matvarde" && t && varde.trim()) skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
|
||||
if (typ === "observation" && t) {
|
||||
skicka({ typ: "observation", text: t });
|
||||
paSparad?.(`Observation: ${t}`);
|
||||
}
|
||||
if (typ === "kommentar" && t) {
|
||||
skicka({ typ: "kommentar", text: t });
|
||||
paSparad?.(t);
|
||||
}
|
||||
if (typ === "hypotes" && t) {
|
||||
skicka({ typ: "hypotes", text: t, niva: "lag" });
|
||||
paSparad?.(`Teknikerns hypotes (ej verifierad): ${t}`);
|
||||
}
|
||||
if (typ === "matvarde" && t && varde.trim()) {
|
||||
skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
|
||||
paSparad?.(`Mätvärde: ${t} = ${varde.trim()}`);
|
||||
}
|
||||
aterstall();
|
||||
};
|
||||
|
||||
@@ -606,10 +668,10 @@ function RapportFlik({
|
||||
const b = brief(arende, metodik, nu);
|
||||
const bilder = foton(arende);
|
||||
const fordelning = tidsfordelningsRader(arende, nu);
|
||||
// Kategoribyten är interna; hypoteser är arbetsmaterial och ingår inte
|
||||
// i det som delas med kund.
|
||||
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
|
||||
// och ingår inte i det som delas med kund.
|
||||
const kundposter = arende.handelser.filter(
|
||||
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
|
||||
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
||||
);
|
||||
|
||||
// Alla exporter bygger på samma händelselogg och versionsmärks:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { useFelsokning, metodikForArende } from "@/felsokning/store";
|
||||
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
||||
import { byggDemoArende } from "@/felsokning/demo";
|
||||
import { AI_MODELL } from "@/felsokning/ai";
|
||||
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||
|
||||
@@ -11,9 +12,11 @@ type Filter = "alla" | "pagaende" | "klara";
|
||||
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
||||
// pågående, klara och starta nytt ärende.
|
||||
export default function Arendelista() {
|
||||
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
|
||||
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer, aiNyckel, sattAiNyckel } = useFelsokning();
|
||||
const [namn, setNamn] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("alla");
|
||||
const [visaAi, setVisaAi] = useState(false);
|
||||
const [nyckel, setNyckel] = useState("");
|
||||
|
||||
if (!anvandare) {
|
||||
return (
|
||||
@@ -90,6 +93,42 @@ export default function Arendelista() {
|
||||
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
|
||||
)}
|
||||
|
||||
<Panel rubrik="AI-handledning">
|
||||
<p className="mb-2 text-zinc-300">
|
||||
{aiNyckel
|
||||
? `Aktiv — Claude (${AI_MODELL}) svarar klassificerat på dokumenterade observationer och mätvärden.`
|
||||
: "Inte aktiverad — den deterministiska metodiken guidar. Lägg till organisationens Claude API-nyckel för AI-handledning."}
|
||||
</p>
|
||||
{!visaAi ? (
|
||||
<StorKnapp variant="sekundar" onClick={() => setVisaAi(true)}>
|
||||
{aiNyckel ? "Ändra API-nyckel" : "Lägg till Claude API-nyckel"}
|
||||
</StorKnapp>
|
||||
) : (
|
||||
<>
|
||||
<TextFalt label="Claude API-nyckel" varde={nyckel} satt={setNyckel} platshallare="sk-ant-…" losenord />
|
||||
<p className="mb-3 text-sm text-zinc-500">
|
||||
MVP: nyckeln sparas endast på den här enheten och anropen görs direkt från webbläsaren. I
|
||||
produktionsversionen hanteras nycklar av plattformens backend.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => { setVisaAi(false); setNyckel(""); }}>
|
||||
Avbryt
|
||||
</StorKnapp>
|
||||
<StorKnapp
|
||||
disabled={!nyckel.trim() && !aiNyckel}
|
||||
onClick={() => {
|
||||
sattAiNyckel(nyckel);
|
||||
setVisaAi(false);
|
||||
setNyckel("");
|
||||
}}
|
||||
>
|
||||
{nyckel.trim() ? "Spara nyckel" : "Ta bort nyckel"}
|
||||
</StorKnapp>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{lista.map((arende) => {
|
||||
const o = objekt(arende);
|
||||
const fel = felbeskrivning(arende);
|
||||
|
||||
Reference in New Issue
Block a user