kyd-shared-badge 0.2.34 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/src/SharedBadgeDisplay.tsx +5 -8
- package/src/ambient-ai.d.ts +9 -0
- package/src/chat/ChatWidget.tsx +317 -0
- package/src/chat/ChatWindowStreaming.tsx +56 -0
- package/src/chat/EvidenceBlock.tsx +39 -0
- package/src/chat/chat-overrides.css +138 -0
- package/src/chat/parseEvidence.ts +18 -0
- package/src/chat/types.ts +18 -0
- package/src/chat/useChatStreaming.ts +85 -0
- package/src/components/AppendixTables.tsx +2 -1
- package/src/components/ConnectedPlatforms.tsx +2 -1
- package/src/components/ProviderInsights.tsx +2 -1
- package/src/components/ReportHeader.tsx +4 -3
- package/src/index.ts +4 -0
- package/src/lib/auth-verify.ts +48 -0
- package/src/lib/chat-store.ts +96 -0
- package/src/lib/context.ts +147 -0
- package/src/lib/rate-limit.ts +29 -0
- package/src/lib/routes.ts +94 -0
- package/src/utils/date.ts +68 -0
- package/src/public/aigreen.png +0 -0
- package/src/public/aired.png +0 -0
- package/src/public/aiyellow.png +0 -0
- package/src/public/codegreen.png +0 -0
- package/src/public/codered.png +0 -0
- package/src/public/codeyellow.png +0 -0
- package/src/public/riskgreen.png +0 -0
- package/src/public/riskred.png +0 -0
- package/src/public/riskyellow.png +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kyd-shared-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
"react-dom": "^19.0.0"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@ai-sdk/amazon-bedrock": "^3.0.22",
|
|
20
|
+
"@aws-sdk/client-s3": "^3.893.0",
|
|
21
|
+
"@aws-sdk/lib-dynamodb": "^3.893.0",
|
|
22
|
+
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
|
23
|
+
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
|
24
|
+
"ai": "5.0.47",
|
|
19
25
|
"i18n-iso-countries": "^7.14.0",
|
|
20
26
|
"react-icons": "^5.5.0",
|
|
21
27
|
"recharts": "^2.15.4"
|
|
@@ -24,6 +30,7 @@
|
|
|
24
30
|
"access": "public"
|
|
25
31
|
},
|
|
26
32
|
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.5.2",
|
|
27
34
|
"@types/react": "^19.1.10",
|
|
28
35
|
"@types/react-dom": "^19.1.7",
|
|
29
36
|
"rollup": "^4.46.2"
|
|
@@ -21,6 +21,8 @@ import CategoryBars from './components/CategoryBars';
|
|
|
21
21
|
import SkillsAppendixTable from './components/SkillsAppendixTable';
|
|
22
22
|
import { BusinessRulesProvider } from './components/BusinessRulesContext';
|
|
23
23
|
import Reveal from './components/Reveal';
|
|
24
|
+
import { formatLocalDateTime } from './utils/date';
|
|
25
|
+
import ChatWidget from './chat/ChatWidget';
|
|
24
26
|
|
|
25
27
|
// const hexToRgba = (hex: string, alpha: number) => {
|
|
26
28
|
// const clean = hex.replace('#', '');
|
|
@@ -519,14 +521,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
|
|
|
519
521
|
|
|
520
522
|
<Reveal>
|
|
521
523
|
<div className={'pt-8 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
|
|
522
|
-
Report Completed: {
|
|
523
|
-
year: 'numeric',
|
|
524
|
-
month: 'long',
|
|
525
|
-
day: 'numeric',
|
|
526
|
-
hour: 'numeric',
|
|
527
|
-
minute: '2-digit',
|
|
528
|
-
timeZoneName: 'short',
|
|
529
|
-
})}
|
|
524
|
+
Report Completed: {formatLocalDateTime(updatedAt)}
|
|
530
525
|
</div>
|
|
531
526
|
</Reveal>
|
|
532
527
|
</div>
|
|
@@ -538,6 +533,8 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
|
|
|
538
533
|
</p>
|
|
539
534
|
</footer>
|
|
540
535
|
</Reveal>
|
|
536
|
+
{/* Floating chat widget */}
|
|
537
|
+
<ChatWidget api={'/api/chat'} badgeId={badgeId} />
|
|
541
538
|
</div>
|
|
542
539
|
</BusinessRulesProvider>
|
|
543
540
|
);
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { FiMessageSquare, FiX, FiChevronRight } from 'react-icons/fi';
|
|
5
|
+
import { useChatStreaming } from './useChatStreaming';
|
|
6
|
+
import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
|
|
7
|
+
import './chat-overrides.css';
|
|
8
|
+
import {
|
|
9
|
+
MainContainer,
|
|
10
|
+
ChatContainer,
|
|
11
|
+
MessageList,
|
|
12
|
+
Message,
|
|
13
|
+
TypingIndicator
|
|
14
|
+
} from '@chatscope/chat-ui-kit-react';
|
|
15
|
+
import EvidenceBlock from './EvidenceBlock';
|
|
16
|
+
import { tryParseEvidence } from './parseEvidence';
|
|
17
|
+
import Image from 'next/image';
|
|
18
|
+
import { FiSend, FiSquare } from 'react-icons/fi';
|
|
19
|
+
|
|
20
|
+
type Props = {
|
|
21
|
+
api?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
hintText?: string;
|
|
24
|
+
badgeId: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintText = 'Ask anything about the developer', badgeId }: Props) {
|
|
28
|
+
// Sidebar open state (default expanded)
|
|
29
|
+
const [open, setOpen] = useState<boolean>(() => {
|
|
30
|
+
try { const s = localStorage.getItem('kydChatSidebarOpen'); return s ? s === '1' : true; } catch { return true; }
|
|
31
|
+
});
|
|
32
|
+
// Sidebar width with bounds and persistence
|
|
33
|
+
const [width, setWidth] = useState<number>(() => {
|
|
34
|
+
try { const w = Number(localStorage.getItem('kydChatSidebarWidth')); return Number.isFinite(w) && w >= 320 ? Math.min(w, Math.floor(window.innerWidth * 0.9)) : 420; } catch { return 420; }
|
|
35
|
+
});
|
|
36
|
+
const minWidth = Math.min(320, Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1200) * 0.92));
|
|
37
|
+
const maxWidth = Math.max(minWidth, Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1200) * 0.9));
|
|
38
|
+
// Collapsed tab vertical position
|
|
39
|
+
const [tabTop, setTabTop] = useState<number>(() => {
|
|
40
|
+
try { const t = Number(localStorage.getItem('kydChatCollapsedTop')); return Number.isFinite(t) ? t : 120; } catch { return 120; }
|
|
41
|
+
});
|
|
42
|
+
const [draggingResize, setDraggingResize] = useState(false);
|
|
43
|
+
const [draggingTab, setDraggingTab] = useState(false);
|
|
44
|
+
const dragStartXRef = useRef(0);
|
|
45
|
+
const startWidthRef = useRef(0);
|
|
46
|
+
const dragStartYRef = useRef(0);
|
|
47
|
+
const startTopRef = useRef(0);
|
|
48
|
+
const tabDraggedRef = useRef(false);
|
|
49
|
+
const [headerTop, setHeaderTop] = useState(0);
|
|
50
|
+
const [showHint, setShowHint] = useState(false);
|
|
51
|
+
const { messages, input, setInput, sending, sendMessage, cancel } = useChatStreaming({ api, badgeId });
|
|
52
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
56
|
+
}, [messages, open]);
|
|
57
|
+
|
|
58
|
+
// Optional hint (only shows if user hasn't dismissed before and when collapsed)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
try {
|
|
61
|
+
const dismissed = localStorage.getItem('kydChatHintDismissed');
|
|
62
|
+
if (!dismissed) {
|
|
63
|
+
const shouldShow = !open; // show only if collapsed on first load
|
|
64
|
+
if (shouldShow) {
|
|
65
|
+
setShowHint(true);
|
|
66
|
+
const t = setTimeout(() => setShowHint(false), 6000);
|
|
67
|
+
return () => clearTimeout(t);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}, [open]);
|
|
72
|
+
|
|
73
|
+
const onDismissHint = () => {
|
|
74
|
+
setShowHint(false);
|
|
75
|
+
try { localStorage.setItem('kydChatHintDismissed', '1'); } catch {}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const header = useMemo(() => (
|
|
79
|
+
<div className={'flex items-center justify-between px-3 py-3 border-b'} style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)' }}>
|
|
80
|
+
<div>
|
|
81
|
+
<div className={'text-md font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
|
|
82
|
+
</div>
|
|
83
|
+
<button aria-label={'Collapse chat sidebar'} onClick={() => setOpen(false)} className={'p-1 rounded hover:opacity-80'}>
|
|
84
|
+
<FiX size={16} style={{ color: 'var(--text-secondary)' }} />
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
), [title]);
|
|
88
|
+
|
|
89
|
+
// Persist sidebar UI state
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
try { localStorage.setItem('kydChatSidebarOpen', open ? '1' : '0'); } catch {}
|
|
92
|
+
}, [open]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
try { localStorage.setItem('kydChatSidebarWidth', String(width)); } catch {}
|
|
95
|
+
}, [width]);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
try { localStorage.setItem('kydChatCollapsedTop', String(tabTop)); } catch {}
|
|
98
|
+
}, [tabTop]);
|
|
99
|
+
|
|
100
|
+
// ESC collapses when open
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const onKey = (e: KeyboardEvent) => {
|
|
103
|
+
if (e.key === 'Escape' && open) setOpen(false);
|
|
104
|
+
};
|
|
105
|
+
window.addEventListener('keydown', onKey);
|
|
106
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
107
|
+
}, [open]);
|
|
108
|
+
|
|
109
|
+
// Measure sticky header height so the sidebar does not overlap it
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const measure = () => {
|
|
112
|
+
const el = document.querySelector('header');
|
|
113
|
+
const h = el ? Math.floor(el.getBoundingClientRect().height) : 0;
|
|
114
|
+
setHeaderTop(h);
|
|
115
|
+
// Keep collapsed tab within bounds beneath header
|
|
116
|
+
setTabTop(t => Math.max(h + 16, t));
|
|
117
|
+
};
|
|
118
|
+
measure();
|
|
119
|
+
window.addEventListener('resize', measure);
|
|
120
|
+
window.addEventListener('scroll', measure, { passive: true });
|
|
121
|
+
return () => {
|
|
122
|
+
window.removeEventListener('resize', measure);
|
|
123
|
+
window.removeEventListener('scroll', measure);
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
// Drag to resize (left edge of sidebar)
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const onMove = (e: MouseEvent) => {
|
|
130
|
+
if (!draggingResize) return;
|
|
131
|
+
const clientX = e.clientX;
|
|
132
|
+
const vw = window.innerWidth;
|
|
133
|
+
const newWidth = Math.min(maxWidth, Math.max(minWidth, vw - clientX));
|
|
134
|
+
setWidth(newWidth);
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
};
|
|
137
|
+
const onUp = () => {
|
|
138
|
+
if (draggingResize) setDraggingResize(false);
|
|
139
|
+
document.body.style.cursor = '';
|
|
140
|
+
document.body.style.userSelect = '';
|
|
141
|
+
};
|
|
142
|
+
if (draggingResize) {
|
|
143
|
+
window.addEventListener('mousemove', onMove);
|
|
144
|
+
window.addEventListener('mouseup', onUp);
|
|
145
|
+
document.body.style.cursor = 'col-resize';
|
|
146
|
+
document.body.style.userSelect = 'none';
|
|
147
|
+
}
|
|
148
|
+
return () => {
|
|
149
|
+
window.removeEventListener('mousemove', onMove);
|
|
150
|
+
window.removeEventListener('mouseup', onUp);
|
|
151
|
+
};
|
|
152
|
+
}, [draggingResize, maxWidth]);
|
|
153
|
+
|
|
154
|
+
// Drag collapsed tab vertically
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const onMove = (e: MouseEvent) => {
|
|
157
|
+
if (!draggingTab) return;
|
|
158
|
+
const delta = e.clientY - dragStartYRef.current;
|
|
159
|
+
if (Math.abs(delta) > 3) tabDraggedRef.current = true;
|
|
160
|
+
const minTop = headerTop + 16;
|
|
161
|
+
const next = Math.max(minTop, Math.min(window.innerHeight - 160 - 16, startTopRef.current + delta));
|
|
162
|
+
setTabTop(next);
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
};
|
|
165
|
+
const onUp = () => setDraggingTab(false);
|
|
166
|
+
if (draggingTab) {
|
|
167
|
+
window.addEventListener('mousemove', onMove);
|
|
168
|
+
window.addEventListener('mouseup', onUp);
|
|
169
|
+
document.body.style.userSelect = 'none';
|
|
170
|
+
}
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener('mousemove', onMove);
|
|
173
|
+
window.removeEventListener('mouseup', onUp);
|
|
174
|
+
if (!draggingTab) document.body.style.userSelect = '';
|
|
175
|
+
};
|
|
176
|
+
}, [draggingTab]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className={'fixed z-50'} aria-live={'polite'}>
|
|
180
|
+
{/* Collapsed vertical tab (draggable) */}
|
|
181
|
+
{!open && (
|
|
182
|
+
<div style={{ position: 'fixed', right: 0, top: Math.max(headerTop + 16, tabTop), height: 160, width: 44 }}>
|
|
183
|
+
<button
|
|
184
|
+
aria-label={'Open chat sidebar'}
|
|
185
|
+
aria-expanded={open}
|
|
186
|
+
onMouseDown={(e) => { setDraggingTab(true); dragStartYRef.current = e.clientY; startTopRef.current = tabTop; tabDraggedRef.current = false; }}
|
|
187
|
+
onMouseUp={(e) => { e.preventDefault(); const dragged = tabDraggedRef.current; tabDraggedRef.current = false; setDraggingTab(false); if (!dragged) setOpen(true); }}
|
|
188
|
+
className={'h-full w-full shadow-lg border rounded-l-lg flex flex-col items-center justify-between transition hover:opacity-90'}
|
|
189
|
+
style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', cursor: draggingTab ? 'grabbing' : 'grab' }}
|
|
190
|
+
>
|
|
191
|
+
<FiMessageSquare size={18} style={{ color: 'var(--text-main)', marginTop: 8 }} />
|
|
192
|
+
<span style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'var(--text-secondary)', fontSize: 12, letterSpacing: 1 }}>Chat</span>
|
|
193
|
+
<FiChevronRight size={16} style={{ color: 'var(--text-secondary)', marginBottom: 8 }} />
|
|
194
|
+
</button>
|
|
195
|
+
{showHint && (
|
|
196
|
+
<button onClick={() => { setOpen(true); onDismissHint(); }} className={'absolute -left-2 -top-8 group max-w-xs text-left'}>
|
|
197
|
+
<div className={'px-2 py-1 rounded shadow border'}
|
|
198
|
+
style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
|
|
199
|
+
<span className={'text-[10px]'} style={{ color: 'var(--text-main)' }}>{hintText}</span>
|
|
200
|
+
</div>
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Expanded right sidebar */}
|
|
207
|
+
{open && (
|
|
208
|
+
<aside
|
|
209
|
+
role={'complementary'}
|
|
210
|
+
aria-label={'Chat sidebar'}
|
|
211
|
+
style={{ position: 'fixed', top: headerTop, right: 0, bottom: 0, width, maxWidth: '92vw' }}
|
|
212
|
+
className={'shadow-xl border flex flex-col overflow-hidden'}
|
|
213
|
+
>
|
|
214
|
+
{/* Left-edge resizer */}
|
|
215
|
+
<div
|
|
216
|
+
onMouseDown={(e) => { setDraggingResize(true); dragStartXRef.current = e.clientX; startWidthRef.current = width; }}
|
|
217
|
+
aria-hidden={'true'}
|
|
218
|
+
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, cursor: 'col-resize', zIndex: 1 }}
|
|
219
|
+
/>
|
|
220
|
+
{/* Panel chrome */}
|
|
221
|
+
<div style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', height: '100%', display: 'flex', flexDirection: 'column' }} className={'bg-[var(--content-card-background)] border-l'}>
|
|
222
|
+
{header}
|
|
223
|
+
<div className={'flex-1 flex flex-col'} style={{ minHeight: 0 }}>
|
|
224
|
+
<div className={'flex-1'} style={{ minHeight: 0 }}>
|
|
225
|
+
<MainContainer style={{ height: '100%', position: 'relative', background: 'var(--content-card-background)', border: 'none'}}>
|
|
226
|
+
<ChatContainer style={{ height: '100%' }}>
|
|
227
|
+
<MessageList typingIndicator={undefined} autoScrollToBottom>
|
|
228
|
+
{messages.length === 0 && (
|
|
229
|
+
<Message model={{ message: 'Start a conversation. I can answer questions about this developer’s report.', sender: 'KYD', direction: 'incoming', position: 'single' }} />
|
|
230
|
+
)}
|
|
231
|
+
{messages
|
|
232
|
+
.filter(m => !(m.role === 'assistant' && (m.content || '').trim().length === 0))
|
|
233
|
+
.map(m => (
|
|
234
|
+
<Message key={m.id} model={{
|
|
235
|
+
message: m.content,
|
|
236
|
+
sender: m.role === 'user' ? 'You' : 'KYD',
|
|
237
|
+
direction: m.role === 'user' ? 'outgoing' : 'incoming',
|
|
238
|
+
position: 'single'
|
|
239
|
+
}} />
|
|
240
|
+
))}
|
|
241
|
+
{(() => {
|
|
242
|
+
const last = messages[messages.length - 1];
|
|
243
|
+
const isAssistantPlaceholder = sending && last && last.role === 'assistant' && (last.content || '').trim().length === 0;
|
|
244
|
+
if (!isAssistantPlaceholder) return null;
|
|
245
|
+
return (
|
|
246
|
+
<div className={'mx-3 my-1'}>
|
|
247
|
+
<div style={{
|
|
248
|
+
background: 'var(--bubble-background)',
|
|
249
|
+
color: 'var(--bubble-foreground)',
|
|
250
|
+
border: '1px solid var(--icon-button-secondary)',
|
|
251
|
+
borderRadius: 12,
|
|
252
|
+
padding: '8px 12px',
|
|
253
|
+
display: 'inline-block'
|
|
254
|
+
}}>
|
|
255
|
+
<span className={'kyd-typing-dots'}>
|
|
256
|
+
<span />
|
|
257
|
+
<span />
|
|
258
|
+
<span />
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
})()}
|
|
264
|
+
{(() => {
|
|
265
|
+
const last = [...messages].reverse().find(mm => mm.role === 'assistant');
|
|
266
|
+
const evidence = last ? tryParseEvidence(last.content) : null;
|
|
267
|
+
return evidence ? (
|
|
268
|
+
<div className={'mx-2'}>
|
|
269
|
+
<EvidenceBlock data={evidence} />
|
|
270
|
+
</div>
|
|
271
|
+
) : null;
|
|
272
|
+
})()}
|
|
273
|
+
</MessageList>
|
|
274
|
+
</ChatContainer>
|
|
275
|
+
</MainContainer>
|
|
276
|
+
</div>
|
|
277
|
+
<form onSubmit={(e) => { e.preventDefault(); if (!sending && input.trim()) sendMessage(); }}>
|
|
278
|
+
<div className={'flex items-end gap-2 p-2 border-t'} style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)' }}>
|
|
279
|
+
<input
|
|
280
|
+
aria-label={'Type your message'}
|
|
281
|
+
className={'flex-1 px-3 py-2 rounded kyd-chat-input'}
|
|
282
|
+
placeholder={'Ask anything about the developer…'}
|
|
283
|
+
value={input}
|
|
284
|
+
onChange={e => setInput(e.target.value)}
|
|
285
|
+
/>
|
|
286
|
+
{sending ? (
|
|
287
|
+
<button
|
|
288
|
+
type={'button'}
|
|
289
|
+
onClick={() => cancel()}
|
|
290
|
+
aria-label={'Stop generating'}
|
|
291
|
+
className={'rounded-full flex items-center justify-center transition-opacity'}
|
|
292
|
+
style={{ width: 40, height: 40, background: 'var(--text-main)' }}
|
|
293
|
+
>
|
|
294
|
+
<FiSquare size={18} style={{ color: 'var(--content-card-background)' }} />
|
|
295
|
+
</button>
|
|
296
|
+
) : (
|
|
297
|
+
<button
|
|
298
|
+
type={'submit'}
|
|
299
|
+
aria-label={'Send message'}
|
|
300
|
+
disabled={!input.trim()}
|
|
301
|
+
className={'rounded-full flex items-center justify-center transition-opacity disabled:opacity-50'}
|
|
302
|
+
style={{ width: 40, height: 40, background: input.trim() ? 'var(--text-main)' : 'var(--icon-button-secondary)' }}
|
|
303
|
+
>
|
|
304
|
+
<FiSend size={18} style={{ color: input.trim() ? 'var(--content-card-background)' : 'var(--text-secondary)' }} />
|
|
305
|
+
</button>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</form>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</aside>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import './chat-overrides.css';
|
|
5
|
+
import { FiSend } from 'react-icons/fi';
|
|
6
|
+
import { useChatStreaming } from './useChatStreaming';
|
|
7
|
+
|
|
8
|
+
export default function ChatWindowStreaming({ api = '/api/chat', badgeId }: { api?: string, badgeId: string }) {
|
|
9
|
+
const { messages, input, setInput, sending, sendMessage } = useChatStreaming({ api, badgeId });
|
|
10
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
14
|
+
}, [messages]);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col border rounded-lg overflow-hidden" style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}>
|
|
18
|
+
<div ref={listRef} className="p-4 space-y-3 h-72 overflow-auto">
|
|
19
|
+
{messages.map(m => (
|
|
20
|
+
<div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
|
|
21
|
+
<div
|
|
22
|
+
className={`inline-block px-3 py-2 rounded border`}
|
|
23
|
+
style={
|
|
24
|
+
m.role === 'user'
|
|
25
|
+
? { background: 'linear-gradient(135deg, var(--gradient-start), var(--gradient-end))', color: '#fff', borderColor: 'transparent' }
|
|
26
|
+
: { background: 'var(--bubble-background)', color: 'var(--bubble-foreground)', borderColor: 'var(--icon-button-secondary)' }
|
|
27
|
+
}
|
|
28
|
+
>
|
|
29
|
+
{m.content}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
<div className="p-3 border-t flex gap-2" style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)'}}>
|
|
35
|
+
<input
|
|
36
|
+
value={input}
|
|
37
|
+
onChange={e=>setInput(e.target.value)}
|
|
38
|
+
onKeyDown={e=>{ if (e.key==='Enter') sendMessage(); }}
|
|
39
|
+
className="flex-1 px-3 py-2 rounded border kyd-chat-input"
|
|
40
|
+
style={{ background: 'var(--input-background)', color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)' }}
|
|
41
|
+
placeholder="Ask KYD…"
|
|
42
|
+
/>
|
|
43
|
+
<button
|
|
44
|
+
onClick={()=>sendMessage()}
|
|
45
|
+
disabled={sending}
|
|
46
|
+
className="px-3 py-2 rounded disabled:opacity-60 kyd-chat-button"
|
|
47
|
+
style={{ background: 'var(--gradient-start)', color: '#fff' }}
|
|
48
|
+
>
|
|
49
|
+
{sending ? 'Sending…' : <FiSend size={18} />}
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { EvidencePayload } from './types';
|
|
4
|
+
import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaPython } from 'react-icons/fa';
|
|
5
|
+
|
|
6
|
+
function ProviderIcon({ provider }: { provider: string }) {
|
|
7
|
+
const n = (provider || '').toLowerCase();
|
|
8
|
+
if (n.includes('github')) return <FaGithub />;
|
|
9
|
+
if (n.includes('gitlab')) return <FaGitlab />;
|
|
10
|
+
if (n.includes('stack')) return <FaStackOverflow />;
|
|
11
|
+
if (n.includes('google')) return <FaGoogle />;
|
|
12
|
+
if (n.includes('linkedin')) return <FaLinkedin />;
|
|
13
|
+
if (n.includes('python')) return <FaPython />;
|
|
14
|
+
return <span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--icon-button-secondary)' }} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function EvidenceBlock({ data }: { data: EvidencePayload }) {
|
|
18
|
+
if (!data?.claims || data.claims.length === 0) return null;
|
|
19
|
+
return (
|
|
20
|
+
<div className={'mt-2 p-2 rounded border text-sm'} style={{ borderColor: 'var(--icon-button-secondary)', color: 'var(--text-main)' }}>
|
|
21
|
+
{data.claims.map((c, idx) => (
|
|
22
|
+
<div key={idx} className={'mb-3 last:mb-0'}>
|
|
23
|
+
<div className={'font-semibold mb-1'}>{c.claim}</div>
|
|
24
|
+
<div className={'space-y-1'}>
|
|
25
|
+
{c.items.map((it, i) => (
|
|
26
|
+
<div key={i} className={'flex items-center gap-2'}>
|
|
27
|
+
<span className={'text-base'} style={{ color: 'var(--text-secondary)' }}><ProviderIcon provider={it.provider} /></span>
|
|
28
|
+
<a href={it.url} target={'_blank'} rel={'noopener noreferrer'} className={'underline'} style={{ color: 'var(--icon-accent)' }}>{it.title}</a>
|
|
29
|
+
{it.date ? <span className={'text-xs'} style={{ color: 'var(--text-secondary)' }}>{new Date(it.date).toLocaleDateString()}</span> : null}
|
|
30
|
+
</div>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* Chat UI Kit theme overrides to align with globals.css variables */
|
|
2
|
+
|
|
3
|
+
/* Container backgrounds */
|
|
4
|
+
.cs-main-container, .cs-chat-container, .cs-message-list, .cs-typing-indicator {
|
|
5
|
+
background: var(--content-card-background) !important;
|
|
6
|
+
color: var(--text-main) !important;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Header area */
|
|
10
|
+
.cs-chat-header, .cs-message-list__typing-indicator-container {
|
|
11
|
+
background: var(--content-card-background) !important;
|
|
12
|
+
border-color: var(--icon-button-secondary) !important;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Message bubbles */
|
|
16
|
+
.cs-message__content {
|
|
17
|
+
background: var(--bubble-background) !important;
|
|
18
|
+
color: var(--bubble-foreground) !important;
|
|
19
|
+
border: 1px solid var(--icon-button-secondary) !important;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Incoming/outgoing differentiation */
|
|
23
|
+
.cs-message--incoming .cs-message__content {
|
|
24
|
+
background: var(--bubble-background) !important;
|
|
25
|
+
}
|
|
26
|
+
.cs-message--outgoing .cs-message__content {
|
|
27
|
+
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)) !important;
|
|
28
|
+
color: white !important;
|
|
29
|
+
border-color: transparent !important;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Input area */
|
|
33
|
+
.cs-message-input__content-editor, .cs-message-input__content-editor-wrapper {
|
|
34
|
+
background: var(--input-background) !important;
|
|
35
|
+
color: var(--text-main) !important;
|
|
36
|
+
}
|
|
37
|
+
.cs-message-input, .cs-message-input__tools, .cs-message-input__content-editor-wrapper {
|
|
38
|
+
border-color: var(--icon-button-secondary) !important;
|
|
39
|
+
}
|
|
40
|
+
.cs-message-input {
|
|
41
|
+
background: var(--content-card-background) !important;
|
|
42
|
+
}
|
|
43
|
+
.cs-message-input__send-button {
|
|
44
|
+
background: var(--gradient-start) !important;
|
|
45
|
+
color: #fff !important;
|
|
46
|
+
}
|
|
47
|
+
.cs-message-input__send-button:hover {
|
|
48
|
+
background: var(--icon-accent-hover) !important;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Placeholder color for chatscope contenteditable */
|
|
52
|
+
.cs-message-input__content-editor[data-placeholder]:empty::before {
|
|
53
|
+
color: var(--text-secondary) !important;
|
|
54
|
+
opacity: 1 !important;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Focus outlines/rings - remove white glow and use subtle border */
|
|
58
|
+
.cs-message-input__content-editor:focus, .cs-message-input__content-editor-wrapper:focus-within, .cs-message-input:focus-within {
|
|
59
|
+
outline: none !important;
|
|
60
|
+
box-shadow: none !important;
|
|
61
|
+
border-color: var(--icon-button-secondary) !important;
|
|
62
|
+
}
|
|
63
|
+
.cs-message-input__send-button:focus {
|
|
64
|
+
outline: none !important;
|
|
65
|
+
box-shadow: none !important;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Custom input in ChatWindowStreaming */
|
|
69
|
+
.kyd-chat-input {
|
|
70
|
+
background: var(--input-background);
|
|
71
|
+
color: var(--text-main);
|
|
72
|
+
border-color: var(--icon-button-secondary);
|
|
73
|
+
}
|
|
74
|
+
.kyd-chat-input::placeholder {
|
|
75
|
+
color: var(--text-secondary);
|
|
76
|
+
}
|
|
77
|
+
.kyd-chat-input:focus {
|
|
78
|
+
outline: none;
|
|
79
|
+
box-shadow: none;
|
|
80
|
+
border-color: var(--icon-button-secondary);
|
|
81
|
+
}
|
|
82
|
+
.kyd-chat-button {
|
|
83
|
+
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
|
84
|
+
color: #fff;
|
|
85
|
+
}
|
|
86
|
+
.kyd-chat-button:hover {
|
|
87
|
+
background: var(--icon-accent-hover);
|
|
88
|
+
}
|
|
89
|
+
.kyd-chat-button:focus {
|
|
90
|
+
outline: none;
|
|
91
|
+
box-shadow: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Scrollbar subtle styling */
|
|
95
|
+
.cs-message-list::-webkit-scrollbar {
|
|
96
|
+
width: 8px;
|
|
97
|
+
}
|
|
98
|
+
.cs-message-list::-webkit-scrollbar-thumb {
|
|
99
|
+
background: var(--icon-button-secondary);
|
|
100
|
+
border-radius: 8px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Typing indicator */
|
|
104
|
+
.cs-typing-indicator__text {
|
|
105
|
+
color: var(--text-secondary) !important;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Pulsing three dots for assistant placeholder */
|
|
109
|
+
.kyd-typing-dots {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 4px;
|
|
113
|
+
}
|
|
114
|
+
.kyd-typing-dots span {
|
|
115
|
+
width: 6px;
|
|
116
|
+
height: 6px;
|
|
117
|
+
border-radius: 9999px;
|
|
118
|
+
background: var(--text-secondary);
|
|
119
|
+
display: inline-block;
|
|
120
|
+
animation: kyd-bounce 1.2s infinite ease-in-out;
|
|
121
|
+
}
|
|
122
|
+
.kyd-typing-dots span:nth-child(2) { animation-delay: 0.15s; }
|
|
123
|
+
.kyd-typing-dots span:nth-child(3) { animation-delay: 0.3s; }
|
|
124
|
+
@keyframes kyd-bounce {
|
|
125
|
+
0%, 80%, 100% { transform: translateY(0); opacity: 0.6; }
|
|
126
|
+
40% { transform: translateY(-3px); opacity: 1; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Prevent scroll chaining to the page so background doesn't bounce */
|
|
130
|
+
.cs-main-container,
|
|
131
|
+
.cs-chat-container,
|
|
132
|
+
.cs-message-list {
|
|
133
|
+
overscroll-behavior: contain !important;
|
|
134
|
+
overscroll-behavior-y: contain !important;
|
|
135
|
+
-webkit-overflow-scrolling: touch;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EvidencePayload } from './types';
|
|
2
|
+
|
|
3
|
+
// Very forgiving parser: expects the assistant to include a JSON block delimited by ```json ... ```
|
|
4
|
+
// If no fenced block found, tries to JSON.parse the entire string.
|
|
5
|
+
export function tryParseEvidence(text: string): EvidencePayload | null {
|
|
6
|
+
try {
|
|
7
|
+
const fenceStart = text.indexOf('```json');
|
|
8
|
+
const fenceEnd = text.indexOf('```', fenceStart + 7);
|
|
9
|
+
const raw = fenceStart >= 0 && fenceEnd > fenceStart ? text.slice(fenceStart + 7, fenceEnd).trim() : text.trim();
|
|
10
|
+
const obj = JSON.parse(raw);
|
|
11
|
+
if (obj && obj.type === 'evidence' && Array.isArray(obj.claims)) {
|
|
12
|
+
return obj as EvidencePayload;
|
|
13
|
+
}
|
|
14
|
+
} catch {}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type EvidenceItem = {
|
|
2
|
+
provider: string; // e.g., 'github'
|
|
3
|
+
title: string; // e.g., repo name
|
|
4
|
+
url: string; // link to evidence
|
|
5
|
+
date?: string; // ISO date
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type EvidenceClaim = {
|
|
9
|
+
claim: string; // e.g., 'This user has lots of python experience'
|
|
10
|
+
items: EvidenceItem[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type EvidencePayload = {
|
|
14
|
+
type: 'evidence';
|
|
15
|
+
claims: EvidenceClaim[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
|