Welle 3 · Oberfläche: Lucys Freiheiten, Rückfragen mit Ja/Nein, Wochenbericht; dist gebaut
- Einstellungen → Lucy: Freiheit je Aktionsart (freier als der Start nur nach Nachfrage), offene und zuletzt beantwortete Rückfragen, Wochenbericht (nächster Termin, letzten lesen, jetzt schicken) - Übersicht: Feld „Rückfrage an dich“ ganz oben, nur wenn eine offen ist; Ja erst nach Bestätigung - Strg+K: Lucys Freiheiten, Rückfragen, Wochenbericht - Einstieg 126,1 KB gzip Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
12cefb427a
commit
1915813cc0
@@ -0,0 +1,60 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { LucyAutonomie, Wochenbericht } from "@/lib/typen"
|
||||
import { Freiheiten, WochenberichtAbschnitt } from "./LucyEinstellungen"
|
||||
|
||||
vi.mock("@/lib/api", async (original) => ({
|
||||
...(await original<typeof import("@/lib/api")>()), api: vi.fn(), post: vi.fn(),
|
||||
}))
|
||||
|
||||
// Einstellungen → Lucy (Ausbauplan Welle 3, Punkte 14 und 16, 25.09.2026).
|
||||
const AUTONOMIE: LucyAutonomie = {
|
||||
arten: [
|
||||
{ id: "neustart", name: "Dienste neu starten", text: "…", stufe: "selbst", standard: "selbst" },
|
||||
{ id: "host_neustart", name: "Proxmox-Host neu starten", text: "Alle Container sind weg.", stufe: "fragen", standard: "fragen" },
|
||||
],
|
||||
stufen: { lesen: "Nur lesen und erklären", fragen: "Erst fragen", selbst: "Selbst handeln, Meldung danach" },
|
||||
}
|
||||
|
||||
function zeigen(ui: React.ReactElement, daten: LucyAutonomie | Wochenbericht) {
|
||||
vi.mocked(api).mockResolvedValue(daten)
|
||||
vi.mocked(post).mockResolvedValue({ ok: true, text: "Gespeichert." })
|
||||
render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe("Lucys Freiheiten", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("strenger stellen geht sofort", async () => {
|
||||
zeigen(<Freiheiten />, AUTONOMIE)
|
||||
const wahl = await screen.findByRole("combobox", { name: "Freiheit: Dienste neu starten" })
|
||||
expect(within(wahl).getByRole("option", { name: "Selbst handeln (Start)" })).toBeInTheDocument()
|
||||
fireEvent.change(wahl, { target: { value: "fragen" } })
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/autonomie", { stufen: { neustart: "fragen" } }))
|
||||
})
|
||||
|
||||
it("freier als der Start erst nach Nachfrage", async () => {
|
||||
zeigen(<Freiheiten />, AUTONOMIE)
|
||||
fireEvent.change(await screen.findByRole("combobox", { name: "Freiheit: Proxmox-Host neu starten" }),
|
||||
{ target: { value: "selbst" } })
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Selbst handeln lassen" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/autonomie", { stufen: { host_neustart: "selbst" } }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Wochenbericht", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("nächster Termin, der letzte zum Nachlesen, jetzt schicken", async () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000)
|
||||
zeigen(<WochenberichtAbschnitt />, { naechster: jetzt + 3 * 86400, zuletzt: { zeit: jetzt - 86400, text: "Commander, ruhige Woche." } })
|
||||
expect(await screen.findByText(/Nächster: .* · zuletzt gestern/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "Letzten lesen" }))
|
||||
expect(screen.getByText("Commander, ruhige Woche.")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jetzt schicken" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/lucy/wochenbericht"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useLucyAutonomie, useLucyAutonomieSetzen, useWochenbericht, useWochenberichtSenden } from "@/lib/abfragen"
|
||||
import type { LucyStufe } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// Einstellungen → Lucy (Ausbauplan Welle 3, Punkte 14 und 16, seit 25.09.2026): wie selbstständig Lucy je Aktionsart
|
||||
// handelt und der Wochenbericht. Start ist „mehr Freiheit“ (User-Entscheid 25.09.): selbst neu starten,
|
||||
// Sicherheitsupdates, Rückweg bei Rot; andere Updates, Aufräumen, Modell-Tests und der Host-Neustart nur nach Ja.
|
||||
|
||||
const STUFEN: { id: LucyStufe; kurz: string }[] = [
|
||||
{ id: "lesen", kurz: "Nur lesen" },
|
||||
{ id: "fragen", kurz: "Erst fragen" },
|
||||
{ id: "selbst", kurz: "Selbst handeln" },
|
||||
]
|
||||
const feld = "h-10 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-lab/60"
|
||||
|
||||
/** Die Freiheiten je Aktionsart. Wird eine Art freier als ihr Start (etwa der Host-Neustart auf „selbst“), fragt die
|
||||
* Seite vorher nach — das ist die eine Stelle, an der Lucy mehr darf, als der Commander am 25.09. entschieden hat. */
|
||||
export function Freiheiten() {
|
||||
const q = useLucyAutonomie()
|
||||
const setzen = useLucyAutonomieSetzen()
|
||||
const [frage, setFrage] = useState<{ id: string; name: string; stufe: LucyStufe } | null>(null)
|
||||
if (!q.data) return <p className="text-sm text-text-3">{q.isPending ? "Wird gelesen …" : "Lucys Freiheiten lassen sich gerade nicht lesen."}</p>
|
||||
const rang = (s: LucyStufe) => STUFEN.findIndex((x) => x.id === s)
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="m-0 text-sm text-text-2">
|
||||
Bittest du Lucy ausdrücklich um etwas, tut sie es. Hier stellst du ein, was sie aus eigenem Antrieb darf: nur
|
||||
erklären, dich vorher fragen (in Telegram antwortest du mit „ja“ und der Nummer) oder selbst handeln — dann
|
||||
bekommst du danach eine Meldung.
|
||||
</p>
|
||||
<div role="table" aria-label="Lucys Freiheiten" className="flex flex-col">
|
||||
{q.data.arten.map((a) => (
|
||||
<div role="row" key={a.id} className="flex flex-col gap-2 border-t border-white/[0.06] py-3 first:border-t-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span role="rowheader" className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-[15px]">{a.name}</span>
|
||||
<span className="text-xs text-text-3">{a.text}</span>
|
||||
</span>
|
||||
<span role="cell" className="shrink-0">
|
||||
<select aria-label={`Freiheit: ${a.name}`} value={a.stufe} disabled={setzen.isPending}
|
||||
className={cn(feld, "min-w-44", a.stufe !== a.standard && "border-lab/50")}
|
||||
onChange={(e) => {
|
||||
const stufe = e.target.value as LucyStufe
|
||||
if (rang(stufe) > rang(a.standard) && stufe === "selbst") setFrage({ id: a.id, name: a.name, stufe })
|
||||
else setzen.mutate({ [a.id]: stufe })
|
||||
}}>
|
||||
{STUFEN.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.kurz}{s.id === a.standard ? " (Start)" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? `„${frage.name}“: Lucy selbst handeln lassen?` : ""}
|
||||
text="Dann tut sie es aus eigenem Antrieb, ohne dich vorher zu fragen, und meldet es erst danach. Zurückstellen kannst du es hier jederzeit."
|
||||
knopf="Selbst handeln lassen"
|
||||
gesperrt={setzen.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage) setzen.mutate({ [frage.id]: frage.stufe })
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Der Wochenbericht: wann der nächste kommt, der letzte zum Nachlesen, „Jetzt schicken“. */
|
||||
export function WochenberichtAbschnitt() {
|
||||
const q = useWochenbericht()
|
||||
const senden = useWochenberichtSenden()
|
||||
const [offen, setOffen] = useState(false)
|
||||
const zuletzt = q.data?.zuletzt
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="m-0 text-sm text-text-2">
|
||||
Sonntags um 07:30 schickt Lucy eine Nachricht statt vieler: was eingespielt wurde, was offen ist, Platten,
|
||||
Sicherungen, Zertifikate — und offene App-Updates zum Beantworten mit „ja“.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={senden.isPending} onClick={() => senden.mutate()}>
|
||||
{senden.isPending ? "Lucy schreibt …" : "Jetzt schicken"}
|
||||
</Button>
|
||||
<span className="text-xs text-text-3">
|
||||
{q.data ? `Nächster: ${flugplanZeit(new Date(q.data.naechster * 1000).toISOString()).toLowerCase()}` : ""}
|
||||
{zuletzt ? ` · zuletzt ${flugplanZeit(new Date(zuletzt.zeit * 1000).toISOString()).toLowerCase()}` : ""}
|
||||
</span>
|
||||
{zuletzt && (
|
||||
<button type="button" className="text-xs text-cyan hover:underline" onClick={() => setOffen(!offen)}>
|
||||
{offen ? "Letzten zuklappen" : "Letzten lesen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{offen && zuletzt && (
|
||||
<pre className="flaeche-innen m-0 max-h-96 overflow-y-auto rounded-2xl p-4 font-sans text-sm whitespace-pre-wrap text-text-2">
|
||||
{zuletzt.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactElement } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { Rueckfrage, Rueckfragen } from "@/lib/typen"
|
||||
import { ausgangText, nochText, OffeneRueckfragen, RueckfragenPanel, RueckfragenVerlauf } from "./Rueckfragen"
|
||||
|
||||
vi.mock("@/lib/api", async (original) => ({
|
||||
...(await original<typeof import("@/lib/api")>()), api: vi.fn(), post: vi.fn(),
|
||||
}))
|
||||
|
||||
// Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, 25.09.2026): Ja führt genau die gefragte Aktion aus —
|
||||
// in der Oberfläche erst nach der Bestätigung, Nein sofort.
|
||||
const jetzt = Math.floor(Date.now() / 1000)
|
||||
const frage = (nr: number, mehr: Partial<Rueckfrage> = {}): Rueckfrage => ({
|
||||
nr, zeit: jetzt - 600, frage: `Soll ich Gitea 1.27.${nr} einspielen?`, text: "Snapshot vorher, Prüfung danach.",
|
||||
pfad: "/api/partner/homelab/ziele/ct-104/update", von: "wochenbericht", stufe: "normal", ablauf: jetzt + 20 * 3600 - 60,
|
||||
status: "offen", befund: null, ...mehr,
|
||||
})
|
||||
|
||||
function zeigen(ui: ReactElement, daten: Rueckfragen) {
|
||||
vi.mocked(api).mockResolvedValue(daten)
|
||||
vi.mocked(post).mockResolvedValue({ ok: true, text: "Rückfrage 7 ausgeführt: Gestartet." })
|
||||
render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe("Rückfragen", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("Ja erst nach der Bestätigung, dann genau diese Rückfrage", async () => {
|
||||
zeigen(<RueckfragenPanel />, { offen: [frage(7)], zuletzt: [] })
|
||||
expect(await screen.findByText("Eine Rückfrage an dich")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Rückfrage 7 · Wochenbericht · vor 10 min · gilt noch 19 h/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ja, ausführen" }))
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
expect(within(dialog).getByText(/wie „ja 7“ in Telegram/)).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Ja, ausführen" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/rueckfragen/7/ja"))
|
||||
})
|
||||
|
||||
it("Nein ohne Umweg", async () => {
|
||||
zeigen(<OffeneRueckfragen />, { offen: [frage(7), frage(8, { von: "lucy", stufe: "dringend" })], zuletzt: [] })
|
||||
const nein = await screen.findAllByRole("button", { name: "Nein" })
|
||||
fireEvent.click(nein[1])
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/rueckfragen/8/nein"))
|
||||
})
|
||||
|
||||
it("ohne offene Rückfrage zeigt die Übersicht nichts, die Einstellungen einen Satz", async () => {
|
||||
zeigen(<><RueckfragenPanel /><OffeneRueckfragen leerText="Gerade wartet keine Rückfrage auf dich." /></>,
|
||||
{ offen: [], zuletzt: [] })
|
||||
expect(await screen.findByText("Gerade wartet keine Rückfrage auf dich.")).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Rückfrage an dich/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("Verlauf: was daraus wurde und wer geantwortet hat", async () => {
|
||||
zeigen(<RueckfragenVerlauf />, { offen: [], zuletzt: [
|
||||
frage(5, { status: "erledigt", antwort: { zeit: jetzt, wer: "lucy", ja: true } }),
|
||||
frage(4, { status: "fehler", ergebnis: "Es läuft gerade ‚Alle aktualisieren‘.", antwort: { zeit: jetzt, wer: "oberflaeche", ja: true } }),
|
||||
] })
|
||||
expect(await screen.findByText(/ausgeführt · per Telegram/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/gescheitert · hier — Es läuft gerade/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("Texte", () => {
|
||||
expect(nochText(jetzt + 90 * 60, jetzt * 1000)).toBe("gilt noch 1 h")
|
||||
expect(nochText(jetzt + 20, jetzt * 1000)).toBe("gilt noch 1 min")
|
||||
expect(ausgangText(frage(1, { status: "abgelaufen" }))).toBe("abgelaufen")
|
||||
expect(ausgangText(frage(1, { status: "erledigt", nachgeprueft: "besteht", antwort: { zeit: 0, wer: "oberflaeche", ja: true } })))
|
||||
.toBe("ausgeführt · hier · der Hinweis blieb")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react"
|
||||
import { MessageCircleQuestionMark } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRueckfrageAntwort, useRueckfragen } from "@/lib/abfragen"
|
||||
import type { Rueckfrage, RueckfrageStatus } from "@/lib/typen"
|
||||
import { vor } from "@/lib/zeit"
|
||||
|
||||
// Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, seit 25.09.2026): Lucy, der Wächter und der Wochenbericht
|
||||
// fragen, bevor etwas passiert, das er bestätigen soll. Dieselben Fragen kommen in Telegram mit „Antworte „ja 7““ und
|
||||
// zwei Antworttasten; hier geht es per Knopf. Ja führt genau die gefragte Aktion aus — sonst nichts.
|
||||
|
||||
const VON: Record<string, string> = {
|
||||
lucy: "Lucy", waechter: "Wächter", wochenbericht: "Wochenbericht", wartung: "Wartungsfenster",
|
||||
}
|
||||
|
||||
const STATUS: Record<RueckfrageStatus, string> = {
|
||||
offen: "offen", laeuft: "läuft", erledigt: "ausgeführt", fehler: "gescheitert", abgelehnt: "abgelehnt",
|
||||
abgelaufen: "abgelaufen", hinfaellig: "hatte sich erledigt",
|
||||
}
|
||||
|
||||
export const vonText = (von: string) => VON[von] ?? von
|
||||
|
||||
/** Wie lange eine Rückfrage noch gilt: „gilt noch 22 h“, „gilt noch 40 min“. */
|
||||
export function nochText(ablauf: number, jetzt = Date.now()): string {
|
||||
const s = Math.max(0, Math.round(ablauf - jetzt / 1000))
|
||||
return s >= 3600 ? `gilt noch ${Math.floor(s / 3600)} h` : `gilt noch ${Math.max(1, Math.round(s / 60))} min`
|
||||
}
|
||||
|
||||
/** Eine Zeile des Verlaufs: was daraus wurde, wer geantwortet hat. */
|
||||
export function ausgangText(r: Rueckfrage): string {
|
||||
const wer = r.antwort ? (r.antwort.wer === "oberflaeche" ? "hier" : r.antwort.wer === "lucy" ? "per Telegram" : r.antwort.wer) : null
|
||||
const teile = [STATUS[r.status] ?? r.status]
|
||||
if (wer && r.status !== "abgelaufen") teile.push(wer)
|
||||
if (r.nachgeprueft === "besteht") teile.push("der Hinweis blieb")
|
||||
return teile.join(" · ")
|
||||
}
|
||||
|
||||
function Eintrag({ r, gesperrt, onJa, onNein }: {
|
||||
r: Rueckfrage
|
||||
gesperrt: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className={cn("flaeche-innen flex flex-col gap-3 rounded-2xl p-4", r.stufe === "dringend" && "ring-1 ring-rot/40 ring-inset")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="etikett text-[10.5px] text-text-3">
|
||||
Rückfrage {r.nr} · {vonText(r.von)} · {vor(r.zeit)} · {nochText(r.ablauf)}
|
||||
</span>
|
||||
<span className="text-[15px] font-semibold break-words">{r.frage}</span>
|
||||
{r.text && <span className="text-sm break-words text-text-2">{r.text}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={gesperrt} onClick={onJa}>Ja, ausführen</Button>
|
||||
<Button variant="outline" size="sm" disabled={gesperrt} onClick={onNein}>Nein</Button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die offenen Rückfragen mit Ja und Nein. Ohne offene Rückfrage zeigt das Feld nichts (Übersicht) bzw. einen Satz. */
|
||||
export function OffeneRueckfragen({ leerText }: { leerText?: string }) {
|
||||
const q = useRueckfragen()
|
||||
const antwort = useRueckfrageAntwort()
|
||||
const [frage, setFrage] = useState<Rueckfrage | null>(null)
|
||||
const offen = q.data?.offen ?? []
|
||||
if (offen.length === 0) return leerText ? <p className="text-sm text-text-2">{leerText}</p> : null
|
||||
return (
|
||||
<>
|
||||
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
|
||||
{offen.map((r) => (
|
||||
<Eintrag key={r.nr} r={r} gesperrt={antwort.isPending}
|
||||
onJa={() => setFrage(r)} onNein={() => antwort.mutate({ nr: r.nr, ja: false })} />
|
||||
))}
|
||||
</ul>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? frage.frage : ""}
|
||||
text={frage ? `${frage.text ? `${frage.text} ` : ""}Das führt genau diese Aktion aus — wie „ja ${frage.nr}“ in Telegram.` : ""}
|
||||
knopf="Ja, ausführen"
|
||||
gesperrt={antwort.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage) antwort.mutate({ nr: frage.nr, ja: true })
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Für die Übersicht: ein Feld, das nur erscheint, wenn eine Rückfrage offen ist. */
|
||||
export function RueckfragenPanel() {
|
||||
const q = useRueckfragen()
|
||||
const anzahl = q.data?.offen.length ?? 0
|
||||
if (anzahl === 0) return null
|
||||
return (
|
||||
<Panel titel={anzahl === 1 ? "Eine Rückfrage an dich" : `${anzahl} Rückfragen an dich`} symbol={MessageCircleQuestionMark} id="rueckfragen">
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Lucy wartet auf dein Ja. In Telegram reicht „ja“ mit der Nummer — oder hier ein Klick.
|
||||
</p>
|
||||
<OffeneRueckfragen />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die zuletzt beantworteten oder abgelaufenen Rückfragen (Einstellungen → Lucy). */
|
||||
export function RueckfragenVerlauf() {
|
||||
const q = useRueckfragen()
|
||||
const zuletzt = (q.data?.zuletzt ?? []).slice(0, 8)
|
||||
if (zuletzt.length === 0) return <p className="text-sm text-text-3">Noch keine beantwortete Rückfrage.</p>
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{zuletzt.map((r) => (
|
||||
<li key={r.nr} className="flex flex-col gap-0.5 border-t border-white/[0.06] py-2 first:border-t-0">
|
||||
<span className="text-sm break-words">{r.frage}</span>
|
||||
<span className="text-xs text-text-3">
|
||||
Rückfrage {r.nr} · {vonText(r.von)} · {vor(r.zeit)} · {ausgangText(r)}
|
||||
{r.status === "fehler" && r.ergebnis ? ` — ${r.ergebnis}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
|
||||
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -511,6 +512,53 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Lucys Freiheiten je Aktionsart (seit 25.09.2026, Einstellungen → Lucy). */
|
||||
export const useLucyAutonomie = (aktiv = true) =>
|
||||
useQuery({
|
||||
queryKey: ["lucy-autonomie"],
|
||||
queryFn: () => api<LucyAutonomie>("/api/lucy/autonomie"),
|
||||
enabled: aktiv,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useLucyAutonomieSetzen = () =>
|
||||
useAktion((stufen: Record<string, LucyStufe>) => post<AktionsErgebnis>("/api/lucy/autonomie", { stufen }), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.",
|
||||
neuLaden: ["lucy-autonomie"],
|
||||
})
|
||||
|
||||
/** Rückfragen an den Commander (seit 25.09.2026): offene und die zuletzt beantworteten. */
|
||||
export const useRueckfragen = (aktiv = true) =>
|
||||
useQuery({
|
||||
queryKey: ["rueckfragen"],
|
||||
queryFn: () => api<Rueckfragen>("/api/rueckfragen"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(30_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Ja oder Nein auf eine Rückfrage — wie „ja 7“ in Telegram: Ja führt genau die gefragte Aktion aus. */
|
||||
export const useRueckfrageAntwort = () =>
|
||||
useAktion(({ nr, ja }: { nr: number; ja: boolean }) => post<AktionsErgebnis>(`/api/rueckfragen/${nr}/${ja ? "ja" : "nein"}`), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Erledigt.",
|
||||
neuLaden: ["rueckfragen", "start", "jobs", "homelab", "homelab-laeufe"],
|
||||
})
|
||||
|
||||
/** Der Wochenbericht (seit 25.09.2026): der letzte und wann der nächste kommt. */
|
||||
export const useWochenbericht = (aktiv = true) =>
|
||||
useQuery({
|
||||
queryKey: ["wochenbericht"],
|
||||
queryFn: () => api<Wochenbericht>("/api/lucy/wochenbericht"),
|
||||
enabled: aktiv,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useWochenberichtSenden = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/lucy/wochenbericht"), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Der Wochenbericht ist unterwegs.",
|
||||
neuLaden: ["wochenbericht", "rueckfragen"],
|
||||
})
|
||||
|
||||
/** Wartungsfenster und Regel je Gerät (seit 25.09.2026). */
|
||||
export const useWartung = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
|
||||
@@ -34,4 +34,10 @@ describe("Befehlssuche", () => {
|
||||
expect(treffer.filter((b) => b.text === "Updates").map((b) => b.unter)).toEqual(["KI-Box", "Homelab"])
|
||||
expect(suchen(alle, "gibtesnicht")).toEqual([])
|
||||
})
|
||||
|
||||
it("findet Lucys Freiheiten, Rückfragen und den Wochenbericht (seit 25.09.2026)", () => {
|
||||
expect(suchen(alle, "lucy freiheit")[0]).toMatchObject({ to: "/einstellungen", hash: "lucy" })
|
||||
expect(suchen(alle, "rückfragen")[0]).toMatchObject({ text: "Rückfragen an dich" })
|
||||
expect(suchen(alle, "wochenbericht")[0]).toMatchObject({ text: "Wochenbericht", hash: "lucy" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,15 @@ export function befehle(ziele: ZielStand[]): Befehl[] {
|
||||
id: `seite:${s.to}`, text: s.label, unter: ORT_NAME[s.ort], to: s.to,
|
||||
stichworte: s.to.replace(/\//g, " "),
|
||||
}))
|
||||
// Seit 25.09.2026 (Ausbauplan Welle 3): Lucys Freiheiten, ihre Rückfragen und der Wochenbericht.
|
||||
liste.push(
|
||||
{ id: "lucy:freiheiten", text: "Lucys Freiheiten", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
|
||||
stichworte: "lucy autonomie selbst fragen" },
|
||||
{ id: "lucy:rueckfragen", text: "Rückfragen an dich", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
|
||||
stichworte: "lucy ja nein telegram rueckfrage" },
|
||||
{ id: "lucy:wochenbericht", text: "Wochenbericht", unter: "Einstellungen → Lucy", to: "/einstellungen", hash: "lucy",
|
||||
stichworte: "lucy bericht sonntag" },
|
||||
)
|
||||
for (const z of ziele) {
|
||||
const geraet = { art: z.art, offen: z.offen }
|
||||
const stichworte = `${ART_WORT[z.art] ?? ""} ${z.id}`
|
||||
|
||||
@@ -841,3 +841,53 @@ export interface Ablauf {
|
||||
status: "ok" | "ueberfaellig" | "gestoppt" | "aus"
|
||||
text: string | null
|
||||
}
|
||||
|
||||
// --- Lucy: Freiheiten, Rückfragen, Wochenbericht (seit 25.09.2026, Ausbauplan Welle 3) ------------------------------
|
||||
|
||||
export type LucyStufe = "lesen" | "fragen" | "selbst"
|
||||
|
||||
/** Eine Aktionsart mit Lucys Freiheit (GET /api/lucy/autonomie). */
|
||||
export interface LucyArt {
|
||||
id: string
|
||||
name: string
|
||||
text: string
|
||||
stufe: LucyStufe
|
||||
standard: LucyStufe
|
||||
}
|
||||
|
||||
export interface LucyAutonomie {
|
||||
arten: LucyArt[]
|
||||
stufen: Record<LucyStufe, string>
|
||||
}
|
||||
|
||||
export type RueckfrageStatus = "offen" | "laeuft" | "erledigt" | "fehler" | "abgelehnt" | "abgelaufen" | "hinfaellig"
|
||||
|
||||
/** Eine Rückfrage an den Commander (GET /api/rueckfragen) — auch in Telegram mit „ja 7“ zu beantworten. */
|
||||
export interface Rueckfrage {
|
||||
nr: number
|
||||
/** Unix-Sekunden */
|
||||
zeit: number
|
||||
frage: string
|
||||
text: string
|
||||
pfad: string
|
||||
von: string
|
||||
stufe: "dringend" | "normal" | "leise"
|
||||
/** Unix-Sekunden: danach gilt die Rückfrage nicht mehr */
|
||||
ablauf: number
|
||||
status: RueckfrageStatus
|
||||
befund: { bereich: "box" | "homelab"; id: string; titel: string } | null
|
||||
antwort?: { zeit: number; wer: string; ja: boolean }
|
||||
ergebnis?: string
|
||||
nachgeprueft?: "behoben" | "besteht"
|
||||
}
|
||||
|
||||
export interface Rueckfragen {
|
||||
offen: Rueckfrage[]
|
||||
zuletzt: Rueckfrage[]
|
||||
}
|
||||
|
||||
/** GET /api/lucy/wochenbericht: der letzte Bericht und wann der nächste kommt (Unix-Sekunden). */
|
||||
export interface Wochenbericht {
|
||||
zuletzt: { zeit: number; text: string } | null
|
||||
naechster: number
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { type ReactNode, useEffect, useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { cn } from "cn"
|
||||
import { ExternalLink, KeyRound } from "lucide-react"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Wartungsfenster } from "@/components/homelab/Wartungsfenster"
|
||||
import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinstellungen"
|
||||
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
@@ -62,10 +64,11 @@ function Abschnitt({ titel, children }: { titel: string; children: ReactNode })
|
||||
}
|
||||
|
||||
/** Eine Karte der Einstellungsseite; KI-Box und Homelab tragen ihre Bereichsfarbe als Schein und Rand (Design v5). */
|
||||
function Karte({ titel, text, farbe, children }: {
|
||||
function Karte({ titel, text, farbe, id, children }: {
|
||||
titel: string
|
||||
text: string
|
||||
farbe?: "box" | "homelab"
|
||||
id?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const glut = farbe === "box" ? "var(--box)" : farbe === "homelab" ? "var(--lab)" : null
|
||||
@@ -73,7 +76,7 @@ function Karte({ titel, text, farbe, children }: {
|
||||
const rand = farbe === "box" ? "[--rand:color-mix(in_srgb,var(--box)_28%,transparent)]"
|
||||
: farbe === "homelab" ? "[--rand:color-mix(in_srgb,var(--lab)_28%,transparent)]" : ""
|
||||
return (
|
||||
<section aria-label={titel} className={cn("flaeche relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-3xl p-5 sm:p-6", rand)}>
|
||||
<section id={id} aria-label={titel} className={cn("flaeche relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-3xl p-5 sm:p-6", rand)}>
|
||||
{glut && (
|
||||
<div aria-hidden className="pointer-events-none absolute -top-40 -left-32 size-[400px] rounded-full"
|
||||
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${glut} 16%, transparent), transparent)` }} />
|
||||
@@ -625,11 +628,29 @@ function HomelabKarte() {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Lucy: Freiheiten, Rückfragen, Wochenbericht (seit 25.09.2026, Ausbauplan Welle 3) --------------------------------
|
||||
|
||||
function LucyKarte() {
|
||||
return (
|
||||
<Karte id="lucy" titel="Lucy" text="Was sie aus eigenem Antrieb darf, ihre Rückfragen an dich und der Wochenbericht.">
|
||||
<Abschnitt titel="Wie selbstständig Lucy handelt"><Freiheiten /></Abschnitt>
|
||||
<Abschnitt titel="Offene Rückfragen"><OffeneRueckfragen leerText="Gerade wartet keine Rückfrage auf dich." /></Abschnitt>
|
||||
<Abschnitt titel="Zuletzt beantwortet"><RueckfragenVerlauf /></Abschnitt>
|
||||
<Abschnitt titel="Wochenbericht"><WochenberichtAbschnitt /></Abschnitt>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Seite ----------------------------------------------------------------------------------------------
|
||||
|
||||
export function EinstellungenSeite() {
|
||||
const box = useEinstellungenStand(true)
|
||||
const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message }
|
||||
// Strg+K „Lucys Freiheiten“ führt mit #lucy hierher (beim Öffnen der Seite; ohne Router-Hook, damit die Seite auch
|
||||
// allein testbar bleibt).
|
||||
useEffect(() => {
|
||||
if (window.location.hash === "#lucy") document.getElementById("lucy")?.scrollIntoView({ block: "start" })
|
||||
}, [])
|
||||
return (
|
||||
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
|
||||
<Karte titel="KI-Box" farbe="box" text="Wann sie sich aktualisiert, das Modell-Radar und Zugänge.">
|
||||
@@ -644,6 +665,7 @@ export function EinstellungenSeite() {
|
||||
</Abschnitt>
|
||||
</Karte>
|
||||
<HomelabKarte />
|
||||
<LucyKarte />
|
||||
<Karte titel="Meldungen" text="Telegram, Morgenmeldung und was sofort kommt.">
|
||||
<Meldungen />
|
||||
</Karte>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "cn"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { Messring } from "@/components/diagramme/Messring"
|
||||
import { RueckfragenPanel } from "@/components/lucy/Rueckfragen"
|
||||
import { Lagering, type Segment } from "@/components/uebersicht/Lagering"
|
||||
import {
|
||||
useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
|
||||
@@ -650,6 +651,9 @@ export function UebersichtSeite() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Rückfragen an den Commander (seit 25.09.2026): nur, wenn eine offen ist — ganz oben, weil sie auf ihn warten. */}
|
||||
<RueckfragenPanel />
|
||||
|
||||
<Instrumente homelabAktiv={homelabAktiv} />
|
||||
|
||||
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
|
||||
|
||||
Reference in New Issue
Block a user