gpt-engineer-app[bot] commited on
Commit
e43d3b8
·
2 Parent(s): 5e2060ffea88db

Added Qwen learnings panel

Browse files

X-Lovable-Edit-ID: edt-05f63dd4-b3be-4ea8-a1d8-3477814f517f

src/components/qwen-bot.tsx CHANGED
@@ -1,33 +1,48 @@
1
- import { useEffect, useState } from "react";
2
  import { cn } from "@/lib/utils";
3
- import { Bot, Sparkles, X } from "lucide-react";
 
 
4
 
5
  type QwenBotProps = {
6
  status: "idle" | "analyzing" | "complete" | "failed";
7
  summary?: string | null;
8
  severity?: string;
9
  error?: string | null;
 
10
  };
11
 
12
- /**
13
- * Floating Qwen mascot — an animated robot that "speaks" the latest analysis.
14
- * Sits in the bottom-right corner of the dashboard.
15
- */
16
- export function QwenBot({ status, summary, severity, error }: QwenBotProps) {
 
 
 
 
 
 
 
 
17
  const [open, setOpen] = useState(true);
 
18
  const [typed, setTyped] = useState("");
 
 
 
 
19
 
20
  const message =
21
  status === "analyzing"
22
  ? "Reasoning through the incident… engaging Event, Safety, Risk and Documentation agents."
23
  : status === "failed"
24
  ? `I hit a snag: ${error ?? "unknown error"}.`
25
- : summary ?? "Hi, I'm Qwen — submit or select an incident and I'll analyze it for you.";
26
 
27
- // Typewriter effect
28
  useEffect(() => {
29
  setTyped("");
30
- if (!open) return;
31
  let i = 0;
32
  const id = setInterval(() => {
33
  i++;
@@ -35,41 +50,138 @@ export function QwenBot({ status, summary, severity, error }: QwenBotProps) {
35
  if (i >= message.length) clearInterval(id);
36
  }, 14);
37
  return () => clearInterval(id);
38
- }, [message, open]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  const moodColor =
41
- status === "failed"
42
- ? "text-severity-critical"
43
- : status === "analyzing"
44
- ? "text-primary"
45
- : severity === "critical" || severity === "high"
46
- ? "text-severity-high"
47
- : "text-primary";
48
 
49
  return (
50
  <div className="fixed bottom-6 right-6 z-50 flex items-end gap-3 pointer-events-none">
51
- {/* Speech bubble */}
52
  {open && (
53
- <div className="pointer-events-auto max-w-xs rounded-2xl rounded-br-sm border border-border bg-surface-elevated/95 backdrop-blur p-4 shadow-[var(--shadow-glow)] animate-in fade-in slide-in-from-bottom-2">
54
- <div className="flex items-center justify-between mb-2">
 
55
  <div className="flex items-center gap-1.5">
56
  <Sparkles className={cn("h-3 w-3", moodColor)} />
57
  <span className="text-[10px] uppercase tracking-widest font-mono text-muted-foreground">
58
  Qwen · {status}
59
  </span>
60
  </div>
61
- <button
62
- onClick={() => setOpen(false)}
63
- className="text-muted-foreground hover:text-foreground"
64
- aria-label="Dismiss"
65
- >
66
  <X className="h-3 w-3" />
67
  </button>
68
  </div>
69
- <p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap">
70
- {typed}
71
- {typed.length < message.length && <span className="inline-block w-1 h-3 bg-primary ml-0.5 animate-pulse" />}
72
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </div>
74
  )}
75
 
@@ -87,18 +199,14 @@ export function QwenBot({ status, summary, severity, error }: QwenBotProps) {
87
  )}
88
  aria-label="Toggle Qwen bot"
89
  >
90
- {/* Pulse ring while analyzing */}
91
  {status === "analyzing" && (
92
  <span className="absolute inset-0 rounded-full border-2 border-primary animate-ping opacity-60" />
93
  )}
94
- {/* Robot face */}
95
  <div className="relative">
96
  <Bot className={cn("h-8 w-8", moodColor)} />
97
- {/* eyes blink */}
98
  <span className="absolute -top-0.5 left-1.5 h-1 w-1 rounded-full bg-primary animate-pulse" />
99
  <span className="absolute -top-0.5 right-1.5 h-1 w-1 rounded-full bg-primary animate-pulse" />
100
  </div>
101
- {/* Status dot */}
102
  <span
103
  className={cn(
104
  "absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
@@ -112,3 +220,17 @@ export function QwenBot({ status, summary, severity, error }: QwenBotProps) {
112
  </div>
113
  );
114
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useCallback } from "react";
2
  import { cn } from "@/lib/utils";
3
+ import { Bot, Sparkles, X, Brain, Plus, Trash2 } from "lucide-react";
4
+ import { supabase } from "@/integrations/supabase/client";
5
+ import { toast } from "sonner";
6
 
7
  type QwenBotProps = {
8
  status: "idle" | "analyzing" | "complete" | "failed";
9
  summary?: string | null;
10
  severity?: string;
11
  error?: string | null;
12
+ incidentId?: string | null;
13
  };
14
 
15
+ type Learning = {
16
+ id: string;
17
+ category: "correction" | "insight" | "error" | "best_practice";
18
+ content: string;
19
+ context: string | null;
20
+ created_at: string;
21
+ };
22
+
23
+ type Tab = "chat" | "learnings";
24
+
25
+ const CATEGORIES: Learning["category"][] = ["correction", "insight", "error", "best_practice"];
26
+
27
+ export function QwenBot({ status, summary, severity, error, incidentId }: QwenBotProps) {
28
  const [open, setOpen] = useState(true);
29
+ const [tab, setTab] = useState<Tab>("chat");
30
  const [typed, setTyped] = useState("");
31
+ const [learnings, setLearnings] = useState<Learning[]>([]);
32
+ const [adding, setAdding] = useState(false);
33
+ const [newCategory, setNewCategory] = useState<Learning["category"]>("correction");
34
+ const [newContent, setNewContent] = useState("");
35
 
36
  const message =
37
  status === "analyzing"
38
  ? "Reasoning through the incident… engaging Event, Safety, Risk and Documentation agents."
39
  : status === "failed"
40
  ? `I hit a snag: ${error ?? "unknown error"}.`
41
+ : summary ?? "Hi, I'm Qwen — submit or select an incident and I'll analyze it for you. Teach me in the Learnings tab.";
42
 
 
43
  useEffect(() => {
44
  setTyped("");
45
+ if (!open || tab !== "chat") return;
46
  let i = 0;
47
  const id = setInterval(() => {
48
  i++;
 
50
  if (i >= message.length) clearInterval(id);
51
  }, 14);
52
  return () => clearInterval(id);
53
+ }, [message, open, tab]);
54
+
55
+ const loadLearnings = useCallback(async () => {
56
+ const { data } = await supabase
57
+ .from("qwen_learnings")
58
+ .select("*")
59
+ .order("created_at", { ascending: false })
60
+ .limit(50);
61
+ setLearnings((data ?? []) as Learning[]);
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ if (open && tab === "learnings") loadLearnings();
66
+ }, [open, tab, loadLearnings]);
67
+
68
+ const addLearning = async () => {
69
+ if (!newContent.trim()) return;
70
+ const { data: u } = await supabase.auth.getUser();
71
+ if (!u.user) {
72
+ toast.error("Sign in first.");
73
+ return;
74
+ }
75
+ const { error: e } = await supabase.from("qwen_learnings").insert({
76
+ user_id: u.user.id,
77
+ incident_id: incidentId ?? null,
78
+ category: newCategory,
79
+ content: newContent.trim(),
80
+ });
81
+ if (e) {
82
+ toast.error(e.message);
83
+ return;
84
+ }
85
+ toast.success("Qwen learned something new.");
86
+ setNewContent("");
87
+ setAdding(false);
88
+ loadLearnings();
89
+ };
90
+
91
+ const removeLearning = async (id: string) => {
92
+ await supabase.from("qwen_learnings").delete().eq("id", id);
93
+ loadLearnings();
94
+ };
95
 
96
  const moodColor =
97
+ status === "failed" ? "text-severity-critical"
98
+ : status === "analyzing" ? "text-primary"
99
+ : severity === "critical" || severity === "high" ? "text-severity-high"
100
+ : "text-primary";
 
 
 
101
 
102
  return (
103
  <div className="fixed bottom-6 right-6 z-50 flex items-end gap-3 pointer-events-none">
 
104
  {open && (
105
+ <div className="pointer-events-auto w-80 rounded-2xl rounded-br-sm border border-border bg-surface-elevated/95 backdrop-blur shadow-[var(--shadow-glow)] animate-in fade-in slide-in-from-bottom-2 overflow-hidden flex flex-col max-h-[70vh]">
106
+ {/* Header */}
107
+ <div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-border">
108
  <div className="flex items-center gap-1.5">
109
  <Sparkles className={cn("h-3 w-3", moodColor)} />
110
  <span className="text-[10px] uppercase tracking-widest font-mono text-muted-foreground">
111
  Qwen · {status}
112
  </span>
113
  </div>
114
+ <button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground" aria-label="Dismiss">
 
 
 
 
115
  <X className="h-3 w-3" />
116
  </button>
117
  </div>
118
+
119
+ {/* Tabs */}
120
+ <div className="flex border-b border-border text-[10px] font-mono uppercase tracking-wider">
121
+ <TabButton active={tab === "chat"} onClick={() => setTab("chat")}>Analysis</TabButton>
122
+ <TabButton active={tab === "learnings"} onClick={() => setTab("learnings")}>
123
+ <Brain className="h-3 w-3 inline mr-1" />Learnings ({learnings.length || "·"})
124
+ </TabButton>
125
+ </div>
126
+
127
+ {/* Content */}
128
+ <div className="flex-1 overflow-y-auto p-4">
129
+ {tab === "chat" ? (
130
+ <p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap">
131
+ {typed}
132
+ {typed.length < message.length && (
133
+ <span className="inline-block w-1 h-3 bg-primary ml-0.5 animate-pulse" />
134
+ )}
135
+ </p>
136
+ ) : (
137
+ <div className="space-y-2">
138
+ <p className="text-[11px] text-muted-foreground leading-snug">
139
+ Teach Qwen. Recent learnings are injected into every analysis.
140
+ </p>
141
+ {learnings.length === 0 && !adding && (
142
+ <p className="text-[11px] text-muted-foreground italic">No learnings yet.</p>
143
+ )}
144
+ {learnings.map((l) => (
145
+ <div key={l.id} className="rounded-md border border-border bg-surface p-2 group">
146
+ <div className="flex items-center justify-between mb-1">
147
+ <span className="text-[9px] font-mono uppercase tracking-wider text-primary">{l.category}</span>
148
+ <button onClick={() => removeLearning(l.id)} className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive">
149
+ <Trash2 className="h-3 w-3" />
150
+ </button>
151
+ </div>
152
+ <p className="text-[11px] text-foreground/85">{l.content}</p>
153
+ </div>
154
+ ))}
155
+
156
+ {adding ? (
157
+ <div className="rounded-md border border-primary/40 bg-surface p-2 space-y-2">
158
+ <select
159
+ value={newCategory}
160
+ onChange={(e) => setNewCategory(e.target.value as Learning["category"])}
161
+ className="w-full text-[11px] bg-background border border-border rounded px-2 py-1"
162
+ >
163
+ {CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
164
+ </select>
165
+ <textarea
166
+ value={newContent}
167
+ onChange={(e) => setNewContent(e.target.value)}
168
+ placeholder="What should Qwen remember?"
169
+ rows={3}
170
+ className="w-full text-[11px] bg-background border border-border rounded px-2 py-1 resize-none"
171
+ />
172
+ <div className="flex gap-1">
173
+ <button onClick={addLearning} className="flex-1 text-[10px] uppercase tracking-wider font-mono bg-primary text-primary-foreground rounded px-2 py-1 hover:opacity-90">Save</button>
174
+ <button onClick={() => { setAdding(false); setNewContent(""); }} className="text-[10px] uppercase tracking-wider font-mono border border-border rounded px-2 py-1 hover:bg-surface">Cancel</button>
175
+ </div>
176
+ </div>
177
+ ) : (
178
+ <button onClick={() => setAdding(true)} className="w-full flex items-center justify-center gap-1 text-[10px] uppercase tracking-wider font-mono border border-dashed border-border rounded px-2 py-2 text-muted-foreground hover:text-primary hover:border-primary/40">
179
+ <Plus className="h-3 w-3" /> Teach Qwen
180
+ </button>
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
  </div>
186
  )}
187
 
 
199
  )}
200
  aria-label="Toggle Qwen bot"
201
  >
 
202
  {status === "analyzing" && (
203
  <span className="absolute inset-0 rounded-full border-2 border-primary animate-ping opacity-60" />
204
  )}
 
205
  <div className="relative">
206
  <Bot className={cn("h-8 w-8", moodColor)} />
 
207
  <span className="absolute -top-0.5 left-1.5 h-1 w-1 rounded-full bg-primary animate-pulse" />
208
  <span className="absolute -top-0.5 right-1.5 h-1 w-1 rounded-full bg-primary animate-pulse" />
209
  </div>
 
210
  <span
211
  className={cn(
212
  "absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
 
220
  </div>
221
  );
222
  }
223
+
224
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
225
+ return (
226
+ <button
227
+ onClick={onClick}
228
+ className={cn(
229
+ "flex-1 px-3 py-2 transition-colors",
230
+ active ? "text-primary border-b-2 border-primary -mb-px" : "text-muted-foreground hover:text-foreground"
231
+ )}
232
+ >
233
+ {children}
234
+ </button>
235
+ );
236
+ }
src/integrations/supabase/types.ts CHANGED
@@ -80,6 +80,44 @@ export type Database = {
80
  }
81
  Relationships: []
82
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  user_roles: {
84
  Row: {
85
  id: string
@@ -115,6 +153,7 @@ export type Database = {
115
  app_role: "admin" | "operator"
116
  incident_severity: "low" | "medium" | "high" | "critical" | "unknown"
117
  incident_status: "pending" | "analyzing" | "complete" | "failed"
 
118
  source_type: "text" | "file" | "pdf" | "video"
119
  }
120
  CompositeTypes: {
@@ -246,6 +285,7 @@ export const Constants = {
246
  app_role: ["admin", "operator"],
247
  incident_severity: ["low", "medium", "high", "critical", "unknown"],
248
  incident_status: ["pending", "analyzing", "complete", "failed"],
 
249
  source_type: ["text", "file", "pdf", "video"],
250
  },
251
  },
 
80
  }
81
  Relationships: []
82
  }
83
+ qwen_learnings: {
84
+ Row: {
85
+ category: Database["public"]["Enums"]["learning_category"]
86
+ content: string
87
+ context: string | null
88
+ created_at: string
89
+ id: string
90
+ incident_id: string | null
91
+ user_id: string
92
+ }
93
+ Insert: {
94
+ category?: Database["public"]["Enums"]["learning_category"]
95
+ content: string
96
+ context?: string | null
97
+ created_at?: string
98
+ id?: string
99
+ incident_id?: string | null
100
+ user_id: string
101
+ }
102
+ Update: {
103
+ category?: Database["public"]["Enums"]["learning_category"]
104
+ content?: string
105
+ context?: string | null
106
+ created_at?: string
107
+ id?: string
108
+ incident_id?: string | null
109
+ user_id?: string
110
+ }
111
+ Relationships: [
112
+ {
113
+ foreignKeyName: "qwen_learnings_incident_id_fkey"
114
+ columns: ["incident_id"]
115
+ isOneToOne: false
116
+ referencedRelation: "incidents"
117
+ referencedColumns: ["id"]
118
+ },
119
+ ]
120
+ }
121
  user_roles: {
122
  Row: {
123
  id: string
 
153
  app_role: "admin" | "operator"
154
  incident_severity: "low" | "medium" | "high" | "critical" | "unknown"
155
  incident_status: "pending" | "analyzing" | "complete" | "failed"
156
+ learning_category: "correction" | "insight" | "error" | "best_practice"
157
  source_type: "text" | "file" | "pdf" | "video"
158
  }
159
  CompositeTypes: {
 
285
  app_role: ["admin", "operator"],
286
  incident_severity: ["low", "medium", "high", "critical", "unknown"],
287
  incident_status: ["pending", "analyzing", "complete", "failed"],
288
+ learning_category: ["correction", "insight", "error", "best_practice"],
289
  source_type: ["text", "file", "pdf", "video"],
290
  },
291
  },
src/routes/dashboard.index.tsx CHANGED
@@ -116,6 +116,7 @@ function IncidentsPage() {
116
  summary={selected?.analysis?.summary}
117
  severity={selected?.severity}
118
  error={selected?.error}
 
119
  />
120
  </>
121
  );
 
116
  summary={selected?.analysis?.summary}
117
  severity={selected?.severity}
118
  error={selected?.error}
119
+ incidentId={selected?.id ?? null}
120
  />
121
  </>
122
  );
src/server/incidents.functions.ts CHANGED
@@ -82,6 +82,20 @@ export const analyzeIncident = createServerFn({ method: "POST" })
82
  await supabase.from("incidents").update({ status: "analyzing", error: null }).eq("id", data.incidentId);
83
 
84
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  const userContent = `INCIDENT TITLE: ${incident.title}\nSOURCE TYPE: ${incident.source_type}\nFILE: ${incident.file_name ?? "(none)"}\n\n--- RAW INPUT ---\n${incident.raw_text ?? "(no text content provided)"}`;
86
 
87
  const requestBody = JSON.stringify({
@@ -89,6 +103,7 @@ export const analyzeIncident = createServerFn({ method: "POST" })
89
  messages: [
90
  { role: "system", content: SYSTEM_PROMPT },
91
  { role: "system", content: AV_KNOWLEDGE_BASE },
 
92
  { role: "user", content: userContent },
93
  ],
94
  tools: [analysisTool],
 
82
  await supabase.from("incidents").update({ status: "analyzing", error: null }).eq("id", data.incidentId);
83
 
84
  try {
85
+ // Pull the user's recent self-improvement learnings (Self-Improving Agent skill)
86
+ const { data: learnings } = await supabase
87
+ .from("qwen_learnings")
88
+ .select("category, content, context")
89
+ .eq("user_id", context.userId)
90
+ .order("created_at", { ascending: false })
91
+ .limit(20);
92
+
93
+ const learningsBlock = learnings && learnings.length
94
+ ? `PRIOR LEARNINGS (operator corrections, insights, past errors — apply these going forward):\n${learnings
95
+ .map((l: any, i: number) => `${i + 1}. [${l.category}] ${l.content}${l.context ? ` (context: ${l.context})` : ""}`)
96
+ .join("\n")}`
97
+ : "PRIOR LEARNINGS: (none yet)";
98
+
99
  const userContent = `INCIDENT TITLE: ${incident.title}\nSOURCE TYPE: ${incident.source_type}\nFILE: ${incident.file_name ?? "(none)"}\n\n--- RAW INPUT ---\n${incident.raw_text ?? "(no text content provided)"}`;
100
 
101
  const requestBody = JSON.stringify({
 
103
  messages: [
104
  { role: "system", content: SYSTEM_PROMPT },
105
  { role: "system", content: AV_KNOWLEDGE_BASE },
106
+ { role: "system", content: learningsBlock },
107
  { role: "user", content: userContent },
108
  ],
109
  tools: [analysisTool],
supabase/migrations/20260508054842_6ca5f2d6-cdd3-4441-a997-dfce23d77e75.sql ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Promote demo account to admin
2
+ INSERT INTO public.user_roles (user_id, role)
3
+ SELECT id, 'admin'::app_role FROM auth.users WHERE email = 'drivecore@drivecore.local'
4
+ ON CONFLICT (user_id, role) DO NOTHING;
5
+
6
+ -- Self-improvement learnings table
7
+ CREATE TYPE public.learning_category AS ENUM ('correction', 'insight', 'error', 'best_practice');
8
+
9
+ CREATE TABLE public.qwen_learnings (
10
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
11
+ user_id uuid NOT NULL,
12
+ incident_id uuid REFERENCES public.incidents(id) ON DELETE SET NULL,
13
+ category public.learning_category NOT NULL DEFAULT 'correction',
14
+ content text NOT NULL,
15
+ context text,
16
+ created_at timestamptz NOT NULL DEFAULT now()
17
+ );
18
+
19
+ ALTER TABLE public.qwen_learnings ENABLE ROW LEVEL SECURITY;
20
+
21
+ CREATE POLICY "learnings owner read" ON public.qwen_learnings
22
+ FOR SELECT USING (auth.uid() = user_id OR public.has_role(auth.uid(), 'admin'));
23
+
24
+ CREATE POLICY "learnings owner insert" ON public.qwen_learnings
25
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
26
+
27
+ CREATE POLICY "learnings owner delete" ON public.qwen_learnings
28
+ FOR DELETE USING (auth.uid() = user_id OR public.has_role(auth.uid(), 'admin'));
29
+
30
+ CREATE INDEX idx_qwen_learnings_recent ON public.qwen_learnings (created_at DESC);